ARTICLE DETAIL

建站实战干货

来自一线的建站与推广经验沉淀,每一条都经过真实交付验证。

STM32H743 ADC低CPU占用采样:定时器触发+DMA双缓冲实战

2026/10/5 6:12:42 拓冰建站 浏览量
STM32H743 ADC低CPU占用采样:定时器触发+DMA双缓冲实战 我最早接触STM32H743的ADC时第一反应是“这芯片ADC挺多3个独立ADC频率也高该不会难用吧”。后来真正上手才发现H743的ADC能折腾的空间比想象中大得多尤其是“定时器触发加DMA搬运”这套组合可以说是把ADC用到了另一个境界CPU几乎可以不碰数据采样靠硬件定时器掐表搬数据靠DMA悄悄干活一整条数据流下来CPU占用率低到我一度怀疑自己是不是没配置对。这篇文章我就把这条低CPU占用数据流的完整玩法拆开讲清楚包括设计思路、CubeMX配置、代码实现、双缓冲细节还有我实测下来踩过的几个坑。如果你是做电机控制、在线振动监测、音频采集、电源纹波分析这类需要连续采样的场景这篇文章尤其适合你。看完你会明白为什么别人做500kSPS以上的连续ADC采样CPU还能空出来跑控制算法和通信协议栈而你写的程序一开采样调度就卡顿CPU占用直接飙到七八十。别急这套方案能帮你把占用率降一个量级。1. 为什么是“定时器触发DMA搬运”先看几种采样方案的CPU成本很多人一开始做ADC采集都是照着CubeMX默认配置来的ADC使能后主循环里不停读HAL_ADC_GetValue()或者开个定时器中断每次中断里手动启动转换。这两种方式不能说错但一旦采样速率上来CPU就非常痛苦。轮询方式最简单粗暴读一次值要等转换完成这个等待过程里CPU被卡住啥也干不了。如果采样率只有1kSPS耽误的CPU时间几乎可以忽略但换成100kSPS甚至1MSPS主循环基本就废了别指望还能跑其他任务。定时器中断方式稍微好一点每来一次定时器中断就在中断服务函数里启动一次ADC转换再等转换完成中断读取结果。CPU占用比轮询低但问题也很明显每一次采样都伴随至少两次中断定时器触发中断一次ADC转换完成中断一次在1MSPS的采样率下中断频率高达每秒上百万次光是压栈出栈都够喝一壶的。更麻烦的是中断里干的事情越多中断延迟抖动越大采样间隔的均匀性就越差最后采出来的波形毛刺一堆。DMA搬运方案解决的正是这个问题。ADC转换完成后结果寄存器里的数据由DMA自动搬到内存缓冲区整个过程无需CPU介入。DMA搬运完一批数据后再产生一次传输完成中断CPU只需要在这一批数据都准备好之后一次性搬走处理就行。这样采样率再高中断频率也降到了“缓冲区大小分之一”。定时器触发解决的是另一个问题采样时刻的准确性。软件触发ADC有个致命弱点指令执行的先后顺序、中断响应延迟都会影响实际采样时刻导致采样点的时间间隔忽长忽短。定时器触发则完全不同ADC的启动信号由硬件定时器的触发事件产生采样积分起点被硬件牢牢锁住间隔极度均匀。这对于需要做FFT、计算相位、评估谐波的应用来说几乎是必须的配置。所以“定时器触发DMA搬运”不是两个功能的简单叠加而是一条完整的硬件级数据采集流水线定时器决定什么时候采ADC负责采集DMA负责搬运CPU只在最后处理数据。中间的每一个环节都不需要CPU操心这才是低CPU占用的本质。2. 动手前的硬知识H743的ADC时钟、采样时间与定时器触发链路2.1 ADC时钟不是随便给的STM32H743的ADC和F1/F4系列的ADC有本质区别它从通用APB总线时钟里“脱离”出来有了自己独立的时钟源。H743的ADC时钟来自PLL2P或者PLL3P而不是常规的APB2时钟。这个独立性带来了一个巨大优势只要你在时钟树里单独配置PLL2ADC的采样时钟可以完全不受系统主频影响。H743的ADC最高可以跑到50MHz ADC时钟但这属于极限参数实际项目里我更推荐控制在30~40MHz。为什么ADC时钟太高采样保持电容充电时间相对变短如果信号源阻抗偏高采样结果会偏小。官方数据手册里给出的性能指标也通常是在比较折中的ADC时钟下测出来的。分辨率方面H743的ADC支持8、10、12、14、16位可配置。16位模式下ADC转换周期会多一些在需要高采样率的场景下可以适当降低分辨率。我的习惯是振动监测、音频采样这类看重动态范围的场景用16位高频电流环采样、锁相环检测这类更看重速度和实时性的场景用12位就足够了。2.2 转换时间到底怎么算ADC单次转换的总时间 采样周期 转换周期单位是ADC时钟周期。H743的采样周期可配置范围非常宽从1.5个时钟周期到810.5个时钟周期都有。转换周期则跟分辨率直接相关16位分辨率下大约需要8.5个ADC时钟周期。举个实际例子ADC时钟36MHz采样周期1.5个ADC时钟分辨率16位总转换周期数1.5 8.5 10个单次转换时间10 / 36MHz ≈ 278ns理论最大采样率约3.6MSPS这个速率相当可观。不过请注意实际连续采样时还要考虑DMA搬运、寄存器同步等开销真实速率会略低。但即使打个七折2MSPS以上也足够了。2.3 定时器触发与中心对齐模式的巧妙配合定时器触发的核心是让ADC的启动信号跟PWM的开关时刻错开。常见做法是使用高级定时器TIM1或TIM8的PWM输出同时把TRGO2触发事件送给ADC。重点来了如果PWM是边沿对齐模式占空比变化时开关沿的位置是抖动的噪声干扰主要集中在边沿附近。如果让ADC每次都在PWM边沿触发采样很可能刚好采集到开关噪声叠加后的电压数值会明显跳变趋势上看起来就像“毛刺”。所以更理想的做法是用中心对齐模式并在计数值接近中点时产生触发。这时PWM上下桥臂的导通状态比较稳定电源轨上的开关噪声也相对收敛采样点处于最“安静”的时刻。这种微秒级的时序安排听起来很细但对采样质量的提升非常明显。还有一个原因中心对齐模式下的触发事件在单个PWM周期内只会出现在规定的位置不会像边沿对齐那样有时候落在上升沿、有时候落在下降沿采样点在信号周期内的相位是固定的。固定相位意味着从时域看等效采样率非常稳定做FFT时频谱能量不会因为相位抖动而“抹开”。3. CubeMX图形化配置实操一步步把硬件链路拉通3.1 第一步配置ADC1规则组关闭连续转换打开STM32CubeMX先选中ADC1参数配置里需要注意几个点Resolution选择16位。Conversion Mode选择Single模式不要把Continuous Conversion Mode打开。既然要用定时器触发就不需要ADC自己连续跑连续转换模式下DMA的节奏会被ADC的内部节奏带着走定时器触发反而失去了意义。Continuous Conversion Mode必须Disable这点很容易被忽略。很多人定时器配置没问题但ADC自己也在连续转换最终数据流的频率完全对不上。Sampling Time选择1.5 Cycles或2.5 Cycles。如果信号源内阻比较高可以适当放宽到8.5 Cycles以上。别为了速度盲目用最小采样周期信号源内阻大时采样保持电容充不满采出来的数值偏小而且越是高频信号偏得越狠。开启Regular Conversion的DMA请求在DMA Settings标签里添加ADC1的DMA通道。3.2 第二步配置TIM1的PWM中心对齐和触发输出TIM1这里我习惯同时用作两件事PWM信号输出和ADC触发源。具体参数如下Clock Source选择Internal Clock。PWM Generation CH1选择PWM Generation CH1这里看你要不要真的输出PWM波形。如果只是做采样触发也可以不开启PWM输出通道只保留Trigger Output本身这样TIM1纯粹作为一个精密触发器来用。Counter PeriodARR按目标采样率计算。比如TIM1输入时钟240MHz想要20kHz采样率中心对齐模式下计数值需要上下往返一次才算一个完整的PWM周期所以ARR 240MHz / (2 × 20kHz) 6000。PulseCCR写成ARR的一半左右。如果是中心对齐模式的PWMCCR决定PWM脉宽位置触发点通常和这个值配合。把Pulse设置为ARR/2可以让TRGO2事件发生在计数值到达ARR/2的位置。在Trigger Output (TRGO2) Parameters里选择“Trigger Output”为“Enable”模式选择“OC1REF”。这样ADC的触发信号就来自PWM的比较匹配点相位非常稳定。3.3 第三步配置DMA循环模式准备双缓冲DMA配置页里把ADC1对应的DMA通道设置成ModeCircular。这是整个方案能跑起来的核心。Circular模式下DMA搬运完一批数据后会自动把内存地址重置回起始地址继续搬运下一批不需要CPU重新配置。ADC连续采样时数据流可以无缝衔接。DirectionPeripheralToMemory。Peripheral Data WidthHalf Word因为ADC是16位分辨率。Memory Data WidthHalf Word。内存地址增量模式Memory Increment开启。这就是“没有双缓冲的双缓冲”最简单的实现方式DMA始终以环形方式往一个固定大小的缓冲区里写数据配合半传输中断和传输完成中断把缓冲区拆成前后两半交替处理。前一半在搬运时CPU处理后一半后一半在搬运时CPU处理前一半。逻辑上等效于双缓冲但CubeMX配置只需要一个DMA请求。3.4 第四步时钟树与电源细节时钟树里要单独配置PLL2。我一般把PLL2P频率设为36MHz给ADC1使用。要注意CubeMX有时默认会把PLL2P拉得很高如果你忘了改ADC时钟可能直接顶到50MHz虽然不算越界但采样质量会下降。比较容易踩坑的地方是修改PLL2的倍频系数后CubeMX会提示冲突这时候需要确认ADC1的时钟源确实挂到了PLL2P上并且数值是36MHz。电源方面H743的ADC参考电压引脚VREF需要低噪声电源。如果板子上没有单独的精密基准源至少要在VREF和VREF-之间加一个高质量去耦电容容值建议1uF加100nF组合。VREF电压不稳ADC转换结果就会有系统性偏移这会表现为“同一电压今天测是一个值明天测是另一个值”而且这种问题靠写代码几乎解决不了。配置项汇总成一张表配置项推荐值说明ADC1分辨率16位高动态范围场景ADC1转换模式Single 定时器触发不要开启连续转换ADC1采样周期1.5~8.5 Cycles根据信号源内阻调整ADC1时钟PLL2P36MHz留有余量保证性能TIM1模式PWM Generation CH1中心对齐触发点固定TIM1计数周期按目标采样率计算ARR 定时器时钟/(2×采样率)TIM1触发输出TRGO2 OC1REF触发点在PWM中点附近DMA模式Circular关键实现无缝数据流DMA数据宽度Half Word匹配16位ADC结果4. 代码实现双缓冲接管数据流4.1 为什么强调“一批数据”而不是“一个数据”很多人写ADC DMA代码第一反应是“DMA搬一个数进内存然后我立刻处理一个数”。这种用法不能算错但完全没有发挥DMA的优势。中断频率中断里做的事情越多和主循环的互斥问题就越复杂。真正舒服的用法是为DMA准备一个较大的缓冲区例如1024个采样点。ADC每采样1024个点DMA才产生一次“满”中断CPU一次性处理1024个点。这带来的好处非常明显中断频率从采样率级别降到“采样率/缓冲区大小”级别。比如2MSPS采样率缓冲区1024点那么中断频率只有约2kHzCPU平均一分钟空余时间极多。批量处理数据时可以用更高效的算法。比如对这1024个点做一次FFT做一次均方根值计算做一次峰值统计开销均摊到每个数据点上非常小。那为什么还要“双缓冲”因为单缓冲有个空档期CPU处理完缓冲区数据后需要重新启动一次DMA搬运这中间会有一段时间ADC数据无处安放产生丢失。DMA的半传输中断和传输完成中断把缓冲区一分为二当CPU正在处理前半部分数据时DMA已经在往后半部分搬数据等CPU处理完前半部分DMA又会在后半部分完成后触发完成中断这时前半部分缓冲区已经空闲可以继续接收后续数据。两个缓冲区交替使用采样流不再有间隙。4.2 启动代码与回调函数CubeMX生成的代码已经帮我们做好了大部分初始化下面是我实际项目里添加的核心内容。首先是启动ADC并打开DMA#define ADC_BUFFER_SIZE 1024 // 一定要用volatile修饰中断和主循环都会访问 static volatile uint16_t adc_buffer[ADC_BUFFER_SIZE]; void adc_start(void) { // 启动ADC之前先清一次DMA中断标记 __HAL_DMA_CLEAR_FLAG(hdma_adc1, DMA_FLAG_TC | DMA_FLAG_HT); // 启动ADC的DMA循环采集 HAL_ADC_Start_DMA(hadc1, (uint32_t *)adc_buffer, ADC_BUFFER_SIZE); }接下来是两个关键回调。HAL库在DMA半传输和传输完成时会自动调用这两个弱函数我们只需要重新实现// 前半部分填满时触发 void HAL_ADC_ConvHalfCpltCallback(ADC_HandleTypeDef *hadc) { if (hadc-Instance ADC1) { // 处理 adc_buffer[0] ~ adc_buffer[ADC_BUFFER_SIZE/2 - 1] process_adc_data(adc_buffer[0], ADC_BUFFER_SIZE / 2); } } // 后半部分填满时触发 void HAL_ADC_ConvCpltCallback(ADC_HandleTypeDef *hadc) { if (hadc-Instance ADC1) { // 处理 adc_buffer[ADC_BUFFER_SIZE/2] ~ adc_buffer[ADC_BUFFER_SIZE - 1] process_adc_data(adc_buffer[ADC_BUFFER_SIZE / 2], ADC_BUFFER_SIZE / 2); } }为什么要分Half和Cplt两个回调因为DMA循环模式下DMA从缓冲区头写到缓冲区尾再跳回头写。前半部分写完的时候触发Half后半部分写完的时候触发Cplt。两个回调加起来刚好覆盖完整缓冲区程序不需要手动维护“当前写到哪了”这个状态机简化和避免出错。4.3 处理D-Cache一致性这是H743用户必踩的坑。H743有D-Cache如果开启D-Cache后DMA直接把数据写进了AXI SRAM而CPU又从Cache里读数据极大概率读到的是缓存里的旧值现象是缓冲区数据“永远不变”或者“过很久才变”。原因很简单DMA写内存不会经过Cache而CPU读数据时会优先命中Cache两边看到的内存不一致。有几个方案可以处理一是干脆不开启D-Cache。对于纯ADC采集然后数据通过串口或网络上传的场景D-Cache带来的性能提升本来就不大不开能省掉所有一致性麻烦。这个方案最安全我很多原型验证板都这么干。二是开启D-Cache但在DMA传输完成回调里手动清理或失效Cache// 半传输回调里做一次Invalidate SCB_InvalidateDCache_by_Addr((uint32_t *)adc_buffer[0], ADC_BUFFER_SIZE / 2 * sizeof(uint16_t)); // 传输完成回调里做一次Invalidate SCB_InvalidateDCache_by_Addr((uint32_t *)adc_buffer[ADC_BUFFER_SIZE / 2], ADC_BUFFER_SIZE / 2 * sizeof(uint16_t));注意一定是在CPU读取这段数据之前做Invalidate并且地址要对齐到32字节边界。如果缓冲区首地址没有对齐SCB的失效操作会出问题表现为一部分数据仍然读旧。稳妥做法是在定义缓冲区时手动对齐__attribute__((aligned(32))) static volatile uint16_t adc_buffer[ADC_BUFFER_SIZE];三是在CubeMX里用MPU把DMA缓冲区所在内存区域配置成Non-Cacheable。这个方案适合既要开D-Cache加速计算、又不想频繁手动操作Cache的场景。MPU配置代码量稍大这里不展开但思路是设置一个Region把缓冲区所在的SRAM范围指定为Non-Cacheable这样DMA写入实时可见CPU读也不需要手动失效。4.4 数据是16位的怎么变成实际电压ADC原始值是0~6553516位模式下要换算成实际电压公式很简单float adc_to_voltage(uint16_t raw) { return (float)raw * 3.3f / 65535.0f; }如果用的是H743内部基准或者VREF不是准确的3.3V需要按实际基准电压计算否则测出来的电压会有系统性偏差。更精确一点的做法是配合外部精密基准源做两点校准测一次0V、测一次满量程把增益和偏置校正系数存下来。实际项目中物料离散和环境温度都会让ADC的增益误差漂移只靠理论公式算精度上限差不多就是百分之零点几到百分之一之间。想精测校准是跑不掉的。5. 实测效果、踩坑记录与硬件注意事项5.1 实测CPU占用到底有多低我在一个电机电流采集项目里用这套方案ADC1 16位分辨率采样率200kSPSDMA缓冲区1024点CPU主频480MHz同时还在跑着FOC控制环路、片内温度监测、LED状态机、OCP保护逻辑。开采集后主循环几乎感受不到采样带来的负担。用调试器统计CPU load采样相关的开销基本在1%以内。对比一下以前用定时器中断方式做同样的事200kSPS的采样率意味着每秒20万次中断每次中断里至少要读一次ADC值及时计算量不重CPU的时间也被切得七零八落。现在每512个点才进一次中断中断频率降到约390Hz每秒只进390次回调单次回调处理512个点总耗时反而比20万次中断少得多。这种“批量处理”思路和网络协议栈里“收包中断NAPI轮询”的理念完全一致嵌入式里很多性能瓶颈都是可以用类似思路解决的。5.2 我踩过的四个坑前几个数据不能用ADC上电和首次启动转换的瞬间内部电路还没有完全稳定前几十个采样点的数值通常偏差明显甚至可能固定是0或者满量程。处理方案很简单启动采集后先忽略前64或128个数据点或者把缓冲区填满后先丢一整个DMA周期的数据再开始处理。我习惯在启动函数里设一个adc_ready_flag等第一次传输完成回调后再置位主循环看到标志位才开始消费数据。DMA数据只写入一半或者全是最后一个值这两个现象我都碰到过。第一种情况地址计数器没有正确回绕往往是因为DMA配置里忘了Memory Increment第二种情况ADC采样值和DMA搬运不同步多半是ADC的DMA请求配置不对或者连续转换模式和定时器触发同时开启了导致ADC内部状态机被打乱。排查方法先把采样率降下来停在定时器触发上用一个非常慢的PWM频率比如100Hz做单次触发用调试器看DMA搬运的数据是不是按照预期逐点递增。先保证低速下链路正确再提速。数据“漂移”同一个直流电压数值缓慢跳动这大概率不是代码问题而是电源噪声或参考电压噪声。H743的ADC参考电压VREF来自芯片供电或有独立VREF引脚。如果VREF上的纹波达到毫伏级ADC结果就会跟着上下漂移。我遇到过一块自制板采样结果在±10 LSB左右抖动最后发现是开关电源纹波直接漏到了VREF引脚加了一级LC滤波后抖动降到了±1 LSB以内。信号幅值偏小尤其是高频信号这不是ADC坏了而是采样保持电容充电时间不足。ADC内部采样开关闭合后采样电容需要在采样周期内把电压充到接近输入信号的电平。如果信号源输出阻抗高或者外部串联了很大的电阻充电时间常数变大采样周期短的时候电压就充不够。解决办法是增加采样周期配置或者用运放做一级低输出阻抗的缓冲把信号源阻抗压到100Ω以下。5.3 硬件设计提醒时钟抖动与电源噪声经常有人说“ADC采样不准是芯片问题”其实很多时候是PCB布局的问题。ADC对时钟抖动非常敏感采样时刻如果出现几百皮秒的抖动对高频信号来说采样电压就会明显变化。所以定时器触发信号和ADC采样时钟的走线尽量短而粗别贴着电感、开关管或者大电流走线走。电源方面我总结三个要点第一VREF走线要独立不要和数字电源混在一起最好是星形连接单独从LDO或基准源拉过来。第二模拟地要单点连接到板级地平面不要让ADC的地电流和电机驱动、继电器驱动的大电流回流路径重叠。第三ADC输入引脚和信号源之间如果加RC滤波器R值别太大C值会影响建立时间尤其是高采样率时外部RC的时间常数直接决定了你能采多宽的信号。RC的取值不是越大越滤波得结合采样周期一起算。现象可能原因排查思路前N个数据异常ADC上电不稳定启动后丢前64~128点数据只有一半DMA Memory Increment未开启检查DMA配置数据全部是同一个值ADC和DMA不同步关掉连续转换核实触发源直流值缓慢漂移VREF噪声VREF加LC滤波高频信号幅值偏小采样电容充电不足增大采样周期或加运放缓冲缓冲区读旧值D-Cache一致性Invalidat或禁用D-Cache按照我个人的体会这套“定时器触发DMA”的组合值得你在任何基于STM32H7的项目里优先尝试。它不仅仅是把采样代码从烦琐的中断中解脱出来更重要的是它改变了整个系统的数据流架构采集链路被硬件接管CPU有了充足预算去跑控制算法、协议栈、界面逻辑这些真正需要“脑子”的部分。第一次搭建时建议先用一个信号发生器生成已知频率的波形把采样率和缓冲区大小调准确认数据链路层层递进无误后再往系统里叠加其他任务。这样会避免很多奇怪的“偶发问题”少走不少弯路。