Linux虚拟串口特殊字节丢失之谜:termios与pty深入排查

发布时间:2026/10/20 13:50:09
Linux虚拟串口特殊字节丢失之谜:termios与pty深入排查
看到“[Linux][虚拟串口]一个特殊的字节”这个题目我第一反应是想起自己调串口透传时被0x00支配的恐惧。串口数据本来是二进制帧帧里有0x00、0x11、0x13这种在ASCII码里被赋予特殊含义的字节一旦走了虚拟串口接收端拿到的数据要么从0x00处直接截断要么0x11和0x13凭空消失要么平白多出一堆0x0D 0x0A。这篇文章想做的事就是把这种“特殊字节”在Linux虚拟串口链路里被各种机制改写、吞掉、截断的过程一层一层拆开来讲清楚并给出我自己验证过的排查套路。不管你是第一次碰pty还是已经被串口转TCP的丢包折腾了几天的开发者这篇都值得存下来当参考。1. 问题现场0x00、0x13这些字节是怎么在虚拟串口里“消失”的我先把“虚拟串口”这个词界定一下不然下面聊的东西容易飘。在Linux里虚拟串口最常见的是pty伪终端也就是内核提供的一对master/slave设备slave端挂在/dev/pts/N应用层读写它就像读写一个真正的串口设备。除此之外还有用socat把串口转成TCP端口、把TCP端口反向转回设备的用法以及内核里的各种虚拟串口驱动。它们有一个共同点数据要经过tty层的行规则line discipline处理而恰恰是这层处理让“特殊字节”变得特殊。说个我自己的调试现场。当时是一个串口透传的模块A端物理串口接传感器B端通过虚拟串口接上位机。传感器周期性吐一帧数据帧头是0xA5 0x5A帧体里带着0x00、0x11、0x13、0x1A结尾是0x0D 0x0A。上位机收到的帧异常得很有规律帧头偶尔完整但帧体里的0x00导致数据在那里断掉后面全没了0x11和0x13看心情消失结尾的0x0A有时候会变成0x0D 0x0A。这种问题最气人的地方在于它不是偶发而是“稳定复现但又不完全稳定”。你单独发一个0x00没问题你发一串纯文本也没问题。可一旦把它放进某个二进制帧里各种怪事就来了。我最后把链路拆开拿着hexdump在每一段盯着看才确认了罪魁祸首全在tty的“行规则”和termios配置里。1.1 虚拟串口和物理串口的本质区别为什么物理串口上很少出这种问题而虚拟串口上高频出现因为物理串口的驱动模型里数据在UART层面就是裸字节谁也不会把0x00当成“行结束”但pty的设计初衷是模拟“终端”不是模拟“串口”。终端是什么是给人敲键盘用的。人在终端里敲回车会有一个换行敲CtrlC会产生中断信号敲CtrlD会被当成EOF。这些字符在ASCII码里有固定的值0x0D是回车0x0A是换行0x04是EOF0x11和0x13是XON/XOFF。tty层为了让终端好用默认会替用户把这些字节的“语义”处理掉。可串口协议里0x00、0x0A、0x13只是普普通通的数据字节没有任何语义。于是同一个字节在物理串口和虚拟串口之间受到的待遇完全不同。这就是整篇文章的核心虚拟串口的默认配置是“终端模式”而不是“裸数据模式”。你要传输的是二进制数据就必须先把终端模式的所有“好心”全部关掉。1.2 典型异常一帧二进制数据到达对端后的四种变形我这里把最常见的四种异常列一下方便你照着排查。假设你发送的原始字节序列是A5 5A 00 11 22 33 13 0D 0A对端可能出现的情况有这些现象看起来的样子大概率原因0x00处截断只收到A5 5A后面没了规范模式下行缓冲未触发返回条件0x11/0x13消失收到A5 5A 00 22 33缺了两个字节软件流控把字节当控制命令消耗掉0x0A变0x0D 0x0A多出一个0x0DOPOST里的ONLCR输出转换读到0x1A直接结束帧被拆成两段第二段丢失EOF语义或read()返回0导致应用层误判第一种情况和termios的ICANON有关第二种和IXON/IXOFF有关第三种和OPOST有关。接下来两章我分别拆开讲。2. termios行规则终端子系统的“隐形加工厂”tty层对整个数据流的处理可以分为三块输入处理iflag、输出处理oflag、规范模式lflag。这些标志全部集中在termios结构体里。应用层如果不主动配置Linux默认会给你一套适合交互式终端的配置而这套配置对二进制串口数据来说就是一座隐形加工厂。2.1 规范模式与原始模式为什么你的read()迟迟不返回规范模式ICANON的核心逻辑是read()不是按字节返回而是按“行”返回。什么算一行内核看到NL0x0A、EOF0x04、EOL和行结束字符时才把缓冲区里的数据一次性交给应用层。这意味着如果你往虚拟串口里发了一串包含0x00的数据但一直没有发0x0A或0x04对端的read()就会一直阻塞看起来就像数据丢了。其实数据都在内核的缓冲区里躺着只是没到“提交”的时机。我在那个项目里就踩过这个坑。上位机是用read()循环收数据的发帧的结尾没有0x0A只有帧校验和。结果上位机收到帧头加两三个字节之后read()就再也不返回了。用strace看read()阻塞在那边fd上没有数据可读。我一度以为是驱动丢包后来把帧尾部补了一个0x0A数据立刻全出来了——因为那个0x0A充当了“行结束符”。解决思路很明确关掉ICANON让read()有数据就返回一字节也不攒。这就是常说的“原始模式”raw mode。用cfmakeraw()可以一次性把ICANON、ECHO、ICRNL、OPOST等全部关掉是最省心的做法。2.2 ICRNL/ONLCR/IGNCR回车换行被悄悄替换的元凶除了行缓冲输入输出处理还会做字符替换。ICRNL会把收到的0x0D回车转成0x0A换行INLCR则反过来把0x0A转成0x0D。如果你的协议里既有0x0D又有0x0A默认配置下它们会被“归一化”帧边界直接被改写。更常见的是输出方向的ONLCR每次发送0x0A的时候tty会自动在它前面补一个0x0D于是你发的0x0A变成了0x0D 0x0A。这在我们刚才那个例子里就出现了原始帧结尾是0x0D 0x0A接收端却收到0x0D 0x0D 0x0A帧尾多了一个字节校验和必然算不对。这里有个容易忽略的点输出转换OPOST是在“发送路径”上生效的。也就是说即使接收端的配置没问题发送端只要开着OPOST数据就已经在出口被改写了。所以排查的时候发送端和接收端的termios都要检查别只盯着收的那一端。2.3 0x00和0x1A被行缓冲和EOF语义坑掉的两个特殊值0x00这个字节在C语言里是字符串终止符但在tty层它本身没有特殊语义。它会被坑主要还是规范模式行缓冲的问题只要行没结束整个缓冲包括0x00都到不了应用层。而0x1A在某些老式驱动里会被当作EOFVEOF的默认值在一些平台上不是0x04而是0x1A。一旦应用层按“读到EOF就结束”的逻辑处理数据0x1A之后的内容就会被丢进下一轮循环甚至直接被扔掉。我见过一个案例帧里只要出现0x1A对端就会把这一帧拆成两半前半截校验失败后半截被当成新帧头两个错误叠加整个协议彻底乱套。处理办法还是一样的关掉ICANON并且把VEOF、VEOL这些特殊字符的语义一并绕开。原始模式下所有字节都是数据没有任何一个字节会触发“行结束”或“EOF”逻辑。3. IXON/IXOFF软件流控0x11与0x13被“扣留”的真相如果说行缓冲和字符替换像“加工”那软件流控更像“扣留”。0x11和0x13在ASCII码里的正式身份是XON和XOFF属于软件流控字符。它们的本意是让慢速设备告诉对端“你先别发了”或者“你可以继续发了”。这个机制在模拟通信年代很有用但对今天的二进制串口协议来说纯属埋雷。3.1 从模拟通信时代留传下来的流控语义软件流控的逻辑很简单。A和B通过串口通信B处理不过来时往线路上发一个0x13XOFFA收到后就知道该暂停发送B缓过来了再发一个0x11XONA恢复发送。在tty层IXON标志的意思是“本端要识别接收到的XON/XOFF并据此暂停/恢复发送”IXOFF的意思是“本端可以主动发出XON/XOFF来控制对端”。问题在于这两个标志默认是开着的。当你用虚拟串口传一个包含0x11的二进制帧接收端的tty层看到0x11不会把它当作数据交给应用层而是解读为“XON继续发送”。0x13也被同样消耗掉在应用层看来这两个字节就是凭空消失了。更严重的场景是你发送了一个0x13tty层以为要暂停如果后续没有XON出现整条链路会一直处于“暂停”状态表现为彻底挂死。3.2 在虚拟串口里软件流控为什么会造成挂死和丢字节挂死和丢字节是两个层面的结果。丢字节发生在“接收路径”0x11/0x13被tty层识别为流控字符后不会进入接收缓冲区应用层永远读不到。挂死则发生在“发送路径”发送端自己的tty认为收到了XOFF于是把发送队列挂起但协议对端根本没这个概念自然也不会回复XON双方就这么僵住。还有一个更隐蔽的IXANY允许任意字符恢复被暂停的发送。这意味着即使你发了一堆数据只要里面碰巧包含一个普通字符发送端可能又恢复发送了。恢复的时机完全不可控数据时序彻底乱掉。对需要严格时序的协议来说这不只是丢字节而是协议状态机整个错乱。3.3 关闭流控与设置原始模式的代码姿势关掉软件流控的代码并不复杂核心是清掉IXON、IXOFF、IXANY同时把ICRNL、INLCR、IGNCR、OPOST等一锅端。最粗暴但有效的方式是cfmakeraw()它会帮你在当前termios基础上把所有“加工”标志全部复位。我自己的习惯是先用cfmakeraw()再手动确认关键位#include termios.h struct termios tio; tcgetattr(fd, tio); cfmakeraw(tio); tio.c_iflag ~(IXON | IXOFF | IXANY); tio.c_lflag ~(ICANON | ECHO | ISIG); tio.c_oflag ~OPOST; tcsetattr(fd, TCSANOW, tio);Python这边可以直接用tty.setraw()或自行操作termiosimport os, termios, tty fd os.open(/dev/pts/5, os.O_RDWR) tty.setraw(fd) attrs termios.tcgetattr(fd) attrs[0] ~(termios.IXON | termios.IXOFF | termios.IXANY) termios.tcsetattr(fd, termios.TCSANOW, attrs)设置完以后0x11和0x13会老老实实地以数据字节身份出现在接收端。顺便说一句TCP链路里不存在这种tty层的流控问题所以如果你用socat把它转成了TCP这部分反而不用操心真正要操心的我放到第5章讲。4. 复现一个“特殊字节”被改写的最小实验理论说得再多不如亲手复现一次。我用pty搭过一个最小的测试台整个验证流程十分钟就能走完非常适合排查前做基线测试。4.1 用pty.openpty()搭一个虚拟串口测试台核心思路是用pty.openpty()创建一对主从描述符从端就是“设备”主端就是“上位机”。你往从端写数据主端读出来的数据就是虚拟串口链路“加工后”的结果。先用默认配置跑一遍再切到raw模式跑一遍一对比答案自然浮出水面。import os, pty, time, tty master, slave pty.openpty() devname os.ttyname(slave) print(slave device:, devname) test_bytes bytes([0xA5, 0x5A, 0x00, 0x11, 0x22, 0x33, 0x13, 0x0D, 0x0A]) os.write(slave, test_bytes) time.sleep(0.2) data os.read(master, 1024) print(default rcv:, data.hex( )) tty.setraw(slave) os.write(slave, test_bytes) time.sleep(0.2) data os.read(master, 1024) print(raw rcv: , data.hex( ))4.2 默认配置下的字节变化对照表我实际跑下来的结果默认配置下读出来的是这样的默认配置收到的字节序列a5 5a 00 11 22 33 13 0d 0d 0a有几个点值得注意。0x0D 0x0A的结尾变成了0x0D 0x0D 0x0A多了一个0x0D这是ONLCR干的。0x00、0x11、0x13倒是“侥幸”出现在缓冲区里那是因为在pty场景下主从两端都是raw访问没有走规范的read行缓冲。如果你在应用层开了ICANON0x00之后的数据就真的到不了read()了这一点和纯命令行的行为有区别但坑的本质没变。切到raw模式后收到的就是完全一致的原始序列a5 5a 00 11 22 33 13 0d 0a差异对照表如下发送字节默认配置接收结果raw模式接收结果0x0A0x0D 0x0A0x0A0x13可能被流控吞掉0x130x00规范模式下截断0x000x11可能被流控吞掉0x114.3 借助strace和hexdump看清底层发生了什么如果只靠read()的结果还不足以说服你可以在另一个终端里用strace挂上去看strace -f -xx -e traceread,write,ioctl -p pid-xx参数会让strace以十六进制形式打印字节内容。你能清楚看到write()写入的是完整一帧而read()返回的内容已经变了。这个差异就是tty层处理的直接证据。同时我还习惯在数据流中间加一个hexdumpsocat -v pty,link/tmp/vmodem0,raw,echo0 pty,link/tmp/vmodem1,raw,echo0socat的-v参数会把收发数据都打到日志里配合hexdump或者直接用socat的hexdump文本能观察到字节是在哪一段被改写的。把物理链路、虚拟链路、TCP链路分段监控逐段对比一般半小时之内就能锁定问题层。5. 链路再往后TCP透传和用户态缓冲区的“二次加工”关掉termios的坑之后很多人以为问题就结束了其实还有第二层坑。虚拟串口最常见的落地形态是“串口转TCP”socat把/dev/ttyS*桥到某个TCP端口远端上位机通过网络连接来收发。这时候特殊字节又换了一副面孔出问题。5.1 socat桥接TCP之后Nagle算法与粘包问题TCP是字节流协议本身不会丢字节也不会把0x00截断。但TCP的Nagle算法会把小包合并发送减少网络报文数量。这在串口透传里就变成了“粘包”你发三帧数据对端可能一次性收到三段连在一起的数据应用层如果按帧尾的0x0D 0x0A去切帧运气好能切对运气不好0x0A本身也被ONLCR改过多了一个字节切出来的帧全是错位的。解决办法是区分“字节完整性”和“帧完整性”。字节完整性依赖TCP本身只要连接不断字节顺序和内容不会变帧完整性必须由应用层自己维护常见的做法是“长度字段校验”或者“分帧字符转义”。我见过不少项目在串口时代靠特殊字节分帧转到TCP后照搬逻辑结果在延迟和重传的影响下频繁错帧因为延迟、重传、Nagle让时序完全不可预测。5.2 用户态缓冲区与内核缓冲区的两层损耗即使字节完整到达应用层还有两层缓冲区可能出问题。第一层是用户态缓冲区这是read()读出来的数据存放在内存里的区域。如果你的协议栈是一次性把整帧读进固定长度数组而缓冲长度小于帧长多出来的字节会被留在内核侧下一轮read()才能读到。这时如果应用状态机没有处理好“半包”情况就会把一帧数据拆成两次业务处理逻辑立刻错乱。第二层是内核侧缓冲区。对串口tty来说接收缓冲区大小有限如果应用read()不够快缓冲区满了之后新到的数据会被丢弃。对TCP来说接收窗口满时对端会停止发送不会丢数据但会表现为吞吐量下降、延迟变大。在虚拟串口TCP的高波特率场景下这两层问题经常叠加用户态读慢了内核缓冲爆掉TCP窗口又收缩整个链路变得又慢又爱丢。实际排查时我会先量吞吐量再决定是加用户态缓冲还是调内核缓冲参数。5.3 分帧策略与转义特殊字节的另一个变体到了应用层特殊字节的麻烦并没有消失只是换了一种形态。许多协议选择用0x7E或0xAA这类字节作为帧分隔符数据段里如果出现相同的字节就必须转义。转义方案一般是把帧分隔符替换成“转义字符分隔符0x20”之类的形式接收端解码时再还原。这一步看着简单做起来全是细节比如转义字符本身也要转义不然接收端看到分隔符和转义符时无法区分。我自己的建议是尽量别用“特殊字节分帧”这种弱协议改用“固定头长度字段校验和”。长度字段明确接收端可以精确地攒够一帧再处理完全不需要关心数据里有没有特殊字节。如果你的上下游协议已经固定了只能靠特殊字节分帧那就必须自己实现完整的转义与去转义逻辑并且把“数据里出现转义字符”的情况通过单测覆盖到。6. 虚拟串口特殊字节排查的通用套路理论到这里已经讲得差不多了最后把我这些年稳定使用的排查套路整理出来给还没被折磨够的朋友一条顺路。6.1 快速定位的三个命令级检查第一步先把链路整理成最小可测形态本地pty对pty不要接物理串口和网络。第二步用hexdump盯着两端# 终端1 socat -v pty,link/tmp/vm0,raw,echo0 pty,link/tmp/vm1,raw,echo0第三步往/tmp/vm0写一串包含0x00、0x0D、0x0A、0x11、0x13的测试数据看/tmp/vm1读出来什么。如果这里就已经不对问题锁定在termios如果这里正常再往TCP链路走。三层判断下来基本能做到十分钟定位。6.2 分层验证法让字节在每一段都暴露在监控下具体做法是发送端构造带特征值的黄金帧格式里饱含所有“特殊字节”在发送端出口、虚拟串口主端、TCP远端、应用层入口各放一个打印点把收到的原始字节按十六进制打出来。哪一段和上一段不一致问题就在哪一段。这个办法虽然土但比任何日志框架都可靠因为十六进制逐字节比对不会被字符串编码掩盖。6.3 我踩过几次坑之后的习惯最后分享几个我个人的固定习惯。第一凡是涉及串口二进制传输的程序初始化代码里必须显式调用cfmakeraw或等效逻辑绝不依赖默认配置。第二所有日志打印一律用hexdump格式禁止用%s直接打印缓冲区——0x00会让字符串打印戛然而止掩盖真问题。第三每新增一个链路环节加TCP、加docker、加虚拟网卡都重新跑一遍黄金帧测试不要默认新环节是无损的。我踩过最狠的一次是整套链路都通了最后发现是中间某层把数据当成文本做了大小写转换。所以再怎么小心都不过分数据是字节不是文本这句话值得写进代码注释的第一行。