STM32 串口发一个 7,却回了一屏 7:我绕进 HAL 源码后,才发现先该看 DMA 模式
STM32 串口发一个 7却回了一屏 7我绕进 HAL 源码后才发现先该看 DMA 模式最近调一个 STM32F103 的串口程序功能本来很简单电脑串口助手发一个字符7MCU 收到后回显同时模拟一次按键按下再打印rx_buffer[0]的内容。听起来没什么难度。结果一跑串口助手直接刷屏。我只发了一个7它却回了一屏7像串口自己突然学会了复读。一开始我以为是回调循环、串扰、中断没清干净甚至顺着 HAL 源码看了一大圈。最后才发现真正的问题不是 HAL 有多复杂而是我一开始就忽略了一个更基础的问题DMA 到底把数据写到了哪里这篇文章就把这次调试过程复盘一下。重点不是证明某个写法一定错而是讲清楚调 STM32 串口 DMA 时为什么要先看 DMA 模式再看 HAL 源码。现场现象发一个 7回显出一堆 7工程环境大概是这样芯片STM32F103C8T6串口USART1引脚PA9 做 TXPA10 做 RX接收方式HAL_UARTEx_ReceiveToIdle_DMA开发环境CubeMX VSCode目标功能串口收到字符后回显并根据字符模拟按键事件核心接收逻辑大概是这样uint8_t rx_buffer[128];HAL_UARTEx_ReceiveToIdle_DMA(huart1, rx_buffer, sizeof(rx_buffer));收到不定长数据后在回调里处理void HAL_UARTEx_RxEventCallback(UART_HandleTypeDef *huart, uint16_t Size){if(huart-InstanceUSART1){HAL_UART_Transmit(huart1, rx_buffer, Size,100);if(rx_buffer[0]7){s_key1.eventPRESSED_EVENT;}HAL_UARTEx_ReceiveToIdle_DMA(huart1, rx_buffer, sizeof(rx_buffer));}}主循环里再根据按键事件打印if(s_key1.eventPRESSED_EVENT){printf(%c\r\n, rx_buffer[0]);s_key1.eventKEY_EVENT_NONE;}问题就出在这里。串口助手只发一次7程序却像进入了某种循环回显、打印、再收到、再回显最后屏幕上刷出一堆7。绕了一圈我先怀疑了回调循环第一反应很自然是不是HAL_UARTEx_RxEventCallback()进了很多次因为用的是ReceiveToIdle_DMA收到一个字符后触发 IDLE 中断进入回调。在回调里又调用HAL_UART_Transmit(huart1, rx_buffer, Size,100);如果板子上 TX 和 RX 靠得很近比如 PA9 和 PA10 挨着或者接线、杜邦线、USB 转串口模块、面包板环境不太干净确实可能出现一点串扰。发送出去的字节被自己的 RX 端又“听”到了一点。于是思路就变成了是不是发送回显时TX 串扰到了 RX然后 RX 又触发了一次 DMA IDLE 回调这条怀疑不是瞎猜。串口线没接好、TX/RX 靠太近、地线不好、波特率边沿干扰这些都可能让接收端看到奇怪的东西。为了打断这个可能的循环我先后加过一堆保护逻辑。比如发送前先关 IDLE 中断__HAL_UART_DISABLE_IT(huart1, UART_IT_IDLE);暂停 DMA 接收HAL_UART_DMAPause(huart1);等 IDLE 状态、清数据寄存器、清 IDLE 标志while(!__HAL_UART_GET_FLAG(huart1, UART_FLAG_IDLE)){}__HAL_UART_FLUSH_DRREGISTER(huart1);__HAL_UART_CLEAR_IDLEFLAG(huart1);最后再恢复 DMAHAL_UART_DMAResume(huart1);__HAL_UART_ENABLE_IT(huart1, UART_IT_IDLE);这一套下来确实有用。打断点看回调只进了一次。这说明什么说明“回调反复进”这个问题被压住了或者至少不是当前继续刷屏的直接原因。但串口还是会输出很多字符。那就说明还有别的地方也在发。第二个坑main 循环里还有一次 printf继续打断点发现一个容易忽略的地方回调里已经回显了一次HAL_UART_Transmit(huart1, rx_buffer, Size,100);主循环里又打印了一次if(s_key1.eventPRESSED_EVENT){printf(%c\r\n, rx_buffer[0]);}而printf()最终也走同一个 USART1。也就是说整个系统里并不是只有回调在发串口主循环也在发串口。如果 DMA 接收已经重新启动主循环的printf()输出又被 RX 端接收到那么它也可能继续触发接收流程。这时候你会发现问题不再是单纯的“回调里能不能发串口”。它变成了一个链路问题串口助手发7↓ DMA 收到数据进入 RxEventCallback ↓ 回调里回显 ↓ 设置按键事件 ↓ main 循环printf↓ TX 侧输出又可能被 RX 侧收到 ↓ 新的接收事件出现所以先前那些保护逻辑并不是完全没价值。它们帮我确认了一件事回调本身没有无限进。但它们还没有触到根因。真正的问题藏在更底层我读rx_buffer[0]的假设和 DMA 的工作模式不匹配。真正的问题DMA 模式和读数据方式不匹配CubeMX 里 DMA 有一个非常关键的配置Normal 还是 Circular。我当时用的是 Circular。Circular 模式下DMA 的写指针会在缓冲区里循环移动。比如缓冲区是 128 字节它不是每次都从rx_buffer[0]开始写而是类似这样rx_buffer[0]rx_buffer[1]rx_buffer[2]... rx_buffer[127]rx_buffer[0]rx_buffer[1]...这对连续数据流很有用。比如你要一直收串口数据自己维护读指针、写指针、环形缓冲区解析协议那 Circular 很合适。但我的代码不是这么写的。我的代码假设数据永远从rx_buffer[0]开始HAL_UART_Transmit(huart1, rx_buffer, Size,100);以及if(rx_buffer[0]7){s_key1.eventPRESSED_EVENT;}这就矛盾了。Circular 模式可能把刚收到的字节写到rx_buffer[37]、rx_buffer[85]甚至任何一个当前位置。但我永远只看rx_buffer[0]。那结果就可能非常怪明明发了7rx_buffer[0]不一定是这次收到的7回显的数据可能不是最新一帧的真实位置有时看起来能触发有时又像隔一次才有反应串扰、回显、printf 叠在一起后现象会更乱最后的解决办法很简单把 DMA 模式从 Circular 改成 Normal。Normal 模式下每次重新调用HAL_UARTEx_ReceiveToIdle_DMA(huart1, rx_buffer, sizeof(rx_buffer));DMA 都从rx_buffer[0]重新开始写。收到 1 个字节它就在rx_buffer[0]收到 5 个字节它就在rx_buffer[0]~ rx_buffer[4]这样代码里的读取假设就成立了if(rx_buffer[0]7){s_key1.eventPRESSED_EVENT;}改成 Normal 后那些诡异现象基本都消失了发一个字符回一堆字符的问题消失数据错乱消失隔一次才有反应的问题消失回调和主循环的行为也更容易分析不是 HAL 突然变简单了。是 DMA 写数据的位置终于和代码读数据的位置对上了。Normal 和 Circular 到底该怎么选这块很容易被新手配错。不是说 Normal 一定好也不是说 Circular 一定坑。关键看你的代码怎么读数据。适合 Normal 的场景如果你的处理方式是“一帧一帧地收”每次收到 IDLE 后处理当前这帧然后重新开启接收HAL_UARTEx_ReceiveToIdle_DMA(huart1, rx_buffer, sizeof(rx_buffer));并且你希望每一帧都从rx_buffer[0]开始那么 Normal 更适合。典型写法是void HAL_UARTEx_RxEventCallback(UART_HandleTypeDef *huart, uint16_t Size){if(huart-Instance!USART1){return;}// 当前这一帧rx_buffer[0]到 rx_buffer[Size -1]ProcessFrame(rx_buffer, Size);// 重新从 rx_buffer[0]开始接下一帧 HAL_UARTEx_ReceiveToIdle_DMA(huart1, rx_buffer, sizeof(rx_buffer));}这类写法对初学者也更友好因为数据位置清楚。适合 Circular 的场景如果你要做持续接收不想每一帧都停下来重新启动 DMA而是让 DMA 一直收再通过读写指针去解析数据那么 Circular 就很合适。但这时候你不能再假设数据从rx_buffer[0]开始。你需要自己维护“上次读到哪里”和“当前 DMA 写到哪里”。示意逻辑大概是static uint16_t old_pos0;uint16_t new_pos;new_posUART_RX_BUFFER_SIZE - __HAL_DMA_GET_COUNTER(huart1.hdmarx);if(new_pos!old_pos){if(new_posold_pos){// 新数据在 old_pos 到 new_pos -1ProcessData(rx_buffer[old_pos], new_pos - old_pos);}else{// 发生回绕分两段处理 ProcessData(rx_buffer[old_pos], UART_RX_BUFFER_SIZE - old_pos);ProcessData(rx_buffer[0], new_pos);}old_posnew_pos;}这才是 Circular 模式的正确打开方式。如果只是把 DMA 配成 Circular但代码仍然写if(rx_buffer[0]7){}那就很容易进入玄学调试。HAL 源码该不该看该看。但顺序要对。这次调试里看 HAL 源码并不是没有收获。比如你能理解HAL_UART_IRQHandler()里 IDLE 标志是怎么处理的HAL_UARTEx_RxEventCallback()为什么会被调用UART_EndRxTransfer()会如何处理接收状态huart-RxState什么时候回到 READYHAL_UART_DMAResume()为什么可能看起来没有效果这些都值得学。但如果一开始没有确认 DMA 模式、缓冲区位置、读数据方式直接扎进 HAL 源码很容易把简单问题看复杂。就像这次我花了不少时间看 IDLE、DMA Pause、Resume、清 DR、清 IDLE 标志。这些都不是错的。但最终决定 Bug 是否消失的是 CubeMX 里那个 DMA ModeCircular -Normal所以更合理的调试顺序应该是先确认物理层TX/RX/GND、波特率、串口助手配置再确认 DMA 模式Normal 还是 Circular再确认数据位置新数据到底在rx_buffer的哪个下标再确认业务逻辑回调和 main 循环是否都在发串口最后再看 HAL 源码状态机、中断标志、DMA 状态是否符合预期顺序很重要。基础假设错了看再多源码也容易绕。以后遇到类似问题按这个顺序查如果你以后也遇到 STM32 UART DMA IDLE 接收异常可以按这个清单排1. 先问我到底想怎么收如果你想一帧一帧接收收到一帧 -处理 -重新开启接收优先考虑 Normal。如果你想连续接收流式数据DMA 一直接收 -自己维护读写指针 -环形解析再考虑 Circular。2. 再问我的代码假设数据在哪如果代码里大量出现rx_buffer[0]那你就要非常警惕。因为这意味着你的代码默认“最新数据从 0 开始”。Normal 可以满足这个假设。Circular 不一定满足。3. 检查回调里做了什么回调里可以做轻量处理但不要随便堆太多逻辑。尤其是HAL_UART_Transmit()printf()重新开启 DMA 修改全局事件 解析协议这些混在一起时问题会很难看。建议回调里先做三件事保存 Size 拷贝或标记数据 置一个事件标志复杂业务放到主循环或任务里。4. 确认系统里到底有几个地方在发串口这次就踩在这里。回调里发一次main 循环里又printf()一次。如果你正在排查“为什么串口一直输出”不要只看回调也要搜HAL_UART_Transmitprintfputs 日志宏 调试打印函数很多时候不是一个地方在发。5. 最后再看 HAL 源码HAL 源码不是不能看。但建议在下面这些问题都确认之后再看DMA 模式对不对 数据位置对不对 Size 是否符合预期 回调是否真的反复进入 main 循环是否也在发送 串扰是否存在这样看源码才有方向。一个更稳的最小写法如果你的目标只是“串口收到一帧处理一帧”可以先用 Normal 模式把逻辑写简单一点。初始化时开启接收#define UART_RX_BUF_SIZE 128uint8_t rx_buffer[UART_RX_BUF_SIZE];volatile uint8_t uart_rx_event0;volatile uint16_t uart_rx_size0;void App_UART_StartReceive(void){HAL_UARTEx_ReceiveToIdle_DMA(huart1, rx_buffer, UART_RX_BUF_SIZE);}回调里只记录事件void HAL_UARTEx_RxEventCallback(UART_HandleTypeDef *huart, uint16_t Size){if(huart-Instance!USART1){return;}uart_rx_sizeSize;uart_rx_event1;}主循环里处理if(uart_rx_event){uart_rx_event0;if(uart_rx_size0){HAL_UART_Transmit(huart1, rx_buffer, uart_rx_size,100);if(rx_buffer[0]7){s_key1.eventPRESSED_EVENT;}}HAL_UARTEx_ReceiveToIdle_DMA(huart1, rx_buffer, UART_RX_BUF_SIZE);}这个写法不是最高级但对初学阶段很清楚DMA Normal 模式每次从rx_buffer[0]开始收回调只做标记主循环统一处理业务处理完再重新开启接收等这个跑稳定了再考虑 Circular、环形缓冲区、协议解析、RTOS 消息队列这些升级版。小站总结这次 Bug 表面看是串口疯狂回显。中间看起来像串扰、IDLE 中断、DMA Pause/Resume、HAL 状态机的问题。但真正的根因是代码和 DMA 模式没有对齐代码假设新数据在 rx_buffer[0]DMA Circular新数据可能在任意位置两边一错位后面所有现象都会变得像玄学。所以调 STM32 串口 DMA我建议先记住一句话先确认数据在哪再追中断怎么进。HAL 源码值得看但它不是第一步。第一步永远是你的工程假设我配的是 Normal 还是 Circular我代码读的是哪个下标这两个东西是否匹配这一步想清楚很多串口 Bug 会少绕一大圈。你们调HAL_UARTEx_ReceiveToIdle_DMA时有没有遇到过“发一个字符回一堆字符”或者“隔一次才收到”的情况如果有建议先回去看一眼 DMA 模式说不定答案就在 CubeMX 那个下拉框里。