news 2026/9/7 6:54:10

手把手自制串口示波器V2:STM32固件到Python上位机全解析

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
手把手自制串口示波器V2:STM32固件到Python上位机全解析

简介:串口数字示波器(Serial Digital Scope)是一款面向单片机开发者、电子爱好者与嵌入式初学者的上位机调试工具,核心用途是将单片机经串口发送至上位机的数据实时绘制成波形,解决原始数据无法直观观察的问题。软件最多支持四个通道同时显示,具备波形缩放、自动跟踪等交互能力,并能一键导出CSV格式,便于对采集数据进行后续分析或记录。资源包共10个文件,主要包含可直接运行的exe程序、C/H源码文件、TXT说明文档以及DAT配置文件,压缩包仅796KB,小巧轻便,适合快速部署到调试环境中。目前已有444人学习下载,受到单片机调试场景的认可。包内源码与可执行程序并存,既能直接使用V2版本进行多通道波形观察,也可通过阅读C/H源码理解串口协议解析与波形绘制原理,便于二次开发和功能裁剪;配合说明文档和配置文件,还能帮助用户快速掌握四通道设置、缩放与自动跟踪等操作,适用于嵌入式课程设计、传感器数据采集和设备调试等多种场景。 最近整理移动硬盘的时候翻到一个Serial_Digital_Scope V2.zip,时间戳显示是两年前打包的。解压看了一圈代码,觉得这套小型串口示波器项目的设计思路和踩坑过程还是挺值得写的。Serial_Digital_Scope 说白了就是一台基于串口传输的简易数字示波器:单片机负责 ADC 采样,把波形数据打包后通过 USB 转串口发给 PC,再在上位机里还原成实时波形。它不是为了替代专业示波器,而是解决日常调试里“手边没有示波器,但需要看传感器输出、PWM 波形、串口电平变化”这类问题。

这篇文章会把 V2 版本从硬件选型、固件数据流、上位机绘制到实际调试中踩过的坑完整拆开讲。如果你打算自己动手做一台低成本示波器,或者只是对串口协议设计、ADC 采样调度和实时绘图感兴趣,这篇应该能帮你少走不少弯路。

1. 这个 zip 里到底装了什么,以及为什么要做一台串口示波器

1.1 从文件命名看项目定位

Serial_Digital_Scope这个名字实际透露出几个关键信息:采用串口(Serial)作为传输通道,采集的信号属于数字域采样得到的离散波形(Digital),最终目标是把信号可视化(Scope)。V2 代表这是第二个迭代版本,不是推倒重来,而是在 V1 的基础上解决采样率不稳、丢帧、波形抖动这些明显问题。

解压后内容和我预期的差不多:

Serial_Digital_Scope_V2/ ├── firmware/ │ ├── Core/ │ ├── Drivers/ │ └── Project/ ├── host_software/ │ ├── serial_scope.py │ ├── scope_ui.py │ └── requirements.txt └── docs/ ├── protocol_v2.md └── calibration.md

固件部分是一个基于 STM32 的标准工程,上位机则用 Python 写的。V1 时代的上位机界面非常简陋,只有一条实时滚动曲线,几乎没有缩放和触发功能。V2 花了大量精力重写了帧协议和数据缓存逻辑,这是和 V1 最大的区别。

1.2 什么场景真正用得上它

有人可能会问:几十块钱的 USB 逻辑分析仪不是更省事吗?确实,逻辑分析仪擅长看数字电平时序,但它很难真实反映模拟信号的幅值变化。Serial_Digital_Scope 的定位正好在“纯逻辑分析仪”和“专业台式示波器”之间,适合这几类场景:

  • 调试 NTC 热敏电阻或光敏电阻的缓慢变化信号;
  • 观察 PWM 输出是否真的达到了预期占空比和频率;
  • 读取加速度计、气压计等传感器通过 SPI/I2C 输出的模拟波形;
  • 分析音频信号经过放大电路后的削波情况;
  • 验证电源轨上电瞬间的纹波是否在允许范围内。

这些场景不需要 500MHz 带宽,也不需要 1Gpts 的存储深度,只要能把波形稳定显示出来,能测量大致幅值和频率就够了。串口示波器够用,而且元器件成本很低,维修替换也方便。

2. 硬件方案取舍:V2 在采样链路上的关键改动

2.1 主控选型与 ADC 采样能力

V1 主控用的是 STM32F103C8T6,V2 依然保留了这颗芯片,原因很直接:便宜、资料多、ADC 速度够用。STM32F103 的 ADC 是 12 位,理论上最高采样率 1Msps,对于大多数传感器信号和低频 PWM 检测完全足够。

不过“理论最高采样率”和“稳定持续采样率”是两码事。如果让 ADC 连续最高速采样,然后把每个 16 位结果都通过串口发出去,串口根本来不及传。V2 妥协后的做法是:

  • 单次触发模式:以 1Msps 采样,采满一帧深度(比如 1024 点)后暂停,再整帧发送;
  • 连续显示模式:把采样率降到 50kSps 到 200kSps 区间,数据边采边发。

ADC 的参考电压也做了改动。V1 直接使用 MCU 的 3.3V VDD 作为参考源,导致 USB 供电波动直接影响测量精度。V2 在模拟电源处增加了一颗低噪声 LDO,并用陶瓷电容和磁珠做了隔离,ADC 的参考电压从 VDD 改为外部基准电压。虽然绝对精度还是达不到实验室级别,但长期稳定度明显提升,5V 电源电压波动时波形基线不会再跟着漂。

2.2 前端信号调理电路的设计

单片机的 ADC 输入范围一般只有 0~3.3V,而需要测量的信号往往可能是双极性(例如音频信号和交流信号),或者超过 3.3V。V1 的输入保护做得很弱,一不下心烧过单片机引脚,V2 专门设计了前端调理电路。

基本的信号链路是这样的:

输入端子 → 分压电阻 → 电压跟随器 → 钳位二极管 → ADC引脚

分压电阻用来衰减高压信号,V2 使用 10kΩ 和 10kΩ 分压,把 0~6.6V 范围折半映射到 0~3.3V。分压会提高信号源阻抗,所以后面必须接一级高输入阻抗的电压跟随器。V2 用了 OPA340 这类单电源轨到轨运放,供电电压 3.3V,输入阻抗在 MΩ 级别,对被测电路的影响极小。

钳位环节是 V1 的教训。原来只加了一个 3.3V 稳压管,结果静电或反接时还在损坏引脚。V2 改用两个快恢复二极管分别钳到 GND 和 3.3V,再串联 1kΩ 限流电阻。这个方案虽然会增加一点点输入阻抗,但保护效果比稳压管好得多。

2.3 串口传输瓶颈决定了整体架构

很多第一次做串口示波器的人会把注意力全放在 ADC 采样率上,结果做完才发现串口才是最大的瓶颈。以常用的 921600 波特率为例,理论有效数据率大约 92KB/s,也就是每秒最多传输九万字节左右。

如果每帧包含 1024 个采样点,每个采样点用 2 字节(16 位)表示,再加上帧头、通道号、 CRC 等开销,一帧差不多 2050 字节。在 921600 波特率下,一帧传输就要 22 毫秒左右。也就是说,理想状态下每秒也只能刷新约 45 帧。如果还要显示多通道,帧率只会更低。

V2 为此专门设计了两种工作模式,避免让“采样率”和“传输率”互相打架。单次触发模式下,MCU 先把一段波形完整采进 SRAM,再慢慢上传;连续显示模式则主动把采样率降低到传输能力允许的范围。这样既能看突发信号细节,也能观察实时变化趋势,不会一上来就把数据链路堵死。

3. 固件端的数据流设计:从 DMA 到串口打包

3.1 定时器触发 + DMA 采样

要让波形显示稳定,ADC 采样间隔必须严格均匀。如果直接在 while 循环里调用HAL_ADC_Start阻塞采集,采样点之间的时间间隔会被中断、串口发送等逻辑扰乱,画出来的波形会明显失真。

V2 的固件采用“定时器触发 ADC + DMA 搬运”的结构。STM32 的高级定时器 TIM1 产生固定频率的更新事件,作为 ADC 的外部触发源;ADC 每次触发采样一次,转换完成后由 DMA 把结果自动搬进内存缓冲区,全程不需要 CPU 介入。

核心初始化逻辑类似:

static void MX_ADC1_Init(void) { hadc1.Instance = ADC1; hadc1.Init.ScanConvMode = DISABLE; hadc1.Init.ContinuousConvMode = DISABLE; hadc1.Init.DiscontinuousConvMode = DISABLE; hadc1.Init.ExternalTrigConv = ADC_EXTERNALTRIGCONV_T1_TRGO; hadc1.Init.DataAlign = ADC_DATAALIGN_RIGHT; hadc1.Init.NbrOfConversion = 1; if (HAL_ADC_Init(&hadc1) != HAL_OK) Error_Handler(); }

这里必须注意ContinuousConvMode设为 DISABLE,否则定时器触发就失去意义了。每次 DMA 传输达到设定长度后触发转换完成中断,在中断里切换缓冲区或启动新一轮采集。

3.2 双缓冲与乒乓切换

如果只用一块固定缓冲区,那么 MCU 在发送这块数据期间,ADC 还在继续往同一块内存里写数据,就会产生覆盖。V2 采用经典的双缓冲(Ping-Pong Buffer),两块缓冲区轮流工作:

#define SAMPLE_BUF_SIZE 4096 __attribute__((aligned(4))) uint16_t adc_buf_a[SAMPLE_BUF_SIZE]; __attribute__((aligned(4))) uint16_t adc_buf_b[SAMPLE_BUF_SIZE]; volatile uint8_t current_buf = 0; void HAL_ADC_ConvCpltCallback(ADC_HandleTypeDef *hadc) { if (hadc->Instance == ADC1) { if (current_buf == 0) { // 通知发送线程处理 buf_a 数据,继续采 buf_b process_and_send(adc_buf_a, SAMPLE_BUF_SIZE); HAL_ADC_Start_DMA(&hadc1, (uint32_t *)adc_buf_b, SAMPLE_BUF_SIZE); current_buf = 1; } else { process_and_send(adc_buf_b, SAMPLE_BUF_SIZE); HAL_ADC_Start_DMA(&hadc1, (uint32_t *)adc_buf_a, SAMPLE_BUF_SIZE); current_buf = 0; } } }

这里的process_and_send要尽可能短。如果在中断里做大量打包和串口发送,采样就会被打断,所以 V2 在实际实现中只把缓冲区标记为“待发送”,真正的发送流程放在主循环里处理。对实时性要求更高的场景,可以考虑用 RTOS 或者更细粒度的临界区保护。

3.3 帧协议:怎么保证上位机能正确解码

串口是字节流,本身就很容易发生错位。如果上位机只是简单从第一个字节开始按顺序解析,一旦一个字节丢失或者多发,之后所有波形数据都会错乱。V2 采用的帧格式如下:

typedef struct __attribute__((packed)) { uint8_t header; // 固定 0xA5 uint8_t channel; // 通道编号 uint8_t mode; // 0=连续, 1=单次 uint8_t point_num; // 采样点数高字节 uint8_t point_num_low; uint8_t sample_rate; // 采样率档位索引 uint8_t reserved; uint8_t crc8; // 帧头校验 uint16_t data[]; // 有效采样数据 } ScopeFrame;

上位机不能只依赖帧头来找边界,因为数据区里也可能出现 0xA5。V2 的做法是“帧头 + 长度 + CRC8”三重校验:先扫描到帧头 0xA5,再根据接下来的长度字段计算整帧长度,最后做 CRC 校验,全部通过才认为这帧有效。如果 CRC 不对,就从下一个字节重新开始搜索帧头,直到自动恢复同步。

3.4 采样率与波特率的换算关系

固件设计里有一个很关键的换算公式。假设波特率是 B,帧开销是 8 字节,每采样点用 number_of_bytes 字节表示,那么理论最大持续采样率大约为:

持续采样率 ≈ (B / 10) / (number_of_bytes + 8 / points_per_frame)

这里除以 10,是因为 UART 每一字节实际传输 10 bit(起始位 + 8 数据位 + 停止位)。以 921600 波特率、每帧 4096 点、每点 2 字节为例:

有效传输 = 92160 Byte/s ≈ 46080 采样点/s

所以连续显示模式下,V2 默认把采样率设置在 50kSps 以内,留出一点余量给操作系统调度和 USB 转换器延迟。如果强行设到 200kSps,上位机接收数据的速度跟不上,很快就会出现丢帧。

V2 在采样率档位设计上做了几个固定档位:1kSps、10kSps、50kSps、100kSps、200kSps、1MSps。前几个用于连续显示,最后两个仅用于单次触发。这样用户不用手动输入频率,减少配置错误。

4. 上位机波形显示:串口数据到屏幕像素的漫漫长路

4.1 技术栈选型:为什么用 Python

上位机最初考虑过 C++ 加 Qt,但 V2 最终还是用了 Python。原因很简单:快速迭代。串口示波器本来就是个辅助调试工具,不值得花大量时间在 C++ 的界面和编译环境上。Python 生态里有pyserial做串口通信,pyqtgraph做高性能绘图,配合 PyQt5 可以在一周内完成界面和逻辑。

依赖列表很小:

pyserial==3.5 PyQt5==5.15.9 pyqtgraph==0.13.3 numpy==1.24.3

pyqtgraph天生基于 numpy 数组绘制,性能远好于 matplotlib,刷新几千个点不会有明显卡顿。

4.2 串口线程与界面线程的协作模式

上位机最容易犯的错误是在主界面线程里去读串口,一旦串口数据堆积,界面就会卡死。V2 的做法是单独开一个 QThread 负责串口读取和帧解析,解析完成的波形数据通过 Qt Signal 发给主窗口。

大致代码结构如下:

class SerialReader(QThread): frame_ready = pyqtSignal(object) def __init__(self, port, baudrate): super().__init__() self.serial = serial.Serial(port, baudrate, timeout=0.05) self.running = True def run(self): buffer = bytearray() while self.running: buffer.extend(self.serial.read(512)) frames = self.parse_buffer(buffer) for frame in frames: self.frame_ready.emit(frame) def parse_buffer(self, buffer): # 查找帧头、解析长度、CRC校验,返回完整帧列表 pass

把串口读取放到独立线程后,即便串口短时间内涌入大量数据,界面线程依然能保持响应。timeout=0.05很重要,它避免线程无限期阻塞在read()上,保证退出程序时线程能及时收到停止信号。

4.3 绘制优化和触发逻辑

pyqtgraph 画波形本身不算难,真正麻烦的是数据刷新策略。V1 每收到一帧就立刻setData,当串口数据量大时界面会明显掉帧。V2 改成定时刷新,比如每 30 毫秒更新一次画面,这个间隔内如果收到多帧数据,就只取最新一帧来显示。这样人眼看起来是实时波形,CPU 占用却低很多。

触发逻辑在 V2 里也做了重做。原来只是简单地从第一个点开始画,波形在屏幕上会左右乱跑。V2 增加了一个上升沿触发检测:当检测到波形从低于阈值变为高于阈值时,从该点开始截取一帧数据。这样波形的起始相位基本固定,主观视觉上稳定很多。

def find_trigger(data, threshold): for i in range(len(data) - 1): if data[i] < threshold and data[i + 1] >= threshold: return i return 0

触发在软件层实现,不可避免有一点延迟,但用于低频信号足够。如果以后要做 MHz 级信号触发,就必须靠硬件比较器了。

5. 实际调试中的典型故障与排查思路

5.1 波形上叠加了规律毛刺

V2 刚调试时,波形正常,但每几百毫秒会周期性叠加一个毛刺。一开始怀疑是电源噪声,后来用示波器量测模拟前端,发现毛刺周期和串口发送中断的周期完全吻合。问题出在串口发送时的 DMA 和 ADC 采样共用总线,串口占用总线的时间会对 ADC 转换造成微小干扰。

解决办法有两个,第一是提高串口 DMA 优先级,让发送过程尽快结束;第二是在模拟前端增加一个 RC 滤波,截止频率设置到几十 kHz,把高频毛刺滤掉。V2 最终两个方案都用上了,毛刺幅度从几十毫伏降到了几个毫伏以内。

这类问题在原理图阶段很难预料,必须通过实测波形反推。所以串口示波器这种工具虽然精度不高,但用来排查自身硬件干扰反而特别方便。

5.2 长时间采集时丢帧

连续跑几分钟后,上位机的波形会出现断层或明显跳变。排查链路是这样的:

  1. 先确认串口硬件:换了一条短 USB 线,问题依旧;
  2. 增加上位机读取缓冲大小,没有明显改善;
  3. 把上位机串口超时调小到 0.01 秒,问题改善了一些;
  4. 最终定位到 USB 转串口芯片的驱动延迟。

很多 USB 转 UART 芯片默认会启用接收缓冲,USB 端点每 1ms 或 2ms 才提交一次数据,导致实际数据到达上位机时是一坨一坨的。Windows 下可以通过修改设备管理器里的“延迟计时器”来降低,Linux 下则可以用setserial设置 low_latency 模式。

setserial /dev/ttyUSB0 low_latency

设置之后长时间采集的丢帧现象基本消失。这提醒我,串口示波器的稳定性不只是 MCU 固件的问题,PC 端驱动风格也会极大影响表现。

5.3 刷新率上不去的真正瓶颈

早期 V2 上位机无论怎么优化,波形刷新率一直卡在十几帧。我一度认为是 pyqtgraph 绘制能力不足,后来用cProfile跑了一遍,发现主要耗时在帧解析里的 numpy 数组拼接操作。

原来的代码每收到一帧就做np.append到大数组,反复分配内存,开销巨大。改成预先分配一块固定大小的 numpy 数组,每次用切片覆盖写入,效率立刻提升两三倍。绘制层面反而非常轻松。

经验就是优先优化数据处理路径,而不是一上来就怀疑绘图引擎。

5.4 测量电压不准的校准方法

ADC 的 12 位读数并不能直接换算成真实电压,因为分压电阻有误差,参考电压也不是绝对精确。V2 的标定很简单:给输入端分别加 0V、1.0V、3.3V 三组已知电压,记录对应的 ADC 读数,然后做线性拟合,得到实际换算系数。

voltage = (adc_code * slope) + offset

这个系数被写进上位机的配置文件里,不用每次都改固件。实测下来,输入 1V 时误差能控制在 10mV 以内,对于观察波形幅值已经够用。

标定还有一个额外作用:能间接检查整个模拟前端的线性度。如果三点拟合出来的残差很大,说明前端电路或参考电压有问题,这时候修硬件比调软件更有效。

6. 如果继续做 V3,我会优先改哪几处

6.1 值得加入的触发和存储深度

V2 的软件触发虽然够用,但触发点之后的数据仍然是有限的。如果能把采样深度提高到 64K 点以上,配合硬件比较器触发,就能捕捉到偶发的异常毛刺。这可能是 V3 最值得做的一点。同时 ADC 采样率上限 1Msps 受限于 MCU 主频,真要提升带宽就得换更高性能的芯片。

6.2 离开串口后的替代方案

串口始终有波特率的天花板。V3 可以考虑直接用 MCU 的 USB 高速模式,或者通过 WiFi 传给 PC,传输带宽能提升一个量级。另外给 MCU 直接接一块小尺寸 TFT 屏,让示波器作为便携设备独立工作,也是一条好玩且实用的路线。

不过,如果只是日常调试用,当前 V2 这套架构其实已经覆盖了 90% 以上的需求。我最后想说的一点是:这类项目最有价值的往往不是最终跑通的波形,而是中间那次“串口数据对不上”“波形为什么有毛刺”“刷新率上不去”的排查过程。把这些经验记录下来,比单纯上传一个 zip 要对后来者有用得多。

本文还有配套的精品资源,点击获取

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/9/7 6:52:52

零编程部署Hermes Agent:把Telegram变成你的AI员工

最近身边不少朋友开始不满足于“打开网页、输入问题、等回答”这种 AI 使用方式了。Hermes Agent 这类项目之所以被反复讨论&#xff0c;就是因为它踩中了一个真实需求&#xff1a;你要的不是一个聊天窗口&#xff0c;而是一个常驻的“AI 员工”——通过 Telegram 发消息就能指…

作者头像 李华
网站建设 2026/9/7 6:47:42

ComfyUI新手入门:从零搭建Stable Diffusion节点式工作流

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/9/7 6:47:39

AMD RX 9700 AI推理加速新方案:R9V Kernel内核优化实践

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/9/7 6:45:04

果宝特攻同人创作:寒冰西瓜尊角色设计与数字绘画技术解析

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华