ARTICLE DETAIL

建站实战干货

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

STM32 ADC-DMA协同采样:告别CPU死等,让主循环飞起来

2026/9/17 7:43:00 拓冰建站 浏览量
STM32 ADC-DMA协同采样:告别CPU死等,让主循环飞起来 前阵子一个做电源管理开发的朋友拿板子来找我说他的主循环已经被ADC采集占满了I2C读取、按键扫描、OLED刷新全都被拖得一顿一顿。我一看代码还是最朴素的轮询方式启动ADC转换后CPU原地死等EOC标志位然后读数据、再启动、再等。问题就出在这个“死等”上——你让CPU当了一个不合格的搬运工明明它才是干活的主力却被一两个寄存器值按在原地。后来我把他的代码改成ADC-DMA协同采样主循环瞬间空了出来采集到的12位电压数据还在后台稳定刷新占用的CPU时间几乎为零。这篇文章就把这套完整思路、计算公式、CubeMX配置细节和我在实际调试中踩过的坑整理出来给想提高电压采样效率、或者正在被“多通道采集卡死主循环”折磨的工程师和学生做个参考。1. 为什么你的采样程序越写越重DMA能替你扛什么1.1 从一次普通轮询采样看CPU浪费假设你用的是STM32F103ADC做了4个规则通道扫描每个通道的采样周期配成1.5周期12位分辨率下单个通道的转换时间是1.167us4个通道一轮下来大概是4.7us左右。如果主循环里像下面这样写问题就出现了while (!(ADC1-SR ADC_SR_EOC)); // CPU空转死等转换完成 value ADC1-DR; // 读走数据这4.7us时间里CPU一秒钟的指令周期差不多能执行几千条指令这些全都在Waiting了。单看一个周期还不觉得疼一旦加上中等速度的外设通信、显示刷新、控制算法每一轮主循环的总延时就越叠越多最后整个系统的实时性就没了。我曾经在一个手持设备上见过更夸张的写法8个通道每采样一轮都死等还在中间夹了几个软件延时拿来稳定时序结果整颗MCU跑起来连2ms的定时器中断都出现明显抖动。用DMA之后这些等待全部消失主循环的耗时从几十微秒降到接近零。1.2 中断方式也会碰到“来不及搬数据”的墙如果觉得轮询太浪费很多人会换成“ADC转换完成中断”的方案每个通道转换完触发一次中断在中断里把ADC_DR寄存器搬到数组。这个方案在慢速采样下确实能用但一旦ADC的转换速率提上来比如做到800Ksps左右也就是1.25us一个采样点每1.25us就要进一次中断每次中断的入栈、出栈、跳转、恢复寄存器这些开销加起来实际上比单纯搬一个数还要贵。你会发现中断方式的CPU占用率并不低而且中断嵌套一多主循环照样卡顿。更危险的是如果某个高优先级中断在这个期间霸占了CPUADC转换完成后没有及时读走ADC_DR下一次转换的结果就会把它覆盖掉最后拿到的是上一个通道的残留值数据直接错乱。所以中断方案不是不行而是它把“搬数据”这个没有技术含量的工作强行塞给了CPU本质上还是没解决核心矛盾。1.3 DMA的真正角色规则组结果的专职搬用工DMA在这套方案里干的事用一句话讲就是ADC每完成一次规则组转换DMA控制器就自动把ADC_DR寄存器里的值搬到你指定的内存缓冲区整个过程不需要CPU参与搬运带宽固定、延迟可控。这里要重点理解STM32的ADC寄存器结构。规则组在ST的ADC设计里只有一个数据寄存器ADC_DR多通道扫描模式下每次转换完成的数据都会往这个寄存器里写如果你上一通道的数据没取走下一通道的结果一来就会把它冲掉。DMA就是为了解决“转换完立刻搬走”这个需求而生的它在ADC的转换完成事件触发下运作搬运一个16位数据的工作量对DMA来说就是几个总线周期的事。所以设计思路上你要把工作拆开看ADC负责“采”DMA负责“搬”CPU只负责“处理”。三者各干各的互不阻塞整个系统才谈得上高效协同。2. 配置前的算账采样周期、转换时间和时钟树2.1 12位转换时间到底怎么算出来的好多人配置ADC时钟时是随手选的后面发现采样率不对或者信号失真也不知道该往哪儿查。这里建议还是把12位ADC的转换时间计算方式吃透公式很简单Tconv (采样周期 12.5) / f_ADC_CLK12.5是12位分辨率下ADC逐次逼近比较阶段固定的时钟周期数所有ST主流M3/M0系列基本都是这个值。以F103为例如果APB2总线时钟是72MHzADC预分频配成6那么ADC_CLK12MHz采样周期配1.5周期时Tconv (1.5 12.5) / 12MHz ≈ 1.167us也就是大约0.86Msps的转换速率单个通道的电压采样能力完全够用。如果你的APB2时钟不是72MHz比如用了48MHz主频那就得重新按这个公式推一次。ADC预分频的选择还要看硬性约束F103的手册要求ADC_CLK最高不超过14MHz所以72MHz主频下只能选6分频或8分频选4分频就超了。2.2 采样周期不是越大越好信号源阻抗是隐藏变量配置界面里采样周期下拉菜单有一堆选项1.5、7.5、13.5、28.5、55.5、71.5、239.5周期。很多人下意识觉得采样周期越大越好其实不然。ADC内部有一个等效的采样保持电容和串联电阻启动采样时这个电容需要从外部信号源充电到足够接近输入电压的的电平充电时间是RC决定的。如果外部信号源的输出阻抗很高例如用一个100kΩ电阻分压网络直接接ADC引脚而采样周期只有1.5周期那么电容还没充满就被切到比较阶段了采到的电压会明显偏低而且通道切换越频繁越明显。一个快速判断方法同一路信号把采样周期从1.5改成55.5之后如果读到的电压值有明显上升那基本可以肯定是信号源阻抗太高、充电时间不够。此时有两条路可走一是软件上把采样周期调大代价是转换速率下降二是硬件上加一级运放跟随器把信号源阻抗降到几十欧级别这才是治本的办法。后面第5章我会展开讲这块硬件细节。2.3 软件连续转换 vs 定时器硬触发什么时候选哪个CubeMX里ADC触发源有“软件触发”和“定时器触发”两类。软件触发模式下只要使能了连续转换ADC会自己一轮接一轮地跑配合DMA循环模式后台数据始终是最新的。这种方案适合电池电压监测、温度采集、面板电位器读取特点是简单代码量少实时性要求不高。定时器触发则适合需要“固定相位采样”的场景比如三相电压采样、电源控制环路。假设你的PWM是20kHz用TIM1的更新事件去触发ADC开始采样那么每个PWM周期你都能保证在同一个时间点采到电压消除了软件主循环实时性差带来的相位抖动。做闭环控制的同学都知道采样点抖动直接影响电流环或电压环的稳定性这点后面第6章专门讲。3. 用CubeMX搭一套“后台自动采集”的ADC-DMA链路3.1 参数面板逐项说明哪些开关容易理解错直接以STM32F103在CubeMX里的配置为例我把每个关键项实际含义说清楚。开启ADC1之后先添加要采的通道比如IN0、IN1、IN2、IN3然后在Parameter Settings里做关键配置ScanConvMode扫描模式启用开启规则组多通道自动扫描。如果你只采一个通道也可以关掉但多通道场景必须开。ContinuousConvMode连续转换启用让ADC在一轮转换结束后立刻重新扫描。如果关掉它DMA搬完一轮数据后就会停在那里等下一次触发。Number of Conversion填4表示规则序列有4个转换依次是Rank1到Rank4。External Trigger Source默认软件触发先不动后面进阶再换成定时器触发。DMA Continuous RequestsF103系列没有这个选项这是G0/G4系列里的。如果你用的是F1DMA工作模式直接在DMA设置里选Circular。接下来是DMA SettingsAdd一个ADC1的DMA请求模式选Circular数据宽度外设和内存都选Half Word。这里的Half Word对应uint16_t正好是12位ADC结果的长度选Byte会截断选Word会浪费空间且读数容易错位。配置界面操作完了这只是第一步。生成代码后必须确认main函数里真的调用了HAL_ADC_Start_DMA(hadc1, adc1_raw_buf, 4);我在很多项目里见过有人CubeMX配完就直接下载发现DMA根本不动最后定位才发现是忘了调用启动函数。生成代码里的注释不会替你干活这句启动代码才是让整个DMA链路真正跑起来的钥匙。3.2 环形缓冲和半传输中断在哪里开DMA的Circular模式本质上就是一个环形缓冲——DMA搬完一轮后地址自动回到缓冲起始位置然后继续往同一个缓冲区写入新数据。也就是说DMA在循环模式下不会自己停下来ADC那边只要不停转换DMA就一直在背后搬运。很多人以为开了环形缓冲就能同时在被覆盖前处理完所有数据结果发现主循环读到的数据已经是“新新旧旧混在一起”的。解决办法是利用DMA的两个中断传输完成中断和半传输中断。半传输中断在缓冲区填了一半时触发传输完成中断在整轮填完时触发。两个中断配合相当于把缓冲区切成了前后两个半区DMA写后半区的时候CPU正好处理前半区这其实就是最简单的乒乓结构。CubeMX里要在NVIC设置中使能DMA通道的中断然后代码里自己实现这两个回调函数void HAL_ADC_ConvHalfCpltCallback(ADC_HandleTypeDef *hadc) { // DMA填满缓冲前半段时进入处理前半段数据 } void HAL_ADC_ConvCpltCallback(ADC_HandleTypeDef *hadc) { // DMA填满缓冲全部时进入处理后半段数据 }3.3 HAL库回调函数里该怎么写数据处理用HAL_ADC_ConvCpltCallback做数据后处理是最常见的做法比如把原始ADC值转换为电压。下面是4通道采样的标准段落__IO uint32_t adc1_raw[4]; float adc1_vol[4]; void HAL_ADC_ConvCpltCallback(ADC_HandleTypeDef *hadc) { if (hadc-Instance ADC1) { for (uint8_t i 0; i 4; i) { adc1_vol[i] (adc1_raw[i] 0xFFFF) * 3.3f / 4095.0f; } } }注意两个细节。第一HAL_ADC_Start_DMA要求缓冲区类型是uint32_t指针所以adc1_raw声明为uint32_t数组但因为ADC数据宽度是Half Word实际有效数据只占低16位转电压前最好先掩码一下。第二这个回调运行在DMA中断上下文里函数体一定要短千万不要在这里面调用阻塞型串口发送或者长延时否则DMA搬运本身也会被拖慢高采样率下容易出现数据覆盖或者缓冲区错位。我自己的习惯是回调里只做数据拷贝或简单换算如果需要对外发送调试数据就在主循环里统一处理或者用一个标志位通知主循环“这轮数据已经ready了”。4. 多通道数据错位、只采到一遍、滤波器不生效的完整排查链路4.1 最经典的坑DMA只搬运了一轮就再也不动现象很典型调试器里看adc1_raw数组只有第一次有数据后面的数值永远不变像是采样卡住了。这一般有三个原因按排查顺序来第一DMA模式被配置成了Normal而不是Circular。Normal模式下DMA搬完指定长度后自动停止只有在ADC触发的搬运数量正好用完时才不再继续。第二ADC的ContinuousConvMode被关了。关掉后ADC每次转换完一轮就停DMA自然没有新数据可搬。第三F103上这两个条件同时满足的情况最多。G0/G4系列上还要看有没有勾选“DMA Continuous Requests”这个选项在G0的ADC配置里实际上是让DMA循环重新装载用的不勾就会停止传输。排查逻辑很简单先把DMA模式改成Circular然后确保ContinuousConvMode是Enable如果是G0系列再把DMA Continuous Requests勾上。三步做完90%的“只采一遍”问题都能解决。4.2 多通道数据错位到底是哪个通道的数据“我是按IN0、IN1、IN2、IN3的顺序配的但读出来IN0的数值其实对应IN2的引脚电压。”这类错位问题也很常见根因主要出在规则序列的Rank配置上。CubeMX里每个通道都有一个Rank编号Rank1转换的是你指定的一个通道Rank2是另一个通道。DMA缓冲区中的顺序严格按照Rank序号排列buffer[0]永远对应Rank1buffer[1]对应Rank2以此类推。所以如果你在界面里改动了通道和Rank的对应关系却没有同步修改程序里数组索引的含义数据对应关系就会乱。排查这类问题我有个笨办法但非常有效先把除了IN0以外所有通道在CubeMX里禁用下载跑一遍确认buffer[0]读到的是IN0然后只启用IN0和IN1确认buffer[1]是IN1一个通道一个通道地加直到全部确认完。不要嫌麻烦这一步能帮你把接线错误和Rank配置错误区分开来省得后面怀疑人生。4.3 缓冲区长度和数据位宽搞错数据怎么看都不对缓冲区长度必须是“一轮扫描的转换总数”4个通道就是4不是1也不是16。填1的话DMA只会搬一个转换结果后面的数据永远不动。填16的话DMA会一直等到搬完16个数据才触发完成中断仿真时看起来像是数据不更新。位宽错的话现象比长度错更隐蔽。F103做12位右对齐时DMA外设数据宽度选Half Word没问题。如果你选成了Word读出来每个值都占了32位低位也许是对的但高16位会出现不可预料的垃圾值或者上一次的残留数据算出来的电压直接漂到天上去。选Byte的话就更离谱12位数据被硬生生截断成8位看起来值还在变但精度完全没了。还有一个隐藏点如果你在ADC配置里选择了数据左对齐那读取时必须将原始值右移4位才能得到标准的12位采样值。4.4 软件滤波不是万能的但它能救你硬件RC滤波和软件滤波是两码事。如果你的信号源本身带有较大的开关噪声比如电机驱动板上的母线电压采样噪声频率可能远高于采样率这时候在ADC输入引脚前放一个RC低通是首选转折频率根据目标噪声频带来选。软件方面滑动平均滤波适合处理随机噪声能有效平滑数据曲线。窗口大小我建议取2的幂次例如8、16、32这样除法可以用移位替代MCU算得快很多#define FILTER_SIZE 8 uint32_t filt_buf[FILTER_SIZE]; uint8_t filt_idx; uint32_t filt_sum; uint32_t moving_average(uint32_t new_val) { filt_sum - filt_buf[filt_idx]; filt_buf[filt_idx] new_val; filt_sum new_val; filt_idx (filt_idx 1) (FILTER_SIZE - 1); return filt_sum 3; }如果噪声表现为突然的尖峰脉冲比如继电器开合瞬间的毛刺滑动平均会被一个尖峰带偏这时候用中值滤波更合适。取连续3个值排序取中间就能把尖峰剔掉代价是每个采样点需要等待两个新值过来。uint32_t median3(uint32_t a, uint32_t b, uint32_t c) { if (a b) { uint32_t t a; a b; b t; } if (b c) { uint32_t t b; b c; c t; } if (a b) { uint32_t t a; a b; b t; } return b; }把中值滤波和滑动平均串起来用先剔尖峰再平滑是工程上性价比很高的组合。不过有一点要记住滤波会带来相位滞后如果你做的是电源闭环控制软件滤波容易让环路产生延迟严重时会导致振荡。控制环路的采样值通常更推荐硬件滤波加同步采样而不是靠软件去磨。5. 从“能采”到“采准”校准、参考电压和PCB上的细节5.1 ADC上电校准不是仪式是去offset误差STM32的ADC内部比较器、采样电容和偏置电路免不了存在偏差这种偏差会表现为固定偏置误差在F103上通常在正负十几个LSB的水平。对于一个满量程3.3V、12位的系统12个LSB大约相当于10mV的误差有时候甚至更难看。F1系列的校准流程很简单在ADC初始化完成后、启动DMA之前调用HAL_ADCEx_Calibration_Start(hadc1);校准的原理是ADC内部自动把输入短接到参考电压VREF然后跑一次转换把测到的误差存进校准寄存器后续转换过程由硬件自动修正。注意整个ADC模块在工作过程中不要随意改动校准时序校准完成后如果工作在连续模式下误差修正会一直生效。G0/G4系列还多了一步叫做“带失调校准”的东西有的型号还有增益校准。不同系列函数名略有差异但总思路一样上电初始化ADC后先校准再开工。我之前碰到过一个批量板子采集电压整体偏高的案例就是工厂在软件里漏了校准函数后来加上去偏差立刻降到个位数LSB。5.2 参考电压VREF和电源噪声直接影响你算出来的电压值很多开发板没有独立的VREF引脚直接内部连VDDA。这种情况下你的ADC参考电压就是模拟电源电压。如果VDDA用的是某个LDO输出的3.3V那么LDO的实际输出精度和纹波都会直接影响采集结果。举一个真实场景板子主电源是Buck输出5V又用一个普通LDO拉出3.3V给MCU供电。Buck的开关纹波如果没被LDO完全抑制掉在ADC的虚拟参考上就能看出一串周期性的波动采样结果也会跟着抖。排除这种问题最直接的办法就是示波器看VDDA波形纹波超过30mV的时候ADC低几位基本就是在随机跳。PCB布局上有3个点是我做过的项目里被验证过最有用的第一模拟电源和数字电源在芯片处单点连接或者用磁珠/小电阻隔开。不要让MCU引脚翻转产生的数字噪声直接耦合进VDDA。第二VREF/VDDA引脚的退耦电容要紧贴芯片引脚放常见做法是1uF和100nF并联低ESR的多层陶瓷电容为佳。第三ADC输入信号线要远离时钟线、PWM输出线等高频边沿信号必要的时候在输入线上做包地处理。5.3 信号源阻抗太高先把跟随器加上前面第2章提到过高阻抗导致采样电容充不满这里展开讲透。STM32内部ADC输入回路的等效结构大致是一个串联电阻加一个采样电容。串联电阻通常是千欧级别采样电容只有几皮法但RC时间常数不小。信号源阻抗如果也是几十千欧时间常数成倍放大1.5周期采样根本充不满最终测到的电压值会明显低于真实值。判断方法有两种。一种是用示波器直接量ADC引脚的电压如果探头一搭上去电压就往下掉说明驱动能力不足。另一种是前面说的加大采样周期看读数是否显著变化变化超过10到20个LSB基本就是源阻抗问题。解决方案就是在信号源和ADC引脚之间插入一个运放做成电压跟随器。运放的输入阻抗极高输出阻抗极低ADC采样电容的充电速度就不再受信号源制约。要注意选输入输出都到轨的运放比如SGM8521、MCP6002这类轨到轨低功耗运放不然当输入信号接近GND或VDD时运放自己先削波了后面的校准和滤波全白做。5.4 时钟抖动和ADC采样时刻稳定性热词里有条“规避时钟抖动与电源噪声的3个PCB布局要点”我在实际项目里也确实踩过这个坑。ADC时钟源如果和数字电路共享同一个PLL而PLL的环路带宽又不够高速数字信号的串扰会导致ADC时钟边沿轻微抖动。时钟抖动最直接的影响就是采样时刻不稳定。对于一个缓慢变化的直流电压抖动影响并不明显但如果你在采高dv/dt的波形比如开关节点电压经分压后的信号采样时刻偏移几十纳秒得到的瞬时值就会有明显跳动。布局上时钟线尽量短不要贴着电源开关管走晶振底部不要覆铜模拟信号走线远离时钟源。这些看起来都是老生常谈但在ADC采样系统中特别容易被忽略。6. 进阶定时器触发加乒乓缓冲把采样做成真正的采样系统6.1 连续转换在闭环控制场景里的局限性连续转换加DMA循环的架构在后台监控类任务里非常舒服但放到电源控制或电机控制里就不够了。这类场景对采样的要求是“每个PWM周期的固定时刻采一次”而不是“随时采采到啥算啥”。连续转换模式下ADC自由跑着你不知道当前这个采样点对应PWM周期里的哪一个相位尤其是在程序主循环被其他任务打断时采样点的位置会乱跑。控制环路算出来的占空比本身没错但采样和PWM没有对齐环路增益就会出现偏差轻则动态响应变差重则直接震荡。解决办法就是让定时器来触发ADC准确说是让PWM定时器的某个事件去触发规则组转换开始。ST的大部分定时器都能输出TRGO信号而ADC的外部触发源可以直接选择这个TRGO。6.2 一个适用于电源控制的定时器触发配置在CubeMX里配置的路径大概是先配置好一个高级定时器比如TIM1让它输出你需要的PWM频率和占空比同时开启TRGO事件然后在ADC的External Trigger Conversion Source里选择Timer 1 Trigger Out事件。这样做的意义在于PWM的周期和ADC的采样周期完全同步每个PWM周期开始后定时器计数到指定位置TRGO信号发出ADC立即启动一次规则组扫描。整个过程是硬件链路不经过CPU采样时刻的抖动被降到硬件级通常在几十纳秒以内。代码上只需要在初始化完成后调用一次HAL_ADC_Start_DMA(hadc1, adc1_raw, 4);之后ADC会一直等定时器触发定时器每个周期触发一次采样DMA自动搬走结果。主循环只负责读数据、做算法完全不用管采样的时序命脉。6.3 乒乓缓冲和半传输中断适合大块波形采集如果你不是只采几个平均值而是想采集一段完整的电压波形比如一个工频周期的220V整流后的电压波形那么128点、256点的批量采集更合适。这时候DMA的Normal模式反而更好用触发一次采集一批搬完就停然后CPU通过传输完成中断来处理这批数据。如果采集长度进一步拉长到1024点甚至更高可以考虑乒乓缓冲。缓冲区开成2048点DMA循环模式跑起来半传输中断触发时表示前半已经填好了传输完成中断触发时表示后半也填好了。CPU处理前半段数据的同时DMA正在后台写后半段互不干扰吞吐量比用一个大队列高不少。实现起来不复杂关键是回调里要做好“半区”和“整区”的处理区分一般用两个指针分别指向前后半区回调触发时各干各的活数据处理函数的入参是半区起始地址和长度。我在实际项目里更喜欢用这种思路做高速波形记录比如用电采样来判断继电器触点是否抖动、分析开关电源上电瞬间的浪涌波形。ADC-DMA跑在后台前台的业务照常执行互不影响整个系统的架构一下子就清爽了。最后再分享一个调试习惯ADC数据从DMA缓冲区到最终电压曲线的过程最好不要只靠IDE变量观察窗去盯太费眼神。我通常会把经过DMA采集的原始数据用串口DMA发出去配合串口助手的波形绘图功能直接看曲线。这样能非常直观地区分“硬件采坏了”和“软件算错了”——如果原始曲线光滑正常说明ADC-DMA链路没问题问题出在后级滤波或电压换算如果原始曲线本身就是毛刺横飞那就回到信号源阻抗、参考电压和PCB布局上找原因。这个顺序帮我在好几个项目里省了一整天的排查时间。