串口DMA实战:如何让MCU在后台“默默”收数据,CPU却几乎不干活?
你有没有遇到过这种情况:
你的STM32正在通过串口接收GPS数据或蓝牙模块的报文,波特率一上115200,系统就“卡顿”了?调试发现,中断服务函数每87微秒就被触发一次——没错,就是每个字节来一次!CPU疲于奔命地搬数据,根本没空干正事。
这不只是性能问题,更是架构隐患。当主循环被频繁打断,实时性下降、任务延迟、甚至丢包都可能发生。
那有没有办法让串口自己把数据存好,等一整包来了再叫你?有——用DMA(Direct Memory Access) + IDLE线检测。
今天我们就从零开始,手把手带你配置串口DMA接收,彻底解放CPU,实现高效、稳定、低功耗的串行通信。
为什么传统中断方式扛不住高波特率?
先看个现实场景:
- 波特率:115200 bps
- 每帧10位(起始+8数据+停止)
- 每秒传输约 11,520 字节
- 平均每87μs就有一个字节到达
如果使用普通中断方式(UART_IRQHandler中读DR寄存器),意味着:
✅ 每隔不到90微秒,CPU就要停下当前工作,跳转到中断服务程序,保存上下文、读寄存器、写缓冲、更新指针……然后再返回。
这种“每字节一中断”的模式,在高速通信下会迅速吃掉大量CPU资源。更糟糕的是,一旦有更高优先级的中断抢占,还可能造成溢出错误(ORE),直接丢数据!
所以,我们迫切需要一种机制:
👉 让硬件自动完成“从USART_DR寄存器拿数据 → 存进内存”的过程,
👉 而CPU只在“一整段数据收完”时才介入处理。
这就是DMA 的使命。
DMA 是什么?它怎么帮我们“偷懒”?
简单说,DMA 就是一个独立的数据搬运工。
它可以在没有CPU参与的情况下,把数据从一个地方搬到另一个地方。比如:
- 外设 → 内存(如 USART_RX → RAM 缓冲区)
- 内存 → 外设(如 发送缓冲区 → SPI_TDR)
- 内存 → 内存(类似
memcpy,但更快)
在 STM32 这类 MCU 中,DMA 控制器是独立外设,有自己的通道、地址管理、传输计数和中断逻辑。
它是怎么工作的?
想象一下流水线打包车间:
- 提前布置好任务:告诉 DMA,“你要从哪个入口取货(源地址),送到哪个货架(目标地址),一共搬多少件(长度)”
- 触发信号来了就开工:当 USART 收到一个字节,会发出一个“请帮我搬走”的请求(DMA Request)
- DMA 接管总线,自己动手搬:它直接访问外设寄存器和内存,不需要 CPU 插手
- 搬完了打个招呼:可以通过中断通知 CPU:“我这边满了/一半了/出错了”
整个过程,CPU 只需在开始前设置参数,结束后处理数据,中间完全不用管。
关键优势一览:DMA vs 中断
| 维度 | 中断方式 | DMA 方式 |
|---|---|---|
| CPU 占用 | 高(每次数据都要进 ISR) | 极低(仅启动和结束介入) |
| 最大吞吐 | 受限于中断响应速度 | 接近物理极限 |
| 实时性 | 易受其他中断干扰 | 更稳定可控 |
| 缓冲管理 | 手动维护环形缓冲 | 支持自动循环、双缓冲 |
| 系统可扩展性 | 多外设易引发中断风暴 | 多通道并行,互不影响 |
别小看这些差异。在一个运行 FreeRTOS 的系统中,频繁中断会导致调度抖动;而在电池供电设备中,CPU 长时间无法休眠会大幅缩短续航。
如何配置串口DMA?一步步来
下面我们以STM32F4xx + HAL 库为例,演示如何为USART2_RX配置 DMA 接收。
第一步:初始化 UART 基础功能
UART_HandleTypeDef huart2; DMA_HandleTypeDef hdma_usart2_rx; uint8_t rx_buffer[256]; // DMA 接收缓冲区 void UART_DMA_Init(void) { // 配置 UART2 参数 huart2.Instance = USART2; huart2.Init.BaudRate = 115200; huart2.Init.WordLength = UART_WORDLENGTH_8B; huart2.Init.StopBits = UART_STOPBITS_1; huart2.Init.Parity = UART_PARITY_NONE; huart2.Init.Mode = UART_MODE_RX; // 只启用接收 huart2.Init.HwFlowCtl = UART_HWCONTROL_NONE; HAL_UART_Init(&huart2); }这部分很常规,设定波特率、数据格式等。注意这里只开了 RX 模式,因为我们聚焦接收优化。
第二步:配置 DMA 通道
// 开启 DMA1 时钟 __HAL_RCC_DMA1_CLK_ENABLE(); // 配置 DMA 句柄 hdma_usart2_rx.Instance = DMA1_Stream5; // STM32F4: USART2_RX 映射到 DMA1 Stream5 hdma_usart2_rx.Init.Channel = DMA_CHANNEL_4; // 通道选择 hdma_usart2_rx.Init.Direction = DMA_PERIPH_TO_MEMORY; // 外设→内存 hdma_usart2_rx.Init.PeriphInc = DMA_PINC_DISABLE; // 外设地址不变(始终读 DR) hdma_usart2_rx.Init.MemInc = DMA_MINC_ENABLE; // 内存地址递增 hdma_usart2_rx.Init.PeriphDataAlignment = DMA_PDATAALIGN_BYTE; // 字节对齐 hdma_usart2_rx.Init.MemDataAlignment = DMA_MDATAALIGN_BYTE; hdma_usart2_rx.Init.Mode = DMA_CIRCULAR; // 启用循环模式!关键点 hdma_usart2_rx.Init.Priority = DMA_PRIORITY_HIGH; // 优先级设高些 hdma_usart2_rx.Init.FIFOMode = DMA_FIFOMODE_DISABLE; // FIFO 不开启(F4 默认关) HAL_DMA_Init(&hdma_usart2_rx);重点解释几个关键配置:
DMA_PERIPH_TO_MEMORY:数据流向是从外设(USART_DR)到内存(rx_buffer)PeriphInc = DISABLE:因为每次都是从同一个寄存器DR读数据,地址固定MemInc = ENABLE:每写一个字节,缓冲区指针自动加一Mode = DMA_CIRCULAR:循环模式!这是实现无限接收的关键。缓冲区满后不会停止,而是重新从头填Priority = HIGH:确保在多请求时能及时响应,避免丢数据
第三步:关联 DMA 到 UART,并启动接收
// 将 DMA 句柄链接到 UART 句柄 __HAL_LINKDMA(&huart2, hdmarx, hdma_usart2_rx); // 启动 DMA 接收 HAL_UART_Receive_DMA(&huart2, rx_buffer, sizeof(rx_buffer));这两行看似简单,实则至关重要:
__HAL_LINKDMA()是宏操作,把huart2.hdmarx指向我们刚配置好的hdma_usart2_rxHAL_UART_Receive_DMA()会:- 清除相关标志位
- 设置传输数据量(NDTR)
- 启动 DMA 流
- 使能 USART 的
DMAR位,允许其发出 DMA 请求
从此以后,只要收到数据,DMA 就会自动把它塞进rx_buffer,无需任何中断!
怎么知道“一包数据已经收完”?靠 IDLE 中断!
虽然 DMA 能持续接收,但我们不能等到缓冲区满了才处理——大多数协议是不定长的(比如 Modbus RTU、自定义 JSON 包)。
这时候就需要一个“帧结束”信号。最佳方案就是:空闲线检测(IDLE Line Detection)
什么是 IDLE 中断?
当串行线上连续一段时间没有新数据到来(通常是 3.5~10 个字符时间),USART 会产生一个 IDLE 标志。这个特性正好用来识别“一帧结束了”。
举个例子:
设备发送了一串 Modbus 报文,发完后线路静默。此时触发 IDLE 中断,我们可以认为这一帧完整收到了。
如何启用 IDLE 中断?
// 在初始化 UART 后启用 IDLE 中断 __HAL_UART_ENABLE_IT(&huart2, UART_IT_IDLE);然后在中断服务程序中处理:
void USART2_IRQHandler(void) { HAL_UART_IRQHandler(&huart2); // 调用 HAL 中断处理框架 } // 实际处理放在回调函数中 void HAL_UART_RxCpltCallback(UART_HandleTypeDef *huart) { // 此回调不会被调用 —— 因为我们用的是循环模式,永远不会“完成” } // 但这个回调会被 IDLE 触发! void HAL_UART_IdleCpltCallback(UART_HandleTypeDef *huart) { if (huart->Instance == USART2) { // 获取当前已接收的数据长度 uint16_t remain = __HAL_DMA_GET_COUNTER(&hdma_usart2_rx); uint16_t received = sizeof(rx_buffer) - remain; // 提取有效数据进行解析 ProcessReceivedFrame(rx_buffer, received); // 清除 IDLE 标志(必须手动清) __HAL_UART_CLEAR_IDLEFLAG(&huart2); // 可选:重置 NDTR(如果是非循环模式才需要) // HAL_UART_Receive_DMA(&huart2, rx_buffer, sizeof(rx_buffer)); } }⚠️ 注意:
HAL_UART_IdleCpltCallback并不是标准 HAL 函数,你需要自己定义并在stm32f4xx_it.c的USART2_IRQHandler中判断是否为 IDLE 触发。
或者更稳妥的做法是在中断服务函数中手动判断:
void USART2_IRQHandler(void) { if (__HAL_UART_GET_FLAG(&huart2, UART_FLAG_IDLE)) { __HAL_UART_CLEAR_IDLEFLAG(&huart2); // 先清标志 // 停止 DMA 以防止边读边改 HAL_UART_DMAStop(&huart2); uint16_t remain = hdma_usart2_rx.Instance->NDTR; uint16_t len = sizeof(rx_buffer) - remain; if (len > 0) { ProcessReceivedFrame(rx_buffer, len); } // 重启 DMA(保持循环接收) HAL_UART_Receive_DMA(&huart2, rx_buffer, sizeof(rx_buffer)); } else { HAL_UART_IRQHandler(&huart2); // 其他情况交给 HAL 处理 } }这样就能精准捕获每一帧的结尾,且无需依赖超时定时器,效率更高、响应更快。
如何防止缓冲区覆盖?三大防护策略
循环模式虽好,但也带来一个问题:如果 CPU 处理不及时,DMA 可能把还没处理的数据覆盖掉。
以下是三种实用解决方案:
✅ 方案一:启用半传输中断(Half Transfer Interrupt)
将缓冲区分成前后两半。当填到一半时,触发 HT 中断,提醒 CPU “前半区快满了,请尽快处理”。
// 初始化时开启 HT 中断 hdma_usart2_rx.Init.Mode = DMA_CIRCULAR; hdma_usart2_rx.Init.Priority = DMA_PRIORITY_HIGH; // ... HAL_DMA_Init(&hdma_usart2_rx); // 启用中断 HAL_NVIC_SetPriority(DMA1_Stream5_IRQn, 1, 0); HAL_NVIC_EnableIRQ(DMA1_Stream5_IRQn); // 启动传输前开启中断 __HAL_DMA_ENABLE_IT(&hdma_usart2_rx, DMA_IT_HT | DMA_IT_TC);然后实现中断处理:
void DMA1_Stream5_IRQHandler(void) { HAL_DMA_IRQHandler(&hdma_usart2_rx); } // HAL 回调 void HAL_DMA_HalfTransferCplt(DMA_HandleTypeDef *hdma) { if (hdma == &hdma_usart2_rx) { // 前半区已满,处理前128字节 ProcessReceivedData(rx_buffer, 128); } } void HAL_DMA_TransferComplete(DMA_HandleTypeDef *hdma) { if (hdma == &hdma_usart2_rx) { // 后半区已满,处理后128字节 ProcessReceivedData(rx_buffer + 128, 128); } }这样相当于实现了“伪双缓冲”,适合中等速率数据流。
✅ 方案二:使用双缓冲模式(Double Buffer Mode)
部分高端芯片(如 STM32H7/F7/F4)支持真正的双缓冲。DMA 在两个缓冲区间自动切换,每次填满一块就切换并产生中断。
配置方式略有不同:
hdma_usart2_rx.Init.Mode = DMA_DOUBLE_BUFFER_M; // 而不是 CIRCULAR // 还需额外指定第二个缓冲区 uint8_t rx_buffer_a[256], rx_buffer_b[256]; hdma_usart2_rx.XferCpltCallback = DMA_DualBufferComplete;优点非常明显:
- 一块被 DMA 写入时,另一块可以安全被 CPU 读取
- 实现真正意义上的“零等待、无锁访问”
缺点是占用双倍内存,且 HAL 库支持不够直观,需深入底层寄存器控制。
✅ 方案三:结合 RTOS 机制(推荐用于复杂系统)
如果你用了 FreeRTOS 或其他 OS,可以用队列/信号量解耦:
QueueHandle_t uart_queue; void HAL_UART_IdleCpltCallback(...) { xQueueSendFromISR(uart_queue, &frame_info, NULL); } // 单独任务处理 void UartProcessTask(void *pv) { FrameInfo info; while (1) { if (xQueueReceive(uart_queue, &info, portMAX_DELAY)) { parse_and_handle(info.data, info.len); } } }既保证实时性,又避免在中断里做耗时操作。
工程实践建议:这些细节决定成败
| 项目 | 建议 |
|---|---|
| 缓冲区大小 | 至少是最大帧长的 2 倍,推荐 256~1024 字节 |
| 内存对齐 | 缓冲区起始地址应 4 字节对齐,避免 DMA 总线错误 |
| DMA 优先级 | 设为 High 或 Very High,避免被其他 DMA 阻塞 |
| 错误处理 | 监听DMA_FLAG_TE(传输错误),必要时复位通道 |
| 调试技巧 | 暂时关闭 DMA,观察原始中断行为,定位问题源头 |
| 低功耗场景 | CPU 可进入 Sleep/WFI,仅靠 DMA 和中断唤醒 |
此外,强烈建议在关键节点添加日志或 LED 指示灯,便于验证流程是否正常。
结语:掌握 DMA,才算真正入门嵌入式系统设计
你看,原本每87微秒就要打扰一次CPU的任务,现在变成了“安静地把数据存好,等你有空再来取”。
这不是简单的代码替换,而是一种系统思维的升级。
当你学会把“数据搬运”这类重复劳动交给硬件协处理器(DMA),你才有精力去关注更重要的事:协议解析、状态机设计、功耗优化、系统稳定性……
未来随着 RISC-V、自研内核的发展,轻量级 DMA 协处理器、链表式描述符(LLI)、动态通道映射等技术也会逐步普及。但无论架构如何演进,理解“外设与内存之间的自主传输机制”,永远是嵌入式工程师的核心能力之一。
如果你正在做一个需要长时间接收串口数据的项目,不妨试试今天这套“DMA + IDLE”组合拳。你会发现,原来 MCU 可以这么“从容”。
如果你在实现过程中遇到了具体问题(比如 NDTR 读出来不对、IDLE 不触发、DMA 死掉),欢迎留言讨论,我们一起排查。