☰
Linux虚拟串口为何吞掉0x13?stty与termios定位字节丢失
2026/10/19 3:46:00 网站建设 项目流程

当我在一轮项目联调中再次盯着0x13这个字节发呆时,我就知道这段经历值得写下来。那是个很普通的场景:Linux 主机上一个组件需要和另一台设备做串口通信,为了不依赖真实硬件,我在开发环境里用虚拟串口搭了一条调试链路。所有配置看起来都没问题,数据也能来回流动,但有一个字节始终不正常。只要数据里出现0x13,接收端就永远少一个字节。

不是随机丢包,也不是缓冲区溢出。帧结构非常稳定:[0x01][0x02][0x13][0x04][0x05],接收端拿到的永远是[0x01][0x02][0x04][0x05]。最开始我怀疑是发送逻辑里对它做了特殊处理,又在模拟器接收侧加了全量日志,结果仍然一样。后来我把发送内容改为单字节0x13,上一秒还在怀疑代码,下一秒就该怀疑系统了:这个字节根本没有进入接收缓冲区,它在更底层的地方就已经消失了。

这篇内容就从这里展开,带你完整走一遍 Linux 虚拟串口的底层机制、调试方法和最终解决方案。你不需要有很深的内核功底,只要写过简单的串口程序、用过几个 Linux 命令就能看懂。看完之后,你至少能明白三件事:为什么虚拟串口会“偷吃”字节,如何用stty和od这类工具迅速定位问题,以及怎样让虚拟串口表现得像一条真正直通的硬件串口链路。

1. 从一次字节丢失开始:虚拟串口的“幽灵现象”

1.1 现象缩到最小后,坏字节依然顽固

我开始调试时,第一反应是检查发送代码。发送端是一个 C 语言实现的模拟器程序,代码里用了标准的write()系统调用,逻辑非常简单:把缓冲区里的帧原封不动发出去。我甚至把发送函数改成了死循环,只发一个字节0x13,然后在接收端用read()读取,结果依然读不到。

这个结果非常关键。它把嫌疑从应用层排除了一大半,因为如果是我自己的代码把它过滤掉,那我无论如何也能在业务层看到痕迹。既然应用层看不到,那问题就只能在数据从write()到对方read()之间的那一段传输路径上,而这段路径恰好就是 Linux 的虚拟串口。

我当时用的虚拟串口创建方式是socat,数据链路大概长这样:

发送进程 -> /dev/ptmx -> 内核 -> /dev/pts/N -> socat -> /dev/pts/M -> 接收进程

这中间任何一个环节做了字节转换,都会造成这类“无声无息丢失”的现象。为了拿到第一手证据,我放弃了应用层的所有日志,直接在链路两端挂上了od命令去观察原始字节。

1.2 为什么这事值得单独写一篇

可能有人会觉得,一个字节被吞掉而已,换一个通道不就行了?但真实项目里不能这么任性。很多嵌入式设备、工控板卡的通信协议里,0x13、0x11、0x0D、0x0A都是合法的数据字节,而不是控制字符。协议里定义了长度字段、校验和,任何中间某个字节被改动或丢失,整个帧都会解析失败。

而且虚拟串口在研发阶段几乎不可避免:你没有真实硬件的时候,总得有一个东西充当目标设备。如果连虚拟串口的字节路径都搞不清楚,后面所有联调结论都不可信。这就像你拿一把不准的尺子去量一个零件的尺寸,读数再漂亮,结果也是错的。

所以我才下定决心,把这条虚拟串口彻底翻个底朝天。

2. 虚拟串口是什么,以及它和管道、真实串口的区别

2.1 伪终端的基本结构:内核里藏着一个“翻译官”

Linux 的虚拟串口,最底层的实现是伪终端(PTY)。伪终端由一对设备组成:主设备(如/dev/ptmx,或者程序打开的 pty master fd)和从设备(/dev/pts/N)。数据的流动方式和普通管道很像:往主设备写,从设备能读到;往从设备写,主设备也能读到。

但和管道不同的是,伪终端并不只是一个传递字节的管子。在主设备和从设备之间,内核会挂一个叫“行规程”的逻辑层,这套东西原本是为了人机交互设计的。你在终端里敲键盘,它会处理回车换行、删除键、Ctrl+C、Ctrl+S、横向制表符等一堆行为。而这些行为,全部都会作用在字节流上。

用生活化的话说,管道像一根透明的吸管,你放进去什么,对面拿到什么;伪终端像一个自带翻译机的话筒,你以为你在说“0x13”,翻译机认为你要表达“暂停一下”,然后它自己就把这个发音吞掉了,根本没有传到另一头。

2.2 和真实串口的一头热对比

真实串口设备节点,也就是/dev/ttyS0、/dev/ttyUSB0这些,它们背后是 UART 驱动,数据收发直接对应硬件引脚上的电平和时序。如果没有额外开启termios里的转换选项,驱动程序就是一块“透明的玻璃”:你给它什么字节,它就通过物理链路发什么字节。

虚拟串口的 PTY 节点不一样,它背后是一套终端模拟逻辑,默认面向的是“人 + 终端模拟器”的场景,而不是“机器 + 机器”。所以你在默认配置下使用它,相当于让一堆设计给人用的功能,强行作用在二进制协议上,自然会出各种幺蛾子。

这不是说 PTY 不能用于串口模拟,而是说使用之前必须做显式的配置,把“人机交互”相关的功能全部关闭。很多人第一次接触虚拟串口时没意识到这一点,于是很容易遇到和我一样的“幽灵字节”问题。

2.3 socat 创建虚拟串口的最常用命令

我用得最多的命令是这一条:

socat -d -d pty,raw,echo=0 pty,raw,echo=0

它会创建两个伪终端,并且让它们互相连接。加了-d -d之后,输出里会显示新生成的设备路径,大概像这样:

2025/01/12 10:00:01 socat[12345] N PTY is /dev/pts/3 2025/01/12 10:00:01 socat[12345] N PTY is /dev/pts/4

然后我就可以开两个终端窗口,一个执行cat < /dev/pts/3,另一个执行echo hello > /dev/pts/4。第一个窗口会看到 hello。这就是一条最基础的虚拟串口通道。

但注意,命令里的raw,echo=0只影响 socat 自己打开端点的方式。如果我再开一个普通终端用cat去直接读取某个/dev/pts/N,那个 cat 进程是从普通终端 shell 启动的,shell 已经把它的标准输入配置成了交互模式,而不是 raw 模式。数据到达/dev/pts/N之后,照样会被行规程处理。很多“必现”的字节丢失问题,就是在这种配置不对等的情况下产生的。

3. 为什么 0x13 会被系统无情吞掉

3.1 规范模式:字节不是立即投递,而是排队进“行”

终端默认工作在规范模式,对应 termios 里的ICANON标志。这个模式的本意是:用户按回车之前,允许修改输入内容;内核先把这一行攒在缓冲区里,等收到换行符\n,或者缓冲区满了,再把整行内容一次性交给read()。

对串口通信来说,这个模式几乎不可用。你发一个字节,对面程序调用read(),正常情况下应该立刻返回一个字节。但在规范模式下,如果后面没有\n,内核会一直憋着不给你。有些初学者会看到“程序读不出来数据”的典型症状,第一反应是波特率或者接线问题,其实就是ICANON没关。

0x13在这个环节并不会直接消失,但它会让整个链路的“数据节流”变得不可预测。如果某些字节之后没有及时跟上一个换行符,接收端就可能长时间收不到完整数据,给你制造一种“链路假死”的错觉。

3.2 软流控:0x13 被当成了暂停键

真正让0x13消失的元凶,是软流控功能,对应的标志叫IXON。终端协议里有两个特殊字符:VSTART,默认值是0x11,也就是 Ctrl+Q;VSTOP,默认值是0x13,也就是 Ctrl+S。

当IXON开启时,从终端输入方向收到0x13,内核会把它解释为“暂时暂停输出”;收到0x11之后,再恢复输出。重点在于:0x13和0x11这两个字符会被内核直接消费,不会出现在最终的数据流里。

这就能完美解释我遇到的丢字节现象。数据帧里其他字节都顺利通过,只有0x13被“按下了暂停键”,直接消失。而且更麻烦的是,如果0x13出现在数据中间,那它后面的所有内容都会暂时停在缓冲区里,直到遇到0x11才继续前进。这会导致两个症状同时出现:一个字节消失,一段数据延迟。

3.3 不只是 0x13,还有一整排容易出问题的字节

顺着软流控这个思路,我把其他终端标志也梳理了一遍,发现能“偷吃”字节的远不止0x13。整理出来大概是这么一张表:

现象可能原因涉及字节
程序收到信号被杀ISIG开启0x03、0x1C、0x1A
0x0D 变成 0x0AICRNL开启0x0D
0x0D 直接消失IGNCR开启0x0D
0x0A 变成 0x0DINLCR开启0x0A
0x13、0x11 被吞IXON开启0x13、0x11
输出时 0x0A 变成 0x0D 0x0AOPOST+ONLCR开启0x0A
自己看不到控制字符,但调试时出现^XECHOCTL开启可视化回显

这些标志都是 Linux tty 默认配置里的一部分。只要你没有显式关闭,它们就全部生效。所以如果你想用虚拟串口传二进制数据,就不能只躲开一个0x13,而是要关闭一整组标志。

3.4 为什么真实串口没有这么“热情”

真实串口有时也有类似问题,因为串口驱动同样实现了termios的翻译机制。比如我在真实串口上就遇到过0x0D被换成0x0A的经典案例。但真实串口默认的转换行为相对简单,而且很多工业场景会把串口设置成 raw 通信模式,所以问题不容易出现。

虚拟串口则不同。它走的是 PTY 的终端模拟路径,只要你的进程是用默认方式打开/dev/pts/N的,内核就默认你是在“用终端”。它会把IXON、ICANON、ISIG、OPOST全部打开,确保你像一个人在终端里敲键盘那样与它交互。因此虚拟串口上遇到字节被改的概率,比真实串口高得多。

4. 定位过程:一步一步把真凶揪出来

4.1 缩小链路,用最小场景复现

我在排查时没有一上来就钻代码,而是先把链路缩到最小。三个终端窗口,一个 socat 进程,一组固定字节。

第一个终端,启动虚拟串口:

socat -d -d pty,raw,echo=0 pty,raw,echo=0

第二个终端,用od读取对端设备,不信任任何应用层打印:

od -A x -t x1z /dev/pts/4

第三个终端,用printf发送一组字节:

printf '\x01\x02\x03\x13\x04\x05\n' > /dev/pts/3

这里特意用了printf而不是echo,因为echo在不同 shell 里的转义规则不一致,容易把\x13拆成多个字符。用printf才能保证发的确实是单字节0x13。

第一次测试,读端看到的是:

000000 01 02 03 04 05 0a

0x13确实不见了。其他字节都还正常,这已经强烈暗示问题出在终端行规程上,因为应用层的 bug 很难做到如此精准地丢失一个特定值。

4.2 stty 是查看终端“内核配置”的第一工具

接下来我用stty查设备当前的完整配置:

stty -a -F /dev/pts/4

输出很长,但真正有用的就那几行:

speed 38400 baud; rows 0; columns 0; line = 0; intr = ^C; quit = ^\; erase = ^?; kill = ^U; eof = ^D; eol = <undef>; eol2 = <undef>; swtch = <undef>; start = ^Q; stop = ^S; ... isig icanon iexten echo echoe echok -echonl -noflsh ... ixon opost ...

两处信息非常重要。第一,stop = ^S,说明停止字符是 Ctrl+S,正好是0x13。第二,ixon处于开启状态,说明软流控逻辑正在工作。这两个条件加在一起,0x13被吞就是必然结果。

我当初看到这里,心里已经明白问题所在。但为了严谨,我还是补了一个对照实验:把接收端设成原始模式,再测试一遍。

4.3 对照实验:改成一个参数,字节立刻回来

执行下面这条命令,把读取侧改成 raw 模式:

stty raw -F /dev/pts/4

然后重新发送刚才的数据帧:

printf '\x01\x02\x03\x13\x04\x05\n' > /dev/pts/3

再看od的输出:

000000 01 02 03 13 04 05 0a

0x13回来了。这个对比实验足以证明:代码没改,程序没动,只是改了终端标志位,字节路径就完全正常了。

做了这一步我还不过瘾,又补测了几个特殊字节。在关闭IXON之前,发0x11也是消失的;关闭ISIG之前,发0x03会让进程收到 SIGINT 信号;关闭ICRNL之前,发0x0D会被转成0x0A。每一个字节的“失踪”背后,都对应着一组终端标志。

4.4 第 4.3 节需要的对照实验为何不能省

有人可能会问,既然stty输出已经说明问题了,为什么还要做对照实验?因为在没有对照的情况下,你只能得出“终端配置不对”这个推测,但不能直观看到修改前后的字节差异。这个差异是你在和同事讨论、写问题报告时最有说服力的证据。

我在那次调试里,最后提交的问题总结里就附了两张od的输出截图:一张是默认配置下丢字节,一张是 raw 模式下字节齐全。所有看到的人都立刻理解了问题,不需要再听我解释一大段 termios 原理。

5. 虚拟串口桥下流量控制隐藏问题:0x13 只是一个代表人物

5.1 字节说丢就丢的原因,不只是终端层

有时候你以为关闭了IXON就万事大吉,但遇到多路转发场景,还会有另一层问题。比如你用 socat 把/dev/pts/3桥接给一个 TCP 服务端,这时链路两端各有一个 sock 缓冲区和一个 PTY 缓冲区。0x13即使没有被终端层吞掉,也可能会在某个缓冲压力较高的瞬间被丢弃。

我后来还碰到过一次类似现象:发送端是一个二进制协议,帧里同时包含0x13和0x11,关闭软流控之后,数据帧基本正常,但还是偶发丢字节。查到最后,发现是 socat 的-u/-U参数方向配错了,导致数据在某一侧只写了没读,缓冲区溢出后把多余的字节挤掉了。

这也提醒我:字节丢失不一定只有一个根因。碰到疑难杂症时不要急着下结论,先用最小链路确认终端层设置无误,再往上检查缓冲区和桥接方向。

5.2 收到 0x13 却多出其他字符

另一种“特殊字节”现象是收到的数据里出现了本不该出现的字符,比如发送端发了一个字节0x13,接收端却看到了0x5E 0x53,也就是文本形式^S。这不是内核把 0x13 编译成了两个字节,而是接收端的终端模拟器开了ECHOCTL,它在回显时把控制字符显示成可打印形式。

^S是控制字符显示法,并不是数据流里真的有两个字节。如果接收端程序用十六进制打印读取到的数据,它读到的仍然是单个0x13。但如果你用cat在终端屏幕上直接看,屏幕上看到的^S会非常迷惑人。我建议所有涉足串口调试的人都养成一个习惯:不要用cat直接观察原始数据,统一用od、xxd或hexdump。

5.3 调试时的“自问清单”

经过好几次字节消失的排查后,我给自己总结了一份“自问清单”,每次遇到异常都会先过一遍:

  • 是否确认了发送端真的发出了目标字节?我用printf或十六进制文件验证。
  • 接收端是用什么工具看的?如果是终端cat,先换成od。
  • 当前 tty 是否处于 raw 模式?用stty -a -F查看。
  • 显式关闭了IXON、ICANON、ISIG、OPOST吗?不要只依靠某一层的默认值。
  • 链路里有没有多层转发?每一层是否都可能存在缓冲和重排?

这份清单帮我省了大量时间。遇到字节问题,我按顺序排查,基本都能在十分钟内定位到层。

6. 排查手段大全:从 od 到 strace 的实战组合拳

6.1 od 和 xxd 是虚拟串口调试的地基

如果你问我,虚拟串口调试最该随身带的工具是什么,我会说od -A x -t x1z。这条命令能让你看到最真实的字节序列,而不是经过终端解释后的文本。它有几个好处:一是十六进制显示,方便和协议对照;二是-A x显示地址,方便定位偏移;三是-t x1z会在末列打印 ASCII 可打印字符,方便快速扫一眼数据形态。

它的用法很简单:

od -A x -t x1z -v /dev/pts/4

-v选项很关键,它避免 od 把重复行折叠成*,否则你会漏掉连续重复的字节。我第一次调试时没加-v,看到一堆*,还以为是数据中断了,后来才发现是 od 自己偷懒。

6.2 tcpdump 不只能用在实际网卡,也能用来排虚拟串口

如果你的虚拟串口链路里经过了一个 TCP 中转,比如用 socat 把 PTY 桥接到 TCP 端口,那排查链路两端字节差异时,可以在 TCP 这一端抓包。虽然这不是 PTY 层的问题,但能帮你判断数据是在进入 PTY 之前就丢了,还是在 PTY 之后才丢的。

具体做法是:

tcpdump -i lo -X port 9000

配合发送端和接收端的od输出,就能画出一条完整的数据路径:发送进程写入 PTY 的字节、socat 转发到 TCP 的字节、TCP 到达对端进程的字节。只要把三处日志对齐,问题出在哪一段立刻清楚。

6.3 strace:观察系统调用层面的字节流

还有一种情况,应用层日志里显示数据已经发出去,但接收端就是收不到。这时候可以上strace,观察发送进程到底调用了哪些write()和read(),以及返回值是多少。

我试过一次,发现发送进程确实调用了write(),返回长度是 6,但发送的缓冲区里第 4 个字节根本不是0x13,而是一个被业务代码替换过的0x00。应用层日志之所以显示0x13,是因为日志打印在替换操作之前。这个坑用strace一抓就准,能少吵好几轮“不是我的问题”。

strace对虚拟串口调试还有一个好处:可以直接看到open()打开的是哪个设备节点,以及后续是否调用了tcgetattr()、tcsetattr()改变终端属性。很多程序打开 tty 后会在内部重置设置,这通过strace一目了然。

6.4 保存现场:写一个可以反复执行的调试脚本

排查结束后,我习惯把整个环境固化成脚本,方便以后回归测试。脚本内容大致是:

#!/bin/bash # 启动虚拟串口 SOCAT_PID=$(socat -d -d pty,raw,echo=0 pty,raw,echo=0 2>&1 | tee /tmp/vpty.log | ... ) # 从日志里解析出两个 PTY 设备路径 PTY_A=$(grep "PTY is" /tmp/vpty.log | sed -n '1p' | awk '{print $NF}') PTY_B=$(grep "PTY is" /tmp/vpty.log | sed -n '2p' | awk '{print $NF}') # 把两个端点都设置为 raw stty raw -F "$PTY_A" stty raw -F "$PTY_B" # 使用 od 读取 PTY_A od -A x -t x1z -v "$PTY_A" & echo "PTY_A=$PTY_A" echo "PTY_B=$PTY_B"

这个脚本把“创建虚拟串口 + 设置 raw + 挂载观测”一次性完成,后续测试直接复用。我在文章开头提到的问题,后来就是在这个脚本基础上,加上一个发送 0x13 的测试用例,作为整套回归的固定步骤。

7. 不常被提起的几个边角细节

7.1 终端退出时可能悄悄恢复默认配置

这是我最想提醒的一条经验:不要以为你已经设置好了终端属性,它就永远是那个状态。很多终端模拟器或测试工具在退出时会恢复之前的终端参数,导致你下一次测试又回到了默认的交互模式。

我在一次调试里把虚拟串口完全调好了,第二天同事运行同一个测试脚本,又遇到了字节丢失。我一开始也懵了,检查了半天,最后发现是前一次测试用的expect脚本退出时把 tty 恢复成默认状态。后来我在所有测试脚本开头都加了stty raw,这个问题再也没出现过。

7.2 对称性问题:两端都有行规程

虚拟串口链路的两侧都是 PTY 端点,每一侧都可能有行规程。你只改一边是不够的。socat 命令里的raw参数通常会同时影响它打开的两个端点,但如果你自己用代码分别打开这两个设备,就必须各自调用tcsetattr。

实际操作中,我经常看到只把发送端设成 raw、接收端用cat读取的情况。接收端的默认配置照样会把0x0D转成0x0A。所以排查时,我习惯把两侧的 stty 输出都打出来对比,确保两边的标志位一致。

7.3 特殊字节 0x00 也要留意

虽然0x13是那次事件的主角,但0x00也值得一提。终端行规程本身不会吞掉0x00,但很多编程语言里的字符串处理库会把它当成字符串结束符,导致后续数据丢失。如果协议里允许出现0x00,建议接收端不要用 C 字符串函数处理数据,直接按长度读取并写十六进制日志。

我在另一个项目里就踩过这个坑:数据帧明明是[0x00][0x01][0x02],接收端用strlen判断长度,结果认为长度是 0,整个帧直接被丢弃。这个和虚拟串口无关,但二者叠加时,排查难度会加倍。遇到字节问题,永远要记得把“字节是什么”和“上层怎么处理字节”分开看。

8. 一张速查表 + 最后的经验建议

8.1 虚拟串口常见症状对照表

症状最可能原因快速验证修复方案
数据帧缺 0x13IXON开启stty -a看 stop 字符stty -ixon或stty raw
数据帧缺 0x11IXON开启发送 0x11 观察同上
0x0D 变 0x0AICRNL开启发送 0x0D 后用 od 查看stty -icrnl或stty raw
0x0D 直接消失IGNCR开启同上stty -igncr
0x03 触发中断ISIG开启发送 0x03,进程收到信号stty -isig
数据一直读不出来ICANON开启发送内容后加\nstty -icanon
数据里多出 0x0DOPOST+ONLCRod查看换行位置stty -opost或stty raw
屏幕上出现^SECHOCTL回显关 echo 再看stty -echo
数据偶发丢失桥接层缓冲区溢出检查 socat 方向参数修正桥接配置

8.2 我个人处理“特殊字节”问题的固定套路

我把这次调试验证过的方法固化成了一套流程,遇到任何虚拟串口字节异常,先按顺序执行:

先用od -A x -t x1z -v同时观测链路两端,确保看到的是真实字节;再用stty -a -F查看两端终端标志;接着在最简链路上做一次“默认配置与 raw 配置”的对照实验;如果还没有结论,就给链路加日志抓包,从strace的write()/read()返回值确认应用层行为。

这套流程不依赖具体工具,也不限定编程语言。它要解决的核心问题只有一个:在哪个层面、由哪个机制,把哪个字节变成了什么样。只要答案出来了,修复方案几乎都是现成的。

8.3 最后的经验体会

这次调试给我最大的收获,不是学会了stty raw,而是明白了“虚拟串口并不等于真串口”这句话的分量。你在真实串口上可以天然直通的字节路径,在 PTY 上需要显式配置才能得到。平时用虚拟串口做开发时,我会把“两端都设置 raw”写进项目规范,而不是等到出问题再排查。

以后你再遇到某个特殊字节莫名消失,可以先别怀疑业务代码,检查一下终端的IXON、ICANON、ISIG这三个标志位。它们看起来只是几个开关,却实实在在决定了一个字节是走完全程,还是半路消失。

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

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

立即咨询