珠海市网站建设_网站建设公司_博客网站_seo优化
2025/12/25 8:00:22 网站建设 项目流程

从轮询到DMA:让串口通信真正“解放”CPU

你有没有遇到过这种情况?系统里接了个GPS模块,波特率115200,数据源源不断地来。你用中断方式接收,每来一个字节就进一次中断——结果CPU几乎一半时间都花在进/出中断上了,主循环卡顿、任务延迟,连LED闪烁都不流畅了。

这正是我刚做嵌入式时踩过的坑。

直到有一天,同事看了我的代码,只问了一句:“你怎么还在用中断收串口?不会上DMA吗?”那一刻我才意识到:原来我一直停留在“入门级”玩法。

今天,我们就来彻底搞明白一件事:如何用UART+DMA实现高效、低负载的串口通信。不讲虚的,从问题出发,带你一步步从零搭建可运行的工程框架,并理解背后的每一个设计决策。


为什么传统方式撑不住高吞吐场景?

先说清楚痛点,才能理解为什么需要DMA。

轮询:最原始的方式

while (1) { if (USART1->SR & USART_SR_RXNE) { rx_buf[i++] = USART1->DR; } }

这种方式简单直观,但有个致命问题:CPU必须一直盯着SR寄存器。哪怕没数据,它也得不停查。系统干不了别的事。

中断:进步了一步

void USART1_IRQHandler(void) { if (USART1->SR & USART_SR_RXNE) { rx_buffer[rx_index++] = USART1->DR; } }

看起来不错——有数据才响应。但当波特率达到115200甚至更高时,每秒可能要触发上万个中断。每次中断都有上下文保存、跳转、恢复开销。频繁中断会严重撕裂CPU的时间片,导致实时性下降。

更麻烦的是,如果多个外设同时产生高频中断,系统很容易陷入“中断风暴”,甚至丢包。

那怎么办?

答案是:把搬运数据这件事交给别人干——这个人就是DMA(Direct Memory Access)


DMA不是魔法,而是“专职搬运工”

你可以把CPU想象成公司老板,UART外设像门口的快递员,而内存里的缓冲区是你办公室的文件柜。

  • 轮询模式:老板每隔几秒钟跑一趟门口看有没有新快递;
  • 中断模式:快递一到就打电话叫老板去拿;
  • DMA模式:雇了个行政助理,快递来了直接由他登记并放进文件柜,老板只在整批文件处理完后过问一句。

这个“行政助理”就是DMA控制器。

它能做什么?

  • 自动从内存读数据送到UART发送寄存器(TDR);
  • 自动把UART接收到的数据存入指定内存区域(RDR → RAM);
  • 整个过程无需CPU插手,只在开始和结束时打个招呼。

这意味着什么?意味着你的CPU可以安心去做协议解析、算法计算、UI刷新这些更有价值的事。


UART+DMA 实战配置:以STM32为例

我们以最常见的STM32F4系列为例,使用HAL库实现一个完整的UART+DMA收发系统。目标很明确:

✅ 实现非阻塞发送
✅ 循环接收不定长数据
✅ 利用空闲中断精准截断每一帧

第一步:硬件资源确认

假设我们使用:
- USART1,TX=PA9, RX=PA10
- DMA通道:发送用DMA2_Stream7,接收用DMA2_Stream5
- 主控芯片:STM32F407VG

这些信息一定要查手册确认,不同型号映射关系不一样。


第二步:初始化UART + DMA发送

UART_HandleTypeDef huart1; DMA_HandleTypeDef hdma_usart1_tx; DMA_HandleTypeDef hdma_usart1_rx; uint8_t tx_buffer[] = "Hello World via UART+DMA!\r\n";
初始化UART参数
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_TX_RX; huart1.Init.HwFlowCtl = UART_HWCONTROL_NONE; huart1.Init.OverSampling = UART_OVERSAMPLING_16; if (HAL_UART_Init(&huart1) != HAL_OK) { Error_Handler(); }

⚠️ 注意:OverSampling会影响波特率精度,一般保持默认即可。


配置DMA发送通道
__HAL_RCC_DMA2_CLK_ENABLE(); 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; hdma_usart1_tx.Init.FIFOMode = DMA_FIFOMODE_DISABLE; HAL_DMA_Init(&hdma_usart1_tx); // 关联DMA句柄到UART __HAL_LINKDMA(&huart1, hdmatx, hdma_usart1_tx);

关键点解释:

  • PeriphInc = DISABLE:因为我们要一直往同一个寄存器(TDR)写;
  • MemInc = ENABLE:源数据在内存中逐字节前进;
  • Mode = NORMAL:单次传输,发完即止;
  • __HAL_LINKDMA:这是HAL库的关键宏,让UART知道该找哪个DMA干活。

启动DMA发送
HAL_UART_Transmit_DMA(&huart1, tx_buffer, sizeof(tx_buffer) - 1);

调用这一行之后,DMA就开始自动搬数据了。函数立即返回,不阻塞!
你可以在发送的同时去做其他事情,比如采集传感器、更新屏幕。


第三步:实现高效接收 —— 循环DMA + 空闲中断

这才是重头戏。大多数应用中,我们需要持续监听来自外部设备的数据流(如蓝牙模块、GPS、PLC等),而且每条消息长度不固定。

经典方案:循环模式 + IDLE中断

思路如下:
1. 开启DMA循环接收,缓冲区填满后自动从头开始;
2. 同时开启UART的空闲线检测中断(IDLE Interrupt)
3. 当一段时间没有新数据到来时,触发IDLE中断;
4. 此时通过DMA计数器算出已接收字节数,提取有效数据;
5. 重新启动DMA接收,形成闭环。

这种方法既避免了高频中断,又能准确截断每一帧数据。


配置DMA接收通道
hdma_usart1_rx.Instance = DMA2_Stream5; hdma_usart1_rx.Init.Channel = DMA_CHANNEL_4; hdma_usart1_rx.Init.Direction = DMA_PERIPH_TO_MEMORY; hdma_usart1_rx.Init.PeriphInc = DMA_PINC_DISABLE; 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; HAL_DMA_Init(&hdma_usart1_rx); __HAL_LINKDMA(&huart1, hdmarx, hdma_usart1_rx);

注意这里的Mode = DMA_CIRCULAR,表示缓冲区满了不会停止,而是回绕继续写。


定义接收缓冲区
#define RX_BUFFER_SIZE 64 uint8_t rx_buffer[RX_BUFFER_SIZE];

建议将缓冲区定义为全局变量,确保位于SRAM且不会被栈溢出覆盖。


启动循环接收 + 使能IDLE中断
void Start_Receiving(void) { HAL_UART_Receive_DMA(&huart1, rx_buffer, RX_BUFFER_SIZE); __HAL_UART_ENABLE_IT(&huart1, UART_IT_IDLE); // 关键!开启空闲中断 }

在中断服务程序中处理IDLE事件

你需要在stm32f4xx_it.c中找到USART1_IRQHandler并添加处理逻辑:

void USART1_IRQHandler(void) { HAL_UART_IRQHandler(&huart1); // 让HAL库处理基础中断 }

然后在用户回调函数中捕获IDLE中断:

void HAL_UART_RxCpltCallback(UART_HandleTypeDef *huart) { // DMA传输完成回调(仅用于Normal模式) } void HAL_UART_ErrorCallback(UART_HandleTypeDef *huart) { // 错误处理,例如溢出、噪声等 HAL_UART_DMAStop(huart); __HAL_UART_CLEAR_ORE_FLAG(huart); // 清除溢出标志 Start_Receiving(); // 重启接收 } // 这个是重点!空闲中断发生时调用 void HAL_UARTEx_RxEventCallback(UART_HandleTypeDef *huart, uint16_t Size) { if (huart->Instance == USART1) { // Size 是本次接收到的有效字节数 Process_Received_Data(rx_buffer, Size); // 重新启动DMA接收 HAL_UARTEx_ReceiveToIdle_DMA(&huart1, rx_buffer, RX_BUFFER_SIZE); } }

等等,这里用了HAL_UARTEx_ReceiveToIdle_DMA?没错!

从STM32CubeMX生成的工程来看,HAL库提供了一个更高级的API:

HAL_UARTEx_ReceiveToIdle_DMA(&huart1, rx_buffer, RX_BUFFER_SIZE);

它可以自动管理IDLE中断与DMA的配合,无需手动计算计数器,大大简化开发!

如果你用的是旧版HAL库,可以用下面这种方式手动实现:

uint32_t tmpcndtr; uint8_t received_len; __HAL_UART_DISABLE_IT(&huart1, UART_IT_IDLE); // 先关闭,防止重复触发 // 停止DMA以便读取当前计数器 HAL_DMA_Abort(&hdma_usart1_rx); tmpcndtr = __HAL_DMA_GET_COUNTER(&hdma_usart1_rx); received_len = RX_BUFFER_SIZE - tmpcndtr; if (received_len > 0) { Process_Received_Data(rx_buffer, received_len); } // 清空中断标志并重启 __HAL_UART_CLEAR_IDLEFLAG(&huart1); HAL_UART_Receive_DMA(&huart1, rx_buffer, RX_BUFFER_SIZE);

实际应用场景:GPS数据接收

设想你在做一个车载定位终端,GPS模块以4800bps持续输出NMEA语句:

$GPGGA,123519,4807.038,N,01131.000,E,1,08,0.9,545.4,M,46.9,M,,*47 $GPRMC,123519,A,4807.038,N,01131.000,E,022.4,084.4,230394,003.1,W*6A

每条语句以\r\n结尾,长度不一。

使用上述DMA循环 + IDLE中断方案,完美匹配这种“不定长+周期性”的数据流。
IDLE中断会在每个句子结束后触发(通常间隔几十毫秒),此时你能拿到完整的一行数据,直接交给解析函数处理即可。

整个过程中,CPU占用率低于5%,即使同时运行FreeRTOS、LCD刷新、CAN通信也不受影响。


常见“坑”与调试秘籍

别以为配完就能跑通。以下是新手最容易栽的几个坑:

❌ 坑1:缓冲区定义在栈上

void start_dma(void) { uint8_t stack_buf[64]; // 危险!函数退出后栈空间可能被覆盖 HAL_UART_Receive_DMA(&huart1, stack_buf, 64); // DMA还在运行,但内存已无效 }

✅ 正确做法:定义为静态或全局变量。


❌ 坑2:忘记开启DMA时钟

// 必须加这句! __HAL_RCC_DMA2_CLK_ENABLE();

否则DMA根本不会工作,但编译链接都不会报错。


❌ 坑3:DMA计数器方向反了

HAL库的__HAL_DMA_GET_COUNTER()返回的是剩余未传输数量,所以实际接收字节数 = 总长度 - 当前计数值。

别搞反了!


❌ 坑4:未清除IDLE标志导致反复进入中断

__HAL_UART_CLEAR_IDLEFLAG(&huart1);

每次处理完必须清标志,否则会无限触发。


✅ 调试技巧推荐

  1. 用逻辑分析仪抓波形:验证是否真的发出去了;
  2. 打印DMA计数器值:观察是否随数据流入而递减;
  3. 设置软件断点:在回调函数中检查接收到的数据;
  4. 启用串口错误中断:监控ORE(溢出)、NE(噪声)等异常;

工程最佳实践清单

项目推荐做法
缓冲区位置使用静态分配,避免栈或动态内存
缓冲区大小至少大于最大预期帧长,建议64/128/256
DMA模式选择固定长度→Normal;流式数据→Circular
中断优先级UART/DMA中断优先级应高于普通任务
错误恢复出现ORE时重置DMA和UART状态
低功耗兼容若需睡眠,确保DMA能唤醒MCU
可维护性封装成独立模块,支持多串口复用

写在最后:这是通往高性能系统的起点

掌握UART+DMA,不只是学会了一个技术点,更是思维方式的转变:

不要让CPU去做可以交给硬件的事。

当你熟练运用DMA后,你会发现类似的模式无处不在:
- ADC采样 → DMA → 数据处理
- I2S音频 → DMA → 缓冲播放
- SDIO读写 → DMA → 文件系统交互

它们的本质都是“让专用硬件各司其职,CPU只负责调度与决策”。

而对于初学者来说,串口DMA正是这条进化之路的第一个里程碑

现在,打开你的IDE,新建一个工程,亲手配置第一个UART+DMA吧。
哪怕只是发一句"Hello DMA!",那也是你迈向高效嵌入式系统设计的第一步。

如果你在实现过程中遇到了具体问题——比如DMA不启动、IDLE中断不触发、数据错乱——欢迎留言交流,我们一起排查。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询