让硬件干活,别让CPU搬砖:STM32 DMA实战全解析
你有没有遇到过这样的场景?
- ADC每毫秒采一次数据,主循环卡得像老式收音机;
-
UART发个日志都要调用
HAL_UART_Transmit()阻塞几百微秒; - 想做音频播放,结果PCM数据根本喂不饱I2S接口;
- 系统一跑起来,CPU占用率直接飙到90%以上……
这时候,很多人第一反应是:“换颗更快的芯片?”
但其实,问题不在性能不够强,而在
资源分配不合理
。
真正高效的嵌入式系统,不是靠“暴力堆算力”,而是懂得 让每个模块各司其职 。而今天我们要聊的这个“隐形英雄”——DMA(Direct Memory Access),正是实现这一理念的关键钥匙。
🧩 “CPU负责思考,DMA负责跑腿。”
—— 这句话听起来简单,但它背后藏着现代MCU高效运行的核心逻辑。
为什么你需要关心DMA?
先来看一组真实对比:
| 场景 | CPU轮询方式 | 使用DMA |
|---|---|---|
| 每1ms采集4通道ADC | 每秒触发1000次中断,每次执行读取+存储指令 | 初始化后零干预,仅每100个样本进一次中断 |
| 发送1KB串口数据包 |
HAL_UART_Transmit()
阻塞约8ms(115200bps)
| 调用即返回,CPU立刻继续工作 |
| 音频I2S播放 | 必须在每个时钟周期由中断喂数据 | 启动DMA后自动持续输出 |
看到差别了吗?
使用DMA后,
同样的功能,CPU从“搬运工”变成了“管理者”
。它不再亲自参与每一个字节的传输,而是说一句:“你们去干吧,干完了叫我。”
这不只是省了几条代码的事,而是从根本上改变了系统的架构和响应能力。
那么,DMA到底是什么?
你可以把它想象成一个
独立的小型搬运机器人
,内置于STM32芯片中。它的任务很简单:
👉 在两个地址之间搬数据,搬完就报告,中间完全不需要你盯着。
而且这家伙还不挑活:
- 外设 → 内存 ✅(比如ADC采样存RAM)
- 内存 → 外设 ✅(比如发送字符串到USART)
- 内存 → 内存 ✅(比如复制大块Flash数据到SRAM)
只要总线允许,它就能上场。
STM32里的DMA长什么样?
STM32不是只给了你一个DMA控制器,而是配了一整套“物流系统”。
以常见的STM32F4系列为例:
- 有两个DMA控制器:DMA1 和 DMA2
- 每个控制器有多个通道(Channel),总共最多7+7=14条
- 每个通道可以绑定不同的外设请求信号(如ADC1、SPI3_TX等)
- 支持优先级调度、双缓冲、循环模式、FIFO……简直是为高吞吐量设计的“高速公路”
更高端的H7系列甚至还有 BDMA (Backup Domain DMA)和 MDMA (Memory-to-Memory DMA),支持跨域访问和更复杂的链式传输。
但这不是重点。
重点是你得知道:
DMA不是一个“可选项”,而是构建高性能系统的“基础设施”
。
它是怎么工作的?拆开看看
我们拿最常见的 ADC + DMA 数据采集 来举例。
假设你现在要做一个环境监测设备,需要每10ms采集一次温湿度、光照、噪声四个传感器的数据。
传统做法可能是这样:
while (1) {
HAL_ADC_Start(&hadc1);
HAL_ADC_PollForConversion(&hadc1, 10);
value1 = HAL_ADC_GetValue(&hadc1);
HAL_ADC_Start(&hadc2); // 假设多路
HAL_ADC_PollForConversion(&hadc2, 10);
value2 = HAL_ADC_GetValue(&hadc2);
process_data(value1, value2); // 处理数据
HAL_Delay(10); // 等待下一个周期
}
这段代码的问题在哪?
- 占用CPU时间太长
-
HAL_Delay()
期间什么都不能干
- 如果加上通信、显示等功能,整个系统会变得非常卡顿
现在换成DMA思路:
🧠 思路转变:
我不再“主动去拿数据”,而是告诉ADC:“你每完成一次转换,就把结果扔进内存里,等攒够了叫我。”
具体流程如下:
-
配置阶段 (一次性设置好)
- 指定源地址:ADC的数据寄存器(固定不变)
- 目标地址:RAM中的缓冲区(例如uint16_t buffer[4])
- 方向:外设 → 内存
- 数据宽度:半字(16位)
- 传输数量:4(对应4个通道)
- 模式:循环模式(Circular Mode)→ 自动重复采集 -
启动后 :
- 只需开启一次ADC,之后所有转换都由硬件自动触发
- 每次转换完成 → ADC发出DMA请求 → DMA自动把值写入buffer
- 当4个通道全部采完 → 触发DMA传输完成中断 -
CPU只需在中断里处理 :
c void HAL_ADC_ConvCpltCallback(ADC_HandleTypeDef *hadc) { if (hadc == &hadc1) { // 此时buffer已更新,可进行滤波/上传等操作 analyze_sensor_data(adc_buffer); } }
整个过程中,CPU除了初始化和最后处理外,全程“脱手”。
💡 实际效果:
原本每秒要被打断1000次的任务,现在可能每秒只中断10次(每次处理100个样本),
CPU负载下降90%以上
!
关键特性详解:这些功能你真的会用吗?
🔁 循环模式(Circular Mode)——无限续航的秘密
当你启用
DMA_CIRCULAR
模式时,DMA会在缓冲区填满后自动重置指针,重新开始填充。
这对于周期性采集简直是神器。
举个例子:
uint16_t adc_buffer[100]; // 缓冲100个样本
hdma_adc1.Init.Mode = DMA_CIRCULAR;
HAL_DMA_Init(&hdma_adc1);
HAL_ADC_Start_DMA(&hadc1, (uint32_t*)adc_buffer, 100);
这意味着:
- 不用手动重启DMA
- 数据流源源不断
- 可结合双缓冲或定时中断做滑动窗口分析
⚠️ 注意:不要在循环模式下使用局部变量作为缓冲区!栈空间可能被覆盖。
🔄 双缓冲模式(Double Buffer Mode)——无缝衔接的艺术
有些应用对数据连续性要求极高,比如音频录制、高速示波器。
这时普通的单缓冲会有“空窗期”:当CPU正在处理当前缓冲时,新的数据仍在不断产生,稍有不慎就会丢失。
解决方案?双缓冲!
开启双缓冲后,DMA会在两个内存区域之间交替写入:
Buffer A ← 写入中
↓
切换
↑
Buffer B ← 已完成,供CPU读取
每当一个缓冲写满,DMA自动切换到另一个,并触发中断通知CPU处理旧缓冲。
在HAL库中启用方式如下:
hdma_adc1.Init.Mode = DMA_CIRCULAR;
hdma_adc1.Init.FIFOMode = DMA_FIFOMODE_ENABLE;
hdma_adc1.Init.MemBurst = DMA_MBURST_SINGLE;
// 注意:需手动使能双缓冲寄存器位(部分型号需底层操作)
或者使用LL库直接配置:
LL_DMA_EnableDoubleBufferMode(DMA2, LL_DMA_STREAM_0);
LL_DMA_SetMemory1BaseAddr(DMA2, LL_DMA_STREAM_0, (uint32_t)&adc_buffer2);
这样一来,你就实现了真正的“一边采样,一边处理”,完全没有停顿。
🎧 典型应用:I2S麦克风录音、软件定义无线电(SDR)、电机电流闭环控制。
⚖️ 优先级管理——谁先谁后很重要
多个DMA通道同时请求怎么办?比如ADC在传数据,同时SPI也在往外发图像。
这时候就得靠 优先级仲裁机制 。
STM32的DMA通道支持四级优先级:
- 非常低(Very Low)
- 低(Low)
- 中(Medium)
- 高(High)
配置也很简单:
hdma_usart1_tx.Init.Priority = DMA_PRIORITY_HIGH;
hdma_adc1.Init.Priority = DMA_PRIORITY_MEDIUM;
规则是:
- 软件优先级高的先服务
- 同级则按通道号顺序(编号小的优先)
所以在设计系统时就要想清楚:
👉 是语音通信更重要?还是传感器采样不能丢?
合理分配优先级,才能避免关键数据被挤占。
📏 数据对齐与FIFO——别让硬件罢工
DMA很强大,但也有些“脾气”。
比如最常见的 HardFault 错误,往往就是因为地址没对齐。
记住这几条铁律:
| 数据宽度 | 要求对齐方式 |
|---|---|
| Byte(8位) | 任意地址 |
| Half-word(16位) | 偶地址(如 0x20000000, 0x20000002) |
| Word(32位) | 4字节对齐(0x20000000, 0x20000004) |
如果你试图用
DMA_MDATAALIGN_WORD
去访问一个未对齐的地址,轻则传输失败,重则直接触发HardFault。
解决办法:
- 定义缓冲区时强制对齐:
__attribute__((aligned(4))) uint32_t dma_buffer[32];
- 或者使用编译器提供的宏:
ALIGN_32BYTES(uint8_t audio_buf[256]);
此外,高端型号还支持FIFO模式,可用于缓解速度不匹配问题。
例如:
- 慢速外设 → 快速内存:启用FIFO可批量传输,减少总线占用
- 快速外设 → 慢速外设:FIFO充当缓冲池,防止溢出
不过FIFO也增加了复杂度,建议初学者先掌握基本模式。
实战案例:用DMA让USART飞起来
你是不是经常为了发一条调试信息,就得让整个程序卡住几毫秒?
试试这个:
UART_HandleTypeDef huart1;
DMA_HandleTypeDef hdma_usart1_tx;
uint8_t log_msg[] = "[INFO] System initialized.\r\n";
void send_log_dma(void) {
HAL_UART_Transmit_DMA(&huart1, log_msg, sizeof(log_msg));
}
就这么一行调用,DMA就会自动把数据从内存搬到USART的TDR寄存器,直到全部发送完毕。
期间CPU干什么都可以:
- 继续采集传感器
- 更新LCD显示
- 执行PID控制算法
等到发送结束,会自动调用回调函数:
void HAL_UART_TxCpltCallback(UART_HandleTypeDef *huart) {
if (huart->Instance == USART1) {
// 可以标记发送完成,或启动下一轮
tx_complete_flag = 1;
}
}
💡 小技巧:如果你想连续发送多条消息,可以用环形队列管理待发数据包,每次DMA发送完成后自动取出下一条。
这样就能实现类似“异步日志系统”的效果,完全不影响主线程。
🚀 应用场景扩展:
- GPS模块数据转发(NMEA语句批量发送)
- Modbus RTU协议帧输出
- BLE/Wi-Fi模组AT指令交互
更进一步:内存到内存的DMA搬运
很多人以为DMA只能用于外设,其实它也能当“内部快递员”。
比如你想把一段Flash中的字体数据加载到SRAM中用于GUI显示:
extern const uint8_t font_data_in_flash[];
uint8_t font_buffer_in_sram[2048];
DMA_HandleTypeDef hdma_mem2mem;
void copy_font_via_dma(void) {
__HAL_RCC_DMA2_CLK_ENABLE();
hdma_mem2mem.Instance = DMA2_Stream1;
hdma_mem2mem.Init.Direction = DMA_MEMORY_TO_MEMORY;
hdma_mem2mem.Init.PeriphInc = DMA_PINC_ENABLE;
hdma_mem2mem.Init.MemInc = DMA_MINC_ENABLE;
hdma_mem2mem.Init.PeriphDataAlignment = DMA_PDATAALIGN_BYTE;
hdma_mem2mem.Init.MemDataAlignment = DMA_MDATAALIGN_BYTE;
hdma_mem2mem.Init.Mode = DMA_NORMAL;
hdma_mem2mem.Init.Priority = DMA_PRIORITY_LOW;
HAL_DMA_Init(&hdma_mem2mem);
HAL_DMA_Start(&hdma_mem2mem,
(uint32_t)font_data_in_flash,
(uint32_t)font_buffer_in_sram,
2048);
// 等待完成(或注册回调)
while (HAL_DMA_PollForTransfer(&hdma_mem2mem, HAL_DMA_FULL_TRANSFER, 100) != HAL_OK);
}
虽然这类操作不如外设DMA常用,但在固件升级、资源动态加载等场景中非常有用。
📌 特别提醒:Cortex-M7/M33等带缓存的芯片要注意 缓存一致性问题 !
比如从Flash复制到SRAM后,如果目标区域在DCache中有缓存,必须手动无效化:
SCB_InvalidateDCache_by_address((uint32_t*)font_buffer_in_sram, 2048);
否则你可能会发现:“明明写了数据,怎么读出来还是旧的?”——这就是缓存惹的祸。
如何选择合适的缓冲策略?
DMA虽好,但缓冲区设计不当也会带来新问题。
下面是几种常见模式及其适用场景:
✅ 固定大小缓冲 + 传输完成中断
最基础的方式,适合稳定速率采集。
优点:逻辑清晰,易于调试
缺点:中断频率固定,无法适应突发流量
uint16_t buf[64];
HAL_ADC_Start_DMA(&hadc1, (uint32_t*)buf, 64);
// 在 ConvCpltCallback 中处理
✅ 半满/全满双中断(Half-Transfer & Full-Transfer)
利用DMA的HT(Half Transfer)和TC(Transfer Complete)两个事件。
相当于把缓冲区切成两半:
- 前半写满 → HT中断 → CPU处理前半
- 后半写满 → TC中断 → CPU处理后半
实现流水线式处理,延迟更低。
// 启用两种中断
__HAL_DMA_ENABLE_IT(&hdma_adc1, DMA_IT_HT | DMA_IT_TC);
// 分别处理
void HAL_ADC_HalfConvCpltCallback() { /* 处理前半 */ }
void HAL_ADC_ConvCpltCallback() { /* 处理后半 */ }
✅ 双缓冲 + 缓冲切换中断
适用于极高实时性需求,如音频流、运动控制。
特点:DMA始终在写,CPU在另一个缓冲上安全读取,绝不冲突。
配合RTOS还能做到任务间解耦。
常见坑点 & 调试技巧
DMA看似简单,但一旦出问题往往很难排查。以下是一些血泪经验总结:
❌ 坑1:缓冲区放在栈上
错误示范:
void start_dma(void) {
uint16_t local_buf[4]; // 栈变量!
HAL_ADC_Start_DMA(&hadc1, (uint32_t*)local_buf, 4);
}
函数退出后,栈空间可能被复用,DMA还在往里面写……后果不堪设想。
✅ 正确做法:全局静态变量 or
static
修饰 or 动态分配(配合RTOS)
❌ 坑2:忘记使能DMA时钟
__HAL_RCC_DMA2_CLK_ENABLE(); // 必须加!否则DMA不工作
尤其是新手容易忽略这点,导致“代码没错却没反应”。
❌ 坑3:DMA通道映射错误
不同STM32型号,外设对应的DMA通道不一样!
比如:
- STM32F407:ADC1 → DMA2_Stream0_Channel0
- STM32F103:ADC1 → DMA1_Channel1
务必查阅《Reference Manual》中的“DMA request mapping”表格。
🔍 推荐做法:打开CubeMX,勾选DMA,自动生成正确配置。
❌ 坑4:缓存未同步(Cache Coherency)
再次强调:M7/M4F等带缓存的芯片,DMA写入SRAM后,CPU可能读到的是缓存旧值!
解决方法:
- 写操作后调用
SCB_InvalidateDCache_by_address()
- 或者将DMA缓冲区定义在Non-cacheable区域(通过链接脚本或MPU配置)
🛠️ 调试技巧三连击:
-
Memory Watch + Breakpoint
在IDE中观察缓冲区变化,设置中断断点看是否如期触发。 -
Logic Analyzer 抓信号
用LA接ADC_BUSY、USART_TX等引脚,验证传输时序是否正常。 -
开启DMA错误中断
c __HAL_DMA_ENABLE_IT(&hdma_adc1, DMA_IT_TE); // 使能传输错误中断
一旦发生地址错误、传输失败等异常,立即捕获。
设计哲学:什么时候该用DMA?
说了这么多技术细节,我们回到最初的问题:
我到底该不该上DMA?
这里给你一套决策框架:
✔️ 上DMA的典型场景:
- ✅ 数据量 > 1KB/s
- ✅ 传输频率 > 100Hz
- ✅ 对实时性敏感(如音频、电机控制)
- ✅ 需要降低功耗(配合Sleep/Stop模式)
- ✅ 多任务系统中希望释放CPU资源
❌ 可不用DMA的情况:
- ❌ 单次传输几个字节(如发个”OK\r\n”)
- ❌ 调试阶段快速验证功能
- ❌ 硬件资源紧张(DMA通道已被占满)
记住一句话: DMA不是万能药,但它是专业系统的标配 。
就像汽车里的自动变速箱——低速挪车用手动挡也行,但跑长途没有自动挡简直反人类。
最后一点思考:DMA教会我们的事
DMA的存在本身就在告诉我们一件事:
🌟 最好的系统,是让硬件尽可能自治 。
它不只是一项技术,更是一种设计思维。
当你学会把“数据搬运”这种重复劳动交给专用硬件,你才有精力去解决真正有价值的问题:
- 如何优化控制算法?
- 如何提升用户体验?
- 如何延长电池寿命?
这才是嵌入式开发从“能跑”到“好用”的跃迁之路。
所以,下次你在写
for
循环拷贝数组的时候,不妨停下来问一句:
“这事,能不能让DMA来干?”
也许答案就是你迈向高性能系统的第一步。
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考
16万+

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



