STM32F103简易示波器:ADC+DMA+定时器采样链路实战
简介:这份资源是面向嵌入式初学者与电子竞赛备赛者的STM32F103C8T6简易示波器实战Demo,围绕ADC采样、定时器触发、串口通信与波形显示等核心环节,帮助读者理解如何用Cortex-M3内核微控制器完成从模拟信号采集到数字波形呈现的完整链路,适合具备一定C语言与单片机基础、希望动手实践信号采集项目的人群。压缩包共298个文件,约8.56MB,以c源文件与h头文件为主体,配合Keil工程文件(uvprojx、uvoptx)、编译产物(o、d、crf、axf、hex)及少量批处理与说明文件,构成可直接打开编译的完整工程目录。目前已有2916人学习下载,说明该案例在入门示波器与STM32外设综合练习中具有较高参考价值。读者可从中获得ADC多通道配置、定时器触发采样、UART数据传输、主机端波形绘制与滤波处理等具体实现思路,并借助工程结构学习固件库组织方式与调试排错方法,为后续扩展量程、提升采样率或增加显示界面打下基础。
1. 一块十几块钱的蓝板子,怎么就成了桌面上的简易示波器
手头只有一块 STM32F103C8T6 最小系统板,想测个 PWM 占空比、看个传感器输出的毛刺,又不想搬出几千块的台式示波器——这个简易示波器 demo 就是为这种场景准备的。它的核心思路很朴素:用 STM32 自带的 ADC 以固定采样率把模拟信号采进来,存进一块环形缓冲区,再通过串口把波形数据丢给上位机画出来,或者直接驱动一块小屏做本地显示。整套东西跑在 72MHz 的 Cortex-M3 上,ADC 最高 1Msps 的采样能力,对付音频以下、几十千赫兹以内的低频信号够用。适合谁?适合刚上手 STM32 外设、想找一个把 ADC、DMA、定时器、串口串起来练手的从业者,也适合需要一台随手能抓波形的低成本工具的人。先把预期摆正:它不是实验室仪器,量程、带宽、精度都有硬边界,但作为理解采样链路和练手外设协同的载体,性价比很高。
2. 采样链路怎么搭:ADC、定时器与 DMA 的三件套
2.1 为什么用定时器触发 ADC 而不是软件轮询
很多人第一版示波器是这么写的:主循环里HAL_ADC_GetValue()读一次,存数组,循环。跑起来就发现波形抖得没法看,采样间隔完全取决于循环里其他代码的执行时间,频率一高就混叠。血泪经验是:采样率必须由硬件定时器来定,软件只负责搬运。
正确做法是让 TIM 的更新事件去触发 ADC 的规则组转换,ADC 转换完成再由 DMA 把结果搬到内存,全程 CPU 不参与单次采样。这样采样间隔由定时器周期严格决定,抖动在纳秒级,波形才稳得住。STM32F103 的 ADC 外部触发源里,TIM2 的 CC2 事件是常用选择,配置成ADC_EXTERNALTRIGCONV_T2_CC2即可。
采样率的计算是这套链路的第一道坎。ADC 总转换时间 = 采样时间 + 12.5 个 ADC 周期,ADC 时钟最高 14MHz(72MHz 六分频)。假设采样时间设 1.5 周期,单次转换约 14 个周期,理论极限约 1Msps。但实际能跑多快还受 DMA 搬运和缓冲区大小制约。我一般先把目标定在 100kSps 到 500kSps 之间,够看大部分低频信号,也留足余量。
2.2 环形缓冲区与双缓冲的取舍
采样数据往哪存,直接决定波形能不能连续看。最省事的是开一个大数组,采满就停,等上位机读完再采——这叫单缓冲,缺点是采的时候不能传、传的时候不能采,波形有断点。进阶一点用双缓冲(ping-pong):DMA 配成循环模式,半满中断和全满中断各触发一次,前半段在采的时候后半段可以往外发,交替进行,波形就连续了。
下面是一段基于 HAL 库的 ADC + DMA + TIM 初始化骨架,双缓冲用 DMA 的 half-transfer 和 transfer-complete 两个中断实现:
// TIM2 配置:72MHz / (71+1) = 1MHz 计数频率,ARR=9 得 100kHz 触发 void TIM2_Init_100k(void) { __HAL_RCC_TIM2_CLK_ENABLE(); TIM_HandleTypeDef htim2 = {0}; htim2.Instance = TIM2; htim2.Init.Prescaler = 72 - 1; // 分频到 1MHz htim2.Init.Period = 10 - 1; // 100kHz 更新 htim2.Init.CounterMode = TIM_COUNTERMODE_UP; HAL_TIM_Base_Init(&htim2); // 用 CC2 事件做 ADC 触发源 TIM_OC_InitTypeDef sConfigOC = {0}; sConfigOC.OCMode = TIM_OCMODE_PWM1; sConfigOC.Pulse = 5; // 50% 占空比,仅用于产生事件 HAL_TIM_PWM_ConfigChannel(&htim2, &sConfigOC, TIM_CHANNEL_2); HAL_TIM_PWM_Start(&htim2, TIM_CHANNEL_2); } // ADC1 + DMA1_Channel1 双缓冲 #define BUF_LEN 1024 uint16_t adc_buf[BUF_LEN]; void ADC_DMA_Init(void) { __HAL_RCC_ADC1_CLK_ENABLE(); __HAL_RCC_DMA1_CLK_ENABLE(); ADC_HandleTypeDef hadc1 = {0}; hadc1.Instance = ADC1; hadc1.Init.ScanConvMode = DISABLE; hadc1.Init.ContinuousConvMode = DISABLE; // 由定时器触发,不连续 hadc1.Init.ExternalTrigConv = ADC_EXTERNALTRIGCONV_T2_CC2; hadc1.Init.DataAlign = ADC_DATAALIGN_RIGHT; hadc1.Init.NbrOfConversion = 1; HAL_ADC_Init(&hadc1); // 采样时间 1.5 周期,配合 14MHz ADC 时钟 ADC_ChannelConfTypeDef sConfig = {0}; sConfig.Channel = ADC_CHANNEL_0; // PA0 输入 sConfig.Rank = 1; sConfig.SamplingTime = ADC_SAMPLETIME_1CYCLE_5; HAL_ADC_ConfigChannel(&hadc1, &sConfig); // DMA 循环模式,半满/全满各一次中断 HAL_ADC_Start_DMA(&hadc1, (uint32_t*)adc_buf, BUF_LEN); }这段代码的关键参数有三个:Prescaler和Period共同决定采样率,改Period就能调采样率,但要注意别超过 ADC 的转换极限;SamplingTime决定输入阻抗匹配,信号源内阻大时要加长采样时间,否则采出来的值偏低;ContinuousConvMode必须关掉,否则定时器触发就失效了。DMA 配成循环模式后,HAL_ADC_Start_DMA一次启动就一直跑,半满和全满中断里分别处理前后半段数据。
2.3 触发方式:自由运行还是边沿触发
真正的示波器有触发功能,波形稳定显示靠的就是触发。简易 demo 里常见两种做法:自由运行(采到啥画啥)和边沿触发(检测到上升沿/下降沿才开始记录)。自由运行实现简单,但波形会在屏幕上左右乱跑,看着像玄学。边沿触发需要在缓冲区里找第一个越过阈值的点,把后续数据对齐输出,波形才定得住。
实现上,可以在 DMA 全满中断里做一次软件触发判断:从缓冲区头开始扫,找到第一个从低于阈值跳到高于阈值的位置,把该位置作为波形起点发给上位机。阈值可以设成缓冲区最大值和最小值的中间值,也可以让上位机下发。这一步多做几十行代码,观感提升非常明显,属于性价比极高的改动。
3. 上位机与本地显示:数据怎么变成波形
3.1 串口协议设计与上位机绘图
STM32 采到的数据是 12 位 ADC 值(0~4095),要通过串口发给上位机。最省事的协议是直接发原始字节,但这样上位机没法知道一帧的边界,容易错位。常见做法是加帧头帧尾:0xAA 0x55 + 长度 + 数据 + 校验。波特率建议 921600 或更高,115200 在 100kSps 下根本传不完——每采样点 2 字节,100kSps 就是 200KB/s,115200 波特率理论上限才 11.5KB/s,差了近 20 倍。这是新手最容易翻车的地方:采样率设得很高,结果串口成瓶颈,波形严重失真。
上位机侧用 Python 的pyserial+matplotlib就能快速搭一个:
import serial, struct import matplotlib.pyplot as plt import matplotlib.animation as animation ser = serial.Serial('COM3', 921600, timeout=1) FRAME_HEAD = b'\xaa\x55' def read_frame(): # 找帧头,读长度,再读数据 while True: if ser.read(1) == b'\xaa' and ser.read(1) == b'\x55': length = struct.unpack('H', ser.read(2))[0] payload = ser.read(length) if len(payload) == length: return struct.unpack(f'<{length//2}H', payload) fig, ax = plt.subplots() line, = ax.plot([], []) def update(_): data = read_frame() if data: line.set_data(range(len(data)), data) ax.relim(); ax.autoscale_view() return line, ani = animation.FuncAnimation(fig, update, interval=50) plt.show()read_frame里先对齐帧头再读长度,能避免串口噪声导致的错位;struct.unpack按小端解析 16 位无符号数,和 STM32 的内存布局一致。interval=50控制刷新率,太快会卡,太慢波形拖影。这套上位机代码不到 30 行,但足够把波形实时画出来,调试阶段非常够用。
3.2 本地 OLED 显示的取舍
不想连电脑的话,可以挂一块 0.96 寸 OLED(SSD1306,I2C 接口)。本地显示的好处是便携,坏处是刷新率和分辨率都受限:128×64 的屏,横轴最多 128 个点,纵轴 64 级,波形细节远不如上位机。我一般把本地显示定位成“快速看有没有信号、大概什么幅度”,精细分析还是走串口。
OLED 刷波形时别整屏清再重画,那样会闪。正确做法是用页地址模式,只更新波形所在的页,或者用双缓冲在内存里画好再一次性推屏。I2C 速率设 400kHz,整屏刷新大约 20ms,勉强能到 50fps,够看。如果信号频率高,本地屏基本只能看个包络,这点要提前有心理预期。
4. 避坑与排查:五个让波形“看起来不对”的真实原因
4.1 波形毛刺多、跳动大
现象:采一个稳定的直流或低频正弦,屏幕上却全是毛刺,数值上下跳几十个 LSB。原因通常是输入阻抗没匹配好,或者采样时间太短。信号源内阻大时,ADC 采样电容来不及充到稳定值,采出来就偏低且抖。解决:把SamplingTime从 1.5 周期加到 41.5 或 239.5 周期,同时确认信号源地线跟板子共地。如果还抖,在输入端并一个 0.1uF 电容做抗混叠。
4.2 波形频率对不上,像是被“压缩”了
现象:输入 1kHz 正弦,画出来却是 100Hz 的样子。原因几乎都是采样率不够,触发了混叠。根据奈奎斯特,采样率至少要是信号频率的 2 倍,实际工程里取 5 到 10 倍才看得清形状。解决:先算清楚当前 TIM 配置下的实际采样率,再对照信号频率调整Period。记住串口带宽也是硬约束,采样率提上去之前先确认传输链路跟得上。
4.3 串口数据错位、上位机画出来是乱码波形
现象:上位机偶尔画出一段完全不对的波形,或者帧长度解析出天文数字。原因是帧同步丢了,噪声或缓冲区溢出导致帧头错位。解决:协议里加校验和,上位机收到校验失败的帧直接丢弃并重新找帧头;STM32 侧在 DMA 中断里发数据时,确保发送完成再启动下一帧,别在中断里做阻塞发送。用HAL_UART_Transmit_DMA比阻塞式HAL_UART_Transmit稳得多。
4.4 ADC 读数始终是 4095 或 0
现象:不管输入什么,ADC 值卡在满量程或零。原因通常是引脚配置错了——ADC 输入脚必须配成模拟输入模式(GPIO_MODE_ANALOG),配成浮空或上拉都会出问题;另一个常见原因是参考电压没接好,VREF+ 悬空或没滤波。解决:检查 GPIO 初始化,确认 VREF 引脚有 0.1uF 加 1uF 的退耦,输入电压别超过 3.3V,超了会钳位甚至损坏引脚。
4.5 双缓冲切换时波形有断点
现象:用双缓冲后,波形每隔一段就有一个明显的断裂。原因是半满/全满中断里处理太慢,DMA 已经绕回覆盖了还没发完的数据。解决:中断里只做标记和启动发送,别在里面做浮点运算或长循环;把发送缓冲区开成两份,和 DMA 的两个半区一一对应;必要时降低采样率,给 CPU 留出搬运时间。这个坑很隐蔽,因为单看代码逻辑没问题,是时序上的竞争。
5. 把 demo 用出仪器感:校准与量程的两个技巧
demo 跑通只是起点,想让它读数可信,还得做两件事:校准和量程换算。ADC 的 12 位读数本身没有物理意义,得先知道参考电压实际是多少。别信标称的 3.3V,用万用表量一下 VREF+ 的实际值,我见过板子上实测 3.28V 的,按 3.3 算就有 0.6% 的系统误差。换算公式是电压 = adc_value * vref / 4095,把实测 vref 代进去,精度立刻上一个台阶。
量程方面,ADC 输入范围是 0 到 VREF,负电压和超过 VREF 的信号都进不来。测小信号时,前面加一级运放做偏置和放大,把信号抬到 1.65V 附近再进 ADC,能充分利用 12 位动态范围;测大信号时用电阻分压,注意分压电阻别太大,否则又回到输入阻抗不匹配的老问题。下面这个换算表可以贴在工位上:
| 场景 | 前端处理 | 有效分辨率 | 注意点 |
|---|---|---|---|
| 0~3.3V 直测 | 无 | 约 0.8mV/LSB | 输入别超 VREF |
| 小信号(mV 级) | 运放放大 10 倍 + 1.65V 偏置 | 提升约 10 倍 | 运放带宽要够 |
| 高压信号 | 电阻分压 1:10 | 降低 10 倍 | 分压电阻取 kΩ 级 |
| 带直流偏置的交流 | 隔直电容 + 偏置 | 取决于偏置稳定性 | 电容和输入阻抗构成高通 |
还有一个提升观感的技巧:在固件里做简单的滑动平均或中值滤波,窗口取 4 到 8 个点,能压掉不少随机噪声,代价是牺牲一点带宽。滤波窗口别开太大,否则高频细节被抹平,反而看不出真实波形。我一般把滤波做成上位机可开关的选项,调试时关掉看原始数据,出图时打开。
从那以后我每次搭采样链路,都强制先把采样率、串口带宽、缓冲区大小这三个数算清楚再动手写代码,因为绝大多数“波形不对”的问题,根子都在这三个数的匹配上,而不是代码写错了。希望这套拆解能帮到你,把手里那块蓝板子变成真正能用的桌面工具。
本文还有配套的精品资源,点击获取