
简介这是一套面向自动化测试初学者与仪器控制开发者的PythonPyQt5实战项目源码围绕泰克MDO3014示波器展开解决波形实时显示、图像保存与日志同步读取等常见需求。资源包共16个文件约139KB包含2个py主程序与1个ui界面文件、5个xml配置、3张png界面截图、2个txt日志示例及pyc缓存等覆盖界面逻辑、控件调用与运行记录结构紧凑便于直接运行与二次修改。项目支持示波器波形在界面实时刷新并保存图片log信息实时读取显示且可导出窗口内容同时提供单次循环执行测试用例的框架用例需使用者自行编写代码中还详细演示了常用Qt控件在Python中的调用方式适合作为仪器通信与GUI开发的入门参考。目前已有3659人学习下载可帮助读者快速理解PyQt5与示波器联调的完整流程掌握实时刷新、日志管理与测试循环的实现思路。1. 用 Python 把泰克示波器变成产线自动测试机这套 PyQt5 上位机到底能干什么产线上做电源纹波、时序、眼图这类测试最耗人的不是测而是人盯着示波器、手抄数据、再敲进 Excel。一台泰克 3000 系列挂在工位上工程师一天要按几百次 Single、等触发、截图、记峰峰值稍微走神就漏采一次。这套基于 Python PyQt5 泰克示波器的自动化测试上位机解决的就是这件事把示波器当受控仪器用代码触发采集、解析波形、实时刷新图片和打印信息最后自动落盘成报告。它适合两类人——一类是手上已经有泰克 3000/DPO3000/MDO3000 系列、想快速搭一套自动测试流程的测试工程师另一类是想学 PyQt5 多线程 仪器通信VISA怎么配合的 Python 开发者。整套东西不依赖厂商付费软件核心就是 pyvisa 通信 PyQt5 界面 定时刷新跑起来就能用。2. 通信底座pyvisa 怎么把泰克 3000 系列接进来在写界面之前得先把电脑怎么跟示波器说话这件事搞定。泰克 3000 系列DPO3000、MSO3000、MDO3000 这一代背后是标准 SCPI 指令集物理层走 USB-TMC 或 LANPython 侧统一用 pyvisa 封装。这一步没打通后面 PyQt5 界面写得再漂亮也是空壳。2.1 选型为什么是 pyvisa 而不是厂商 SDK泰克官方有 TekVISA 和对应的 .NET/Python 库但实际项目里我更倾向 pyvisa原因有三点。第一pyvisa 是跨厂商的今天接泰克、明天换是德代码结构不用推倒重来只换资源地址和指令前缀。第二pyvisa 的 backend 可以挂 pyvisa-py纯 Python或 NI-VISA / TekVISA前者免装驱动适合快速验证后者稳定适合产线。第三社区资料多遇到超时、编码、终止符这类玄学问题搜一下基本都有答案。泰克 3000 系列的 SCPI 指令有几个固定套路记住这几条就能覆盖八成场景功能指令说明查询身份*IDN?返回厂商、型号、序列号、固件版本复位*RST恢复出厂设置测试前建议执行单次采集ACQuire:STATE ONACQuire:STOPAfter SEQuence触发一次后停读波形CURVe?返回波形数据点读刻度WFMPRe:...?取 X/Y 增量和偏移用于还原真实值截图SAVe:IMAGe C:/temp.png存到示波器本地或 U 盘2.2 建立连接资源地址怎么找、超时怎么设第一步是让 pyvisa 列出当前能看到的仪器。USB 接上示波器、装好驱动后跑下面这段import pyvisa # 列出所有可见的 VISA 资源 rm pyvisa.ResourceManager() resources rm.list_resources() for r in resources: print(r) # 典型输出 # (USB0::0x0699::0x0401::C012345::INSTR,) 泰克 USB # (TCPIP0::192.168.1.100::inst0::INSTR,) LAN拿到地址后建立连接。这里有两个参数必须显式设否则大概率翻车import pyvisa rm pyvisa.ResourceManager() # 用 USB 地址连接超时设 5000ms终止符按泰克默认 scope rm.open_resource(USB0::0x0699::0x0401::C012345::INSTR) scope.timeout 5000 # 单位毫秒读波形时建议放大到 10000 scope.read_termination \n # 泰克默认换行 scope.write_termination \n # 验证连接 print(scope.query(*IDN?)) # 输出类似TEKTRONIX,DPO3054,C012345,CF:91.1CT FV:2.34逻辑说明open_resource只是建立通道真正的坑在timeout和终止符。泰克默认终止符是\n但有些固件版本对query的响应会带\r\n如果读出来字符串末尾有乱码就把read_termination改成\r\n试试。timeout默认 2000ms读长波形比如 10k 点时经常超时我一般直接设 10000。参数说明timeout是单次读写的最长等待不是总时长read_termination决定 pyvisa 什么时候认为一条消息读完了设错会一直阻塞到超时。LAN 连接时地址里的inst0是 VISA 的接口实例名别漏。2.3 采集一次完整波形从触发到还原真实电压连上之后一次标准采集分四步配置、触发、读数据、还原。下面这段是可直接抄的骨架import pyvisa import numpy as np rm pyvisa.ResourceManager() scope rm.open_resource(USB0::0x0699::0x0401::C012345::INSTR) scope.timeout 10000 scope.read_termination \n scope.write_termination \n # 1. 复位并配置通道 scope.write(*RST) scope.write(DATa:SOUrce CH1) # 数据源选 CH1 scope.write(DATa:ENCdg RIBINARY) # 二进制传输比 ASCII 快很多 scope.write(DATa:WIDth 2) # 2 字节/点 scope.write(DATa:STARt 1) scope.write(DATa:STOP 10000) # 取 10000 个点 # 2. 配置触发上升沿、CH1、电平 1.5V scope.write(TRIGger:A:MODe AUTO) scope.write(TRIGger:A:EDGE:SOUrce CH1) scope.write(TRIGger:A:EDGE:SLOPe RISe) scope.write(TRIGger:A:LEVel 1.5) # 3. 单次采集并等待完成 scope.write(ACQuire:STOPAfter SEQuence) scope.write(ACQuire:STATE ON) scope.query(*OPC?) # 阻塞直到采集完成 # 4. 读波形 读刻度参数 raw scope.query_binary_values(CURVe?, datatypeh, is_big_endianTrue) ymult float(scope.query(WFMPRe:YMULT?)) yoff float(scope.query(WFMPRe:YOFF?)) yzero float(scope.query(WFMPRe:YZERO?)) xincr float(scope.query(WFMPRe:XINCR?)) # 还原真实电压 volts (np.array(raw) - yoff) * ymult yzero time_axis np.arange(len(volts)) * xincr print(f峰峰值 {volts.max() - volts.min():.4f} V)逻辑说明RIBINARY是二进制小端/大端传输比 ASCII 快一个数量级10000 点用 ASCII 要好几秒二进制几十毫秒。*OPC?是同步点它返回 1 才代表采集真正结束少了这句会读到上一帧的旧数据——这是新手最常踩的坑。还原公式(raw - yoff) * ymult yzero是泰克固定的ymult是每格电压yoff是垂直偏移yzero是零点三个缺一不可。参数说明DATa:WIDth 2对应 2 字节整数datatypeh是 signed short如果示波器设成 1 字节就要改成b。is_big_endianTrue是因为泰克默认大端设错波形会变成锯齿状乱码。DATa:STOP不要超过示波器记录长度超了会报错或截断。3. PyQt5 界面实时刷新图片和打印信息的多线程写法通信打通后界面才是真正考验人的地方。核心矛盾是示波器采集是阻塞的一次几百毫秒到几秒而 PyQt5 的主线程要负责界面响应如果在主线程里直接query界面必卡死。所以这套上位机的骨架一定是主线程管界面 工作线程管采集 信号槽传数据。3.1 线程模型QThread 信号槽别在主线程碰仪器我见过太多人图省事直接在按钮的槽函数里写scope.query结果点一下按钮界面白屏三秒。正确做法是把采集逻辑塞进QThread子类通过pyqtSignal把波形和日志发回主线程。from PyQt5.QtCore import QThread, pyqtSignal import pyvisa import numpy as np class ScopeWorker(QThread): # 定义信号波形数据、日志文本、错误信息 wave_ready pyqtSignal(object, object) # (time_axis, volts) log_ready pyqtSignal(str) error_ready pyqtSignal(str) def __init__(self, resource_addr): super().__init__() self.addr resource_addr self._running True def run(self): try: rm pyvisa.ResourceManager() scope rm.open_resource(self.addr) scope.timeout 10000 scope.read_termination \n scope.write_termination \n self.log_ready.emit(f已连接: {scope.query(*IDN?).strip()}) while self._running: scope.write(ACQuire:STATE ON) scope.query(*OPC?) raw scope.query_binary_values(CURVe?, datatypeh, is_big_endianTrue) ymult float(scope.query(WFMPRe:YMULT?)) yoff float(scope.query(WFMPRe:YOFF?)) yzero float(scope.query(WFMPRe:YZERO?)) xincr float(scope.query(WFMPRe:XINCR?)) volts (np.array(raw) - yoff) * ymult yzero t np.arange(len(volts)) * xincr self.wave_ready.emit(t, volts) self.log_ready.emit( f采集完成 点数{len(volts)} 峰峰值{volts.max()-volts.min():.4f}V) except Exception as e: self.error_ready.emit(str(e)) def stop(self): self._running False self.wait()逻辑说明run()里是独立线程的执行体所有阻塞操作都在这里主线程完全不受影响。wave_ready.emit把 numpy 数组打包成object发出去Qt 的信号槽会自动做线程间排队不会出现跨线程访问控件的问题。_running标志位配合stop()实现优雅退出直接terminate()会留下未关闭的 VISA 会话下次连接可能报资源被占用。参数说明pyqtSignal(object, object)里用object是为了传 numpy 数组Qt 原生信号类型没有 ndarray。self.wait()会阻塞到线程真正结束别在stop()里省这句。采集循环里没有sleep因为*OPC?本身就是阻塞等待加 sleep 反而拖慢节奏。3.2 实时刷新图片把波形画到 QLabel 上波形拿到后要显示。有人用 pyqtgraph有人用 matplotlib 嵌入我一般用 matplotlib 的FigureCanvasQTAgg因为它跟 PyQt5 集成最顺画出来的图也够产线看。from PyQt5.QtWidgets import QWidget, QVBoxLayout, QLabel from matplotlib.backends.backend_qt5agg import FigureCanvasQTAgg from matplotlib.figure import Figure class WavePanel(QWidget): def __init__(self): super().__init__() self.figure Figure(figsize(6, 3)) self.canvas FigureCanvasQTAgg(self.figure) self.ax self.figure.add_subplot(111) self.ax.set_xlabel(Time (s)) self.ax.set_ylabel(Voltage (V)) self.ax.grid(True) layout QVBoxLayout(self) layout.addWidget(self.canvas) def update_wave(self, t, v): self.ax.clear() self.ax.plot(t, v, color#1f77b4, linewidth0.8) self.ax.set_xlabel(Time (s)) self.ax.set_ylabel(Voltage (V)) self.ax.grid(True) self.canvas.draw_idle() # 比 draw() 更省资源逻辑说明update_wave每次先clear再重画这是最简单的实时刷新方式。draw_idle()会把重绘请求合并到 Qt 事件循环的空闲时机比draw()直接同步重绘流畅得多高频刷新时差别很明显。如果刷新率要求更高比如 10Hz 以上就别每次 clear改成更新line.set_data()再draw_idle()。参数说明figsize决定画布物理尺寸产线屏幕一般 6x3 够用。linewidth0.8是经验值10000 点画粗线会糊成一团。颜色用十六进制是为了跟报告模板统一不是必须。3.3 实时打印信息QTextEdit 做日志窗口日志窗口看着简单但高频写入时容易卡。要点是用append而不是setText并且限制最大行数否则跑一晚上内存就爆了。from PyQt5.QtWidgets import QTextEdit from PyQt5.QtGui import QTextCursor class LogPanel(QTextEdit): MAX_LINES 500 def __init__(self): super().__init__() self.setReadOnly(True) self.setStyleSheet(font-family: Consolas; font-size: 12px;) def append_log(self, text): self.append(text) # 超过上限就删掉最旧的行 doc self.document() if doc.blockCount() self.MAX_LINES: cursor QTextCursor(doc) cursor.movePosition(QTextCursor.Start) cursor.select(QTextCursor.BlockUnderCursor) cursor.removeSelectedText() cursor.deleteChar() # 自动滚到底部 self.moveCursor(QTextCursor.End)逻辑说明append会自动换行并滚动但不会自动清理。blockCount是当前段落数超过 500 就删掉第一段保证内存稳定。moveCursor(End)让日志始终显示最新一条产线上工程师扫一眼就知道当前状态。参数说明MAX_LINES按需调调试期可以设 2000产线跑长测设 500 足够。字体用 Consolas 是为了对齐时间戳不是硬性要求。3.4 主窗口组装把线程、波形、日志串起来最后把三块拼起来主窗口负责创建 worker、连接信号、启动线程from PyQt5.QtWidgets import (QMainWindow, QWidget, QVBoxLayout, QPushButton, QHBoxLayout) from PyQt5.QtCore import Qt class MainWindow(QMainWindow): def __init__(self, addr): super().__init__() self.setWindowTitle(泰克示波器自动测试上位机) self.resize(1000, 700) self.wave_panel WavePanel() self.log_panel LogPanel() self.btn_start QPushButton(开始采集) self.btn_stop QPushButton(停止) self.btn_stop.setEnabled(False) top QHBoxLayout() top.addWidget(self.btn_start) top.addWidget(self.btn_stop) top.addStretch() layout QVBoxLayout() layout.addLayout(top) layout.addWidget(self.wave_panel, stretch3) layout.addWidget(self.log_panel, stretch1) central QWidget() central.setLayout(layout) self.setCentralWidget(central) self.worker ScopeWorker(addr) self.worker.wave_ready.connect(self.wave_panel.update_wave) self.worker.log_ready.connect(self.log_panel.append_log) self.worker.error_ready.connect( lambda e: self.log_panel.append_log(f[错误] {e})) self.btn_start.clicked.connect(self.start_capture) self.btn_stop.clicked.connect(self.stop_capture) def start_capture(self): if not self.worker.isRunning(): self.worker.start() self.btn_start.setEnabled(False) self.btn_stop.setEnabled(True) self.log_panel.append_log(采集线程已启动) def stop_capture(self): self.worker.stop() self.btn_start.setEnabled(True) self.btn_stop.setEnabled(False) self.log_panel.append_log(采集线程已停止)逻辑说明信号连接在__init__里一次性完成start_capture里用isRunning()防止重复启动。按钮状态互斥是基本体验避免用户连点。stretch参数让波形区占大头、日志区占小头符合产线看板习惯。参数说明resize(1000, 700)是 1080p 屏幕下的舒适尺寸。stretch3和stretch1是比例不是像素。worker 的地址从外部传入方便做成配置项。4. 避坑与排查这套组合最容易翻车的五个地方这套东西跑通不难难的是稳定跑一整天。下面五条是我和同行踩出来的血泪经验每条按现象 → 原因 → 解决写。4.1 现象界面点开始后直接卡死任务管理器显示无响应原因采集逻辑写在了主线程的槽函数里query阻塞导致 Qt 事件循环停摆。这是最经典的翻车。解决所有仪器 IO 必须放进QThread子类的run()主线程只通过信号槽收数据。检查方法很简单——在槽函数里加一句print(QThread.currentThread())如果打印的是主线程 ID就说明写错地方了。4.2 现象波形读出来是锯齿状乱码或者数值大得离谱原因字节序或数据类型设错。泰克默认大端query_binary_values的is_big_endian默认是False不显式设True就会把高低字节读反。另外DATa:WIDth设成 2 字节datatype却用b也会错位。解决固定用datatypeh, is_big_endianTrue配DATa:WIDth 2。如果换型号先用*IDN?确认固件版本再查对应手册的字节序说明。还原公式三个参数YMULT/YOFF/YZERO一个都不能少少一个数值就整体偏移。4.3 现象跑一段时间后报资源被占用或连接超时原因VISA 会话没关闭。程序异常退出、线程被terminate()、或者stop()里没wait()都会留下僵尸会话。USB-TMC 的会话数有限攒几个就满了。解决worker 的run()用try/finally包住finally里执行scope.close()和rm.close()。停止线程一律走_running Falsewait()绝不用terminate()。如果已经卡死重启示波器或拔插 USB 能清掉残留会话。4.4 现象日志窗口越跑越卡内存占用一路涨原因QTextEdit只 append 不清理文档对象无限增长。跑一晚上几万行重绘一次要几百毫秒。解决按 3.3 节的写法限制MAX_LINES超过就删最旧的行。另外日志内容别塞大段二进制或超长字符串需要记录波形就存文件日志里只写摘要。4.5 现象触发一直不成功*OPC?等到超时原因触发电平设在了信号范围之外或者触发源选错通道。TRIGger:A:LEVel是绝对电压值不是百分比设成 1.5 但信号只有 0.5V 就永远触发不了。解决先用AUTO模式让示波器自己找触发点确认信号正常后再切NORMal。电平值可以先读CH1的峰峰值取中间值。触发源一定要跟DATa:SOUrce一致否则读的是 A 通道、触发的是 B 通道数据对不上。5. 进阶把单次采集做成批量测试与报告落盘单次采集跑通只是起点产线要的是测 N 次、判 PASS/FAIL、出报告。这里分享一个我常用的批量测试骨架核心思路是把采集逻辑抽成函数循环调用每次结果写进 CSV最后用 pandas 汇总。import csv import time import numpy as np def measure_once(scope, points10000): 单次采集返回峰峰值、频率估计、原始波形 scope.write(ACQuire:STATE ON) scope.query(*OPC?) raw scope.query_binary_values(CURVe?, datatypeh, is_big_endianTrue) ymult float(scope.query(WFMPRe:YMULT?)) yoff float(scope.query(WFMPRe:YOFF?)) yzero float(scope.query(WFMPRe:YZERO?)) xincr float(scope.query(WFMPRe:XINCR?)) v (np.array(raw) - yoff) * ymult yzero vpp v.max() - v.min() # 过零计数估频率 crossings np.where(np.diff(np.signbit(v - v.mean())))[0] freq len(crossings) / 2 / (len(v) * xincr) if len(crossings) 1 else 0 return vpp, freq, v def batch_test(scope, n50, vpp_limit(0.9, 1.1)): results [] for i in range(n): vpp, freq, _ measure_once(scope) passed vpp_limit[0] vpp vpp_limit[1] results.append({ index: i 1, vpp: round(vpp, 4), freq: round(freq, 2), result: PASS if passed else FAIL, timestamp: time.strftime(%Y-%m-%d %H:%M:%S) }) time.sleep(0.2) # 给示波器一点喘息避免指令堆积 # 落盘 with open(test_report.csv, w, newline, encodingutf-8) as f: writer csv.DictWriter(f, fieldnamesresults[0].keys()) writer.writeheader() writer.writerows(results) pass_count sum(1 for r in results if r[result] PASS) print(f完成 {n} 次PASS {pass_count}FAIL {n - pass_count}) return results逻辑说明measure_once把采集和还原封装成纯函数方便单测和复用。频率估计用过零检测是最糙但最快的办法对正弦/方波够用复杂波形建议上 FFT。batch_test循环里加time.sleep(0.2)是防止指令堆积——示波器处理指令有队列发太快会丢包或超时。结果写 CSV 用DictWriter字段名从第一条结果取保证列顺序稳定。参数说明vpp_limit是判定阈值按被测信号规格改。n50是批次大小产线一般 20~100。time.sleep(0.2)是经验值USB 连接可以短一点LAN 连接建议 0.3 以上。频率公式里除以 2 是因为一次完整周期有两次过零。如果想在界面上直接触发批量测试把batch_test也放进 worker 线程每完成一次就emit一条日志和一次波形主线程照常刷新。这样界面既能看到实时波形又能看到进度比闷头跑完再出结果体验好得多。从那以后我每次搭这类上位机都强制走一遍先*IDN?验证连接、再*OPC?同步采集、最后try/finally关会话的固定流程这三步能挡掉八成以上的偶发故障。希望帮到你。本文还有配套的精品资源点击获取