做单片机数据采集这几年,我处理过最多的问题不是程序跑飞,而是“采样频率不对”。需求文档里明明写着10kHz采样,代码里也像模像样地配了定时器,可示波器一测,实际采样间隔忽长忽短,波形扫描全是毛刺,FFT频谱底噪直接抬高好几个dB。后来排查了一圈,问题几乎都出在同一个地方:ADC的触发方式选错了。用STM32CubeMX配置定时器触发ADC采样频率,是解决这类问题最理想的路径,也是工业级数据采集里最常见的做法。这篇文章我会从触发原理、CubeMX图形化配置、频率计算到代码实现和踩坑记录,完整过一遍这套方案。全文以最常见的STM32F103C8T6为例,但思路和步骤对STM32整个家族基本通用。
1. 为什么说“定时器触发”是精准采样频率的前提
1.1 三种ADC启动方式,实际效果天差地别
STM32的ADC启动方式大体分三种:软件触发、连续转换、外部事件触发(最常见的就是定时器触发)。很多新手第一次接触时觉得它们只是启动方式不同,结果都差不多,实际用下来完全是两个世界。
软件触发就是你在代码里调用HAL_ADC_Start或者HAL_ADC_Start_IT,让ADC开始转换。最粗暴的轮询读取方式甚至是在循环里反复启动转换。这种方式的问题在于,从你调用函数到ADC真正开始采样,中间夹杂着函数调用开销、中断抢断、上下文切换,每次触发的时间偏差可能达到几十甚至上百微秒。做低速传感检测可能无所谓,但一旦涉及波形还原、FFT频谱分析,这种抖动会被直接放大,频谱里出现乱七八糟的杂散分量。
连续转换模式是让ADC上电后自己无休止地转,不需要CPU干预,采样间隔稳定在ADC自身的转换时间。听起来不错,可问题在于你没法精准控制“什么时候采”。比如你想让采样点与PWM输出的某个相位点严格对齐,或者想让采样率和另一个外设的中断节奏同步,连续转换根本做不到。
定时器触发是让定时器产生一个精准脉冲,ADC的外设硬件收到这个脉冲后自动开始转换。整个过程不经过CPU,也不需要软件参与,触发精度完全由定时器时钟决定,误差能控制在几个时钟周期以内。配合DMA搬运数据,基本能达到“硬件采集、硬件搬运、CPU撒手不管”的理想状态。
1.2 什么项目必须走定时器触发这条路
我自己的经验,以下三类场景如果没有定时器触发,几乎没法做出合格的数据:
第一类是电机控制。做FOC矢量控制时,电流采样必须与PWM载波同步,通常要在PWM中心对齐点触发ADC,这样采到的电流值才是当前控制周期最真实的表现。这种同步需求只能依靠硬件触发链,软件在中断里调用ADC根本无法保证相位一致。
第二类是音频、振动信号采集。这类信号做FFT分析时对采样周期的一致性极其敏感。软件触发时每次采样的微小时间差,换算到频谱上就是底噪抬高和频谱泄漏。我实测过同一套算法,硬件触发频谱底噪能比软件触发低3到5个dB。
第三类是ADC采样速率不高但要求长期稳定的场合,比如电池电压监测、温度巡检。因为定时器触发的周期由晶振决定,哪怕系统里中断满天飞,采样率也不会跑偏。
1.3 CubeMX在其中的角色
早些年配这套触发链路得手动改寄存器。ADC要设置外部触发使能、选择触发源,定时器要设置主模式TRGO输出,DMA要配置外设地址,漏一位就全盘抓瞎。CubeMX把这些封装成图形选项,你在图形界面选好触发源,生成代码后HAL库会自动把寄存器写对。
更重要的一点是,CubeMX的时钟树页面会直接显示每个外设的当前时钟频率。定时器触发ADC算频率时必须知道定时器时钟具体是多少,图形化界面能把这个数字直观列出来,避免口算分频器时算错。
2. 图形化配置:从时钟树到ADC触发源
2.1 开始配置前先把时钟树摆正
新建工程后,第一步别急着点ADC和TIM,先把RCC和时钟树搞定。我这里用的是8MHz外部晶振,内部HSI在采样率要求较高时不建议作为主时钟,因为HSI在全温范围内误差能到1%以上,频率的绝对精度远不如晶振。
在Clock Configuration页面里,把PLL倍频到72MHz,APB1预分频设置为/2。这里有个F103特有的坑:APB1总线频率是36MHz,但挂在这条总线上的定时器时钟会自动倍频到72MHz。也就是说,TIM2、TIM3、TIM4等定时器的输入时钟是72MHz,不是36MHz。很多人在算定时器分频时把这个忽略了,导致实际采样频率和预期差了整整一倍。
ADC的时钟也要在这里设置。F103的ADC时钟上限是14MHz,超过这个值转换精度会明显下降。72MHz主频下ADC预分频设为/6,得到12MHz,这是最常用的稳定组合。如果你图省事设成/4变成18MHz,短期内看不出来问题,但实际转换结果可能比预想更早出现非线性偏差。
2.2 ADC配置里的几个关键点位
时钟树设置完,进入ADC1的配置页面(以PA1通道为例,实际引脚看你的硬件连接)。
第一件事是把Scan Conversion Mode设为Disabled。这是单通道采样,不需要扫描多通道。如果你开了多通道扫描,后面采样频率的计算逻辑会完全变样,这个我后续会单独说。
第二件事是Continuous Conversion Mode必须设为Disabled。这一步直接决定你配置的定时器频率是否真的等于采样率。如果这里误开了连续转换,效果是:定时器第一次触发ADC后,ADC就自己连续不断地转换,不再等待定时器下一次触发。此时采样率会变成ADC的转换速度,而不是你设置的定时器周期。这是配置过程里最常见的一个坑。
然后是External Trigger Source,选择Timer 3 Trigger Out event。触发源具体选哪个定时器,取决于芯片型号和CubeMX下拉列表里给出的选项。F103上TIM2、TIM3、TIM4等都能作为ADC的外部触发源,但要保证该定时器没有被其他功能占用。
External Trigger Edge选择Rising Edge。定时器TRGO输出的本质上是一个脉冲信号,上升沿触发ADC开始转换,符合常规逻辑。
采样时间Sample Time这一项也值得花心思。F103支持1.5到239.5个周期不等。采样时间越长,内部采样电容充电越充分,测高阻抗源越准,但代价是单次转换时间变长。我的习惯是,信号源输出阻抗不高、采样率要求也不极端时,选7.5或13.5个周期,作为速度和精度的平衡点。如果信号源是电阻分压器或者运放输出,阻抗比较高,宁可把采样率降下来也要把采样时间压到55.5甚至71.5周期,否则读到的电压值会系统性偏低。
2.3 定时器触发输出的设置
进入TIM3配置页面。Prescaler和Counter Period这两个参数是采样频率的核心,我放在下一章专门算。这里先讲Trigger Output(TRGO)。
在TIM3的配置项里,把Trigger Output (TRGO)设为Update Event。意思是定时器计数溢出产生更新事件时,会同步输出一个触发脉冲给ADC。这个更新事件的周期正好等于定时器溢出周期,用它作为采样节拍,语义上最自然。
有人可能会问,为什么不用PWM模式里的OCx信号来触发?当然可以,有些场景甚至必须用OC信号,比如想在PWM周期内特定的比较点触发采样。但普通周期采样任务里,Update Event就是最简单可靠的选择,少了一层比较寄存器配置,排查问题也容易。
2.4 DMA通道让高频率采样不打扰CPU
ADC数据搬回内存,我强烈建议走DMA,优先别用ADC中断逐点采集。8kHz以下可能还能勉强应付,一旦上到50kHz、100kHz采样,每次转换都进一次中断,CPU就全泡在中断里了,主循环什么都干不了。
DMA配置要点如下:在DMA Settings里Add一个DMA Request,Direction选择Peripheral To Memory;Mode选择Circular,这样DMA存满一个缓冲区后会自动重新从头开始存,实现持续采样的环形效果;Data Width建议Peripheral和Memory都设为Half Word,因为ADC是12位分辨率,16位宽度正好容纳;Memory Address Increment设为Enabled,外设地址则保持不变。
还有一项容易被忽略的是ADC配置页里的DMA Continuous Requests,我一般设为Enable。这样一来DMA请求会持续保持,配合定时器触发,数据流不会因为DMA请求结束而中断。如果你设的是Disable,可能遇到DMA传完一轮就不再有新数据进来的怪现象。
所有配置完成后,最后确认一遍ADC2、TIM4之类无关外设没有被误开,然后点Generate Code生成工程。
3. 采样频率的计算:看懂PSC和ARR,才能彻底掌控频率
3.1 核心公式其实很简单
定时器触发ADC时,采样频率和定时器溢出频率是严格一一对应的,所以核心公式只有一个:
F_sample = F_tim_clk / ((PSC + 1) * (ARR + 1))
其中F_tim_clk是定时器输入时钟,在F103上默认是72MHz。PSC是预分频值,ARR是自动重载值。公式里的加1是因为这两个寄存器都是从0开始计数的。预分频器把72MHz先降到一个较低频率,计数器从这个低频开始数,数到ARR加满一次,产生一个更新事件,也就是一次触发脉冲。
整套逻辑类似钟表:分频器决定秒针走一格是多久,计数值决定走多少格响一次整点报时。两者相乘,得到的就是完整的触发周期。
3.2 几个可以直接抄的配置组合
我整理了几个常用采样率下的配置组合,都是基于72MHz定时器时钟计算出来的,可以直接填进CubeMX。
| 目标采样率 | 预分频PSC | 重载值ARR | 实际触发频率 | 计算式 |
|---|---|---|---|---|
| 1kHz | 71 | 999 | 1kHz | 72MHz / (72 × 1000) |
| 8kHz | 17 | 499 | 8kHz | 72MHz / (18 × 500) |
| 10kHz | 71 | 99 | 10kHz | 72MHz / (72 × 100) |
| 100kHz | 7 | 89 | 100kHz | 72MHz / (8 × 90) |
| 44.1kHz(音频) | 0 | 1632 | 44.09kHz | 72MHz / (1 × 1633) |
注意最后一组。44.1kHz是音频行业的标准采样率,但72MHz作为主频,无法整数分频精确得到44100Hz。用PSC=0、ARR=1632算出来是72MHz/1633,约44090.6Hz,误差约0.02%。绝大多数音频应用能接受这个误差,但如果你的系统对采样率绝对精度有强迫症级别的要求,就得换成其他主频方案,或者用外部高精度时钟源重新设计时钟树。在做这类计算时,记住一个检验原则:用CubeMX的时钟树页面先看清TIMx的时钟数值,再套公式,别拿直觉猜。
3.3 触发频率不能超过ADC的转换能力上限
定时器能触发的频率并不等于ADC能承受的频率。ADC单次转换本身要消耗时间,F103的转换时间公式是:
T_conv = (采样时间 + 12.5个固定周期) / ADC时钟频率
以ADC时钟12MHz、采样时间1.5个周期为例,单次转换耗时约(1.5 + 12.5) / 12MHz ≈ 1.167微秒,换算下来理论上限大约857kHz。这个上限是采样频率的物理天花板,定时器触发频率再高,ADC转换不完就是白搭。实际工程中建议至少留出20%的裕量,也就是说触发频率别超过700kHz。
还有两个容易搞混的情况需要单独说明。
第一种是开了连续转换的情形。连续转换模式下定时器只是把ADC“踹醒”,之后ADC就按自己的转换速度一路到底,采样率完全不受定时器控制。所以前面我才反复强调要关Continuous Conversion。
第二种是多通道扫描。如果你开了Scan模式,规则组里排了好几个通道,那么每一次定时器触发只转换当前序号的一个通道,下一触发转换下一个通道。这种情况下,某个通道的实际采样率等于触发频率除以通道总数。你以为在10kHz采样,实际每个通道的有效采样率只有5kHz,这个错误在调试多通道采集时非常隐蔽,经常被误判为“某一路数据丢了”。
4. 工程生成之后要补的代码:启动顺序和回调处理
4.1 别小看初始化顺序,它决定数据从哪里开始算起
CubeMX生成的代码会把外设初始化函数都排布好,但不会替你启动定时器和DMA。这时需要在main函数里主动补充启动逻辑。启动顺序有一点讲究:先启动ADC的DMA传输,再启动定时器。
原因是DMA必须提前进入等待接收数据的空闲状态。如果先启动定时器,第一个触发脉冲打进来时DMA还未准备好,这个样本就会丢失,体现为数据流开头莫名少了一个点,或者首个数据是0。反过来,先启动DMA时,虽然定时器还没跑,DMA只是安静地等着,不会产生任何副作用。
停止采样时的顺序刚好相反。先把定时器停掉,切断触发源,再调用HAL_ADC_Stop_DMA。如果先停ADC的DMA而定时器还在跑,定时器触发脉冲会不断尝试启动一个已经关闭的ADC外设,虽然通常不会造成严重后果,但状态机可能变得不干净,下次启动时有些寄存器的状态会出乎意料。
4.2 一个可以直接用的代码骨架
下面这段代码是我项目里精简出来的基础框架,验证过能直接跑:
// 定义DMA缓冲区,256个采样点,uint16_t对应ADC 12位精度 uint16_t adc_buf[256]; volatile uint8_t adc_full_flag = 0; int main(void) { HAL_Init(); SystemClock_Config(); MX_GPIO_Init(); MX_DMA_Init(); MX_ADC1_Init(); MX_TIM3_Init(); // 1. 先让DMA进入等待状态 HAL_ADC_Start_DMA(&hadc1, (uint32_t *)adc_buf, 256); // 2. 再启动定时器,开始周期触发 HAL_TIM_Base_Start(&htim3); while (1) { if (adc_full_flag) { adc_full_flag = 0; // 这里处理adc_buf里的数据 // 比如做滤波、FFT、通过串口上传等 } } } // DMA传输完成回调,HAL库在每次缓冲区填满时自动调用 void HAL_ADC_ConvCpltCallback(ADC_HandleTypeDef *hadc) { if (hadc->Instance == ADC1) { adc_full_flag = 1; } }这段代码的核心思路是“中断只置标志,主循环做处理”。DMA缓冲区满256个点后,HAL库在DMA完成中断里回调HAL_ADC_ConvCpltCallback,我只在这里做了一个置位操作,耗时几个纳秒,对采样流程几乎没有干扰。主循环检测到标志后再去处理这批数据,避开了在中断里做耗时的滤波、打印或浮点计算。
4.3 数据量大的时候怎么避免丢数据
采样率较高时,比如50kHz以上,256个点的缓冲区0.02秒就满了。如果主循环处理这批数据需要超过5毫秒,下个缓冲区就会覆盖进来,出现数据丢失。解决办法是双缓冲或者半满/全满交替处理。
CubeMX生成工程后,默认回调只有全满回调HAL_ADC_ConvCpltCallback。你还可以在main函数里额外实现HAL_ADC_ConvHalfCpltCallback,利用DMA循环模式的半传输中断。当DMA存一半时处理前半部分,存满时处理后半部分,相当于把一个大缓冲区拆成两块轮流消费。缓冲区越大、切分越多,主循环的有效反应窗口就越宽。工程实践里我会把缓冲区设到1024甚至4096,配合半满/全满两个标志交替处理。
4.4 实际运行中改采样频率的三种方式
调试过程中经常要动态调整采样率,三种方式我按推荐程度排一下。
最省事的是直接在CubeMX里改定时器ARR,重新生成代码。缺点是每次都要重新编译下载,适合开发期调参。
第二种是用HAL库的宏在运行时改ARR,比如__HAL_TIM_SET_AUTORELOAD(&htim3, 1999),配合当前PSC设置,频率瞬间就能变。需要注意的是,修改自动重载值后,要等定时器正常走完当前周期后才会生效,不是立即改变,但这对绝大多数使用场景没有影响。
第三种是改PSC。我个人的建议是不要轻易在运行中修改PSC,因为预分频器修改牵涉到时基计数器的内部时序,处理不当会让当前计数值错位,触发间隔出现一次异常脉冲。如果非要改,最好先停止定时器,改完再启动。相比下来,改ARR是更安全的选择,因为自动重载值允许随时更新,实际生效在下一个更新事件。
5. 实测验证与踩坑记录:让每一个参数经得起示波器考验
5.1 一个IO口翻转,就能看出真实采样率
配置完之后,别急着信代码里写的采样率,示波器上的实测才是真依据。最简单有效的验证方法是选一个空闲的GPIO,在DMA半满或全满回调里翻转这个引脚,比如:
void HAL_ADC_ConvCpltCallback(ADC_HandleTypeDef *hadc) { if (hadc->Instance == ADC1) { HAL_GPIO_TogglePin(GPIOC, GPIO_PIN_13); } }GPIO翻转频率和DMA缓冲区长度、采样率直接相关。假如采样率10kHz,缓冲区256点,那么全满回调每25.6毫秒触发一次,这说明实际采样率正确。用示波器测两次翻转的间隔,如果和理论值吻合,说明整条触发链没有任何问题。
这个方法也能快速发现定时器时钟倍频错误。若配置的是10kHz实测却只有5kHz,第一反应就该去检查定时器时钟是不是真的72MHz。我在调试时遇到过好几次类似问题,最后都发现是APB1配置时不小心把定时器倍频弄丢了。
5.2 用已知频率正弦波做FFT验证精度
GPIO翻转只能确认频率数量级对,但要想验证“采样间隔是否均匀、有没有抖动”,更严格的做法是输入一个已知频率的干净正弦波,让系统采样后做FFT分析。
我习惯输入1kHz正弦波、把采样率设成10kHz。在FFT结果里,1kHz处应该出现一个尖锐的主峰,旁边没有明显的边带。如果频谱底部抬得比较高,或者主峰两侧出现成群的小峰,说明采样触发稳定性有问题。把触发方式换成软件触发再做一次同样分析,这种差别会非常直观。这也是我在文章开头说的,硬件触发比软件触发的频谱底噪低3到5个dB这个结论的由来。
5.3 三个我真实踩过的坑
第一个坑是APB1倍频问题。我早期做项目时,在CubeMX看到APB1总线频率是36MHz,就理所当然地把定时器时钟按36MHz算,结果所有采样率都翻了一倍,一开始还以为是示波器探头接错了。后来才明白F103定时器时钟有个隐性倍频,总线36MHz但定时器是72MHz。现在我在CubeMX里算频率前都会特意看一眼TIM输入时钟的显示值。
第二个坑是连续转换模式。有次客户反馈采样数据看起来像“锯齿状”,频率也高得离谱。排查发现是有人配置ADC时把Continuous Conversion打开了,定时器只起了一个启动作用,之后ADC就按自己的转换周期连续跑。这类问题在代码层面几乎看不出来,回看CubeMX配置才找到根因。
第三个坑是多通道扫描下的采样率误判。我做一个三相电压采集时,规则组里配了3个通道,定时器触发频率设成15kHz,心里想着每通道5kHz。结果某一路信号的FFT分析总是出现频率对不上的情况,直到画时序图才意识到每次触发只转换一个通道,三个通道循环着采,每通道的有效采样率根本不是5kHz,更像是触发频率受扫描顺序影响后产生了错位。从那以后我在做多通道设计时,都先画一张触发时序图再定参数。
5.4 快速排查速查表
| 现象 | 可能原因 | 处理方案 |
|---|---|---|
| 采样率比配置值高一倍 | APB1定时器倍频没算清 | 在CubeMX时钟树查看TIM时钟,按72MHz重算PSC/ARR |
| 采样率比配置值低或乱跳 | Continuous Conversion被打开 | 关闭连续转换模式 |
| DMA数据只有第一批,后面不更新 | DMA Mode不是Circular,或DMA Continuous Requests为Disable | 检查DMA循环模式,开启Continuous Requests |
| ADC读值整体偏低 | 采样时间太短、信号源阻抗偏高 | 增大采样时间,加入缓冲器降低源阻抗 |
| 数据流起点异常或首个值丢失 | 定时器先于DMA启动 | 先HAL_ADC_Start_DMA,再HAL_TIM_Base_Start |
| 多通道时某一路频率突然减半 | 规则组通道数与触发频率关系没算对 | 按“单通道采样率=触发频率/通道数”重新计算 |
做采样系统最需要培养的思维是:频率不是软件里写出来的,而是硬件配置链路决定的。定时器分频、ADC触发源、连续转换开关、DMA传输模式,每个环节都是决定最终采样行为的一环。我在实际项目里养成了一个习惯:每次改完配置,都会用GPIO翻转法花半分钟确认一遍真实频率,再往后继续开发。
最后再分享一个小技巧。如果你想让系统的采样频率在长期运行中保持绝对稳定,优先使用外部晶振作为HSE时钟源,别省这个成本用内部HSI。HSI在室温下精度尚可,但温度漂移后会导致采样率跟着飘,数据采集项目中这是硬伤。定时器触发ADC这套组合本身已经足够“精准”,剩下的稳定性,就要靠整个时钟链路一起来保证。