用STM32自制虚拟示波器:从ADC采样到FFT频谱分析
2026/9/15 13:19:04 网站建设 项目流程

简介:这是基于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 引脚不建议超过 VDDA+0.3V,宁可分压保守一点。

2.2 采样率与分辨率怎么选:定时器触发 ADC 而不是软件延时

虚拟示波器最容易犯的错是用HAL_DelayHAL_ADC_GetValue循环采样。软件延时受中断影响,每次延时的抖动可能几十微秒,波形会出现明显的左右晃动。正确做法是让定时器产生触发事件,ADC 在固定时刻启动转换,这样相邻采样点的时间间隔由硬件保证。

采样率先按信号最高频率定。奈奎斯特要求理论最高采样率大于 2 倍信号频率,实际显示波形至少要 5~10 倍,做 FFT 则建议 20 倍以上。STM32 内置 ADC 的速度差异很大,选型前先看数据手册里的 ADC 时钟上限和最短转换周期。

STM32 系列ADC 时钟上限12 位最短转换周期理论最高采样率
F10314MHz12.5+1.5=14 周期约 1MSPS
F40730MHz12.5+3=15.5 周期约 1.9MSPS
H72336MHz12.5+1.5=14 周期约 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 = 100Hz,Period = 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 虚拟串口 12Mbps
10kSPS20KB/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, timeout=0.05) MAX_POINTS = 4096 wave = np.zeros(MAX_POINTS, dtype=np.uint16) curve = pg.plot().plot(pen='y', name='wave') 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, dtype='<u2') 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, threshold=1600): 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 / N,N=8192 时分辨率约 12.2Hz,FFT 点数越多频率轴越细,但每帧需要的时间也越长。做 FFT 的数据最好是 2 的幂次,numpy 虽然会补零,但补零只是插值,不会提高真实分辨率。这套计算逻辑放进定时器里,每帧刷新一次频谱,虚拟示波器就变成了虚拟信号分析仪。

5. 校准与自检:用 STM32 内部基准和 PWM 信号源给虚拟示波器定位

5.1 用内部基准电压校准 ADC 量程

ADC 满量程对应的电压不是精确的 3.300V,不同板子可能差几十毫伏。STM32 芯片内部有一个带隙基准电压,F103 在 ADC 通道 17,F4 系列在通道 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 带宽,这套闭环能快速区分采集链路和上位机软件的问题。

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

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询