你有没有遇到过这样的场景?
项目需要同时读取温度、湿度、光照和电流信号,结果写了一堆轮询代码,CPU 被 ADC 占满,主程序卡顿,响应延迟严重……最后只能靠加延时、关中断来“凑合”解决问题?
其实,STM32F407VET6 早就为你准备了更优雅的解决方案——
ADC 多通道扫描模式 + DMA
。它能让硬件自动完成多个模拟通道的采集,数据直接送进内存,CPU 几乎零参与。
今天我们就来彻底搞懂这套机制,不讲空话,不套模板,只聊工程师真正关心的事:怎么用、为什么这么用、踩过哪些坑、如何避雷⚡️
在嵌入式系统中,传感器越来越多,但 MCU 的资源却不会无限增长。比如一个智能农业监测节点,可能要接:
如果每个通道都单独启动 ADC、等待转换完成、读取结果……那你的
while(1)
很快就会变成“伪实时”系统——看着在跑,实则处处卡顿。
而使用
扫描模式(Scan Mode)+ DMA
,你可以做到:
✅ 所有通道按顺序自动采集
✅ 数据自动存入数组,无需中断服务频繁处理
✅ 主程序继续执行其他任务,完全不受干扰
✅ 实现接近“并行”的多路采样体验(虽然物理上仍是串行)
这不仅是效率问题,更是系统架构设计的分水岭。用得好,系统流畅稳定;用不好,轻则数据失真,重则死机重启。
先别急着写代码,咱们先把芯片的能力摸清楚。STM32F407VET6 可不是普通单片机,它的 ADC 模块相当硬核:
这些特性组合起来,意味着你可以在不牺牲性能的前提下,构建出高效、低功耗、高精度的多路采集系统。
🤔 小知识:为什么是“最多 16 个外部通道”?
因为 STM32 的 ADC 输入通道编号 IN0~IN18 中,有些是共用引脚的。例如 ADC1 的 IN0 同时对应 PA0、PB0 等(取决于具体封装),但在同一时间只能启用其中一个作为输入。
我们常说“轮询 ADC”,但如果这个“轮询”是由 CPU 来做的,那就太累了。真正的高手,是让硬件自己动起来。
想象一下,你有一个待办清单(conversion sequence),上面写着:
如果你亲自跑一趟,每到一处都要记录数据、返回起点再出发——这就是传统的“逐个启动 + 查询”方式,效率极低。
而扫描模式相当于雇了个助手,你把清单交给他,说:“按顺序走一遍,把数据记下来放桌上。”然后你就去干别的了。等他回来告诉你“搞定了”,你再去桌上拿数据就行。
📌 这个“助手”就是 ADC 控制器,“清单”就是你在 CubeMX 或代码里配置的
转换序列(Rank)
,“桌子”就是内存中的数组。
整个过程完全由硬件控制,CPU 只需在开始时喊一声“开始!”,结束时知道“好了!”即可。
💡 关键点:扫描模式的核心价值不是“能采多个通道”,而是“
减少 CPU 干预
”。这才是嵌入式系统追求的目标。
HAL 库确实简化了开发,但也隐藏了很多细节。很多人按照默认配置走下来,发现数据不准、顺序错乱、DMA 溢出……其实问题往往出在几个关键参数上。
下面我们拆开来看,
哪些设置必须小心对待
。
ScanConvMode = ENABLE
这是开启扫描模式的开关。没有它,即使你配置了多个通道,ADC 也只会转换第一个就停下来。
hadc1.Init.ScanConvMode = ENABLE;
⚠️ 注意:一旦启用扫描模式,就必须设置
NbrOfConversion
表示总共多少个通道参与转换。
NbrOfConversion = 2
表示本次扫描中有几个通道会被依次转换。这个值必须和你在后续调用
HAL_ADC_ConfigChannel()
时配置的 Rank 数量一致。
hadc1.Init.NbrOfConversion = 2; // 必须等于实际使用的 Rank 总数
❌ 错误示范:
hadc1.Init.NbrOfConversion = 1;
// 然后却配置了 Rank_1 和 Rank_2 —— 第二个根本不会被执行!
ContinuousConvMode
这个选项决定 ADC 是否循环采集。
DISABLE
Start_DMA
ENABLE
👉 推荐做法:
一般设为 DISABLE
,配合定时器触发使用,便于精确控制采样频率。
例如你想每 10ms 采集一次所有通道,可以用 TIM2 触发 ADC,这样既能保证等间隔,又能避免 DMA 缓冲来不及处理的问题。
EOCSelection = ADC_EOC_SEQ_CONV
这个参数决定了“什么时候算转换结束”。
ADC_EOC_SINGLE_CONV
ADC_EOC_SEQ_CONV
✅ 正确选择:
ADC_EOC_SEQ_CONV
,否则你会被一堆中断打断得怀疑人生。
DMAContinuousRequests = ENABLE
是否允许 ADC 在每次转换后都发出 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();
}
}
这部分很多人忽略,但它决定了 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
Mode = DMA_CIRCULAR
Priority = MEDIUM
你以为启动了
HAL_ADC_Start_DMA()
就万事大吉?Too young.
最大的陷阱来了:
你怎么知道
adc_raw[]
里的数据是不是最新的?
因为 DMA 是后台默默搬运的,当你在
while(1)
里读
adc_raw[0]
的时候,可能它正在被覆盖,也可能还是上次的老数据。
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)
}
然后在主循环中检测标志位,而不是盲目读取数组。
如果你启用了 DMA 的双缓冲功能(
FIFOMode = ENABLE
+
DoubleBufferMode
),那么 DMA 会在两个内存区域之间切换,进一步提升安全性。
不过对于仅两个通道的小数组来说,有点杀鸡用牛刀了。但在音频采集、高速波形记录等场景中非常有用。
别以为照着例程就能一帆风顺,以下是我在实际项目中踩过的坑,现在免费送给你👇
现象:CH0 是 3.3V,CH1 是 0V,但读出来 CH1 居然有 200 多的值!
原因:
采样时间太短 + 信号源阻抗太高
。
ADC 的采样阶段就像给一个小电容充电。如果信号源内阻大(比如电位器、某些传感器),而采样时间又短,电容还没充到位就开始转换了,导致电压偏低或残留前值。
✅ 解法:
- 把
SamplingTime
改成
ADC_SAMPLETIME_480CYCLES
- 或者在 PCB 上靠近 MCU 引脚处加一个 100nF 陶瓷电容作局部储能
- 极端情况可外接运放缓冲器
是的,STM32F4 的 ADC 是单个转换核心,轮流采集各通道,存在微小时间差(几微秒级)。
对大多数应用(温湿度监控)可以忽略;
但如果你要做三相电流采样用于电机控制,这就会影响相位计算。
✅ 解法:
- 使用
双 ADC 交错模式
(Dual Mode with Interleaved)
- 或改用外部同步采样 ADC 芯片(如 AD7606)
尤其是在
DMA_CIRCULAR
模式下,主程序没及时处理,新数据就把旧数据冲掉了。
✅ 解法:
- 使用
状态标志 + 双缓冲策略
- 或改为
DMA_NORMAL
模式,每次手动重启
- 更高级的做法:结合 FreeRTOS,用消息队列传递数据指针
HAL_ADCEx_Calibration_Start(&hadc1, ADC_SINGLE_ENDED)
__HAL_RCC_ADC1_CLK_DISABLE()
掌握了这套机制之后,你可以轻松扩展到更复杂的系统:
你会发现,
一旦打通了“硬件自动采集 → DMA 搬运 → 主程序处理”这条链路,系统的扩展性和稳定性将大幅提升
。
很多初学者看到 ADC 配置一大堆结构体,DMA 又要设通道、又要对齐、还要优先级……一下子就懵了。
但你要记住:
所有复杂背后,都是为了实现一个简单的目标——让 CPU 少干活,让系统更高效
。
多通道扫描 + DMA 的本质,就是把原本属于 CPU 的“体力活”交给硬件去做。你只需要学会“下达命令”和“接收成果”,剩下的交给 STM32 去完成。
下次当你面对“我要同时读 5 个模拟量”的需求时,不要再写五个
HAL_ADC_Start...
了,试试这一套组合拳:
🔁 扫描模式 + 📦 DMA + 🕒 定时器触发 = 真·高效采集
你会发现,原来 STM32F407VET6 的潜力,远不止点亮一个 LED 那么简单。