简介:面向STM32F4系列嵌入式开发者的ADC多通道采集工程源码,采用DMA方式实现多路模拟信号的高效数据搬运,避免CPU等待转换结果,显著提升数据吞吐率与实时性,适合传感器监测、设备控制等场景。压缩包内共128个文件,以60个C源码和60个头文件为核心,配套hex可烧录固件、Keil工程配置文件及辅助脚本,整体大小仅572KB,可快速加载到常见STM32F4开发板实验。代码覆盖ADC时钟、通道初始化、多通道转换序列排布、采样时间调节、DMA参数配置、传输完成中断服务及数据缓冲区处理等完整流程,同时涉及外部触发启动与异常处理等实用技巧,开发者可以对照标准外设库理解底层寄存器设置和库函数调用逻辑。已有1372人学习浏览,无论用于多路传感器数据采集、监测系统还是电机控制,都能借助这套源码快速搭建稳定的采集链路,有效降低开发门槛与调试成本。资源目录结构紧凑,便于随时查阅各模块配置代码。 前两天群里有人发了一段ADC采集的代码,四路模拟量,DMA方式,但读出来只有第一路是准的,后面三路要么是0,要么跟着第一路的值一起变。这种问题我在STM32 ADC多通道采集的帖子里见过不下十次。很多人以为只要把CubeMX里的通道配齐、打开DMA,数据就会按顺序乖乖躺进数组里,实际上一旦涉及扫描模式、连续转换、DMA循环这三个开关的组合,任何一个没配对,出来的数据就是乱七八糟的。这篇就把ADC多通道DMA采集的完整配置、数据排序规则和几个高频翻车现场复盘一遍,适合正在做多通道采集、被数据错位和卡死问题折磨的人参考。
1. 多通道采集为什么推荐DMA:三种采集方式的差别
ADC采集本身不难,难的是“多通道”和“实时性”两个词一起出现。同样是读四路电压,轮询、中断、DMA三种方式,写出来的代码难度和运行效果差很远。
1.1 轮询模式:代码最简单,代价是CPU空转
轮询模式是最直观的写法:启动一次转换,死等转换完成标志位,然后读数据寄存器。单通道这么玩没问题,但换成四通道,一次完整采集就需要依次等待四次转换结束。每次等待期间CPU什么事都干不了,只能在那空转。
如果ADC时钟是12MHz,一个通道转换周期是14个ADC时钟(采样周期用默认值1.5周期加上12.5周期转换时间),单次转换大约1.17微秒,四通道就是不到5微秒。听起来不长,但如果你要做200Hz的实时控制,CPU每5毫秒就要空转5微秒,占比虽然只有0.1%,可一旦系统里还有PID运算、通信协议、显示刷新,这种空转就会被无限放大。更麻烦的是,轮询方式读到的数据在时间轴上天然是错开的,四个通道的数据其实相差了数微秒,某些同步采集场景是不能接受的。
1.2 中断模式:适合低频,高频下是灾难
中断方式比轮询先进一些,每次转换完成后进入中断,在中断里读取数据并启动下一次转换。它的优点是不需要CPU死等,但代价是中断频率成倍上升——四通道每采集一轮就要进四次中断。每次中断进入、压栈、读取、出栈,实际开销远大于轮询里的那个空while。
当采集频率不高、通道数不多时,中断方式完全够用。但当你把采样频率拉到几十千赫,或者通道数扩展到八个以上,CPU的大部分时间都会耗费在中断切换上,主循环反而被饿死。我见过一个项目用中断方式做八通道20kHz采样,结果中断占用时间超过30%,稍微加一点逻辑处理程序就崩了,最后不得不改成DMA。
1.3 DMA方式:数据搬运不再占用CPU
DMA方式最直观的理解是:ADC转换完一个通道的数据后,DMA控制器自动把结果从ADC的数据寄存器搬到内存数组里,整个过程不需要CPU参与。你要做的只有两件事:第一,在启动时调用一次HAL_ADC_Start_DMA;第二,等整轮转换全部完成后,在DMA传输完成回调里拿到数据。
这意味着CPU在ADC转换期间完全解放,可以去做别的事情。四通道的转换顺序、数据排序、搬运地址,全部由硬件和DMA配置决定,不依赖中断响应速度。对于需要高频率、多通道、数据需要同步处理的场景,DMA基本是唯一合理的选择。用一句话概括:轮询是让CPU亲自盯,中断是让CPU频繁跑腿,DMA是派一个专职快递员,数据直接送货上门。
2. CubeMX配置里最容易搞错的三个开关:扫描、连续转换与循环模式
不管你是用CubeMX自动生成代码还是纯寄存器开发,多通道DMA采集的本质就是组合几个模式开关。这三个开关各自独立又相互影响,但网上很多教程只讲了其中一个,导致大家配完之后出现各种奇怪现象。
2.1 ADC参数面板里必须逐项确认的配置
先以CubeMX配ADC1为例,Parameter Settings里这几个参数,每一个都和最终行为直接相关:
| 配置项 | 推荐值 | 说明 |
|---|---|---|
| ScanConvMode | Enabled | 多通道必须开启,否则只转第一个Rank |
| ContinuousConvMode | Enabled | 连续转换,转换完一轮紧接着下一轮 |
| DMA Continuous Requests | Enabled | DMA持续搬运,和连续转换配合使用 |
| End of Conversion Selection | EOC flag at end of sequence | 整轮转换结束才产生事件,适合多通道 |
| Number of Conversion | 具体通道数 | 比如四通道就填4 |
| Rank | 依序选择对应通道 | Rank1是第一次转换,Rank2是第二次,依此类推 |
ScanConvMode这个开关我单独强调一下。很多人以为只要把多个Rank配置上了,ADC就会自动扫描所有通道,实际上扫描模式不打开,ADC只认Rank1对应的那一个通道,后面配置的Rank2、Rank3、Rank4全部被忽略。这是“只采到第一路”最常见的根因。
End of Conversion Selection也容易被忽略。默认情况下EOC在每个通道转换结束后都会置位,如果此时恰好DMA配置有偏差,很容易出现数据还没搬完就被下一次转换覆盖。多通道场景建议直接选“End of sequence conversion”,确保整轮都转完了才产生一次事件,数据完整性更有保障。
2.2 DMA配置里的两个关键参数
DMA部分在CubeMX里选ADC1的请求,然后重点看两个参数。第一个是Mode,必须选Circular循环模式。选Normal的话,DMA搬完一轮数据就停了,ADC还在继续转换,但再也没有人帮它搬运数据,数组里的值永远是第一轮的旧数据。第二个是Data Width,ADC的数据寄存器是16位有效,DMA的数据宽度建议Memory和Peripheral都设成Half Word,也就是16位,跟缓冲区类型保持一致。这里如果配成Word,缓冲区声明成uint16_t,DMA每次搬运4字节,数组会被写穿,后果就是数据错乱甚至意外覆盖其它变量。
这三个开关配好之后,CubeMX会自动生成MX_ADC1_Init和MX_DMA_Init两个函数。我的建议是生成完代码后不要急着下载,人肉检查一遍初始化顺序。DMA的初始化必须在ADC初始化之前,否则外设请求和对应DMA通道之间可能还没建立好关系。CubeMX生成的顺序一般是对的,但如果你手改过代码,这一点很容易被破坏。
3. DMA缓冲区里数据是怎么摆放的:通道排序与缓冲区大小计算
多通道DMA采集里另一个高频疑惑是:数据到底按什么顺序放进数组的?数组该开多大?搞清楚这两件事,代码基本就成功了一半。
3.1 缓冲区长度公式与对应关系
假设Rank1配置成通道0,Rank2配置成通道1,Rank3配置成通道2,Rank4配置成通道3,那么ADC转换启动后,第一次转换结果写到adc_buf[0],第二次写到adc_buf[1],第三次写到adc_buf[2],第四次写到adc_buf[3]。也就是说,DMA缓冲区里数据的顺序和你配置的Rank顺序严格一一对应,而不是按照通道号的数值大小排序。
缓冲区长度公式很简单:缓冲区长度 = 通道数 × 每个通道需要采集的次数。比如四个通道,每通道只采一组数据,数组就是uint16_t adc_buf[4],执行HAL_ADC_Start_DMA(&hadc1, (uint32_t*)adc_buf, 4),第三次参数填4,表示一次性搬运4个数据。
如果每通道要采16次用于软件滤波,那数组就是uint16_t adc_buf[4 * 16],启动时第三次参数填64。搬运完成的标志会在整批64个数据都搬完后产生一次回调,这种批处理方式特别适合和均值滤波结合。
3.2 HAL库启动与读取代码示例
启动代码和单通道没有太大区别,核心就一行:
uint16_t adc_buf[4]; HAL_ADC_Start_DMA(&hadc1, (uint32_t *)adc_buf, 4);启动之后不要再用HAL_ADC_GetValue(&hadc1)去读数据,DMA模式下真正的数据在adc_buf数组里。读取逻辑是:
// 第一路电压,假设参考电压3.3V且没有分压 float voltage_ch0 = adc_buf[0] * 3.3f / 4095.0f;如果你想一次性把四路都读出来,按索引直接取就行:
uint16_t ch0 = adc_buf[0]; uint16_t ch1 = adc_buf[1]; uint16_t ch2 = adc_buf[2]; uint16_t ch3 = adc_buf[3];不要在转换过程中频繁去读这个数组,DMA每转完一个通道就会往数组里写一次数据,你在这个间隙去读,读到的可能是新旧混合的中间状态。要么等转换完成回调触发后再统一读取,要么在主循环里用一个标志位判断当前是否处于稳定状态。
3.3 进阶:一次DMA循环采集多组数据
很多实时性要求高的项目,不是简单采一次就用,而是连续采几十次做均值。这时候DMA的环形缓冲区优势就体现出来了。比如四个通道各采16次,数组大小64,回调触发时数组里的数据是最近64次转换结果。
均值处理的索引计算是重点,直接看代码:
#define CH_NUM 4 #define SAMPLE_NUM 16 uint16_t adc_buf[CH_NUM * SAMPLE_NUM]; uint16_t filtered[CH_NUM]; uint32_t sum; void ProcessADCData(void) { for (uint8_t ch = 0; ch < CH_NUM; ch++) { sum = 0; for (uint8_t i = 0; i < SAMPLE_NUM; i++) { sum += adc_buf[i * CH_NUM + ch]; } filtered[ch] = (uint16_t)(sum / SAMPLE_NUM); } }关键在adc_buf[i * CH_NUM + ch]这个索引:DMA按转换次序连续写,第一轮四个通道存在索引0、1、2、3,第二轮存在4、5、6、7,所以第i轮第ch个通道的位置就是i * CH_NUM + ch。这样处理后,filtered数组里就是四路均值结果,拿来滤波或者上报都很方便。
4. 踩坑实录:多通道ADC+DMA最常见的五个翻车现场
这一节写的都是我在实际项目里遇到或者帮别人排查过的真实故障。每个症状都有对应的排查链路,建议收藏起来,遇到问题按顺序查。
4.1 只采到第一路:扫描模式没开
症状非常典型:adc_buf[0]随输入电压变化,adc_buf[1]到最后全是0,或者复制的第一路的值。
排查链路:先看CubeMX里ScanConvMode是不是Enabled。如果用的是寄存器开发,检查ADC_CR1寄存器的SCAN位。扫描模式不打开,ADC根本不会按Rank表走到第二个通道,后面的转换请求全部被忽略。配置正确后,再用调试器全速运行,在回调里打断点看数组内容,确认是否四路都有变化。
这个坑之所以反复出现,是因为CubeMX在默认新建工程时,ScanConvMode初始是Disabled,你添加了多个Rank但忘了把扫描模式打开,代码生成后也不会报错,只有运行时才暴露问题。
4.2 采了一轮就停:DMA循环模式没开
症状:串口打印第一轮四路数据是正确的,但第二轮开始数据不变,永远是第一次采集的值。
排查链路:检查DMA配置里Mode是不是Normal。Normal模式的含义是搬运完指定数量数据后,DMA通道自动关闭。ADC如果配置成连续转换,它还在后台不断转换,但DMA已经不工作了,数组自然没人更新。解决办法有两种:一是把DMA的Mode改成Circular,让它搬完一批自动接着搬下一批;二是不改DMA配置,在DMA传输完成回调里重新调用一次HAL_ADC_Start_DMA。第二种方式多一次重复启动逻辑,不如第一种干净。
4.3 数据错位与覆盖:DMA数据宽度和缓冲区类型不匹配
症状:改变第二路输入电压,数组里对应位置没变,反而另一个索引的值跟着变,或者整个数组的数据看起来像是被某种规律打乱了。
排查链路:优先检查DMA的Data Width设置。ADC数据寄存器是16位有效,但如果你把外设和内存数据宽度都设成Word(32位),同时缓冲区声明为uint16_t数组,那么DMA每写一次数据实际上占用了两个数组元素的位置,相当于把相邻通道的数据覆盖掉了。反过来,如果缓冲区声明成uint32_t,DMA宽度设Half Word,数组里会出现高低位错位的情况。正确做法很统一:DMA宽度用Half Word,缓冲区类型用uint16_t,两者严格对齐。
4.4 回调里做重活,系统卡死
症状:程序跑着跑着就死机,或者主循环里的其他任务明显变慢。把回调函数里的处理代码注释掉,程序就恢复正常。
排查链路:DMA传输完成回调是在中断上下文执行的。多通道DMA的转换周期可能只有几十微秒,如果你在HAL_ADC_ConvCpltCallback里做了浮点运算、库函数打印、OLED刷新这类耗时操作,下一轮DMA中断已经在中断队列里排队了,长期积累导致栈溢出或者中断响应异常。
正确姿势是回调里只做最快的事情。比如置一个全局标志位,或者用memcpy把数据快速拷贝到另一个缓冲区,真正的滤波和业务处理放到主循环里。
volatile uint8_t adc_ready = 0; void HAL_ADC_ConvCpltCallback(ADC_HandleTypeDef *hadc) { if (hadc->Instance == ADC1) { adc_ready = 1; } }主循环里检测adc_ready再处理数据,处理完清掉标志。这个模式在DMA采集里比什么都稳。
4.5 数值整体偏移:采样周期和阻抗不匹配
症状:用万用表量引脚明明是2.5V,ADC读回来却稳定在2.3V左右,而且这个偏差随信号源内阻变化。
排查链路:ADC内部采样电容需要在采样阶段将电压充到与外部信号源一致,采样时间太短、信号源阻抗太大、外部没有接滤波电容,都会导致采样电压建立不充分。解决方法最直接的是把采样周期调大。CubeMX里每个通道的SamplingTime都可以单独配,比如从1.5周期改成239.5周期。ADC时钟在12MHz时,239.5周期的采样时间大约20微秒,大部分几十千欧以内阻抗的场景都够用了。另一个有效方案是在ADC输入引脚和地之间并联一个0.1uF电容,能显著降低信号源阻抗对采样电容的影响。
4.6 容易忽略的GPIO模拟输入配置
还有一个看似低级但实际高频的问题:ADC引脚没有配置成模拟输入模式。直接表现是采集值有变化但噪声很大,或者数值表现不稳定。
STM32的GPIO引脚在作为ADC输入时,必须配置为Analog模式。如果保持默认的推挽输出或者复用模式,引脚内部结构会引入额外的导通电阻和寄生电容,直接影响采样精度。用CubeMX时,在Pinout界面把对应引脚选为ADC通道,它会自动设为Analog。如果是手写寄存器配置,记得把GPIO的MODER寄存器对应位设成Analog模式。这个细节不处理,后面所有精度优化都是白费。
5. 精度再提高一步:采样周期设定与软件滤波的实操建议
数据能稳定采出来了,接下来的重点就是精度。ADC的精度受硬件布局、参考电压、采样周期和软件算法四方面影响,其中采样周期和软件滤波是开发者可以控制的。
5.1 采样周期的计算公式与选择策略
ADC一次完整的转换时间由两部分组成:采样时间和转换时间。12位分辨率下转换时间是固定的12.5个ADC时钟周期,采样时间则由SamplingTime寄存器位决定,可选值从1.5周期到239.5周期不等。
总转换时间公式:
总转换时间 = (采样周期 + 12.5) / ADC时钟频率在ADC时钟12MHz下,如果采样周期选1.5周期,总转换时间约1.17微秒;选239.5周期,则是21微秒。频率和精度是矛盾的,信号源阻抗越高,需要的采样时间越长。我的个人经验是:
- 信号源阻抗低(几百欧以内)、线短、要求高速采集时,用1.5周期或较少周期,配合高质量参考电压;
- 信号源阻抗中等(几十千欧)或经过分压电阻网络采集时,默认直接用239.5周期,省去排查建立时间的麻烦;
- 采集电池电压、温度传感器这类变化缓慢的信号时,采样周期拉满,转换频率低一点完全无所谓。
如果多个通道信号源特性差别很大,CubeMX里可以给不同Rank配不同采样时间,不用为了某个慢速通道把整个ADC拖慢。
5.2 三种常用滤波方式怎么选
软件滤波本质上是牺牲时间换精度,DMA批量采集为滤波提供了天然便利。
中值滤波适合滤除尖峰毛刺,比如电机启动瞬间的电磁干扰,方法是连续采N个值取中间值。均值滤波适合处理随机噪声,直接求算术平均,是ADC采集中使用频率最高的方法。滑动平均是均值滤波的改进版,每次新数据进来只计算最近N个数据的平均值,适合实时性要求高的闭环控制。
三种方式没有绝对好坏,看场景。如果是采集电池电压,均值滤波就可以;如果是采集存在脉冲干扰的电流信号,中值滤波更稳。
5.3 与DMA缓冲区结合的均值滤波实现
借助DMA的批量搬运能力,均值滤波可以写得很优雅。配置上把DMA的目标缓冲区开成通道数 × 采样次数的大小,启动时一次填满,然后等一轮DMA传输完成,在主循环里做均值计算。前面3.3节里的代码就是完整实现,这里不再重复。
一个补充建议:均值采样次数选择2的幂次,比如8、16、32。因为除以2的幂可以改成右移运算,编译器会直接优化成移位指令,处理几十万次采样时,性能和除法指令有明显差距。代码上只需要把除法改成:
filtered[ch] = (uint16_t)(sum >> 4); // 除以16这个细节在低主频MCU上体现更明显,也算是一个低成本优化。
多通道DMA采集虽然只是ADC应用里的一小块,但它把嵌入式开发里的中断管理、外设协同、缓冲区设计都串起来了。我在实际项目里习惯把DMA循环模式配合固定缓冲区,回调里只翻转标志位,主循环统一做滤波和协议上报,这样采集频率再高也不会出现数据覆盖或中断卡死。最后再分享一个小技巧:调试阶段不要只看平均值,把原始数组通过串口打印出来用上位机画波形,数据抖动、错位、建立时间不足这类问题一眼就能看出来,比盯着调试器的Watch窗口效率高得多。
本文还有配套的精品资源,点击获取