☰
STM32F103C8T6自制示波器:ADC+DMA+定时器采样与波形显示
2026/10/14 16:26:21 网站建设 项目流程

简介:这是基于STM32F103C8T6的简易示波器Demo工程,面向嵌入式初学者和电子爱好者,解决如何用STM32片上ADC采集模拟信号、定时器触发采样并经串口或USB传输后在PC端显示波形的入门问题。资源包共298个文件、约8.56MB,主体为59个.h头文件与56个.c源文件,配合Keil的.uvprojx/.uvoptx工程配置、.hex固件与.lst列表文件,可以直接打开工程浏览配置并重新编译。已有2914人学习下载。工程代码覆盖STM32的GPIO、ADC多通道、定时器、UART等模块初始化,包含中断触发采样、数据滤波与波形上传的完整逻辑,并保留了调试中间文件和编译脚本,方便从底层外设到应用逻辑逐段对照阅读。通过这个Demo,读者既能对照源码掌握HAL/LL库下的外设配置思路,也能理解采样率与定时器触发的关系,进而扩展多通道采集、OLED显示或上位机绘图界面,适合作为示波器项目起步参考。

1. 一块C8T6小蓝板能当示波器用:从片内ADC到软硬分工

第一次听说有人拿STM32F103C8T6这块小蓝板当示波器用的时候,我第一反应是噱头:片内ADC理论采样率也就1MSPS左右,还要跟DMA搬运、显示刷新、触发判断抢CPU,软件上稍不留神,波形图就变成一团乱麻。但实际把demo的资源扒下来跑了一遍之后,我发现它给的不是“替代台式示波器”的幻想,而是一套能看清几十kHz以内信号轮廓的调试工具:看PWM占空比、查传感器输出毛刺、确认串口波特率对不对,这些场景它都能顶上。适合两类人:一类是刚入门、手里只有STM32最小系统板的学生,另一类是常年坐在工位前、嫌台式示波器搬起来不方便的一线工程师。前提是你把ADC、DMA、定时器这三件套的配合关系想清楚。


2. 采样架构:ADC+DMA+TIM怎么配合才不丢点

2.1 为什么片内ADC够用,瓶颈不在采样率

很多第一次接触这个demo的人会下意识拿它跟台式示波器比带宽,觉得1MSPS采样率连看个1MHz正弦都勉强。但实际要抓的是低频调试信号,比如电机PWM的占空比、心率传感器输出的几百赫兹脉搏波、I2C总线上的时序变化,这些信号的频率普遍在几十kHz以下,1MSPS理论采样率对应每个周期几十个采样点,画出来已经足够判断波形形状和幅值。

真正需要关心的是F103片内ADC的一些物理限制。F103的ADC是逐次逼近型(SAR),有一个采样保持电容,采样时间从1.5个ADC时钟周期到239.5个周期可选。ADC时钟最高只能到14MHz,实测一般配置在12MHz。如果信号源输出阻抗偏高,比如直接用10kΩ电位器分压出来的信号,采样时间又选得特别短,采样电容没充满电就开始转换,采出来的电压会偏向上一拍的值,出现“记忆效应”,波形上的表现就是台阶感和非线性畸变。这个问题后面第5章会专门展开。

所以做这个demo的第一个认知要转变:限制这个系统的主要不是采样率,而是信号调理和触发方式。信号源阻抗没处理好,哪怕把采样率压到50kSPS,波形照样是烂的。

2.2 连续转换和定时器触发:波形稳定靠的是后者

F103的ADC可以简单配置成“连续转换模式”,芯片自己以最快速度不停地转,DMA在后头跟着搬数。这种方式实现最简单,跑起来采样率能冲很高,但有一个毛病:每个采样周期什么时候开始是随机的,跟被测信号没有固定相位关系。结果就是你每次在屏幕上看到的波形,起点落在信号周期的不同位置,波形在OLED上左右乱跑,根本没法读数。

demo里处理这个问题用的是定时器触发采样。以TIM2为例,把TIM2的TRGO事件接到ADC的外部触发输入,ADC在TIM2的更新事件上升沿启动一次转换。这样采样时刻由硬件定时器严格决定,采样率就是TIM2的溢出频率,和主循环的软件延时没有任何关系。采样间隔恒定之后,再用软件触发去锁定窗口起点,画面才能稳定下来。

选择定时器触发而不是外部引脚触发,还有一个好处是频率可以精细调节。TIM2挂在72MHz的APB1总线上,PSC和ARR都是16位寄存器,采样率可以从每秒几百次到每秒几十万次连续调节,调参非常灵活。如果你用外部方波触发,采样率就绑定在被测信号上了,反而不好控制。

2.3 DMA双缓冲:谁在什么时间动数据

ADC每转完一次会产生一个12位数据,放在数据寄存器里。如果靠CPU一个个读,100kSPS的采样率意味着每10微秒就得进一次中断或轮询一次,CPU被吃得很死,OLED刷新和触发判断全都被拖垮。所以必须让DMA来搬运。

这里有个关键问题:DMA一直在往缓冲区里写,CPU也一直要读缓冲区去画波形,两边同时碰同一块内存,画出来的画面可能会“撕裂”——上半屏是这一帧的数据,下半屏是上一帧的数据。解决方式是双缓冲:把缓冲区切分成两半,DMA写A半区的时候,CPU只读B半区;DMA写满B半区的时候,CPU再回过头读A半区。两边交错工作,谁也不会踩到谁的脚。

在HAL库里,DMA的半传输完成中断和传输完成中断刚好对应缓冲区前一半和后一半的切换点,所以双缓冲逻辑可以借助这两个回调来实现,不需要用定时器去猜DMA写到哪了。


3. 工程搭建:CubeMX初始化、双缓冲代码与采样率标定

3.1 关键外设参数表与CubeMX配置

整个工程用CubeMX先生成底层代码,再手动补双缓冲逻辑和采样率标定,这个流程最省事,也最容易复现。下面是我在demo原始工程里整理出来的关键参数,和CubeMX里的配置保持一致。

外设关键配置项参数值
ADC1分辨率12位
ADC1扫描模式单通道(关闭扫描)
ADC1连续转换使能
ADC1外部触发源TIM2 TRGO
ADC1触发极性上升沿
ADC1采样时间7.5个ADC时钟周期
ADC1ADC时钟分频PCLK2 6分频,约12MHz
DMA模式Circular循环
DMA数据宽度外设和内存均为HalfWord(16位)
TIM2PSC / ARRPSC=0,ARR=719
USART1波特率921600

TIM2的PSC和ARR决定了采样率。TIM2挂载时钟72MHz,输出频率计算公式是:

采样率 = 72MHz / (PSC + 1) / (ARR + 1)

PSC=0、ARR=719时,采样率正好是100kSPS。如果你要存更久的波形,把ARR调大,采样率跟着降;想看得更细,就把ARR调小,但要记住ADC转换速度跟不上的时候,触发间隔太短会导致转换结果无效。

ADC时钟分频这里容易配错。F103的ADC时钟来自APB2总线,即PCLK2,默认72MHz。如果分频系数选4,ADC时钟变成18MHz,超出14MHz的上限,长期跑会出问题。选6分频得到12MHz,比较稳妥。

3.2 双缓冲核心代码:半满中断切换安全区

CubeMX生成的工程里,ADC的DMA配置已经初始化好了,但默认只有一个缓冲区。要改成双缓冲,需要定义一个2倍长度的数组,并在DMA回调里切换读写指针的标志位。以1024点为一帧,总缓冲区长度就是2048点。

#define SAMPLE_PER_BUF 1024 static uint16_t adc_buf[SAMPLE_PER_BUF * 2]; /* 当前DMA正在写入的半区:0代表前半区,1代表后半区 */ static volatile uint8_t dma_writing_half = 0; void HAL_ADC_ConvHalfCpltCallback(ADC_HandleTypeDef *hadc) { if (hadc->Instance == ADC1) { /* DMA写满前半区,当前正写后半区 */ dma_writing_half = 1; } } void HAL_ADC_ConvCpltCallback(ADC_HandleTypeDef *hadc) { if (hadc->Instance == ADC1) { /* DMA写满后半区,当前正写前半区 */ dma_writing_half = 0; } }

这个逻辑要反着看:DMA写满前半区时,中断触发,此时DMA已经转到后半区继续写,所以可读的安全区是后半区,标志位置为1。主循环读取时判断标志位,永远读“当前没有被DMA写的那一半”。

uint16_t *get_readable_buf(uint16_t *out_len) { if (dma_writing_half == 0) { /* DMA正在写前半区,后半区可读 */ *out_len = SAMPLE_PER_BUF; return &adc_buf[SAMPLE_PER_BUF]; } else { /* DMA正在写后半区,前半区可读 */ *out_len = SAMPLE_PER_BUF; return &adc_buf[0]; } }

读取侧返回的是指针而不是拷贝数据,好处是零拷贝,主循环拿到的直接是内存里的采样数组。坏处是如果你在读取过程中恰好DMA完成了后半区的写入,回调把标志位翻回来,你手上这个指针的指向区域就变成了正在被写的区域。对于显示波形这种可以容忍一两帧抖动的情景,这个问题不大;如果要做精确的FFT分析,就需要加临界区保护,或者干脆在回调里把数据拷到独立的应用缓冲区。

dma_writing_half本身是volatile修饰的,已经暗示了它会被中断上下文修改。其实HAL库的ADC DMA处理有两种模式:一种是我们这种Circular循环模式,另一种是Normal模式配合HAL_ADC_Start_DMA重新启动。demo选Circular,因为它不用每次转换完都停一下DMA,采样是连续不断的。

3.3 采样率标定:不要相信纸面计算

PSC=0、ARR=719算出来是100kSPS,但实际跑起来不一定就是100kSPS。原因有两点:TIM2挂在APB1上,如果APB1分频不是1,定时器时钟可能是72MHz的一半,也就是36MHz,那实际采样率就是50kSPS;另外ADC的触发信号经过内部同步电路会有一点点延迟,但影响不大。

标定采样率有个土办法:用芯片上的另一个定时器输出一个已知频率的方波,比如TIM3产生1kHz方波,接到ADC输入引脚上。然后抓一帧数据,数相邻两个上升沿之间隔了多少个采样点:

采样率 = 方波频率 × 相邻上升沿间隔点数

如果1kHz方波两个上升沿之间隔了100个点,采样率就是100kSPS;如果隔了50个点,采样率就是50kSPS。这个方法不需要额外仪器,只要在代码里加一段简单的过阈值统计就行。我在复现这个demo时发现,实际采样率比计算值低的时候,多半是APB1分频器被CubeMX悄悄改成了2。


4. 显示端落地:OLED直绘还是串口上位机,按用途选

4.1 OLED直绘:看轮廓,128列怎么装下1024个点

OLED方案用的是常见的128x64分辨率、SSD1306驱动、I2C接口的小屏。直接画波形时有个矛盾:采集缓冲区1024个点,OLED横向只有128个像素列,不可能一个点对应一列。常见做法是压缩显示——把1024个点按8个一组分成128段,每段只取最小值和最大值,在对应列上从最小值画到最大值,形成一条“竖线”。这个竖线画法能保留波形在这个时间段内的包络,毛刺和过冲都能看出来,比单纯抽一个点画折线要真实得多。

static uint8_t oled_buffer[128][8]; /* 显存,每个字节对应一列8个像素 */ void oled_draw_waveform(uint16_t *samples, uint16_t len) { uint16_t points_per_col = len / 128; for (uint16_t col = 0; col < 128; col++) { uint16_t min_v = 4095, max_v = 0; for (uint16_t i = 0; i < points_per_col; i++) { uint16_t v = samples[col * points_per_col + i]; if (v < min_v) min_v = v; if (v > max_v) max_v = v; } /* 将12位ADC值映射到64行 */ uint8_t y_min = (uint64_t)min_v * 64 / 4096; uint8_t y_max = (uint64_t)max_v * 64 / 4096; for (uint8_t y = y_min; y <= y_max; y++) { oled_buffer[col][y / 8] |= (1 << (y % 8)); } } oled_flush(oled_buffer); /* 把整块显存写入SSD1306 */ }

每列取min/max的做法在低频信号上非常有用。假设你测的是一个占空比很小的PWM,直接等间隔抽样可能抽不到脉冲,但min/max包络必然会把高低电平都囊括进去。

OLED方案的实际瓶颈是刷新率。I2C跑400kHz时,传输整整1KB显存需要大约20多毫秒,加上SSD1306内部的处理开销,一帧画面刷新实测在30到50毫秒之间,也就是每秒20到30帧。看低频信号没问题,看100kHz以上的信号时,画面上能看到的更多是包络而不是真正的波形细节。所以OLED方案适合便携检查和现场调试,不适合精细测量。

4.2 串口上位机:二进制帧格式与Python读图

当你要把采样数据拿回PC上仔细分析时,OLED屏就不够用了。demo里第二套方案是把采样值通过USART1发到电脑,上位机用Python读取并绘图。串口速率设到921600,这是F103在8MHz晶振下比较稳妥的最高波特率档位。

发送端按4字节一帧组织,帧格式如下:

字节偏移内容说明
00xAA帧头1
10x55帧头2
2ADC值低8位12位采样值右对齐
3ADC值高8位采样值高字节

帧头选0xAA 0x55是因为二进制里这两个字节是10101010和01010101,不容易和数据字节混淆。用二进制帧而不是ASCII字符串,能省一半带宽。波特率921600理论上限是每秒大约92160字节,如果按4字节一帧,最多只能传约2.3万点每秒。前面明明采样率是100kSPS,数据根本发不完,所以串口方案必须配合降采样率或者单帧抓取模式使用。我一般把采样率调到20kSPS再开波特率921600,上位机画面才会流畅更新。

Python上位机的读取逻辑很直接:

import serial import numpy as np import matplotlib.pyplot as plt ser = serial.Serial('COM3', 921600, timeout=0.5) N = 1024 samples = np.zeros(N, dtype=np.uint16) idx = 0 while idx < N: frame = ser.read(4) if len(frame) < 4: continue if frame[0] == 0xAA and frame[1] == 0x55: samples[idx] = frame[2] | (frame[3] << 8) idx += 1 plt.plot(samples) plt.ylim(0, 4095) plt.show()

这段代码里每次只读4字节,校验帧头后拼接出16位采样值。串口偶尔丢一个字节是常态,一旦丢字节,后续帧头全部错位。所以上位机要设计成“持续寻找帧头”的模式,而不是硬性按位置解析。上面代码其实已经能自恢复:只要当前4字节里前两位不是0xAA 0x55,就放弃这一帧,下一次循环对齐到下一个可能帧头。

4.3 两套方案的取舍边界与组合用法

对比维度OLED直绘串口上位机
实时性20~30帧/秒取决于波特率和采样率
可移植性便携、不依赖电脑需要USB转串口
数据精度只能看12位原始映射可配合NumPy做FFT分析
适合场景现场调试、低频包络录波、频谱分析、长时间记录

实际使用时两套方案可以并存:OLED用来做现场监视,串口只在需要记录数据时打开。主循环里用同一个采样缓冲区,OLED按20fps刷新,串口按需触发发送,互不干扰。注意串口发送是一个很耗时的阻塞操作,发送1024个点约4KB数据,在921600波特率下要接近50毫秒,这期间千万不要关ADC中断,否则采样会断流。常见做法是把待发送的数据拷贝到独立发送缓冲区,然后让DMA去跑串口,CPU继续处理显示。


5. 调试点与常见问题排查:五个真实翻车现场

5.1 波形在屏幕上左右乱跑,一直没有稳定的画面

第一个翻车现场非常经典。所有采样、DMA、显示代码都正常,OLED上也确实有正弦波,但波形像在跳舞一样左右横移,完全没法读数。

原因是采样起点没有被约束。F103的ADC在连续转换模式下由内部时钟自由运行,每次启动转换的时刻跟被测信号没有任何相位关系,所以每次抓取的1024个点落在信号周期的哪个位置是随机的。解决方法是加软件触发逻辑:在采到的缓冲数组里找阈值跨越点,比如信号垂直中心电平是2048,就从第1个点开始找第一次由低到高穿过2048的位置,把这个位置作为波形显示的起点。有了触发点,波形在屏幕上就固定住了。

uint16_t wave_trigger_index(uint16_t *buf, uint16_t len, uint16_t level) { for (uint16_t i = 1; i < len; i++) { if (buf[i - 1] < level && buf[i] >= level) { return i; } } return 0; /* 未找到触发点 */ }

这个函数每次进入显示流程时都重新找一次触发点,逻辑简单但有效。注意如果信号本身带毛刺,可能在阈值附近来回穿越,需要加一个“触发后锁存”的机制,比如找到一个上升沿后跳过若干个采样点再继续找下一次上升沿。

5.2 采到的PWM电平不是0V和3.3V,而是带斜坡的烂波形

第二个坑是测量PWM输出时,明明信号源是数字PWM,ADC采回来却是一个带斜坡的梯形波,电平也不对。我开始怀疑是信号本身有问题,后来用台式示波器确认源信号完全正常。

问题出在信号源输出阻抗和ADC采样时间上。PWM信号从单片机引脚直接出来还好,但如果你是从一个电位器分压或一个高阻传感器输出的信号,输出阻抗可能高达几十kΩ。F103片内ADC在采样时间1.5个ADC周期时,采样开关导通时间只有约125纳秒(12MHz ADC时钟下),这么短的时间根本不足以给采样电容充满电。解决措施有两步:一是在CubeMX里把采样时间从1.5个周期加长到55.5个周期甚至239.5个周期;二是被测量信号来源是高阻时,在ADC引脚对地并联一个0.1uF电容做电荷池,或者用一个运放搭建电压跟随器做缓冲。我一般优先改采样时间,因为不增加硬件。代价是采样率会被拉低,但测50kHz以下的信号完全够用。

5.3 DMA搬运的数据出现周期性错位,波形像被切碎了一样

这个坑藏得很深,表现是波形每隔一段就出现一次跳变,像是两个波形叠在一起。查了很久,最后发现是DMA的数据宽度配置错了。

CubeMX里DMA配置默认External/ Memory Data Width给的是Byte,也就是8位。但F103的ADC数据寄存器是16位的,DMA按字节搬运时会把一个16位的ADC结果拆成两个8位,分别搬到内存的两个不同位置。缓冲区里奇数位是ADC值的高字节,偶数位是低字节,画面自然乱成一团。

解决很简单:DMA的PeriphDataWidth和MemDataWidth都改成HalfWord,也就是16位。改完之后波形立刻恢复。这个坑在HAL库下特别隐蔽,因为CubeMX不会给你任何警告,DMA也正常工作,只是数据排列全错了。

5.4 串口上位机收不到完整数据,画面越来越慢最后卡死

串口方案跑起来之后,前几百个点很正常,但越到后面越卡,最终上位机停在那里一动不动。用串口调试助手看了原始数据流,发现下位机还在发,但上位机解析出来的有效点越来越稀疏。

原因有两层:一是921600波特率的吞吐量上限大约92KB/s,而100kSPS采样率乘以2字节每点是200KB/s,数据量已经是链路承载能力的两倍,DMA发送缓冲区不断堆积,最终溢出;二是上位机端pyserial的缓冲区也会被撑爆,读取循环陷入等待。

解决方法是把链路流量算清楚再选参数。我最终调到20kSPS采样率,加上帧头每点4字节,总流量80KB/s,勉强在921600波特率以内。如果你坚持用100kSPS采样,就用单帧模式:按下按键后停止采样,抓一帧1024点,再慢慢发完。这样链路压力只是间歇性的,不会持续溢出。

5.5 板子还在跑但OLED不亮了,DMA回调里不能做耗时的活

最后一个坑是我自己作出来的:为了调试方便,我在DMA回调里加了一行printf,结果程序一跑,OLED画面完全卡死,串口也只输出一遍就停住。

原因要算一次中断优先级账。DMA半传输完成中断的优先级如果高于SysTick的中断优先级,而printf和HAL_Delay这类函数内部依赖SysTick来计时和驱动底层串口轮询,那么DMA中断一进来就再也出不去了——SysTick被锁死,所有依赖delay的逻辑全部瘫痪。

规矩是:DMA中断回调里只能做两件事,置标志位和非常快速地拷贝少量数据。任何可能耗时超过几微秒的操作全部移到主循环里。从那以后我每次都会在主循环里用“待处理标志”机制代替以前随手就在中断里干活的习惯。


6. 给自己加一档:片上DAC自检与软件触发窗口

复现完基本功能之后,最值得做的一个升级是给系统加一套自检手段。F103C8T6内置了DAC模块,虽然只有12位精度,但足够生成一个已知频率和幅值的正弦波,用来验证ADC链路是否正常。

DAC输出引脚在PA4,配置成DMA循环输出模式,数据源是一张512点的正弦查表。关键技巧是让DAC也由TIM2触发,跟ADC共用同一个定时器。这样DAC产生的正弦波和ADC的采样时钟是严格同源的,采样结果不会因为两个时钟的微小偏差而产生相位漂移,自检结果更加可信。

uint16_t sine_table[512]; for (int i = 0; i < 512; i++) { sine_table[i] = (uint16_t)(2048 + 2047 * sin(2 * M_PI * i / 512)); } DAC_ChannelConfTypeDef dac_config = {0}; dac_config.DAC_Trigger = DAC_TRIGGER_T2_TRGO; dac_config.DAC_OutputBuffer = DAC_OUTPUTBUFFER_ENABLE; HAL_DAC_ConfigChannel(&hdac, &dac_config, DAC_CHANNEL_1); HAL_DAC_Start_DMA(&hdac, DAC_CHANNEL_1, (uint32_t *)sine_table, 512, DAC_ALIGN_12B_R);

生成正弦表的代码要放在main函数开始处,确保初始化完成前数据就绪。DAC输出频率由TIM2的触发频率除以512决定,例如TIM2触发频率是51.2kHz,DAC输出的正弦波频率就是100Hz。把PA4和PA0用杜邦线短接,OLED上就能看到一条稳定的正弦波。如果波形有任何台阶或畸变,问题一定出在ADC侧,DAC只是参考源,这个闭环自检逻辑非常干净。

软件触发窗口的做法是配合这个自检信号使用的:在采到的窗口里找上升沿,然后把显示起点固定在上升沿之后第N个采样点。这样即使外界干扰导致触发点抖动,画面依然能稳定显示波形。触发电平不要设在信号的正中心,最好设在信号幅值的20%位置,减少噪声来回穿越造成的误触发。

有一次我改动了ADC触发方式,从TIM2上升沿触发改成了下降沿触发,自检正弦波在OLED上完全倒挂,才意识到DAC和ADC必须保持相同的触发极性,不然采到的相位关系就反了。从那以后我每次改完工程都会做一遍“DAC自检-看波形-对频率”三个步骤,宁可多花一分钟,也不带着疑似问题继续往下写代码。这套方法很简单但极其管用,希望帮到你。

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

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

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

立即咨询