1. 多通道 ADC 的用武之地
把“Multichannel ADC”和“STM32F411CEU6”这两个关键字放在一起,其实就是嵌入式开发里最常遇到的一类需求:用一块不算贵的 MCU,同时采集多路模拟信号。STM32F411CEU6 是 48 引脚的 Cortex-M4F 主控,主频能到 100MHz,内部带了 3 个 12 位逐次逼近型 ADC,外加上 DMA 支持和经典的扫描采样机制,做多通道数据采集非常顺手。这篇文章不打算只贴代码,我会把从硬件架构、CubeMX 配置、HAL 底层逻辑到实际调试中踩过的坑,完整拆开讲一遍。
适应什么场景?比如四路 NTC 温度采集、电机驱动里的相电流和母线电压同时监测、电池管理系统里多节电芯电压巡检,或者一个简单的手持仪表需要同时看三到四路传感器输出。这类需求共同点是:采样通道多、要求数据连续、不希望 CPU 在每次转换完成后都被打断。
那为什么偏偏用 STM32F411CEU6?因为它在 LQFP48 封装下仍然能引出 16 个 ADC 外部输入通道,2.4Msps 的理论采样率对绝大多数传感器足够,功耗也低,而且 HAL 库和 CubeMX 对这套外设支持很成熟,网上随便一搜就是大量现成案例。真正把多通道 ADC 玩明白,关键不是会点几个按钮,而是搞清楚扫描模式、DMA 循环缓冲、采样时间、触发方式这些概念之间的关系。下面我从硬件底层开始讲。
2. ADC 硬件资源与设计前置知识
2.1 F411CEU6 的 ADC 基本规格
STM32F411CEU6 内部有 3 个 ADC 外设,编号 ADC1、ADC2、ADC3,都是 12 位逐次逼近型(SAR)结构。它们的参考电压都来自 VDDA/VREF+,48 引脚封装里 VREF+ 和 VDDA 在内部是相连的,所以实际参考电压就是模拟电源电压。每个 ADC 支持多达 16 个外部通道,外加内部温度传感器通道和 VREFINT 参考电压通道。
需要注意,虽然芯片内部有 3 个 ADC,但外部引脚并不充裕。F411CEU6 上实际可用的 ADC 输入通道如下表:
| 通道号 | 引脚 | 通道号 | 引脚 |
|---|---|---|---|
| ADC_IN0 | PA0 | ADC_IN8 | PB0 |
| ADC_IN1 | PA1 | ADC_IN9 | PB1 |
| ADC_IN2 | PA2 | ADC_IN10 | PC0 |
| ADC_IN3 | PA3 | ADC_IN11 | PC1 |
| ADC_IN4 | PA4 | ADC_IN12 | PC2 |
| ADC_IN5 | PA5 | ADC_IN13 | PC3 |
| ADC_IN6 | PA6 | ADC_IN14 | PC4 |
| ADC_IN7 | PA7 | ADC_IN15 | PC5 |
这几个通道对于多路传感器采集是相当充裕的。实际项目里最常用的组合是 PA0 到 PA3 接 4 路模拟量,PB0、PB1 再接两路,六路同时采样一点问题没有。但如果引脚同时被 SPI、I2C、USART 占用,就需要在布局阶段提前规划。我的经验是先把模拟通道固定下来,再做数字外设的引脚分配。
2.2 SAR ADC 的转换周期是怎么算的
要理解采样率,就得先拆转换周期。SAR ADC 每次转换分两个阶段:采样阶段和逐次逼近阶段。F411 的 ADC 时钟由 APB2 分频得到,可配置为 2、4、6、8 分频。F411CEU6 在 100MHz 主频下,APB2 默认是 50MHz,所以 2 分频后就是 25MHz 的 ADC 时钟。
每次转换需要“采样时间 + 12 个 ADC 时钟周期”。采样时间可以单独配置,有 3、15、28、56、84、112、144、480 个周期可选。如果采样时间选 3 周期,一整次转换就是 15 个 ADC 时钟周期,那么:
单通道最大转换率 = 25MHz / 15 ≈ 1.67Msps
如果是 4 个通道的扫描模式,每轮扫描完成 4 次转换,那么每个通道的实际采样率就降到:
1.67Msps / 4 ≈ 417ksps
这个数据对绝大多数温度、压力、电压检测都足够了。但如果你要做音频或振动分析,每个通道想要 100ksps 以上,就得留意采样时间是否选得太长。选采样时间不能只看采样率,还要看信号源的内阻和 PCB 走线阻抗,后面精度部分我会展开讲。
2.3 为什么要用扫描模式而不是逐个切换
很多新手会写一个循环,每次切换 ADC 通道、启动转换、等标志位、读数据。这种方式在单通道或极低速场景下能跑,但有两个明显问题:每次切换通道都要重新配置寄存器,CPU 一直忙等 EOC 标志,浪费大量算力。扫描模式下,你只需要把要采的通道和顺序配置好,触发一次后 ADC 会自动按顺序采完所有通道,数据由 DMA 搬运到内存,CPU 全程不用参与。
这里的核心概念是“规则通道序列”。ADC 里有一套规则通道寄存器,你把通道 0 放在 Rank1、通道 1 放在 Rank2、通道 2 放在 Rank3、通道 3 放在 Rank4,转换时硬件就按这个顺序依次执行。配合 DMA,结果会按照同样的顺序连续写入内存缓冲区,数据排列天然整齐。
3. 多通道采样整体方案设计
3.1 为什么必须用 DMA
我在评论区见过不少新手问:“不用 DMA 直接读可以吗?”可以,但不推荐。ADC 转换完成后,如果没有用 DMA,每个通道转换完都要进一次中断,或者 CPU 轮询 EOC 标志。假设每通道 20ksps、共 4 路,那 CPU 每秒钟要处理 8 万次中断或查询。虽然也能跑,但中断处理函数里有压栈、出栈、标志清理这些开销,对系统实时性影响很大。而 DMA 是硬件搬运,数据从 ADC 数据寄存器搬到内存不占用 CPU 时间。
F411 的 ADC1 可以映射到 DMA2 的 Stream0 或 Stream4,Channel 固定为 0。CubeMX 会自动帮你选择 Stream0,配成外设到内存方向、循环模式、半字长度,这样一个 DMA 通道就能同时服务多路 ADC 数据。
3.2 连续转换与 DMA 循环模式的配合
多通道 ADC 的经典配置是“扫描模式开启 + 连续转换开启 + DMA 循环模式”。扫描模式负责一轮内按顺序采完所有通道,连续转换负责采完一轮马上开始下一轮,DMA 循环模式负责把数据源源不断搬进内存缓冲区,并且缓冲区满了自动从头开始覆盖。
这三者配合,最终在内存里呈现的是一个连续的数据流,顺序大概是:
通道0、通道1、通道2、通道3、通道0、通道1、通道2、通道3……
这给数据解析提供了很大方便。你只要知道一轮有多少个通道,就能从缓冲区里按固定步长把每个通道的数据抽出来。
3.3 缓冲区设计思路
缓冲区大小的选择有讲究。最简单的做法是设一个长度为“通道数”的小缓冲区,DMA 每搬完一轮就产生一次传输完成中断,在中断里取走数据。这种方案的问题是中断频率太高,而且 DMA 回绕速度和中断执行速度之间容易出竞争。
更好的做法是使用一个较大的环形缓冲区,利用 DMA 的半传输和传输完成中断,实现类似乒乓缓冲的效果。比如 4 通道、每轮 4 个数据,设缓冲区长度为 256 个半字(64 轮完整数据),DMA 每搬满 128 个半字产生一次半传输中断,再搬满另外 128 个半字产生一次传输完成中断。两个中断交替处理缓冲区的前半段和后半段,这样在处理数据的同时,DMA 还在往另一段缓冲区写入,互不冲突。
4. STM32CubeMX 配置过程
4.1 时钟树配置
打开 CubeMX,芯片选 STM32F411CEU6。第一步先把时钟配好。如果板子上有 8MHz 晶振,RCC 里选 HSE 外部晶振,然后在 Clock Configuration 页面把 SYSCLK 配到 100MHz,APB2 自然就是 50MHz。ADC 预分频器选 /2,得到 25MHz 的 ADC 时钟。
如果板子上没有外部晶振,那就只能用 HSI 16MHz,通过 PLL 倍频到 96MHz 左右也能用,只是精度会差一些。做多通道 ADC 采集,外部晶振还是建议装一个,尤其当你后续想用定时器做精确采样触发时,时钟源精度直接影响采样间隔。
4.2 ADC 参数配置
在 Analog 选项卡里打开 ADC1,勾选你需要的通道。假设我要采 4 路,分别是 PA0、PA1、PA2、PA3,那就分别勾选 IN0、IN1、IN2、IN3。
参数区按下面设置:
- Mode:Independent Mode
- Scan Conversion Mode:Enabled
- Continuous Conversion Mode:Enabled
- Discontinuous Conversion Mode:Disabled
- DMA Continuous Requests:Enabled
- End of Conversion Selection:Sequencer
- Number Of Conversion:4
- External Trigger Source:Software Trigger
- External Trigger Edge:None
通道顺序是在每个通道的子项里设置的。Rank 1 对应通道 0,Rank 2 对应通道 1,以此类推。每个通道还有独立的采样时间,如果没有特殊要求,先全部选 3 Cycles,后面发现采样值不稳再逐个加大。
这里有一个容易混淆的点:Continuous Conversion Mode 和 DMA Continuous Requests 是两回事。前者是 ADC 自己连续启动扫描,后者是 DMA 工作在循环模式。在 CubeMX 里如果只开连续转换、不开 DMA 连续请求,DMA 传输一轮后就会停止,需要手动重新启动,这是个很容易踩的坑。
4.3 DMA 配置
直接在 CubeMX 左侧列表里点 DMA Settings,添加 DMA Request 为 ADC1。方向选 Peripheral To Memory,Mode 选 Circular,外设数据宽度和内存数据宽度都选 Half Word。优先级建议选 High,因为 ADC 数据流不允许丢失,优先级太低可能被其他 DMA 请求抢占。
在 NVIC 选项卡里,要确保 DMA2 Stream0 的全局中断是 Enabled。没有这个中断,后续你在代码里写的回调函数就永远不执行。很多新手配完 DMA 发现回调不触发,八成是这里没勾。
4.4 生成工程的代码结构
CubeMX 生成的代码里,adc.c 会包含 ADC 初始化函数和每个通道的配置,dma.c 会包含 DMA 初始化函数。函数调用关系在 main.c 里是:
MX_DMA_Init(); MX_ADC1_Init();这两个初始化本身不需要你改,但默认并不会开启 ADC 采样。你要在 main 函数里手动加上校准和启动 DMA 的代码,下面第五节我会给完整示例。
5. HAL 库代码与数据处理实现
5.1 启动校准和 DMA 采样
F4 系列带有 ADC 出厂校准功能,启动转换前建议先校准一次。代码很简单:
HAL_ADCEx_Calibration_Start(&hadc1); HAL_ADC_Start_DMA(&hadc1, (uint32_t *)adc_dma_buf, ADC_BUF_LEN);注意HAL_ADC_Start_DMA的第二个参数是 uint32_t 指针,但实际数据是 16 位的,所以强制转换一下即可。第三个参数是 DMA 传输的数据单元个数,不是字节数。缓冲区定义为 uint16_t 数组,长度就是数组元素个数。
校准的目的是消除 ADC 内部的失调误差。如果你发现采集到的信号在零输入时读数不为零,先检查是不是漏了校准。
5.2 缓冲区与处理函数实现
这里我给出一个可直接用的多通道数据处理框架。假设通道数是 4,缓冲区总长度 256,每个 DMA 中断处理 128 个半字:
#define ADC_CH_NUM 4U #define ADC_GROUP_LEN (ADC_CH_NUM * 32U) /* 128个半字,即32轮完整扫描 */ static uint16_t adc_dma_buf[ADC_GROUP_LEN * 2U]; static uint32_t adc_acc[ADC_CH_NUM]; static uint32_t adc_group_cnt; static void ProcessAdcSamples(const uint16_t *buf, uint32_t len) { uint32_t offset = 0; uint32_t total_groups = len / ADC_CH_NUM; while (offset + ADC_CH_NUM <= len) { for (uint32_t ch = 0; ch < ADC_CH_NUM; ch++) { adc_acc[ch] += buf[offset + ch]; } offset += ADC_CH_NUM; } adc_group_cnt += total_groups; } void HAL_ADC_ConvHalfCpltCallback(ADC_HandleTypeDef *hadc) { if (hadc->Instance == ADC1) { ProcessAdcSamples(&adc_dma_buf[0], ADC_GROUP_LEN); } } void HAL_ADC_ConvCpltCallback(ADC_HandleTypeDef *hadc) { if (hadc->Instance == ADC1) { ProcessAdcSamples(&adc_dma_buf[ADC_GROUP_LEN], ADC_GROUP_LEN); } }这段代码的逻辑是:DMA 先搬满前半段缓冲区,触发半传输中断,我处理前半段;DMA 继续搬后半段,触发传输完成中断,我处理后半段。因为两段缓冲区不重叠,所以 DMA 往后半段写的时候,前半段是稳定的,反之亦然。
5.3 主循环取平均结果
在主循环里,读取累加结果并换算成电压:
while (1) { uint32_t acc_copy[ADC_CH_NUM]; uint32_t cnt_copy; float voltage[ADC_CH_NUM]; __disable_irq(); for (uint32_t ch = 0; ch < ADC_CH_NUM; ch++) { acc_copy[ch] = adc_acc[ch]; } cnt_copy = adc_group_cnt; __enable_irq(); if (cnt_copy > 0) { for (uint32_t ch = 0; ch < ADC_CH_NUM; ch++) { float avg_raw = (float)acc_copy[ch] / (float)cnt_copy; voltage[ch] = avg_raw * 3.3f / 4095.0f; } } HAL_Delay(100); }为什么要在中断关闭状态下拷贝数据?因为中断回调里还在累加,主循环直接读取 adc_acc 可能会读到一半写一半的数据。拷贝到局部变量后,即使中断继续累加,也不影响本次计算的一致性。这是多线程共享变量最常见的数据竞争问题,嵌入式里同样适用。
5.4 一个容易忽略的细节
回调函数里处理的样本数与中断触发时机要严格匹配。如果你在 CubeMX 里把 DMA 缓冲区长度设成了 128,但代码里回调又按 256 去处理,就会越界访问。我的习惯是缓冲区长度全部用宏定义,CubeMX 配置、代码声明、回调处理三处都引用同一个宏,避免手改不一致。
还有一个细节是 DMA 计数寄存器NDTR。调试时如果怀疑 DMA 搬运数量不对,可以直接读__HAL_DMA_GET_COUNTER(&hdma_adc1)看还有多少数据没搬完。这是在 debug 模式排查问题非常好用的手段。
6. 精度、噪声与校准经验
6.1 采样时间怎么选
很多人把采样时间固定设为 3 Cycles,然后抱怨 ADC 读数跳动厉害。原因很简单:信号源内阻过大,采样电容还没充满就开始转换了。SAR ADC 的采样过程相当于通过一个内部电阻给采样电容充电,外部信号源内阻越大,充电时间常数越大。如果采样窗口太短,电容电压还没追平输入电压,转换出来的结果自然偏低且不稳定。
我的经验是,从运放输出直接驱动 ADC 的,3 Cycles 就够;接分压电阻网络的,建议至少 15 Cycles;如果是高阻传感器直接接在引脚上,比如光敏电阻、热敏电阻,用 84 甚至 480 Cycles 更稳妥。采样时间加长对采样率的损失,用上一节提到的均值滤波完全可以补偿回来。
6.2 均值滤波和过采样
在 12 位 ADC 上追求更高有效位数,常见做法是过采样。但要注意,直接用 DMA 累加再求平均,只能降低随机噪声,并不能真正提高分辨率。做 4 倍过采样平均,有效分辨率大概提升 1 位;做 16 倍过采样平均,大概提升 2 位。
实际项目中我建议不要盲目追求位数的名义提升,关键是先去掉明显噪声源。比如开关电源给模拟部分供电时,ADC 的参考电压会跟着纹波跳动,这时候采样结果就像在水面上漂着的船,无论怎么平均都无法消除参考电压本身抖动带来的误差。解决办法是给 VDDA 引脚加磁珠和去耦电容,模拟部分单独用低噪声 LDO 供电,模拟地与数字地单点连接。
6.3 校准的意义和局限
F411 自带的校准可以消除内部失调误差,但校准温度变化后会漂移,所以对精度要求高的系统,最好在每次上电或环境温度明显变化后重新校准。执行校准前要确保 ADC 已经上电并且时钟使能,否则可能死等在校准函数里。
还有一个指标叫有效位数(ENOB)。12 位 ADC 的 ENOB 通常也就 10 到 11 位,信噪比越高,ENOB 越接近 12。实际设计时,与其看数据手册里的理想信噪比,不如用一块已知精度的信号源直接测量,比如用高精度万用表同时读输入电压和 ADC 输出,画一条实际传递曲线,看线性度和增益误差。这个方法在量产前做一次,能发现很多 datasheet 上看不出来的问题。
6.4 参考电压不稳怎么办
F411CEU6 在这个封装里没有独立 VREF+ 引脚,参考电压和 VDDA 是同一个引脚。这意味着如果你用 3.3V 给 MCU 供电,ADC 参考就是 3.3V,而这个 3.3V 还会给其他数字电路供电。数字电路高速翻转时,电源上会产生微小的压降和毛刺,直接反映在 ADC 结果里。
解决思路有两个方向。第一个方向是让电源更干净,给 VDDA 加滤波,数字部分和模拟部分供电分开。第二个方向是软件补偿,用一个已知基准电压通道同时采样,然后用这个通道的读数反推实际参考电压。F411 内部有 VREFINT,可以采样这个内部基准来校准参考电压漂移。这个技巧在电池供电、电压随电量下降的场景下特别有用。
7. 定时器触发采样和多 ADC 同步扩展
7.1 为什么需要定时器触发
连续转换模式适合低速、实时性要求不高的数据采集。但有些场景要求采样间隔严格等间隔,比如电机 FOC 电流环、音频采样、振动分析。这时候连续转换模式就有问题,因为 ADC 转换完成后立刻开始下一次转换,每次转换之间的间隔完全依赖转换时间,而转换时间又涉及时钟抖动和中断延迟,无法保证周期绝对均匀。
解决办法是用定时器的 TRGO 事件作为 ADC 的外部触发源。在 CubeMX 里,ADC 的 External Trigger Source 选择某个定时器的 Trigger Out event,同时把定时器配置成你想要采样频率的 PWM 输出模式。定时器到达更新事件时,自动触发 ADC 启动一次转换或一轮扫描,采样间隔完全由定时器硬件决定,和 CPU 负载无关。
7.2 CubeMX 配置方法
假设用 TIM2 做触发源,ADC 触发选择 Timer 2 Trigger Out event,边沿选 Rising。TIM2 的时钟源选 Internal Clock,Prescaler 和 Counter Period 按目标采样率计算。例如 APB1 定时器时钟 50MHz,想要 10kHz 采样,那么:
分频系数 = 50MHz / 10000 = 5000
如果 Precaler = 0,Counter Period 就设 4999,输出 10kHz 的更新事件。这样每 100 微秒触发一次 ADC。DMA 缓冲区的使用方式和连续模式一样,定时器触发模式下启动后,DMA 依然以循环模式搬运数据到内存。
7.3 双 ADC 同步采集
如果应用中需要同时采样两路信号,且要求两路采样时刻完全一致,比如三相电流检测,那么可以尝试双 ADC 同步模式。F411 支持 ADC1 和 ADC2 在同步模式下工作,两个 ADC 同时触发、同时转换,转换结果可以选择交错存储在同一个 DMA 缓冲区里。
CubeMX 里把 ADC1 的 Mode 选为 Dual regular simultaneous mode,ADC2 会自动同步配置。这种模式的优点是硬件时间戳完全相同,避免了在同一 ADC 上分时采样带来的相位差。但代码复杂度也明显上升,数据排列是通道间交错还是按规则序列排列,需要仔细看参考手册。我的建议是,先跑通单 ADC 多通道方案,再考虑双 ADC 扩展,否则问题叠加很难排查。
8. 常见问题与调试经验
8.1 问题速查表
| 现象 | 可能原因 | 排查方向 |
|---|---|---|
| 采样值全部为 0 | DMA 未启动,或采样时间不足 | 检查是否调用 HAL_ADC_Start_DMA;用调试器看 adc_dma_buf 内容 |
| 只有第一个通道有数据 | 扫描模式未开启,或 Number Of Conversion 没设够 | 确认 ScanConvMode 为 ENABLE,NbrOfConversion 等于通道数 |
| 数据顺序和通道顺序对不上 | Rank 顺序配置与预期不一致 | 在 CubeMX 里逐个核对每个通道的 Rank |
| DMA 回调不执行 | NVIC 中 DMA 中断未使能 | 检查 DMA2_Stream0 全局中断是否勾选 |
| 采样值跳变明显 | 信号源阻抗过高、电源噪声 | 加长采样时间,加强 VDDA 滤波 |
| 半传输回调处理数据错乱 | 缓冲区长度与回调处理长度不一致 | 统一宏定义,检查 len 参数是否被修改 |
| 连续模式下只采一轮就停止 | DMA Continuous Requests 未开启 | 配置里打开 DMA Continuous Requests |
| 校准函数卡住 | ADC 时钟未使能或 ADC 被停止 | 先确认 RCC 里 ADC 时钟分频配置,再确认校准前没有调用 Stop |
8.2 我踩过的几个坑
第一个坑是 DMA 中断和 ADC 中断混用。F4 系列的 ADC 本身也有中断,比如转换结束中断、模拟看门狗中断。有些新手在 CubeMX 里把 ADC 全局中断和 DMA 中断都开了,然后在 HAL_ADC_IRQHandler 和 DMA IRQHandler 里重复处理,结果一个数据被统计两次。正确的做法是:DMA 搬运模式下主要靠 DMA 中断回调,ADC 的转换结束中断不打开,让它安安静静地把数据交给 DMA。
第二个坑是半传输回调和传输完成回调的先后顺序。DMA 循环模式下,先触发半传输回调,再触发传输完成回调。如果你把两个回调里处理的数据段搞反了,数据处理顺序就乱。调试时可以在回调里加一个临时断点或者翻转 GPIO 电平,用示波器看两个回调的执行节奏。
第三个坑和 DMA 缓冲区大小有关。DMA 传输的半字数量和缓冲区的字节长度经常被混淆。设缓冲区为 uint16_t 数组,长度 256,那么 DMA 配置里的 Data Length 是 256,而不是 512。如果误设成 512,DMA 会越界写入,程序大概率跑飞,而且这种错误很难通过看代码发现。
8.3 调试工具和手段
多通道 ADC 这类问题,最有效的调试工具其实就是三段:第一段,用调试器在回调函数里设置断点,确认回调频率和实际采样频率匹配;第二段,把采集到的原始值通过串口或虚拟示波器工具发到 PC 端,画出来看波形;第三段,用信号发生器输入已知幅值的正弦波,对比 ADC 还原出来的波形幅值和相位。
如果你手头没有示波器,也可以先在内存里填一段已知数据,模拟一个假信号源,比如正弦波查表,然后走同样的 DMA 路径,确认数据处理链路是通的。这个方法可以帮你把“ADC 硬件问题”和“数据处理代码问题”分开排查,非常节省时间。
9. 关于多通道 ADC 的一点个人体会
做了不少多通道 ADC 的板子,我最深的体会是:通道数量本身从来不是难点,难的是每个通道都有不同的信号特性。有的通道接的是低阻电压源,采样时间可以设得很短;有的通道接的是高阻传感器,采样时间必须拉长;还有的通道信号本身就带高频噪声,需要在软件里做滤波。把整条链路当成一个系统去看,比单独调某个参数重要得多。
另一个体会是,CubeMX 生成代码后,一定要自己读一遍 adc.c 和 dma.c 里的初始化逻辑,不要直接跳到写业务代码。HAL 库的初始化顺序、DMA 请求映射、中断优先级这些信息,全都在生成代码里写着。我见过不少项目工作得好好的,但在 CubeMX 里重新生成一次代码后,突然 DMA 不工作了,原因就是新的配置把某个中断优先级或 DMA 流悄悄改掉了。
这套“扫描模式 + DMA 循环 + 半传输回调”的方案,是我做 F411 多通道采集时会优先推荐的基础模板。它不挑具体应用,温度采集能用,电流采样能用,电池电压巡检也能用。你只需要根据实际信号特征调整采样时间、缓冲长度和均值次数,剩下交给 DMA 去跑。