STM32 串口连续接收“假死“:一个判错 DMA 通道的低级错误
单帧发送一切正常,一连发就卡住——这种 bug 最磨人。这篇记一下前阵子踩的一个坑:现象看着像协议解析挂了,实际是发送完成中断判错了 DMA 通道的标志,结果把接收这条线整个锁死。
现象
协议很简单,就是帧头 + 命令字 + 数据 + 帧尾。接收走中断,每来一个字节塞进软件 FIFO,主循环再从 FIFO 里取出来解析。
单帧发没问题。可上位机一连发多帧,就会出现:前几帧解析正常,到某一帧之后本地数据不再更新,但串口工具还在不停地发,MCU 也没复位、没进异常。看上去,就是解析流程突然不动了。
收发那几段代码长这样:
// 接收中断:每来一字节入 FIFOvoidUSARTx_IRQHandler(void){if(UART_RX_NOT_EMPTY)EnFifo(&uart_rx_fifo,READ_UART_DATA());if(UART_IDLE_DETECTED){uart_tx_busy=0;// 注意这句:收到帧间空闲,却顺手清了"发送"忙标志CLEAR_UART_IDLE_FLAG();}}// 主循环:发送不忙时才解析voidProtocol_Handle(void){uint8_tdata;if(uart_tx_busy==0&&DeFifo(&uart_rx_fifo,&data))Protocol_Parse(data);}// 解析成功后用 DMA 回 ACKvoidUart_SendAck(void){uart_tx_busy=1;HAL_UART_Transmit_DMA(&huart,tx_buf,len);}这里先记住一点:主循环要不要解析,卡在uart_tx_busy这一个标志上。后面的麻烦全从这儿来。
怎么找到的
挂上 debug 单步跟,很快就缩小了范围。串口接收中断照常进,字节也确实进了 FIFO,说明收这一侧没问题;但DeFifo后来再没被调到,解析停在了入口之前。盯住那个入口条件一看,uart_tx_busy连发的时候卡在 1 再也没回过 0。
再往 ACK 发送那条线追。在清零那行打个断点,连发时根本不命中;同时拿逻辑分析仪扫一眼 TX 脚,ACK 明明早就发完了。也就是说,DMA 传输是正常完成的,只是"发完之后清忙"这一步没执行。问题这下指向了发送完成中断。
真正的原因:通道判错了
进中断一核对,破绽就出来了。以 F1、USART1_TX走DMA1_Channel4为例,原代码大致是这样:
voidDMA1_Channel4_IRQHandler(void){// 实际是通道 4,这里却判了通道 5 的 TC 标志if(__HAL_DMA_GET_FLAG(&hdma_uart_tx,DMA_FLAG_TC5)!=RESET){__HAL_DMA_CLEAR_FLAG(&hdma_uart_tx,DMA_FLAG_TC5);uart_tx_busy=0;// ← 这句永远进不来}HAL_DMA_IRQHandler(&hdma_uart_tx);}通道 4 的活,却判通道 5 的完成标志,条件永远不成立,uart_tx_busy = 0这句从来没跑过。F4/F7 是"流 + TCIFx_y 组合标志",把 Stream 号或者宏写错,是同一个坑的另一副长相。
不过这里有个前提得说清楚,不然懂行的会觉得逻辑对不上:这段代码末尾还调了HAL_DMA_IRQHandler,如果句柄绑定的通道是对的,HAL 自己就会识别完成、回调HAL_UART_TxCpltCallback——那"手动判错通道"其实不会卡死,HAL 会兜底。所以这个 bug 能成立,要么是发送完成处理完全寄存器手写、没接 HAL 回调,要么是压根没实现TxCpltCallback、清忙全靠上面那段手判。我这边属于后者。
捋顺了就明白卡在哪了:发 ACK 前uart_tx_busy置 1,发完没清掉,于是主循环里if (uart_tx_busy == 0)永远过不去。这边串口中断还在收、还在往 FIFO 里塞,那边主循环却再也不去取——数据堆在 FIFO 里没人理,表面看就成了"还在收、解析却停了"。
说到底,判错标志只是导火索,真正别扭的是设计本身:拿一个发送状态去闸接收侧的处理,把两条本该各走各的线绑死在了一个变量上。
为什么单帧好好的
这 bug 最唬人的地方,就是单帧、或者帧间隔大一点的时候一切正常。
原因就藏在前面那句被我标了注释的代码里——接收中断的 IDLE 分支,顺手把发送忙标志也给清了。
两帧之间留了空隙就会触发 IDLE,于是流程变成:发一帧、解析、回 ACK、置忙、DMA 发完但没清(bug 潜伏着)、帧间空闲触发 IDLE、忙标志被误清——下一帧照样能解析,看着风平浪静。可连发的时候帧挨着帧,几乎没有空隙,IDLE 不一定及时来,uart_tx_busy就长期卡在 1。
换句话说,是 RX 的 IDLE 事件去清 TX 的 busy 这种职责混乱,在单帧场景下歪打正着替真 bug 打了掩护,反倒让问题更难露馅。
怎么修
最直接的,用哪个通道就判哪个通道的标志:
if(__HAL_DMA_GET_FLAG(&hdma_uart_tx,DMA_FLAG_TC4)!=RESET){__HAL_DMA_CLEAR_FLAG(&hdma_uart_tx,DMA_FLAG_TC4);uart_tx_busy=0;}但更省心的是干脆别手写判标志,直接用 HAL 的发送完成回调,从根上躲开写错通道的风险(正常模式下也不用每次手动HAL_UART_DMAStop,HAL 会自己收尾):
voidHAL_UART_TxCpltCallback(UART_HandleTypeDef*huart){if(huart==&target_huart)uart_tx_busy=0;}用了回调,就把那段手判删干净,别留着两套并存,徒增混乱。
顺带说说设计上的坑
一行就能修好的 bug,背后其实是两处"状态串味"凑一块儿才闹出来的,值得一并改掉。
一是 TX 和 RX 的状态别共用一个变量,更别让 IDLE 这种接收事件去碰发送的 busy——谁的事归谁管,免得一个标志被好几条流程随手改。
二是想清楚"发送时为什么要停解析"。如果是 RS485 半双工,发的时候本来就收不了,那道闸是合理的,正确做法是让 busy 标志可靠(比如上面的回调),而不是把闸拆了;要是全双工、收发互不影响,那接收 FIFO 就该一直消费,别让连发的数据堆到溢出。先看自己是哪种,再决定怎么改。
顺便提一句,FIFO 写失败最好加个计数,不然真溢出了你也只看到"解析不动了",根本分不清到底丢没丢数据。还有就是测试别只测单帧,连发、无间隔、异常帧混入、FIFO 快满这些场景,才是真正能把状态机和标志管理里的雷逼出来的——这次就是活例子。
小结
整条因果链是:判错 DMA 通道的完成标志 →uart_tx_busy清不掉 → 主循环停掉接收 FIFO 的消费 → 解析停摆 → 连发时表现为卡死。单帧之所以没事,纯粹是帧间空闲触发 IDLE,间接把那个错误的忙状态清掉了。
留两句话:串口里"收到数据"从来不等于"数据被处理掉了",中间任何一个状态标志卡死,都能让接收整条线停摆、却伪装成解析的 bug;而连发测试,往往比单帧更能把这种藏在状态机和 DMA 标志里的问题逼出来——越是"单帧好好的",越要留个心眼。
