如何用STM32的HAL库打造一个“会自己干活”的ADC扫描系统?
你有没有遇到过这种情况:想读几个传感器的数据,结果主循环里塞满了HAL_ADC_Start()、HAL_ADC_PollForConversion(),CPU占用率蹭蹭往上涨?更糟的是,采样时间还不准——前一个通道刚读完,下一个又被中断打断,数据不同步,滤波都救不回来。
其实,STM32早就给你准备了解法:让ADC自己动起来,DMA自动搬数据,定时器当指挥官,CPU只管最后看一眼结果就行。
今天我们就来拆解这套“自动化采集流水线”是怎么搭出来的。不是简单贴代码,而是从为什么这么配、每一步在干什么、不这么做会出什么问题讲清楚。你会发现,这不只是初始化一堆外设,而是在构建一个能独立运行的子系统。
1. 先搞明白:我们说的“scanner”到底是什么?
别被名字唬住。“scanner”不是某个神秘外设,它是一种工作模式组合——通常是:
定时器(TIM) → 触发 → ADC(多通道扫描) → 数据 → DMA搬运 → 内存缓冲区
整个过程不需要软件干预,就像一条全自动装配线。你在工业控制面板、触摸按键、电池电压监测里看到的轮询,背后很可能就是这套机制。
本文聚焦最常见的实现:ADC + 定时器触发 + DMA传输。这也是最通用、最值得掌握的基础模型。
2. 核心三剑客:ADC、DMA、TIM 如何协同?
要让这条流水线跑起来,三个外设必须严丝合缝地配合。我们先看它们各自的角色:
| 外设 | 扮演角色 | 关键职责 |
|---|---|---|
| ADC | 工人 | 负责把模拟信号变成数字值 |
| DMA | 搬运工 | 把工人产出的数据自动搬到指定仓库 |
| TIM | 钟表+发令员 | 到点就喊“开工”,精准控制节奏 |
如果你只启动ADC+DMA但没给触发源,那ADC永远等不到“开工”信号;如果DMA没开循环模式,第二轮数据就会覆盖第一轮……任何一个环节配置错,整个系统就卡住。
所以,初始化不是堆API,而是设计一个自洽的工作流。
3. 第一步:告诉ADC“你要怎么干活”
我们从MX_ADC1_Init()开始。这段代码看似平淡,其实每一行都在设定行为规则:
hadc1.Instance = ADC1; hadc1.Init.ClockPrescaler = ADC_CLOCK_SYNC_PCLK_DIV2; hadc1.Init.Resolution = ADC_RESOLUTION_12B; hadc1.Init.ScanConvMode = ENABLE; // ✅ 启用扫描模式 hadc1.Init.EOCSelection = ADC_EOC_SEQ_CONV; // 序列结束才置位 hadc1.Init.ContinuousConvMode = DISABLE; // ❌ 不用内部连续 hadc1.Init.NbrOfConversion = 3; // 扫3个通道 hadc1.Init.ExternalTrigConv = ADC_EXTERNALTRIG_T1_TRGO; // ⏱ 来自TIM1的TRGO hadc1.Init.ExternalTrigConvEdge = ADC_EXTERNALTRIGCONVEDGE_RISING; // 上升沿触发 hadc1.Init.DMAContinuousRequests = ENABLE; // ✅ 每次转换都请DMA帮忙关键配置解读:
ScanConvMode = ENABLE
这是“scanner”的灵魂。开启后,ADC会按你排好的顺序(Rank)依次采样多个通道,而不是只干一票就停。ContinuousConvMode = DISABLE
很多人习惯开连续模式,但在外部触发场景下必须关!否则ADC自己不停启动,和定时器冲突,会导致采样频率失控。ExternalTrigConv = ADC_EXTERNALTRIG_T1_TRGO
明确告诉ADC:“别听软件的,只听TIM1的TRGO信号。” 这样才能实现硬件同步。DMAContinuousRequests = ENABLE
意思是“每次转换完我都叫一次DMA”。如果不启用,DMA可能只搬第一个数据,后面全丢。
📌 小贴士:
EOCSelection设为ADC_EOC_SEQ_CONV表示等到整轮扫描结束才置位标志。这样回调函数就知道“这一组数据齐了”,适合做整体处理。
4. 第二步:给DMA画一张“搬运地图”
DMA不是随便搬数据的,得明确告诉它:
- 从哪搬?→ ADC的数据寄存器(DR)
- 搬到哪?→ 内存中的数组
adc_raw_buffer - 怎么搬?→ 字对齐、内存地址递增
- 搬几次?→ 3次(对应3个通道)
static uint32_t adc_raw_buffer[3]; hdma_adc1.Instance = DMA1_Channel1; hdma_adc1.Init.Direction = DMA_PERIPH_TO_MEMORY; hdma_adc1.Init.PeriphInc = DMA_PINC_DISABLE; // 外设地址固定(总是读DR) hdma_adc1.Init.MemInc = DMA_MINC_ENABLE; // 内存地址++,填满数组 hdma_adc1.Init.PeriphDataAlignment = DMA_PDATAALIGN_WORD; hdma_adc1.Init.MemDataAlignment = DMA_MDATAALIGN_WORD; hdma_adc1.Init.Mode = DMA_CIRCULAR; // 🔁 循环模式!重点!为什么用Circular Mode(循环模式)?
假设你监测温度、湿度、光照,希望每秒采100次。如果DMA用Normal模式,搬完3个数据就停止,那你得在回调里手动重启DMA——中间有空档期,可能漏掉一次触发。
而Circular模式相当于开了个无限循环播放列表:搬完第3个,自动回到第1个位置继续写。只要ADC不断输出,DMA就永不停歇。
💡 实战经验:对于实时监控类应用,几乎都应该选 Circular 模式。只有一次性采集才用 Normal。
别忘了这句关键绑定:
__HAL_LINKDMA(&hadc1, DMA_Handle, hdma_adc1);它让HAL库知道“这个ADC配的是哪个DMA”,后续调用HAL_ADC_Start_DMA()才能自动关联。
5. 第三步:让定时器成为“节拍器”
谁来决定每毫秒采一次?还是每100微秒?答案是定时器。
htim1.Instance = TIM1; htim1.Init.Prescaler = 80 - 1; // 80MHz → 1MHz计数 htim1.Init.Period = 1000 - 1; // 1ms溢出一次 → 1kHz频率然后最关键的一句:
sMasterConfig.MasterOutputTrigger = TIM_TRGO_UPDATE;意思是:计数器一更新(即溢出重装),立刻从TRGO引脚发出一个脉冲。这个脉冲直接连到ADC的触发输入端,相当于轻轻拍一下ADC说:“该你上了。”
⚠️ 注意陷阱:有些开发者误用PWM输出作为触发源,虽然也能产生周期信号,但相位不稳定,容易导致采样抖动。TRGO是最干净、最准时的同步方式。
6. 启动!让系统自己跑起来
前三步都是“布线”,现在按下启动按钮:
HAL_ADC_Start_DMA(&hadc1, (uint32_t*)adc_raw_buffer, 3); HAL_TIM_Base_Start(&htim1); // 开启定时器,开始发脉冲就这么两行,整个系统就活了:
- TIM1每1ms发出一个上升沿;
- ADC收到信号,立即启动扫描:CH1 → CH2 → CH3;
- 每完成一个通道,DMA顺手把结果搬进buffer;
- 第三个通道结束,生成
DMA Half Transfer或Transfer Complete中断; - 进入
HAL_ADC_ConvCpltCallback()回调,你可以在这里处理最新一组数据。
整个过程,CPU除了最开始按个开关,全程零参与。
7. 回调函数里该做什么?不该做什么?
void HAL_ADC_ConvCpltCallback(ADC_HandleTypeDef* hadc) { if (hadc->Instance == ADC1) { process_sensor_data(adc_raw_buffer); // ✅ 快速处理 // send_to_uart(); // ⚠️ 谨慎!可能阻塞 } }这里有个黄金原则:回调函数越短越好。
因为当下一轮TRGO到来时,如果上一轮还在发UART,ADC可能已经开始新转换,导致数据混乱或DMA总线竞争。
✅ 推荐做法:
- 在回调中仅做标记(如设置标志位)、复制数据到队列;
- 主循环或其他任务负责实际处理和通信。
或者使用双缓冲机制(Double Buffer),但这需要更复杂的DMA配置,适合高级应用。
8. 常见“翻车”现场与避坑指南
❌ 问题1:DMA只搬了一个数据,后面全是0
原因:DMAContinuousRequests = DISABLE或ScanConvMode = DISABLE
→ ADC没持续请求DMA,或根本没开启多通道扫描。
❌ 问题2:采样频率不对,忽快忽慢
原因:用了软件触发 + 延时,而不是硬件定时器TRGO。
→ 改用TIMx_TRGO作为触发源。
❌ 问题3:数据错位,CH1的值跑到CH2的位置
原因:DMA缓冲区大小和实际通道数不匹配,或内存未对齐。
→ 确保adc_raw_buffer是uint32_t类型,且数量 ≥ 通道数。
❌ 问题4:系统卡死,进不了回调
原因:DMA配置了Normal模式但没在回调里重启。
→ 改用Circular模式,或在回调中重新调用HAL_ADC_Start_DMA()。
9. 更进一步的设计思考
🎯 采样率怎么定?
根据奈奎斯特准则,采样率至少是信号最高频率的2倍,工程上建议取5~10倍。比如测50Hz交流信号,至少250Hz以上采样。
🔇 模拟前端怎么处理?
在每个传感器输入端加一个RC低通滤波器(如10kΩ + 10nF,截止约1.6kHz),防止高频噪声混叠。
🔋 如何省电?
在DMA采集期间,让CPU进入Sleep 或 Stop 模式。等一整组数据收完再唤醒处理,大幅降低功耗。
🧪 精度不够怎么办?
启用ADC内部校准,或定期读取VREFINT通道进行归一化修正,对抗电源波动和温漂。
结语:你写的不是代码,是系统的“操作系统”
当你配置好这套ADC scanner,本质上是创建了一个脱离主程序运行的自治单元。它有自己的时钟(TIM)、自己的工作流程(ADC扫描)、自己的数据通道(DMA),CPU只是个“值班经理”。
这种“硬件自动化 + 软件轻量化”的思想,正是现代嵌入式系统的精髓。
下次当你面对十几个传感器需要轮询时,别再写for循环了。想想能不能让外设自己动起来——这才是STM32真正强大的地方。
如果你正在做电池管理、环境监测、工业I/O模块,这套方案可以直接复用。动手试试吧,评论区欢迎分享你的调试心得!