STM32中的DMA:如何让CPU“躺平”,数据自己跑?
你有没有遇到过这样的场景:
- 用ADC采集传感器数据,每毫秒一次,结果发现CPU一半时间都在读寄存器;
- 串口发日志卡顿,一查发现是
printf占用了主循环; - 想做音频播放或波形输出,但数据流太大,中断频繁到系统崩溃。
如果你点头了——恭喜,你已经踩进了嵌入式开发的“高带宽陷阱”。而破局的关键,就藏在STM32里那个被很多人忽略的外设:DMA。
为什么我们需要DMA?
先说个扎心事实:CPU不是干搬运工活的。
它的真正职责是决策、计算和调度。可现实中,我们却常常让它去做最枯燥的事——一个字节一个字节地搬数据。
比如通过USART发送一串字符串:
for (int i = 0; i < len; str++) { while (!(USART1->SR & USART_SR_TXE)); // 等待发送寄存器空 USART1->DR = *str; // 写入数据 }这段代码看似简单,实则让CPU全程“盯梢”硬件状态。如果要发1KB的数据?那CPU就得忙几百微秒,期间啥也干不了。
而DMA的作用,就是把这个苦力活交给专用硬件模块来完成。你只管说:“我要把这块内存的数据送到USART,完成后叫我一声。”然后转身去处理更重要的事,甚至进入睡眠模式省电。
一句话总结:DMA = 数据搬运外包公司,专治CPU过劳症。
DMA到底怎么工作的?从“请求—通道—流”讲起
STM32的DMA控制器不像你想象中那么“智能”,它更像是一个听指令办事的流水线工人。整个过程可以拆解为四个关键环节:
1. 谁发起任务?——外设的DMA请求
当某个外设(如ADC转换完成、USART准备接收)时,它会向DMA控制器发出一个信号:“我需要数据!”这个信号叫做DMA Request(请求)。
例如:
- ADC完成一次采样 → 触发DMA请求
- USART的TDR寄存器变空 → 请求下一笔数据
这些请求不会直接找CPU,而是连到DMA控制器的输入端口上。
2. 谁来执行任务?——DMA通道与流
以STM32F4系列为例,芯片有两个DMA控制器(DMA1和DMA2),每个控制器有多个Stream(流),每个流又可绑定不同的Channel(通道)。
你可以这样理解:
-Stream是一条独立的数据搬运通道(类似高速公路车道)
-Channel决定这条流服务哪个外设(比如选了Channel=4,就表示这条路专供USART1使用)
⚠️ 注意:不同外设可能共享同一个通道编号,但不能同时激活。你需要查参考手册确认映射关系。
3. 搬什么?怎么搬?——三大传输参数
每次启动DMA前,必须告诉它三件事:
| 参数 | 说明 |
|---|---|
| 源地址 | 数据从哪来?比如&ADC1->DR |
| 目标地址 | 数据往哪送?比如你的缓冲数组rx_buffer |
| 传输数量 | 搬多少次?单位可以是字节、半字或字 |
此外还要设置:
- 地址是否自动递增(内存通常递增,外设寄存器不递增)
- 数据宽度(8/16/32位匹配外设需求)
- 传输方向(内存→外设?外设→内存?)
一旦配置好并启用,DMA就会接管总线控制权,逐拍完成数据拷贝,全程无需CPU插手。
4. 完成后通知我!——中断回调机制
虽然搬运过程全自动,但我们往往希望知道“什么时候搬完了”。
这时就可以开启DMA的中断功能。常见的标志包括:
- TCIF:传输完成(Transfer Complete Interrupt Flag)
- HTIF:半传输中断(Half Transfer)
- TEIF:传输错误
在中断服务函数中清除标志,并调用用户定义的回调函数即可实现事件响应。
实战教学:用DMA实现无阻塞串口发送
下面我们以最常见的应用场景为例:使用DMA通过USART发送日志信息。
第一步:打开相关时钟
任何外设操作之前,先得供电。
// 开启DMA2和USART1时钟 RCC->AHB1ENR |= RCC_AHB1ENR_DMA2EN; RCC->APB2ENR |= RCC_APB2ENR_USART1EN;第二步:配置DMA参数(HAL库方式)
这里推荐初学者使用HAL库简化流程:
DMA_HandleTypeDef hdma_tx; hdma_tx.Instance = DMA2_Stream7; // 使用DMA2的Stream7 hdma_tx.Init.Channel = DMA_CHANNEL_4; // 对应USART1_TX hdma_tx.Init.Direction = DMA_MEMORY_TO_PERIPH;// 内存→外设 hdma_tx.Init.PeriphInc = DMA_PINC_DISABLE; // 外设地址不变 hdma_tx.Init.MemInc = DMA_MINC_ENABLE; // 内存地址递增 hdma_tx.Init.PeriphDataAlignment = DMA_MDATAALIGN_BYTE; hdma_tx.Init.MemDataAlignment = DMA_MDATAALIGN_BYTE; hdma_tx.Init.Mode = DMA_NORMAL; // 单次模式 hdma_tx.Init.Priority = DMA_PRIORITY_LOW; hdma_tx.Init.FIFOMode = DMA_FIFOMODE_DISABLE; if (HAL_DMA_Init(&hdma_tx) != HAL_OK) { Error_Handler(); }第三步:关联DMA与UART句柄
为了让HAL库知道该用哪个DMA实例,需要用宏连接:
__HAL_LINKDMA(&huart1, hdmatx, hdma_tx);这行代码的意思是:“以后huart1的发送任务,交给hdma_tx去办。”
第四步:启动DMA传输
有两种方式可以选择:
方法一:一键启动(推荐新手)
uint8_t msg[] = "Hello from DMA!\r\n"; HAL_UART_Transmit_DMA(&huart1, msg, sizeof(msg));从此刻起,CPU就可以继续执行其他任务,甚至进入低功耗模式,而DMA会在后台默默把数据推给USART。
方法二:手动寄存器操作(适合想深入底层的同学)
DMA2_Stream7->PAR = (uint32_t)&(USART1->DR); // 目标地址:USART数据寄存器 DMA2_Stream7->M0AR = (uint32_t)msg; // 源地址:内存缓冲区 DMA2_Stream7->NDTR = sizeof(msg); // 传输字节数 // 配置控制寄存器 DMA2_Stream7->CR = (0 << 6) | // 清方向位:内存→外设 DMA_SxCR_MINC | // 内存递增 DMA_SxCR_PSIZE_0 | // 外设大小:8位 DMA_SxCR_MSIZE_0 | // 内存大小:8位 DMA_SxCR_EN; // 启动流! // 别忘了开启USART的DMA使能位 USART1->CR3 |= USART_CR3_DMAT;看到没?真正的“启动”只需要写几个寄存器,剩下的全由硬件搞定。
第五步:传输完成怎么办?加个回调!
你肯定想知道“发完了吗?”所以我们要注册一个回调函数:
void HAL_UART_TxCpltCallback(UART_HandleTypeDef *huart) { if (huart->Instance == USART1) { // 可在此处启动下一轮发送,或点亮LED提示完成 HAL_GPIO_WritePin(LED_GPIO_Port, LED_Pin, GPIO_PIN_SET); } }只要你在工程中定义了这个弱函数,HAL库就会自动调用它。
更高级玩法:ADC多通道循环采集 + DMA
这才是DMA的杀手级应用之一。
设想你要同时采集温度、湿度、光照三个传感器信号,频率要求1kHz。传统做法是轮流启动ADC+等待EOC中断,效率极低且容易漏采。
而用DMA,方案如下:
配置要点:
- ADC设置为连续扫描模式
- 启用ADC的DMA请求
- DMA设为循环模式(Circular Mode)
- 目标缓冲区为一个固定长度数组
#define ADC_BUF_LEN 3 uint16_t adc_results[ADC_BUF_LEN]; // 配置DMA为循环模式 hdma_adc.Init.Mode = DMA_CIRCULAR; // 关键!自动重复填充一旦启动,会发生什么?
- ADC按顺序采集CH0、CH1、CH2
- 每次转换结束,DMA自动将
ADC->DR中的值搬到adc_results[i] - 当最后一个通道完成,DMA回到第一个位置重新覆盖
- 整个过程无需中断,CPU完全解放!
你可以每隔一段时间去读取adc_results进行滤波处理,或者结合DMA双缓冲模式实现无缝切换。
新手必看:那些年我们都踩过的坑
DMA虽强,但也容易出问题。以下是几个典型“翻车现场”及应对策略:
❌ 坑点1:程序跑飞,进BusFault?
原因很可能是地址未对齐!
如果你设置了32位传输模式,但源地址是奇数地址(比如0x20000001),就会触发总线错误。
✅ 解决方法:
uint8_t buffer[100] __attribute__((aligned(4)));强制让编译器将其分配在4字节对齐的位置。
❌ 坑点2:DMA写入了数据,但我读出来是旧的?
尤其是在STM32F4/F7/H7这类带D-Cache的芯片上常见。
原因是:DMA写的是实际内存,但CPU从缓存里读的是旧副本。
✅ 解决方法:
SCB_InvalidateDCache_by_Addr((uint32_t*)buffer, size);在CPU访问DMA写入的内存前,先清无效化缓存。
❌ 坑点3:DMA传着传着突然停了?
检查是否开启了全局中断。某些HAL库API内部依赖中断来清理状态。
另外,确保没有其他高优先级DMA请求抢占了总线资源。
✅ 秘籍:调试DMA的小技巧
观察NDTR寄存器
在IDE调试模式下查看DMAx_Streamy->NDTR,它的值应该随着传输逐步减小。用逻辑分析仪抓信号
探测DMA Busy或DMA Request引脚,验证传输节奏是否符合预期。打印ITM日志
使用SWO输出关键事件时间戳,避免因断点暂停导致DMA超时。
设计哲学:让合适的人做合适的事
掌握DMA的意义,远不止于学会一个外设配置。
它背后体现的是一种系统级思维转变:
| 角色 | 应该做的事 |
|---|---|
| CPU | 决策、算法、协议解析、异常处理 |
| DMA | 数据搬运、批量传输、定时触发 |
当你开始思考“这部分能不能甩给DMA”,你就离高手不远了。
举几个实战思路:
-低功耗采集系统:RTC定时唤醒 → ADC+DMA采集 → 立即进入STOP2模式
-音频播放:I2S + DMA + 双缓冲 → 实现流畅音乐输出
-图像传输:LCD驱动中用DMA推送像素数据,释放CPU渲染UI
结语:从“搬砖”到“架构”
DMA不是一个炫技工具,而是构建高效嵌入式系统的基石。
对于刚入门的开发者,建议按照以下路径渐进学习:
- 先用STM32CubeMX生成DMA配置代码,观察其行为
- 尝试修改为纯HAL库调用,理解每一行的作用
- 最后尝试手动操作寄存器,彻底吃透底层机制
你会发现,原本让你头疼的性能瓶颈,在DMA面前变得迎刃而解。
正如一位资深工程师所说:“优秀的嵌入式系统,不是看CPU多快,而是看它有多‘懒’。”
现在,轮到你让STM32的CPU真正“躺平”了。
如果你在实践过程中遇到了具体问题,欢迎留言交流,我们一起排查DMA的每一个细节。