资讯动态

Python+PyQt5示波器自动化测试上位机:多线程采集与实时刷新

发布时间:2026/10/26 10:52:46 来源:尧图企业网站定制
简介面向需要将泰克MDO3014示波器接入Python桌面应用的开发者与测试人员这套基于PyQt5的自动化测试上位机示例可解决仪器图像实时展示、测试日志同步读取以及简单测试循环执行等问题。资源包共16个文件、约139KB以Python源码和Qt界面设计文件为主辅以XML配置、演示截图、日志文本及编译缓存等体量轻便便于直接阅读和二次改造。已有3661人学习说明其在仪器控制与GUI集成场景中具有一定参考价值。读者可从中了解示波器画面在界面中的实时刷新与保存方法、日志窗口的实时输出与内容留存方式以及单次循环执行测试用例的组织思路测试用例需自行编写。源码还结合示例讲解了常用Qt控件在Python中的调用方式适合作为搭建测试上位机、验证界面交互和梳理仪器通信流程的起步模板。1. 把 Python、PyQt5 和示波器自动化测试放在一起把 Python、PyQt5 和示波器自动化测试放在一起最直接的价值不是炫技而是把“等触发、截图、抄数、贴报告”这条重复链路压成一次点击。这个工程面向 3000 系列示波器的日常验证场景界面里配通道、时基和触发后台走 VISA/SCPI 采集波形前台实时刷新波形图片和打印信息测试结果落盘。适合硬件测试工程师、产线验证开发者和需要二次开发上位机的人。新手能照着示例跑通单次采集熟手更该关注它的线程模型、驱动分层和日志刷新方式因为真正让人翻车的往往不是命令而是界面卡死和数据错位。2. 工程结构拆解从启动入口到仪器驱动层的分层这类工程最容易写成一个大窗口类按钮回调里直接开 VISA、读波形、刷控件。单次采集看着没问题连续跑十分钟就开始卡关窗后进程还挂着。我拆这种项目先不看界面长什么样先看职责边界有没有分开谁管仪器会话谁管测试步骤谁管 UI 刷新。下面这套分层是我看这类 PyQt5 示波器自动化测试工程时会优先找的结构目录名可能不同但职责一般跑不出这几块。2.1 目录分层UI、驱动、采集、测试序列各管一摊scope_auto_test/ ├── main.py # 启动入口只做 QApplication 和主窗口装配 ├── ui/ │ ├── main_window.py # 主界面按钮、波形区、日志区 │ └── main_window.ui # Qt Designer 文件编译成 py 或运行时加载 ├── drivers/ │ └── scope_t3000.py # 仪器驱动连接、SCPI、波形读取 ├── core/ │ ├── acquire_worker.py # QThread 采集线程发信号给 UI │ ├── test_sequence.py # 测试步骤编排与判定 │ └── models.py # 数据类Waveform、TestResult ├── utils/ │ ├── logger.py # 日志队列、文件落盘 │ └── visa_helper.py # 资源发现、超时重试 ├── config/ │ └── instruments.yaml # 资源地址、通道、触发参数 ├── reports/ # 截图、波形 CSV、测试报告 └── requirements.txt这份目录的逻辑是让每一层只承担一种变化。drivers只和 SCPI 命令、VISA 会话打交道不导入 Qt 控件core只处理采集流程、判定规则和结果模型不直接操作按钮ui只读信号、更新控件不碰仪器命令。参数上config/instruments.yaml放资源地址、通道号、垂直档位、时基和触发电平换一台仪器只改配置不改界面代码。常见误用是把 SCPI 查询写在按钮槽函数里界面一卡用户以为仪器挂了其实主线程被query阻塞了。注意启动阶段只加载配置不建立仪器会话连接动作放到“连接”按钮或采集线程里便于重试和报错。2.2 启动入口与配置加载main.py 只做装配import sys from PyQt5.QtWidgets import QApplication from ui.main_window import MainWindow from utils.logger import init_logging from core.test_sequence import TestSequence def main(): # 文件日志先初始化配置解析失败时还能看到堆栈 init_logging(logs/app.log) app QApplication(sys.argv) # 配置外置资源地址、通道、触发参数都从 yaml 读 seq TestSequence.from_yaml(config/instruments.yaml) win MainWindow(sequenceseq) win.show() sys.exit(app.exec_()) if __name__ __main__: main()这段代码的逻辑是“装配而不执行”main.py只负责初始化日志、创建应用、加载测试序列、打开主窗口。参数上logs/app.log是文件日志路径config/instruments.yaml是仪器与用例配置。TestSequence.from_yaml里只做解析和校验不在这里连仪器否则仪器不在线时程序启动就崩。失败时先看启动日志里有没有 YAML 解析异常或字段缺失再看配置里的资源地址是不是写成了别的网段。很多人把连接代码塞进main现场换一台仪器就要改代码重新打包这种写法后期很被动。2.3 线程边界AcquireWorker 与 UI 只靠信号说话界面卡不卡关键不在 PyQt5 本身而在有没有把阻塞操作放到工作线程。示波器的*OPC?、波形读取、截图查询都可能等几百毫秒到几秒放在 UI 线程里日志区就不会刷新窗口拖动都发涩。我一般让AcquireWorker继承QThread在run()里循环采集通过pyqtSignal把波形对象、日志文本和错误信息发回主线程。from PyQt5.QtCore import QThread, pyqtSignal class AcquireWorker(QThread): wave_ready pyqtSignal(object) # 传 Waveform 数据类不传裸 list方便带单位 log_ready pyqtSignal(str, str) # level, message error pyqtSignal(str) def __init__(self, driver_factory, params): super().__init__() self.driver_factory driver_factory self.params params self._running True def stop(self): self._running False def run(self): driver None try: driver self.driver_factory() # VISA 会话在采集线程里创建 self.log_ready.emit(INFO, 采集线程启动) while self._running: wave driver.single_acquire(self.params) self.wave_ready.emit(wave) self.log_ready.emit(INFO, f采集完成 points{len(wave.y)}) except Exception as exc: self.error.emit(str(exc)) finally: if driver: driver.close()这段代码的逻辑是把 VISA 会话的创建、采集循环和关闭都收在run()里UI 线程只接收信号。参数上driver_factory是一个可调用对象避免在主线程提前建会话params里放通道、时基、触发电平和采集次数_running用布尔标志控制退出不用terminate()硬杀线程。信号跨线程默认走队列连接wave_ready发的是数据类不是控件引用这样 UI 刷新和采集节奏解耦。失败时先看error信号弹出来的文本再看线程有没有卡在query上常见原因是超时太短或触发一直没发生。3. 仪器通信与 SCPI 采集连接、触发、取波形的完整链路实时刷新图片的前提是每次采集都可重复。如果触发没配好界面刷出来的波形一会儿有、一会儿空白日志里全是“采集完成”这种数据拿去做自动化判定没有意义。这个项目的通信层一般绕不开 VISA 资源字符串、SCPI 命令和波形前导参数换算。下面按连接、触发、取波形、编排四步拆命令缩写在不同固件上可能有差异先按仪器手册核对再抄。3.1 VISA 资源发现与连接参数别把地址写死import pyvisa rm pyvisa.ResourceManager() resources rm.list_resources() print(resources) # 示例地址按实际资源字符串替换 scope rm.open_resource(TCPIP0::192.168.1.50::INSTR) scope.timeout 5000 # 毫秒单次采集别设太短 scope.read_termination \n scope.write_termination \n print(scope.query(*IDN?)) # 握手确认连接的是目标仪器这段代码的逻辑是先用list_resources()看当前环境能发现哪些资源再用资源字符串打开会话。参数上timeout是单次读写超时单位毫秒read_termination和write_termination按仪器要求设置TCPIP 常见是\nUSB 有时要按驱动说明调整。*IDN?只用于确认连接不要把返回内容硬编码进报告模板。失败时先看list_resources()是不是空再看资源字符串是 TCPIP、USB 还是 GPIB三种写法不一样。把地址写死在代码里换工位就要重新打包这是后期维护最烦的一类改动。3.2 触发配置与单次采集先保证触发再谈刷新def setup_single_acquire(scope, ch1, vertical1.0, timebase1e-3, level0.5): scope.write(f:CHAN{ch}:DISPLAY ON) scope.write(f:CHAN{ch}:SCALE {vertical}) # 垂直档位 V/div scope.write(f:TIMebase:SCALe {timebase}) # 时基 s/div scope.write(f:TRIGger:A:EDGE:SOUrce CHAN{ch}) scope.write(f:TRIGger:A:LEVel {level}) # 触发电平单位跟垂直档位相关 scope.write(:TRIGger:A:MODe NORMAL) scope.write(:ACQuire:STOPAfter SEQuence) # 单次序列 scope.write(:ACQuire:STATE ON) # 等价于按一次 Single scope.query(*OPC?) # 等采集完成别用 sleep 硬等这段逻辑是先配通道显示、垂直档位、时基和边沿触发再让仪器进入单次采集最后用*OPC?等操作完成。参数上vertical是 V/divtimebase是 s/divlevel是触发电平具体范围受通道档位影响ch换成 2、3、4 时命令里的通道号也要跟着换。常见翻车点是用time.sleep(1)代替*OPC?仪器还没采完就去读波形读回来的是上一次的数据。失败时先查:TRIGger:STATe?和:ACQuire:STATE?再看触发电平是不是落在信号范围外。注意不同 3000 系列固件对命令缩写有差异先查仪器 SCPI 手册再把缩写换成全称验证。3.3 波形数据读取与单位换算原始码值不是电压def read_waveform(scope, ch1): scope.write(f:DATA:SOUrce CHAN{ch}) scope.write(:DATA:ENCdg RIBINARY) # 二进制传输比 ASCII 快 scope.write(:DATA:WIDTH 1) scope.write(:WFMOutpre:BYT_Nr 1) ymult float(scope.query(:WFMOutpre:YMULT?)) yoff float(scope.query(:WFMOutpre:YOFF?)) yzero float(scope.query(:WFMOutpre:YZERO?)) xincr float(scope.query(:WFMOutpre:XINCR?)) xzero float(scope.query(:WFMOutpre:XZERO?)) raw scope.query_binary_values(:CURVe?, datatypeb, containerlist) y [(v - yoff) * ymult yzero for v in raw] x [xzero i * xincr for i in range(len(y))] return x, y这段代码的逻辑是把波形从二进制码值换算成带单位的电压和时间轴。参数上YMULT是垂直比例因子YOFF是垂直偏移YZERO是垂直零点XINCR是采样间隔XZERO是时间轴起点。datatypeb表示有符号 8 位如果仪器返回无符号数据要改成B否则负电压会算错。失败时先看读回的字节数对不对再看波形是不是上下颠倒常见原因是YOFF没减就乘了比例。把原始码值直接画到界面上看着像波形实际幅值全是错的这种坑在报告里很难被发现。3.4 测试序列编排采集之后要判定和落盘class TestSequence: def run_once(self, driver, case): driver.apply(case.trigger) x, y driver.read_waveform(case.channel) vpp max(y) - min(y) ok case.vmin vpp case.vmax result {case: case.name, vpp: vpp, ok: ok} driver.save_csv(case.name, x, y) return result这段逻辑是把“采集”升级成“测试”先应用用例触发参数再取波形算峰峰值按上下限判定最后落 CSV。参数上case.vmin和case.vmax是用例阈值应该放在配置里不要写死在界面代码driver.save_csv里带上用例名和时间戳避免多次运行互相覆盖。失败时先看判定阈值是不是按通道档位换算过再看 CSV 里的时间轴长度和波形点数是否一致。自动化测试真正省时间的地方不是“能采一次”而是每次采完都有可追溯的结果文件和日志。4. PyQt5 实时刷新波形图片和打印信息怎样不卡界面实时刷新图片和实时刷新打印信息是这类上位机最容易堆出性能问题的地方。新手常见写法是每来一个点就setText每采集一帧就重建一次画布界面刚开始很流畅日志一多、波形一密就开始拖影。我的血泪经验是采集线程只管产数据UI 线程按固定节奏消费中间用最新值缓存和批量追加别让信号洪水直接冲到控件上。4.1 采集线程与 UI 槽函数信号连接顺序有讲究class MainWindow(QMainWindow): def start_acquire(self): self.worker AcquireWorker(self.driver_factory, self.params) # 先连接信号再启动线程避免第一帧丢失 self.worker.wave_ready.connect(self.on_wave) self.worker.log_ready.connect(self.append_log) self.worker.error.connect(self.on_error) self.worker.start() def on_wave(self, wave): self.latest_wave wave # 只缓存不在槽里做重活 self.update_wave_image(wave) # 绘制走节流后的刷新这段逻辑是把线程启动和信号连接顺序固定下来先connect再start否则线程跑得太快时第一帧可能丢掉。参数上self.worker必须保持引用局部变量被回收后线程会异常退出self.params是当前界面上的采集参数启动前做一次校验。失败时先看窗口关掉后线程有没有停再看on_wave里是不是做了耗时解析。把 VISA 查询塞进on_wave等于把阻塞操作又拉回 UI 线程前面分层就白做了。4.2 波形图片实时刷新截图法与重绘法的选择波形图片有两条路一条是让示波器直接返回屏幕截图界面显示QPixmap另一条是读回波形数据用pyqtgraph或 Matplotlib 重绘。截图法保真适合做报告留档重绘法灵活适合做缩放、测量和自动判定。实时刷新场景里我一般用重绘法刷界面用截图法在关键节点留档两者不要混在一个高频循环里。方式优点缺点适合场景截图QPixmap和屏幕一致留档直观解析难刷新频率受仪器响应限制报告截图、异常留证pyqtgraph 重绘刷新快数据可复用需要自己处理单位和坐标实时波形、自动判定Matplotlib 重绘图表功能全高频刷新容易卡低频分析、离线复盘import pyqtgraph as pg from PyQt5.QtWidgets import QWidget, QVBoxLayout from PyQt5.QtCore import QTimer class WaveView(QWidget): def __init__(self): super().__init__() self.plot pg.PlotWidget() self.curve self.plot.plot(penpg.mkPen(#2f80ed, width2)) layout QVBoxLayout(self) layout.addWidget(self.plot) self.latest None self.timer QTimer(self) self.timer.setInterval(50) # 20Hz 节流别跟着采集帧率刷 self.timer.timeout.connect(self.flush) self.timer.start() def push(self, wave): self.latest wave # 只更新最新值旧帧可以丢 def flush(self): if self.latest is None: return self.curve.setData(self.latest.x, self.latest.y) self.latest None这段逻辑是“缓存最新帧 定时刷新”采集线程发得再快界面也只按 50ms 的节奏重绘。参数上setInterval(50)是刷新周期觉得卡可以调到 80100msself.latest只保留最后一帧实时显示不需要排队补画每一帧。失败时先看setData的 x 和 y 长度是否一致再看是不是在flush里做了max/min大计算。把绘图放在采集线程里更危险Qt 控件不是线程安全的偶发崩溃很难查。4.3 打印信息实时刷新QTextEdit 批量追加加队列from collections import deque from PyQt5.QtCore import QTimer from PyQt5.QtGui import QTextCursor class LogPanel: def __init__(self, text_edit): self.text_edit text_edit self.buffer deque(maxlen2000) self.timer QTimer() self.timer.setInterval(100) self.timer.timeout.connect(self.flush) self.timer.start() def append(self, level, msg): self.buffer.append(f[{level}] {msg}) def flush(self): if not self.buffer: return lines [] while self.buffer: lines.append(self.buffer.popleft()) self.text_edit.append(\n.join(lines)) self.text_edit.moveCursor(QTextCursor.End)这段逻辑是把日志先塞进deque每 100ms 批量追加到QTextEdit。参数上maxlen2000防止日志无限涨内存setInterval(100)是批量刷新周期日志量特别大时可以调到 200ms。moveCursor(QTextCursor.End)保证视图跟到底部但不要每来一条就调一次。失败时先看界面是不是在日志暴增时卡住再看文件日志有没有同步落盘。常见误用是直接在采集线程里append控件短期看着能跑长期就是随机崩溃。4.4 时间戳与序号波形和日志对不上时的后悔药from dataclasses import dataclass from datetime import datetime dataclass class Waveform: seq: int ts: datetime x: list y: list ch: int这段数据类的逻辑是给每一帧波形带上序号、时间戳、通道和坐标数据。参数上seq自增日志里也打印同一个序号ts用采集完成时间不要用界面显示时间。失败时拿报告里的序号去日志里搜能快速定位是哪一次采集、哪一个通道。没有这两个字段波形图卡在某一帧、日志还在刷你很难判断是采集停了还是 UI 没刷新。这个后悔药不贵但关键时候能省半天。5. 避坑与排查连接、刷新、线程退出的血泪经验这类项目真正耗时间的不是主流程而是现场排查。仪器能 ping 通但query超时波形刷着刷着不动关窗后进程还在后台都是很典型的坑。下面几条按“现象 → 原因 → 解决”写都是我在类似工程里踩过的能少走一遍就少走一遍。5.1 连接与触发类问题能 ping 通不等于能 query踩坑一list_resources()为空或者打开资源超时。现象是程序启动后一直卡在连接日志里只有超时异常。原因常见有三类资源字符串写错、仪器和电脑不在同一网段、VISA 后端没装好。解决时先用系统命令确认网络再用 PyVISA 单独列资源不要一上来就调整个工程。ping 192.168.1.50 python -c import pyvisa; rmpyvisa.ResourceManager(); print(rm.list_resources())这段命令的逻辑是先确认链路再确认 VISA 能发现资源。参数上IP 换成配置里的仪器地址如果list_resources()返回空先查网线、子网掩码和仪器 LAN 设置再查 VISA 后端。解决后把资源字符串放回instruments.yaml不要写死在驱动里。现场换仪器只改配置能省掉重新打包的时间。踩坑二*OPC?返回了波形却没更新。现象是日志显示采集完成界面还是上一帧。原因通常不是刷新逻辑而是触发没发生仪器停在等待触发状态*OPC?等的是命令完成不是触发事件。解决时先查触发状态和采集状态再看触发电平是不是超出信号范围。print(scope.query(:TRIGger:STATe?)) print(scope.query(:ACQuire:STATE?)) print(scope.query(:TRIGger:A:LEVel?))这段查询的逻辑是把触发和采集状态摊开看。参数上TRIGger:STATe?返回的是触发状态ACQuire:STATE?返回采集状态TRIGger:A:LEVel?用来核对电平。解决方式是把触发模式改成 Normal/Auto 做对比确认信号真的过阈值再恢复单次序列。触发这层是玄学最多的地方示波器面板上看着触发上了远程命令不一定同步。5.2 刷新与数据一致性类问题界面越刷越卡踩坑三日志一多界面像卡死。现象是采集还在跑窗口拖动变慢日志区滚动延迟。原因是每条日志都直接appendQTextEdit频繁重绘UI 线程被拖住。解决方式是队列加批量刷新按 4.3 的写法每 100ms 追加一次同时限制缓冲长度。文件日志用单独的 handler 落盘不要和界面刷新抢同一把锁。# 采集线程只发信号不碰控件 self.log_ready.emit(INFO, fcase{case.name} vpp{vpp:.4f})这段逻辑是把线程和控件隔开参数上把关键字段放进日志文本方便后面检索。解决后如果还卡就看是不是在 UI 槽里做了 CSV 写盘或大数组计算这些也该放到工作线程。日志刷新这块没有多高深的技术核心就是别让高频小操作直接打到控件上。踩坑四波形图和日志错位。现象是日志说第 12 次采集失败界面上显示的却是第 11 次波形。原因是波形信号和日志信号分别排队UI 处理节奏不一致。解决方式是给每帧波形带seq和时间戳日志里打印同一个seq界面标题或状态栏显示当前帧号。self.wave_ready.emit(wave) self.log_ready.emit(INFO, fwave seq{wave.seq} ch{wave.ch})这段逻辑是让波形和日志共享序号。参数上seq在采集线程里自增不要用界面刷新次数ch带进日志多通道采集时才能对上。真出问题时拿报告里的序号反查日志比盯着界面猜靠谱得多。5.3 线程退出与打包类问题关窗后进程还在踩坑五关闭窗口后进程不退出VISA 会话被占用。现象是窗口没了任务管理器里还在下一次连接提示资源忙。原因是QThread还在循环或者 VISA 会话没close()。解决方式是在closeEvent里先停线程再等一小段时间最后关会话。def closeEvent(self, event): if self.worker and self.worker.isRunning(): self.worker.stop() self.worker.wait(3000) # 最多等 3 秒别无限等 if self.driver: self.driver.close() event.accept()这段逻辑是把退出顺序固定为“停线程、等线程、关会话”。参数上wait(3000)是最大等待时间采集循环里要检查_running否则等再久也没用。解决后如果进程还在查一下有没有非 daemon 的 Python 线程或定时器没停。线程退出这块最容易留尾巴现场表现就是“关不掉、连不上”。踩坑六打包后找不到 ui 或 config。现象是开发环境能跑打包后启动报文件不存在。原因是相对路径按当前工作目录算快捷方式启动时目录变了。解决方式是用Path(__file__)或打包后的资源目录拼绝对路径。from pathlib import Path import sys def app_dir(): if getattr(sys, frozen, False): return Path(sys._MEIPASS) # 打包后的资源目录 return Path(__file__).resolve().parent.parent这段逻辑是给配置和 ui 文件找稳定根目录。参数上sys._MEIPASS是打包工具解压资源的临时目录__file__是源码运行时的当前文件。解决后把config、ui、assets都按这个根目录拼不要用./config这种写法。打包问题看着小现场换一台机器就可能翻车。6. 二次开发把单台示波器测试改成多工位批量验证单台跑通之后下一步通常不是加更多按钮而是把驱动接口抽出来。我一般先定义一个最小驱动接口让 3000 系列示波器只是其中一种实现后面换型号或加多工位时不用改测试序列。判定规则和报告模板也要外置否则每加一个用例就改一次 UI维护成本很快上来。6.1 驱动接口抽象测试序列不认具体型号from abc import ABC, abstractmethod class ScopeDriver(ABC): abstractmethod def open(self): ... abstractmethod def apply(self, params): ... abstractmethod def read_waveform(self, channel): ... abstractmethod def close(self): ...这段接口的逻辑是把“连接、配参数、取波形、关闭”四件事固定下来测试序列只依赖ScopeDriver不依赖某个具体命令集。参数上params用配置对象传channel只传通道号单位换算留在驱动实现里。改造时先让原驱动实现这个接口跑通原有单台用例再加工位号。改造点原实现改造后仪器地址写在代码或单个配置多工位配置列表按工位取判定阈值界面里写死用例配置外置报告单次 CSV按工位和批次汇总驱动直接调 SCPI实现统一接口按型号注入6.2 判定规则与报告模板外置判定规则不要塞进on_wave放到TestSequence或独立规则文件里输入是Waveform和用例参数输出是TestResult。报告模板也单独放界面只负责展示当前结果。这样后面加多工位时采集线程可以按工位起多个结果汇总层不用关心哪个型号。从那以后我每次接新仪器都先把连接、触发、单次采集三步跑通再开 UI 加实时刷新图片和打印信息。这个顺序看着慢实际比在界面里反复猜命令快得多。希望帮到你。本文还有配套的精品资源点击获取

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑