简介:这是一套面向自动化测试初学者与仪器控制开发者的Python+PyQt5实战项目源码,围绕泰克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 或 LAN,Python 侧统一用 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 ON+ACQuire: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?', datatype='h', is_big_endian=True) 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 字节整数,datatype='h'是 signed short;如果示波器设成 1 字节,就要改成'b'。is_big_endian=True是因为泰克默认大端,设错波形会变成锯齿状乱码。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?', datatype='h', is_big_endian=True) 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', linewidth=0.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 够用。linewidth=0.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, stretch=3) layout.addWidget(self.log_panel, stretch=1) 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 屏幕下的舒适尺寸。stretch=3和stretch=1是比例,不是像素。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',也会错位。
解决:固定用datatype='h', is_big_endian=True配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 = False+wait(),绝不用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, points=10000): """单次采集,返回峰峰值、频率估计、原始波形""" scope.write('ACQuire:STATE ON') scope.query('*OPC?') raw = scope.query_binary_values('CURVe?', datatype='h', is_big_endian=True) 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, n=50, 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='', encoding='utf-8') as f: writer = csv.DictWriter(f, fieldnames=results[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是判定阈值,按被测信号规格改。n=50是批次大小,产线一般 20~100。time.sleep(0.2)是经验值,USB 连接可以短一点,LAN 连接建议 0.3 以上。频率公式里除以 2 是因为一次完整周期有两次过零。
如果想在界面上直接触发批量测试,把batch_test也放进 worker 线程,每完成一次就emit一条日志和一次波形,主线程照常刷新。这样界面既能看到实时波形,又能看到进度,比闷头跑完再出结果体验好得多。
从那以后我每次搭这类上位机,都强制走一遍"先*IDN?验证连接、再*OPC?同步采集、最后try/finally关会话"的固定流程,这三步能挡掉八成以上的偶发故障。希望帮到你。
本文还有配套的精品资源,点击获取