
简介STM32F4系列ADC多通道采集通过DMA方式实现高效数据搬运面向嵌入式入门及项目开发者解决多传感器模拟信号实时采样场景下CPU被频繁占用的问题。资源包含完整工程源码共128个文件以60个C源码和60个头文件为主另有hex烧录文件、uvprojx工程配置及启动文件等压缩包仅572KB轻量易用。已有1372人学习下载。代码涵盖ADC初始化、通道序列配置、DMA流及中断服务函数编写开发者可基于标准外设库快速移植至F4系列其他型号也可参照中断处理逻辑扩展多通道数据滤波或阈值判断对理解ADCDMA工作机制很有帮助。 STM32 ADC多通道采集用DMA方式这个方案我实际验证过工程代码也一直保留着。很多人把ADC采集想简单了——先初始化ADC然后循环调用HAL_ADC_GetValue()读取这种做法在单通道低频采样时没什么问题但一旦涉及到多通道连续采集、实时性要求高的场景这条省事的路就走不通了。CPU会被ADC折腾得够呛转换完成中断频繁触发每次进中断还要切换通道、读取数据整个系统的实时性被拉低一大截。这篇文章想跟你聊的就是我在实际项目里摸索出来的那套做法用DMA配合多通道ADC采集CPU基本不用管数据搬运的事转换结果由DMA自动存到内存数组里等一批数据采集完再统一处理。这套方案在多通道数据采集、传感器信号监测这类场景下非常实用配合STM32CubeMXHAL库开发代码量不大稳定性却很高。适合已经有STM32基础、想搞清楚DMA方式ADC采集怎么配置、怎么避坑的开发者。文章里会把我调试时踩过的坑、实测参数、完整代码都贴出来。1. 为什么多通道采集不用中断非要上DMA先把需求背景交代清楚。我当时的项目是做一个多路模拟量采集板需要同时采集4路电压信号采样率要求每通道1kHz以上数据要实时送进控制算法里用。如果用传统的ADC中断查询方式时序是这样的ADC转换完成进入中断在中断服务函数里读数据、切换通道、启动下一次转换然后退出中断。这个过程本身没错但问题在于每次ADC转换完成都会触发一次中断4个通道、1kHz采样率意味着每秒要进4000次中断每次中断的开销入栈、出栈、跳转、判断加起来非常可观。在中断里做数据处理的话整个系统的中断响应会被拖慢如果主循环里还有实时性任务很容易互相影响出生各种莫名其妙的问题。数据要多通道连续采集时切换通道、读取、存储这些流程全都在中断里完成代码复杂出错概率也高。DMADirect Memory Access直接内存访问解决的就是这个搬运数据的问题。DMA控制器可以独立于CPU工作外设这里是ADC的数据转换完成后DMA会自动把数据搬运到指定的内存地址全程不需要CPU参与。CPU只需要在数据搬运完成时收到一个完成中断或者干脆轮询一个标志位就行了。这样一来CPU的负载大幅降低中断次数从每秒几千次降到几乎为零DMA传输完成才触发一次。多通道数据的读取顺序、存储位置由硬件管理不会出现丢数据、错位的问题。数据积累到一定量再批量处理和实时中断的方式相比CPU有更多时间跑核心算法和处理其他外设。我做了个简单的对比表格实际测量下来的数据就是这样采集方式CPU占用率4通道1kHz中断频率代码复杂度稳定性查询方式约30%-40%几乎无简单中等中断方式约15%-20%4kHz较复杂一般DMA方式约1%-2%1kHz传输完成中断简单高表格里的查询方式指的是那种不断检查EOC标志位的老写法实际开发里偶尔简单用用还行但遇到多通道高采样率就吃力了。我自己的体会是大部分项目里DMA方式并不是高级技巧而是应该用但没过脑子去用。尤其是当你后面还想加RTOS、加通信、加HMI的时候DMA那点配置成本根本不算什么节省出来的CPU时间才是真的宝贵。2. 多通道ADC与DMA搭配的原理以及为什么序列扫描是重点ADC和DMA搭配核心要理解两件事ADC的转换序列以及DMA的传输方向。STM32的ADC以F103为例有规则组和注入组两组通道。规则组最多可以配置16个通道按顺序依次转换转换结果统一放在一个数据寄存器里。注入组可以配置4个通道每个通道有自己的数据寄存器。多通道采集用的就是规则组的顺序转换功能配置好通道扫描顺序后ADC会按照你设置的顺序依次把每个通道转换一遍转换完成的数据放到ADC_DR寄存器里。这里需要注意规则组所有通道共用一个数据寄存器所以多通道采集必须配合DMA不然第一批数据还没读走第二批就覆盖过来了。DMA在这里的角色是搬运工。当ADC完成一次转换DMA就把ADC_DR里的数据搬到内存数组中。你要做的就是提前告诉DMA三件事源地址ADC的数据寄存器地址目标地址内存数组地址搬运长度通道数或者期望的采样次数然后启动一次DMA传输硬件就自动转起来了。配置中有几个关键点我逐个说开启扫描模式只有开启扫描模式ADC才会按通道顺序一个接一个转换。不开启的话ADC只转换你指定的第一个通道多通道就是摆设。选择转换模式有两种选择——单次转换模式一次扫描所有通道后停止和连续转换模式扫描完一轮后又从第一个通道开始。多通道连续采集必须选连续转换模式这样才能持续输出数据。DMA循环模式DMA传输到指定长度后如果不开启循环模式DMA就停了不会再搬运新数据。开启循环模式后DMA自动重新从数组起始地址开始搬运。连续采集肯定要开循环模式。数据宽度ADC数据寄存器是16位的DMA的数据宽度也要设置成16位半字否则数据拆包对不上。拿生活场景做个类比ADC就像一个自动测量流水线上的工人规则组就是工人手里的测量清单上面写着先测A通道再测B通道接着测C通道。DMA则是旁边专门记录数据的秘书工人量完一个数据喊一声记一下秘书马上把这个数记到本子上账目清清楚楚。CPU就是老板老板只需要看秘书整理好的整本记录不需要一直盯着工人干活。如果你不需要理解硬件级别的细节记住下面这几个配置就够了参数推荐配置原因Scan Conversion ModeEnabled必须开启否则只转换一个通道Continuous Conversion ModeEnabled连续采集需要单次采集可以关DMA Continuous RequestsEnabled配合DMA循环模式持续搬运End of Conversion SelectionEOC flag at end of sequence等整个序列结束再触发减少中断DMA Transfer ModeCircular循环往返搬运不停止DMA Data WidthHalf Word / 16位匹配ADC的16位数据寄存器3. CubeMX配置与代码实现手把手走一遍下面进入实操环节。我用的开发环境是STM32CubeMX版本6.x HAL库MCU以STM32F103C8T6为例配置两个通道PA0为ADC1_IN0PA1为ADC1_IN1演示实际你改成PA2、PA3甚至更多通道都行流程完全一样。3.1 CubeMX中的图形化配置打开STM32CubeMX先选中芯片型号时钟配置为72MHzSTM32F103系列常用。左侧Categories找到ADC开启ADC1。在ADC1的Configuration面板里Parameters选项卡按上面的表格配置Scan Conversion Mode选EnabledContinuous Conversion Mode选EnabledNumber Of Conversion选2逐一配置每个转换的通道和采样周期。在DMASettings选项卡里点击Add添加ADC1的DMA请求。传输方向选PeripheralToMemory外设到内存模式选Circular数据宽度都选Half Word。在NVIC Settings里打开DMA中断一般勾选DMA channel global interrupt即可。工程管理里选好工具链我用的是MDK-ARMGenerate Code。这样生成的基础代码就有了DMA和ADC的初始化接下来补充启动代码和回调函数。3.2 初始化代码的核心片段生成代码里重点看MX_ADC1_Init函数HAL库会自动生成但我建议你逐行检查一下关键配置尤其是DMA部分因为一旦初始化顺序或模式配错后面调试会头疼。static void MX_ADC1_Init(void) { ADC_ChannelConfTypeDef sConfig {0}; hadc1.Instance ADC1; hadc1.Init.ScanConvMode ADC_SCAN_ENABLE; // 扫描模式多通道必须 hadc1.Init.ContinuousConvMode ENABLE; // 连续转换模式 hadc1.Init.DiscontinuousConvMode DISABLE; hadc1.Init.ExternalTrigConv ADC_SOFTWARE_START; // 软件触发够用 hadc1.Init.DataAlign ADC_DATAALIGN_RIGHT; // 右对齐 hadc1.Init.NbrOfConversion 2; // 两通道 if (HAL_ADC_Init(hadc1) ! HAL_OK) Error_Handler(); // 配置第一个转换通道 sConfig.Channel ADC_CHANNEL_0; // PA0 sConfig.Rank ADC_REGULAR_RANK_1; sConfig.SamplingTime ADC_SAMPLETIME_239CYCLES_5; if (HAL_ADC_ConfigChannel(hadc1, sConfig) ! HAL_OK) Error_Handler(); // 配置第二个转换通道 sConfig.Channel ADC_CHANNEL_1; // PA1 sConfig.Rank ADC_REGULAR_RANK_2; sConfig.SamplingTime ADC_SAMPLETIME_239CYCLES_5; if (HAL_ADC_ConfigChannel(hadc1, sConfig) ! HAL_OK) Error_Handler(); }通道配置顺序和扫描顺序一致Rank_1是第一个转换的通道Rank_2是第二个依次类推。所以你在配置时要注意顺序不是按通道号大小排而是按你实际希望ADC转换的顺序排。DMA初始化在MX_DMA_Init函数里这段是CubeMX自动生成的static void MX_DMA_Init(void) { __HAL_RCC_DMA1_CLK_ENABLE(); // 打开DMA1时钟 HAL_NVIC_SetPriority(DMA1_Channel1_IRQn, 0, 0); HAL_NVIC_EnableIRQ(DMA1_Channel1_IRQn); // 使能DMA中断 }注意这里只是使能了中断DMA通道本身的配置在HAL_ADC_MspInit里HAL库通过回调的方式把ADC和DMA关联起来这也是HAL库的典型写法。void HAL_ADC_MspInit(ADC_HandleTypeDef* hadc) { GPIO_InitTypeDef GPIO_InitStruct {0}; __HAL_RCC_ADC1_CLK_ENABLE(); __HAL_RCC_GPIOA_CLK_ENABLE(); // 配置ADC引脚为模拟输入 GPIO_InitStruct.Pin GPIO_PIN_0 | GPIO_PIN_1; GPIO_InitStruct.Mode GPIO_MODE_ANALOG; HAL_GPIO_Init(GPIOA, GPIO_InitStruct); // DMA配置的关键代码 hdma_adc1.Instance DMA1_Channel1; // STM32F103的ADC1对应DMA1_Channel1 hdma_adc1.Init.Direction DMA_PERIPH_TO_MEMORY; // 外设到内存 hdma_adc1.Init.PeriphInc DMA_PINC_DISABLE; // 外设地址不自增 hdma_adc1.Init.MemInc DMA_MINC_ENABLE; // 内存地址自增多通道必须 hdma_adc1.Init.PeriphDataAlignment DMA_PDATAALIGN_HALFWORD; hdma_adc1.Init.MemDataAlignment DMA_MDATAALIGN_HALFWORD; hdma_adc1.Init.Mode DMA_CIRCULAR; // 循环模式 hdma_adc1.Init.Priority DMA_PRIORITY_HIGH; __HAL_LINKDMA(hadc, DMA_Handle, hdma_adc1); // 关联ADC和DMA HAL_DMA_Init(hdma_adc1); }__HAL_LINKDMA这行是关键它把ADC句柄的DMA_Handle指向了我们配置好的DMA句柄这样启动ADC时HAL库就能自动寻找到DMA并启动它。找不到这行的话后面调用HAL_ADC_Start_DMA会直接卡死或者不工作。3.3 修改后的主循环与DMA回调CubeMX生成的main函数里你需要在初始化之后加上数据数组声明和ADC启动#define ADC_CHANNEL_NUM 2 #define ADC_SAMPLE_NUM 4 uint16_t adc_buffer[ADC_CHANNEL_NUM * ADC_SAMPLE_NUM]; // 存储DMA搬运来的数据 int main(void) { HAL_Init(); SystemClock_Config(); MX_GPIO_Init(); MX_DMA_Init(); MX_ADC1_Init(); // 启动ADC的DMA采集 HAL_ADC_Start_DMA(hadc1, (uint32_t *)adc_buffer, ADC_CHANNEL_NUM * ADC_SAMPLE_NUM); while (1) { // 主循环中可以处理adc_buffer里的数据 // 由于DMA循环模式持续搬运每隔固定时间取一次数据处理即可 } }HAL_ADC_Start_DMA的第三个参数是DMA传输的总次数不是简单的数据个数而是你要ADC搬多少个数据到数组里。我这里是2通道、每通道采4次所以传的是8。这个参数传错的话采集工作会不正常后面常见问题里我详细说。DMA传输完成HAL库会回调以下函数你可以在这里设置标志位或者直接处理数据void HAL_ADC_ConvCpltCallback(ADC_HandleTypeDef* hadc) { if (hadc-Instance ADC1) { // 一批数据采完可以做整体处理 // 比如把adc_buffer里的数据转换成电压值并输出 // 注意这里不能做太耗时的事否则会影响DMA循环传输虽然影响很小 } }如果你是连续采集建议配合HAL_ADC_ConvHalfCpltCallback使用用双缓冲的思路。DMA在搬运前半段数据搬运完时会触发半传输回调搬完整个数组时触发完成回调。你在半传输回调里处理前半段缓冲在完成回调里处理后半段这样CPU和数据搬运动作并行效率最高。做音频采集、波形录制这类高频连续采集时这个技巧几乎是必须的。3.4 直接可用的完整示例代码下面把我实际测试过的完整代码贴出来比上面的函数片段更整一点思路清晰可以直接拷到工程里用。/* main.c 中的关键部分 */ #define ADC_CHANNEL_NUM 2 #define ADC_SAMPLE_NUM 4 #define ADC_BUFFER_SIZE (ADC_CHANNEL_NUM * ADC_SAMPLE_NUM) /* 全局数据缓冲区 */ uint16_t adc_buffer[ADC_BUFFER_SIZE] {0}; /* 打开ADC DMA采集 */ void adc_dma_start(void) { HAL_ADC_Start_DMA(hadc1, (uint32_t *)adc_buffer, ADC_BUFFER_SIZE); } /* 软件滤波简单滑动平均实际项目可增强 */ uint16_t adc_filter_channel(uint8_t ch) { uint32_t sum 0; for (uint8_t i 0; i ADC_SAMPLE_NUM; i) { sum adc_buffer[i * ADC_CHANNEL_NUM ch]; } return (uint16_t)(sum / ADC_SAMPLE_NUM); } int main(void) { HAL_Init(); SystemClock_Config(); MX_GPIO_Init(); MX_DMA_Init(); MX_ADC1_Init(); adc_dma_start(); while (1) { /* 每隔一段时间读取滤波后的通道0、通道1电压值 */ uint16_t ch0_raw adc_filter_channel(0); uint16_t ch1_raw adc_filter_channel(1); float v0 ch0_raw * 3.3f / 4096.0f; float v1 ch1_raw * 3.3f / 4096.0f; /* 这里继续处理v0、v1 */ HAL_Delay(100); } }这里有一个细节值得说清楚由于DMA是循环模式它在不断往adc_buffer里写入新数据。主循环读数据时并没有做任何同步保护可能会读到一半被DMA更新的数据覆盖。实际测试下来只要你的主循环处理速度足够快这个竞争几乎不会出问题。但如果你的数据一致性要求极高建议用半传输/完成回调里的标志位来做同步或者直接配合双缓冲。4. 多通道采集的校准与滤波直接影响精度DMA帮你把数据搬回来了但原始数据离可用的精准数据还有一段路走。我实际调试中发现很多新手拿到adc_buffer里的数据直接拿去用结果系统跑起来数据波动大、误差也大回头就怀疑DMA配置有问题。其实问题不在DMA而在数据后处理。4.1 多通道串扰为什么通道之间会互相影响多通道采集有个很隐蔽的坑通道串扰。现象就是某一路输入电压变化了相邻通道的数据也跟着轻微变化。原因主要是ADC采样电容的充电时间不够上一通道的残余电荷还没放干净就开始采下一通道了。解决办法主要有两个方向降低ADC采样时钟频率或者拉长采样时间给采样电容更充分的充电时间。ADC_SAMPLETIME便宜的直接拉满是一个省心的办法。外部硬件上在每个ADC输入引脚对地加一个100nF的去耦电容给采样电容提供电荷存储。我之前遇到通道3的数据明显跟随通道2变化排查了半天最后把触发一个通道的采样周期从ADC_SAMPLETIME_13CYCLES_5改成ADC_SAMPLETIME_239CYCLES_5问题立刻缓解了很多。如果你的信号源内阻高采样时间拉长是不可避免的这行配置省不得。4.2 软件滤波简单滑动平均DMA采到的原始数据受环境噪声影响直接拿去算电压值会看到明显的跳动。我比较推荐先用滑动平均或者一阶低通滤波处理再进入业务逻辑。滑动平均的代码上面已经给过了简单说下思路每个通道采集N次我设置的是4次取平均值。这N次数据正好在adc_buffer里是一组一组的比如通道0的数据在索引0、2、4、6位置通道1的数据在索引1、3、5、7位置。如果你要更平滑的效果可以用一阶低通滤波float filtered_value 0.0f; #define ALPHA 0.2f // 滤波系数0~1之间越小越平滑响应越慢 float adc_lowpass_filter(float new_value) { filtered_value ALPHA * new_value (1.0f - ALPHA) * filtered_value; return filtered_value; }系数ALPHA的选取看需求数据要跟手ALPHA就偏大要稳定平滑ALPHA就偏小。我一般先给0.2运行起来看效果再调。4.3 电压校准从AD值到真实电压的换算默认的电压换算公式非常简单电压 AD值 × 3.3V / 4096针对12位ADC。问题在于参考电压3.3V实际上可能不是精确的3.3V而且ADC本身还带了增益误差和偏移误差直接算出来的电压跟万用表实测的数据有偏差。我在实际项目里做了一个最简单的两点校准。拿两个标准电压比如0.5V和2.5V输入到ADC通道记下对应的AD值然后算出线性的斜率和偏移量// 标定参数 #define CAL_AD_LO 620 // 实测0.5V对应的AD值 #define CAL_VOLT_LO 0.5f #define CAL_AD_HI ##### // 实测实际电压对应的AD值 #define CAL_VOLT_HI 2.5f // 换算公式 float adc_calibrated_voltage(uint16_t raw_ad) { float slope (CAL_VOLT_HI - CAL_VOLT_LO) / (CAL_AD_HI - CAL_AD_LO); float offset CAL_VOLT_LO - slope * CAL_AD_LO; return slope * raw_ad offset; }如果你的项目对精度要求不高可以直接用3.3/4096这个系数算但如果你拿产品的精度标准和万用表对比过就会知道线性校准的这步省不了。多通道ADC每个通道的误差其实也有差异严格的做法是对每个通道分别做两点校准。5. 调试实录那些年我踩过的ADC DMA坑这一部分我按问题现象-原因排查-解决方法的格式来写每一段都是真实调试验证过的。5.1 数据读出来全是0x0000或者0x0280这是多通道DMA采集里最常见的问题之所以把0x0280单列是因为0x0280是STM32F103系类ADC一个很典型的空转值表现为初始基准电平根据具体参考电压浮动。出现这个现象先检查两件事是否真正开启了扫描模式和连续转换模式很多人只在CubeMX里勾了DMA扫描模式默认还是Disabled。这样ADC永远只在第一个通道反复转换DMA搬运的数据全是同一个通道的值。DMA的目标地址长度是否足够如果我配置的数组长度是ADC_CHANNEL_NUM但实际DMA传输长度填的是ADC_BUFFER_SIZEDMA访问越界后数据就乱了读出来自然不对。我排查这个问题的流程是先关掉DMA在调试器里读ADC_DR寄存器看值是否正常变换。如果ADC_DR正常那就是DMA配置的问题如果ADC_DR也不正常则先检查ADC通道配置和模拟引脚配置。5.2 只有第一个通道有数据其他通道全是0排查思路确认NbrOfConversion是否设置成了通道总数。有的懒人喜欢只配一个通道但把扫描模式打开了结果只有Rank_1的通道有数据。还要检查DMA的MemInc是否使能如果内存地址不自增DMA搬运的每个数据都会把上一个数据覆盖掉数组里最后只有最后一个通道的数据。5.3 通道顺序错乱数据放不进预期位置这个坑在CubeMX配置里很容易踩你按PA2、PA3、PA4、PA5的顺序配了四个转换通道依次是ADC_IN2、ADC_IN3、ADC_IN4、ADC_IN5但adc_buffer里的数据顺序却不匹配。原因在于CubeMX的Rank配置里如果你手动选择的通道号和Rank序号不一致扫描顺序就是Rank的顺序而不是通道号的顺序。比如Rank_1ADC_CHANNEL_2、Rank_2ADC_CHANNEL_3那DMA搬运的第一个数据就是PA2的数据第二个才是PA3的。在代码里写滤波函数、数据解析时一定要按Rank顺序索引// 正确第0个数据永远是Rank_1通道的数据即ADC_IN2/PA2 uint16_t ch_pa2 adc_buffer[0]; uint16_t ch_pa3 adc_buffer[1];5.4 DMA中断一直触发主循环卡死我之前遇到过一种情况DMA中断服务函数在跑主循环却完全卡住查下来是HAL_ADC_ConvCpltCallback里做了耗时的打印或数据处理而DMA传输速度很快半传输和完成中断连续触发主循环根本抢不到CPU。对策有两条路在回调里只置标志位数据处理放到主循环里做。如果必须实时处理可以把处理逻辑拆分成小块保证单次回调执行时间极短。5.5 HAL_ADC_Start_DMA 之后ADC没有启动或过一会儿自己停了这个现象经常发生在DMA传输一次就停、没有循环的情况下。检查DMA的模式是不是Circular。还有一个容易忽略的地方ADC的连续转换模式如果没打开ADC转换完一轮就停了只搬运一批数据后续无数据可搬。另一种情况是CubeMX生成代码的初始化顺序问题。DMA必须要在ADC初始化之前完成初始化否则__HAL_LINKDMA关联不到DMA句柄。如果你在main函数里看到MX_ADC1_Init()在MX_DMA_Init()之前被调用请调整顺序。实际我在F103上遇到过这个坑查了两天才发现是这么个顺序问题。6. 采样速率的选择与性能余量很多人配置ADC时对采样周期这块是随便填一个但这个参数直接决定了采样速度和稳定性尤其多通道DMA连续采集时如果采样周期太短各通道的串扰会非常明显数据质量直接拉胯。STM32F103的ADC最高时钟是14MHzAPB2时钟72MHz分频得到ADC预分频选6分频就是12MHz选4分频就是18MHz注意18MHz会超限所以F103推荐6分频。每个通道的转换时间 采样时间 12.5个ADC时钟周期。以ADC_SAMPLETIME_239CYCLES_5为例单通道转换时间 (239.5 12.5) / 12MHz ≈ 21µs。四个通道一轮就是约84µs对应的单通道采样率约为11.9kHz。实际我建议一般外部传感器信号采样周期设置在ADC_SAMPLETIME_71CYCLES_5到ADC_SAMPLETIME_239CYCLES_5之间。信号源内阻大、对精度要求高的选239.5周期信号源驱动能力强、要求速度快的可以选71.5周期。采样周期缩短一半通道串扰就开始上升了你得在速度和精度之间做取舍。DMA搬运本身不消耗额外时间它和ADC转换是并行进行的基本上可以认为对采样速率没有影响。这又是DMA方案的一个明显优势几乎不影响采样率上限。7. 再聊一点工程上的延伸多通道ADCDMA这套方案换个外设也完全通用。比如串口的收发、SPI的数据采集都可以用DMA来搬运。我在项目里把DMA用在了串口接收上配合空闲中断处理不定长数据效果很好。那次的经验让我明白DMA不是某个外设的特权而是CPU忙不过来和中断太频繁这类问题的通用解。还有一点想提醒你CubeMX生成的Error_Handler函数里通常是死循环如果初始化任何一个环节失败程序会卡死在里面。调试时建议先从断点或者串口打印确认初始化的每一步都正常走过再往下排查应用代码。这个多通道ADC采集方案后面如果接到更复杂的项目可以直接在adc_buffer上作FFT做频域分析也可以把数据通过DMA直接送到DAC做波形播放。整体框架已经完全够用剩下就看你的创意了。本文还有配套的精品资源点击获取