资讯动态

STM32 ADC与DMA协同配置:从轮询到高频采集的数据完整性实战

发布时间:2026/10/14 6:20:57 来源:尧图企业网站定制
1. 从一次采样数据错位说起为什么ADC和DMA必须一起用刚接触STM32那会儿我做过一个很典型的项目用ADC采集一路电位器的电压然后通过串口把数值打印出来。当时想得很简单ADC转换完成读一下寄存器不就行了于是写了个轮询的代码主循环里不停地启动转换、等待EOC标志、读数据、发串口。单路低速采集的时候这套逻辑跑得挺欢数值也稳定。可后来需求变了要同时采集四路信号而且采样率要提到几十千赫兹问题立刻就暴露了——串口打印出来的数据开始错位通道1的值跑到通道2的位置上偶尔还夹杂着上一次的残留数据。更头疼的是主循环被ADC轮询彻底占满其他任务几乎没时间执行。这个坑其实非常经典。ADC负责把模拟量变成数字量DMA负责把数字量从外设搬到内存这两者单独用都不难难的是让它们协同工作并且在高采样率下保证数据不错位、不丢失、不覆盖。很多人卡在ADC配置上觉得寄存器都设对了却忽略了DMA的搬运节奏和ADC的转换节奏必须严格对齐。一旦节奏错位你看到的就是一堆看似随机、实则必然的脏数据。这篇内容就是围绕“STM32的ADC与DMA配合”展开的。我会从实际项目里踩过的坑出发把ADC的采样时序、DMA的搬运机制、两者联动的配置要点、以及高频采集下的数据完整性保障一层层拆开讲清楚。不管你是刚上手STM32的新手还是已经用过ADC但被DMA绕晕的开发者都能从里面找到可以直接复现的配置思路和排查方法。核心关键词就三个ADC采样、DMA搬运、数据完整性整篇内容都围绕它们转。2. ADC独立运行时的三个隐形陷阱2.1 采样时间设太短数值跳得像心电图ADC的采样时间Sampling Time是很多人随手一填的参数。STM32的ADC采样过程分两个阶段采样阶段和转换阶段。采样阶段相当于一个内部电容在充电充电时间就是采样时间转换阶段才是逐次逼近得到数字量。如果采样时间设得太短电容还没充到输入电压就进入转换了结果自然偏低且不稳定。我见过一个案例某开发者用ADC采集NTC热敏电阻的分压采样时间设成了最小的1.5个ADC时钟周期。常温下数值勉强能看一旦温度升高、分压电阻变化数值就开始剧烈跳动。他以为是热敏电阻质量问题换了好几个型号都没用。后来把采样时间加到71.5个周期曲线立刻平滑了。采样时间怎么选有个经验公式采样时间要大于信号源内阻与ADC内部采样电容形成的时间常数的5到10倍。STM32的采样电容通常在几皮法量级如果你前面的分压电阻是10kΩ时间常数就是几十纳秒理论上1.5个周期假设ADC时钟14MHz约107ns勉强够。但实际PCB有寄生电容、走线电阻再加上信号源本身可能带输出阻抗所以留足余量永远是对的。我一般低速信号直接用最长的采样时间高速信号再根据实测调整。2.2 连续转换模式下的EOC标志处理不当ADC有单次转换、连续转换、扫描转换几种模式。单次转换就是启动一次转一次转完停下等你读连续转换是转完自动开始下一次EOC标志会周期性置位。很多人用连续转换模式然后在主循环里轮询EOC读数据。问题在于如果你读数据的速度跟不上转换速度EOC标志会一直处于置位状态你读到的可能是同一个数据也可能错过中间几次转换。更隐蔽的问题是EOC标志的清除时机。STM32的EOC标志在读取ADC_DR寄存器时自动清除但如果你用了DMADMA读取ADC_DR也会清除EOC。这就引出一个关键点ADC的EOC标志和DMA的传输完成标志是两套独立的机制必须分别处理。我见过有人把EOC中断打开又在DMA里读数据结果中断服务函数里读到的数据已经被DMA搬走了读了个空。正确的做法是如果用DMA搬运ADC数据就不要再开EOC中断去读数据了。让DMA全权负责搬运你只需要关注DMA的传输完成中断或者半传输中断。EOC标志让DMA去自动清除你不需要干预。2.3 多通道扫描时的顺序与数据对齐多通道采集是ADC的常见用法比如规则组里配置了通道0、通道1、通道2、通道3ADC会按顺序依次转换每转完一个通道就把结果放到ADC_DR里。如果你用DMA搬运DMA会按顺序把每个通道的结果搬到内存数组里。这里有个容易忽略的细节DMA的搬运顺序和ADC的扫描顺序必须一致而且内存数组的大小要等于通道数。我踩过一次坑规则组里配了4个通道DMA缓冲区只开了3个元素。结果DMA搬完3个数据后触发传输完成中断第4个通道的数据把缓冲区的第一个元素覆盖了。表现出来就是通道0的数据偶尔变成通道3的值而且随着采样次数增加错位越来越严重。当时排查了很久最后用调试器看DMA的计数器才定位到问题。还有一个细节是数据对齐。ADC的结果可以是左对齐或右对齐12位精度下右对齐时高4位是0。如果你用16位变量接收右对齐直接读就行如果左对齐读出来的数值要右移4位才是真实值。DMA搬运的是原始寄存器值不会帮你做移位所以对齐方式要在软件里统一处理。3. DMA搬运ADC数据的底层逻辑3.1 DMA请求从哪来什么时候触发DMA本身不会主动工作它需要外设发出请求。ADC在每次转换完成后会产生一个DMA请求这个请求告诉DMA“我这边有一个新数据放到ADC_DR了你来搬走。”DMA收到请求后执行一次数据传输从ADC_DR的地址读取数据写到内存里预设的地址然后把内存地址递增如果配置了递增传输计数器减一。这里的关键是ADC的DMA请求频率等于ADC的转换频率。如果你配置的采样率是100kHzDMA每秒就要响应10万次请求。DMA的响应速度取决于总线时钟和DMA控制器的优先级。如果总线上还有其他高优先级的外设在抢带宽DMA可能会来不及响应导致ADC_DR里的数据被下一次转换覆盖也就是常说的“溢出”Overrun。STM32的ADC有个OVR标志当新数据准备好而旧数据还没被读走时OVR置位新数据会丢失。用DMA的时候如果DMA响应不及时OVR就会置位。所以高频采集时要确保DMA的优先级足够高并且总线没有被其他大流量外设占满。3.2 循环模式与普通模式的选择依据DMA有两种主要模式普通模式Normal和循环模式Circular。普通模式下DMA搬完指定数量的数据就停止需要软件重新使能才能继续搬。循环模式下DMA搬完一轮后自动回到起始地址继续搬周而复始。对于ADC连续采集的场景循环模式几乎是唯一合理的选择。你开一个固定大小的缓冲区比如256个元素DMA循环往里填。软件通过半传输中断和传输完成中断来读取数据半传输中断时前128个元素已经填满可以处理传输完成中断时后128个元素也填满了可以处理。这样软件处理数据的速度只要跟得上半缓冲区的填充速度就不会丢数据。普通模式适合单次采集或者低速采集。比如你按一下按键采集一次用普通模式就行搬完一个数据DMA就停了下次采集再重新启动。但如果你在连续转换模式下用普通模式DMA搬完一轮就停了ADC还在转OVR必然置位。3.3 内存地址递增与数据宽度匹配DMA配置里有几个参数必须和ADC匹配外设地址、内存地址、数据宽度、地址递增方式。外设地址固定是ADC_DR的地址不递增内存地址是你的缓冲区首地址通常要递增这样数据才能依次填入数组。数据宽度方面ADC_DR是16位寄存器所以外设数据宽度设为半字16位内存数据宽度也设为半字。如果内存数据宽度设成了字节8位DMA会把16位数据拆成两个字节搬你的数组就会变成高低字节交错读出来的数值完全不对。如果设成了字32位DMA会把两个16位ADC数据拼成一个32位数据搬数组元素数量减半同样错乱。所以外设和内存的数据宽度必须一致且与ADC_DR的位宽匹配。还有一个容易忽略的点内存地址递增的步长。半字宽度下每次递增2个字节。如果你手动算地址要按2字节步长算。用HAL库或者LL库的话这些底层细节库函数会处理但理解原理对排查问题很有帮助。4. ADC与DMA联动的完整配置链路4.1 时钟与引脚的前置检查在配置ADC和DMA之前有两件事必须先确认ADC的时钟源和输入引脚的模拟模式。STM32的ADC时钟通常来自APB2总线经过一个预分频器。ADC时钟有上限比如F1系列不能超过14MHzF4系列不能超过36MHz。如果超频了转换结果会不准甚至完全错误。输入引脚要配置成模拟输入模式Analog Mode。如果配成了浮空输入或者上拉输入引脚上的数字输入缓冲器会引入额外漏电流影响采样精度。这个坑很隐蔽因为配错了ADC也能出数只是数值有偏差低速下不容易发现。我一般会在初始化代码里加一句断言或者打印确认ADC时钟频率在允许范围内。比如用HAL库的HAL_RCC_GetPCLK2Freq()读出APB2时钟再除以ADC预分频系数算出来的值如果超过手册上限就要调整预分频。4.2 ADC规则组的通道顺序与采样时间分配规则组是ADC最常用的转换序列。你可以配置1到16个通道ADC按顺序依次转换。每个通道可以单独设置采样时间这很灵活高阻抗信号源给长采样时间低阻抗信号源给短采样时间。配置时要注意通道顺序和DMA缓冲区的对应关系。比如规则组顺序是通道5、通道0、通道2那么DMA缓冲区第一个元素是通道5的值第二个是通道0的值第三个是通道2的值。软件处理数据时要按这个顺序去解析。我习惯在代码里定义一个枚举或者宏把通道号和数组下标对应起来避免后面读错。采样时间的分配有个经验总转换时间等于所有通道采样时间之和加上转换时间之和。如果总转换时间太长采样率就上不去。比如4个通道每个采样时间设为71.5周期转换时间12.5周期ADC时钟14MHz那么一个通道84周期4个通道336周期采样率就是14MHz除以336约41.6kHz。如果你需要更高的采样率就要压缩采样时间或者减少通道数。4.3 DMA流/通道的优先级与中断配置STM32的DMA控制器有多个流F4/F7/H7或通道F1/F0每个流可以配置优先级。如果系统里还有其他DMA传输比如串口发送、SPI接收就要合理分配优先级。ADC采集通常要求实时性高建议给ADC的DMA流设成高优先级或最高优先级。中断方面至少要使能传输完成中断TC和半传输中断HT。半传输中断让你能在DMA填满半个缓冲区时就开始处理数据减少延迟。如果缓冲区开得足够大只开传输完成中断也行但处理延迟会翻倍。另外如果担心溢出可以把ADC的OVR中断也打开一旦溢出立刻知道方便调试。HAL库的配置顺序一般是先初始化DMA再初始化ADC最后启动DMA和ADC。启动顺序很重要先启动DMA再启动ADC。如果先启动ADCADC开始转换并发出DMA请求时DMA还没准备好第一次请求就会丢失导致数据错位。这个顺序问题在HAL库的HAL_ADC_Start_DMA()里已经处理好了但如果你用LL库或者寄存器操作就要自己保证。5. 高频采集下的数据完整性保障5.1 半传输与全传输中断的配合使用假设你开了一个256元素的缓冲区DMA循环填充。半传输中断在DMA填到第128个元素时触发全传输中断在填到第256个元素时触发。软件的处理逻辑是半传输中断时处理前128个元素全传输中断时处理后128个元素。这样软件有足够的时间处理数据同时DMA在后台继续填充另一半。这里有个细节处理数据的速度必须快于DMA填满半个缓冲区的时间。如果DMA填满128个元素需要1ms你的处理函数必须在1ms内完成否则DMA会追上你覆盖还没处理的数据。我一般会在处理函数里做最少的操作比如只是把数据拷贝到另一个缓冲区或者做简单的滤波复杂的运算放到主循环里慢慢做。如果处理速度实在跟不上有两个办法一是加大缓冲区给软件更多时间二是降低采样率减少数据量。加大缓冲区是最简单的STM32的RAM通常够用开个1024甚至2048元素的缓冲区都没问题。5.2 用双缓冲思路避免数据覆盖双缓冲是解决数据覆盖的经典方案。DMA配置成循环模式缓冲区大小设为2N半传输中断对应前N个元素全传输中断对应后N个元素。软件在两个中断里分别处理对应的半区处理的时候DMA在填另一半互不干扰。但双缓冲有个前提软件处理半区的时间要小于DMA填满半区的时间。如果处理时间超了DMA填完另一半后会回到这一半继续填覆盖还没处理完的数据。所以双缓冲不是万能的它只是把“处理速度跟不上”的问题暴露得更晚一些。真正要解决还是要优化处理逻辑或者降低数据率。我在一个电机控制项目里用过双缓冲ADC采集三相电流采样率20kHz缓冲区开512元素半区256。电流环的计算在中断里做只做Clarke变换和PI调节大概几十微秒就完成了远小于半区填充时间256除以20kHz等于12.8ms所以很稳。5.3 溢出标志的监控与恢复策略即使配置得再好极端情况下ADC还是可能溢出。比如系统突然进入一个高优先级中断DMA响应被延迟ADC_DR被新数据覆盖OVR置位。OVR置位后ADC会停止转换在DMA模式下需要软件清除OVR并重新启动。监控OVR的方法有两种一是开OVR中断一旦溢出立刻进中断处理二是在DMA传输完成中断里检查OVR标志。我倾向于开OVR中断因为溢出是实时事件越早发现越好。OVR中断里要做的事清除OVR标志重新使能ADC的DMA请求必要时重置DMA计数器。但要注意OVR中断本身也会占用时间如果溢出频繁发生说明系统设计有问题不是靠中断能解决的。这时候要回头检查DMA优先级够不够总线是不是被其他外设占满了采样率是不是超过了系统处理能力6. 调试ADCDMA时最容易被忽略的细节6.1 用调试器看DMA计数器判断搬运进度调试ADCDMA问题时光看变量值往往不够因为DMA在后台搬运变量值可能随时在变。这时候要看DMA的传输计数器NDTR。NDTR表示还剩多少个数据没搬初始值等于缓冲区大小每搬一个减一。循环模式下NDTR减到0后自动重载。通过观察NDTR的变化可以判断DMA是否在正常工作。如果NDTR一直不变说明DMA没收到请求可能是ADC没启动或者DMA没使能。如果NDTR变化很快说明搬运正常。如果NDTR减到0后不重载说明DMA配置成了普通模式而不是循环模式。我习惯在调试时把NDTR加到watch窗口里实时观察。这个技巧帮我定位过很多次问题比如有一次DMA配置成了普通模式NDTR减到0就停了但ADC还在转OVR立刻置位。看NDTR就能一眼看出问题。6.2 内存对齐与编译器优化的影响DMA搬运数据时内存地址的对齐方式会影响效率甚至影响正确性。半字传输时内存地址最好是2字节对齐字传输时4字节对齐。如果地址不对齐DMA可能会拆分传输效率降低极端情况下会出错。编译器优化也会影响DMA缓冲区。如果你定义了一个数组作为DMA目标编译器可能会把它优化到寄存器里或者因为没看到“写操作”而认为它没被使用直接优化掉。解决办法是用volatile修饰缓冲区或者用编译器指令禁止优化。HAL库的DMA缓冲区通常定义成全局数组并且加volatile就是为了防止编译器优化。还有一个细节DMA缓冲区的地址在运行时不能变。如果你在函数里定义局部数组函数返回后栈空间被回收DMA还在往那块地址写就会破坏其他数据。所以DMA缓冲区必须是全局的或者静态的。6.3 采样率与串口打印的带宽匹配很多人调试ADC时喜欢用串口打印数据但串口带宽是有限的。比如波特率115200每秒最多传11520字节如果每个ADC数据用ASCII码打印一个数据至少5到6个字节每秒最多打印2000个数据。如果你的采样率是10kHz串口根本来不及打印数据会堆积在发送缓冲区里最后要么丢数据要么阻塞主循环。正确的做法是调试时降低采样率或者用二进制格式传输或者用DMA把数据搬到内存后批量处理。我一般会在调试阶段把采样率降到1kHz以下用串口打印看趋势确认逻辑没问题后再提高采样率用DMA双缓冲的方式处理不再依赖串口实时输出。如果一定要在高采样率下看数据可以用SWO或者把数据存到外部RAM里事后分析。串口打印只适合低速调试高速场景下它本身就是瓶颈。7. 从轮询到DMA的代码迁移实录7.1 轮询版本的典型写法与瓶颈轮询版本的ADC代码通常长这样主循环里调用HAL_ADC_Start()启动转换然后HAL_ADC_PollForConversion()等待转换完成最后HAL_ADC_GetValue()读数据。单通道低速下没问题但多通道时每个通道都要走一遍这个流程主循环被占满。瓶颈在于CPU的时间全花在等待ADC转换完成上了。ADC转换一次需要几十个时钟周期这段时间CPU本可以做其他事但轮询模式下它只能空转。如果采样率是10kHz每次转换100微秒CPU每秒有1秒的时间在空转其他任务根本没法执行。7.2 迁移到DMA版本的关键改动点迁移到DMA版本核心改动有三处第一ADC初始化时使能DMA请求配置DMA为循环模式第二启动时调用HAL_ADC_Start_DMA()传入缓冲区地址和大小第三在DMA传输完成中断或半传输中断里处理数据。具体代码上轮询版本的HAL_ADC_Start()和HAL_ADC_PollForConversion()都要去掉换成HAL_ADC_Start_DMA()。数据处理从主循环移到中断回调函数里。中断回调里不要做耗时操作只做数据拷贝或标记复杂处理放到主循环。迁移后CPU占用率大幅下降。原来轮询时CPU占用率可能80%以上迁移后降到10%以下其他任务有充足的时间执行。这就是DMA的价值把CPU从数据搬运中解放出来让它专注于计算和控制。7.3 迁移后实测数据对比我在一个项目里做过对比测试同样的4通道ADC采集采样率10kHz轮询版本CPU占用率约75%串口打印的数据偶尔错位DMA版本CPU占用率约8%数据连续采集10分钟无错位、无丢失。轮询版本的主循环里只能跑一个任务DMA版本可以同时跑串口通信、按键扫描和LED刷新。这个对比很直观地说明了DMA的优势。但也要注意DMA不是没有代价的它增加了配置复杂度调试难度也更高。如果项目对采样率要求不高轮询版本反而更简单可靠。选择哪种方式要看具体需求。8. 几个真实项目里的经验教训8.1 一次OVR溢出引发的连锁反应有个项目用ADC采集电池电压采样率不高1kHz左右按理说DMA完全跟得上。但现场运行一段时间后系统会莫名其妙重启。排查了很久最后发现是ADC的OVR标志置位后没有处理ADC停止转换但DMA还在等请求软件以为数据在更新实际上读到的是旧数据。电池电压显示一直不变保护逻辑误判触发了重启。这个问题的根因是OVR中断没有使能溢出后软件不知道。后来加了OVR中断处理溢出时重新启动ADC问题解决。这个教训告诉我DMA模式下OVR标志必须监控不能假设它永远不会发生。8.2 采样时间与信号源阻抗的匹配实验前面提到采样时间要匹配信号源阻抗我做过一个实验来验证。用10kΩ和100kΩ两个信号源分别用1.5周期和71.5周期的采样时间采集结果如下信号源阻抗采样时间实测误差10kΩ1.5周期±3%10kΩ71.5周期±0.5%100kΩ1.5周期±15%100kΩ71.5周期±2%数据很清楚地说明高阻抗信号源必须用长采样时间。100kΩ配1.5周期时误差高达15%完全不可用。这个实验让我养成了一个习惯不确定采样时间时先用最长的再根据实测往下调。8.3 缓冲区大小与系统实时性的权衡缓冲区开多大合适开小了容易溢出开大了占用RAM且增加处理延迟。我的经验是缓冲区大小要保证软件在最坏情况下也能处理完。最坏情况包括高优先级中断抢占、其他DMA传输竞争总线、处理函数本身耗时波动。一般我会先估算处理函数的最大耗时然后乘以2作为安全系数再根据采样率算出需要的缓冲区大小。比如处理函数最大耗时100微秒采样率10kHz那么100微秒内DMA会填1个数据乘以2就是2个数据缓冲区至少开4个元素。但实际项目中我通常会开256或512留足余量因为RAM通常不是瓶颈。9. 写在最后一些个人习惯配置ADCDMA时我习惯先把采样率降到很低比如100Hz用调试器看缓冲区里的数据是否正确。确认数据顺序、数值范围都没问题后再逐步提高采样率观察OVR标志和NDTR的变化。这个“先慢后快”的调试节奏帮我省了很多时间因为低速下问题更容易复现和定位。另外我强烈建议在代码里加一个统计变量记录OVR发生的次数。正常运行时应为0如果非0说明系统有隐患哪怕当前功能看起来正常。这个计数器可以放在串口打印里也可以放在调试变量里随时监控。最后DMA缓冲区的定义一定要用volatile并且放在全局或静态区。我见过不止一个案例因为缓冲区定义在局部或者没加volatile导致数据时对时错查了半天才发现是编译器优化搞的鬼。这些细节看起来不起眼但在实际项目里它们往往就是稳定与不稳定的分界线。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑