1. 为什么ADC加DMA是STM32开发的必修课
搞STM32开发的人,迟早会碰到一个场景:需要连续采集模拟信号,比如电压、电流、温度、压力这些传感器输出。最直接的做法是用ADC单次转换,在中断里读数据,然后存到数组里。但只要你试过在1kHz以上的采样率下这么干,就会发现CPU基本被中断吃满了,主循环里其他任务根本跑不动。
这时候ADC加DMA的组合就是标准解法。ADC负责把模拟量转成数字量,DMA负责把转换结果从外设寄存器搬到内存,整个过程CPU只需要在缓冲区填满时处理一次数据。我实测过,用DMA搬运的方式,采样率做到100kHz以上,CPU占用率依然能控制在个位数百分比。
这个内容适合谁看?如果你已经会用STM32点灯、串口收发,但对ADC和DMA的配合还停留在“照着例程改改”的阶段,那这篇就是写给你的。我会从方案选型、寄存器配置、代码实现到踩坑排查,把整个链路讲透,让你能根据自己的项目需求独立设计采集方案,而不是到处抄代码。
2. 整体方案设计与核心思路拆解
2.1 为什么不用中断搬运数据
先算一笔账。假设ADC采样率设为50kHz,每次转换完成触发一次中断,中断服务函数里做一次读寄存器和存数组的操作。STM32F103系列的中断响应时间大约12个时钟周期,加上入栈出栈和函数调用开销,保守估计一次中断处理需要1到2微秒。50kHz意味着每20微秒来一次中断,CPU有5%到10%的时间花在中断进出上,这还没算主循环里其他任务的执行时间。
如果采样率提到200kHz,每5微秒一次中断,CPU基本就干不了别的事了。而且中断频繁触发还会导致主循环里的任务被反复打断,时序敏感的操作直接崩掉。
DMA的思路完全不同。你告诉DMA:源地址是ADC的数据寄存器,目标地址是内存里的数组,每次ADC转换完成触发DMA请求,DMA控制器自动完成一次数据搬运。整个过程CPU不参与,只在DMA传输完成一半或全部完成时产生中断,让你去处理数据。同样是200kHz采样率,CPU只需要在缓冲区满的时候响应一次中断,负担降低几个数量级。
2.2 连续转换模式与扫描模式的取舍
ADC的工作模式选择直接决定了DMA的配置方式。这里有两个关键选项:单次转换和连续转换,以及是否开启扫描模式。
单次转换模式下,ADC转换一次就停,需要软件或外部触发再次启动。这种模式适合低速、按需采集的场景,比如按键检测时读一下电压。但要做连续采集,每次转换完都得重新启动ADC,效率太低。
连续转换模式下,ADC完成一次转换后自动开始下一次,不需要软件干预。配合DMA的循环模式,就能实现永不停歇的数据采集。这是大多数连续采集场景的首选。
扫描模式则是针对多通道的。如果你要轮流采集多个通道的模拟量,开启扫描模式后,ADC会按照你配置的顺序依次转换每个通道。每次转换完成都会触发DMA请求,DMA把数据依次搬到数组里。这样数组里就是按通道顺序排列的采样值。
我一般建议:单通道连续采集用连续转换+DMA循环模式;多通道采集用扫描模式+连续转换+DMA循环模式。这两种组合覆盖了90%以上的应用场景。
2.3 DMA循环模式与普通模式的差异
DMA的循环模式和普通模式区别在于传输完成后是否自动重置。普通模式下,DMA搬完指定数量的数据就停止,需要重新配置才能继续。循环模式下,DMA搬完指定数量后自动回到起始地址继续搬,形成一个环形缓冲区。
对于连续采集,循环模式几乎是必然选择。你只需要在DMA传输完成中断里处理数据,处理完不需要重新启动DMA,它会自动继续。但这里有个细节:循环模式下,DMA传输完成中断触发时,新的数据可能已经开始覆盖缓冲区了。如果你处理数据的速度跟不上采样速度,就会出现数据被覆盖的问题。
解决办法是使用双缓冲模式,或者把缓冲区设得足够大,确保在DMA覆盖之前处理完。我通常会把缓冲区大小设为采样率的10到20倍,比如100kHz采样率,缓冲区设1000到2000个点,这样每10到20毫秒才需要处理一次,给CPU留足余量。
3. 核心寄存器配置与参数计算
3.1 ADC时钟配置与采样时间计算
STM32的ADC时钟来自APB2总线,经过分频后供给ADC。以F103为例,APB2时钟最高72MHz,ADC时钟经过2、4、6、8分频后不能超过14MHz。所以通常配置为6分频,ADC时钟12MHz。
采样时间决定了ADC内部采样保持电容充电的时间。采样时间越长,转换结果越准确,但转换速度越慢。总转换时间 = 采样时间 + 12.5个ADC时钟周期(12位分辨率下)。
举个例子:采样时间设为55.5个周期,ADC时钟12MHz,那么总转换时间 = (55.5 + 12.5) / 12MHz = 5.67微秒。理论最大采样率约176kHz。如果采样时间设为1.5个周期,总转换时间 = 14 / 12MHz = 1.17微秒,理论最大采样率约857kHz。
但实际采样率还受限于DMA搬运速度和系统总线带宽。我实测下来,F103在12MHz ADC时钟下,稳定采样率做到200kHz左右问题不大,再高就要看具体场景了。
采样时间的选择要根据信号源阻抗来定。信号源阻抗越高,采样保持电容充电越慢,需要更长的采样时间。一般低阻抗信号源(比如运放输出)用1.5到7.5个周期就够了,高阻抗信号源(比如直接接传感器)建议用55.5个周期以上。
3.2 DMA通道选择与数据宽度匹配
STM32的DMA控制器有多个通道,每个通道对应特定的外设请求。ADC1的DMA请求通常映射到DMA1的通道1。这个映射关系在参考手册的DMA请求映射表里有详细说明,选错了通道DMA根本不会触发。
数据宽度方面,ADC转换结果是12位,存放在16位寄存器里。DMA的外设数据宽度和内存数据宽度都配置为半字(16位)最合适。如果配成字节(8位),高4位会被截断,数据就错了。
传输方向是外设到内存,这个不用多说。地址增量方面,外设地址不增量(始终读ADC数据寄存器),内存地址增量(依次存入数组)。循环模式使能,这样DMA搬完一轮自动重来。
3.3 缓冲区大小与中断频率的平衡
缓冲区大小直接决定了DMA传输完成中断的频率。缓冲区越大,中断越少,但数据处理延迟越大。缓冲区越小,实时性越好,但CPU中断负担越重。
假设采样率100kHz,缓冲区设500个点,那么每5毫秒触发一次DMA传输完成中断。这个频率对大多数应用来说很舒服,CPU有足够时间处理数据,又不会延迟太大。
如果缓冲区设50个点,每0.5毫秒中断一次,CPU频繁进出中断,效率反而下降。如果设5000个点,每50毫秒才中断一次,但数据延迟50毫秒,对于实时控制场景可能就太慢了。
我的经验值是:缓冲区大小设为采样率的5到20倍,对应中断间隔5到20毫秒。这个范围兼顾了实时性和CPU效率。具体取值还要看你的数据处理逻辑有多复杂,处理时间越长,缓冲区就要越大。
4. 完整代码实现与关键步骤解析
4.1 硬件初始化流程
先理清楚初始化顺序:时钟使能、GPIO配置、ADC配置、DMA配置、启动校准、开启转换。这个顺序不能乱,尤其是DMA必须在ADC之前配置好,否则ADC转换完成时DMA还没准备好,数据就丢了。
时钟使能要开三个:GPIO时钟、ADC时钟、DMA时钟。F103的ADC1在APB2上,DMA1在AHB上,别搞混了。
GPIO配置为模拟输入模式。这里有个坑:模拟输入模式下,GPIO的输出驱动器和施密特触发器都关闭了,引脚直接连到ADC内部。如果你不小心配成了浮空输入或上拉输入,ADC读出来的值会不准。
ADC配置包括:独立模式、扫描模式使能或关闭、连续转换使能、外部触发关闭、数据对齐右对齐、通道数量、采样时间。这些参数根据你的采集需求来定。
DMA配置包括:通道选择、外设地址、内存地址、传输方向、缓冲区大小、外设地址不增量、内存地址增量、外设数据宽度半字、内存数据宽度半字、循环模式、优先级。
4.2 ADC校准与启动顺序
ADC上电后必须执行校准,否则转换结果会有偏差。校准步骤是:使能ADC、等待ADC稳定、启动校准、等待校准完成。这个过程大概需要几十个ADC时钟周期。
校准完成后,使能ADC的DMA请求,然后启动ADC转换。如果是连续转换模式,启动一次后ADC就会一直转下去。DMA也会在第一次转换完成后自动开始搬运。
这里有个细节:校准必须在ADC使能之后、开始转换之前执行。如果顺序错了,校准不生效,数据会有几个LSB的误差。我见过有人调了半天数据不对,最后发现是校准没做。
4.3 DMA传输完成中断的处理
DMA传输完成中断是数据处理的主战场。中断触发时,缓冲区里已经填满了新的采样数据。你需要在中断里尽快把数据取走或标记为可处理,然后清除中断标志。
如果数据处理逻辑简单,比如只是求个平均值,可以直接在中断里算完。但如果处理逻辑复杂,比如要做FFT或滤波,建议在中断里只设置一个标志位,让主循环去处理。中断里做太多事会阻塞其他中断,影响系统实时性。
使用循环模式时,DMA传输完成中断触发后,DMA会自动重新开始搬运。但要注意:中断标志清除后,如果新的数据已经开始写入缓冲区,而你还在处理旧数据,就可能出现数据竞争。解决办法是使用双缓冲,或者在处理前先复制一份数据出来。
4.4 代码实现示例
#include "stm32f10x.h" #define ADC_BUFFER_SIZE 1000 uint16_t adc_buffer[ADC_BUFFER_SIZE]; volatile uint8_t adc_data_ready = 0; void ADC_DMA_Init(void) { GPIO_InitTypeDef GPIO_InitStructure; ADC_InitTypeDef ADC_InitStructure; DMA_InitTypeDef DMA_InitStructure; RCC_APB2PeriphClockCmd(RCC_APB2Periph_GPIOA | RCC_APB2Periph_ADC1, ENABLE); RCC_AHBPeriphClockCmd(RCC_AHBPeriph_DMA1, ENABLE); RCC_ADCCLKConfig(RCC_PCLK2_Div6); GPIO_InitStructure.GPIO_Pin = GPIO_Pin_0; GPIO_InitStructure.GPIO_Mode = GPIO_Mode_AIN; GPIO_Init(GPIOA, &GPIO_InitStructure); DMA_DeInit(DMA1_Channel1); DMA_InitStructure.DMA_PeripheralBaseAddr = (uint32_t)&ADC1->DR; DMA_InitStructure.DMA_MemoryBaseAddr = (uint32_t)adc_buffer; DMA_InitStructure.DMA_DIR = DMA_DIR_PeripheralSRC; DMA_InitStructure.DMA_BufferSize = ADC_BUFFER_SIZE; DMA_InitStructure.DMA_PeripheralInc = DMA_PeripheralInc_Disable; DMA_InitStructure.DMA_MemoryInc = DMA_MemoryInc_Enable; DMA_InitStructure.DMA_PeripheralDataSize = DMA_PeripheralDataSize_HalfWord; DMA_InitStructure.DMA_MemoryDataSize = DMA_MemoryDataSize_HalfWord; DMA_InitStructure.DMA_Mode = DMA_Mode_Circular; DMA_InitStructure.DMA_Priority = DMA_Priority_High; DMA_InitStructure.DMA_M2M = DMA_M2M_Disable; DMA_Init(DMA1_Channel1, &DMA_InitStructure); DMA_ITConfig(DMA1_Channel1, DMA_IT_TC, ENABLE); DMA_Cmd(DMA1_Channel1, ENABLE); ADC_InitStructure.ADC_Mode = ADC_Mode_Independent; ADC_InitStructure.ADC_ScanConvMode = DISABLE; ADC_InitStructure.ADC_ContinuousConvMode = ENABLE; ADC_InitStructure.ADC_ExternalTrigConv = ADC_ExternalTrigConv_None; ADC_InitStructure.ADC_DataAlign = ADC_DataAlign_Right; ADC_InitStructure.ADC_NbrOfChannel = 1; ADC_Init(ADC1, &ADC_InitStructure); ADC_RegularChannelConfig(ADC1, ADC_Channel_0, 1, ADC_SampleTime_55Cycles5); ADC_Cmd(ADC1, ENABLE); ADC_ResetCalibration(ADC1); while(ADC_GetResetCalibrationStatus(ADC1)); ADC_StartCalibration(ADC1); while(ADC_GetCalibrationStatus(ADC1)); ADC_DMACmd(ADC1, ENABLE); ADC_SoftwareStartConvCmd(ADC1, ENABLE); } void DMA1_Channel1_IRQHandler(void) { if(DMA_GetITStatus(DMA1_IT_TC1)) { DMA_ClearITPendingBit(DMA1_IT_TC1); adc_data_ready = 1; } }这段代码实现了单通道连续采集,DMA循环搬运,每填满1000个点触发一次中断。主循环里检测到adc_data_ready标志后处理数据,处理完清零标志。
5. 常见问题排查与避坑经验
5.1 数据全是0或者固定值
这是最常遇到的问题。先检查GPIO模式是不是配成了模拟输入,如果配成了其他模式,ADC读到的就是固定电平。再检查ADC通道和引脚是否对应,比如ADC_Channel_0对应PA0,别接错引脚了。
如果GPIO和通道都没问题,检查ADC时钟是否使能、校准是否执行。校准没做的话,数据会有偏差但不会全是0。全是0通常是ADC根本没启动,或者DMA没触发。
还有一个隐蔽的坑:ADC的DMA请求没使能。ADC_DMACmd(ADC1, ENABLE)这行代码很容易漏掉,漏了之后ADC正常转换,但DMA收不到请求,缓冲区里永远是初始值。
5.2 数据错位或通道顺序混乱
多通道采集时,如果数组里的数据顺序和预期不符,检查扫描模式下通道配置的顺序。ADC_RegularChannelConfig的第三个参数是规则通道的排序位置,第一个配置的通道排在第1位,第二个排在第2位,以此类推。DMA会按照这个顺序依次搬运。
如果数据错位,还可能是DMA的内存地址增量配置错了。多通道采集时内存地址必须增量,否则所有通道的数据都堆在同一个地址上,后面的覆盖前面的。
5.3 采样率达不到预期
理论计算能到200kHz,实际只有50kHz,这种情况通常是DMA搬运速度跟不上。检查DMA的优先级设置,如果和其他高优先级DMA通道冲突,ADC的DMA请求会被延迟。
另一个原因是ADC时钟分频太大。RCC_ADCCLKConfig(RCC_PCLK2_Div6)在72MHz系统时钟下给ADC提供12MHz时钟,如果改成Div8就只有9MHz,转换时间变长,采样率下降。
还有可能是中断处理太慢。如果DMA传输完成中断里做了太多事,下一次DMA传输完成时中断还没退出,就会丢中断。解决办法是中断里只设标志,主循环处理数据。
5.4 数据跳动大、噪声明显
ADC采集到的数据跳动大,先排除硬件问题:电源是否干净、模拟地和数字地是否分开、信号线是否远离高频干扰源。硬件没问题的话,软件上可以增加采样时间,让采样保持电容充得更满。
如果信号源阻抗高,比如直接接热敏电阻分压,采样时间至少要55.5个周期。我试过用1.5个周期采热敏电阻,数据跳得没法看,改成55.5个周期后稳定多了。
还可以在软件上做多次采样求平均,比如连续采16次去掉最大最小值再平均。但这样会降低有效采样率,要根据实际需求权衡。
5.5 DMA传输完成中断不触发
中断不触发先检查NVIC配置。DMA1_Channel1_IRQn的中断使能有没有开,优先级有没有配。很多人只配了DMA和ADC,忘了配NVIC,中断自然进不来。
再检查DMA_ITConfig有没有使能传输完成中断。DMA_IT_TC是传输完成中断,DMA_IT_HT是半传输中断,别配错了。
还有一点:如果DMA配置为普通模式而不是循环模式,传输完成后DMA就停止了,不会再次触发中断。需要手动重新使能DMA才能继续。
5.6 常见问题速查表
| 现象 | 可能原因 | 排查方法 |
|---|---|---|
| 数据全是0 | GPIO模式错误、ADC未启动、DMA请求未使能 | 检查GPIO_Mode、ADC_Cmd、ADC_DMACmd |
| 数据固定不变 | 信号源未接入、通道配置错误 | 用万用表测引脚电压、核对通道映射 |
| 数据错位 | DMA内存地址增量配置错误、通道顺序错误 | 检查DMA_MemoryInc、核对通道配置顺序 |
| 采样率低 | ADC时钟分频过大、DMA优先级低、中断处理慢 | 调整分频、提高DMA优先级、优化中断 |
| 数据跳动大 | 采样时间不足、硬件干扰、参考电压不稳 | 增加采样时间、检查硬件布局、加滤波电容 |
| 中断不触发 | NVIC未配置、中断标志未清除、DMA模式错误 | 检查NVIC配置、清除标志、确认循环模式 |
6. 进阶优化与扩展思路
6.1 双缓冲模式提升数据处理效率
单缓冲区在DMA循环模式下有个天然缺陷:DMA写前半段时CPU读后半段,但如果CPU处理速度不够快,DMA写完后半段开始写前半段时,CPU还没读完前半段的数据,就会读到被覆盖的新数据。
双缓冲模式解决了这个问题。DMA配置两个缓冲区,写满第一个时触发中断,CPU处理第一个缓冲区的数据,同时DMA继续写第二个缓冲区。第二个写满时再触发中断,CPU处理第二个,DMA回头写第一个。这样CPU和DMA交替工作,互不干扰。
STM32的DMA支持双缓冲模式,通过DMA_DoubleBufferModeConfig和DMA_DoubleBufferModeCmd配置。中断里通过DMA_GetCurrentMemoryTarget判断当前DMA在写哪个缓冲区,CPU处理另一个。
6.2 定时器触发ADC实现精确采样率
软件启动ADC转换的采样率不够精确,因为代码执行时间有波动。用定时器触发ADC可以实现精确的采样间隔。定时器配置为PWM输出或更新事件,触发ADC开始转换。这样采样率由定时器频率决定,精度取决于晶振,非常稳定。
配置方法是:定时器设置好频率,ADC的外部触发源选择对应的定时器事件,然后使能ADC的外部触发。DMA配置不变,依然负责搬运数据。这种方式适合对采样时序要求严格的场景,比如音频采集或振动分析。
6.3 数据预处理减轻主循环负担
如果采样率很高,数据量很大,主循环处理不过来,可以在DMA中断里做一些简单的预处理。比如累加求和,中断里只累加,主循环里算平均值。或者做简单的阈值判断,只把超过阈值的数据存下来,减少后续处理量。
但要注意中断里不能做太复杂的运算,浮点运算尤其要避免。F103没有硬件浮点单元,浮点运算在中断里会消耗大量时间。定点运算或者查表法是更好的选择。
6.4 从F103到F4系列的移植要点
F4系列的ADC和DMA架构和F103有差异。F4的ADC时钟最高36MHz,采样时间配置更灵活。DMA控制器升级为DMA2,支持更多通道和更灵活的数据宽度配置。
移植时主要改这几个地方:时钟使能寄存器不同、ADC初始化结构体多了几个字段、DMA通道映射不同、中断向量名不同。整体思路一样,但寄存器操作要对照F4的参考手册重新配置。
F4的ADC还支持多重ADC模式,多个ADC可以同步采样,适合需要同时采集多路信号的场景。这个功能在F103上是没有的,有需求的话可以考虑升级平台。
7. 实操心得与经验总结
我在实际项目里用ADC加DMA这套方案做过不少采集系统,从低速的温度采集到高速的振动信号分析都试过。踩过的坑主要集中在几个地方:一是校准没做导致数据偏差,二是DMA请求忘了使能导致数据不动,三是中断里处理太慢导致数据覆盖。
最深刻的一次教训是做一个电机电流采集项目,采样率要求100kHz,缓冲区设了200个点,中断间隔2毫秒。中断里做了均方根计算,浮点运算花了1.5毫秒,结果主循环里其他任务全被拖慢了。后来改成中断里只存数据,主循环里算均方根,问题解决。
还有一个经验:调试ADC采集时,先用一个已知电压测试,比如接3.3V或GND,看读数是不是接近满量程或0。这样能快速判断是配置问题还是信号问题。如果接GND读出来不是0,那肯定是配置有问题,不用去查信号源。
最后分享一个小技巧:如果采样数据偶尔出现几个异常值,可以在DMA中断里做简单的限幅滤波。比如前后两个数据差值超过阈值就丢弃,用前一个值代替。这个方法简单有效,能过滤掉大部分突发干扰。
这个方案后续还可以扩展:加一个SD卡或Flash存储,做成离线数据记录仪;加一个无线模块,把数据实时传到上位机;加一个LCD屏,现场显示波形。核心的ADC加DMA采集部分不用动,只需要在数据处理和输出环节做文章。