你有没有遇到过这样的场景?
项目需要同时读取温度、湿度、光照和电流信号,结果写了一堆轮询代码,CPU 被 ADC 占满,主程序卡顿,响应延迟严重……最后只能靠加延时、关中断来“凑合”解决问题?
其实,STM32F407VET6 早就为你准备了更优雅的解决方案——
ADC 多通道扫描模式 + DMA
。它能让硬件自动完成多个模拟通道的采集,数据直接送进内存,CPU 几乎零参与。
今天我们就来彻底搞懂这套机制,不讲空话,不套模板,只聊工程师真正关心的事:怎么用、为什么这么用、踩过哪些坑、如何避雷⚡️
在嵌入式系统中,传感器越来越多,但 MCU 的资源却不会无限增长。比如一个智能农业监测节点,可能要接:
- 土壤温湿度(模拟输出)
- 光照强度
- 空气温湿度
- CO₂ 浓度
- 风速风向(部分型号为模拟量)
如果每个通道都单独启动 ADC、等待转换完成、读取结果……那你的
while(1)
很快就会变成“伪实时”系统——看着在跑,实则处处卡顿。
而使用
扫描模式(Scan Mode)+ DMA
,你可以做到:
✅ 所有通道按顺序自动采集
✅ 数据自动存入数组,无需中断服务频繁处理
✅ 主程序继续执行其他任务,完全不受干扰
✅ 实现接近“并行”的多路采样体验(虽然物理上仍是串行)
这不仅是效率问题,更是系统架构设计的分水岭。用得好,系统流畅稳定;用不好,轻则数据失真,重则死机重启。
先别急着写代码,咱们先把芯片的能力摸清楚。STM32F407VET6 可不是普通单片机,它的 ADC 模块相当硬核:
-
✅
3 个独立 ADC
(ADC1/2/3),可单独或协同工作 -
✅ 每个 ADC 支持
16 个外部通道 + 2 个内部通道
- 外部通道对应 GPIO 引脚(如 PA0 → ADC123_IN0)
- 内部通道包括:温度传感器、内部参考电压(Vrefint)
-
✅
12 位分辨率
,数字输出范围 0~4095 -
✅ 可编程采样时间:
3 ~ 480 个 ADC 时钟周期
- ✅ 支持多种触发方式:软件启动、定时器触发、外部信号等
-
✅ 支持
DMA 请求
,每次转换后自动搬运数据 -
✅ 提供
扫描模式
和
间断模式
,灵活应对不同需求
这些特性组合起来,意味着你可以在不牺牲性能的前提下,构建出高效、低功耗、高精度的多路采集系统。
🤔 小知识:为什么是“最多 16 个外部通道”?
因为 STM32 的 ADC 输入通道编号 IN0~IN18 中,有些是共用引脚的。例如 ADC1 的 IN0 同时对应 PA0、PB0 等(取决于具体封装),但在同一时间只能启用其中一个作为输入。
我们常说“轮询 ADC”,但如果这个“轮询”是由 CPU 来做的,那就太累了。真正的高手,是让硬件自己动起来。
它是怎么工作的?
想象一下,你有一个待办清单(conversion sequence),上面写着:
- 去厨房测温度
- 去阳台测光照
- 去客厅测湿度
如果你亲自跑一趟,每到一处都要记录数据、返回起点再出发——这就是传统的“逐个启动 + 查询”方式,效率极低。
而扫描模式相当于雇了个助手,你把清单交给他,说:“按顺序走一遍,把数据记下来放桌上。”然后你就去干别的了。等他回来告诉你“搞定了”,你再去桌上拿数据就行。
📌 这个“助手”就是 ADC 控制器,“清单”就是你在 CubeMX 或代码里配置的
转换序列(Rank)
,“桌子”就是内存中的数组。
具体流程如下:
- 启动 ADC(软件或定时器触发)
- ADC 自动选择第一个通道(比如 Rank 1 对应 IN0)
- 开始采样 → 转换 → 得到结果
- 如果启用了 DMA,立刻将结果搬移到指定内存地址
- 切换到下一个通道(Rank 2),重复步骤 3~4
- 所有通道完成后,产生 EOC(End of Conversion Sequence)标志
- (可选)触发中断或 DMA 完成回调
整个过程完全由硬件控制,CPU 只需在开始时喊一声“开始!”,结束时知道“好了!”即可。
💡 关键点:扫描模式的核心价值不是“能采多个通道”,而是“
减少 CPU 干预
”。这才是嵌入式系统追求的目标。
HAL 库确实简化了开发,但也隐藏了很多细节。很多人按照默认配置走下来,发现数据不准、顺序错乱、DMA 溢出……其实问题往往出在几个关键参数上。
下面我们拆开来看,
哪些设置必须小心对待
。
1.
ScanConvMode = ENABLE
ScanConvMode = ENABLE
这是开启扫描模式的开关。没有它,即使你配置了多个通道,ADC 也只会转换第一个就停下来。
hadc1.Init.ScanConvMode = ENABLE;
⚠️ 注意:一旦启用扫描模式,就必须设置
NbrOfConversion
表示总共多少个通道参与转换。
2.
NbrOfConversion = 2
(或其他数值)
NbrOfConversion = 2
表示本次扫描中有几个通道会被依次转换。这个值必须和你在后续调用
HAL_ADC_ConfigChannel()
时配置的 Rank 数量一致。
hadc1.Init.NbrOfConversion = 2; // 必须等于实际使用的 Rank 总数
❌ 错误示范:
hadc1.Init.NbrOfConversion = 1;
// 然后却配置了 Rank_1 和 Rank_2 —— 第二个根本不会被执行!
3.
ContinuousConvMode
:单次 vs 连续
ContinuousConvMode
这个选项决定 ADC 是否循环采集。
DISABLE
Start_DMA
才能再次采集
ENABLE
👉 推荐做法:
一般设为 DISABLE
,配合定时器触发使用,便于精确控制采样频率。
例如你想每 10ms 采集一次所有通道,可以用 TIM2 触发 ADC,这样既能保证等间隔,又能避免 DMA 缓冲来不及处理的问题。
4.
EOCSelection = ADC_EOC_SEQ_CONV
EOCSelection = ADC_EOC_SEQ_CONV
这个参数决定了“什么时候算转换结束”。
-
ADC_EOC_SINGLE_CONV
:每个通道转换完都置位 EOC 标志 → 中断频繁 -
ADC_EOC_SEQ_CONV
:只有当所有通道全部转换完成后才置位 EOC → 更适合扫描模式
✅ 正确选择:
ADC_EOC_SEQ_CONV
,否则你会被一堆中断打断得怀疑人生。
5.
DMAContinuousRequests = ENABLE
DMAContinuousRequests = ENABLE
是否允许 ADC 在每次转换后都发出 DMA 请求。
- 启用 ✔️:每一个通道转换完成都会触发一次 DMA 搬运
- 禁用 ❌:只在最后一次转换时请求 DMA → 前面的数据会丢失!
所以,只要用了多通道扫描 + DMA,这一项必须打开。
下面这段代码,是你真正能用在项目里的版本,我已经把它优化到了“拿来即用”的程度,并加上了详细注释和工程建议。
#include "main.h"
#include "stm32f4xx_hal.h"
// 存储两个通道的原始数据
uint16_t adc_raw[2];
ADC_HandleTypeDef hadc1;
DMA_HandleTypeDef hdma_adc1;
void SystemClock_Config(void);
static void MX_GPIO_Init(void);
static void MX_DMA_Init(void);
static void MX_ADC1_Init(void);
int main(void)
while (1)
{
// 主循环自由运行
// adc_raw[0] -> PA0 数据
// adc_raw[1] -> PA1 数据
// 示例:每 500ms 打印一次数据(通过串口或其他方式)
HAL_Delay(500);
// 实际项目中应通过标志位判断数据是否更新
// 而不是盲目延时
}
}
初始化函数详解
static void MX_ADC1_Init(void)
{
ADC_ChannelConfTypeDef sConfig = {0};
hadc1.Instance = ADC1;
hadc1.Init.ClockPrescaler = ADC_CLOCK_SYNC_PCLK_DIV4; // PCLK2 = 84MHz → ADCCLK = 21MHz
hadc1.Init.Resolution = ADC_RESOLUTION_12B;
hadc1.Init.ScanConvMode = ENABLE; // 必须开!
hadc1.Init.ContinuousConvMode = DISABLE; // 单次模式,推荐
hadc1.Init.DiscontinuousConvMode = DISABLE; // 不使用间断模式
hadc1.Init.ExternalTrigConvEdge = ADC_EXTERNALTRIGCONVEDGE_NONE;// 软件触发
hadc1.Init.DataAlign = ADC_DATAALIGN_RIGHT;
hadc1.Init.NbrOfConversion = 2; // 两个通道
hadc1.Init.DMAContinuousRequests = ENABLE; // 每次转换都发 DMA 请求
hadc1.Init.EOCSelection = ADC_EOC_SEQ_CONV; // 整个序列结束后才算完成
if (HAL_ADC_Init(&hadc1) != HAL_OK)
{
Error_Handler();
}
// 配置通道 0 (PA0)
sConfig.Channel = ADC_CHANNEL_0;
sConfig.Rank = ADC_REGULAR_RANK_1;
sConfig.SamplingTime = ADC_SAMPLETIME_480CYCLES; // 高阻抗源必须长采样!
sConfig.SingleDiff = ADC_SINGLE_ENDED;
sConfig.OffsetNumber = ADC_OFFSET_NONE;
sConfig.Offset = 0;
if (HAL_ADC_ConfigChannel(&hadc1, &sConfig) != HAL_OK)
{
Error_Handler();
}
// 配置通道 1 (PA1)
sConfig.Channel = ADC_CHANNEL_1;
sConfig.Rank = ADC_REGULAR_RANK_2;
// 注意:除了 Channel 和 Rank,其他参数最好显式重设一遍
sConfig.SamplingTime = ADC_SAMPLETIME_480CYCLES;
sConfig.SingleDiff = ADC_SINGLE_ENDED;
sConfig.OffsetNumber = ADC_OFFSET_NONE;
sConfig.Offset = 0;
if (HAL_ADC_ConfigChannel(&hadc1, &sConfig) != HAL_OK)
{
Error_Handler();
}
}
MSP 层初始化(底层驱动挂钩)
这部分很多人忽略,但它决定了 GPIO 和 DMA 能不能正常工作。
void HAL_ADC_MspInit(ADC_HandleTypeDef* adcHandle)
{
GPIO_InitTypeDef GPIO_InitStruct = {0};
if(adcHandle->Instance == ADC1)
// 把 DMA 句柄绑定到 ADC 句柄
__HAL_LINKDMA(adcHandle, DMA_Handle, hdma_adc1);
}
}
🎯 关键点提醒:
-
MemInc = ENABLE
:确保每次 DMA 把数据写入数组的不同位置 -
Mode = DMA_CIRCULAR
:非常适合连续采集场景,缓冲区自动回绕 -
Priority = MEDIUM
:避免被低优先级任务阻塞,影响实时性
你以为启动了
HAL_ADC_Start_DMA()
就万事大吉?Too young.
最大的陷阱来了:
你怎么知道
adc_raw[]
里的数据是不是最新的?
因为 DMA 是后台默默搬运的,当你在
while(1)
里读
adc_raw[0]
的时候,可能它正在被覆盖,也可能还是上次的老数据。
解决方案一:使用半传输中断(Half Transfer Interrupt)
DMA 支持两种中断:
- HT(Half Transfer):一半数据传完时触发
- TC(Transfer Complete):全部传完时触发
我们可以利用这两个事件来通知主程序:“新数据来了!”
修改 DMA 配置:
hdma_adc1.Init.Mode = DMA_CIRCULAR;
// 启用中断
if (HAL_DMA_Start_IT(&hdma_adc1,
(uint32_t)&hadc1.Instance->DR,
(uint32_t)adc_raw,
2) != HAL_OK)
{
Error_Handler();
}
添加回调函数:
void HAL_ADC_ConvHalfCpltCallback(ADC_HandleTypeDef* hadc)
}
void HAL_ADC_ConvCpltCallback(ADC_HandleTypeDef* hadc)
}
然后在主循环中检测标志位,而不是盲目读取数组。
解决方案二:双缓冲模式(Double Buffer Mode)
如果你启用了 DMA 的双缓冲功能(
FIFOMode = ENABLE
+
DoubleBufferMode
),那么 DMA 会在两个内存区域之间切换,进一步提升安全性。
不过对于仅两个通道的小数组来说,有点杀鸡用牛刀了。但在音频采集、高速波形记录等场景中非常有用。
别以为照着例程就能一帆风顺,以下是我在实际项目中踩过的坑,现在免费送给你👇
🔹 问题 1:前一个通道影响后一个通道的读数?
现象:CH0 是 3.3V,CH1 是 0V,但读出来 CH1 居然有 200 多的值!
原因:
采样时间太短 + 信号源阻抗太高
。
ADC 的采样阶段就像给一个小电容充电。如果信号源内阻大(比如电位器、某些传感器),而采样时间又短,电容还没充到位就开始转换了,导致电压偏低或残留前值。
✅ 解法:
– 把
SamplingTime
改成
ADC_SAMPLETIME_480CYCLES
– 或者在 PCB 上靠近 MCU 引脚处加一个 100nF 陶瓷电容作局部储能
– 极端情况可外接运放缓冲器
🔹 问题 2:多个通道不是“同时”采集?
是的,STM32F4 的 ADC 是单个转换核心,轮流采集各通道,存在微小时间差(几微秒级)。
对大多数应用(温湿度监控)可以忽略;
但如果你要做三相电流采样用于电机控制,这就会影响相位计算。
✅ 解法:
– 使用
双 ADC 交错模式
(Dual Mode with Interleaved)
– 或改用外部同步采样 ADC 芯片(如 AD7606)
🔹 问题 3:DMA 缓冲被覆盖,数据错乱?
尤其是在
DMA_CIRCULAR
模式下,主程序没及时处理,新数据就把旧数据冲掉了。
✅ 解法:
– 使用
状态标志 + 双缓冲策略
– 或改为
DMA_NORMAL
模式,每次手动重启
– 更高级的做法:结合 FreeRTOS,用消息队列传递数据指针
电源设计
参考电压
输入保护
PCB 布局
校准
HAL_ADCEx_Calibration_Start(&hadc1, ADC_SINGLE_ENDED)
提升精度
功耗优化
__HAL_RCC_ADC1_CLK_DISABLE()
调试技巧
掌握了这套机制之后,你可以轻松扩展到更复杂的系统:
🧩 场景 1:环境监测站
- 采集 6 路传感器:温湿度 ×2、PM2.5、CO、NO₂、光照
- 每 2 秒采集一次,通过 LoRa 发送到网关
- 使用定时器触发 ADC,避免主循环延时不准
🧩 场景 2:电机控制系统
- 三相电流采样(INA/INB/INC)+ 直流母线电压
- 使用 TIM1 的 TRGO 触发 ADC,实现精准同步
- 结合 DMA 和 DCMI 接口,甚至可以做简易示波器功能
🧩 场景 3:电池管理系统(BMS)
- 多节锂电池电压采集(通过分压电阻接入)
- 每节电池轮流扫描,配合窗口比较器快速识别异常
- 数据经滤波算法后上传至上位机
你会发现,
一旦打通了“硬件自动采集 → DMA 搬运 → 主程序处理”这条链路,系统的扩展性和稳定性将大幅提升
。
很多初学者看到 ADC 配置一大堆结构体,DMA 又要设通道、又要对齐、还要优先级……一下子就懵了。
但你要记住:
所有复杂背后,都是为了实现一个简单的目标——让 CPU 少干活,让系统更高效
。
多通道扫描 + DMA 的本质,就是把原本属于 CPU 的“体力活”交给硬件去做。你只需要学会“下达命令”和“接收成果”,剩下的交给 STM32 去完成。
下次当你面对“我要同时读 5 个模拟量”的需求时,不要再写五个
HAL_ADC_Start...
了,试试这一套组合拳:
🔁 扫描模式 + 📦 DMA + 🕒 定时器触发 = 真·高效采集
你会发现,原来 STM32F407VET6 的潜力,远不止点亮一个 LED 那么简单。


