做自动采集类项目时,我最早的习惯是ADC采集丢在定时器中断里,每次进中断读一次转换结果。一开始数据量小没觉得有什么问题,后来把采样率提到20kHz以上,CPU占用率直接飙到一半多,主循环里的显示刷新跟着掉帧。后来我把方案改成“定时器TIM触发ADC + DMA搬运”,同样的采样率,CPU占用几乎可以忽略,主循环想干什么干什么。
这篇文章就把这套基于STM32F407 HAL库的定时器触发ADC采集与DMA数据传输方案从头到尾拆一遍,重点放在配置参数怎么推导、HAL库的API怎么调用、实际调试中会遇到哪些坑,以及把这些坑解决掉之后系统到底能跑到什么程度。适合正在调ADC又不想让CPU被采样拖垮的开发者。
1. 为什么是TIM+ADC+DMA:从“软件触发”到“硬件自动流水线”
很多新手拿到开发板,第一反应是在while(1)里轮询HAL_ADC_PollForConversion,或者用定时器中断里做软件触发。这两种方式在演示Demo里没问题,但在实际项目里会有明显瓶颈。
1.1 中断和轮询方案的三个痛点
第一个痛点是CPU被白白占满。以20kHz采样率为例,每50us就要进一次中断,如果中断里只做启动转换和读取结果,那还算轻。可一旦在中断里顺手做了数字滤波、阈值判断、数据拷贝,中断执行时间就会拉长,主循环的实时性迅速恶化。
第二个痛点是采样间隔抖动。软件触发ADC的时机依赖中断响应,而中断响应又受其他中断优先级影响。假设你同时开了串口和SPI中断,它们稍微抢一下CPU,ADC的采样间隔就会漂移,这在做交流信号采集、振动分析时是致命问题。定时器硬件触发则完全不受CPU调度影响,触发脉冲由定时器硬件直接发给ADC外设,间隔精准、确定。
第三个痛点是数据搬运效率低。即使ADC能自己转换,每次结果还是要CPU去寄存器里取出来,再自己搬到内存数组里。DMA出现在这套链路里,就是把这件重复、机械的搬数据工作从CPU手里拿走,交给专门的硬件通道去做。
1.2 三个外设分工:定时器负责“什么时间采”,ADC负责“采什么量”,DMA负责“结果往哪放”
这套方案的本质,是搭建一条完全不依赖CPU干预的硬件流水线,每个外设只负责自己那一个环节。
定时器TIM在配置为PWM输出或更新事件触发模式后,会产生一个周期性的触发信号,硬件上直接连接到ADC的外部触发输入引脚。ADC收到这个触发信号后,自动启动一次转换,转换完成后结果自动锁存到ADC的数据寄存器。DMA通道则在ADC转换完成信号的控制下,把数据寄存器里的值搬到内存数组,搬完一轮后自动重新装载地址,进入下一轮。
整个过程中,CPU只需要在启动前把整条流水线配置好,然后在后台处理已经搬进内存的数据。这就是为什么这套方案能同时满足“采样速率高”“采样时间准”“CPU占用低”三个要求,三个外设各司其职,谁也不会拖累谁。
提示:把ADC当成一个被动的“测量模块”,把定时器当成“节拍器”,把DMA当成“搬运工”,这三个角色想清楚后,配置代码就是水到渠成的事。
2. 配置参数推导:CubeMX里TIM、ADC、DMA到底怎么填
我习惯先在CubeMX里生成工程骨架,再手动调整代码细节。下面以STM32F407、主频168MHz、目标采样率10kHz、单通道ADC1、DMA循环模式为例,把每一步的参数推导过程写在下面。
2.1 定时器部分:根据目标采样率反推PSC和ARR
定时器触发ADC最常用的做法,是让定时器产生更新事件(Update Event),更新事件同时连接到ADC的触发输入。定时器的频率公式是:
定时器触发频率 = 定时器时钟频率 / ((PSC + 1) * (ARR + 1))F407的APB1定时器时钟一般是84MHz,APB2定时器时钟是168MHz。TIM2、TIM3、TIM4、TIM5挂在APB1上,TIM1和TIM8挂在APB2上。这里用TIM2举例,时钟84MHz。
如果目标采样率是10kHz,即触发周期100us,那么:
(PSC + 1) * (ARR + 1) = 84MHz / 10kHz = 8400我通常会让PSC先固定成0,ARR取8399,这样触发精度最高,因为分频段越小,计数器每一步的粒度越细。如果你希望触发频率更低,比如1kHz,可以把PSC设成83、ARR设成839,或者PSC设成0、ARR设成83999。两种组合得到的标称频率一样,但实际抖动特性不同,PSC越小抖动越小。原因在于定时器计数器的每一步代表一个完整时钟周期,分频系数越大,更新事件对齐的时钟粒度越粗。
CubeMX里的配置项:
- TIM2时钟源选择Internal Clock
- Prescaler填0
- Counter Period填8399
- auto-reload preload选择Enable
- trigger output选择Update Event
2.2 ADC部分:采样时间、分辨率与触发源的选择
ADC配置的核心是“外部触发转换源”要选成TIM2 Trigger Out事件,再根据“是否只采一次”决定转换模式。
关键选项和理由如下:
- Resolution:F407的ADC是12位。虽然极少数场景会调到10位、8位去换速度,但我建议一般工程保持12位,精度优先。
- Scan Conversion Mode:只采一个通道时选Disable。
- Continuous Conversion Mode:选Disable。这条很关键,因为这套方案用定时器外部触发,ADC每次转换都是在收到触发脉冲后执行一次,不需要自己连续转。如果你选了Enable,ADC转完一次紧接着又转一次,定时器触发反而没意义。
- External Trigger Conversion Source:选Timer 2 Trigger Out event。
- External Trigger Conversion Edge:触发边沿我一般选Rising Edge,上升沿触发。
- Rank里的Sampling Time:直接决定了单个通道的采样保持时间。
F407的ADC转换时间公式是:
总转换时间 = 采样时间 + 12个ADC时钟周期以12位分辨率约3us为例,如果把采样时间设为15个周期,ADC时钟设为21MHz,那么单次转换时间约为15 + 12 = 27个ADC时钟周期,折算约1.286us。这个时间远小于10kHz要求的100us触发间隔,所以完全够用。
但如果采样时间设成480个周期,单次转换时间就变成480 + 12 = 492个周期,约23us,依然能扛住10kHz。只是要注意采样时间越长,信号源内阻对采样精度的影响越小。驱动源输出阻抗高时,采样时间尽量放宽。
2.3 DMA部分:循环模式还是普通模式,数据宽度怎么配
F407的DMA控制器分为DMA1和DMA2,每个控制器下有8条Stream。ADC1的DMA请求可以映射到DMA2 Stream0或者DMA2 Stream4,具体的映射关系要看芯片参考手册里的DMA request mapping表,CubeMX会在ADC配置里自动列出可选Stream。
配置项里最容易踩坑的几个地方:
- Mode:选Circular。循环模式下DMA传输完一轮缓冲区后会自动回到起点重新开始,不需要CPU干预,这是“后台持续采集”的关键。Normal模式只搬一轮就停,适合单次采集场景,不适用连续监测。
- Data Width:Peripheral选Half Word(半字),Memory选Half Word,方向是PeripheralToMemory。F407的ADC数据寄存器是16位宽度,读写两边宽度不一致会导致DMA传输数据错乱。
- Memory Increment:Enable,DMA每搬一个数据,内存指针自动加一,否则只能反复写同一个地址。
- Peripheral Increment:Disable,ADC寄存器地址固定。
- DMA Request:如果你在ADC配置里看到“DMA Continuous Requests”这个选项,一定要Enable。它的含义是:即使ADC没有连续转换模式,DMA也会在每次ADC转换完成后,持续响应外设发出的数据传输请求。如果这里没有打开,很多工程会出现“只采到第一批数据,之后DMA再也不搬”的怪现象。
数组中元素的类型建议直接用uint16_t,因为ADC的12位结果在16位容器里刚好对齐。定义缓冲区长度时,要根据后续处理逻辑和DMA半完成中断的节奏综合考虑,比如1024、2048个点都是合理范围。
3. HAL库驱动代码:初始化顺序、启动流程与回调机制
CubeMX生成的MX_*_Init函数已经把外设寄存器都配好了,剩下的工作就是按顺序启动外设,以及正确处理DMA的半传输和全传输回调。
3.1 启动顺序:先启动TIM,还是先启动ADC?
项目里最常见的错误是只启动了ADC转换,忘记启动定时器,然后发现数据永远是0。正确的启动顺序是:
HAL_ADC_Start_DMA(&hadc1, (uint32_t*)adc_buf, ADC_BUF_SIZE); HAL_TIM_Base_Start(&htim2);先调用HAL_ADC_Start_DMA,让ADC外设进入等待外部触发的状态,DMA也挂载到ADC上等待着。再启动TIM2,更新事件脉冲才会开始产生并触发ADC转换。
反过来的问题是,如果先启动TIM,ADC还没有进入DMA模式,可能有部分触发脉冲丢失,导致前面几个采样点不是预期的节拍。虽然大多数时候差的几个点可以忽略,但做精密相位分析时最好保持这个顺序。
当你要停止采集时,也不能只停一个:
HAL_TIM_Base_Stop(&htim2); HAL_ADC_Stop_DMA(&hadc1);先停触发源,让ADC不再收到新脉冲,再停DMA,把当前数据传输安全收尾。
3.2 循环DMA下的双缓冲读写:半传输和全传输回调
DMA循环模式的核心优势在于,内存里的缓冲区会被周期性地覆盖写入。如果你等整块缓冲区写满再处理,有可能在DMA写第二轮时,CPU还没来得及读完第一轮,造成数据覆盖。为了避免这个问题,我通常把缓冲区一分为二,前半段和后半段交替处理。
DMA硬件会在传输到缓冲区一半时产生半传输中断,传完一整轮时产生全传输中断。HAL库对应两个回调函数:
void HAL_ADC_ConvHalfCpltCallback(ADC_HandleTypeDef *hadc) { if (hadc->Instance == ADC1) { process_flag[0] = 1; } } void HAL_ADC_ConvCpltCallback(ADC_HandleTypeDef *hadc) { if (hadc->Instance == ADC1) { process_flag[1] = 1; } }在主循环里检查process_flag,为1时处理对应的半段,处理完清零。因为CPU处理数据的速度远快于10kHz采样速度,所以不用担心半段数据还没读完DMA又写回来。
如果你不想用回调+标志位这种写法,也可以直接在回调里启动一轮FFT计算或者把数据拷贝到另一个环形缓冲区,但要注意回调本质是中断上下文,不要在里面做printf、延时这类耗时操作。我的习惯是回调里只置标志位,数据运算全部放主循环。
3.3 通过DMA向串口发数据时,别和ADC抢Stream
相关的热词里有一条“STM32串口HAL库使用DMA发送数据不能连续发送”,这里面有很大一部分原因是DMA Stream冲突配置错误。F407的串口DMA通道和ADC DMA通道可能映射到同一个DMA控制器的不同Stream,如果只用CubeMX默认配置,两个外设可能会被分到同一Stream上的不同请求,运行起来就互相打断。
解决办法是,在CubeMX里打开DMA请求列表,把ADC分配到一个Stream,USART,例如分配到一个Stream,确保各用各的Stream。DMA1和DMA2是两套独立控制器,能把串口和ADC分开挂在两个控制器上,那抗干扰能力最好。
另外,“DMA发送不能连续发送”还有一个常见原因:串口DMA传输完成后,需要等待传输完成标志清掉,或者传输完成中断HAL_UART_TxCpltCallback里没有做状态复位。我习惯在串口DMA发送启动后,用一次HAL_UART_StateTypeDef判断状态,避免上次数据还没发完,下一次HAL_UART_Transmit_DMA又把当前传输覆盖掉。
4. 调试过程中踩过的坑:完整排查链路
我在这套方案上几乎把所有能踩的坑都踩了一遍,下面按排查顺序写出来,遇到问题时可以照着这个链路一步步定位。
4.1 坑一:DMA只搬运一次,之后数据全部卡住
这个问题的典型现象是,上电后第一次缓冲区确实有数据,但后面缓冲区内容停滞不动。我会先做下面几步排查:
第一步,看hadc1->DMA_Handle->State的状态值,如果是HAL_DMA_STATE_READY,说明DMA已经处于停止状态,根本没有等待下一次传输。
第二步,确认ADC配置里的“DMA Continuous Requests”是不是Enable。这是我遇到最多的情况,CubeMX默认经常是Disable,一旦关闭,ADC虽然会持续触发持续转换,但它向DMA发出的请求只在第一次有效,之后DMA不再响应。
第三步,确认DMA的Mode是不是Circular。如果误设成Normal,DMA自己传完一轮就休息了,不会自动重置起始地址。
4.2 坑二:数据有值但是全部是0x0000或0x0FFF
这个现象说明ADC确实在转换,DMA也在搬运,但采集到的数字不对。优先查DMA的数据宽度是否配置为Half Word。如果配成了Byte或者Word,16位的数据在不同字节序下会错位,读出的大概率是0x0000或者高8位丢失。
还要检查启动函数里有没有正确传入缓冲区地址。注意HAL_ADC_Start_DMA的第二个参数是uint32_t*类型,但我们的数组是uint16_t。我在工程里会把数组定义成__IO uint16_t adc_buf[1024];然后用(uint32_t*)adc_buf强制转换。这里需要确保缓冲区地址是4字节对齐的,多数ARMCC和GCC编译器默认会把数组对齐到4字节,所以通常没问题。但如果用了#pragma pack或者自定义了字节对齐属性,就要手动检查__ALIGN_BEGIN。
4.3 坑三:多个通道交错时数据顺序错乱
开启多通道采样时,每个通道的转换顺序由Rank顺序决定。DMA每次把转换序列的结果连续搬到缓冲区,缓冲区里会出现形如[ch0, ch1, ch2, ch0, ch1, ch2, ...]的交错格式。
我在做三相电采样时踩过这个坑。当时以为DMA搬完一个周期后缓冲区前三分之一是A相、中间是B相、后面是C相,实际交错存储,取出来的数据整套都是错的。正确做法是先按通道个数拆分,把缓冲区每N个数据看成一组,按索引位置分别累加到对应通道的数组中。比如3通道时,adc_buf[i]对应第0通道,adc_buf[i+1]对应第1通道,adc_buf[i+2]对应第2通道,需要在代码里做一次解交织。
4.4 坑四:采样时间不够导致信号源带动不了ADC
这个问题比较隐蔽,现象是采集直流电压还算准,但采集正弦波时幅度偏小,或者高频信号幅度呈指数衰减。一个很典型的内因是信号源阻抗太高,采样电容在采样时间内没有被充分充电。
F407的ADC输入端实际上是一个电容采样保持电路。采样开关闭合后,外部信号源通过源阻抗给采样电容充电,充电时间常数是源阻抗和采样电容的乘积。源阻抗越高,需要的RC建立时间就越长。我遇到过有些分压采集电路里分压电阻用的是100k级别,采集结果明显偏低。后来把分压电阻改成10k,并在ADC引脚加了一个0.1uF的电容,结果立刻正常。这个0.1uF电容在两个层面起作用,一是稳定引脚电压,二是为采样电容提供电荷释。
4.5 坑五:采样值波动大,ADC读数漂移
数据漂移首先检查参考电压。F407的VREF+引脚如果直接接3.3V,那么ADC精度就和电源质量直接挂钩。板子上的3.3V如果纹波大,ADC低位就会跳。热词里提到的“ADC数据漂移”和“电源噪声”基本都是这个原因。
其次是参考热词ADC/DAC电路设计中提到的时钟抖动和电源噪声问题。ADC的采样时钟由内部产生,如果给MCU供电的电源纹波偏高,时钟抖动会被带入ADC转换过程,导致同一电压不同时刻读出的码值不一致。我在实际调试低频信号采集时发现,给MCU供电用线性LDO后,采样数组的跳动明显比DC-DC直供时小。
5. 这套方案的性能边界:采样率能到多高,数据怎么用起来
定时器触发ADC+DMA不仅是“能把数据采回来”这么简单,它在工程上更重要的价值是“可预测”。把整套配置跑通后,我还习惯做几个验证,确认系统确实按预期工作。
5.1 采样率边界:理论极限和实际瓶颈
F407的ADC最大时钟是36MHz,12位分辨率下最小转换时间是15个ADC周期,约等于0.48us。因为本次用的是外部触发,触发脉冲间隔不能小于单次转换时间。换算下来,理论最高采样率约在2MSPS附近。但实际工程很少真的跑这个值,原因有三点:
一是DMA带宽。虽然F407的DMA能跑到很高,但同一DMA控制器上还挂着串口、SPI等外设,数据传输竞争会让ADC的DMA请求等待。二是系统电源和信号源质量,高速采样时采样电容充放电更快,对外围电路的稳定度要求更高。三是后续处理能力,即使ADC能采到2M个点每秒,CPU如果每采完一轮就要做FFT或者滤波,处理时间可能远超采集时间。
所以我的工程经验是:普通信号监测用10kHz~100kHz采样率,波形分析用100kHz~500kHz,再往上就要评估DMA控制器的负载和数据处理的实时性。
5.2 数据使用案例:用采集到的波形数频率和有效值
以50Hz交流信号采样为例,采样率设成10kHz,每个工频周期能采到200个点,缓冲区设置为1024个点的话,一次完整缓冲能覆盖约5个周波。我在主循环里对缓冲区做这样的处理:
- 找到最大值和最小值,差值就是峰峰值,有效值按正弦波峰值的0.707倍估算。
- 对相邻数据做符号判断,出现正到负的过零点算半个周期,两个连续过零点的间隔乘以2就是周期,倒数是频率。
- 逐点累加平方和再开根号,算RMS,这个值比峰峰值估算更准。
这套处理方法不复杂,但能直接验证ADC链路是否稳定。如果频率值在50Hz左右抖动不超过0.1Hz,说明定时器触发精度没问题。如果抖动明显,就要回头检查定时器分频配置或者DMA搬运是否出现了丢点。
5.3 让系统跑得更稳的PCB布局建议
热词里有一条“ADC/DAC电路设计:规避时钟抖动与电源噪声的3个PCB布局要点”,这一点很有意思。如果你是自己画板子,而不仅仅是拿开发板调代码,有三个地方最容易影响ADC性能。
第一,VREF+和VDDA必须独立走线,不要和数字VDD共用一根细走线。ADC的参考电压直接决定转换结果的绝对精度,参考电源一旦叠加了数字开关噪声,读数值就会周期性漂移。第二,晶体振荡器位置要离ADC引脚远一些,时钟信号走线不能贴着ADC的模拟输入走线,否则时钟跳变沿会通过PCB耦合进采样信号。第三,ADC模拟输入端口的RC滤波电阻和电容要靠近MCU引脚放置,让信号进入MCU之前先完成带宽限制,避免高频噪声混入采样保持电路。
这些属于硬件层的“最后一公里”,代码写得再漂亮,布局不合理,ADC数据照样飘。为了改善软件和硬件的配合,我在实际PCB布局完成后,会用最小系统板对比测试同一段代码,确认数据质量差异到底来源于代码还是来源于板子。
6. 延伸:采样率可变、多通道和调试输出技巧
整套链路跑通之后,在工程里还会遇到一些锦上添花的需求,比如运行中动态改采样率、实时把数据显示到OLED上、以及把浮点数通过串口打印出来辅助调试。这几个点看着小,处理不好也会卡人。
6.1 运行中动态调整采样率
定时器触发频率由PSC和ARR共同决定。在代码运行中修改采样率,最优雅的方式是直接改定时器的自动重载值:
__HAL_TIM_SET_AUTORELOAD(&htim2, 839);改成839后,触发频率从10kHz变成100kHz。要注意修改自动重载值后,最好重新调用一次HAL_TIM_Base_Stop和HAL_TIM_Base_Start,让计数器重新对齐,避免第一次触发间隔异常导致缓冲区的第一个点时间偏移。动态改采样率的典型场景是示波器类仪器的时基切换,前几ms的数据可能是异常间隔,若要求严苛,清空缓冲区后再启动采集。
6.2 用OLED显示实时值,别把显示逻辑写进中断
如果你用OLED做数据展示,最稳妥的做法是把显示函数放在主循环里,显示内容由ADC采集结果动态刷新。OLED屏这种设备速度慢,用SPI刷一帧也要几十毫秒,塞进DMA回调等于堵住整个数据通路。
我碰到过有人在ADC转换完成回调里直接调OLED_ShowNum,结果一旦刷新慢,DMA缓冲区被反复覆盖,采集数据出现周期性的坏点。后来把回调里的显示调用全部挪到主循环,只保留标志位传递,问题立刻消失。还是那个原则:中断里只做最轻量的事情,数据处理和显示全部放到主循环。
6.3 打印浮点数需要手动开启FPU printf重定向
调试时想直接printf("temp = %.2f\r\n", temp)看温度值,会发现在F407默认工程里,浮点输出被裁剪精简库过滤掉了,打印结果是空或者乱码。这是因为标准C库的窄字符浮点输出函数默认没有被链接进来。
有两种解决办法。第一种是使用MDK的MicroLIB,在魔术棒Target标签页勾选Use MicroLIB,这样printf对浮点的支持会开启。第二种是在AC6或GCC环境下配置-u _printf_float链接选项,把浮点输出函数强拉进链接。这件事和热词里的“STM32F407 FPU开启”还有点关联:只开FPU硬件浮点单元不配置printf第三方函数,依然打不出浮点数,两者必须搭配着搞。
代码层面,我一般直接用snprintf把浮点格式化成字符串,再用DMA发出去,避免printf阻塞和数据冲突。
6.4 缓冲区分块思路可以扩展到更多场景
这套“DMA循环搬运+半满/全满回调分块处理”的思路不仅仅适用于ADC采集。串口接收不定长数据、SPI采集传感器数据、外置ADC芯片像ADS1256这类通过SPI读数据时,都可以用同样的逻辑:DMA循环搬运,半满回调处理前半块,全满回调处理后半块,主循环只消费已就绪的数据块。
我在实际工程里把串口接收也改成了这种方式,接收缓冲区设定为256字节,每收到128字节触发一次半满处理,收到256字节再触发一次全满处理。相比逐字节中断接收,主循环的调度压力大幅下降,而且处理逻辑非常统一,不需要维护复杂的接收状态机。
注意:DMA循环模式下,如果你不清buffer计数,老数据会被新数据覆盖。设计时需要保证“切换块”的速度大于“填满一块”的速度。简化判断方式:一个块的数据在下一轮DMA覆盖之前必须被处理完,否则就要增加缓冲区长度或者降低采样率。
写在最后:定时器、ADC和DMA三者的配合,核心是“让硬件干它擅长的事”
这套方案跑通后,我再回过去看当初的中断触发方案,最大的感慨是:STM32的外设其实都提供了很成熟的硬件联动机制,定时器触发ADC、ADC触达DMA、DMA搬运数据,每一步都有对应的硬件信号。我们要做的只是把这些信号接好,然后让CPU从这些重复劳动里解放出来,去处理真正需要逻辑判断的事情。
调试这套链路的时候,日志输出是最关键的帮手。我会在定时器中断里临时加一个计数器,或者用GPIO翻转测一下采样节奏是否稳定,用示波器观察对应引脚波形的周期变化,从波形上马上就能看出PSC、ARR配置是否正确。软件上打印缓冲区的首个值和最后几个值,也能快速判断DMA是否出现丢点。
我的建议是,先把单通道的定时器触发链路跑通,数据稳定后再扩展多通道和动态采样率。每加一个功能前,先确认前一步的验证数据没有异常,这样整个工程的可维护性会好很多。如果你正好在调试类似方案,照着上面几个坑排查一遍,大概率能省下一整天。