在 Linux 下调虚拟串口的时候,你大概率遇到过这种让人想掀桌的现象:两端明明已经连通,普通字节来来往往都正常,但只要报文里混进去一个特殊字节——比如0x13——对端拿到的数据就莫名其妙少了一位,甚至整个链路像被谁按了暂停键,后面所有数据都开始错位。这个特殊字节我在调试笔记里给它起了个代号,就叫标题里的“x字节”。它不是乱码,也不是非法字符,而是很多串口协议里被“精挑细选”出来当作流控信号的控制字符。如果你正被这类问题折磨,这篇文章会带你把这层窗户纸捅破。
我最初是在一个模拟串口终端设备的项目里踩到这个坑的。协议帧里包含长度字段和原始二进制负载,负载的任何字节值都可能出现。测试时发现,只要负载里出现0x13,接收端解析出来的帧就少一个字节;出现连续两个0x13还会让链路卡死。那段时间我还以为是虚拟串口的实现有 bug,后来一步步排查才发现,问题根本不在虚拟串口本身,而在于它背后的 termios 行规程(line discipline)在悄悄改写字节流。这篇文章不绕弯子,直接把排查过程、根因和几种根治方案完整拆给你看。
1. 案发现场:0x13 在虚拟串口里不翼而飞
1.1 先还原一下现场
我当时的测试环境很常见:Host 机上用 socat 创建了一对虚拟串口,一端挂模拟设备端,一端挂被测的上位机程序。模拟设备端按固定周期往外发协议帧,帧结构很简单,前 4 个字节是帧头AA 55 AA 55,接着 1 字节长度,后面带若干字节负载。上位机收到帧头后,按长度字段读取负载,然后做 CRC 校验和解析。
问题出现在压力测试阶段。负载是从随机数填充的缓冲区里截取的,意味着0x00~0xFF每个字节值都会出现。测试跑了不到一分钟,上位机就开始报错:CRC 校验不过、帧长不对、解析出来的字段缺胳膊少腿。我把报错时上位机收到的原始数据打出来一看,帧头都还在,负载长度也对得上,但负载内容里凡是应该出现0x13的位置,全都变成了空——不是变成别的值,而是直接消失了。于是整个负载往后错位一位,CRC 当然怎么算都不对。
1.2 用 xxd 和 hexdump 锁定丢失字节
为了确认丢字节的位置和规律,我在上位机的 read 循环里加了一层原始数据落盘,把每次 read 返回的内容十六进制打印出来。同时对端发送的数据也单独打日志,两边放在一起对比。很快规律就清楚了:
- 发送端发了
01 02 13 03 04,接收端拿到的是01 02 03 04; - 发送端发了
13 11 22 33,接收端拿到的是11 22 33; - 发送端发了
01 13 13 02,接收端拿到的是01 02,读一次就结束了,后续数据像被卡住了一样迟迟不来。
第一条和第二条还能解释成“丢字节”,第三条就非常诡异了:两个连续0x13之后,后面明明是合法的普通数据,但对端就是收不到,像链路被冻结了。这时候我心里基本有数了——0x13这个字节在某个层次被当成了控制信号,而不是普通数据。
为什么偏偏是它?因为0x13的 ASCII 码是 DC3,也叫 XOFF。在串口世界里有另外一对更出名的兄弟:0x11(DC1 / XON)和0x13(DC3 / XOFF),它们合起来是传统的软件流控机制。很多人在应用层写串口程序时根本没考虑这对字节,但底层的 termios 默认配置里很可能开着软件流控开关,于是一发一个准。
2. 虚拟串口对的建立方式和数据通道
2.1 最简 socat 虚拟串口对
先把虚拟串口本身的搭建方式说清楚。Linux 下面最常用的工具是 socat,用它把两个伪终端(pty)连成一对,命令大致长这样:
socat -d -d pty,raw,echo=0,link=/tmp/ttyV0 pty,raw,echo=0,link=/tmp/ttyV1执行后,/tmp/ttyV0和/tmp/ttyV1两个设备文件就出来了。往/tmp/ttyV0写的数据,可以从/tmp/ttyV1读到;反过来也一样。这套东西在嵌入式开发里特别常用,尤其是要模拟串口设备、做自动化测试、或者在没有真实硬件的情况下验证协议栈的场合。
但很多人忽略了一个关键点:这个命令里的raw和echo=0只是让 socat 这一层不搞额外手脚,并不代表两端打开设备文件的进程也自动处在 raw 模式。真正影响你 read/write 字节形态的,是每个终端设备上独立的 termios 配置。也就是说,虚拟串口对本身是一条“管道”,但这条管道的两端各带了一套串口参数,默认参数可能非常“不友好”。
2.2 虚拟串口不等于裸管道:中间还隔着一层 line discipline
这是整篇文章最值得先搞清楚的概念:在 Linux 里,你打开/dev/ttyV0并调用read(),读到的数据并不是简单地从另一端“搬运”过来的原始字节。数据要经过内核里的N_TTY行规程处理,这一层会根据 termios 的配置对字节流做各种加工——包括把回车换行互相转换、把特殊控制字符过滤掉、把鼠标键盘模式下的行编辑功能套在你头上,等等。
我用个不严谨但好懂的说法:真实串口芯片收到的是纯粹的电压波形,驱动把它变成字节流;而虚拟串口的另一端本质是pty,它模拟的是“一台终端设备和一个终端用户之间的交互”。所以默认情况下,它的行为更像一个人在终端里敲键盘,而不是一个单纯的串口外设。你从一个 pty 里读数据,内核会先问一遍“这串字节像是终端用户敲出来的吗?回车要不要转成换行?XOFF 要不要触发流控?”——这一问,就是事故的开端。
想验证这一点很简单,用 Python 打开虚拟串口往对端发一串普通字节,再用hexdump -C去看对端收到的内容。如果你没做任何 raw 设置,0x0A(换行)经常会被扩展成0x0D 0x0A(回车加换行),发出去的字节已经变了形。
3. 干掉这个字节的真凶:termios 软件流控
3.1 XON/XOFF 为什么会“吞”字节
回到我们的0x13。在串口世界里,软件流控利用0x11(XON)和0x13(XOFF)两个信号来协调收发双方的节奏:接收方处理不过来时,发送方发一个 XOFF 让对方暂停;接收方准备好继续接收了,再发一个 XON 让对方恢复发送。
这套机制在硬件流控(RTS/CTS)没接线的老式终端时代非常实用,但现在几乎成了吞字节的隐患。Linux 的 termios 配置里面对应有三个开关:
| 开关 | 作用 | 默认值(典型的 canonical 串口) |
|---|---|---|
IXON | 接收时识别 XON/XOFF,自动暂停或恢复输出 | 常见默认开启 |
IXOFF | 发送 XON/XOFF 给对端,实现接收流控 | 视系统而定 |
IXANY | 任何字符都能重新触发 XON | 通常关闭 |
问题就出在IXON上。当你这端的程序打开虚拟串口时,如果 termios 配置里带上了IXON,内核在读入数据流的时候会盯着看有没有0x11和0x13。一旦看到0x13,它不会把这个字节放进 read 缓冲区交给你,而是直接当作流控信号:暂停本端的输出。于是你的程序从内核缓冲区里读到数据时,0x13已经没了。如果连发两个0x13,本端输出被暂停后,也没有任何机制去切回继续输出,整个链路就像被“踩了刹车”,后面的数据全部堆在内核里。
这就是为什么我第三条测试里01 13 13 02只能读到01 02后面就没了。其实0x13之后确实没有新数据能上来:第一个0x13触发了暂停,第二个0x13又重复了一次暂停状态,但内核一样不会把它交付到用户态。要等一个0x11(XON)才能“松刹车”,可后续数据里没有0x11,于是链路彻底僵住。
3.2 IXON/IXOFF 的生效链路在哪一层
为了说清楚这个坑,我把从对端写入到本端 read 返回的链路拆一下:
- 对端进程
write(fd, buf, len)把数据交给对端虚拟串口; - socat 把这段字节从对端 pty 搬运到本端 pty 的输入队列;
- 本端内核的
N_TTY行规程开始处理输入队列; N_TTY处理器发现0x13且IXON开启,直接执行流控逻辑,字节不进入 read 缓冲区;- 本端进程调用
read(),只能拿到被过滤后的数据。
注意第 3~4 步是内核态完成的,不是某个用户的程序干的。所以你在应用层读不到它,但用strace又能看到read()返回的数据里确实少了一位——这个问题非常隐蔽,因为它发生在 read 系统调用之上、内核 line discipline 之内。你抓包、打日志、加断点都很难发现,只有把 termios 配置摆出来才能看清。
3.3 旁边还有哪些“字节穿越者”:ICRNL/OPOST/ISIG
IXON是吞字节的头号嫌疑,但它不是唯一的一个。我顺带把虚拟串口场景里最容易一起踩中的几个 termios 开关列出来:
ICRNL:把收到的0x0D(回车)转成0x0A(换行)。很多二进制协议里回车和换行都是合法数据,这一个开关就能直接改写负载内容。ONLCR:发送方把0x0A转成0x0D 0x0A,这是输出方向的转换,会让收到的数据凭空多出字节。ISIG:开启后,0x03(Ctrl-C)触发 SIGINT,0x1A(Ctrl-Z)触发 SIGTSTP,应用层根本收不到这两个字节。ECHO:把收到的数据回显回去,导致一端的发送内容绕一圈又出现在自己这边。ICANON:进入规范模式,read 会按行缓冲,0x04(Ctrl-D)会被当成“行结束”,导致 read 提前返回 0。
单独看每一个开关都挺不起眼,但它们叠加在一起,足以让一个看起来正常的二进制串口协议在虚拟串口上跑得稀碎。这也是为什么我对所有调串口的项目都有一个条件反射:先把 termios 整个切成 raw 模式,再去谈协议。
4. 排查链路:从现象到最小复现
4.1 一个能稳定复现丢字节的测试
为了确认根因,我搭了一个最小复现环境。代码很简单,分两端:发送端用 socat 的/tmp/ttyV0,接收端用/tmp/ttyV1。发送端每次发送一组固定的二进制负载,里面带上0x13;接收端用 Python 打开设备,设置好长短超时,持续 read,并把结果打印成 hex。
发送端的核心代码:
import os import time fd = os.open('/tmp/ttyV0', os.O_RDWR) payload = bytes([0x01, 0x02, 0x13, 0x03, 0x04]) while True: os.write(fd, payload) time.sleep(0.5)接收端的核心代码:
import serial ser = serial.Serial('/tmp/ttyV1', 115200, timeout=0.2) while True: data = ser.read(256) if data: print(data.hex(' '))跑起来之后,接收端输出如下:
01 02 03 04 01 02 03 04 01 02 03 040x13每一次都稳定消失。为了排除 Pythonserial库自己做了什么转换,我换成直接用os.read()打开设备节点再读,结果一模一样。到这一步基本能确认:丢字节不是测试程序的锅,是内核层的行为。
4.2 stty -a 看虚拟串口默认加载的 flags
接下来就是看 termios 配置。stty -a -F /dev/ttyV1输出里,重点看这几行:
isig icanon iexten echo echoe echok -echonl -noflsh -xcase -tostop -echoprt echoctl echoke -flusho -extproc -ixoff -tstamp -vmin 1 -vtime 0 ixon -ixany -imaxbel -iutf8 opost -olcuc -ocrnl onlcr -onocr -onlret -ofill -ofdel icrnl -inlcr -igncr -ignbrk -brkint -ignpar -parmrk -inpck -istrip -inlcr翻译成白话就是:这套 termios 默认开着isig、icanon、echo、ixon、opost、onlcr、icrnl。也就是说,它不是 raw 模式,而是非常典型的“终端交互模式”。在这种模式下,0x13被 IXON 吞掉一点也不意外。
我当场又补了一个对照实验:发送端发0x11,接收端同样收不到。0x11是 XON,在 IXON 开启时同样会被内核消化。这两个字节也是我在嵌入式二进制协议中最怕见到的两个值。
4.3 strace 验证应用层和内核层的缝隙
如果你还想要更硬核的证据,那就上strace。在接收进程外面包一层:
strace -f -e trace=read,write -o /tmp/trace.log python3 recv.py然后再运行发送端。打开 trace 日志,你会看到一行典型的输出:
read(5, "\001\002\003\004", 256) = 4注意 read 的返回值是 4,并不是 5。说明内核从行规程交到用户态的字节流里根本没有0x13。这在证据链上非常关键:write 那边确实写了 5 个字节,虚拟串口链路也没有丢包的故障,read 这边却只拿到 4 个字节。那 1 个字节去哪了?答案就是内核的 IXON 流控把它拿去当刹车信号用了。
如果是在真实串口上,这个位就直接被驱动提交上来了;但在虚拟串口/pty 上,line discipline 从一开始就参与了“解释”,所以你既要防硬件层的错误,还要防这一层逻辑上的“翻译腔”。
5. 解决办法:把虚拟串口收拾成 raw 模式
5.1 socat 创建时直接 raw、echo=0
最省事的办法是在源头把两个伪终端都设成 raw 模式。回到第一节那条 socat 命令,raw这个关键字就是干这个的:它会把 pty 的 termios 设置成 raw 模式,关闭 IXON、ICRNL、OPOST 这些乱七八糟的转换。加不加echo=0看场景,如果你希望这一端不要回显对端发来的数据,就显式关掉。
所以我推荐的创建命令是:
socat -d -d pty,raw,echo=0,link=/tmp/ttyV0 pty,raw,echo=0,link=/tmp/ttyV1创建完立刻用stty -a -F /tmp/ttyV0验证一下,这时你应该看到:
-ixoff -ixon -icanon -isig -iexten -opost -icrnl其中-ixon、-icrnl、-opost这些关键项都变成负号,说明行规程不再干预字节流。这是我最推荐的方案,因为一劳永逸,后面所有打开该设备的进程都不会踩雷。
5.2 对已有虚拟串口执行 stty raw
如果你已经用普通模式创建了虚拟串口对,或者接管了一个别人留下的环境,也可以在应用打开设备前手动把设备切成 raw:
stty raw -echo -F /tmp/ttyV0 stty raw -echo -F /tmp/ttyV1这条命令把两个设备都设成 raw,并且关掉回显。注意它只对当前设备的 inode 生效,不是对 socat 进程生效。也就是说,如果某个进程再次打开设备并重新设置 termios,还是会覆盖掉这个配置。所以它更适合用来临时验证、快速止血,不适合当作最终软件交付的依赖。
还有一个细节:如果两端都要跑测试程序,建议在每一端打开设备之后,由程序自己再设置一次 termios,不要假设外部stty命令一定生效了。因为很多库在打开串口时会把 termios 重置成默认值,比如某些版本的 Pythonserial库在open()时会重置配置,你预先stty raw的结果可能被它冲掉。
5.3 应用代码里用 cfmakeraw 一劳永逸
对于正式程序,我的习惯是在打开设备之后立刻用 C 语言的cfmakeraw()把 termios 拍平。cfmakeraw()的本质是一次性关掉一堆开关:ICANON、ECHO、ISIG、IXON、ICRNL、OPOST等全给你清掉,然后把字符长度设成 8 位、禁用奇偶校验,相当于给串口建立一个“只传字节,别做解释”的通道。
一段典型代码长这样:
#include <termios.h> #include <fcntl.h> #include <unistd.h> int fd = open("/tmp/ttyV0", O_RDWR | O_NOCTTY); if (fd < 0) { // 处理打开失败 } struct termios tio; tcgetattr(fd, &tio); cfmakeraw(&tio); tcsetattr(fd, TCSANOW, &tio);cfmakeraw并不是 POSIX 标准函数,但在 Linux 的<termios.h>里基本都有提供,日常开发足够了。如果你想更严格地只改自己关心的开关,可以手动去置位/清零,但绝大多数场景用cfmakeraw最省心。
Python 侧的pyserial其实默认打开串口时会配置成 raw 模式,只要你不额外指定rtscts=True、dsrdtr=True之类,它就天然不启用软件流控。但要注意,如果你绕开pyserial直接用os.open、fcntl或者 C 的open(),就必须自己处理 termios,不能指望默认配置对你友好。
6. 除了 0x13,虚拟串口里还有哪些字节容易被“调包”
6.1 0x1A 被当 EOF
0x1A是 Ctrl-Z 对应的 ASCII 码。在ISIG开启的终端模式下,输入0x1A会触发 SIGTSTP,把进程挂起。就算进程没有因此挂起,这个字节也不会原样进入 read 缓冲区。如果你的协议帧里可能包含0x1A,而接收程序又没有把 termios 拉到 raw,那帧数据就会被这个“人畜无害”的字节截断。
我实际碰到过一个更隐蔽的情况:某设备端固件为了兼容老式终端习惯,会在发送完一帧后追加0x1A表示“文件结束”,结果在虚拟串口上这个字节被ISIG吞掉,倒是没影响帧内容,但如果你依赖收尾字节做帧同步校验,也会对不上。
6.2 0x00 让 C 字符串处理直接截断
严格来说0x00不会被 termios 吞掉,它在线路规程那里是正常通过的。真正的坑在应用层:很多人收到数据后不是按长度处理,而是把它当成 C 字符串,用strlen、strcpy、printf("%s")这类函数去操作。只要负载中间有一个0x00,后面内容全部被丢弃,看起来就跟“特殊字节被吞了”一样。
所以不管调试虚拟串口还是真实串口,我都会先把“按缓冲区长度处理”写进代码规范。任何要从串口读二进制数据的程序,都不应该用字符串 API 来搬运。
6.3 0x0D/0x0A 的 CRLF 互转
虚拟串口的默认配置里,ICRNL负责把收到的0x0D转成0x0A,OPOST配合ONLCR负责把发送的0x0A扩展成0x0D 0x0A。这一来一回,数据就变了味。如果你的协议按长度字段解析还好,只要长度对得上,最多内容变成 CRLF 干扰你比对;要是你按特定字节值做分隔符,那 CR/LF 互转足以把协议打乱。
我遇到过一次离谱的现象:在虚拟串口上跑一个 Modbus 协议栈,回读报文时发现寄存器数据的字节顺序全乱了。排查半天,发现是ONLCR把某些特定的字节序列里某个0x0A扩展成了两个字节,导致帧长度和预期不符,仪器的状态机直接走进了错误分支。所以调试这类协议时,raw 模式几乎是必须的。
6.4 0x03、0x04、0x7F:信号与行编辑的干扰
0x03(Ctrl-C)在ISIG开启时会发送 SIGINT,默认动作是终止进程。0x04(Ctrl-D)在ICANON模式下表示 EOF,read 会立即返回 0,你的程序可能把它误判为“对端关闭了连接”,然后退出循环。0x7F(DEL)在ICANON模式下作为行编辑的退格键,可能被用来删除前面的字符,影响行缓冲内容。
这几个字节在真实串口里如果没开对应 termios 功能,都是普通数据;但在虚拟串口默认配置下,就会被终端语义消化掉。我在测试程序里通常会把isig、icanon、echo这三个大项全部关掉,再按需打开流控开关,这样能最大限度避免“神秘字节消失”问题。
之前那轮排查结束后,我给自己定下了一条规矩:凡是 Linux 下调试虚拟串口或者真实串口,第一件事不是写收发函数,而是先stty -F <device> raw检查一遍,或者在代码里cfmakeraw()。这个习惯帮我避开了很多看起来像“硬件故障”的软件坑。以后再遇到那种“普通数据正常、特殊字节消失、链路偶发卡死”的现象,先不要怀疑虚拟串口的实现,也不要怀疑协议栈的 bug,直接去看行规程里的 IXON、ICRNL、OPOST、ISIG 这些开关——很多时候,答案不在你写的代码里,而在内核替你做的“贴心处理”里。