ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

STM32 ADC+DMA多通道采集实战:从配置到调试的完整指南

STM32 ADC+DMA多通道采集实战:从配置到调试的完整指南

1. 为什么ADC和DMA是STM32进阶的第一道分水岭

很多刚接触STM32的朋友,学完GPIO点灯、按键扫描、串口打印之后,下一步几乎都会撞上ADC这个关卡。原因很简单:现实世界里的信号——温度、光照、电压、压力、声音——全是模拟量,而单片机只认0和1。ADC就是那座桥,把连续变化的模拟电压翻译成离散的数字量。但真正让人头疼的往往不是ADC本身,而是采完数据之后怎么高效地搬走。你要是用轮询方式一个通道一个通道读,CPU基本就被绑死在等待转换完成这件事上了,主循环里别的活儿全得靠边站。这时候DMA就该登场了。

我见过太多项目,ADC配置得没问题,采样值也读得到,但一旦采样率提上去、通道数多起来,整个系统就开始卡顿、丢数据、甚至跑飞。根子就在数据搬移这一环。ADC负责“生产”数据,DMA负责“运输”数据,CPU只负责“消费”数据——这三者配合好了,系统才能跑得流畅。这套组合拳打下来,你能做到:多通道ADC连续扫描,DMA自动把结果搬到内存数组里,CPU该干嘛干嘛,等一整块数据攒够了再统一处理。适合谁看?如果你已经能点亮LED、能配串口,但一碰到ADC多通道采集就发怵,或者你的项目里ADC采样总是和主循环打架,那这篇内容就是给你准备的。

我打算从整体设计思路讲起,把ADC和DMA各自扮演什么角色、为什么要这样配合说清楚;然后拆解关键细节,包括时钟配置、采样时间计算、DMA传输模式选择;接着给出一套完整的实操流程,从CubeMX配置到代码编写再到调试验证;最后把我踩过的坑和常见问题整理成速查表。整篇内容基于常见实践补充细节,你照着做基本能复现。

2. 整体设计思路:让ADC专心采样,让DMA专心搬数据

2.1 核心矛盾:CPU到底该不该等ADC

先想清楚一个问题:ADC转换一次要多久?以STM32F103为例,ADC时钟最高14MHz,采样时间加转换时间最短约1微秒。听起来很快对吧?但如果你要采8个通道,每个通道采100次做平均滤波,那就是800次转换,累计约800微秒。这800微秒里CPU如果一直在轮询等待,那主循环里其他任务全被阻塞。更别说有些应用要求连续采样、实时处理,CPU根本等不起。

轮询方式的代码大概长这样:启动转换,while等待EOC标志置位,读数据寄存器,存到数组,再启动下一次。逻辑简单,但效率极低。中断方式好一些,每次转换完成触发中断,在中断里读数据。但中断频率一高,CPU频繁进出中断上下文,开销同样不小,而且中断里做数据搬运容易引发优先级冲突。

DMA的思路完全不同:ADC每完成一次转换,硬件自动发出DMA请求,DMA控制器直接把数据从ADC数据寄存器搬到你指定的内存地址。整个过程CPU不参与,不需要执行任何指令。等DMA搬完你设定的一批数据,它再产生一个中断通知CPU:“数据准备好了,来处理吧。”CPU只在最后处理时介入,中间搬运全自动。

注意:DMA不是“更快地搬运”,而是“不需要CPU搬运”。它的价值在于解放CPU,而不是提升搬运速度本身。

2.2 方案选型:为什么是ADC+DMA而不是其他组合

有人会问:我用ADC中断加软件搬运不行吗?行,但要看场景。如果只是偶尔采一两个通道,中断方式完全够用。但一旦涉及多通道、高采样率、连续采集,DMA就是更合理的选择。我整理了一个对比表,方便你判断自己的项目该用哪种方式。

采集方式CPU占用适用场景缺点
轮询极高单次、低频、单通道阻塞主循环,浪费CPU
中断中等低频、少量通道高频时中断开销大
DMA极低多通道、高频、连续采集配置稍复杂,调试门槛高

从表里能看出来,DMA的优势在“多通道+高频+连续”这三个条件同时满足时才真正凸显。如果你的项目正好符合,那ADC+DMA就是标准解法。

2.3 数据流架构:从引脚到内存的完整链路

整个数据流是这样的:模拟信号从GPIO引脚进入ADC的某个通道,ADC按你配置的顺序依次扫描各个通道,每完成一个通道的转换就把结果放进同一个数据寄存器。与此同时,DMA控制器盯着ADC的DMA请求线,一旦有请求就立刻把数据寄存器里的值搬到你指定的内存缓冲区,然后自动把内存地址偏移到下一个位置。等搬够了你设定的数量,DMA产生传输完成中断,CPU在中断里处理这批数据。

这条链路里有两个关键配置点:一是ADC的扫描模式和连续转换模式要打开,否则它采完一个通道就停了;二是DMA的循环模式和传输宽度要配对,否则搬着搬着地址就乱了。后面会详细讲。

2.4 内存布局:缓冲区怎么设计才合理

缓冲区的大小和结构直接影响后续数据处理的便利性。假设你采3个通道,每个通道采10次,那缓冲区至少需要30个元素。但这里有个细节:ADC扫描是轮流采通道0、通道1、通道2、通道0、通道1、通道2……所以缓冲区里的数据是按“通道轮转”顺序排列的,不是按通道分组排列的。你拿到数据后需要自己按规律拆分。

我一般会定义一个二维数组或者用宏来索引,比如adc_buf[30],然后通道0的数据在adc_buf[0]、adc_buf[3]、adc_buf[6]……通道1在adc_buf[1]、adc_buf[4]……这样处理起来清晰。缓冲区大小建议是通道数的整数倍,否则最后一轮数据不完整,处理时容易越界。

3. 核心细节解析:时钟、采样时间、DMA模式一个都不能错

3.1 ADC时钟配置:别让时钟超速

STM32的ADC时钟不是直接来自系统时钟,而是经过一个独立的分频器。以F103为例,ADC时钟最高不能超过14MHz,否则转换结果不准甚至完全错误。系统时钟72MHz的情况下,常用分频系数是6,得到12MHz;或者分频8,得到9MHz。分频系数选小了时钟超限,选大了采样率上不去。

这里有个容易忽略的点:ADC时钟分频是在RCC里配置的,不是ADC模块内部。CubeMX里在Clock Configuration页面能找到ADC Prescaler选项。我见过有人系统时钟设成72MHz,ADC分频忘了改,默认可能是2分频,结果ADC时钟36MHz,远超14MHz上限,采出来的数据跳得厉害,还以为是硬件干扰,查了半天才发现是时钟问题。

提示:配置完时钟后,务必在Clock Configuration页面确认ADC时钟频率。这个数值不会自动帮你检查是否超限。

3.2 采样时间计算:给信号源足够的充电时间

ADC转换分两个阶段:采样阶段和转换阶段。采样阶段是内部采样电容充电的过程,充电时间由采样周期决定。如果采样时间太短,电容没充到信号电压就开始转换,结果必然偏低。采样时间太长,虽然精度有保障,但整体采样率下降。

STM32的采样时间以ADC时钟周期为单位,可选1.5、7.5、13.5、28.5、41.5、55.5、71.5、239.5个周期。总转换时间等于采样周期加上12.5个固定转换周期。以12MHz ADC时钟、55.5周期采样为例,总时间=(55.5+12.5)/12MHz≈5.67微秒,对应约176kHz的单通道采样率。

怎么选采样时间?看信号源的内阻。信号源内阻越大,采样电容充电越慢,需要的采样时间越长。一般规则是:低内阻信号源(如运放输出)用较短的采样时间,高内阻信号源(如光敏电阻分压)用较长的采样时间。我通常先用55.5周期做保守配置,实测波形没问题再逐步缩短。

3.3 DMA传输模式:循环模式还是单次模式

DMA的传输模式决定了搬完一批数据之后的行为。单次模式下,DMA搬完指定数量的数据就停止,需要CPU重新使能才能继续。循环模式下,DMA搬完一批自动回到缓冲区开头继续搬,周而复始,不需要CPU干预。

对于连续采集的应用,循环模式是首选。你只需要在初始化时启动一次,之后DMA就一直在后台搬运,CPU定期去缓冲区取数据就行。单次模式适合“采一批、处理一批、再采一批”的场景,但每次都要重新启动DMA,代码上多一步操作。

这里有个关键细节:循环模式下,DMA的传输完成中断会在每搬完一批数据时触发。如果你在中断里处理数据的时间超过了搬完一批数据的时间,就会发生数据覆盖——DMA已经在写下一批数据了,你还在读上一批。解决办法是加大缓冲区、用双缓冲模式,或者降低采样率。

3.4 数据宽度匹配:外设和内存要一致

ADC数据寄存器是16位的,但实际有效数据只有12位(F103系列)。DMA传输时,外设数据宽度和内存数据宽度都要设成半字(16位)。如果设成字节(8位),数据会被截断,高4位丢失,你读到的值永远不对。如果设成字(32位),虽然不会丢数据,但会浪费内存空间,而且缓冲区索引计算会变复杂。

我一般统一设成半字,缓冲区用uint16_t类型。这样每个数据占2字节,DMA搬起来也顺畅。CubeMX里在DMA Settings页面可以分别设置Peripheral和Memory的数据宽度,默认可能是字节,记得手动改成Half Word。

3.5 通道扫描顺序:和DMA缓冲区索引的对应关系

ADC的扫描顺序由规则通道序列决定。你可以在CubeMX里添加多个通道,每个通道有对应的Rank序号。Rank 1最先转换,Rank 2其次,以此类推。DMA搬运时,第一个数据对应Rank 1通道,第二个对应Rank 2通道,循环往复。

这个顺序必须和你的数据处理逻辑严格对应。比如你Rank 1设了通道0接温度传感器,Rank 2设了通道1接光照传感器,那缓冲区里偶数索引是温度,奇数索引是光照。如果顺序搞反了,温度数据被当成光照处理,结果就全乱了。我建议在代码里用宏定义把通道和索引绑定,比如#define TEMP_IDX 0、#define LIGHT_IDX 1,处理时用adc_buf[i*2+TEMP_IDX]来取值,清晰且不易出错。

4. 实操过程:从CubeMX配置到代码验证的完整流程

4.1 硬件准备与引脚确认

先确认你的开发板上有哪些引脚可以接模拟信号。以常见的STM32F103C8T6最小系统板为例,ADC1的通道0到通道7对应PA0到PA7引脚。这些引脚默认是模拟输入模式,但如果你之前配置过其他功能,需要先确认没有冲突。

我一般会选两个通道做测试:PA0接一个电位器中间抽头,两端分别接3.3V和GND,这样可以通过旋转电位器产生0到3.3V的可调电压;PA1接一个光敏电阻分压电路,用来模拟变化的环境信号。两个通道同时采集,方便验证多通道扫描和DMA搬运是否正常。

注意:模拟输入引脚不要接超过3.3V的电压,否则可能损坏ADC模块。如果不确定信号范围,先用分压电路把电压降到安全区间。

4.2 CubeMX配置步骤详解

打开CubeMX,新建工程选好芯片型号后,按以下步骤操作。

第一步,配置时钟。在RCC里把HSE设为Crystal/Ceramic Resonator,然后在Clock Configuration页面把系统时钟拉到72MHz,ADC Prescaler设为6分频,确保ADC时钟12MHz。

第二步,配置ADC。在Analog选项卡里找到ADC1,勾选IN0和IN1两个通道。在Parameter Settings里设置:Mode为Independent,Scan Conversion Mode设为Enabled,Continuous Conversion Mode设为Enabled,DMA Continuous Requests设为Enabled。Rank里确认Rank 1是Channel 0,Rank 2是Channel 1,采样时间都设为55.5 Cycles。

第三步,配置DMA。在ADC1的DMA Settings里点Add,选择ADC1,Mode设为Circular,Peripheral和Memory的数据宽度都设为Half Word,Memory地址自增设为Enable。

第四步,配置NVIC。在NVIC Settings里勾选DMA1 Channel1 global interrupt,优先级设为中等即可。

第五步,生成代码。确认工程设置里的Toolchain是STM32CubeIDE或MDK-ARM,生成代码。

4.3 关键代码解析与缓冲区定义

生成的代码里,ADC和DMA的初始化已经自动完成。你需要在main函数里手动启动ADC的DMA传输。核心代码就一行:

HAL_ADC_Start_DMA(&hadc1, (uint32_t*)adc_buf, ADC_BUF_SIZE);

这行代码做了三件事:启动ADC、启动DMA、把ADC数据寄存器和adc_buf绑定。之后ADC就开始连续转换,DMA自动搬运,你不需要再调用任何启动函数。

缓冲区定义建议放在全局:

#define ADC_CHANNELS 2 #define ADC_SAMPLES_PER_CH 10 #define ADC_BUF_SIZE (ADC_CHANNELS * ADC_SAMPLES_PER_CH) uint16_t adc_buf[ADC_BUF_SIZE];

DMA传输完成中断回调函数里处理数据:

void HAL_ADC_ConvCpltCallback(ADC_HandleTypeDef* hadc) { if (hadc->Instance == ADC1) { // 数据已就绪,adc_buf里是最新一批采样值 // 按通道拆分处理 for (int i = 0; i < ADC_SAMPLES_PER_CH; i++) { uint16_t temp_val = adc_buf[i * ADC_CHANNELS + 0]; uint16_t light_val = adc_buf[i * ADC_CHANNELS + 1]; // 后续处理... } } }

注意回调函数里不要做耗时操作,否则会阻塞下一次DMA传输完成中断。我一般只在回调里置一个标志位,主循环检测到标志位再处理数据。

4.4 电压换算与数据验证

ADC读到的原始值是0到4095(12位分辨率),对应0到3.3V。换算公式是:电压 = 原始值 × 3.3 / 4095。但实际使用中,VDDA可能不是精确的3.3V,可以用万用表量一下实际值代入计算,精度会更高。

验证方法:旋转电位器,观察串口打印的电压值是否平滑变化。如果跳变严重,先检查采样时间是否够长、信号源内阻是否过大。如果数值始终为0或4095,检查引脚是否接对、ADC通道是否使能。如果两个通道数据串位,检查Rank顺序和缓冲区索引是否匹配。

我习惯在调试阶段把原始值和换算后的电压都打印出来,对比观察。原始值的变化趋势更能反映ADC本身的工作状态,电压值则方便直观判断。

4.5 用串口输出验证数据流的完整性

串口是验证ADC+DMA最方便的工具。配置一个串口,波特率115200,在DMA传输完成回调里置标志位,主循环里检测到标志位就打印一批数据。打印格式建议包含通道编号和原始值,比如:

CH0: 2048 CH1: 1024 CH0: 2050 CH1: 1023 ...

如果打印出来的数据有规律地跳动,说明DMA搬运正常。如果数据一直不变,可能是ADC没有启动或者DMA没有使能。如果数据错位,检查缓冲区大小和通道数是否匹配。

提示:串口打印本身会占用时间,如果采样率很高,打印速度跟不上会导致数据覆盖。调试时可以先降低采样率,或者只打印部分数据。

5. 常见问题与排查技巧实录

5.1 ADC数据跳动严重怎么办

这是最常见的问题。原因可能有几个:一是采样时间太短,采样电容没充够电。解决办法是增大采样周期,从55.5周期起步,逐步调整。二是信号源内阻太大,比如直接用高阻值光敏电阻分压,内阻可能上百千欧。解决办法是加一个电压跟随器(运放缓冲),把高阻信号变成低阻输出。三是电源噪声,VDDA和VSSA没有加滤波电容。解决办法是在VDDA引脚附近加100nF和10uF电容。四是ADC时钟超限,检查分频系数是否让ADC时钟超过了14MHz。

我遇到过一次数据跳动特别厉害的情况,查了半天发现是开发板上的3.3V稳压芯片质量一般,纹波较大。后来在VDDA引脚并了一个10uF钽电容,跳动明显减小。所以硬件上的电源质量对ADC精度影响很大,不能全指望软件滤波。

5.2 DMA搬运的数据总是错位

错位通常表现为:通道0的数据跑到通道1的位置,或者数据整体偏移一个位置。原因一般是DMA缓冲区大小和ADC通道数不匹配。比如你设了2个通道,但DMA缓冲区大小设成了15(不是2的整数倍),那DMA搬完14个数据后,第15个数据会覆盖到缓冲区开头,导致后续数据全部错位。

解决办法很简单:缓冲区大小必须是通道数的整数倍。我一般设成通道数乘以每通道采样次数,比如2通道×10次=20。另外,DMA的Memory地址自增必须使能,否则所有数据都堆在同一个地址上,你读到的永远是最后一个值。

5.3 采样率上不去是什么原因

采样率受限于三个因素:ADC时钟频率、采样时间、DMA传输速度。ADC时钟最高14MHz,采样时间最短1.5周期,加上12.5周期转换时间,单通道最短转换时间约1微秒,对应最高约1MHz采样率。但多通道扫描时,每个通道都要单独转换,总采样率要除以通道数。

如果你发现采样率远低于理论值,检查这几点:ADC时钟分频是否过大、采样时间是否设得太长、DMA传输模式是否设成了单次(单次模式下搬完一批就停,需要重新启动)。另外,如果DMA传输完成中断里处理时间过长,也会拖慢整体节奏。

5.4 常见问题速查表

现象可能原因排查方向
数据始终为0ADC未启动、引脚未接信号检查HAL_ADC_Start_DMA是否调用、引脚电压
数据始终为4095引脚接3.3V、通道配置错误检查信号源、Rank配置
数据跳动大采样时间短、电源噪声、内阻大增大采样时间、加滤波电容、加缓冲
数据错位缓冲区大小非通道整数倍调整缓冲区大小为通道数整数倍
采样率低时钟分频大、采样时间长、单次模式调整分频、缩短采样时间、改循环模式
只有第一个通道有数据扫描模式未使能、Rank只配了一个检查Scan Conversion Mode和Rank数量

5.5 几个容易被忽略的实操心得

第一个心得:DMA传输完成中断里不要调用HAL_ADC_Stop_DMA,否则循环模式会被打断。如果你需要暂停采集,用HAL_ADC_Stop_DMA在需要的地方调用,但不要在回调里调用。

第二个心得:如果ADC和DMA都配置好了但数据就是不动,试试在启动DMA之前先调用一次HAL_ADC_Start,再调用HAL_ADC_Start_DMA。有些HAL库版本对启动顺序有要求,先启动ADC再启动DMA更稳妥。

第三个心得:调试时可以用调试器实时查看adc_buf数组的内容,比串口打印更直观。在IDE里把adc_buf添加到Watch窗口,设置刷新频率,旋转电位器就能看到数值实时变化。

第四个心得:如果项目对精度要求高,可以考虑使用ADC的内部参考电压或者外部基准芯片。F103没有内部参考电压输出,但可以用VDDA作为参考。如果VDDA不稳定,精度就无从谈起。

第五个心得:多通道采集时,如果某个通道的信号变化很快,而另一个通道变化很慢,可以考虑给快变通道分配更短的采样时间,慢变通道分配更长的采样时间。CubeMX里每个Rank可以单独设置采样时间,灵活调整能优化整体采样率。

6. 进阶扩展:双缓冲与定时器触发的组合玩法

6.1 双缓冲模式解决数据覆盖问题

前面提到循环模式下如果处理速度跟不上,数据会被覆盖。双缓冲模式就是为解决这个问题设计的。DMA在搬第一块缓冲区的时候,CPU可以处理第二块;搬完第一块自动切到第二块,同时通知CPU处理第一块。两块交替使用,只要CPU处理一块的时间不超过DMA搬一块的时间,就不会丢数据。

STM32的DMA支持双缓冲模式,在CubeMX里把DMA Mode改成Circular,然后使能Double Buffer Mode,设置两个内存地址。HAL库提供了对应的回调函数HAL_ADC_ConvHalfCpltCallback和HAL_ADC_ConvCpltCallback,分别在搬完一半和全部时触发。你可以在半完成回调里处理前半块,在完成回调里处理后半块。

6.2 定时器触发ADC实现精确采样率

自由运行的ADC采样率由转换时间决定,不够精确。如果需要精确控制采样时刻,可以用定时器触发ADC转换。配置一个定时器,设置好频率,把定时器的TRGO输出连接到ADC的外部触发源。这样每次定时器溢出就触发一次ADC转换,采样率由定时器频率精确决定。

这种方案适合需要同步采样的场景,比如音频采样、振动分析。定时器触发加上DMA搬运,整个采集链路完全由硬件驱动,CPU只在数据处理时介入,时序精度和CPU效率都达到最优。

6.3 从单ADC到多ADC同步采集

如果通道数不够用,或者需要同时采集多路信号,可以考虑使用多个ADC。STM32F103有ADC1、ADC2、ADC3(部分型号),可以配置成同步模式,两个ADC同时转换,结果分别存入不同的缓冲区。DMA可以分别搬运,也可以配置成双ADC模式由同一个DMA搬运。

多ADC同步的配置比单ADC复杂不少,涉及模式选择(同步注入、同步规则、交替触发等)、DMA通道分配、中断优先级协调。建议先把单ADC+DMA跑通,再逐步扩展。我个人的经验是,如果单ADC的通道数够用,优先用单ADC多通道,配置简单、调试容易。实在不够用了再上多ADC。

6.4 低功耗场景下的ADC+DMA策略

电池供电的设备对功耗敏感。ADC和DMA在运行时都会消耗电流,需要合理管理。策略是:不需要采集时关闭ADC和DMA,需要时再启动;降低采样率,减少转换次数;使用单次模式而非循环模式,采完自动停止;在DMA传输完成中断里处理完数据后立即关闭ADC。

HAL库提供了HAL_ADC_Stop_DMA函数,调用后ADC和DMA都停止工作,电流消耗降到最低。下次需要采集时再调用HAL_ADC_Start_DMA重新启动。这种按需采集的方式在低功耗项目中很实用。

6.5 从F103到F4系列的移植注意事项

F4系列的ADC性能比F103强不少,12位分辨率、更高的采样率、更多的通道。但移植时要注意几点:F4的ADC时钟最高36MHz,分频配置不同;F4支持多重ADC模式更丰富;DMA控制器从DMA1/DMA2变成了DMA1/DMA2加上DMA2D等,通道映射有变化;HAL库的API基本兼容,但部分参数结构体有差异。

移植时建议重新用CubeMX生成F4的初始化代码,把F103的业务逻辑代码移植过去,而不是直接复制寄存器配置。HAL库的抽象层让上层逻辑移植变得容易,底层配置交给CubeMX重新生成更稳妥。

7. 我个人在实际操作中的几点体会

这套ADC+DMA的组合我用了很多年,从F103到F4再到G0系列,基本套路没变过。最大的体会是:配置阶段多花十分钟检查时钟和DMA参数,调试阶段能省下几个小时。我见过太多人卡在数据不对上,最后发现是ADC时钟超限或者DMA数据宽度设错。这些细节在CubeMX里都是可视化的,点开看一眼就能确认,但偏偏容易被忽略。

另一个体会是:不要迷信高采样率。够用就行。采样率越高,数据量越大,后续处理压力越大,功耗也越高。先想清楚你的应用到底需要多高的采样率,再倒推配置参数。比如测温度,一秒采一次都嫌多;测音频,44.1kHz是起步。需求决定配置,而不是反过来。

最后分享一个小技巧:在项目初期,我会在DMA传输完成回调里翻转一个GPIO引脚,用示波器测量引脚频率,就能直观看到实际采样率是多少。这个方法比算理论值靠谱,也比串口打印直观。引脚频率乘以每批数据的采样次数,就是实际采样率。如果和预期不符,再回头查配置。这个技巧帮我快速定位过好几次采样率异常的问题。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进