STM32 ADC多通道DMA采集:原理、配置与数据处理实战
1. 项目缘起为什么需要ADCDMA多通道采集如果你正在用STM32做数据采集比如环境监测、电机电流检测或者音频信号处理大概率会遇到一个经典问题CPU被频繁的ADC采样中断拖累导致主程序卡顿或者采样率上不去。我最初做平衡车项目时为了读取多个电机的电流和姿态传感器数据就掉进了这个坑。当时用轮询方式挨个通道读ADC主循环慢得像蜗牛控制周期根本达不到要求。后来改用中断虽然好一点但高采样率下中断风暴依然让系统喘不过气。直到我把ADC和DMA组合起来才真正解放了CPU实现了稳定、高效的多通道连续采集。这几乎是所有STM32中级玩家必须掌握的核心技能。简单来说ADC负责把模拟信号比如电压转换成数字值DMA则像一个“快递员”能在不打扰CPU你的主程序的情况下自动把ADC转换好的数据“搬运”到你指定的内存数组里。多通道采集就是让ADC依次对多个引脚比如通道0、1、2上的电压进行采样转换。HAL库是ST官方提供的硬件抽象层库它用一套统一的函数封装了底层寄存器操作让我们能更快速、更安全地开发但同时也隐藏了一些细节如果理解不透彻配置起来反而容易迷糊。所以这篇内容我会结合我踩过的坑和实战经验手把手带你从原理到配置再到数据处理的完整流程实现一个基于STM32 HAL库的ADC多通道DMA采集方案。目标是让你不仅能“抄作业”跑通更能明白每一个配置项背后的“为什么”以后遇到任何变种需求都能自己搞定。2. ADC与DMA协同工作原理深度拆解在动手写代码之前我们必须把ADC和DMA是怎么“搭伙干活”的机制搞清楚。这就像组装一台精密仪器不看说明书直接拧螺丝大概率会装错。2.1 ADC的扫描模式与连续转换模式STM32的ADC模块非常灵活但核心工作模式就几种。对于多通道采集我们必须启用扫描模式。扫描模式ADC会按照你预先配置好的通道序列比如先通道1再通道2最后通道5一个接一个地自动进行转换。就像一个自动化的流水线检测工位依次检查多个点位。连续转换模式当一次扫描所有通道转换一遍完成后ADC是停下来等待指令还是立刻开始下一轮扫描如果启用连续转换模式ADC在完成一轮扫描后会立刻、自动地开始新一轮扫描永不停歇直到你强行让它停止。这对于需要持续监控信号的场景是必须的。在我们的多通道DMA采集方案里通常会将ADC配置为扫描模式 连续转换模式。这样ADC就会不知疲倦地、循环地对我们设定的多个通道进行采样。2.2 DMA的搬运逻辑单次请求 vs 循环模式DMA的工作需要ADC来“触发”。每次ADC完成一个通道的转换产生一个转换完成事件这个事件就可以作为DMA的传输请求。这里有两个关键配置决定了DMA的行为DMA传输宽度与内存对齐ADC转换结果寄存器通常是16位或32位的取决于ADC分辨率。我们的目标内存是一个数组。必须确保DMA每次传输的数据大小字节数与源ADC数据寄存器和目的我们的数组的数据宽度匹配。例如12位ADC结果通常存放在16位的寄存器里我们定义的目标数组是uint16_t类型那么DMA就应该配置为半字16位传输。DMA工作模式单次模式DMA在传输完预设的数据量后比如搬完100个数据就自动停止需要软件重新启动。这适合单次采集一批数据。循环模式DMA在传输完预设的数据量后自动从头开始覆盖原来的数据继续传输。这正好与ADC的连续转换模式完美匹配。我们定义一个足够大的数组作为缓冲区DMA会循环地向里面填充最新的ADC数据形成一个“环形缓冲区”。CPU可以随时从数组里读取最新数据而不用担心数据被覆盖只要读取速度比DMA写入快。在我们的场景下DMA应配置为循环模式传输宽度与ADC结果寄存器及目标数组类型匹配。2.3 完整的“数据流水线”把整个过程串起来就形成了一条高效的数据流水线ADC启动软件触发或定时器触发。ADC开始扫描第一个通道进行采样和转换。第一个通道转换完成产生“转换完成”事件。该事件触发DMADMA将ADC数据寄存器中的值搬运到我们定义的数组的第一个位置。ADC自动开始扫描并转换第二个通道。第二个通道转换完成再次触发DMADMA将数据搬运到数组的第二个位置。依此类推直到所有配置的通道都转换完一遍完成一轮扫描。由于ADC处于连续模式它立刻开始第二轮扫描DMA也由于是循环模式在填满数组末尾后会回到数组开头继续填充如此循环往复。整个过程中CPU除了最初的配置和启动完全不需要干预。你可以在主循环里放心地进行复杂的控制算法计算、通信等任务只需在需要数据时去那个数组里读取即可。注意这里有一个极其重要的细节——数据在数组中的排列顺序。如果配置了3个通道Ch0, Ch1, Ch2采用扫描模式那么DMA搬运到内存数组中的数据流将是[Ch0数据, Ch1数据, Ch2数据, Ch0数据, Ch1数据, Ch2数据, ...]。你写数据处理代码时必须严格按照这个顺序来解析。3. 基于CubeMX的工程配置详解理论懂了我们开始实战。我强烈推荐使用STM32CubeMX进行图形化初始配置它能帮你生成正确的底层初始化代码避免很多低级错误。这里以STM32F103系列其他系列类似为例。3.1 ADC参数配置在CubeMX中打开你的芯片型号找到ADC1或其他ADC单元。Mode因为我们用多通道所以选择“Independent mode”独立模式。Scan Conversion ModeEnabled。这是关键启用扫描模式。Continuous Conversion ModeEnabled。启用连续转换模式让ADC自动循环。Discontinuous Conversion ModeDisabled。我们不使用间断模式。DMA Continuous RequestsEnabled。这个选项非常重要它允许在DMA传输期间ADC仍然能连续发出DMA请求。如果不开启在一轮DMA传输完成后ADC可能会停止发出请求导致数据流中断。End Of Conversion Selection选择“EOC after each conversion”每个转换后产生EOC事件。这样每个通道转换完都会触发DMA。Clock Prescaler根据你的系统时钟和所需ADC时钟来分频。ADC时钟不能超过芯片手册规定的最大值如STM32F103是14MHz。Resolution选择转换精度如12位。Data AlignmentRight alignment右对齐。这样12位数据存放在16位寄存器的低12位便于我们处理。Sampling Time采样时间。根据你的信号源阻抗选择阻抗越大需要更长的采样时间来让采样电容充电稳定。一般信号可选“239.5 Cycles”或更短。Rank序列配置在“Channel Selection”下选择你要用的ADC通道如IN0, IN1。在“Rank”中设置它们的转换顺序1 2...。采样时间也可以为每个通道单独设置。设置你需要的所有通道。3.2 DMA配置在CubeMX的“DMA Settings”标签页点击Add添加一个DMA请求。DMA Request选择“ADC1”与你使用的ADC对应。ModeCircular循环模式。核心选项Priority根据系统需求选择通常“High”或“Very High”确保数据不被丢失。Data WidthPeripheral选择“Half Word”16位。因为ADC数据寄存器是16位的。Memory选择“Half Word”16位。因为我们的目标数组是uint16_t。务必保持两者一致否则会导致数据错乱。3.3 生成代码与关键函数解读配置好时钟树保证ADC时钟正确和项目后生成代码。CubeMX会帮我们生成MX_ADC1_Init()和MX_DMA_Init()函数。我们需要关注用户代码区。在main.c的/* USER CODE BEGIN PV */区域定义全局变量#define ADC_BUFF_SIZE 256 // 缓冲区大小必须是通道数的整数倍 #define ADC_CHANNEL_NUM 3 // 你实际使用的通道数 uint16_t adc_dma_buffer[ADC_BUFF_SIZE]; // DMA目标缓冲区 volatile uint8_t adc_conversion_complete_flag 0; // 用于双缓冲或半传输中断的标志在/* USER CODE BEGIN 2 */区域启动ADC和DMA// 启动DMA传输将ADC数据搬运到adc_dma_buffer // 参数ADC句柄 目标缓冲区 缓冲区长度以字为单位这里就是数据个数 if (HAL_ADC_Start_DMA(hadc1, (uint32_t*)adc_dma_buffer, ADC_BUFF_SIZE) ! HAL_OK) { Error_Handler(); // 启动失败处理 }这一行代码是核心它做了三件事1. 启动ADC转换。2. 启动DMA。3. 将两者关联起来。4. 数据处理策略与环形缓冲区实战数据源源不断地来了我们怎么安全、高效地读取它直接去adc_dma_buffer里取最新的值吗没那么简单。因为DMA在后台不停地写这个数组如果你读取的时候DMA正好在写同一个位置就可能读到“半截”数据撕裂读。所以我们需要一个安全的策略。4.1 双缓冲区与半传输/传输完成中断最优雅的方式是利用DMA的半传输完成中断和传输完成中断。这是HAL库支持的特性。原理我们把adc_dma_buffer逻辑上分成前后两半。当DMA填满前半部分时触发“半传输完成中断”HT当DMA填满整个缓冲区即写完后半部分回到开头覆盖前半部分时触发“传输完成中断”TC。操作在HT中断里我们知道DMA正在写后半部分缓冲区那么前半部分缓冲区的数据是完整的、稳定的CPU可以安全地读取和处理这半部分数据。在TC中断里我们知道DMA已经回到开头写前半部分缓冲区那么后半部分缓冲区的数据是完整的、稳定的CPU可以安全地读取和处理这半部分数据。实现在CubeMX的NVIC设置中使能DMA通道的全局中断。在代码中重写DMA的中断回调函数// 在 stm32f1xx_it.c 中找到DMA中断服务函数它会调用HAL_DMA_IRQHandler最终调用下面的回调 // 我们需要在用户文件中重写这个弱函数 void HAL_ADC_ConvHalfCpltCallback(ADC_HandleTypeDef* hadc) { if(hadc-Instance ADC1) { adc_conversion_complete_flag 1; // 表示前半部分数据就绪 // 此时可以安全处理 adc_dma_buffer[0] 到 adc_dma_buffer[ADC_BUFF_SIZE/2 -1] } } void HAL_ADC_ConvCpltCallback(ADC_HandleTypeDef* hadc) { if(hadc-Instance ADC1) { adc_conversion_complete_flag 2; // 表示后半部分数据就绪 // 此时可以安全处理 adc_dma_buffer[ADC_BUFF_SIZE/2] 到 adc_dma_buffer[ADC_BUFF_SIZE -1] } }在主循环中检查adc_conversion_complete_flag然后去处理对应的半缓冲区数据。处理完后清除标志。这种方法实现了CPU和DMA的无锁协同是工业级应用的首选。4.2 简易“乒乓”缓冲区策略如果项目简单不想用中断也可以用一个“取巧”但需谨慎的方法双缓冲乒乓操作。定义两个缓冲区uint16_t bufferA[ADC_BUFF_SIZE];和uint16_t bufferB[ADC_BUFF_SIZE];。启动DMA指向bufferA。在某个安全点比如主循环开始或一个固定频率的定时器中断里停止DMAHAL_ADC_Stop_DMA此时bufferA数据完整。立即将DMA目标切换到bufferB并重新启动DMAHAL_ADC_Start_DMA。在后台处理bufferA的数据。下次循环再在安全点停止DMA切换回bufferA处理bufferB如此往复。警告这种方法会短暂中断数据流。在停止和重启DMA的瞬间会丢失几个ADC样本。如果你的应用对数据连续性要求不是极其严苛且处理速度远快于缓冲区填满的速度这个方法简单有效。但如果要求无缝连续务必使用4.1的中断法。4.3 数据解析与校准拿到原始ADC值uint16_t后需要转换成电压值。float adc_value_to_voltage(uint16_t raw_adc, float vref) { // raw_adc: 原始ADC值 (0-4095 for 12-bit) // vref: 实际参考电压 (V) return ((float)raw_adc / 4095.0f) * vref; }关键点vref是你的ADC参考电压。对于STM32可能是VREF引脚接入的电压也可能是VDDA模拟电源电压。务必在硬件上保证参考电压的稳定和准确这是精度的基础。有些高端型号有内部参考电压和校准值可以读取芯片唯一ID对应的校准值来修正这里不展开。对于多通道数据解析时要牢记顺序。假设通道顺序是[Ch0, Ch1, Ch2]缓冲区大小是6为了举例那么缓冲区内容可能是[Ch0_sample1, Ch1_sample1, Ch2_sample1, Ch0_sample2, Ch1_sample2, Ch2_sample2]处理函数需要知道通道数然后按步长提取void process_adc_buffer(uint16_t* buf, uint32_t size, uint8_t ch_num) { for(int i 0; i size; i ch_num) // 每次步进一个“帧” { uint16_t ch0_val buf[i]; uint16_t ch1_val buf[i1]; uint16_t ch2_val buf[i2]; // ... 处理每个通道的值 } }5. 高频问题排查与性能优化心得配置完了代码写了但可能没数据、数据错乱或者速度不达标。下面是我总结的几个常见坑和优化点。5.1 DMA传输计数器不减少或数据为0现象启动后缓冲区里全是0或者某个固定值DMA的传输计数器CNDTR没变化。排查时钟未使能首先检查MX_DMA_Init()和MX_ADC1_Init()是否被正确调用。确保DMA和ADC的外设时钟都已开启CubeMX通常会自动做好。触发源错误我们的配置是软件启动后连续转换。检查是否调用了HAL_ADC_Start_DMA。如果用的是定时器触发则需要配置ADC为“外部触发”模式并启动定时器。DMA通道映射错误不同ADC的DMA请求可能映射到不同的DMA流/通道。务必在CubeMX里核对ADC1的DMA请求是否分配到了正确的DMA和Stream/Channel。STM32F1的DMA比较简单F4/H7等系列有Stream和Channel的概念更容易配错。内存或外设地址错误极少数情况下手动配置DMA时地址写错。使用CubeMX生成可避免此问题。5.2 数据顺序错乱或通道间串扰现象通道0的数据跑到了通道1的位置或者所有通道值都差不多。排查GPIO模式未配置ADC通道对应的GPIO引脚必须配置为模拟输入模式。在CubeMX的Pinout视图里检查相关引脚必须是“Analog”。如果配置成浮空输入或其他模式会导致信号无法正确输入。采样时间不足如果信号源阻抗较大比如用了很大的分压电阻而采样时间设置太短采样电容充电不足转换结果就不准确可能表现为通道值偏低或波动大。增加Sampling Time可以解决。PCB布局与滤波模拟信号走线要远离数字信号特别是时钟线和PWM线在ADC输入引脚就近增加一个对地的小电容如100nF进行滤波可以显著减少噪声和串扰。5.3 如何提高有效采样率采样率 ADC时钟频率 / (采样周期数 转换周期数)。对于12位转换转换周期固定是12.5个ADC时钟周期。提升ADC时钟在芯片允许的范围内通过时钟树配置提高ADC的输入时钟PCLK2并减少分频比。减少采样时间在信号质量允许的前提下减少Sampling Time。这是提升采样率最有效的方法之一。减少通道数扫描的通道越多完成一轮扫描的时间就越长每个通道的实际采样率就越低。公式是单通道采样率 总采样率 / 通道数。使用规则组与注入组对于需要高速采样的特定通道可以将其放入“注入组”在任意时刻中断常规扫描序列优先转换它。但这属于高级用法会中断常规的DMA流需要更精细的控制。5.4 关于HAL库的延迟与超时HAL库的HAL_ADC_Start_DMA等函数内部可能有超时判断。在极端高性能要求下这些软件延迟可能成为瓶颈。对于这种情况直接使用LL库ST提供的底层库寄存器级操作几乎没有额外开销。寄存器直接操作在HAL初始化好的基础上直接操作ADCx-CR2等寄存器来启动/停止但需要对参考手册非常熟悉。优化HAL超时可以修改stm32f1xx_hal_conf.h中的HAL_ADC_TIMEOUT_VALUE定义减少超时等待时间但需谨慎。对于绝大多数应用HAL库的性能和便利性是完全足够的不必过早优化。6. 进阶话题定时器触发与精确采样控制前面我们用的是软件触发、连续转换。这种方式简单但采样间隔由ADC转换总时间决定不够精确。在很多需要固定频率采样的场合如音频、振动分析我们需要用定时器来触发ADC。6.1 配置定时器作为ADC触发源CubeMX配置配置一个定时器如TIM2工作在“更新事件”触发模式。计算好定时器频率使其产生你想要的ADC采样频率。在ADC配置中将“External Trigger Conversion Source”设置为该定时器如“Timer 2 Trigger Out event”。将“Continuous Conversion Mode”改为Disabled。因为现在由定时器事件来触发每一次扫描。代码修改启动顺序变为先启动DMA (HAL_ADC_Start_DMA)然后启动定时器 (HAL_TIM_Base_Start)。一旦定时器开始运行就会按照设定频率定时触发ADC扫描。这样采样间隔就由定时器的周期精确控制与ADC转换时间无关只要转换能在下次触发前完成即可。6.2 处理定时器触发下的数据流此时DMA和ADC依然工作在循环模式。定时器每次触发ADC完成一轮多通道扫描DMA搬运一组数据。缓冲区里数据的排列依然是顺序的但数据的产生是周期性的。这种方法特别适合与数字信号处理算法如FFT配合因为采样点是严格等时间间隔的。7. 项目实战构建一个简单的四通道电压监测器让我们把所有知识串起来做一个能实际跑起来的例子。目标用STM32F103C8T6Blue Pill板子采集4个通道的电压假设来自电位器通过串口每秒打印一次各通道的平均电压值。硬件连接PA0 - 电位器1中间引脚 - ADC_IN0PA1 - 电位器2中间引脚 - ADC_IN1PA2 - 电位器3中间引脚 - ADC_IN2PA3 - 电位器4中间引脚 - ADC_IN3电位器两端接3.3V和GND。USART1 (PA9 TX, PA10 RX) 连接USB转TTL模块到电脑。CubeMX关键配置ADC1扫描模式使能连续转换模式使能DMA连续请求使能分辨率12位右对齐。为IN0, IN1, IN2, IN3设置Rank 1-4采样时间239.5 Cycles。DMA为ADC1添加DMA模式循环宽度半字。USART1异步模式波特率115200。时钟树系统时钟72MHzADC预分频设为6得到12MHz ADC时钟在F103允许范围内。核心代码 (main.c)/* USER CODE BEGIN PV */ #define ADC_BUFF_SIZE 256 #define ADC_CH_NUM 4 uint16_t adc_dma_buf[ADC_BUFF_SIZE]; volatile uint8_t adc_half_ready 0; volatile uint8_t adc_full_ready 0; uint32_t adc_ch_sum[ADC_CH_NUM] {0}; uint16_t adc_ch_avg[ADC_CH_NUM] {0}; uint16_t samples_processed 0; /* USER CODE END PV */ /* USER CODE BEGIN 0 */ // DMA半传输完成回调 void HAL_ADC_ConvHalfCpltCallback(ADC_HandleTypeDef* hadc) { adc_half_ready 1; } // DMA传输完成回调 void HAL_ADC_ConvCpltCallback(ADC_HandleTypeDef* hadc) { adc_full_ready 1; } /* USER CODE END 0 */ int main(void) { HAL_Init(); SystemClock_Config(); MX_GPIO_Init(); MX_DMA_Init(); MX_ADC1_Init(); MX_USART1_UART_Init(); // 启动ADC DMA传输 if (HAL_ADC_Start_DMA(hadc1, (uint32_t*)adc_dma_buf, ADC_BUFF_SIZE) ! HAL_OK) { Error_Handler(); } while (1) { // 处理前半缓冲区 if(adc_half_ready) { process_adc_buffer(adc_dma_buf, ADC_BUFF_SIZE/2, ADC_CH_NUM); adc_half_ready 0; } // 处理后半缓冲区 if(adc_full_ready) { process_adc_buffer(adc_dma_buf[ADC_BUFF_SIZE/2], ADC_BUFF_SIZE/2, ADC_CH_NUM); adc_full_ready 0; } // 每秒计算并发送一次平均值 static uint32_t last_tick 0; if(HAL_GetTick() - last_tick 1000) { last_tick HAL_GetTick(); if(samples_processed 0) { char msg[128]; for(int i0; iADC_CH_NUM; i) { adc_ch_avg[i] adc_ch_sum[i] / samples_processed; // 计算整数平均 float voltage ((float)adc_ch_avg[i] / 4095.0f) * 3.3f; // 假设Vref3.3V sprintf(msg, CH%d: %.3fV , i, voltage); HAL_UART_Transmit(huart1, (uint8_t*)msg, strlen(msg), 100); adc_ch_sum[i] 0; // 清零和为下一秒准备 } sprintf(msg, \r\n); HAL_UART_Transmit(huart1, (uint8_t*)msg, strlen(msg), 100); samples_processed 0; } } } } // 处理缓冲区数据的函数 void process_adc_buffer(uint16_t* buf, uint32_t size, uint8_t ch_num) { // size是本次要处理的数据总数 for(uint32_t i0; i size; i ch_num) { for(uint8_t ch0; ch ch_num; ch) { adc_ch_sum[ch] buf[i ch]; // 累加对应通道的值 } samples_processed; // 处理完了一“帧”数据 } }这个例子展示了从采集、缓冲、处理到输出的完整链路。通过双缓冲中断机制安全读取数据在主循环中非实时地计算平均值并通过串口输出是一个很实用的框架。你可以在此基础上增加数据滤波、报警、通过其他协议上传数据等功能。