STM32 DMA控制器配置实战:从零实现高效串口通信
在嵌入式开发中,你是否遇到过这样的场景?系统跑着跑着突然卡顿,调试发现CPU被UART中断“淹没”——每来一个字节就进一次中断,波特率115200意味着每秒上万次中断。这不仅拖慢主逻辑,还容易丢数据。
有没有办法让CPU“解放双手”,把搬数据这种重复劳动交给别人干?
有,这就是DMA(Direct Memory Access)——STM32里最值得掌握的外设加速技术之一。它就像一条专用的数据高速公路,让外设和内存之间可以直接对话,完全绕开CPU这个“交通指挥员”。
今天我们就以STM32F4系列为例,手把手带你用DMA实现UART串口的循环接收与发送,彻底告别高频中断烦恼。
为什么你需要DMA?
我们先来看一组真实对比:
| 方式 | 每秒中断次数(115200bps) | CPU占用估算 | 实时性 |
|---|---|---|---|
| 中断方式接收 | ~11,500次 | 高(>30%) | 差(易丢帧) |
| DMA方式接收 | 可降至每256字节1次 → ~45次 | 极低(<5%) | 好 |
看出差距了吗?减少99%以上的中断频率,这是什么概念?相当于原来你在接电话时每秒钟被打断十几次,现在变成几分钟才响一次铃声。
更别说在ADC采样、音频播放、图像传输等大数据量场景下,DMA几乎是刚需。
STM32的DMA架构长什么样?
STM32F4系列有两个DMA控制器:DMA1 和 DMA2,每个都有8个数据流(Stream 0~7),每个数据流又可以绑定不同的通道(Channel)连接不同外设。
比如你要用UART1_RX走DMA,就得查手册找到它对应的是哪个DMA、哪个Stream、哪个Channel。
📌关键点来了:
- UART1_RX → DMA2_Stream2 → Channel 4
- UART1_TX → DMA2_Stream7 → Channel 4
这些映射关系不是随便定的,必须对照《STM32F4xx参考手册》第10章确认。一旦配错,DMA就不会响应。
核心参数怎么设?别再死记硬背了!
很多人学DMA卡在一堆寄存器配置上,其实只要理解它的“工作模式”,一切就顺了。
想象一下你要安排一个人帮你搬运箱子:
- 从哪搬?→ 源地址
- 搬到哪去?→ 目标地址
- 搬几个?→ 数据长度
- 搬完要不要继续?→ 是否循环
- 地址要不要自动加?→ 地址增量
- 谁说了算?→ 优先级
把这些类比套到DMA里,是不是清晰多了?
下面是实际开发中最常设置的几个参数及其含义:
| 参数 | 解释 | 典型值 |
|---|---|---|
Direction | 数据流向 | 外设→内存 / 内存→外设 / 内存→内存 |
PeriphInc/MemInc | 外设/内存地址是否自增 | 接收时外设地址固定,内存递增 |
DataAlignment | 数据宽度 | 字节(Byte)、半字(HalfWord)、全字(Word) |
Mode | 传输模式 | 单次(Normal)、循环(Circular) |
Priority | 优先级 | 高/中/低/非常低 |
FIFOMode | 是否启用缓冲 | 提高突发效率,建议开启 |
记住一句话:外设地址通常不自增,内存地址要自增;接收用循环模式,发送看需求。
手把手教你配置DMA接收UART数据
我们现在要做一件事:让STM32通过UART持续接收主机发来的命令,并存入缓冲区,全程不打扰CPU。
第一步:初始化UART并关联DMA句柄
#include "stm32f4xx_hal.h" UART_HandleTypeDef huart1; DMA_HandleTypeDef hdma_usart1_rx; uint8_t rx_buffer[256]; // 接收缓冲区 void MX_USART1_UART_Init(void) { huart1.Instance = USART1; huart1.Init.BaudRate = 115200; huart1.Init.WordLength = UART_WORDLENGTH_8B; huart1.Init.StopBits = UART_STOPBITS_1; huart1.Init.Parity = UART_PARITY_NONE; huart1.Init.Mode = UART_MODE_RX; // 只启用接收 huart1.Init.HwFlowCtl = UART_HWCONTROL_NONE; HAL_UART_Init(&huart1); // 关键!将DMA接收句柄绑定到UART结构体 __HAL_LINKDMA(&huart1, hdmarx, hdma_usart1_rx); }注意这行宏:
__HAL_LINKDMA(&huart1, hdmarx, hdma_usart1_rx);它的作用是告诉HAL库:“以后调用HAL_UART_Receive_DMA()的时候,就用我定义的这个DMA句柄。”
没有这一步,后续启动DMA会失败。
第二步:配置DMA控制器
void MX_DMA_Init(void) { __HAL_RCC_DMA2_CLK_ENABLE(); // 必须先使能DMA2时钟! hdma_usart1_rx.Instance = DMA2_Stream2; // 使用DMA2 Stream2 hdma_usart1_rx.Init.Channel = DMA_CHANNEL_4; // UART1_RX属于Channel 4 hdma_usart1_rx.Init.Direction = DMA_PERIPH_TO_MEMORY; // 外设→内存 hdma_usart1_rx.Init.PeriphInc = DMA_PINC_DISABLE; // 外设地址不变(始终读DR寄存器) hdma_usart1_rx.Init.MemInc = DMA_MINC_ENABLE; // 内存地址递增 hdma_usart1_rx.Init.PeriphDataAlignment = DMA_PDATAALIGN_BYTE; hdma_usart1_rx.Init.MemDataAlignment = DMA_MDATAALIGN_BYTE; hdma_usart1_rx.Init.Mode = DMA_CIRCULAR; // 循环模式!重点! hdma_usart1_rx.Init.Priority = DMA_PRIORITY_HIGH; hdma_usart1_rx.Init.FIFOMode = DMA_FIFOMODE_DISABLE; // 简单应用可关闭 if (HAL_DMA_Init(&hdma_usart1_rx) != HAL_OK) { Error_Handler(); } // 配置中断优先级并使能 HAL_NVIC_SetPriority(DMA2_Stream2_IRQn, 0, 0); HAL_NVIC_EnableIRQ(DMA2_Stream2_IRQn); }📌 特别说明几个易错点:
-DMA_PERIPH_TO_MEMORY:表示数据从外设流向内存,即接收。
-PeriphInc = DISABLE:因为所有数据都来自USART1->DR,地址不变。
-Mode = DMA_CIRCULAR:缓冲区满后自动回卷,适合长期监听。
- 中断必须打开,否则无法感知“一半已满”或“全部填满”的时机。
第三步:启动DMA接收
只需要一行代码:
void Start_UART_DMA_Reception(void) { HAL_UART_Receive_DMA(&huart1, rx_buffer, 256); }执行之后,DMA就开始工作了。每当UART收到一个字节,硬件就会自动把它搬到rx_buffer里,直到填满256个字节。
期间CPU完全可以去做别的事,比如处理传感器数据、跑控制算法,甚至进入低功耗睡眠。
第四步:中断服务函数 & 回调处理
当DMA完成一半或全部传输时,会产生中断。我们在ISR中调用标准处理函数即可:
void DMA2_Stream2_IRQHandler(void) { HAL_DMA_IRQHandler(&hdma_usart1_rx); }然后利用HAL提供的回调函数插入业务逻辑:
void HAL_UART_RxHalfCpltCallback(UART_HandleTypeDef *huart) { if (huart->Instance == USART1) { // 前128字节已接收完毕,可以开始解析前半段命令 ParseCommand(rx_buffer, 128); } } void HAL_UART_RxCpltCallback(UART_HandleTypeDef *huart) { if (huart->Instance == USART1) { // 后128字节接收完成,处理后半部分 ParseCommand(rx_buffer + 128, 128); } }这两个回调分别在半传输完成和全传输完成时触发,非常适合做双缓冲数据处理。
例如你正在接收一个JSON指令包,可以在HalfCplt时预处理前半部分,在RxCplt时拼接完整并执行命令。
发送也能用DMA吗?当然!
发送同样可以用DMA,尤其适合批量发送大量数据,比如日志输出、波形上传、固件更新。
只需稍微改一下方向和实例:
DMA_HandleTypeDef hdma_usart1_tx; void MX_DMA_Tx_Init(void) { hdma_usart1_tx.Instance = DMA2_Stream7; hdma_usart1_tx.Init.Channel = DMA_CHANNEL_4; hdma_usart1_tx.Init.Direction = DMA_MEMORY_TO_PERIPH; hdma_usart1_tx.Init.PeriphInc = DMA_PINC_DISABLE; hdma_usart1_tx.Init.MemInc = DMA_MINC_ENABLE; hdma_usart1_tx.Init.PeriphDataAlignment = DMA_PDATAALIGN_BYTE; hdma_usart1_tx.Init.MemDataAlignment = DMA_MDATAALIGN_BYTE; hdma_usart1_tx.Init.Mode = DMA_NORMAL; // 发送一般不用循环 hdma_usart1_tx.Init.Priority = DMA_PRIORITY_MEDIUM; HAL_DMA_Init(&hdma_usart1_tx); __HAL_LINKDMA(&huart1, hdmatx, hdma_usart1_tx); // 绑定发送句柄 }发送调用也很简单:
uint8_t tx_data[] = "Hello over DMA!\r\n"; HAL_UART_Transmit_DMA(&huart1, tx_data, sizeof(tx_data));发送完成后也会触发HAL_UART_TxCpltCallback(),可用于通知“发送结束”。
实战经验分享:那些年踩过的坑
❌ 坑一:忘记开DMA时钟
__HAL_RCC_DMA2_CLK_ENABLE(); // 必须加!否则DMA不工作❌ 坑二:缓冲区未对齐导致HardFault
特别是使用FIFO模式时,要求内存地址按4字节对齐。声明缓冲区时建议加上对齐属性:
__ALIGN_BEGIN uint8_t rx_buffer[256] __ALIGN_END;或者用静态分配确保对齐。
❌ 坑三:没关优化导致变量被编译器优化掉
如果你在中断中修改了某个标志位,记得用volatile修饰:
volatile uint8_t dma_transfer_complete = 0;否则编译器可能认为这个变量没被使用而直接删掉。
✅ 秘籍:如何判断DMA还在运行?
查看状态寄存器:
if (__HAL_DMA_GET_FLAG(&hdma_usart1_rx, DMA_FLAG_TCIF2)) { // 传输已完成 }也可以用API查询:
if (HAL_DMA_GetState(&hdma_usart1_rx) == HAL_DMA_STATE_READY) { // DMA空闲 }更进一步:双缓冲模式真的无缝吗?
STM32的DMA支持双缓冲模式(Double Buffer Mode),只需设置Mode = DMA_DOUBLE_BUFFER_Memory并提供两个缓冲区指针。
启用后,DMA会在两个缓冲区间自动切换,CPU处理当前块的同时,DMA往另一个块写入新数据,真正做到“零等待”。
不过要注意:双缓冲只能用于循环模式,且初始化时就要指定两个缓冲区地址。
示例:
hdma_usart1_rx.Init.Mode = DMA_CIRCULAR; // 不是单独的DoubleBuffer宏 // 双缓冲需在启动函数中指定两块内存 HAL_UARTEx_ReceiveToIdle_DMA(&huart1, buffer1, buffer2, 128);具体接口因HAL版本略有差异,请查阅最新文档。
总结一下:DMA到底带来了什么?
- CPU解脱了:不再为每个字节奔波,专注核心任务;
- 系统更稳了:避免中断风暴,降低丢数据风险;
- 功耗更低了:CPU能更快进入Sleep模式;
- 实时性更强了:数据传输由硬件精确控制;
- 扩展性更好了:轻松应对ADC、I2S、SDMMC等高带宽需求。
掌握DMA,是你从“会写代码”迈向“懂系统设计”的重要一步。
下一步你可以尝试……
- 结合RTOS,在DMA回调中发送消息队列唤醒任务;
- 用DMA+ADC实现无损音频采集;
- 配合LTDC+DMA2D做图形界面刷新;
- 在STM32H7上体验MDMA带来的AXI总线级性能飞跃。
如果你也在用DMA解决实际问题,欢迎留言交流你的应用场景和调试心得!