STM32串口丢最后一个字节?从TXE到TC彻底解决
2026/9/4 11:42:52 网站建设 项目流程

在STM32上调试串口时,我遇到过最让人摸不着头脑的问题,就是发送一串数据,上位机收到的总是少最后一个字节。比如我明明发的是“ABCDEF”,调试助手里只显示“ABCDE”。刚开始我以为是波特率不对,又怀疑是USB转串口工具坏了,折腾半天才发现问题出在代码的“最后一哆嗦”上。这种问题在UART通信里非常典型,尤其是用轮询方式发送字符串、用DMA批量发送数据、或者发送完立刻进入低功耗模式的时候,几乎一踩一个准。

这篇文章我打算把这个问题彻底讲透:先带你定位现象,再拆解UART发送的底层机制,然后给出几种立竿见影的修复方案,顺便把你可能用到的FT232R、CP2102N这类USB转串口工具的排查方法也一起覆盖了。不管你是刚接触STM32的在校生,还是已经在做嵌入式项目开发的工程师,只要遇到过“串口丢尾巴”的诡异现象,这篇文章都值得你花几分钟看完。

1. 丢失最后一个字节的典型场景与问题定位

1.1 三个最常见的“丢尾巴”现场

第一个场景是轮询发送。很多人习惯直接调用HAL_UART_Transmit,或者用标准库写一个while循环往USART_DR里塞数据。发送缓冲区里的最后一个字节,有时候就是出不来。这种问题不是偶发,而是固定丢,只要发送长度超过两三个字节就必现。

第二个场景是DMA发送。从DMA的角度看,传输完成中断触发时,数据已经全部从内存搬到UART的数据寄存器里了。可你在中断回调里加一句“关闭DMA通道”或“关闭UART时钟”,或者立刻把CPU拖入睡眠,最后一个字节就可能在移位寄存器里断掉。

第三个场景是低功耗设备。比如NB-IoT模组、电池供电的传感器节点,发送完一帧数据后为了省电马上进入STOP模式。这种场景下丢最后一个字节几乎成了标配,因为代码根本没有等待“发送完成”这个时刻。

我之前调过一款步进电机驱动芯片TMC2226SA,它通过UART发送寄存器配置命令。配置帧发出去了,但芯片总是没有反应,用示波器一看,帧尾少了半个字节。这类对时序敏感的外设,最怕的就是这种“丢尾巴”,因为寄存器写入是整帧生效,最后一个字节丢了,整条指令就无效了。

1.2 先搞清楚:是MCU没发出去,还是工具没显示出来?

遇到这类问题,我强烈建议先不要改代码,哪怕只是多写一行延时,也要先用示波器或者逻辑分析仪看一眼TX引脚的实际波形。这是判断问题归属的唯一权威依据。

把示波器探头接到MCU的TX引脚和GND上,配置好触发,然后运行发送函数。重点看最后一帧数据之后,第10个bit(停止位)是否完整。如果停止位在高电平的持续时间跟前面几帧一致,说明MCU波形完整,问题很可能出在USB转串口工具或者上位机软件上;如果停止位被提前截断、电平被拉低,或者整个第10位不见了,那就要回MCU代码里找原因。

这里有个很容易踩的坑:不少USB转串口芯片,比如FT232R、CP2102N,内部都有FIFO缓冲,数据从UART接口进来后会先缓存,再由USB批量传输上报给主机。如果上位机软件不刷新或者缓冲区设置太小,就会出现“MCU发出了,电脑上却看不到”的假象。所以做定位时,示波器永远比软件可靠。

2. 根因拆解:为什么偏偏丢最后一个字节

2.1 UART发送链路上的两个寄存器

要搞懂这个问题,必须理解UART发送端内部的两个关键寄存器。很多初学者把它们当成一个,这是丢字节的根本原因之一。

第一个是数据寄存器,在STM32上叫TDR,在标准库的寄存器版本里叫DR。CPU往这个寄存器写一个字节,发送硬件就认为“有活儿干了”。第二个是移位寄存器,它才是真正负责把数据一位一位搬到TX引脚上的部件。CPU写入TDR后,TDR里的数据会被硬件自动搬运到移位寄存器,然后移位寄存器在波特率时钟驱动下逐位移出,先发起始位,再发8个数据位,最后发停止位。

我用一个“仓库发货”的类比解释一下:TDR相当于仓库门口那箱已经打包好的货,TXE标志是“门口空了可以放下一箱”的提示;移位寄存器则是正在装车的那箱货,它什么时候卸完,才是真正的“发送完成”。如果你只看TXE,那么你只能知道“货已经放到门口了”,但不知道“车上的货卸完没有”。

2.2 TXE和TC这两个状态位,差得很远

UART状态寄存器里有两位跟发送相关,名字很像,但含义完全不同。

TXE全称是Transmit Data Register Empty,表示数据寄存器为空、可以写入新数据。CPU往TDR写入一个字节后,TXE会被硬件清零;当TDR里的数据被搬运到移位寄存器后,TXE又会被置1,表示可以写下一个字节了。TC全称是Transmission Complete,表示整个发送过程彻底结束,包括最后一个停止位都已经从TX引脚上移出去。

问题就出在这里。大多数发送循环的逻辑是:查询TXE为1,写下一个字节;继续查询TXE;再写下一个字节。当你写完最后一个字节时,TXE置1了,程序以为发送完成了,但实际上移位寄存器还在工作。如果此时你关闭UART时钟、复位外设、切换TX引脚功能,或者让整个MCU睡过去,移位寄存器里的最后那点数据就没机会全部移出了。

2.3 HAL库发送函数到底做了什么?为什么还会丢?

有的读者会问:我用的明明是HAL_UART_Transmit,它在HAL库里是有等待TC标志的,为什么我还会丢最后一个字节?

HAL库在轮询模式下的发送流程大致是:先等TXE,然后写TDR,循环往复;等到最后一个字节写完,再等待TC位置位。逻辑上它确实不丢字节。但实际操作中还有几个漏洞。

第一个漏洞是调用方的问题。函数返回确确实实表示TC已经置位了,你如果在这个函数返回之后再做一个“禁掉UART时钟”的操作,那正常是不会丢的。可很多人是在发送完成后立刻调用HAL_UART_DeInit、或者直接把PB10这种TX引脚重配置为GPIO,结果把已经输出到引脚上的电平强行改了。

第二个漏洞是部分旧版本的HAL库或者第三方移植版本里,HAL_UART_Transmit在发送最后一个字节时存在只等待TXE就返回的情况。这种库代码版本差异在ST官网升级后就不太常见了,但如果你用了旧工程或者网上现成的初始化代码,就得去翻一下底层实现,确认最后有没有等待TC。

第三个更隐蔽,是重定向printf时自己写死了寄存器操作。比如在fputc里调用USART_SendData,然后用while循环等待TXE置位,这时printf打印一串字符串,最后一个字符写进TDR后TXE置位,函数直接返回。从printf的角度看“输出已完成”,可硬件还在慢慢挪最后一位。如果主程序紧接着执行了USART_Cmd(USART1, DISABLE),那最后一位就永远醒不过来了。

3. 快速修复:三种方案与可复制代码模板

3.1 方案一:发送函数返回前等待TC标志(最稳)

最直接的修复思路,是确保在所有数据写入TDR之后、彻底处理完发送流程之前,等待硬件把最后一位搬完。标准库的写法是全部数据写完以后,加上一行等待TC的循环。

void UART_SendString(uint8_t *buf, uint16_t len) { for (uint16_t i = 0; i < len; i++) { USART_SendData(USART1, buf[i]); while (USART_GetFlagStatus(USART1, USART_FLAG_TXE) == RESET); } // 关键:等待最后一个字节完全发送完毕,包括停止位 while (USART_GetFlagStatus(USART1, USART_FLAG_TC) == RESET); }

如果你用的是HAL库,HAL_UART_Transmit本身已经包含了等待TC的逻辑,但为了保险,我习惯在调用之后再加一个TC等待:

void UART_SendString(const uint8_t *buf, uint16_t len) { HAL_UART_Transmit(&huart1, (uint8_t *)buf, len, 1000); while (__HAL_UART_GET_FLAG(&huart1, UART_FLAG_TC) == RESET); }

注意:如果HAL_UART_Transmit超时返回了HAL_TIMEOUT,再在这里死等TC可能会卡死。稳妥一点可以检查返回值,只有HAL_OK才继续等TC。但大多数情况下,这个函数不会出这种问题。

我个人的习惯是,只要代码里出现“串口”两个字,所有发送函数都必须以“等待TC”作为收尾动作。哪怕HAL_UART_Transmit内部已经做了等待,我也不会省掉这一条,多一个标志位判断对性能的影响几乎为零,但能换来绝对的心安。

3.2 方案二:发送完成后加一个“残影时间”延时

如果项目里不方便等待TC标志,比如你用的是非常老的寄存器库,或者中断优先级分配导致标志位读取很麻烦,也可以退而求其次,用延时来兜底。

计算一下:一个字节的发送时间等于起始位、数据位和停止位的总bit数除以波特率。以115200波特率、8数据位、无校验、1停止位为例,一帧是10个bit,每个bit约8.68微秒,一帧就是86.8微秒。为了留足余量,发送完最后一个字节后延时200微秒就足够安全。

// 用DWT或SysTick实现阻塞延时 UART_SendData(USART1, data); while (USART_GetFlagStatus(USART1, USART_FLAG_TXE) == RESET); delay_us(200);

这种方式适合快速验证一个猜想,但我不建议在正式产品里长期依赖延时。因为波特率变化后,延时时间没有跟着变,要么浪费CPU,要么因为余量不足又出现偶发丢字节。真正可靠的还是等标志位。

3.3 方案三:DMA发送时,先关闭DMA再等待TC

DMA发送场景是丢最后一个字节的重灾区。DMA的传输完成中断触发时,只是表示数据已经从内存搬运到了UART外设,并不代表UART已经把所有数据都发到了TX引脚上。如果此时直接关闭DMA通道,UART依然有数据正在发送,问题倒不太大;但如果你在DMA的回调里顺手把UART时钟关了,或者把外设Disable了,那就真的把最后一个字节扼杀在摇篮里了。

标准库处理方式:

void DMA1_Channel4_IRQHandler(void) { if (DMA_GetITStatus(DMA1_IT_TC4) != RESET) { DMA_ClearITPendingBit(DMA1_IT_TC4); // 停止DMA通道,防止重复传输 DMA_Cmd(DMA1_Channel4, DISABLE); // 关键:等UART移位寄存器发完最后一位 while (USART_GetFlagStatus(USART1, USART_FLAG_TC) == RESET); // 此时才能安全关闭UART外设时钟 USART_Cmd(USART1, DISABLE); } }

HAL库里则是在HAL_UART_TxCpltCallback回调里做同样的操作。DMA中使用的UART发送,中断回调里等TC的操作几乎可以保证一个字节都不丢。这是我调试DMX灯光控制器和步进电机配置文件时反复验证过的写法。

4. USB转串口工具与上位机侧丢字节的排查

4.1 这类问题经常被误判为MCU故障

实际在论坛里看到的问题,很大一部分根本不是MCU丢字节,而是USB转串口工具或串口调试助手丢显示。FT232R、CP2102N这类USB转串口芯片,内部都有FIFO缓冲,UART接收的数据先进入芯片FIFO,再通过USB批量传输给主机。如果上位机软件的接收线程没有及时读取,或者一次性读取的字节数太大,最后一个字符就可能滞留在工具芯片的缓冲区里,表现成“串口没收到”的样子。

这也是为什么我说,判断这个问题千万不要只盯着串口调试助手的显示框,除非你已经用示波器确认过波形完全没问题。如果MCU输出正常,那就要把排查重点转向工具和软件了。

4.2 驱动与工具设置的排查步骤

先确认USB转串口工具是否被系统正确识别。以FT232R为例,安装完FTDI VCP驱动后,设备管理器里应该能看到一个COM口,设备名带“USB Serial Port”。CP2102N则需要安装Silicon Labs的CP210x驱动,安装后同样会出现对应的COM口编号。如果设备管理器里显示的是未知设备,或者COM口上有黄色感叹号,驱动的状态就不对,数据通路断断续续是正常的。

工具设置方面,检查串口调试助手的接收缓冲区大小。某些调试助手默认缓冲区很小时,数据量大一点就容易被覆盖。再看看“暂停显示”、“接收超时”这些参数,有时候不需要改缓冲区,只需要换一个调试助手再试就能确认问题。我常用XCOM和sscom做交叉验证,同一PC上如果两个工具状态不同,那基本就是软件显示层的锅了。

还有一个很容易被忽略的选项是硬件流控。如果串口助手开启了RTS/CTS流控,而MCU侧没有接对应的流控引脚,主机会在缓冲区快满时拉低RTS,USB转串口芯片就可能暂停向主机上报数据。这会直接导致最后几个字节被“压”在工具里面,等MCU数据发完了,主机才慢慢把数据上报,看起来就是丢尾巴。排查时先把RTS/CTS全部关掉。

4.3 用“加换行结尾”的方法做快速判断

这里有个非常实用的小技巧,当你怀疑上位机显示不及时时,发送字符串时故意在末尾加上\r\n。如果上位机没收到最后一个可见字符,但收到了换行符,那说明MCU发出的数据整体上是完整的,USB转串口芯片也接收到了,只是调试助手的显示逻辑或缓冲刷新有问题。反之,如果加了换行符仍然丢,那大概率是MCU侧真的被截断了。

这个方法不需要示波器也能缩小范围,但因为它只判断“末尾有没有数据”,不能百分百证明中间数据完整,所以只能当快速筛选。我之前用这个方法排查过一个FT231X工具丢字节的问题,加了\r\n后最后一个字符又冒出来了,确认是上位机缓冲区的小毛病,一场虚惊。

5. 实操避坑清单与排查速查表

5.1 五个最容易忽略的细节

第一个是RS485收发器的方向控制。在RS485总线上,DE引脚控制发送方向。很多工程师发送完数据后立刻把DE拉低,让总线回归接收态。问题在于UART的最后一个停止位还在电平转换器和收发器之间传输,DE一拉低,总线直接被切断,最后一个停止位就被“腰斩”了。正确的做法是先等UART的TC标志,再拉低DE。

第二个是电平转换芯片的电源管理。MAX3232、SP3485这类器件如果发送完马上就关闭电源或关闭使能,跟RS485的DE问题一样,波形在中间被截断。低功耗设计里尤其常见,加一个TC后置动作就能解决。

第三个是发送之前没有检查上一次发送是否完成。如果上一次发送还没结束,代码就往TDR里写了新数据,会把正在发送的帧破坏掉。发送前等一次TC,等于给整个外设做一次“清场”。

第四个是printf重定向时只处理TXE。前面已经说过,fputc内部如果只关心TXE,printf打印结束后,最后一位很可能还悬在半空。这种问题用示波器看特别明显,波形最后一个字节的停止位短了一截。

第五个是仿制的USB转串口芯片。市面上很多便宜的USB转TTL模块用的是盗版芯片,驱动兼容性和FIFO行为都跟正品有差异,丢最后一个字节是家常便饭。排查到最后如果所有逻辑都对,换个官方原装芯片的工具,问题也许就消失了。

5.2 排查速查表

现象可能原因排查方法修复建议
发送后立刻关UART时钟,最后一个字符固定丢失移位寄存器数据未完成示波器观察停止位是否被截断在TC置位后再关时钟
使用DMA发送后立刻关DMA/关外设,丢尾字节DMA完成不代表UART发完查看DMA TC中断后波形DMA中断里先等待UART_TC
串口助手显示不全,但示波器波形完整USB转串口FIFO或上位机刷新问题换调试助手或加\r\n测试增大缓冲区或关流控
RSAV发送后DE立即拉低,总线帧错误停止位被收发器截断看总线波形等TC后再切换DE
用printf打印字符串,最后字符丢失fputc只等待TXE在重定向里加TC等待修改fputc实现

5.3 不同平台也适用这个思路

STM32是这样,其他单片机也大同小异。ESP32使用IDF时,UART驱动内部有自己的ringbuffer和发送队列,调用uart_write_bytes后数据还在驱动层排队,如果立刻调用uart_driver_delete会丢失尚未发送的字节。处理方式是先调用uart_wait_tx_done,再删除驱动。

51单片机使用SBUF发送时,查询TI标志是最常见的写法。TI由硬件置位,但如果你在TI置位后马上下一个动作关闭串口中断或直接复位外设,最后一位同样会烂尾。AVR的UART则用TXC位表示传输完成。不同平台名字不同,本质都一样:你要等的是“移位寄存器完全腾空”,而不是“数据寄存器空出来”。

6. 写在最后的实际操作心得

经过这几年越来越多的串口调试,我自己的习惯已经固定下来:不管什么平台,发数据前先看上一帧有没有发完,发完数据后永远等待发送完成标志位,然后再做时钟关闭、引脚切换、方向翻转这些操作。这个习惯帮我省下来的时间,比看完十篇技术文档还值。

对于手头还没有逻辑分析仪的开发者,我真心建议花几十块钱买一个简易的逻辑分析仪。别小看这个工具,UART、SPI、I2C这类协议基本全靠它来定案。串口调试助手只能告诉你“电脑收到了什么”,而逻辑分析仪能告诉你“MCU到底发出了什么”。这两者之间差的,往往就是排查“丢最后一个字节”时的那几个小时。

如果你也遇到过类似问题,不妨按照这篇文章的步骤先测波形、再看工具、最后改代码,大概率能找到你那个“消失的最后一个字节”。

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

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

立即咨询