STM32多通道ADC采集:轮询、中断与DMA对比及实战选型 上个月有个做仪器仪表的朋友忽然甩给我一段代码一块 STM32F030C8T68 路电位器分压做模拟量输入他在 main 里开了一个 for 循环一路一路切 ADC 通道然后 HAL_ADC_PollForConversion 傻等。他说采集频率要提上去CPU 被拖得不行想改 DMA。我当时就问了他一句你实际采集频率到底多少这句话其实才是问题核心——多通道 ADC 的轮询、中断、DMA 三种实现本质上就是拿 CPU 时间换数据方案选型跟着场景走而不是跟着“听起来高级”走。这篇东西我打算把 F030 上的三种做法从头到尾拆开讲代码怎么写、CubeMX 怎么配、每个方案卡在哪个地方、换方案后 CPU 占用差多少、最后给你一张选型表。适合刚接触 STM32 多通道采集的人也适合已经能跑通但想搞明白“为什么别人推荐 DMA”的开发者。1. 先把需求量化F030 的 ADC 资源与三种方案的设计思路1.1 这个项目到底在测什么采集任务的真实约束先别急着配寄存器。做多通道 ADC 之前必须把两个数字定下来你要采几路以及每路多久采一次。我朋友这个项目是 8 路电位器分压用来模拟 8 个旋钮位置人手动旋转频率最多几十赫兹理论上轮询绰绰有余。但他后来想同时在主循环里跑优化算法每隔几十毫秒还要刷新一次小屏轮询那点阻塞时间就变得不能忍了。这就是典型场景采集本身不重但你不能让采集阻塞其他事情。STM32F030C8T6 的 ADC 资源在同价位里算很实在12 位分辨率外部输入通道十几个小封装上实际引出的引脚少一些内部还带 VREFINT 参考电压通道和温度传感器通道最高支持约 1Msps 采样。主频 48MHzDMA1 一共 5 个通道其中一个固定映射给 ADC。这套组合决定了它非常适合做“多路模拟量连续采集 CPU 处理其他逻辑”的活前提是你把 ADC 的方式选对。1.2 三种方式的本质区别CPU 在数据链路上扮演什么角色轮询、中断、DMA 的核心区别不是代码风格而是 CPU 在每一次数据搬运里投入了多少精力。轮询模式下CPU 启动 ADC 转换后什么都不干死死盯着标志位等转换完成再去读数据寄存器。这相当于你在餐厅后厨盯着厨师炒菜菜不出锅你别想走开。中断模式好一点CPU 启动转换后可以先忙别的等 ADC 转换完成发出中断CPU 停下手里的事进中断服务函数把数据读走读完之后再重新启动下一路。这相当于你按了个铃铃声一响你才跑回厨房端菜。DMA 模式最彻底ADC 转换完成后由 DMA 控制器直接把数据寄存器里的值搬到内存数组里全程不需要 CPU 参与搬完一整批再通知 CPU 一次。这相当于雇了个传菜员菜出锅自动上桌全部上完喊你一声。所以这三种方式其实对应三种系统设计哲学轮询是简单但阻塞中断是并发但繁琐DMA 是高效但有配套成本。接下来逐个聊。2. 轮询模式5 行代码能跑但有隐藏代价2.1 轮询模式从 CubeMX 到代码的落地过程轮询模式的配置最简单。CubeMX 里打开 ADC1不需要开 DMA不需要开中断唯一建议做的是把 ScanConvMode 关掉也就是不启用扫描模式因为我们用单通道循环切换的方式来实现多通道。代码也很直观uint16_t adc_read_channel(uint32_t channel) { ADC_ChannelConfTypeDef sConfig {0}; sConfig.Channel channel; sConfig.Rank ADC_REGULAR_RANK_1; sConfig.SamplingTime ADC_SAMPLINGTIME_41CYCLES_5; HAL_ADC_ConfigChannel(hadc1, sConfig); HAL_ADC_Start(hadc1); HAL_ADC_PollForConversion(hadc1, 10); uint16_t val HAL_ADC_GetValue(hadc1); HAL_ADC_Stop(hadc1); return val; } // 主循环里每 10ms 轮询一次 8 路 uint32_t last_scan HAL_GetTick(); while (1) { if (HAL_GetTick() - last_scan 10) { last_scan HAL_GetTick(); for (int i 0; i 8; i) { adc_samples[i] adc_read_channel(adc_channels[i]); } } }这段代码看起来笨但有一个非常实际的好处它天然可控你随时可以暂停某一路或者临时插一路进去不需要重新配 DMA 缓冲区。刚上电调试时先用这种方式确认每一路 ADC 都能正常读到值再去优化效率是我自己习惯的调试顺序。2.2 扫描模式配轮询是个坑我劝你避免很多人一上来就想用“扫描模式 轮询”读多通道因为 CubeMX 里 ScanConvMode 打开后序列里配置好几个通道看起来一次启动就能扫完。对 DMA 来说这是对的但对轮询来说未必。在我手里的 HAL 库版本里HAL_ADC_PollForConversion 的等待逻辑在扫描模式下会一直等到整个序列转换结束才返回也就是说它不会“每转完一个通道就返回一次”让你挨个取数。等你拿到返回值时数据寄存器里已经是最后一个通道的值了。不同 HAL 版本的行为有细微差别但你如果直接用扫描模式配轮询很可能踩到“读了半天全是同一路”的坑。所以我才推荐上面那套“关扫描、单通道切换、循环读取”的写法。虽然多配几次通道会让速度慢一点但逻辑绝对可靠。2.3 轮询模式的核心瓶颈CPU 全程陪跑轮询模式最大的问题是 CPU 等待转换期间无法做别的事。我们简单算一笔账ADC 时钟 12MHz12 位转换固定需要 12.5 个 ADC 周期如果采样时间按 41.5 个周期算单通道一次转换需要 54 个 ADC 周期约 4.5us。8 个通道就是 36us。如果每秒只采 100 轮CPU 占用率只有 0.36%完全无所谓。但如果采集频率提到 10kHz光 ADC 等待就占掉 36% 的 CPU而且这期间主循环是卡住的按键扫描、屏幕刷新、通信协议全都会被拖慢。轮询模式因此只适合两类场景要么采集频率很低要么这个 MCU 真的一点别的事都没有。3. 中断模式用中断替代忙等CPU 终于能喘口气3.1 中断方式的配置与代码实现当你发现主循环被轮询拖住时第一反应就是把“等待”换成“中断”。CubeMX 里在 ADC1 配置页打开全局中断代码上把 HAL_ADC_Start 换成 HAL_ADC_Start_IT然后在转换完成回调里读取数据。多通道的实现思路是每次只配置一个通道转换完成进中断中断里读值然后切换到下一个通道并再次启动转换。volatile uint8_t ch_idx 0; uint16_t adc_samples[8]; static const uint32_t adc_channels[8] { ADC_CHANNEL_0, ADC_CHANNEL_1, ADC_CHANNEL_2, ADC_CHANNEL_3, ADC_CHANNEL_4, ADC_CHANNEL_5, ADC_CHANNEL_6, ADC_CHANNEL_7 }; void HAL_ADC_ConvCpltCallback(ADC_HandleTypeDef *hadc) { if (hadc-Instance ADC1) { adc_samples[ch_idx] HAL_ADC_GetValue(hadc1); ch_idx (ch_idx 1) % 8; ADC_ChannelConfTypeDef sConfig {0}; sConfig.Channel adc_channels[ch_idx]; sConfig.Rank ADC_REGULAR_RANK_1; sConfig.SamplingTime ADC_SAMPLINGTIME_41CYCLES_5; HAL_ADC_ConfigChannel(hadc1, sConfig); HAL_ADC_Start_IT(hadc1); } } // 初始化时先启动第一路 HAL_ADC_Start_IT(hadc1);这里有一个很关键的习惯回调里不要做耗时操作尤其不要做浮点运算、串口打印、malloc 之类的动作。中断上下文里任务越短越好通常只做“读书据到数组 修改下一个通道号 重新启动”然后立刻退出真正对数据的处理放到主循环。顺便提一句初学的时候很容易把中断优先级配得过高。F030 的中断优先级分组和 Cortex-M0 的嵌套向量中断控制器特性有关如果你把 ADC 中断优先级拉到最高而系统里又有串口中断之类极端情况下串口数据可能被频繁打断表现就是通信偶发丢字节。我的习惯是ADC 采集属于周期性任务中断优先级给中等偏下不跟通信这类“错过就丢”的中断抢。3.2 中断风暴离你有多远实时性没有想象中高中断模式解决了“主循环被阻塞”的问题但并没有减少 CPU 实际投入的总时间反而可能更多。因为每次转换完成后CPU 要经历“跳中断向量、压栈、进入回调、读数据、配置下一通道、再次启动、出栈”这一整串操作这些开销叠加起来往往比轮询里简单看一眼标志位还要贵。我实际测过F030 在 48MHz 下每路 ADC 中断服务的完整时长大概在 2us 到 3us 之间取决于你在回调里干了多少活。如果也按 8 路、每路 4.5us 转换时间算一轮采集约 36us 转换时间加上 8 次中断开销约 20us总共接近 56us。对比轮询模式的 36usCPU 实际占用不降反升。那中断模式到底为了什么它不是为了让 CPU“更快”而是为了让 CPU “更自由”。轮询模式下 CPU 持续傻等无法响应外部其他事件中断模式下CPU 在每次转换间隙能回到主循环处理按键、刷新屏幕、跑协议虽然总耗时可能更高但系统的并发响应能力强了一大截。所以中断模式适合那些“采集频率不算太高但系统里还得干别的事”的项目。不过当通道数再多一点比如 16 路采样频率再拉高到几十 kHz中断模式就会接近“中断风暴”的边缘CPU 大部分时间都在进进出出中断主循环反而被饿死。这时候就该 DMA 上场了。4. DMA 模式把搬运工换成硬件实测效率差距明显4.1 CubeMX 里的 DMA 配置每一步都不能错DMA 模式是我最终推荐大部分多通道项目采用的方案但配置导航里坑也最多。我按 CubeMX 的操作顺序来。第一步ADC1 配置页ScanConvModeEnabled开启扫描ContinuousConvModeEnabled连续转换NumberOfConversion填实际通道数比如 8在序列列表里依次把 Rank 对应到你需要的通道比如 Rank1ADC_IN0、Rank2ADC_IN1……采样时间根据信号源阻抗来我后面会专门讲第二步在 ADC1 的 DMA Settings 标签页添加 DMA RequestDMA RequestADC1ModeCircular循环模式Data WidthPeripheral 和 Memory 都设 Half Word这里有一个非常容易被漏掉的选项不同版本 CubeMX 里位置略有不同有些在 ADC 配置页底部叫 DMA Continuous Requests务必设为 Enabled。如果不打开DMA 可能只搬一轮数据就停了表现就是你看到数组里只有头几个值在变化后几个一直是零。第三步确认生成代码后main 函数里初始化顺序应该是 MX_DMA_Init 在 MX_ADC1_Init 之前。CubeMX 生成的代码默认是这么排的但如果你手动改过代码顺序或者把初始化代码重新整理过很容易把 DMA 初始化放到 ADC 后面结果 ADC 请求 DMA 时 DMA 还没准备好现象非常诡异编译没问题运行也正常数据就是不动。检查方法很简单看初始化代码里有没有先把 DMA 使能再初始化 ADC。另外 F030 的 ADC DMA 请求固定映射到 DMA1 的通道 1。写代码的时候可以不用关心这个映射CubeMX 会自动生成但如果你以后想在标准库里手写配置这个映射关系必须记住。4.2 关键代码校准、启动与回调里处理数据DMA 方式的初始化代码比前两种多一步ADC 校准。STM32F0 系列的 ADC 上电之后建议先做一次校准否则偏置误差可能达到几十 mV 级别对要求不高的场景也许无所谓但做电压监测时就很难受了。uint16_t adc_buf[8]; int main(void) { HAL_Init(); SystemClock_Config(); MX_GPIO_Init(); MX_DMA_Init(); MX_ADC1_Init(); HAL_ADCEx_Calibration_Start(hadc1); HAL_ADC_Start_DMA(hadc1, (uint32_t *)adc_buf, 8); while (1) { if (frame_ready) { frame_ready 0; // 在这里处理 adc_buf[0] ~ adc_buf[7] } } } void HAL_ADC_ConvCpltCallback(ADC_HandleTypeDef *hadc) { if (hadc-Instance ADC1) { frame_ready 1; } }注意 HAL_ADC_Start_DMA 的参数。第三个参数是传输长度如果只传 8那么 DMA 每搬完 8 次数据就触发一次传输完成中断刚好对应一轮扫描的 8 个通道。这种写法下adc_buf 的下标跟你在 CubeMX 里配置的 Rank 顺序一一对应Rank1 的数据进 adc_buf[0]Rank2 进 adc_buf[1]如此类推。回调里我还是只干一件事置标志位。不要在里面直接打印数据串口打印在慢速波特率下动不动几百微秒起步放在中断里会把 DMA 回调的实时性拖垮。主循环检测到标志位再去处理这是最稳妥的姿势。4.3 DMA 循环连采的数据一致性半传输中断的正确用法DMA 配成 Circular 模式后最典型的坑不是“不工作”而是“工作得太连续”外设每完成一个通道转换DMA 就往 adc_buf 里写一个数据写完 8 个数之后自动从头开始写。如果主循环直接去读 adc_buf可能刚好读到 DMA 写到一半的数组前 4 个元素是这一轮的后 4 个元素还是上一轮的拼在一起就是错的数据。我管这叫“帧撕裂”。解决思路有三个。最简单粗暴只在 HAL_ADC_ConvCpltCallback 回调里读数据。因为传输完成中断发生时恰好说明一整轮数据已经写完此时数据是完整的。缺点是如果一轮采集周期很短而主循环很长回调里置了标志位后等主循环去读时 DMA 可能已经写了下一轮好几笔数据所以不要让数据在缓冲区里等太久。进阶一点的做法把缓冲区开成两倍长比如 16 个 uint16_t然后开启 DMA 的半传输中断和传输完成中断。DMA 写入前 8 个元素时触发半传输中断这时 CPU 可以去处理前半段数据DMA 继续写后半段时 CPU 处理前半段等后半段写完触发传输完成中断CPU 再回来处理后半段。这样数据永远都是隔了半个缓冲期才被读取天然避开撕裂。uint16_t adc_buf[16]; // 8 通道 x 2 帧 void HAL_ADC_ConvHalfCpltCallback(ADC_HandleTypeDef *hadc) { if (hadc-Instance ADC1) { process_frame(adc_buf[0]); // 前半帧 } } void HAL_ADC_ConvCpltCallback(ADC_HandleTypeDef *hadc) { if (hadc-Instance ADC1) { process_frame(adc_buf[8]); // 后半帧 } }这样做的本质是把单个长缓冲区拆成两个半区轮流读和写防止读写冲突。很多做音频流、高频数据采集的项目里都能看到类似思路只是换了名字叫双缓冲或者 Ping-Pong Buffer核心逻辑完全一样。5. 三种方式横向实测对比CPU 占用、实时性与代码量5.1 实测场景与结果100kHz 采样率下差距是一个数量级我的测试环境是自制的 F030C8T6 最小系统板VDDA 接 3.3V外部 4 路电位器分压输入ADC 时钟 12MHz12 位分辨率采样时间 41.5 个 ADC 周期单通道转换时间约 4.5us。我写了一个临时测试把空闲 CPU 用 GPIO 翻转表示用逻辑分析仪量高电平时间估算占用率。8 通道测试结果大致如下实现方式每轮 8 通道耗时(估算)1kHz 采集时CPU占用10kHz 采集时CPU占用代码复杂度轮询(单通道切换)约 36us切换开销约 4%约 40%最简单中断(单通道切换)约 36us8次中断开销约 6%约 60%中等DMA(扫描循环)约 36usCPU几乎不参与小于 1%约 3%中等偏高注意这组数据是粗略估算绝对值会随着编译器优化等级、回调里干活的多少、甚至库函数版本浮动但相对量级很说明问题DMA 模式下 CPU 只在每轮结束时被叫醒一次10kHz 采样率下 CPU 占用依然可以控制在个位数百分比。轮询和中断在低采样率下其实也够用但频率一旦拉上去根本没有可比性。5.2 到底怎么选一张表帮你做决定低频率、代码快速验证、通道数 1 到 2 路直接轮询。不需要为了 10Hz 的采集去折腾 DMA 和中断而且轮询代码可读性最好后期维护成本最低。如果系统在主循环里还要处理按键、显示、通信这类周期性任务频率要求又不高中断模式是平滑过渡的选择。它没有 DMA 那么多配置讲究又比轮询“优雅”。如果你要做的是真正的多通道连续采集比如 8 路传感器信号、三相电流、电压监测或者采样频率超过几十 kHzDMA 基本是唯一合理选择。它把 CPU 从繁重的搬运工作中解放出来让主循环只负责算法和控制逻辑。还有一类特殊场景我要单独提一下强实时控制。比如用 ADC 采样去做电流环这时候不仅要 DMA还建议把 ADC 触发方式改成定时器触发让采样时刻精确可控CPU 只负责在 DMA 中断里执行控制算法。6. 多通道 ADC 实战踩坑记录与排查方法6.1 通道顺序错乱与 DMA 数据错位DMA 模式最常见的异常是数据读出来了但通道对应关系完全不对比如你接在 ADC_IN0 上的电压跑到了 adc_buf[3] 里。第一步检查 CubeMX 里 Rank 的顺序。Rank1 对应 adc_buf[0]Rank2 对应 adc_buf[1]按 Rank 逐个核对别只看 Channel 列表里列了多少通道。第二步检查 GPIO 配置ADC 输入引脚要设置为模拟模式CubeMX 里在芯片引脚图上点一下对应引脚选 ADC_INx 即可自动配置。第三步检查内存数组类型F030 的 ADC 数据寄存器是 16 位结构右对齐下高 4 位是零数组务必要用 uint16_tDMA 数据宽度必须是 Half Word。用到 uint8_t 或 Byte 宽度搬出来的数据直接就是乱的。排查这类问题我有一个很土但很有效的办法只给一路输入接一个已知电压比如 1.5V 电池其他输入全部接地然后打印 adc_buf 全部值。正常情况下只有一个值明显偏大其他接近零。如果出现两个大值或者大值出现在错误下标问题就出在 Rank 配置或 DMA 长度上。6.2 首次转换结果漂移与校准问题F030 的 ADC 有一个非常典型的“上电第一次读数不准”现象。原因分两块一是 ADC 完全没有做过校准就启动内部比较器偏置没有被修正二是刚上电时内部参考电压和基准源还没有稳定立刻采样读数自然漂。所以 DMA 启动之前必须加校准而且校准和启动之间最好留十几个毫秒让时序稳定下来。即使加了校准DMA 启动后的第一帧数据我也建议直接丢掉从第二帧开始再作为有效数据。做法很简单置一个 skip_first_frame 标志第一次回调里只清标志不处理数据。还有一个常见但容易被忽略的坑如果你在运行过程中调用了 HAL_ADC_Stop_DMA然后想再次启动建议先复位 DMA 状态再重新启动。HAL 库在 Stop 之后 DMA 句柄状态有时候没有完全复位直接再次 Start_DMA 可能导致回调不触发或数据不更新。稳妥做法是调用 HAL_DMA_Abort 或干脆把 DMA 通道显式复位一下再启。6.3 采样时间不足读数偏低或者跳变这个问题在直接从高阻信号源采样时最容易踩。ADC 内部采样电容需要在采样阶段被外部信号源充到跟输入电压一致如果外部源阻抗太大采样时间太短电荷还没充够就被切断了读出来的值必然偏低。特别是多通道切换时每一次采样前电容里保留的还是上一个通道的电荷前几个通道受影响最明显。经验值按 F030 的 ADC 时钟 12MHz 来算常用采样时间档位如下采样时间配置实际时间(us)适合的源阻抗场景1.5 cycles0.125低阻抗运放输出13.5 cycles1.125常规分压电阻 10k 以下28.5 cycles2.375高阻抗传感器、电位器41.5 cycles3.46信号源阻抗较高时最保险71.5 cycles5.96高阻、容性负载、长走线239.5 cycles19.96极慢信号追求稳定性如果你的输入是 10k 分压电阻网络建议至少用 28.5 cycles 以上。如果传感器是光敏电阻、压电片这类高阻抗器件外部还要并联一个 0.1uF 电容同时采样时间拉长到 71.5 cycles 也不过分。判断是否“采样时间不足”有个简单方法把采样时间调大一档如果读数明显上升说明之前没充够电如果读数几乎不变说明采样时间已够再调大也只是浪费时间。6.4 浮空引脚和 DMA Continuous Requests 漏配浮空引脚的影响我之前提了一嘴这里展开说。ADC 输入引脚如果被配置成模拟模式但没有连接真实信号引脚电压会悬空或受到邻近线路串扰噪声会被 ADC 采进来。如果你多个通道共用同一个电路板走线很长甚至会出现通道间串扰一个通道接了大电压旁边浮空通道读出来的值也跟着变。解决方法是把所有不用的 ADC 通道在硬件上接地或者在软件里根本不配置它们不把它们加入转换序列。CubeMX 里 DMA Continuous Requests 是否漏配判断也很简单如果启动后数据只在第一次更新之后就固定不变十有八九就是这个选项没开。打开之后ADC 在外设端的“连续请求信号”才能不断触发 DMA 搬运循环模式才真正循环起来。每次重新生成工程后也要回去检查一下因为不同版本的 CubeMX 模板对这条默认值的处理不一样。6.5 参考电压与 VDDA 的电源质量最后说一个很多人容易忽视的问题F030 的 ADC 参考电压。如果是 LQFP48 这类有独立 VREF 引脚的封装你可以在 VREF 上接高精度基准源来提升精度。但 F030C8T6 这种小封装通常把 VREF 跟 VDDA 绑在一起也就是说 ADC 的满量程完全取决于 VDDA 的电压质量。我见过有人在同一个 3.3V 稳压器上又带电机又带 ADC采集出来的电压读数跟着电机转速跳来跳去。这不是 ADC 的锅是参考电压本身在抖动。解决办法是给 VDDA 单独加 LC 滤波或者用独立的线性稳压器供电ADC 的信号输入也尽量远离 PWM 走线和高频数字线。多通道 ADC 项目里电源部分花的功夫往往比代码多但收益也是最直接的。再补一个我自己的习惯不管用哪种方式我都会在采样数组里多开两个元素一个存“有效的最后一轮序号”一个存“这一轮里是否有通道越界”之类的软件状态这样主循环在处理数据时可以先做一个合法性判断再进入业务逻辑。看起来多花两个字节但排查问题时能省下不少时间。做 ADC 采集这件事代码能跑通只是起点把 CPU 占用、数据一致性、抗干扰能力一起考虑进去才算是真正把这个模块做明白了。