定时器+DMA硬件触发ADC采样:彻底解决采样时序抖动与CPU占用问题

发布时间:2026/10/24 2:51:34
定时器+DMA硬件触发ADC采样:彻底解决采样时序抖动与CPU占用问题

1. 为什么我会觉得定时器 + DMA 同步采样这套方案值得专门写一篇

先讲个自己踩过坑的事。几年前我配合某传感器模块做信号采集,模拟前端运放接ADC引脚,硬件上很简单,程序上我一开始也偷懒:在定时器中断里启动ADC、等待转换结束标志、再读寄存器。当时的想法是"也就1kHz采样率,中断里随便折腾都来得及"。结果数据出来我人都傻了——波形整体长得像那么回事,可每一个采样点的时间间隔忽长忽短,在电脑上连点画线,那个间距就像心电图早搏一样,乱七八糟。

后来想明白了,问题不在传感器,也不在ADC,而是在"软件介入采样时序"这件事本身。中断确实在应该发生的时刻触发,但CPU拿到中断、入栈、跳转到中断服务函数、写着启动ADC的代码,这个过程中间任何一个高优先级中断插进来,或者刚好碰上临界区关中断,采样间隔就偏了。一次偏几百纳秒、一两微秒,单看都不起眼,但后面我要拿这些数据和激励信号做互相关,频谱上多了不该有的杂散分量,相位计算干脆直接报废。

所以我把方案改成定时器输出触发信号直接接到ADC的触发引脚、ADC转出来的结果由DMA自动搬进内存,CPU从头到尾不碰采样时刻、也不碰原始数据搬运。这个方案在电机控制、开关电源、振动监测这些对时序有硬性要求的领域早就用烂了,但做普通传感器采集的人很多还停留在"定时器采集中断里读ADC"的老路子上。这篇就从原理、参数计算、代码骨架到排查经验一次性倒出来,照着配基本能跑通。

2. 硬件链路拆解:定时器怎么"指挥"ADC,DMA又怎么"搬走"数据

2.1 软件触发和硬件触发的本质区别

大多数ADC默认的启动方式是软件触发,说穿了就是往某个寄存器的位置上写1,ADC外设收到这个命令开始转换。这在单次测量场景下非常自然,但连续等间隔采样就露怯了,因为"写1"这个动作的时机完全取决于CPU执行到哪一行代码,中断嵌套、总线仲裁、甚至Flash等待周期都会影响实际执行时刻。

硬件触发就完全不一样。ADC模块上有一根触发输入线,可以通过内部交叉矩阵或外部事件连接器,接到定时器的事件输出上。定时器计数溢出时,硬件逻辑直接给这根线发出一根脉冲,ADC收到脉冲就自己开始转换,全程不需要CPU执行任何指令。只要定时器的分频系数和自动重载值配置正确,采样间隔就是硬件级精确的,抖动只来自晶振本身的稳定度,微乎其微。

2.2 定时器的主模式输出——TRGO信号

要实现硬件触发,先要弄清楚"定时器以什么方式把事件送出去"。大多数32位MCU的定时器都带主模式输出TRGO,通过配置主模式选择位,可以从更新事件、比较通道输出、使能信号等里面挑一种,送到TRGO引脚或内部线路上。

最常用的触发信号是更新事件。更新事件就是计数器从0数到自动重载值、溢出归零那一刻产生的。配置这一路最简单,定时器溢出一次,ADC被触发一次,采样周期就等于定时器溢出周期。但如果你的系统里同一个定时器还要输出PWM激励信号,而且要求采样点精确落在激励波形的某个相位上,仅靠更新事件就不够灵活了,需要用比较通道触发。

2.3 ADC的触发源选择和连续转换模式的取舍

让ADC响应外部触发,要完成两件事:选择触发源,使能外部触发沿。触发源一般映射到某个定时器的TRGO,触发边沿选上升沿或下降沿,然后根据需求决定是否打开连续转换模式。

这里有个关键坑:做定时器硬件触发时,推荐关闭连续转换模式。连续转换模式下ADC自己转个不停,完全无视外部节奏,触发脉冲来的时候ADC可能正在转换,于是这次触发被忽略或排到后面,采样间隔就乱了。我们要的是"一个脉冲来,转一次,转完停下来等下一个脉冲",这个"一个脉冲转一次"的从模式,在大多数ADC外设里叫单次转换模式,配合外部触发用刚刚好。

2.4 DMA在这里扮演的角色

ADC转完的结果放在数据寄存器里,数据寄存器通常只有一个(或者按通道有几个,但总共也就那么几个)。如果不及时取走,下一次转换结果一来,旧数据就被覆盖。以前的做法是转换完成中断里读取,可这又把CPU拖回来了。

正确的玩法是用DMA。ADC完成一次转换后会自己产生一个DMA请求,DMA控制器就把数据寄存器里的值搬到内存缓冲区,搬完不用CPU管。设置成循环模式后,DMA搬完最后一笔会自动回到缓冲区开头继续搬,周而复始。CPU平时该干嘛干嘛,只有缓冲区搬完一半或者一整帧时,DMA会发中断提醒CPU"该处理数据了"。

3. 参数计算与关键配置:从采样率到代码骨架

3.1 触发频率怎么算——Precale、Period和采样率

一个常见的定时器输入时钟是72MHz。设置预分频值为71,计数时钟变为72MHz/(71+1)=1MHz,也就是计数器每1微秒加1。再把自动重载值设为999,计数器从0计数到999,总共1000个脉冲,每1000微秒溢出一次,也就是1kHz。触发ADC的频率就是1kHz,采样间隔严格1毫秒。

通用公式很简单:

触发频率 = 定时器时钟频率 / (预分频值 + 1) / (自动重载值 + 1)

反过来已知目标采样率也可以反推。我的习惯是先把预分频值尽量取大、让自动重载值落在比较好记的数上,这样后续代码注释和调试都清爽,而且就算采样率要改,只改重载值一个参数就行。

3.2 别忘了ADC自身的转换时间约束

ADC不是瞬间完成的。内部有采样电容充电阶段和逐次逼近比较阶段,这个耗时是固定的几个ADC时钟周期。比如某类MCU的ADC时钟上限约14MHz,最短采样周期1.5个周期加上转换本身12.5个周期,一次转换最少约14个ADC时钟,最快也就1MHz出头。

如果定时器触发频率设得太高,上一个转换还没结束下一个触发就来了,ADC就会丢弃触发或数据错乱。我调试时一定给定时器周期留至少20%余量,尤其当传感器输出阻抗偏高、需要加长ADC采样时间换取精度时,实际采样率会远低于ADC理论上限。先算清楚再配寄存器,别等数据乱了再回来调。

3.3 采样时间与传感器输出阻抗的关系

说到ADC采样时间,必须提醒一下做传感器采集的人。ADC的采样电容在极短时间内从信号源取电荷,如果信号源输出阻抗太高,采样电容没充满就开始转换,结果自然偏低。

经验法则是采样时间至少达到信号源内阻与采样电容时间常数的5倍以上。更直观的验证方法:拿一个精密电位器从参考电压分压,把分压点接到ADC引脚,然后用万用表读数对比ADC采样值。如果差了挺多,优先加大ADC采样时间,再检查信号通路上的阻抗。

3.4 一份可以直接抄的配置骨架

用某个常见MCU的HAL库做例子。定时器部分:

TIM_HandleTypeDef htim2; htim2.Instance = TIM2; htim2.Init.Prescaler = 71; // 72MHz -> 1MHz 计数时钟 htim2.Init.Period = 999; // 触发频率 1kHz HAL_TIM_Base_Init(&htim2); TIM_MasterConfigTypeDef masterCfg = {0}; masterCfg.MasterOutputTrigger = TIM_TRGO_UPDATE; // 更新事件映射到TRGO HAL_TIMEx_MasterConfigSynchronization(&htim2, &masterCfg);

ADC部分:

hadc1.Instance = ADC1; hadc1.Init.ExternalTrigConv = ADC_EXTERNALTRIGCONV_T2_TRGO; hadc1.Init.ExternalTrigConvEdge = ADC_EXTERNALTRIGCONVEDGE_RISING; hadc1.Init.ContinuousConvMode = DISABLE; // 关闭连续转换,脉冲触发一次转一次 HAL_ADC_Init(&hadc1);

DMA部分:

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.PeriphDataAlignment = DMA_PDATAALIGN_HALFWORD; hdma_adc1.Init.MemDataAlignment = DMA_MDATAALIGN_HALFWORD; hdma_adc1.Init.Mode = DMA_CIRCULAR; // 循环搬运 HAL_DMA_Init(&hdma_adc1);

启动顺序有讲究:

__HAL_LINKDMA(&hadc1, DMA_Handle, hdma_adc1); HAL_ADC_Start_DMA(&hadc1, (uint32_t *)adc_buffer, ADC_BUF_SIZE); HAL_TIM_Base_Start(&htim2);

先启动DMA和ADC,最后启动定时器。反过来操作的话,定时器一跑起来就开始发触发脉冲,此时ADC还没进入DMA就绪状态,开头几次采样会丢,而且这种丢还不好排查。

4. 传感器激励与ADC采样同步:从"同频"到"相位可控"

4.1 为什么激励信号和采样要绑在同一个定时器上

很多传感器不是被动输出信号就完了,得先有激励。比如电化学类传感器要施加方波电压再测电流响应,应变式传感器要给电桥供电,某些电容式传感器要周期性充放电后再采样。如果激励信号靠一个PWM定时器输出,采样靠另一个定时器,就算两个定时器初始相位对齐,也会因为时钟源配置不一致、中断延迟等因素逐渐漂移,甚至出现"这次采样落在激励高电平中间、下次落在低电平中间"的错乱现象。

把激励PWM和ADC触发绑在同一个定时器上,就一劳永逸了。定时器溢出周期同时决定激励频率和采样频率,PWM输出和TRGO触发脉冲的相位关系永远是固定的,不需要软件校准。

4.2 采样点延时:比较通道触发解决"想晚点采样"的需求

最简单的同步是更新事件触发ADC,采样点落在计数器归零的瞬间,此时PWM周期也刚好翻转。但很多场景并不想让ADC在激励跳变的瞬间立刻采样,比如方波刚切换时会有充放电尖峰,得等200微秒让瞬态稳定再采。

这时候更新事件就不够用了。更好的方案是用比较通道触发ADC:让定时器开两个通道,通道1负责PWM输出激励波形,通道2只配置一个比较值,计数到这个值时通道2的比较输出产生一个脉冲,把TRGO信号源从"更新事件"改成这个比较通道的事件。这样只要修改通道2的比较值,就能精确控制采样点落在激励波形上任何一个位置,延迟多少微秒完全由计数值说了算。

有个细节我调试时栽过一次:PWM模式里面通道的输出极性和比较事件脉冲的实际边沿,不同芯片内部逻辑有细微差异。我一开始按上升沿触发配,结果死活采不到数,改成下降沿就正常了。遇到类似情况别硬试,先把TRGO信号引到空余引脚上,示波器看看真实的脉冲形状,再决定触发边沿。

4.3 多ADC同步:一个定时器触发多路采集

如果有两个ADC模块,比如同时采集电压和电流,可以让ADC1和ADC2都选用同一个定时器的TRGO做外部触发源。此时两路ADC的采集时刻是严格同时的,因为触发脉冲同时到达两个模块的触发输入端。再给每路ADC配置各自的DMA通道,数据就能分别搬到两个缓冲区里。

这在电机控制里的意义很大:三相电流采样要求三路数据基本在同一时刻采到,如果按照"先采A相、再采B相、再采C相"的软件顺序,相与相之间相差几微秒,电流相位计算就会失真。用多ADC同步触发,三路电流的相位基准就完全一致了。需要注意的是,多路ADC同步采集时,每个ADC的采样时间设置要一致,否则各路信号到达时刻虽然一样,但各自的采样窗口宽度不同,带宽特性就不同了。

5. 双缓冲:用"半传输中断"实现一边采样一边算

5.1 为什么单缓冲区不够用

DMA循环模式会把数据不断写进同一块缓冲区。如果你在缓冲区写满时才处理,数据本身没问题,但"这一帧数据"的时间范围很可能横跨了很久:开头是几百毫秒前的,结尾是刚采到的,实时性很差,而且如果中间某段时间数据没来得及处理就被覆盖,丢失的部分还不好察觉。

解决办法是加一块同样大小的缓冲区,让DMA在两块缓冲区之间交替搬运。底层机制其实是DMA的半传输中断和传输完成中断:把DMA传输长度设为N,实际缓冲区开2N个样本。DMA搬到第N个样本时触发半传输中断,代表前一半缓冲区写满了;DMA继续搬到第2N个样本时触发传输完成中断,代表后一半缓冲区写满了。两个中断交替触发,CPU在两个回调里分别标记"前半可用"和"后半可用",主循环发现标记后就可以处理对应半块数据。

5.2 回调函数里该干什么、不该干什么

回调函数最忌讳的是在里面做重量级操作。FFT、浮点运算、打印日志、delay之类统统不能放。这些操作会拖住中断处理时间,一旦超过DMA搬运半块缓冲所需的时间,数据覆盖就来了。

正确做法是回调里只翻转标志位、记录时间戳、或者把指向缓冲区的指针做一个交接,实打实的数据处理放在主循环或RTOS任务里。如果主循环忙不过来,宁可丢掉来不及处理的那一帧,也绝不能让采样链路阻塞。这个"丢帧保实时"的思路,在电机控制和信号采集领域是通用共识。

5.3 双缓冲配置的时间预算

以1kHz采样率、每帧256个样本为例,半块是128个样本,半传输中断每隔128毫秒触发一次。也就是说CPU必须在128毫秒内把上一半数据消耗掉,否则DMA会写回这块缓冲区。看起来128毫秒很宽裕,但如果系统里还有别的任务抢占CPU,比如屏幕刷新、文件系统写入,这个预算转眼就没了。所以我做这类项目时会给采样任务留一个较高的优先级,并且建议在进入其他耗时任务前先主动把采样缓冲区的数据快照一份。

6. 实测中出现过的问题与排查实录

6.1 现象一:DMA只搬了一次就再也没动静

这种情况非常典型。首先怀疑DMA没有配置成循环模式,如果设置成正常模式,DMA搬完设定的长度后自动停止,不会再响应后续ADC的DMA请求。其次检查ADC的DMA请求是否真的被使能,有些MCU需要单独设置一个DMA请求位,只配置DMA外设本身是不够的。调试时最直接的办法是看DMA的当前剩余传输计数器,如果数值一直不变,说明根本没在搬运。

6.2 现象二:采到的数据全是0或者全是满量程

先查触发边沿。定时器TRGO输出的脉冲极性,不同芯片内部处理可能会有差异,我把上升沿改成下降沿就好了好几次。再查ADC是否真的处于外部触发模式,如果代码里又调用了软件触发,可能干扰到外部触发的正常流程。

6.3 现象三:波形上出现规律性毛刺,但频率不高

毛刺如果出现在每次DMA中断回调附近,多半是CPU在回调里耗时操作导致总线繁忙,或者中断处理过程中电源纹波增大干扰了模拟部分。我把回调里的一处浮点运算挪到主循环后,毛刺立刻消失了。如果毛刺完全没有规则性,就要怀疑模拟前端和地线问题,给参考电压加滤波电容、把ADC引脚走线缩短、在模拟地和数字地之间做单点汇聚,这些老办法依然管用。

6.4 现象四:多通道扫描模式下数据全错位

多通道扫描时,DMA搬运的数据严格按照ADC扫描顺序排列,不是你代码里遍历缓冲区的顺序。比如配置了通道1和通道3扫描,缓冲区里就是[通道1, 通道3, 通道1, 通道3]这样交替排列。如果代码里按[通道1, 通道2]的顺序解析,自然全错。这种情况下先把通道序列寄存器里的顺序打印出来,再对照调代码,最快就能定位。

6.5 现象五:环形缓冲区读写指针打架导致数据异常

循环模式下DMA不断写缓冲区,主循环不断读缓冲区,如果读的速度跟不上写,又没有做边界检测,就会出现读了半个新数据、半个旧数据的拼接残帧。我的习惯是在缓冲区块首部放一个递增的序号字段,每次处理完一帧顺带校验序号是否连续,不连续就丢弃整帧。这个方法成本低但非常有效,能避免大量隐形脏数据。

7. 最后分享几条实操心得

先说示波器。搞这种时序敏感的采集,示波器比调试器有用得多。把TRGO触发信号引到一个空余引脚上,用示波器看是不是理想脉冲;再在DMA中断回调里翻转另一个测试引脚,看回调处理耗时对采样节奏有没有影响。这两个引脚一出来,整个链路谁在拖后腿一目了然,省得对着代码猜来猜去。

再说代码可读性。HAL库配置这类外设链路时,代码会拉得很长,但核心逻辑其实就几行。建议写注释时把"定时器溢出=ADC触发=结果进缓冲区"这个因果链画出来,半年后回看代码能少死一堆脑细胞。

最后说一句这个方案的扩展空间。很多高端采集方案,核心思路也是"硬件定时保证采样点,DMA保证数据连续,CPU只负责消费数据"。把定时器+DMA+双缓冲这套组合玩熟之后,再去看各种专用采集芯片的寄存器手册,会发现很多设计思想是相通的,配置起来也快很多。如果你也正在被采样时序不准、CPU占用过高这些问题折磨,直接照上面的参数和流程试一轮,大概率能一次性跑通。