
简介这是基于STM32的多功能虚拟信号分析仪示波器套件面向嵌入式学习者和硬件工程师提供一套可运行的信号采集、处理与波形显示方案。资源共302个文件压缩包约10.98MB以C语言源码、头文件、汇编文件为主还包含PCB设计文档、PDF手册与BMP波形截图便于对照硬件电路和实测结果理解固件程序。已有192人学习下载套件覆盖STM32的数据采集、ADC定时采样、实时滤波、频谱分析与LCD显示等完整流程硬件部分涉及微控制器、A/D转换器、采样存储器和模拟前端固件模块划分清晰适合学习Cortex-M内核的中断、DMA和定时器应用也可作为课程设计或毕业设计的参考工程。压缩包内包含可直接编译的Keil工程文件方便快速上手。读者可借助其中的电路图和源码搭建虚拟信号分析仪在现有代码上修改采样率、触发方式与显示界面加深对数字信号处理流程的理解。1. 示波器不在工位上时用 STM32 凑一台虚拟信号分析仪示波器不在手边时拿一块 STM32 开发板加一个电脑上位机就能临时组成一台虚拟信号分析仪——STM32 负责采样通过 USB 转串口把波形数据传到 PC上位机在屏幕上实时画波形、测频率、做 FFT。这个标题里的“多功能示波器套件”本质就是把调理电路、STM32 采集固件和上位机软件串成一条完整链路。嵌入式初学者可以把它当作理解采样率、触发、FFT 的练手项目老手也可以在没有台式示波器的场景下快速验证时序和幅值。下文按硬件采集、固件协议、上位机绘制、校准自检四个环节展开。2. 采集链路设计让 STM32 的 ADC 在虚拟示波器里看到真实波形2.1 前端信号调理双极性信号如何落到 ADC 的 0~3.3V 区间STM32 内置 ADC 的输入范围是 0 到 VREF常见的 F103、F407 开发板 VREF 接 3.3V。直接测 5V 逻辑方波会把引脚打坏测双极性音频信号则永远采不到负半周所以套件里第一级必须是调理电路。我常用的方案分两种。测 5V 信号时用两个电阻分压例如 1kΩ 对 2kΩ把 5V 缩到 3.33V再串一个 100Ω 电阻进 ADC 引脚测交流或双极性信号时用运放做加法器把信号抬升 1.65V 后再送入 ADC。运放用单电源轨到轨型号例如 MCP6002、LM358 都行前者失调电压更好。信号类型实测范围前端方案ADC 输入5V 逻辑方波0~5V1kΩ 对 2kΩ 分压0~3.33V耳机音频±0.5V运放加法器 1.65V 偏置0.15V~2.15V传感器输出0~10V高阻分压 4:10~2.5V分压电阻不能随便选。STM32 ADC 采样瞬间会从输入引脚抽取电流外部源阻抗太大会导致采到的电压偏低所以分压后最好经过运放跟随器。如果目标只是测同开发板上的信号源阻抗通常在 100Ω 以下直接分压也能工作。注意 STM32 引脚耐压F103 的 ADC 引脚不建议超过 VDDA0.3V宁可分压保守一点。2.2 采样率与分辨率怎么选定时器触发 ADC 而不是软件延时虚拟示波器最容易犯的错是用HAL_Delay加HAL_ADC_GetValue循环采样。软件延时受中断影响每次延时的抖动可能几十微秒波形会出现明显的左右晃动。正确做法是让定时器产生触发事件ADC 在固定时刻启动转换这样相邻采样点的时间间隔由硬件保证。采样率先按信号最高频率定。奈奎斯特要求理论最高采样率大于 2 倍信号频率实际显示波形至少要 5~10 倍做 FFT 则建议 20 倍以上。STM32 内置 ADC 的速度差异很大选型前先看数据手册里的 ADC 时钟上限和最短转换周期。STM32 系列ADC 时钟上限12 位最短转换周期理论最高采样率F10314MHz12.51.514 周期约 1MSPSF40730MHz12.5315.5 周期约 1.9MSPSH72336MHz12.51.514 周期约 2.5MSPS实际使用不要顶着上限跑。ADC 时钟、GPIO 布置、DMA 带宽都会影响稳定度我一般留 30% 余量。F103 做 100kHz 采样绰绰有余做 500kHz 就开始吃紧。另外 12 位分辨率对应 4096 个量化等级在 3.3V 量程下每个 LSB 约 0.8mV满足一般调试需求如果要做高精度电压测量就得外接 16 位 ADC 芯片。2.3 STM32 内置 ADC 的初始化与 DMA 搬运参数我用 F103 HAL 库举例核心是 TIM2 通道 2 的 PWM 输出触发 ADC1 转换。定时器预分频把 72MHz 降到 1MHz自动重装值决定采样率Pulse设为 1 使计数到 1 时产生触发事件每个周期只在固定相位触发一次。// F103 HAL 库ADC1 由 TIM2 CC2 上升沿触发DMA 循环搬运 void MX_ADC1_Init(void) { hadc1.Instance ADC1; hadc1.Init.ScanConvMode DISABLE; // 单通道不扫 hadc1.Init.ContinuousConvMode DISABLE; // 关连续转换等触发 hadc1.Init.ExternalTrigConv ADC_EXTERNALTRIGCONV_T2_CC2; hadc1.Init.ExternalTrigConvEdge ADC_EXTERNALTRIGCONVEDGE_RISING; hadc1.Init.DataAlign ADC_DATAALIGN_RIGHT; hadc1.Init.Resolution ADC_RESOLUTION_12B; HAL_ADC_Init(hadc1); } void MX_TIM2_Init(uint32_t sample_rate_hz) { htim2.Instance TIM2; htim2.Init.Prescaler 72 - 1; // 72MHz / 72 1MHz htim2.Init.Period 1000000 / sample_rate_hz - 1; htim2.Init.CounterMode TIM_COUNTERMODE_UP; HAL_TIM_Base_Init(htim2); TIM_OC_InitTypeDef oc {0}; oc.OCMode TIM_OCMODE_PWM1; oc.Pulse 1; // 计数到 1 时触发 oc.OCPolarity TIM_OCPOLARITY_HIGH; HAL_TIM_OC_ConfigChannel(htim2, oc, TIM_CHANNEL_2); HAL_TIM_OC_Start(htim2, TIM_CHANNEL_2); HAL_TIM_Base_Start(htim2); }采样率由Preiod 1决定例如Period 9999时触发频率为 1MHz / 10000 100HzPeriod 9时为 100kHz。Pulse 1是触发点设成 0 会出现在上溢瞬间容易和更新事件竞争设成 1 更稳。ContinuousConvMode必须关闭否则 ADC 会连续转换定时器触发就没有意义。接着初始化 DMA让转换结果自动搬到内存。void MX_DMA_Init(void) { __HAL_RCC_DMA1_CLK_ENABLE(); hdma_adc1.Instance DMA1_Channel1; hdma_adc1.Init.Direction DMA_PERIPH_TO_MEMORY; hdma_adc1.Init.PeriphInc DMA_PINC_DISABLE; hdma_adc1.Init.MemInc DMA_MINC_ENABLE; hdma_adc1.Init.PeriphDataSize DMA_PCDATAWORD_HALFWORD; hdma_adc1.Init.MemDataSize DMA_MDATAWORD_HALFWORD; hdma_adc1.Init.Mode DMA_CIRCULAR; HAL_DMA_Init(hdma_adc1); __HAL_LINKDMA(hadc1, DMA_Handle, hdma_adc1); }DMA 数据宽度必须是半字因为 STM32 ADC 寄存器是 16 位最高 12 位有效数据。Mode设成循环模式后DMA 写满缓冲区会从头继续固件侧不需要手动管理搬运过程只需要在回调里处理新数据。DMA1 通道选择要查参考手册F103 中 ADC1 固定连在 DMA1_Channel1。3. STM32 端固件把波形数据打包成上位机能认的帧3.1 用 DMA 循环采样加双缓冲避免丢波形DMA 循环模式会连续把 ADC 结果写进缓冲区问题是固件不知道哪些数据是新来的哪些是旧的。常见做法是双缓冲把缓冲区切成 AB 两半DMA 写满 A 半时触发半传输中断写满 B 半时触发全传输中断中断里各自处理半边。#define SAMPLE_BUF_SIZE 8192 volatile uint16_t adc_buf[SAMPLE_BUF_SIZE]; void HAL_ADC_ConvHalfCpltCallback(ADC_HandleTypeDef *hadc) { if (hadc-Instance ADC1) { // 处理 adc_buf[0] ~ adc_buf[SAMPLE_BUF_SIZE/2 - 1] } } void HAL_ADC_ConvCpltCallback(ADC_HandleTypeDef *hadc) { if (hadc-Instance ADC1) { // 处理 adc_buf[SAMPLE_BUF_SIZE/2] ~ adc_buf[SAMPLE_BUF_SIZE-1] } }两个回调都在中断上下文执行处理速度必须跟上采样速度。最稳妥的做法是回调里只把数据搬进待发送队列真正编码和发送放到主循环。如果发送来不及队列写满后直接丢弃最旧的一批保证上位机显示的是最近波形。不要在主循环里用延时等待 DMA 完成那样采样率会被拉低波形会出现周期性缺口。F1 系列没有硬件双缓冲位判断当前属于哪个半区靠 CNDTR 寄存器也行但用半传输中断最直接。3.2 帧结构和命令字启动、停止、设置采样率裸数据直接通过串口发出去上位机很难判断从哪个字节开始是一帧所以要有清晰帧格式。我常用的格式如下帧头A5 5A固定两字节接着命令字和参数然后是数据区和校验和。字段长度说明帧头2 字节固定 0xA5 0x5A用于找帧边界命令字2 字节0x0001 启动0x0002 停止0x0003 设置采样率通道号2 字节当前使用哪个 ADC 通道采样率4 字节小端序单位 Hz数据区2N 字节N 个 16 位采样点校验和1 字节帧头后所有字节求和取低 8 位typedef struct { uint8_t head[2]; // 0xA5 0x5A uint16_t cmd; // 命令字小端 uint16_t channel; // 通道号 uint32_t sample_rate; // 实际采样率 uint16_t count; // 数据区点数 uint16_t data[]; // 波形数据 uint8_t checksum; // 校验和放在 data 之后 } WaveFrame;上位机先找A5 5A再按长度读取后续字节校验和正确才解析。这套帧设计能容忍串口丢字节因为帧头一旦错位校验和会失败上位机重新同步到下一个帧头。固件侧的命令解析也很简单收到0x0001时启动定时器触发收到0x0002时停止触发收到0x0003时重新计算定时器预分频和重装值。3.3 吞吐量怎么算波特率不够时改配置数据传输速率往往成为整个虚拟示波器的瓶颈。12 位 ADC 结果存成 16 位一个采样点占 2 字节所以数据率为fs * 2字节每秒。普通 UART 的 115200 波特率约只能承载 11.5KB/s换算下来采样率只有 5.7kHz。采样率数据字节/秒115200 UARTUSB 虚拟串口 12Mbps10kSPS20KB/s不够够100kSPS200KB/s不行够1MSPS2MB/s不行接近上限F103 开发板常用的 CH340 等 USB 转串口最高能到 2Mbps但稳定性受线材和驱动影响。做虚拟示波器套件时我一般直接把 STM32 的 USB 接到 PC枚举成虚拟串口这样波特率概念就不存在吞吐量由 USB Full Speed 的 1MB/s 左右决定跑 200kHz 采样率没压力。如果必须用物理串口就要牺牲采样率或者把数据压缩成 8 位。另一种办法是缩小单帧点数例如每帧只传 1024 个点帧率提高后实时性反而更好。4. 上位机虚拟示波器波形绘制、简易触发和 FFT 分析落地写法4.1 选 pyqtgraph 还是 matplotlib取决于刷新率和保存需求上位机是虚拟示波器的门面。matplotlib 画静态频谱和保存报告方便但实时刷新几千个点CPU 占用明显偏高拖动、缩放会卡顿。pyqtgraph 底层做绘制优化几万点的波形刷新能保持流畅而且自带滚动区、坐标轴控制适合长时间显示波形。我的选择是 PyQt 做界面pyqtgraph 绘制时域波形numpy 做 FFT需要保存时再从数据缓冲区导出 PNG 或 CSV。4.2 串口数据解析与实时绘图参数上位机串口读取用pyserial读取频率放到定时器里触发比如每 30ms 检查一次串口缓冲区。下面这段代码串口波特率设为 921600对应 F103 的 100kHz 采样率不会丢帧。import numpy as np import serial import pyqtgraph as pg from PyQt5.QtCore import QTimer ser serial.Serial(COM5, 921600, timeout0.05) MAX_POINTS 4096 wave np.zeros(MAX_POINTS, dtypenp.uint16) curve pg.plot().plot(peny, namewave) def read_wave_frame(ser): while True: if ser.read(1) ! b\xA5: continue if ser.read(1) ! b\x5A: continue header ser.read(10) # cmd(2)channel(2)rate(4)count(2) if len(header) 10: return None count int.from_bytes(header[6:8], little) body ser.read(count * 2) checksum ser.read(1)[0] if checksum (sum(header) sum(body)) 0xFF: return np.frombuffer(body, dtypeu2) def update(): frame read_wave_frame(ser) if frame is None: return n min(len(frame), MAX_POINTS) wave[-n:] frame[:n] curve.setData(wave) timer QTimer() timer.timeout.connect(update) timer.start(30)read_wave_frame先找帧头再读数据校验和不对就继续等下一个帧头这样即使串口丢了一个字节下一帧也能恢复同步。wave[-n:] frame[:n]做了滚动式覆盖把新数据放在数组尾部显示效果类似示波器滚动模式。MAX_POINTS不要设太大4096 点足够看清大多数波形点数越多绘制压力越大。4.3 触发怎么实现用一块小缓存找上升沿虚拟示波器没有硬件触发但可以软件实现。常见做法是维护一个 N 帧滑动窗口在最新窗口里找满足条件的点当前值低于阈值且下一个值高于阈值就是上升沿。找到后把显示窗口的左边界设到该点让波形稳定在同一位置。def find_rising_edge(data, threshold1600): for i in range(len(data) - 1): if data[i] threshold data[i 1]: return i return 0阈值要根据信号电平动态调整固定阈值只适合特定波形。更实用的做法是取缓冲区的最大值和最小值把阈值设在两者之间 50% 位置。软件触发有一个天生弱点触发点只能在已经采到的数据里找触发位置精度受采样率限制显示的波形会在一个采样周期内抖动但这不影响常规观察。真正要求严格时就得用 STM32 内部的模拟看门狗或比较器做硬件触发。4.4 FFT 窗函数和幅值校正从看波形到看频谱测频率和频谱是信号分析仪的核心功能。对同一段时域数据做 FFT窗函数选错会直接导致频谱泄漏。矩形窗频率分辨率最好但旁瓣泄漏大适合测频率成分简单的正弦波汉宁窗主瓣稍宽但旁瓣衰减快适合看连续频谱。fs 100000 N 8192 window np.hanning(N) spectrum np.abs(np.fft.rfft(frame * window)) freqs np.fft.rfftfreq(N, 1 / fs) # 幅度校正单频正弦真实幅值 频谱峰值 * 2 / window.sum() amp_correct 2.0 / window.sum() spectrum_db 20 * np.log10(spectrum * amp_correct 1e-12)rfft只算正频率比完整 FFT 少一半计算量。频率分辨率等于fs / NN8192 时分辨率约 12.2HzFFT 点数越多频率轴越细但每帧需要的时间也越长。做 FFT 的数据最好是 2 的幂次numpy 虽然会补零但补零只是插值不会提高真实分辨率。这套计算逻辑放进定时器里每帧刷新一次频谱虚拟示波器就变成了虚拟信号分析仪。5. 校准与自检用 STM32 内部基准和 PWM 信号源给虚拟示波器定位5.1 用内部基准电压校准 ADC 量程ADC 满量程对应的电压不是精确的 3.300V不同板子可能差几十毫伏。STM32 芯片内部有一个带隙基准电压F103 在 ADC 通道 17F4 系列在通道 18。读取该通道的值再按 1.2V 标准反推实际 VREF就能修正每一点的电压值。// 读取 VREFINT 通道计算实际参考电压 HAL_ADC_Start(hadc1); HAL_ADC_PollForConversion(hadc1, 10); float vref 1.2f * 4095.0f / HAL_ADC_GetValue(hadc1); // 随后每个采样点电压 adc_value * vref / 4096.0f注意 VREFINT 通道的采样时间要适当加长数据手册要求最低采样时间太短读数偏小。这个系数在整个程序运行期间基本不变可以在启动时算一次避免每帧都做除法。上位机显示电压前还需要有一个偏移校准输入接地记录 ADC 平均值在软件里减去这个值。5.2 用定时器输入捕获复核频率用 PWM 输出做自检信号FFT 测频率的精度受采样率和点数限制。测方波频率更稳的方法是定时器输入捕获上升沿捕获当前计数值与上次捕获值相减就能得到周期。用 TIM3_CH1 测外部信号捕获值的倒数就是频率精度比 FFT 高两个数量级。把这个功能做进套件里当“频率计”用正好补齐信号分析仪的短板。最后给套件加一个自检信号源。用 TIM4 输出 1kHz、占空比 50% 的方波通过跳线帽直接连到 ADC 输入开机后虚拟示波器不需要外接信号发生器就能看波形。调试顺序一般是先看自检方波是否平直再切换到 FFT 看基频是否在 1kHz、底部噪声是否偏低若波形异常再回头查采样触发配置和 DMA 带宽这套闭环能快速区分采集链路和上位机软件的问题。本文还有配套的精品资源点击获取