我很少为一两个字节的问题专门写一篇分享,但UART发送数据丢失最后一个字节这个坑,我前前后后踩了不下三次,每次还都是在不同的项目里以不同的面貌出现,实在值得好好说道说道。
这个问题在串口通信里太典型了,典型的“看起来小、排查起来绕、知道原理后一分钟解决”。不管你是用STM32的HAL库,还是标准外设库,或者干脆直接操作寄存器,甚至是在Linux下写串口应用,只要涉及UART发送,都有可能撞上它。
先说清楚这篇文章能帮你解决什么:彻底搞清楚“为什么最后一个字节总是丢”,学会从硬件机制层面理解UART发送的完整流程,拿到可以直接套用的解决方案和代码,顺便收获一张排查速查表。适合正在被串口收发问题折磨的嵌入式开发新手,也适合想系统梳理UART发送机制的老手。
1. 问题现象与根因分析:为什么偏偏是最后一个字节
1.1 一个典型到不能再典型的现象描述
先还原一下最常见的现场。你用STM32通过串口往外发一串数据,代码逻辑大概是:
// 伪代码,示意用 for (i = 0; i < len; i++) { while (USART_GetFlagStatus(USART1, USART_FLAG_TXE) == RESET); USART_SendData(USART1, buffer[i]); }逻辑上看起来没有任何问题,发送缓冲区非空就等待,空了就填下一个字节。但你把逻辑分析仪接到TX引脚上一看,或者用USB转TTL小板子收到电脑上对比,发现发出去的数据总是少了最后一个字节。
更气人的是,这个现象不是每次都出现。波特率越高越容易出现,数据量越大越容易出现,有时候连续发几十次才出现一次,有时候次次都丢。这种“间歇性Bug”最折磨人,因为不好复现,就不容易定位。
我最早遇到这个现象时,第一反应是检查波特率有没有配错,因为波特率误差会导致接收端采样错位。但检查下来发现波特率没问题,因为前面的字节都收得准准的。后来又怀疑是USB转TTL模块的兼容性问题,换了好几个模块,问题照旧。
直到有一次把示波器探头直接点在TX引脚上,才终于看到真相:最后一个字节其实已经送进了移位寄存器,正在往外一位一位地发,但发送完成后引脚电平没有完全拉到位,整个数据帧的停止位是残缺的,接收端自然就判定这一帧无效,丢弃了。
1.2 硬件层面真正的根因:TXE标志和TC标志的差别
要真正弄明白这个问题,得回到UART的硬件结构上。UART发送路径上有两个关键寄存器/标志位,很多开发者在初级阶段只知其一不知其二。
第一个是TXE(Transmit Data Register Empty),发送数据寄存器空。你往数据寄存器DR里写一个字节,硬件会把DR里的数据并行搬运到移位寄存器,然后一位一位地串行发出去。DR被搬空之后,TXE标志位置1,表示“你可以往DR里写下一个字节了”。
第二个是TC(Transmission Complete),发送完成。这个标志位要等到移位寄存器里的所有数据位、校验位、停止位全部发送完毕,TX引脚回到空闲电平之后才会置1。
问题就出在这里:很多人的代码只等了TXE就认为发送完成了。
TXE置1的时候,你写的那个字节只是刚刚被搬进移位寄存器,可能才发出去几位,甚至还没开始发。如果你此时就把外设关了、把GPIO复用了、把时钟关了、进入了低功耗模式,移位寄存器里剩下的那些位就会被硬生生掐断,最后一字节自然就丢了。
这就好比你在食堂打饭,阿姨把菜从大锅里舀到你的餐盘里(DR -> 移位寄存器),你觉得菜已经到手了,转身就走,结果阿姨最后一勺还在半空中没落到你盘子里,你一转身,那勺菜就掉地上了。TXE标志就是“菜已经舀起来了”,TC标志才是“菜已经稳稳落进你盘子里”。
1.3 软件层面常见的三个触发动作
光知道TXE和TC的区别还不够,还得知道实际代码里是哪些操作“掐断了”最后那个字节的发送。根据我的经验,最常见的触发动作有三个。
第一个是发送完后立刻操作外设。比如发送完一串AT指令后,紧接着把串口切换到接收模式,或者调用某个关闭串口的函数,又或者把TX引脚重新配置成普通的GPIO。这些操作都可能在TC标志还没置位的时候把发送链路断掉。
第二个是发送完后立刻进入低功耗模式。这在低功耗蓝牙、传感器节点、手持设备项目里特别常见。主控发完最后一帧数据,觉得“活儿干完了”,马上调用WFI或者进入STOP模式,结果最后一个字节的停止位还没发完,时钟一停,数据就烂尾了。
第三个是中断或者DMA的“提前收工”。用中断方式发送时,最后一个字节触发的中断里做了收尾动作;用DMA发送时,DMA传输完成中断里关了UART或者清理了标志位。听起来都合理,但实际执行顺序里有个隐蔽的时间窗口,会导致最后一个字节没真正发完。
2. 不同实现场景下的丢字节复盘:我踩过的四种坑
2.1 轮询发送场景:等了TXE就开跑
最基础也最常见的坑就是轮询方式只判断TXE。
我早年在一个温湿度采集器项目里,用STM32F103的标准外设库发数据,代码就是最经典的那几行:
void UART_SendString(UART_TypeDef *UARTx, uint8_t *str, uint16_t len) { for (uint16_t i = 0; i < len; i++) { while (USART_GetFlagStatus(UARTx, USART_FLAG_TXE) == RESET); USART_SendData(UARTx, str[i]); } while (USART_GetFlagStatus(UARTx, USART_FLAG_TC) == RESET); // 加了这个才稳 }当时以为循环结束后数据肯定发出去了,结果上位机那边经常收不到完整帧。一开始我怀疑是上位机的接收缓冲问题,后来用串口助手加时间戳一看,每帧数据的末尾都缺一个字节。
后来我查看了参考手册里关于TC标志的描述,又用示波器对比了加不加while (USART_GetFlagStatus(UARTx, USART_FLAG_TC) == RESET);这一行的波形差异,才彻底确认问题就出在TXE和TC之间那个时间窗口上。
这里有个细节值得多说一句:如果你发完最后一个字节后,紧接着还有其他对串口外设的操作,比如清标志、关中断、改波特率,那必须在这些操作之前保证TC已经置位。最简单粗暴的方式就是发送完最后一个字节后,死等TC标志。
2.2 中断发送场景:最后一个字节进了移位寄存器,中断却先收工了
中断方式发送比轮询方式复杂一些,丢字节的原因也更隐蔽。
我做过一个用中断方式驱动SIM800模块发短信的项目。发送流程是:先在主循环里把要发的内容放进一个环形缓冲区,然后使能TXE中断,让中断服务函数逐个把缓冲区里的字节写到DR寄存器。
void USART1_IRQHandler(void) { if (USART_GetITStatus(USART1, USART_IT_TXE) != RESET) { if (send_index < send_len) { USART_SendData(USART1, send_buffer[send_index++]); } else { USART_ITConfig(USART1, USART_IT_TXE, DISABLE); // 这里直接关了TXE中断,但最后一个字节可能还在移位寄存器里 } } }问题就出在send_index == send_len这个分支:你只是把最后一个字节写进了DR,它被搬到移位寄存器后,TXE重新置1,又触发了一次中断。但这时候你的发送索引已经用完了,于是你直接关了TXE中断,认为发送结束。
实际上,最后一个字节可能才刚进移位寄存器,还没发完。虽然TXE中断关了不会影响移位寄存器继续发送——只要UART外设本身没有复位、时钟没有停,它会老老实实把剩下的位发完——但如果此时主循环里有别的代码立刻操作了这个串口,比如清标志位、重配置,或者调用了USART_Cmd关闭外设,那最后一个字节就悬了。
更微妙的是,如果此时另一个更高优先级的任务对UART外设做了任何写操作,新的数据会把还没发完的旧数据顶掉。这种情况在实时操作系统里尤其容易出现。
2.3 DMA发送场景:传输完成并不等于发送完成
DMA方式是丢最后一个字节的重灾区,因为DMA有自己的“传输完成”概念,它和UART的“发送完成”是两个完全不同的时刻。
DMA负责把内存里的数据搬运到UART的DR寄存器。当DMA把最后一个字节写进DR,它会立刻置位DMA传输完成标志。但此时UART的移位寄存器可能正在发送倒数第二个字节,最后一个字节还只是待在DR里等着被搬进移位寄存器。
很多人的DMA发送完成中断里是这样写的:
void DMA1_Channel4_IRQHandler(void) { if (DMA_GetITStatus(DMA1_IT_TC4)) { DMA_ClearITPendingBit(DMA1_IT_TC4); USART_DMACmd(USART1, USART_DMA_Req_TX, DISABLE); // 关闭UART的DMA发送请求 UART_TxDone = 1; // 通知主循环发送完成 } }关闭UART的DMA发送请求这个操作本身不会掐断移位寄存器正在发送的字节,但问题是,如果主循环收到UART_TxDone = 1的信号后,立刻去操作UART外设——比如切换GPIO方向、重新配置串口参数、使能接收DMA——那最后一个字节就很容易丢。
这里我要分享一个我自己的教训:曾经在DMA发送完成中断里,不仅关了DMA请求,还顺手调用了USART_Cmd(USART1, DISABLE)去关闭UART外设,想着省电。结果就是每次发送的最后一个字节都丢,而且丢得很规律,后来查了三天才在数据手册的一行小字里看到:关闭USART外设会把当前正在发送的数据和移位寄存器里的数据都清掉。
所以DMA发送的正确姿势是:DMA传输完成中断里只关DMA请求,不关UART外设,然后等待UART的TC标志置位后再做后续操作。如果用的是支持“发送完成中断”的MCU,可以直接开启UART的TC中断,在TC中断里做收尾动作。
2.4 低功耗场景:发完最后一个字节就睡,结果再也没醒过来
这个场景在物联网设备里特别典型。设备通过UART上报数据,上报完立刻进低功耗模式。
我做过一个用NB-IoT模块上报数据的项目,主控是STM32L151。代码逻辑大致是:
UART_SendString("AT+QMTCFG=\"recv/mode\",0,0,1\r\n"); UART_SendString("AT+QMTCFG=\"recv/ind\",0,0,1\r\n"); UART_SendString("AT+QMTOPEN=0,\"iot.xxx.com\",1883\r\n"); // ... 一系列AT指令 ... UART_SendString("AT+QMTPUB=0,0,0,0,\"topic\",\"hello\"\r\n"); // 发送完最后一串指令后立刻进低功耗 Enter_LowPower_Mode();现象是:模块有时候能收到数据,有时候收不到,而且收不到的时候占大多数。用串口助手挂在TX和模块之间抓包发现,每次“收不到”的情况,都是最后一串AT指令的最后一个\r没有发出去,或者发了一半就断了。
原因就是最后一条指令发送完后立刻进了低功耗模式。虽然代码里发送函数用的是轮询方式,并且等了TXE,但并没有等TC。最后那个换行符可能还躺在移位寄存器里,主控就熄灯睡觉了,它自然就发不完整。
这个场景的解决方案其实也很简单:发送完最后一条数据后,先原地等TC标志置位,再进低功耗。有些MCU还提供了更优雅的做法,比如开启UART的TC中断,在TC中断里设置一个标志位,主循环确认标志位后再进入低功耗,确保“数据真的发完了”。
3. 彻底解决:从标准库到HAL库的完整方案对比
3.1 方案一:死等TC标志——最传统也最直接
对于轮询发送方式,最直接的解决方案就是发送完最后一个字节后,等待TC标志置位。
标准外设库的写法:
void UART_SendString_WaitTC(UART_TypeDef *UARTx, uint8_t *data, uint16_t len) { for (uint16_t i = 0; i < len; i++) { // 等待DR寄存器为空 while (USART_GetFlagStatus(UARTx, USART_FLAG_TXE) == RESET); USART_SendData(UARTx, data[i]); } // 关键:等待最后一个字节完全发送完毕 while (USART_GetFlagStatus(UARTx, USART_FLAG_TC) == RESET); }HAL库的写法更简单,因为HAL_UART_Transmit内部已经处理了这个逻辑:
HAL_StatusTypeDef HAL_UART_Transmit(UART_HandleTypeDef *huart, uint8_t *pData, uint16_t Size, uint32_t Timeout) { // HAL库内部在发送完所有字节后,会等待TC标志 // 具体代码在stm32f1xx_hal_uart.c里,逻辑如下(简化): while (huart->TxXferCount > 0U) { // 等待TXE if (__HAL_UART_GET_FLAG(huart, UART_FLAG_TXE) == RESET) { // 超时处理 } // 写DR huart->Instance->DR = (*pData++ & (uint8_t)0xFF); huart->TxXferCount--; } // 等待发送完成标志 while (__HAL_UART_GET_FLAG(huart, UART_FLAG_TC) == RESET) { // 超时处理 } }如果你在用HAL库,直接用HAL_UART_Transmit一般不会遇到丢最后一个字节的问题,因为库已经帮你处理了TC等待。但如果你是在中断或者DMA模式下使用HAL库,就得额外注意了。
3.2 方案二:DMA发送的完整正确姿势
DMA方式的核心思路是:DMA传输完成不等于UART发送完成,发送完成要以UART的TC标志为准。
用STM32标准库实现的一个标准流程:
// 发送前的准备 void UART_DMA_Send(uint8_t *data, uint16_t len) { // 等待上一次发送完全结束,防止覆盖 while (uart_dma_busy); uart_dma_busy = 1; // 填充DMA缓冲区和长度 DMA_SetCurrDataCounter(DMA1_Channel4, len); // 这里要确保DMA源地址正确指向要发送的数据 DMA_Config_Transfer(data, len); // 使能UART的DMA发送请求 USART_DMACmd(USART1, USART_DMA_Req_TX, ENABLE); // 使能DMA通道 DMA_Cmd(DMA1_Channel4, ENABLE); } // DMA传输完成中断 void DMA1_Channel4_IRQHandler(void) { if (DMA_GetITStatus(DMA1_IT_TC4)) { DMA_ClearITPendingBit(DMA1_IT_TC4); // 关闭UART的DMA请求,但不关闭UART本身 USART_DMACmd(USART1, USART_DMA_Req_TX, DISABLE); // 使能UART的TC中断,等待最后一个字节真正发完 USART_ITConfig(USART1, USART_IT_TC, ENABLE); } } // UART的TC中断 void USART1_IRQHandler(void) { if (USART_GetITStatus(USART1, USART_IT_TC) != RESET) { USART_ClearITPendingBit(USART1, USART_IT_TC); USART_ITConfig(USART1, USART_IT_TC, DISABLE); uart_dma_busy = 0; // 真正发送完成,释放信号量 } }这套流程的关键点在于:DMA中断只负责“数据已经从内存搬到UART的DR”,后续的发送完成交给UART的TC中断来确认。
如果你用的是STM32CubeMX生成的HAL库DMA代码,那HAL_UART_Transmit_DMA这个函数其实不会自己等待TC,它只负责启动DMA传输。你需要使用HAL_UART_TxCpltCallback回调函数,并且在这个回调里等待TC标志。不过有个更巧妙的做法,就是直接使用HAL_UART_Transmit_IT中断方式,HAL库会在中断里自动处理TC等待。
3.3 方案三:清TC标志的必要性和正确位置
还有一个容易被忽略的细节:TC标志的清零时机。TC标志的置位条件是移位寄存器发送完成。如果你不清除TC标志,下一次发送时,你的等待TC循环会立即返回,因为它看到的还是上次遗留的标志位。
在标准库中,清除TC标志的推荐做法是先读SR寄存器,再写DR寄存器,或者直接操作SR寄存器的TC位:
// 清除TC标志的两种方式: // 方式一:读SR再写DR (void)USART_GetFlagStatus(USART1, USART_FLAG_TC); // 读SR USART_SendData(USART1, data); // 写DR // 方式二:直接清TC位(部分系列支持) USART_ClearFlag(USART1, USART_FLAG_TC);在HAL库里,__HAL_UART_CLEAR_FLAG(huart, UART_FLAG_TC)可以直接用。
实际操作中我的习惯是:每次发送前,先清一次TC标志,然后再启动发送。这样不管上一次发送是什么状态结束的,本次发送的TC等待一定能等到真正的“完成”时刻,不会因为历史标志位的残留而误判。
3.4 几种方案的选型建议
不同场景适合不同方案,我整理了一张选型对比表:
| 使用场景 | 推荐方案 | 说明 |
|---|---|---|
| 简单轮询发送,数据量小 | 发送完死等TC | 实现简单,最可靠,但会阻塞CPU |
| 中断发送,数据量为中等 | 中断里处理TXE,最后等TC | 不阻塞主循环,但需要注意中断优先级 |
| DMA发送,数据量大 | DMA + TC中断联动 | 效率最高,但配置稍微复杂 |
| 低功耗设备发送后立即睡眠 | 发完等待TC再睡眠 | 必须等TC,否则数据必丢 |
| RTOS环境多任务并发 | 互斥锁 + 等待TC | 注意多任务同时访问串口的竞争问题 |
我自己在项目里的选择逻辑很简单:如果数据量不大、也不频繁,就用轮询加等TC,简单可靠不出错;如果数据量中等、涉及长帧传输,就上DMA加TC中断;如果有低功耗需求,不管用哪种方式,发送完成后都必须确认TC置位才能睡。
4. 调试定位技巧与排查速查表
4.1 用逻辑分析仪看波形的正确方法
遇到UART丢数据问题,第一反应应该是看波形,而不是改代码碰运气。
把逻辑分析仪的通道接到TX引脚上,地线接好,采样率设置成波特率的至少8倍以上。比如波特率是115200,采样率至少要921600,建议直接设置成1M或者2M,波形会更清晰。
然后发送一串有规律的数据,比如0x55, 0xAA, 0x55, 0xAA这种交替的字节。0x55是01010101,0xAA是10101010,这两种字节能让你在波形上清晰地看到每一位的电平变化,如果发送不完整,一眼就能看出哪一位缺失。
我常用的一个验证套路是发送一串自定义数据,比如0x01, 0x02, 0x03, 0x04, 0x05,然后在波形上数停止位。正常的UART帧结构是:起始位(低电平) + 8个数据位 + 停止位(高电平)。如果最后一个字节只有起始位和部分数据位,没有完整的停止位,那基本可以确定是发送被提前中断了。
有一个细节要注意:逻辑分析仪的触发设置。如果设置成上升沿触发,可能会漏掉起始位;建议设置成下降沿触发,也就是起始位的边沿,这样能保证抓到完整帧。
4.2 用串口助手和上位机辅助判断的几个技巧
没有逻辑分析仪的情况,也可以用串口助手加一些技巧来判断。
第一种技巧是发送端发完数据后,隔一小段时间再关闭串口或者进入低功耗。比如在发送函数末尾加一个delay_ms(1),如果加上延时后数据就完整了,说明问题大概率就是发送未完成就进行了后续操作。
第二种技巧是让发送端每隔一段时间重复发送同样的数据,然后在上位机统计完整帧的比例。如果丢失率随着波特率升高而变高,或者随着单次发送数据量增大而变高,基本可以断定是发送完成时序问题。
第三种技巧比较冷门:把发送内容里加上帧尾校验,比如CRC或者累加和。如果上位机收到的数据经常是“缺了最后一个字节导致校验失败”,那定位起来就非常快。我后来在做协议设计时,都会强制要求应用层加帧尾校验,不只是为了数据完整性,也是为了排查问题方便。
4.3 UART丢最后一个字节排查速查表
根据我多年踩坑经验,整理一张速查表,基本覆盖了各种可能性:
| 现象特征 | 可能原因 | 检查方向 |
|---|---|---|
| 每次发送都丢最后一个字节 | 发送后未等待TC就关闭外设或操作GPIO | 检查发送函数后面是否有其他代码 |
| 偶发丢失最后一个字节 | 发送完成后有其他中断或任务抢占了CPU | 检查是否在发送完成前进入了低功耗模式 |
| 波特率越高丢得越频繁 | 移位寄存器发送时间缩短,TC等待被错过 | 检查波特率配置是否正确 |
| 只有DMA发送丢字节 | DMA传输完成中断里关闭了UART外设 | 检查DMA中断里是否禁止了USART外设 |
| 只有使用HAL库时丢字节 | 中断方式或DMA方式的回调里处理不当 | 检查HAL_UART_TxCpltCallback里是否有TC等待 |
| 发送完后马上进睡眠丢字节 | 进入低功耗模式太早 | 检查睡眠前是否确保TC标志置位 |
| 数据帧最后缺失停止位 | 发送被硬件复位或外设关闭中断 | 用示波器检查最后一位波形 |
4.4 一个我自己写的小工具函数:安全发送
排查过太多次这个问题之后,我习惯把“安全发送”封装成一个工具函数,所有项目都用同一个模式,从根上避免这个坑。
/** * @brief 安全发送:确保所有字节都真正发出 * @param huart UART句柄 * @param data 数据指针 * @param len 数据长度 * @return 0成功,-1失败 */ int UART_SafeTransmit(UART_HandleTypeDef *huart, uint8_t *data, uint16_t len) { // 方式一:轮询发送(HAL自带TC等待) if (HAL_UART_Transmit(huart, data, len, 1000) != HAL_OK) { return -1; } // 方式二:如果是自己用寄存器写的话,一定要等TC // 最后再补一次显式TC确认,双保险 while (__HAL_UART_GET_FLAG(huart, UART_FLAG_TC) == RESET) { // 加超时保护,防止死循环 static uint32_t tick = 0; if (tick++ > 100000) { return -1; } } return 0; }这个函数的核心思想就是:不管底层用什么方式发送,最后都要确认TC标志。尤其在低功耗设备上,这个函数会作为进入睡眠前的最后一道关卡。
5. 背后的硬件原理:移位寄存器视角
5.1 UART发送数据通路的完整过程
要彻底根治这个问题,最好还是把UART发送的完整链路在脑子里过一遍。我从数据手册和实际波形中总结了一个极简流程:
第一步,CPU或DMA把一个字节写入UART的DR数据寄存器。这个写入动作本身不会直接触发发送,而是把数据放在一个“待发缓冲区”里。
第二步,如果此时移位寄存器的准备工作已完成,DR里的数据会被自动搬运到移位寄存器。同时,TXE标志置位,表示DR已经空了,可以接收下一个字节。如果DR里已经有新数据但没有TXE置位,那说明数据还没有被搬走,写入操作会被阻塞或者覆盖,具体取决于芯片设计。
第三步,移位寄存器按照配置的波特率,把数据一位一位地移到TX引脚上:先是起始位,然后是8个数据位,再是校验位(可选),最后是停止位。移位寄存器完全清空、TX引脚回到空闲高电平后,TC标志置位。
这里有一个很多初学者不清楚的细节:DR只有一个字节深度的缓冲,没有更深层的FIFO(除非芯片专门标注)。所以当DR里有数据、TXE没有置位时,你再往DR里写数据,会直接把之前没搬走的数据覆盖掉。这就是为什么写发送代码时要先等TXE再写DR。
更深的机制是,有些MCU会在“DR数据被搬运到移位寄存器”这个瞬间,同时清除TXE标志,表示DR已经在“占用中”。所以要养成习惯:写DR前一定先检查TXE,不要盲目往DR里灌数据。
5.2 不同MCU系列的细节差异:别拿F1的写法套F4
不同MCU系列在UART发送的细节上可能会有细微差异,尤其是标志位的定义和清除方式。
STM32F1系列的USART算是最经典的,它有一个SR状态寄存器,TXE和TC都在里面。清除TC的推荐操作是“读SR后写DR”。这看起来有点反直觉,但这是参考手册明确写的,目的是防止误清除其他标志。
STM32F4系列和F7系列在USART模块上做了一些改进,寄存器结构有所调整。虽然TXE和TC的语义没变,但有些系列增加了FIFO选项(比如F7的UART模块支持FIFO模式),在启用FIFO的情况下,TXE标志的含义会略有不同,它表示FIFO里还有空间可以写入多个字节,而不是只是DR单缓冲。
其他厂商的MCU,比如NXP的LPC系列、TI的MSP430系列,虽然UART模块叫的名字不同(有的叫UART,有的叫USCI,有的叫eUSCI),但底层的数据通路基本一致:都是“数据寄存器 -> 移位寄存器 -> TX引脚”的结构。换句话说,只要理解了“数据寄存器空”不等于“发送完成”这个核心,就能应对几乎所有MCU的UART发送丢最后一个字节问题。
5.3 波特率误差和最后一个字节的关系
还有一个容易混淆的点,我单独拎出来说清楚:波特率误差一般不会导致只丢最后一个字节,而是会导致整个数据帧里随机出现误码。如果你发现只有最后一个字节丢失,而且丢的时候波形上停止位是完整的,那大概率不是波特率问题,还是“发送未完成就被打断”的问题。
但有一种特殊情况:当波特率误差较大时,最后一个字节的停止位采样点可能刚好落在误码区间,导致接收端判定“帧错误”。这种情况通常在数据帧很长时才会出现,因为误差会累积,越到后面的字节采样偏差越大。如果你发送的数据帧特别长,比如一次发几百个字节,最后一个字节的停止位采样出问题的概率确实会增加。
解决这类问题的思路是:尽量将波特率误差控制在2%以内,选外部晶振而不是内部RC振荡器(内部RC在高低温下误差可能超过3%),接收端使用带自动波特率检测或帧错误恢复的UART外设。
6. 从一次实际排障过程看完整思路
6.1 现场情况:一个锂电池BMS项目的真实案例
去年做一个锂电池BMS从控板,主控是STM32G030,通过UART和上位机通信。现象是:上位机偶尔收不到从控板上报的数据帧,而且每次都缺最后一个字节。更诡异的是,用USB转TTL模块单独接到从控板上测试时又不丢,只有接上整个BMS系统时才丢。
这个问题查了我整整两天。开始怀疑是电源纹波导致UART电平异常,在TX引脚上加了滤波电容,没用。后来怀疑是接地问题,把各个板子的地重新接了一遍,还是没用。再后来怀疑是上位机那边的串口接收线程处理太慢,加了缓冲区,依然没用。
最后我用逻辑分析仪同时抓TX引脚和另一个干扰信号引脚的波形,才发现:每次丢字节的时候,TX引脚的最后一个字节的停止位刚发到一半,从控板的SPI Flash写入操作就开始了,这个操作瞬间拉高了板载DC-DC的输出纹波,导致TX引脚的电平被污染,接收端判定停止位无效。
6.2 怎么一步步缩小排查范围
这次排障给我最大的启发是:排查顺序很重要,不要一上来就怀疑代码逻辑。
第一步,确认“最后一个字节丢了”是不是事实。用逻辑分析仪抓原始波形,不要依赖上位机软件显示。上位机软件可能因为驱动、缓冲区等问题漏数据。
第二步,判断丢字节是发送端责任还是接收端责任。把发送端的TX引脚直接接到另一个独立的串口接收设备上,如果独立设备也丢,那问题在发送端;如果独立设备不丢,那问题在发送端和原接收端之间的环境干扰。
第三步,检查发送端的软件时序。在发送函数前后加几个GPIO翻转的调试引脚,用逻辑分析仪同时抓GPIO和TX波形,就能看出“发送完成”和“后续操作”之间的时间关系。如果后续操作(比如SPI写入)发生在TC标志置位之前,那就有问题了。
第四步,检查硬件环境。这部分最容易忽视,却往往是最坑的。电源纹波、地弹、长线传输、引脚驱动能力不足,都可能表现为最后一个字节丢失。特别是在大电流设备旁边,UART这种异步通信对电平质量还是比较敏感的。
6.3 排障工具箱:我常用的几种工具组合
这里列一下我日常排查串口问题时的常用工具,都是些常见设备,没有昂贵仪器:
- 逻辑分析仪:必备,推荐8通道以上的,采样率至少24M。几十块钱的“玩具级”逻辑分析仪就够用,关键是软件要好用,能把UART协议直接解码出来。
- USB转TTL模块:多备几个不同方案的,比如CH340、CP2102、FT232等。不同方案的驱动和电气特性有差异,可以互相验证是不是模块问题。
- 示波器:逻辑分析仪看到的是“逻辑电平”,示波器能看到真实的电压波形。如果怀疑信号质量有问题,就得用示波器看上升沿、下降沿、过冲、振铃。
- 串口调试助手:推荐支持Hex显示和文件日志的,能记录完整数据帧,方便回放分析。
这套工具组合下来,一般串口问题都不会卡太久。核心思路就一个:把“看不见的信号”变成“看得见的波形”,一切以实测为准。
7. 再往深做一步:几个容易踩的延伸坑
7.1 发送完成回调里做“太多事”导致的问题
使用HAL库时,HAL_UART_TxCpltCallback这个回调函数几乎是人人都会用,但很多人会在这个回调里做额外操作,比如更新状态、启动下一次发送、操作外设。
这里有个隐患:HAL库的HAL_UART_TxCpltCallback是在TC标志置位后才被调用的,所以此时最后一个字节理论上已经发送完毕。但要注意,这个回调是在中断上下文里执行的,如果你在回调里执行了耗时操作,比如软件延时、打印日志、操作I2C外设,可能会阻塞中断处理,带来其他时序问题。
我见过一个很有趣的Bug:有人在HAL_UART_TxCpltCallback里调用HAL_UART_Transmit发送另一条日志数据,结果发生了递归中断,数据发送完全错乱。这是因为HAL_UART_Transmit使用的是轮询等待,但此时又处在中断上下文里,如果UART的接收中断或者错误中断被触发,就会造成嵌套混乱。
在回调里应该做的是:设置标志位、释放信号量、启动另一个异步操作(比如DMA),而不是做耗时的同步操作。
7.2 多字节连续发送时的TXE处理:一个常用的FIFO技巧
当需要连续发送多个字节时,如果每个字节都等一次TXE,效率会比较低。尤其在高波特率下,UART发送一个字节可能只需要几十微秒,但CPU从“检测到TXE置位”到“写入DR”这个过程会有延迟,如果延迟太长,就会让TX引脚出现空闲期,降低实际吞吐量。
一个常用的优化技巧是在第一个字节写入DR后,立刻循环检查TXE并写入下一个字节,而不是每写一个字节都重新判断TXE。这样做的好处是:当硬件把第一个字节从DR搬运到移位寄存器时,TXE立即置位,CPU可以马上写入下一个字节,避免了“DR空着但CPU还没写”的空档期。
这段优化后的代码大概是:
void UART_SendString_Fast(UART_TypeDef *UARTx, uint8_t *data, uint16_t len) { if (len == 0) return; // 先写第一个字节 USART_SendData(UARTx, data[0]); // 再循环发送剩余字节 for (uint16_t i = 1; i < len; i++) { while (USART_GetFlagStatus(UARTx, USART_FLAG_TXE) == RESET); USART_SendData(UARTx, data[i]); } // 最后等待TC while (USART_GetFlagStatus(UARTx, USART_FLAG_TC) == RESET); }但这个优化有个前提:你必须确保DR的深度足够,或者说你必须在TXE置位后尽快写入下一个字节。如果芯片的UART支持FIFO模式,那可以利用FIFO深度来进一步减少CPU干预,但底层逻辑依然不变:必须等TC才能确认发送完成。
7.3 为什么有些时候“丢最后一个字节”其实不是UART的问题
最后说一个容易误判的情况。有时候你在上位机看到数据少了最后一个字节,不一定就是发送端真的少发了,也可能是接收端的处理问题。
比如,USB转TTL模块的驱动有缓存溢出问题,当数据量很大时,驱动缓冲区满了,最后一个字节可能被丢弃。又比如,上位机的串口接收程序用了固定长度的缓冲区,一次性读取了N个字节,但串口数据到达的时机有延迟,导致最后一次读取只读到了前N-1个字节。
判断方法很简单:用逻辑分析仪直接抓TX引脚,如果TX引脚上最后一个字节的波形是完整的,那问题就在接收链路。如果波形不完整,那才是发送端的事。
还有种更隐蔽的情况:有的USB转TTL模块在收到数据后会通过USB批量传输协议发给上位机,这个过程中可能会因为USB的帧调度延迟,导致数据“看起来”不完整,但实际上数据是完整的,只是到达时间有偏差。这种情况在高速率、大批量传输时尤其常见。
我自己现在遇到“丢最后一个字节”这个问题,第一反应是先上逻辑分析仪,把发送端波形看清楚,再下结论。这个习惯帮我避开了很多冤枉路。
7.4 关于项目改造后的稳定性验证
改完发送逻辑后,不建议只测几个包没问题就收工。UART这种异步通信问题,最容易在小概率、长时间运行后突然冒出来。我的习惯是跑一轮压力测试:让设备连续发送至少10万帧数据,上位机或者逻辑分析仪统计误码率、丢帧率,并且至少覆盖波特率和数据长度的边界条件。
比如你平时的波特率是9600,那可以上下各测一档,同时把单帧数据长度从1个字节到最大帧长都测一遍。因为不同长度下,UART的TC等待时间不一样,可能会暴露出新的时序窗口。实测下来,这套方法配合前文的速查表,基本能把UART发送侧的坑都踩平。
另外说一句,如果项目里用的MCU内部RC振荡器,建议在做压力测试前,先把系统时钟用外部晶振校准一遍。因为内部RC的精度受温度影响大,如果系统时钟和UART波特率发生器本身就有偏差,压力测试时很容易出现偶发的帧错误。这个坑我当年也踩过,单独写出来提醒大家。