STM32多通道ADC采集实战:DMA+扫描模式避坑指南

📅 发布时间:2026/7/30 6:18:21
STM32多通道ADC采集实战:DMA+扫描模式避坑指南 1. 从一次失败的传感器数据采集说起最近在做一个环境监测的小项目核心需求是同时采集温度、湿度和光照强度三个传感器的模拟量数据。我手头正好有一块STM32F103C8T6的核心板心想这还不简单直接用ADC轮询读取三个通道不就完事了结果代码一跑起来问题接踵而至数据更新慢得像蜗牛主循环被ADC转换卡得死死的更头疼的是偶尔还会读到一些明显“跳变”的异常值导致屏幕上的数值乱闪。这让我意识到在需要实时、稳定采集多路模拟信号的场景下那种“初始化ADC启动转换等待完成读取数据”的简单轮询模式根本不够用。这正是标题里“踩坑”二字的由来。而“ADCDMA”的组合就是走出这个坑的“傻瓜式”解决方案。它背后的核心思想是“解放CPU让数据自动搬家”。ADC负责专心致志地把模拟电压转换成数字值DMA直接存储器访问则像一个勤快的搬运工在ADC每转换完一个通道的数据后自动把这个数据搬运到你预先指定好的内存数组里。整个过程完全不需要CPU参与你的主程序可以放心地去处理通信、显示、逻辑判断等其他任务等需要数据时直接去那个数组里取就行既高效又实时。这篇文章我就以STM32F1系列为例结合我实际踩过的那些坑带你一步步搭建一个稳定可靠的多通道ADC采集框架。我们会从原理开始捋清楚ADC扫描模式和DMA传输的配合机制然后手把手进行CubeMX配置和代码编写最后重点剖析几个最容易导致采集失败或数据异常的“深坑”并给出经过实测的解决方案。无论你是刚开始接触STM32的ADC还是已经被多路采集搞得焦头烂额相信这篇“踩坑实录”都能给你带来直接的帮助。2. 核心原理ADC扫描模式与DMA如何协同工作在深入代码之前我们必须先吃透ADC和DMA是如何“搭伙干活”的。很多初学者配置失败问题往往不是出在代码上而是对这两个外设的工作模式理解有偏差。2.1 ADC的扫描模式与连续转换普通的单通道ADC采集我们通常配置为“单次转换”模式触发一次转换一个通道然后停止。但对于多通道这种模式效率极低。STM32的ADC提供了“扫描模式”。一旦启用扫描模式ADC会按照你预先在“通道序列”比如规则序列寄存器SQR里排好的顺序一个接一个地自动转换多个通道。这里有一个关键点扫描模式解决的是“按顺序转换多个通道”的问题但它不解决“连续不断地进行扫描”的问题。也就是说一次扫描完成后ADC就停止了。如果你想让它周而复始地自动扫描就需要配合“连续转换模式”。当同时启用扫描模式和连续转换模式时ADC在完成一次对所有指定通道的扫描后会自动从头开始下一次扫描形成一个永不间断的循环。这正是我们实现多通道实时采集的基础。2.2 DMA的搬运逻辑与数据对齐DMA在这里扮演了“自动抄表员”的角色。我们需要告诉DMA三件事从哪里搬源地址、搬到哪里去目标地址、怎么搬搬运模式。源地址固定为ADC数据寄存器ADCx-DR的地址。这是ADC转换完成后存放结果的地方。目标地址我们自己在内存中定义的一个数组比如uint16_t adc_value[3];用来存放三个通道的数据。搬运模式这里通常选择“循环模式”。在这种模式下DMA会从目标数组的开头开始依次存放数据当填满数组即完成了一次设定的数据量搬运后它会自动回到数组开头覆盖旧数据开始新一轮的搬运。这完美匹配了ADC连续扫描的工作节奏。一个极其重要的细节是数据对齐。STM32F1的ADC是12位的转换结果在数据寄存器DR中默认是右对齐的。这意味着一个16位的uint16_t变量其高4位是0低12位是有效数据。当我们使用DMA以16位宽度半字从DR寄存器搬运数据到uint16_t数组时这个对齐方式是自动匹配的无需特别处理。但如果你出于某些原因比如节省空间想用8位数组uint8_t来存就必须小心处理数据拆分否则会读错。对于初学者强烈建议统一使用uint16_t数组避免不必要的麻烦。2.3 工作流程全景图让我们把整个过程串起来看一个动态的工作流程初始化配置ADC为扫描模式连续转换模式设置好要转换的通道序列例如通道1、2、3。配置DMA源地址是ADC-DR目标地址是adc_value数组数据宽度16位模式为循环模式数据数量为3。启动使能ADC的DMA请求然后启动ADC转换。自动运行ADC开始转换通道1转换完成后数据存入DR寄存器。此时ADC会向DMA发送一个请求信号。DMA收到请求立即将DR寄存器中的16位数据搬运到adc_value[0]这个内存位置。ADC自动开始转换通道2完成后DMA再将数据搬运到adc_value[1]。接着是通道3数据搬运到adc_value[2]。一次扫描结束。由于开启了连续转换ADC立刻又从通道1开始新一轮扫描。当DMA完成3次搬运即填满数组由于是循环模式它会将目标地址指针重置回adc_value[0]准备接收新一轮的数据。同时DMA可能会产生一个“传输完成”中断如果你开启了的话通知CPU一批数据已经准备好了。主程序读取在你的主循环或任何需要传感器数据的地方直接读取adc_value[0]、adc_value[1]、adc_value[2]它们就是近乎实时的、最新的三个通道的ADC值。整个过程CPU只在初始化时参与了配置后续的采集和搬运全由硬件自动完成实现了极高的效率。3. 实战配置从CubeMX到代码的完整步骤理解了原理我们动手实现。我使用HAL库和STM32CubeMX进行配置这是目前最快速、最不易出错的方式。3.1 CubeMX图形化配置引脚配置在“Pinout Configuration”标签页找到你的ADC例如ADC1。将你需要使用的模拟输入通道对应的GPIO引脚设置为“Analog”模式。比如PA1ADC1_IN1、PA2ADC1_IN2、PA3ADC1_IN3。ADC参数配置进入ADC1的“Parameter Settings”选项卡。Scan Conversion Mode: 设置为Enabled。这是启用扫描模式的关键。Continuous Conversion Mode: 设置为Enabled。这是实现自动连续扫描的关键。DMA Continuous Requests: 设置为Enabled。这保证了在连续转换模式下DMA请求也能持续有效。End Of Conversion Selection: 保持默认EOC after each conversion。Data Alignment: 选择Right alignment。这是最常用的12位右对齐。Resolution: 选择12 bits。ADC通道序列配置切换到“ADC1”的“NVIC Settings”旁边的“Rank”设置。在“Rank”列表中点击“Add”来添加规则转换序列。对于第一个RankRank1在“Channel”下拉框中选择你的第一个通道如IN1。可以设置采样时间Sample Time根据信号源阻抗调整阻抗越大时间需要越长通常1.5到239.5个周期对于大多数传感器选择“239.5 Cycles”能保证稳定。同样地添加Rank2选择通道IN2添加Rank3选择通道IN3。这里的顺序决定了DMA搬运到数组中的顺序。DMA配置在“DMA Settings”选项卡点击“Add”。DMA Request: 选择“ADC1”。Mode: 选择Circular循环模式。这是实现自动覆盖旧数据的关键。Data Width: 两边都选择Half Word半字即16位。这与我们的uint16_t数组和ADC的12位右对齐数据格式匹配。生成代码配置好时钟树确保ADC时钟不超过14MHz对于F1系列在“Project Manager”设置好项目名称、路径和IDE点击“Generate Code”。3.2 代码编写与关键函数解析CubeMX生成的代码已经完成了绝大部分初始化。我们只需要在合适的地方添加少量用户代码。首先在main.c文件的用户变量定义区定义我们的ADC值数组和用于计算电压的变量/* USER CODE BEGIN PV */ uint16_t adc_raw_buf[3]; // 存放三个通道的原始ADC值 float voltage[3]; // 存放计算后的电压值 /* USER CODE END PV */然后在main()函数的初始化部分/* USER CODE BEGIN 2 */之后启动ADC的DMA传输/* USER CODE BEGIN 2 */ // 启动ADC的DMA传输将转换结果自动搬运到 adc_raw_buf 数组 // 参数ADC句柄目标数组数组长度即DMA传输次数 if (HAL_ADC_Start_DMA(hadc1, (uint32_t*)adc_raw_buf, 3) ! HAL_OK) { Error_Handler(); // 如果启动失败进入错误处理 } /* USER CODE END 2 */这行代码是核心。它告诉ADC和DMA“开始工作吧每次转换完就把数据放到adc_raw_buf里连续搬3个数据算一轮。”接下来在主循环中我们就可以直接读取这个数组了。通常我们还需要将原始的ADC值0-4095转换为实际的电压值0-3.3V/* USER CODE BEGIN WHILE */ while (1) { /* USER CODE END WHILE */ /* USER CODE BEGIN 3 */ // 读取并处理ADC数据 for(int i0; i3; i) { // 将原始值转换为电压 (Vref 3.3V, 12位ADC满量程为4095) voltage[i] (float)adc_raw_buf[i] * 3.3f / 4095.0f; // 这里可以添加你的传感器标定公式例如将电压转换为温度、湿度等 // temperature some_calibration_function(voltage[0]); } // 示例通过串口打印数据需要事先配置好串口 // printf(Ch1: %.3fV, Ch2: %.3fV, Ch3: %.3fV\r\n, voltage[0], voltage[1], voltage[2]); HAL_Delay(1000); // 每秒处理一次 } /* USER CODE END 3 */注意HAL_ADC_Start_DMA启动后adc_raw_buf数组的内容会在后台被DMA自动、持续地更新。主循环中的读取操作是安全的因为DMA搬运一个16位数据是原子操作在32位总线上我们读取时要么读到旧值要么读到完整的新值不会读到被撕裂的中间状态。但如果你的应用对数据一致性要求极高比如需要一组严格同时刻的采样则需要使用DMA传输完成中断在中断里处理一整组数据。代码至此一个基础的多通道ADC采集程序就完成了。下载到板子用杜邦线将PA1、PA2、PA3分别连接到开发板的3.3V、GND和一个可调电位器的中间脚你应该能看到串口打印的电压值随之变化。4. 避坑指南那些让我调试到深夜的问题如果一切顺利那么恭喜你。但现实往往是骨感的下面就是我遇到过的几个典型问题及其解决方案。4.1 数据错位通道顺序和数组下标对不上这是最常见的问题之一。现象是我接在PA1通道1上的电压打印出来的却是voltage[1]或voltage[2]的值。根因分析问题出在CubeMX中的Rank顺序、DMA搬运顺序和代码中数组下标的对应关系上。CubeMX里我设置的Rank1是IN1Rank2是IN2Rank3是IN3。DMA会严格按照ADC转换完成的顺序搬运。ADC先转换Rank1IN1所以DMA把第一个数据放到adc_raw_buf[0]然后转换Rank2IN2数据放到adc_raw_buf[1]最后Rank3IN3数据放到adc_raw_buf[2]。如果我在代码里错误地认为adc_raw_buf[0]对应PA2那数据就全乱了。解决方案明确映射关系在代码注释或文档里清晰地写下映射关系。例如// adc_raw_buf[0] - ADC1_IN1 (PA1) - 温度传感器 // adc_raw_buf[1] - ADC1_IN2 (PA2) - 湿度传感器 // adc_raw_buf[2] - ADC1_IN3 (PA3) - 光照传感器测试验证编写一个简单的测试程序依次给每个通道输入一个已知的独特电压比如通道1接3.3V通道2接GND通道3接1.65V然后观察打印出的数组值立刻就能确认映射关系是否正确。4.2 数据跳动剧烈采样时间不足与硬件滤波缺失现象是电压值在小范围内无规律地快速跳动即使输入电压很稳定。根因分析采样时间太短ADC的输入引脚内部有一个采样电容。采样时间Sample Time就是给这个电容充电到输入电压水平的时间。如果信号源阻抗较大比如用了很长的导线或传感器本身输出阻抗高或者采样时间设置得太短电容就没能充分充电到稳定值导致每次转换结果都有偏差。STM32CubeMX中每个Rank都可以独立设置采样时间。电源与参考电压噪声模拟电路的“地”不干净数字电路的高速开关噪声通过电源或地线串扰到了模拟部分。ADC的参考电压VREF不稳定会直接导致转换结果缩放比例变化。软件滤波不足ADC本身存在量化误差和噪声直接读取单次转换值本身就是不稳定的。解决方案增加采样时间在CubeMX配置中将每个通道的采样时间Sample Time适当调大。对于大多数传感器应用设置为“239.5 Cycles”是一个比较稳妥的起点它能应对较高的信号源阻抗。硬件滤波在ADC输入引脚与地之间并联一个0.1uF的瓷片电容可以滤除高频噪声。确保模拟部分ADC引脚、传感器供电和数字部分MCU内核、IO的电源在物理上尽可能分开并采用磁珠或0欧电阻进行单点连接。如果条件允许使用独立的、干净的LDO低压差线性稳压器为模拟部分和ADC参考电压供电避免开关电源的纹波干扰。软件滤波这是成本最低且效果显著的方法。不要使用单次值而是对连续采集的多个值进行数学处理。均值滤波连续采样N次如64次求和后取平均。这是最常用的方法能有效抑制随机噪声。中值滤波连续采样N次奇数个取大小排在中间的那个值。这对消除偶发的、幅度大的脉冲干扰尖峰噪声特别有效。一阶滞后滤波低通滤波new_value k * old_value (1-k) * current_sample其中k为滤波系数0k1。这种方法计算量小能平滑数据但会引入相位滞后。一个简单的均值滤波示例代码可以放在主循环中#define SAMPLE_COUNT 64 uint32_t sum[3] {0}; uint16_t avg_buf[3] {0}; // 采集多次并求和 for(int j0; jSAMPLE_COUNT; j) { // 等待DMA更新数组简单延时或更好的方式是利用DMA中断 // 这里简单起见假设主循环速度远慢于ADC采样速度数组已更新 for(int i0; i3; i) { sum[i] adc_raw_buf[i]; } HAL_Delay(1); // 稍微延时确保采集到新数据 } // 求平均值 for(int i0; i3; i) { avg_buf[i] sum[i] / SAMPLE_COUNT; sum[i] 0; // 清零和为下一次做准备 voltage[i] (float)avg_buf[i] * 3.3f / 4095.0f; }4.3 DMA传输不启动或数据不更新现象是数组里的数据一直是初始值通常是0或者完全不变化。根因排查启动顺序错误一定要先完成ADC和DMA的所有初始化最后再调用HAL_ADC_Start_DMA()。如果在初始化流程中过早启动可能外设还未就绪。DMA或ADC时钟未使能虽然CubeMX通常会自动配置但检查一下SystemClock_Config()函数确认__HAL_RCC_ADC1_CLK_ENABLE()和__HAL_RCC_DMA1_CLK_ENABLE()或DMA2被调用。数组作用域或存储问题确保adc_raw_buf数组是全局变量或静态变量并且有足够的生命周期。如果它被定义在某个函数内部局部变量函数退出后其内存可能被回收DMA写入就会导致内存错误。中断冲突如果开启了DMA传输完成中断但没有正确编写中断服务函数或者中断优先级设置不当被其他高优先级中断阻塞也可能导致异常。解决方案与调试技巧使用调试器这是最强大的工具。在HAL_ADC_Start_DMA()后打一个断点单步执行查看返回值是否为HAL_OK。检查寄存器在调试模式下查看ADC控制寄存器ADCx-CR2的ADON、CONT、SCAN、DMA位是否被置1。查看DMA相关通道的配置寄存器是否设置正确。内存观察在IDE的“Memory”或“Watch”窗口中添加adc_raw_buf数组的地址实时观察其值是否在变化。如果看到数据在规律地变化说明ADC和DMA硬件层面工作正常问题可能出在软件读取或处理逻辑上。简化测试先做一个最简单的单通道ADC DMA测试排除多通道和扫描模式的复杂性。成功后再逐步增加通道。4.4 多ADC同步采集的进阶问题如果你的项目需要更高的采样率或真正的同步采样可能会用到STM32的双ADC模式如交替、同步等。这里有一个大坑问题在ADC1和ADC2同步模式下通常只启用ADC1的DMA。但ADC2的数据如何通过DMA搬运答案与坑在同步模式下两个ADC的数据会合并成一个32位数据存入ADC1的数据寄存器ADC1-DR。其中高16位是ADC2的数据低16位是ADC1的数据。因此你的DMA目标数组应该定义为uint32_t并且配置DMA的数据宽度为Word字32位。在数据处理时需要将32位数据拆开uint32_t adc_dual_raw_buf[SAMPLE_SIZE]; uint16_t adc1_value, adc2_value; // ... 在中断或主循环中处理 ... adc1_value (uint16_t)(adc_dual_raw_buf[i] 0xFFFF); // 取低16位 adc2_value (uint16_t)((adc_dual_raw_buf[i] 16) 0xFFFF); // 取高16位千万注意不要错误地配置为16位宽度去搬运否则只能拿到一半数据而且顺序全乱。5. 性能优化与工程实践建议当基本功能跑通后我们可以从工程角度考虑如何让它更稳健、更高效。5.1 使用DMA传输完成中断处理数据在主循环中轮询读取数组虽然简单但无法精确知道“一组数据”何时采集完成。使用DMA传输完成中断是更专业的方式。在CubeMX中启用DMA传输完成中断在DMA配置页面找到“NVIC Settings”或DMA流的中断设置勾选“Transfer Complete Interrupt”。编写中断回调函数在stm32f1xx_it.c中DMA的中断服务函数会自动调用HAL_DMA_IRQHandler最终会调用到用户重写的弱函数HAL_ADC_ConvCpltCallback()。在用户代码中重写该回调函数/* USER CODE BEGIN 4 */ // 当DMA搬运完预设长度的数据即一组数据后会进入此中断 void HAL_ADC_ConvCpltCallback(ADC_HandleTypeDef* hadc) { if(hadc-Instance ADC1) // 判断是哪个ADC { // 此时 adc_raw_buf 中已经填充好了一组完整的新数据 // 可以设置一个标志位通知主循环处理 adc_data_ready_flag 1; } } /* USER CODE END 4 */主循环中检查标志位while (1) { if(adc_data_ready_flag) { adc_data_ready_flag 0; // 处理 adc_raw_buf 中的数据 process_adc_data(); } // ... 执行其他任务 }这样做的好处是数据处理与数据采集在时间上是解耦的。主循环不必忙等待可以处理其他任务只在数据准备好时才进行处理提高了系统效率。5.2 校准ADC以提高精度STM32的ADC内置了校准功能可以减小内部电容误差带来的偏移。上电后或环境温度变化较大时执行一次校准能提升精度。/* USER CODE BEGIN 2 */ // 启动ADC校准 HAL_ADCEx_Calibration_Start(hadc1); // 校准完成后再启动DMA if (HAL_ADC_Start_DMA(hadc1, (uint32_t*)adc_raw_buf, 3) ! HAL_OK) { Error_Handler(); } /* USER CODE END 2 */5.3 动态调整采样率与功耗权衡连续转换模式下ADC的采样率由ADC时钟ADCCLK和总转换时间决定。总转换时间 采样时间 12.5个周期固定转换时间。如果你想降低采样率以节省功耗或者适应慢速变化的信号有两种方法降低ADC时钟在CubeMX的时钟树配置中降低APB2总线时钟ADC挂载在APB2上但会影响其他外设。使用定时器触发转换这是更灵活和推荐的方式。将ADC的转换触发源从“软件触发”改为“定时器触发”例如TIM2的TRGO事件。然后配置定时器的溢出频率即可精确控制ADC的采样间隔。这样ADC只在定时器事件到来时才进行一次扫描转换其余时间处于低功耗状态。在CubeMX的ADC配置“Parameter Settings”中找到“External Trigger Conversion Source”进行设置即可。经过以上步骤你应该已经能够构建一个稳定、高效且易于维护的STM32多通道ADC采集系统了。从原理理解到避坑实践最关键的是动手调试和观察。当你看到传感器数据平稳、实时地呈现在屏幕上时那种成就感就是对之前所有调试工作的最好回报。