简介:基于Python的虚拟示波器项目,面向电子工程、科研与教育领域需要观察分析信号波形的人群,借助matplotlib、numpy及PyQt等库实现信号采集、实时显示、滤波分析和多通道对比,无需依赖实体示波器即可完成电路与信号实验。包内共12个文件,压缩包仅20KB,包含4个Python脚本(主程序、波形绘制、数据处理等)、PyQt界面文件、项目配置xml及pyc编译缓存,结构精简。已有1155人学习下载。源码涵盖GUI控制、matplotlib波形绘制、numpy数据处理、自定义滤波、数据保存回放与多通道对比等核心逻辑,可直接运行或二次扩展;对想学习Python科学计算,或希望用软件模拟示波器进行教学演示、课前验证的开发者具有实用参考价值。
1. python虚拟示波器:用笔记本替代硬件示波器靠什么
在嵌入式开发和信号调试中,示波器是发现问题最快的工具,但实体示波器价格高、体积大,存储深度还经常不够用。python虚拟示波器要做的事,是把电脑自带的声卡、串口或 USB 设备当成前端采集硬件,再用 PyQtGraph 或 Matplotlib 把信号实时画出来。你真正要自己实现的,不只是画线,还包括采样率切换、电压标定、触发和测量。下面内容从声卡采集讲到串口接入单片机,最后落到触发和性能调优。适合想用笔记本替代示波器看波形、又不想买昂贵硬件的电子工程师和上位机开发者;如果你刚接触 Python,按章节顺序装包、跑代码就行。
2. 声卡作为数据源:python虚拟示波器的采集层与采样率选择
虚拟示波器第一步是解决模拟信号的入口。不接外部硬件时,你手里最便宜的高质量 ADC 就是笔记本声卡。声卡的采集前级自带抗混叠滤波器和可调增益,比一块裸的 ADC 芯片容易驱动。这一章先把声卡采集链路跑通,让后面的波形处理有稳定的数据来源。
2.1 采样率与缓冲区大小的选择标准
声卡常见的采样率是 44100 Hz 和 48000 Hz,部分设备能到 192000 Hz。根据采样定理,采样率的一半才是理论上能显示的最高频率,所以声卡虚拟示波器的有效带宽通常在 20 kHz 以下。显示时还需要把数据切成固定大小的帧,这就是 blocksize 的作用。
| 采样率 (Hz) | 奈奎斯特频率 (kHz) | blocksize=1024 时单帧时长 (ms) |
|---|---|---|
| 44100 | 22.05 | 23.2 |
| 48000 | 24.00 | 21.3 |
| 192000 | 96.00 | 5.3 |
波形刷新率等于采样率除以 blocksize。48000 Hz 采样、1024 点一帧时,每秒刷新约 47 帧,肉眼已经感觉不到闪跳。如果你一直卡在“画面只有一条直线”,先别怀疑绘图代码,多半是设备枚举没对上采样率。还没装环境的话,先从 python.org 拿 3.10 以上的官方安装包,装的时候勾上 Add python.exe to PATH,然后执行pip install sounddevice numpy scipy pyqtgraph pyserial,这几个包覆盖了后面所有章节。
2.2 用 sounddevice 打开输入流的最小代码
sounddevice 是 PortAudio 的薄封装,用它做采集比 pyaudio 少写一半代码。下面的脚本会打开默认输入设备,把每一帧波形放进队列:
import sounddevice as sd import numpy as np from queue import Queue, Empty SAMPLERATE = 48000 BLOCKSIZE = 1024 CHANNELS = 1 q = Queue(maxsize=8) def audio_callback(indata, frames, time_info, status): if status: print("声卡状态变化:", status) q.put(indata[:, 0].copy()) with sd.InputStream( samplerate=SAMPLERATE, blocksize=BLOCKSIZE, channels=CHANNELS, dtype="float32", callback=audio_callback, ): while True: try: frame = q.get(timeout=2) except Empty: print("采集线程可能已经断开") continue print("帧长度", len(frame), "峰值", np.abs(frame).max())indata 的形状是 (blocksize, channels),所以indata[:, 0]取出单通道波形,copy 是为了避免复用缓冲把旧数据覆盖。q 的 maxsize 设成 8,是防止采集速度快于消费速度时内存无限膨胀。status 非空时通常意味着 underflow 或 overflow,出现时先检查 blocksize 是否太小。
2.3 回调、队列和 UI 线程的职责
这段代码的关键不是“开一个音频流”,而是把采集和显示完全解耦。PortAudio 的回调运行在实时线程里,如果你在这里执行 matplotlib 绘图或文件写入,声卡驱动会认为你处理不过来,连续丢帧。采集线程唯一合法的动作,是把数据放入线程安全的队列。UI 线程再以固定频率从队列取帧,这样即使绘图卡顿 100 ms,音频线程也不会被拖死。
提示:不要在 callback 里调用任何绘图 API。遇到线程崩溃,先检查是不是把 plot.update 放进 callback。
3. numpy 信号处理:电压标定、去除直流与降噪滤波
把声卡拿到的原始 float 数组直接画出来,你会遇到三个问题:波形带着稳定的直流偏移、噪声比万用表读数大、数值还停留在无量纲单位上。示波器与录音软件的区别,就是显示前要对时间序列做一层校正。这一层用 numpy 写起来很短。
3.1 去除直流分量,避免波形整体抬高
笔记本内置声卡的输入端是交流耦合,但 ADC 本身会引入一个缓慢漂移的直流偏置。直接看原始数据,波形中心会随着温度或增益变化上下移动。第一个处理就是按帧减均值:
def remove_dc(frame: np.ndarray) -> np.ndarray: return frame - np.mean(frame)均值是整个数据块的直流分量,减掉后波形围绕 0 V 摆动。低频信号尤其需要这步,因为它的周期比一帧数据长时,均值并不能代表真正的直流偏置,波形会在帧边界出现跳变。更稳妥的做法是对连续数据流做一阶高通滤波,保留 5 Hz 以下的成分用来跟踪偏置。
滤波不是越强越好,虚拟示波器追求的是“看到的波和真实信号一致”。一旦引入带内衰减,相位就差出来了,后面的触发判断也会偏移。这也是为什么很多开源示波器默认不做平滑,只做直流移除。
3.2 电压标定:把无量纲数值变成伏特
逐一对应:
FULL_SCALE_V = 2.4 # 这个值由实测标定得到,单位 Vpp volts = processed * (FULL_SCALE_V / 2.0)声卡输入在 float32 下范围是 -1.0 到 1.0,对应 ADC 满量程。如果满量程是 2.4 Vpp,那么 -1.0 代表 -1.2 V,1.0 代表 +1.2 V,换算系数就是 2.4 / 2.0。注意这里“满量程”不是声卡标称最大输入,而是实际硬件接上信号后,用万用表或标准信号源测出来的有效范围,不同声卡差异很大。
标定过程也很直接:输入一个已知峰峰值的 1 kHz 正弦波,把采集到的峰值记录下来,用实际电压 / 采样峰值算出标定系数。把这个系数写进配置,显示器上的 Y 轴就能标注为 V。
3.3 降噪滤波:移动平均与 Savitzky-Golay 的取舍
| 方法 | 适用条件 | 参数建议 |
|---|---|---|
| 简单移动平均 | 白噪声为主,波形边缘不急 | 窗口 3~7 点 |
| Savitzky-Golay | 波形本身平缓但叠加噪声脉冲 | 窗口 31,阶数 3 |
from scipy.signal import savgol_filter filtered = savgol_filter( frame, window_length=31, polyorder=3, )window_length 必须是奇数,polyorder 不能大于窗口长度。savgol_filter 对波形细节的保留比移动平均好,但高频方波会被拉圆。如果你要观察的还是触发沿,就别滤波,先保证触发正确再谈降噪。
4. PyQtGraph 绘制:实时波形渲染与曲线颜色定制
采集和处理都做完了,接下来是让波形稳定滚动起来。Matplotlib 的 animation 在 50 FPS 下 CPU 占用很高,窗口缩放还会卡顿。pyqtgraph 直接绘制 OpenGL 图形,单曲线 6000 点轻松跑到 120 FPS。这一章的重点是搭建可复用的画布,并解决“如何改变曲线颜色”这个高频问题。
4.1 为什么选择 PyQtGraph,不选 Matplotlib 动画
Matplotlib 擅长出版级静态图,但它的重绘机制是整体重绘,波形移动时锯齿和残影明显。pyqtgraph 把数据放在场景图中,调用setData只更新顶点缓冲,响应速度完全不在一个量级。虚拟示波器要求的是持续滚动、低延迟、能稳定缩放,这是 pyqtgraph 的主场。
4.2 最小实时波形窗口
下面的脚本默认你已经有了第 2 章定义的q和SAMPLERATE,这里只做 UI 部分:
import sys import numpy as np import pyqtgraph as pg from PyQt5 import QtCore, QtWidgets from queue import Empty app = QtWidgets.QApplication(sys.argv) win = pg.GraphicsLayoutWidget(show=True) plot = win.addPlot(title="CH1") plot.showGrid(x=True, y=True, alpha=0.3) curve = plot.plot(pen=pg.mkPen("#FFD700", width=2)) def poll_queue(): try: frame = q.get_nowait() except Empty: return y = frame[:2048] t = np.arange(len(y)) / SAMPLERATE curve.setData(t, y) timer = QtCore.QTimer() timer.timeout.connect(poll_queue) timer.start(30) app.exec_()poll_queue 由 Qt 的事件循环驱动,每 30 ms 检查一次队列。取不到帧就直接返回,不会阻塞 UI。setData是 pyqtgraph 增量更新的核心,比重新创建 PlotDataItem 节省大量时间。把 y 截断到 2048 点,是为了限制单帧绘制规模,刷屏时 CPU 占用稳定在低位。
4.3 如何改变曲线颜色:QPen 颜色表与刷新逻辑
颜色问题在示波器里很常见:暗色背景下黄线最清楚,但保存截图后黄线变成白色,反而看不清。处理思路是把颜色集中管理,用函数切换,而不是到处硬编码。
from pyqtgraph import mkPen COLOR_TABLE = { "yellow": "#FFD700", "cyan": "#00FFFF", "green": "#00FF7F", "red": "#FF5500", } def set_curve_color(name: str): pen = mkPen(COLOR_TABLE.get(name, "#FFD700"), width=2) curve.setPen(pen) curve.setBrush(None)| 颜色 | 常用场景 |
|---|---|
| 黄色 #FFD700 | 暗色背景主通道,最醒目 |
| 青色 #00FFFF | 第二通道,线条细时辨识度高 |
| 绿色 #00FF7F | 长时间观察波形时眼睛疲劳度低 |
setBrush(None) 是为了不填充曲线下方的面积,否则滚动时会残留彩色色块。颜色切换与波形缩放独立,你甚至可以把它绑定到键盘快捷键,方便调试时随时换色。
4.4 双通道共用时间轴
多通道时最怕两个通道时间轴对不上。pyqtgraph 的做法是让第二通道共享第一通道的 X 轴:
plot2 = win.addPlot(row=1, col=0) plot2.setXLink(plot) curve2 = plot2.plot(pen=mkPen("#00FFFF", width=1))这样鼠标放大任何一路,另一路的时间范围跟着同步移动。剩下的数据分发,只需要在 poll_queue 里把帧拆成多个通道的数组,分别送入 setData 即可。
5. 串口数字示波器接入:解析 Arduino/STM32 的采集帧
不是所有信号都适合走声卡。传感器输出、电机控制、STM32 调试引脚这些都直接接在串口上。串口数字示波器最常见的工作方式,是把单片机当成模拟前端。你搜索 serial_digital_scope v2 这类命名时,看到的结构基本也分两层:一个定时采集的固件,一个解析串口的 Python 脚本。协议设计没有神秘之处,难点在帧边界。
5.1 单片机端的帧格式和协议选型
用文本行传数据最简单,比如每行发"512,723\n",但边界解析依赖换行符,遇到高波特率时容易出现半个数据包。更稳的做法是定义二进制帧,让接收方通过帧头和校验对齐。
| 波特率 | 文本行格式 9 字节 | 二进制帧 8 字节 |
|---|---|---|
| 115200 | 约 1280 帧/秒 | 1440 帧/秒 |
| 921600 | 约 10240 帧/秒 | 11520 帧/秒 |
二进制帧的效率和容错都更好。8 字节一帧在 115200 下等于每帧一次双通道采集,对 1 kHz 内的信号足够稳定。需要更高采样率时,把 STM32 接到 USB 虚拟串口,波特率不再限制传输速度。
一个通用帧格式建议:
- 帧头 2 字节:0xAA 0x55
- 通道 1:uint16 little-endian
- 通道 2:uint16 little-endian
- 校验:通道1 与通道2 相加取低 8 位
- 帧尾:0x0D
5.2 用 pySerial 读帧并校验
import serial ser = serial.Serial("COM16", baudrate=115200, timeout=0.1) def read_scope_frames(ser, max_len=512): buf = bytearray() while True: chunk = ser.read(64) if not chunk: continue buf.extend(chunk) while len(buf) >= 8: if buf[0] != 0xAA or buf[1] != 0x55: buf.pop(0) continue ch1 = int.from_bytes(buf[2:4], "little") ch2 = int.from_bytes(buf[4:6], "little") checksum = (buf[2] + buf[3] + buf[4] + buf[5]) & 0xFF if checksum != buf[6] or buf[7] != 0x0D: buf.pop(0) continue yield ch1, ch2 del buf[:8]buf.pop(0) 在这里是线性操作,但单片机的发送频率通常不高,可读性优先。int.from_bytes 的 little 参数必须和固件端一致,否则两通道会互相交换。校验失败时丢掉一个字节而不是整帧,是为了容错半包后的重同步。
5.3 把串口数据接进第 4 章 UI
串口线程和声卡线程本质相同:数据进队列,UI 定时取。处理完一帧后把两个通道的 numpy 数组传给两个 curve:
for ch1, ch2 in read_scope_frames(ser): t = np.arange(len(frame)) / SAMPLERATE curve.setData(t, ch1) curve2.setData(t, ch2)如果数据源是 MCU,时间轴不再是声卡的 SAMPLERATE,而是实际采样间隔。固件发回一个时间戳或按固定次数标记,上位机用帧序号换算时间,避免波形横向漂移。
6. 触发、游标与数据导出:虚拟示波器的三个实用技巧
6.1 用 NumPy 实现边沿触发
没有触发的虚拟示波器只是录音器,波形会左右跳动。边沿触发的核心是找信号穿越阈值的点:
def edge_trigger(data, level=0.0, mode="rising"): sign = np.sign(data - level) diff = np.diff(sign) idx = np.where(diff > 0)[0] + 1 if mode == "rising" else np.where(diff < 0)[0] + 1 for i in idx: if i >= 64: return i return None idx = edge_trigger(frame) if idx is not None: seg_start = idx - 64 view = frame[seg_start:seg_start + 1024] if seg_start >= 0 else frame[:1024]level 是触发电平,rising 对应上升沿,diff > 0 表示信号由下往上穿越。跳过前 64 点等于预触发区,确保触发点在窗口左侧露出波形触发前的状态。
6.2 游标与电压测量
用pg.InfiniteLine加两条可拖动线:
vline = pg.InfiniteLine(angle=90, movable=True, label="t={value:.3f}s") hline = pg.InfiniteLine(angle=0, movable=True, label="U={value:.2f}V") plot.addItem(vline) plot.addItem(hline)拖动后 label 自动显示所在坐标值,不需要额外写事件回调。测量频率时把两条竖线分别移到两个相邻周期的同相位点,时间差取倒数就是频率。
6.3 导出 CSV 验证测量结果
np.savetxt( "scope_export.csv", np.column_stack((t, view)), delimiter=",", header="time_s,ch1", comments="", )导出后用 Excel 或 pandas 打开,检查第一列时间间隔是否等于 1/SAMPLERATE。如果导出的数据时间间隔不对,问题一定出在采样的帧边界或滤波延迟,回头查第 3 章的高通滤波参数。触发电平也建议先设到信号峰峰值的中间,太小会让噪声触发抖动,太大则可能错过真实沿,这两种情况在文本界面上都能直接看到触发点索引的跳变。
本文还有配套的精品资源,点击获取