ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

STM32定时器触发ADC+DMA采集:HAL库实现精确采样与CPU解放

STM32定时器触发ADC+DMA采集:HAL库实现精确采样与CPU解放 STM32F407 HAL库这套组合把TIM定时器、ADC、DMA三个外设串联起来做采集是我在实际项目里反复用过且觉得非常值得分享的一套写法。很多人一开始会觉得定时器触发ADC这件事有点绕尤其是加上DMA之后总觉得好像哪里没打通。这篇文章我就从方案思路、CubeMX配置、代码实现到实测参数全部讲一遍也包括这几年踩过的坑希望能帮你一次把TIMADCDMA这条链路趟平。这个方案解决的最核心问题有两个一是采样率要精确可控二是CPU不能被数据搬运占死。它特别适合传感器采样、电机电流/电压监测、音频采集、信号同步分析这类场景。无论是刚接触HAL库的入门者还是已经写过不少标准库程序、刚切到HAL库的老手都能从这套配置里拿到可以直接抄作业的模板。下面我按自己项目里的梳理顺序来写。1. 为什么非要把定时器、ADC、DMA三个外设绑在一起1.1 软件触发ADC的致命短板先说一个最常见的反面教材。很多人一开始写ADC采集是这样的在while(1)里不停调用HAL_ADC_Start()、HAL_ADC_PollForConversion()等转换完成再去读数据。这样做的确能采到电压值但采样时刻完全不可控。主循环里一旦有其他任务插入比如串口打印、按键扫描、OLED刷新、比如某个延时函数下一次启动ADC转换的时刻就会往后漂。你以为自己采的是100Hz的信号实际采样间隔里混进了大量抖动。这类抖动在低速场景下看不出来一旦信号上到几kHz甚至几十kHz波形还原效果就会变得很差尤其在后续做FFT、做PID控制的时候采样率不恒定带来的问题简直让人头大。第二个问题是CPU被白白占住。HAL_ADC_PollForConversion在等待转换完成的周期里CPU是死等的。单通道还好如果是多通道连续扫描转换CPU大量时间都耗在这句话上主程序别的活根本干不了。所以需要从一个更高层面去设计采集链路用定时器硬件产生固定节拍的触发信号用ADC外设响应触发并完成转换再用DMA把转换结果自动搬到内存。CPU只在需要处理整帧数据的时候介入一次其余时间完全解放。1.2 三个外设的分工节拍器、采样员和搬运工这套组合里三个外设各司其职。定时器相当于一个极其精确的节拍器它的更新事件Update Event通过TRGO引脚映射到ADC的外部触发输入端。每到一次节拍ADC就自动开始一次转换转换完成后的数据落地到ADC数据寄存器DR这个过程不经过CPU。ADC本身是个精细的采样员负责把引脚上的模拟电压转换成12位的数字量。F407上有三个ADC每个ADC有多个外部通道而且支持规则组和注入组两套转换序列。规则组就是我们常用的多通道扫描模式可以按顺序依次采集多个通道转换结果是一个明确有序的输出序列。DMA则纯粹是搬运工角色。ADC转换完成后硬件会自动发出DMA请求DMA控制器把数据寄存器里的值搬到内存缓冲区。搬运过程中CPU不用参与搬运结束之后DMA还可以通过中断的方式通知CPU“数据准备好了”。这个模型很像一个快递流水线定时器定时往传送带上放包裹ADC给包裹贴上标签DMA负责把包裹搬到仓库老板只需要等货架满了来见证收货就行。1.3 为什么选HAL库而不是LL库或者纯寄存器这个问题几乎每次讲HAL库项目都会有人问。HAL库最大的好处是CubeMX图形化配置流程非常成熟能省掉大量外设初始化代码的编写时间。尤其是ADC和DMA这种寄存器配置非常琐碎的外设用CubeMX拖一拖、选一选、生成代码比自己翻数据手册查每个位的含义要快得多。ST官方现在同时维护HAL和LL两套库LL库更贴近寄存器操作代码执行效率更高、ROM占用更小但写起来更像在操作寄存器。HAL库则偏重抽象和可移植性初始化代码可能显得冗余但换来的是跨芯片、跨系列的兼容性以及清晰的回调机制。在TIMADCDMA这种“多外设联动”的场景下HAL库的回调函数、DMA中断处理接口都属于开箱即用开发效率优势很明显。嵌入式开发永远在“效率和开发速度”之间权衡这种中等复杂度项目我一般优先HAL。2. 方案选型与关键配置逻辑2.1 定时器触发源怎么选为什么我常用TIM2F407上的定时器资源很丰富但ADC外部触发并不是所有定时器都能接。以ADC1为例它支持TIM1、TIM2、TIM3、TIM4、TIM5等多个定时器通过TRGO输出作为触发源。具体哪个定时器对应哪个ADC需要查STM32F407参考手册中“ADC外部触发”那一节的触发映射表。我习惯用TIM2一个重要原因是TIM2是32位定时器。计数器周期可以设置得很大PSC和ARR组合起来很方便计算更新频率不那么容易遇到溢出问题。另一个原因是在双ADC、多ADC需要同步触发的场景里TIM2的触发路径能覆盖到ADC1/ADC2/ADC3的大部分组合。当然这不是绝对的如果你项目里TIM2已经被占用了换成TIM3、TIM4也完全没问题只是注意CubeMX中选择对应的Trigger Out event即可。在CubeMX配置TIM2时有一个容易忽略的点触发输出TRGO到底选“Update Event”还是“OCxREF”这会影响触发时刻和定时器周期的对应关系。最常见的做法是让TRGO输出Update Event也就是定时器计数器溢出重载的那一刻输出一个触发脉冲。此时不管你在CubeMX里有没有勾选PWM模式只要定时器主模式里配了TRGO为更新事件启动定时器后就能在每周期产生触发脉冲。2.2 最容易翻车的选项Continuous Requests必须关掉这里我想单独划一个重点说因为太多人在这里翻车。ADC配置里有一个很显眼的参数叫Continuous Requests中文界面里可能叫“连续请求”或“连续转换”。它在寄存器层面的作用就是CONT位。如果把Continuous Requests设为Enable那么ADC一旦被触发完成一次转换会立刻自动开始下一次转换不再等待外部触发源。换句话说定时器触发只决定了第一次采样时刻后续采样完全由ADC自己按最快速度连续转。这时候定时器节拍就等于完全失效采样率变成ADC的自身转换时间决定而且可能和你的预期差出一大截。我见过有人配完后用逻辑分析仪看采样点发现波形周期和定时器频率根本不匹配折腾半天最后发现就是这个CONT位开着。所以定时器触发ADC这个场景里Continuous Requests必须为Disable。每次转换都严格等下一个定时器边沿这样采样率才等于定时器更新频率。这个选项是TIM和ADC联动方案里的第一道“生死线”。2.3 ADC通道扫描与DMA循环模式怎么匹配ADC1默认是单通道采集如果要同时采多路信号就得打开Scan Conversion Mode然后在规则序列里配置通道顺序。F407的规则组最多支持16个序列槽可以自由设定每个槽对应哪个通道。DMA搬运时数据严格按照规则序列的顺序输出到内存缓冲区。比如规则序列第一个是ADC_CHANNEL_0第二个是ADC_CHANNEL_1那么DMA缓冲区第0个元素是CH0第1个元素是CH1如此循环。DMA的模式选择很关键。如果只想采一批数据就结束Normal模式就可以。但大多数连续监测场景我们都希望数据不停地从ADC搬进内存、采满一区又从头开始继续搬。这时必须选Circular循环模式。Circular模式下DMA缓冲区装满后硬件自动把内存地址指针重置到缓冲区起始地址继续下一轮搬运。如果需要处理数据就用半传输中断和传输完成中断把一帧数据分成两半轮流处理避免读写冲突。这个思路在后面代码里会详细说。还有个细节是数据宽度。ADC数据寄存器是16位有效所以DMA的外设和内存数据宽度都设成Half Word16位内存缓冲区声明成uint16_t数组最合适。如果声明成uint8_t数组DMA每次只搬8位结果会错乱声明成uint32_t数组则会看到高16位每次都不一样而且内存占用翻倍。16位是这套配置中最自然的选择。3. 基于STM32CubeMX的完整配置流程3.1 时钟树与基本参数规划在做具体外设配置之前先把系统时钟和ADC时钟给捋顺。F407最高主频168MHzAPB2总线频率84MHzADC的时钟源从APB2经过分频器得到。F407的ADC最高允许工作时钟是36MHz超过会直接影响转换精度甚至导致功能异常。CubeMX中ADC分频系数可选2、4、6、8。APB2是84MHz如果选2分频就是42MHz已经超出36MHz的上限CubeMX会有警告提示。选4分频得到21MHz这是最常用的保守配置所有采样周期档位下都能稳定工作。ADC的转换时间和测量精度对时钟敏感稳定优先不建议极限超频。以84MHz APB2、4分频为例最终ADCCLK 84 / 4 21MHz。这个数值后面算采样周期会用到。系统时钟保持168MHz不变定时器TIM2挂载在APB1上APB1定时器时钟是84MHz。记住这个84MHz后面算TIM更新频率还要用它。时钟配置对了整条链路的底层就稳了。3.2 TIM2配置25kHz触发源就这样产生打开STM32CubeMX选择STM32F407VET6或对应型号在Pinout Configuration里找到TIM2。我要做的配置如下Clock Source选择Internal ClockPrescaler设置为83Counter Period自动重装值设置为399auto-reload preload选择EnableTrigger OutputTRGO选择Update Event此时如果用预分频公式算一下TIM2输入时钟84MHz分频系数PSC184计数周期ARR1400更新频率84MHz / 84 / 400 25000Hz也就是25kHz。这个频率下的触发间隔是40微秒非常适合模拟信号采集实验。如果后面想改成任意频率直接调整PSC和ARR即可。有人会问为什么TRGO选了Update Event还要不要配PWM模式。其实不需要。ADC只需要一个上升沿触发信号Update Event已经产生了一个周期性的脉冲足够用了。只有在某些特殊场景比如你想把触发点和PWM波形的占空比边界严格对齐才会去考虑OCxREF触发模式。普通数据采集完全没有必要引入PWM的额外复杂度。3.3 ADC1配置多通道采样序列与外部触发在CubeMX里选中ADC1按下面步骤设置打开Scan Conversion Mode使能扫描转换在Number Of Conversion里填2表示规则组序列有两个槽Rank 1选择ADC_CHANNEL_0采样周期先选3 CyclesRank 2选择ADC_CHANNEL_1采样周期同样选3 CyclesExternal Trigger Conversion Source选择Timer 2 Trigger Out eventExternal Trigger Conversion Edge选择RisingEdgeContinuous Conversion Mode保持DisableDiscontinuous Conversion Mode保持Disable这里采样周期选了3 Cycles属于最快档。F407在12位分辨率下ADC转换时间由“采样周期固定周期12.5个ADCCLK”组成最短就是312.515.5个ADCCLK周期。在21MHz时钟下单次转换约0.738微秒。这个值比触发间隔40微秒小得多完全可以保证每次触发间隔里转换早已完成。如果信号源内阻比较大比如直接从几千欧的分压电阻网络取信号建议把采样周期增加到15或更高的Cycles给内部采样电容充分充电时间采集值会更准。这个取舍后面实测部分再展开讲。3.4 DMA参数与NVIC中断配置在ADC1配置窗口里切到DMA Settings标签点击Add添加DMA请求。F407的ADC1对应DMA2控制器建议选Stream0、Channel0。参数这样设Direction选择Peripheral To MemoryMode选择CircularPeripheral Increment设为Disable因为ADC数据寄存器地址固定Memory Increment设为Enable内存地址要逐次递增Peripheral Data Width设为Half WordMemory Data Width设为Half WordFIFO Mode选择Disable直接模式响应延迟更小Priority按需选High保证数据不丢F407的DMA2 Stream0还支持双缓冲模式可以把缓冲区拆成两块交替使用。对常规项目来说用半传输/传输完成中断配合单缓冲就够了双缓冲属于进阶用法这里先不展开。NVIC配置里要把DMA2 Stream0中断勾选Enable。半传输中断和传输完成中断在HAL库里都走同一个DMA中断向量区别体现在回调函数上。 DMA中断优先级我一般设为比主循环任务高、比硬实时中断低比如优先级5左右。不要太高因为ADC中断里如果做了大量数据解析会拖累其他紧急任务。也不要太低否则数据缓冲区容易被新一轮DMA覆盖。3.5 CubeMX生成代码后的必要改动CubeMX生成工程后打开main.c可以发现初始化顺序基本是MX_GPIO_Init、MX_DMA_Init、MX_ADC1_Init、MX_TIM2_Init。这里有个关键点HAL_ADC_Start_DMA启动ADC和DMA的代码一定要放在定时器启动之前。原因是定时器一旦通过TRGO产生触发脉冲ADC会立刻开始转换转换完成后立刻发出DMA请求。如果此时DMA还没有被HAL_ADC_Start_DMA启动第一次触发对应的数据就丢失了。不要小看这一帧数据的差异在多通道扫描时丢失第一帧会导致后续所有数据在序列里错位整帧数据全乱。我在实际调试中遇到的“数据顺序不对”问题很多都源于启动顺序不对。CubeMX不会自动生成校准代码建议在main函数的用户代码区加上HAL_ADCEx_Calibration_Start调用详见后续代码部分。校准能消除ADC内部电容阵列的偏移对提升采集一致性有帮助成本极低。4. 核心代码实现与关键写法4.1 主函数里的完整启动流程CubeMX生成初始化代码后要添加的内容集中在main函数和回调函数里。先定义缓冲区#define ADC_BUFFER_SIZE 64 uint16_t adc_buffer[ADC_BUFFER_SIZE]; volatile uint8_t adc_half_done 0; volatile uint8_t adc_full_done 0;在main中MX_TIM2_Init等所有初始化函数之后执行启动代码HAL_TIM_Base_Start(htim2); HAL_ADCEx_Calibration_Start(hadc1, ADC_SINGLE_ENDED); HAL_ADC_Start_DMA(hadc1, (uint32_t *)adc_buffer, ADC_BUFFER_SIZE);顺序上HAL_TIM_Base_Start先于HAL_ADC_Start_DMA是否可行实际项目中我往往先启动DMA再启动定时器最稳妥的顺序是HAL_ADCEx_Calibration_Start(hadc1, ADC_SINGLE_ENDED); HAL_ADC_Start_DMA(hadc1, (uint32_t *)adc_buffer, ADC_BUFFER_SIZE); HAL_TIM_Base_Start(htim2);先启动DMA的好处是定时器启动后第一个触发边沿到来时DMA通道已经处于监听状态数据一产生就会被搬走。启动定时器放在最后从根源上杜绝启动瞬间丢帧的问题。这个顺序我在多个项目里验证过是稳定首选。4.2 缓冲区数据如何解析双通道规则组的坐标关系我们配置的规则组序列是Rank1 CH0Rank2 CH1。DMA在循环模式下每次完整的规则组扫描会产生两个转换结果按序填入缓冲区。也就是说adc_buffer[0]对应CH0adc_buffer[1]对应CH1adc_buffer[2]对应CH0adc_buffer[3]对应CH1以此类推如果缓冲区大小定义成64那么一次完整DMA传输周期实际采集了32轮规则组扫描CH0和CH1各有32个有效样本。我们可以用简单宏来提取#define CH0_SAMPLE(buf, i) (buf[(i) * 2]) #define CH1_SAMPLE(buf, i) (buf[(i) * 2 1])这种坐标关系在单通道下不用多想但一旦开启多通道扫描很容易误以为缓冲区大小就是每个通道的样本数。实际上定义64的时候每个通道只有32个点。如果项目里要做FIFO、做均值滤波这个数量关系必须提前算清楚不然滑动窗口的长度统计会出偏差。4.3 半传输中断与完成中断交错处理整帧数据DMA在Circular模式下缓冲区满了会自动回卷继续搬运。这就会带来一个经典问题如果上一轮数据还没被CPU取走新一轮数据可能覆盖正在处理的内存区导致数据撕裂。解决办法很成熟利用DMA半传输中断和传输完成中断把缓冲区一分为二。前半部分填满时触发半传输中断此时CPU处理前半部分数据后半部分填满时触发传输完成中断CPU处理后半部分数据。由于DMA回卷到缓冲区头部后会从下一轮重新开始填充而它覆盖前半部分时后半部分的处理早就结束了读写冲突从机制上被避免。回调代码如下void HAL_ADC_ConvHalfCpltCallback(ADC_HandleTypeDef *hadc) { if (hadc-Instance ADC1) { adc_half_done 1; } } void HAL_ADC_ConvCpltCallback(ADC_HandleTypeDef *hadc) { if (hadc-Instance ADC1) { adc_full_done 1; } }主循环里可以这样消费数据while (1) { if (adc_half_done adc_full_done) { // 这种双标志同时置位一般不会出现这里仅示意 } if (adc_half_done) { adc_half_done 0; // 处理 adc_buffer[0] 到 adc_buffer[31] } if (adc_full_done) { adc_full_done 0; // 处理 adc_buffer[32] 到 adc_buffer[63] } }注意回调里只是置标志位不要在中断回调里直接做滤波、运算、打印这些耗时操作。把标志位带回主循环再处理是嵌入式中断服务程序的黄金法则。我见过有人在回调里直接printf、直接做浮点运算轻则数据抖动重则中断嵌套过多导致系统卡死。4.4 停机与重启的正确姿势采集系统不是无止境跑下去的总会遇到需要暂停采集的时刻。比如设备进入低功耗模式、用户关闭通道、运行过程中切换量程。这时要特别小心停机的顺序HAL_TIM_Base_Stop(htim2); // 第一步先停定时器停止触发 HAL_ADC_Stop_DMA(hadc1); // 第二步再停ADC和DMA先停定时器的原因很简单如果先关ADC而定时器还在跑TRGO的触发脉冲可能持续产生此时ADC已经停止DMA通道也关闭外部事件没有任何处理者虽然不一定会有什么危害但会留下漫长的恢复隐患。反过来先停定时器ADC就没有新的触发源等当前转换完成、DMA空闲后再停DMA整个链路是稳稳当当收尾的。如果之后要重新启动把4.1小节的启动代码原样再来一遍即可。需要注意的是重复调用HAL_ADCEx_Calibration_Start没有问题但最好在ADC不在转换状态下进行。实践中校准一次后长时间保持同一温度环境下运行没有必要反复校准。5. 采样率测算与实测性能分析5.1 触发频率和ADC转换时间的计算这套方案的采样率由定时器更新频率直接决定写公式就是F_sample TIM_CLK / (PSC 1) / (ARR 1)TIM2挂载在APB1 Timer Clock也就是84MHz。按上文PSC83、ARR399代入F_sample 84000000 / 84 / 400 25000 Hz也就是说外部信号以25kHz的速率被采样。这个频率下触发间隔是40微秒。ADC单次转换时间要单独算T_conv (采样周期 12.5) / ADCCLK配置里选了3 CyclesADCCLK是21MHz那么T_conv (3 12.5) / 21000000 ≈ 0.738微秒0.738微秒远小于40微秒说明每次触发到来时上次转换早已结束ADC始终处于就绪状态不会出现触发信号来了但ADC还在忙的“overrun”现象。如果以后想提高采样率到200kHz触发间隔5微秒也依然远大于0.738微秒。这个余量允许我在保持同一ADC配置的情况下灵活调高触发频率不必再担心转换时间瓶颈。但如果反过来你把采样周期设成480 Cycles这种极端值单次转换时间就会变成(48012.5)/21M≈23.5微秒。此时触发频率稍微高一点就可能发生ADC转换不完整、数据稳定性变差的情况。所以配置采样周期不能只看“采样速度越快越好”要和触发频率联动考虑。5.2 实测数据质量的快速验证方法没有示波器、没有信号发生器的环境里怎么快速验证这套链路通没通我建议先把ADC输入引脚接到一个固定电位上。比如用一个10k电位器从3.3V分压把分压点接到PA0和PA1。此时程序采到的数值应该在0到4095之间平稳变化转动电位器时数值连续增减不跳变就是基本正常。想验证采样率是否确实锁定到了25kHz最直接的办法是给输入加一个已知频率的正弦波比如用手机播放器和电阻衰减网络产生一个1kHz正弦信号采集后计算一个完整周期的采样点数。1kHz正弦在25kHz采样率下一个周期大概25个点数一数缓冲区中相邻两个波峰的间距如果稳定在25个点左右说明采样节拍没有问题。我处理过最典型的一个伪故障是波形看起来是对的数据也连续但从FFT看到的频谱峰值位置偏移严重。最后定位到Continuous Requests被开启ADC实际采样率远高于定时器频率导致频谱分析时用了错误的Fs。这个排查过程走了不少弯路现在我会在拿到工程的第一时间检查CONT位而不是先查信号链路。6. 踩坑实录这些问题我全遇到过6.1 采集数据全为0DMA却显示工作正常这个问题排查起来最迷。DMA回调一直触发缓冲区里也一直有数据写进来但所有数值都是0。后来逐一排查发现是GPIO引脚没有配置成模拟模式。ADC输入引脚在CubeMX里如果忘了把PA0、PA1的GPIO Mode选成Analog引脚可能默认成了输出模式或者复用功能模式ADC根本采不到外部电压只能采到0。另一个常见原因是外部触发边沿选错。CubeMX里触发边沿有上升沿、下降沿两种如果选了下降沿而定时器发出的TRGO更新事件是上升沿脉冲ADC就永远等不到触发自然不会产生转换。把边沿改成Rising Edge就好了。6.2 多通道数据错位第一个数据对不上通道以前调试四通道采集时发现按CH0到CH3的顺序配置规则组但实际数据中通道顺序整体偏移了一位。后来发现是我在启动DMA之前定时器的第一个触发就已经来了DMA还没来得及就绪CH0第一次转换数据丢了后续数据全部提前一位填充呈现“序列整体错位”的假象。解决办法就是前面反复强调的启动顺序先HAL_ADC_Start_DMA再HAL_TIM_Base_Start。如果还是担心启动瞬间会丢一帧可以在主循环里加一个简易丢弃逻辑启动后先忽略前几个采样节拍的数据等缓冲区连续填满两轮之后再开启数据解析。这样代价很小却能彻底规避启动毛刺。6.3 波形变成“阶梯状”或采样率莫名的低数据不是在切换而是一段时间内数值不变隔一阵跳一下看起来像量化噪声。这种问题大概率出在信号源阻抗太高或采样时间太短。F407的ADC内部有采样保持电容采样开关闭合时间越短外部信号源需要提供的充电电流越大。如果信号源是几十kΩ以上的高阻输出3 Cycles的采样时间可能根本充不满采集到的值就偏小、乱跳。这个时候把ADC的采样周期往上调比如15 Cycles、84 Cycles数值会明显稳定。代价只是单次转换时间变长在低速采集中完全无所谓在高速采集中要重新推演是否满足触发间隔。另外如果你看到采样率比定时器频率正好慢一半优先怀疑DMA缓冲区宽度配成了Byte而不是Half Word。DMA每次只搬8位数据ADC转换结果的高8位被丢弃采出来的波形看起来是断断续续的乱码。6.4 常见问题速查表现象可能原因解决办法缓冲区全为0DMA正常触发GPIO未配置Analog参考电压接触不良通道输入悬空检查引脚模式和VREF数据顺序整体错位DMA启动时序晚于定时器规则组Rank顺序不对先启动DMA再启动定时器采样率低于定时器配置Continuous Requests误开启ADC实际按自身速率转换将Continuous Requests设为Disable波形出现阶梯、跳变采样周期太短信号源内阻过大增大采样Cycles加电压跟随器数据看起来只有8位精度DMA数据宽度配成Byte外设和内存宽度统一配Half Word半传输回调一直不触发DMA中断未使能NVIC优先级配置异常检查DMA中断在CubeMX里勾选定时器一启动就死机DMA缓冲区地址未对齐缓冲区大小填错缓冲区定义为uint16_t数组大小保持合理这套TIMADCDMA的方案我在做电机电流环采样、传感器多通道同步采集时反复用过稳定性已经验证得比较充分。最后分享一个反向应用DMA的搬运方向反一下把内存里的波形表数据通过DMA搬到DAC的数据寄存器或者定时器比较寄存器就能实现DMA驱动的PWM波形发生器原理和采集链路完全对称。嵌入式里很多看似复杂的外设协作本质都是这几条数据通路的变体想明白一条其他都是顺藤摸瓜。
返回列表