用DMA+ADC打造“零CPU干预”的自动采集系统,STM32CubeMX实战全解析
你有没有遇到过这样的场景?
手里的STM32板子正在跑一个传感器采集任务:温度、湿度、电压三路信号每毫秒采一次。为了不丢数据,你开了ADC中断,结果发现主循环越来越卡,RTOS的任务调度开始抖动,甚至Wi-Fi上传都延迟了。
更糟的是—— 明明硬件支持1Msps的采样率,实际只能稳定在10ksps以下 。问题出在哪?答案很可能是: CPU成了瓶颈 。
这时候你就该考虑换一种思路了:让CPU“下班”,让外设自己干活。
今天我们就来聊聊如何用 STM32CubeMX + DMA + ADC 搭建一套真正意义上的“自治式”模拟信号采集系统——从启动那一刻起,直到你主动关闭,整个过程 无需CPU参与任何一次数据搬运 。
为什么传统ADC采集方式走不远?
先别急着上DMA,咱们得搞清楚“病根”在哪。
最常见的ADC读取方式有三种:轮询、中断、定时器触发+中断。听起来挺完整,但在真实项目中,它们各有软肋。
轮询:最原始也最伤效率
while (1) {
HAL_ADC_Start(&hadc1);
while (HAL_ADC_PollForConversion(&hadc1, 10) != HAL_OK);
uint16_t value = HAL_ADC_GetValue(&hadc1);
// 处理value...
}
这段代码看着简单,但每一帧都要:
- 启动转换
- 死等完成
- 手动取值
假设采样频率是1kHz,那CPU每秒就要被这件事“绑架”上千次。如果中间还夹着其他任务?轻则延迟,重则丢帧。
🤯 想象一下你在开会时每隔1ms就被同事拍一下肩膀问:“喂,做完没?”——这就是轮询对CPU的日常。
中断方式:好一点,但依然不够优雅
改用中断后,至少不用轮询等待了:
HAL_ADC_Start_IT(&hadc1);
void HAL_ADC_ConvCpltCallback(ADC_HandleTypeDef* hadc) {
uint16_t value = HAL_ADC_GetValue(hadc);
// 存进缓冲区 or 发送处理
}
看似进步了,可隐患仍在:
- 如果中断服务函数(ISR)执行时间太长(比如做了浮点计算或串口发送),可能错过下一次ADC完成事件;
- 多通道扫描时,中断频繁触发,导致上下文切换开销大;
- 实时性依赖NVIC优先级配置,稍不注意就会被更高优先级中断打断。
而且最关键的一点:
每一次数据搬移,还是得靠CPU动手
。哪怕只是
*(buf++) = DR
这么一句,积少成多就是负担。
所以,真正的出路只有一个: 把数据搬运这件事,彻底交给别人干 。
DMA登场:让ADC自己把数据“扔”到内存里
DMA(Direct Memory Access),直译是“直接存储器访问”。它的本质是一个独立的数据搬运工,专门负责在外设和内存之间搬数据,全程不需要CPU插手。
拿ADC来说,原本流程是:
ADC转换完成 → 触发中断 → CPU响应 → 读取DR寄存器 → 写入数组
而加上DMA之后,变成了:
ADC转换完成 → 触发DMA请求 → DMA控制器自动从DR读数据 → 直接送进指定内存地址
CPU呢?啥也不用干,可以去睡觉(低功耗模式)、处理通信协议、跑控制算法……完全自由。
那么,DMA到底强在哪?
我们来看一组对比:
| 维度 | 轮询 | 中断 | DMA |
|---|---|---|---|
| CPU占用 | ⛔ 极高 | ⚠️ 中等 | ✅ 几乎为零 |
| 支持高速采集 | ❌ 一般不超过10ksps | ⚠️ 受限于中断响应速度 | ✅ 可达数Msps(取决于ADC性能) |
| 数据连续性 | ❌ 易丢帧 | ⚠️ 有风险 | ✅ 硬件保障 |
| 功耗表现 | 🔥 高 | 🔥 中 | 💤 极佳(CPU可休眠) |
看到没?只有DMA能做到“既快又稳还省电”。
特别是当你做电池供电设备、工业监控、音频前级这类对实时性和能效要求高的应用时,DMA几乎是必选项。
STM32的ADC+DMA联动机制揭秘
STM32系列MCU在这方面做得非常成熟,尤其是F1/F4/H7等主流型号,ADC模块原生支持与DMA联动。
关键机制如下:
-
每次ADC转换完成后
,会自动产生一个DMA请求(
DMA Request); - DMA控制器监听该请求 ,一旦检测到就立即执行一次传输;
-
源地址固定为
ADCx->DR,目标地址由用户设定(如全局数组); - 支持循环模式(Circular Mode) ,缓冲区满后自动回绕,实现无限采集;
- 可配合定时器触发 ,实现精确周期采样(例如每100μs采一次)。
整个链路完全是硬件级别的联动,时序精准,不受软件延迟影响。
🎯 小知识:STM32F103的ADC最大采样率约1Msps(标准模式),理论上每秒能通过DMA搬100万个16位数据——这要是靠CPU来搬,早就累瘫了。
动手实操:用STM32CubeMX快速搭建DMA+ADC系统
接下来我们以 STM32F103C8T6 (蓝丸开发板常用芯片)为例,一步步教你用STM32CubeMX配置这套“自动驾驶”采集系统。
第一步:创建工程 & 配置时钟
打开STM32CubeMX,选择芯片型号。
进入 System Core → RCC ,启用外部晶振(HSE),然后去 Clock Configuration 页面把系统时钟拉到72MHz(这是F1系列最高主频)。
为什么要72MHz?因为ADC时钟来自APB2(PCLK2),默认也是72MHz,需要分频到不超过14MHz才能正常工作(参考手册规定)。所以我们后面会在ADC设置里手动分频。
第二步:配置PA0为模拟输入
在Pinout图中找到PA0引脚,点击下拉菜单,选择 Analog 模式。
这样告诉芯片:“这个脚我不用来做GPIO,我要接模拟信号进来。”
通常我们会接个电位器、NTC热敏电阻或者运放输出到这里。
第三步:配置ADC1
双击左侧的ADC1模块,进入参数设置页面。
主要配置项如下:
- Mode : Independent Mode(单ADC模式)
- Channel Selection : 添加 Channel IN0(对应PA0)
- Scan Conversion Mode : Disabled(单通道)或 Enabled(多通道)
- Continuous Conversion Mode : Disabled(我们靠DMA循环实现连续采集)
- Discontinuous Mode : Disabled
- Data Alignment : Right alignment(右对齐,常用)
- Sampling Time : 建议设为 55.5 cycles (提高精度,尤其信号源阻抗较高时)
📌 注意:虽然叫“Continuous Conversion Mode”,但如果我们启用了DMA循环模式,其实就不需要它了。否则反而容易冲突。
第四步:关键!配置DMA
切换到 DMA Settings 标签页,点击“Add”添加一条DMA请求。
填写以下参数:
| 参数 | 设置 |
|---|---|
| Peripheral | ADC1 |
| Direction | Peripheral to Memory |
| Mode | Circular ✅(必须勾选!) |
| Data Width | Half Word(16位,匹配ADC输出) |
| Increment Address | Memory: Yes / Peripheral: No |
| Buffer Size | 100(可根据需求调整) |
解释几个重点:
- Circular Mode :开启后,当DMA搬完100个数据,指针自动回到开头重新填充,形成一个“环形缓冲区”。这是实现无限采集的核心。
-
Memory Increment Enable
:内存地址递增,确保每个新数据写入下一个位置;外设地址禁止递增,因为我们始终只读
ADC1->DR。 - Half Word :STM32的ADC结果寄存器是16位宽,所以要用半字传输,避免错位。
此时你会看到下方提示:“DMA1_Channel1 will be used” —— CubeMX已经自动分配了通道。
第五步:生成代码
设置项目名称、路径、工具链(推荐STM32CubeIDE或Keil MDK),记得勾选:
✅ Generate peripheral initialization as a pair of ‘.c/.h’ files per peripheral
这个选项能让HAL初始化代码更清晰,方便后期维护。
点击“Generate Code”,等待几秒,工程就 ready 了!
生成的代码长什么样?看看底层发生了什么
CubeMX不仅帮你配好了外设,还会自动生成关键的底层驱动代码。我们重点关注两个地方。
1.
HAL_ADC_MspInit()
—— DMA初始化的核心
文件位于
Src/stm32f1xx_hal_msp.c
中:
void HAL_ADC_MspInit(ADC_HandleTypeDef* adcHandle)
{
DMA_HandleTypeDef hdma_adc;
if(adcHandle->Instance == ADC1)
{
__HAL_RCC_DMA1_CLK_ENABLE(); // 开启DMA1时钟
hdma_adc.Instance = DMA1_Channel1;
hdma_adc.Init.Direction = DMA_PERIPH_TO_MEMORY;
hdma_adc.Init.PeriphInc = DMA_PINC_DISABLE; // 外设地址不变
hdma_adc.Init.MemInc = DMA_MINC_ENABLE; // 内存地址递增
hdma_adc.Init.PeriphDataAlignment = DMA_PDATAALIGN_HALFWORD;
hdma_adc.Init.MemDataAlignment = DMA_MDATAALIGN_HALFWORD;
hdma_adc.Init.Mode = DMA_CIRCULAR; // 循环模式!
hdma_adc.Init.Priority = DMA_PRIORITY_LOW;
if (HAL_DMA_Init(&hdma_adc) != HAL_OK)
{
Error_Handler();
}
__HAL_LINKDMA(adcHandle, DMA_Handle, hdma_adc); // 绑定ADC与DMA
}
}
这段代码完成了DMA通道的初始化,并通过宏
__HAL_LINKDMA
把DMA句柄挂载到ADC结构体上,后续调用
HAL_ADC_Start_DMA()
时就能正确关联。
💡 提醒:如果你手动修改了DMA配置,请务必检查这里的初始化是否同步更新。
2. 如何启动采集?
回到主程序,在
main.c
的合适位置加入:
#define ADC_BUFFER_SIZE 100
uint16_t adc_buffer[ADC_BUFFER_SIZE]; // 全局缓冲区
int main(void)
{
HAL_Init();
SystemClock_Config();
MX_GPIO_Init();
MX_ADC1_Init();
MX_DMA_Init();
// 🚀 启动DMA+ADC采集
HAL_ADC_Start_DMA(&hadc1, (uint32_t*)adc_buffer, ADC_BUFFER_SIZE);
while (1)
{
// CPU自由了!可以干别的事
// 比如每秒打印一次平均值
HAL_Delay(1000);
uint32_t sum = 0;
for(int i = 0; i < ADC_BUFFER_SIZE; i++) {
sum += adc_buffer[i];
}
float avg = (float)sum / ADC_BUFFER_SIZE;
printf("Avg ADC Value: %.2f\r\n", avg);
}
}
就这么一行
HAL_ADC_Start_DMA()
,整个采集系统就开始运转了。
DMA会在后台持续不断地把ADC结果塞进
adc_buffer
,而你可以在主循环里定期读取这批数据进行滤波、上传、显示等操作。
🔄 补充:如果你想在缓冲区填满一半或全部时收到通知,可以用回调函数:
void HAL_ADC_ConvHalfCpltCallback(ADC_HandleTypeDef* hadc) {
// 前50个数据已就绪
}
void HAL_ADC_ConvCpltCallback(ADC_HandleTypeDef* hadc) {
// 后50个数据已就绪(仅双缓冲模式有效)
}
不过注意:F1系列不支持双缓冲(Double Buffer Mode),所以
ConvCplt
回调不会触发。如需中断通知,建议使用定时器触发+EOC中断组合策略。
多通道采集怎么做?
前面说的是单通道(PA0),但现实中更多是多传感器并行采集。
比如你要同时监测三路电压:Vbat、Temp、Light。
方法一:扫描模式 + DMA
在CubeMX中启用 Scan Mode ,然后依次添加多个通道:
- IN0 (PA0)
- IN1 (PA1)
- IN2 (PA2)
保持DMA配置不变,缓冲区大小设为3的倍数(如60,对应20组三通道数据)。
采集顺序将是:
IN0 → IN1 → IN2 → 回到IN0 → ...
DMA会按此顺序依次将结果写入buffer:
adc_buffer[0] = IN0_sample1
adc_buffer[1] = IN1_sample1
adc_buffer[2] = IN2_sample1
adc_buffer[3] = IN0_sample2
...
因此你需要在处理时按索引分组提取:
for (int i = 0; i < SAMPLE_GROUP_COUNT; i++) {
uint16_t vbat = adc_buffer[i * 3 + 0];
uint16_t temp = adc_buffer[i * 3 + 1];
uint16_t light = adc_buffer[i * 3 + 2];
// 处理...
}
⚠️ 注意事项:
- 扫描模式下总采样率 = 单通道速率 ÷ 通道数。例如你想每通道10ksps,总共就得配置30ksps;
- 各通道采样时间尽量一致,避免精度偏差;
- 若各通道信号变化频率差异大(如温度慢、电压快),建议分时采集而非同步扫描。
方法二:多个ADC + 独立DMA(高级玩法)
某些STM32型号(如F3/F4)有两个甚至三个ADC,支持交替触发、同步采样等功能。你可以让ADC1采集一路,ADC2采集另一路,各自绑定不同的DMA通道,实现真正的并行采集。
但这属于进阶用法,普通项目用扫描模式足够。
实际应用中的那些“坑”,我都替你踩过了
你以为配置完就万事大吉?Too young.
以下是我在多个量产项目中总结出的 实战经验清单 ,帮你避开常见雷区。
❗ 缓冲区大小怎么定?
太小 → 数据还没处理完就被覆盖 → 丢失;
太大 → 浪费RAM,尤其在小容量MCU上不可接受。
✅ 推荐公式:
缓冲区大小 = 采集频率 × 处理周期(秒)
例如:
- 每秒采1000次(1ksps)
- 主循环每100ms处理一次
- 则缓冲区应 ≥ 1000 × 0.1 = 100 个样本
留点余量,设为128或200即可。
❗ DMA循环模式必须开!
很多人忘了在CubeMX里勾选 Circular Mode ,结果DMA搬完一轮就停了。
后果就是:第一次能拿到数据,第二次再看
adc_buffer
,全是旧值。
记住一句话: 没有Circular,就没有无限采集 。
❗ 内存对齐问题不能忽视
如果你定义的是
uint8_t buffer[100]
,却设置DMA为Half Word传输,会导致总线错误(HardFault)!
✅ 正确做法:
uint16_t adc_buffer[100]; // 必须是16位类型
// 或者用 __attribute__((aligned(2))) 强制对齐
GCC编译器通常会自动对齐,但跨平台移植时要特别小心。
❗ 不要让多个外设抢同一个DMA通道
比如你同时用了ADC1和USART1,两者都试图使用DMA1_Channel1,就会冲突。
解决方案:
- 查阅参考手册《DMA request mapping》表格;
- 在CubeMX中观察是否有警告标志;
- 必要时手动更换通道(如USART1改用DMA1_Channel4)
❗ ADC噪声问题怎么破?
即使硬件接好了,有时候读出来的数据“跳得很厉害”。
常见原因:
- 参考电压不稳定(VREF+没加电容)
- 模拟电源未滤波
- PCB布线靠近数字信号线(尤其是CLK、SWD)
- 输入信号源阻抗过高,采样时间不够
✅ 解决方案:
- 在VDDA和VSSA之间加100nF去耦电容;
- 使用较大的采样时间(如239.5 cycles);
- 对信号做硬件RC滤波(如10kΩ + 100nF);
- 软件端做滑动平均或卡尔曼滤波。
🧪 我的一个项目曾因把ADC引脚挨着SWDIO布线,导致每次下载程序时采集值狂跳……改版才解决。
它适合哪些场景?我给你划重点
这套方案不是万能的,但它在以下领域简直是“神兵利器”:
✅ 电池管理系统(BMS)
需要持续监测多节电芯电压,精度要求高,且系统需长期低功耗运行。DMA+ADC能让CPU大部分时间处于Stop模式,仅靠定时器唤醒采样。
✅ 工业PLC模拟量输入模块
4-20mA或0-10V信号采集,要求抗干扰能力强、数据连续不断。DMA配合定时器触发,完美满足。
✅ 智能穿戴设备
手表、手环中的心率、血氧、体温等生物信号采集,往往需要高频采样+低功耗。DMA让你既能抓细节,又能省电。
✅ 音频前置采集(简易版)
虽然STM32的ADC不适合专业音频,但对于语音命令识别、环境音检测等低端应用,配合DMA实现10-48ksps采样完全可行。
❌ 不适合的场景
- 超低频采集(如每分钟一次)—— 太浪费资源;
- 单次测量(如按键电压判断)—— 完全没必要上DMA;
- RAM极度紧张的系统(<4KB)—— 缓冲区占不起。
还能怎么优化?我的私藏技巧分享
最后分享几个我在实际项目中用过的“进阶技巧”,让你的采集系统更聪明。
🎯 技巧1:用定时器代替软件触发,实现精准采样间隔
默认情况下,调用
HAL_ADC_Start_DMA()
后,ADC会立即开始连续转换。但由于内部机制限制,间隔并不精确。
更好的方式是:
- 配置一个通用定时器(TIM2/TIM3);
- 设置为“Update Event”作为触发源;
- 在CubeMX的ADC配置中选择 External Trigger Conversion Source → Timer X TRGO ;
- 设置预分频和自动重载值,得到精确周期(如100μs);
这样一来,ADC就在定时器驱动下一拍一拍地工作,节奏稳如节拍器。
🎯 技巧2:结合FreeRTOS做异步处理
不要在主循环里直接遍历整个buffer!那样会阻塞其他任务。
推荐做法:
- 创建一个低优先级任务专门处理ADC数据;
- 使用二值信号量或队列通知它“新数据来了”;
- 或者利用DMA传输完成中断(Half/Complete Callback)发消息;
示例:
osSemaphoreId_t adcSem;
void HAL_ADC_ConvHalfCpltCallback(ADC_HandleTypeDef* hadc) {
osSemaphoreRelease(adcSem); // 通知RTOS任务
}
// RTOS任务中
void AdcProcessingTask(void *pvParameters) {
for (;;) {
osSemaphoreAcquire(adcSem, portMAX_DELAY);
// 处理前50个数据
}
}
这样既保证实时性,又不影响系统整体调度。
🎯 技巧3:动态调节采样率
有些场景下,你希望平时低速采样以省电,检测到异常时提速。
可以通过修改定时器ARR值动态调整触发频率,从而改变ADC采样率。
比如监测振动信号:静止时1ksps,晃动时切到10ksps抓特征。
写在最后:让外设“自治”,才是嵌入式设计的高级形态
回头想想,我们最初的目标是什么?
不是学会某个API,也不是搞定某个配置步骤,而是 构建一个能自我运行的系统 。
DMA+ADC只是一个起点。顺着这个思路往下走,你会发现:
- USART + DMA → 实现无感串口收发
- SPI + DMA → 高速驱动屏幕或Flash
- TIM + DMA → 波形生成、PWM序列输出
这些外设之间的协同,正在构成一种新的设计哲学: 让CPU成为系统的“指挥官”,而不是“搬运工” 。
当你熟练掌握这种“外设自治”思维,你会发现:
同样的MCU,能做的事情比以前多了十倍;
同样的功耗预算,续航时间翻了一番;
同样的开发周期,交付质量提升不止一个档次。
而这,才是嵌入式工程师真正的成长曲线。
现在,轮到你动手试试了。
236

被折叠的 条评论
为什么被折叠?



