如果你在 Linux 下用 socat 创建过一对虚拟串口,又刚好在调协议时发现对端程序像“死机”了一样毫无反应,那你大概率也撞上过这种“特殊字节”的坑。我这次要聊的,就是我在联调中碰到的一个特殊字节——0x1A,以及它是怎么把整个串口链路“卡”住的。标题里那个“x”,我理解成“排除”和“遭遇”的双关:先是遭遇了一个特殊的字节,再一步步把它从虚拟串口里排除掉。这个过程里涉及到的 tty、pty、termios、线路规程,是很多人遇到问题后第一反应去怀疑“驱动坏了”却没有真正吃透的知识点。
这篇文章就围绕虚拟串口和这个 0x1A 展开,讲清楚底层原理、复现步骤、修复方法,以及我在实际调试中踩过的其他坑。适合正在做嵌入式串口调试、写串口通信工具、或者对 Linux tty 子系统感兴趣的开发者参考。不管你是被这个问题卡住,还是想提前避开,这篇文章都能给你一个完整的排查思路。
1. 虚拟串口是什么,为什么调试时绕不开它
1.1 一个“串口不存在”的开发场景
先讲一个很常见的场景:某项目要开发一个基于串口的外部设备控制协议,但硬件板卡还在打样,手头唯一的串口又被调试终端占用。这时候要么等硬件,要么在电脑上造一对“假串口”出来。后者就是虚拟串口。
虚拟串口从应用层的角度看,和真实串口几乎一样:它有路径、有波特率、可配置 termios、能收发字节。但它背后不是一个物理 UART,而是 Linux 的伪终端(pty)机制。你对着/dev/pts/5写入一段数据,另一端/dev/pts/6就能原样读到,看起来就像两个串口设备之间连了一根虚拟线。
我当时用的工具是 socat,两条命令就能建好一对虚拟串口:
socat -d -d pty,echo=0 pty,echo=0输出类似:
2024/01/05 10:23:45 socat[12345] N PTY is /dev/pts/5 2024/01/05 10:23:45 socat[12345] N PTY is /dev/pts/6 2024/01/05 10:23:45 socat[12345] N starting data transfer loop with FDs...这时/dev/pts/5和/dev/pts/6就是一对互联的串口设备。左侧程序把数据写到/dev/pts/5,右侧程序从/dev/pts/6就能读到,反之同理。这套环境用来模拟外部设备、测试协议、验证上位机逻辑,非常方便。
1.2 伪终端(pty)与 tty 线路规程
虚拟串口底层是 pty 的主从设备对。主设备叫 master,从设备叫 slave。从设备就是你在/dev/pts/N里看到的那一个,应用程序一般打开它来读写;主设备则由 socat、ssh、终端模拟器等持有。
数据在主从之间传递时,并不是简单地从一头搬到另一头。中间有一层叫线路规程(line discipline)的东西,负责对数据做“解释”。
你可以这么理解:线路规程是快递链路上的一个中转站。它不只是把包裹原封不动搬走,还会先看一遍包裹上有没有“危险品”标签。如果有,它会按规则处理——可能是丢弃、可能是转换、可能是直接给某个进程发一个信号。这个机制是上世纪终端时代留下来的产物,目的是让终端设备能处理 Ctrl+C、Ctrl+Z 这类控制字符。可当你想用它来传二进制数据时,这些“贴心”的行为就成了干扰源。
1.3 创建虚拟串口对的几种方式对比
除了 socat,还有几种常见创建虚拟串口的方式:
| 方式 | 命令或接口 | 特点 |
|---|---|---|
| socat | socat -d -d pty,echo=0 pty,echo=0 | 最方便,适合快速搭环境 |
| Python pty 模块 | pty.openpty() | 在脚本里创建一对 fd,适合自动化测试 |
| 程序自建 | open("/dev/ptmx", O_RDWR)后配套调用 | 灵活性最高,需要处理较多细节 |
| Linux 终端自身 | 每个终端窗口本身就是 pty | 不需要额外工具,天然存在 |
socat 的优点是零依赖、一条命令搞定。Python 的pty.openpty()适合你需要在同一个测试脚本里同时操作两端的场景。无论用哪种,最终得到的都是/dev/pts/N这样的设备节点。
有个关键点必须注意:虚拟串口和真实串口在“默认 behavior”上并不完全一致,但两者都受 termios 参数控制。也就是说,你在物理串口上调过的那些raw、-echo、-icrnl选项,在虚拟串口上一样有效,底层都是同一套 tty 机制。这也意味着,真串口上会发生的“特殊字节被拦截”,虚拟串口一样会发生。我这次出问题的根源,恰恰就藏在这层线路规程里。
2. 那个“特殊字节”到底动了什么手脚
2.1 字节从应用层进入 tty 的完整路径
要搞明白为什么一个 0x1A 能搞挂整个链路,得先弄清楚一个字节从应用层写到读端之间发生了什么。
假设你往/dev/pts/5写入一个字节:
write(fd, "\x1a", 1);这个字节会进入 tty 核心,然后传给线路规程。线路规程先判断这个字节要不要特殊处理,比如:
- 当前是不是规范模式(ICANON)?
- 是否开启信号生成(ISIG)?
- 是否启用了软件流控(IXON)?
- 是否需要做回车/换行转换(ICRNL/ONLCR)?
如果这一步通过了,字节才会真正被送到另一端的 slave 设备,等待程序去 read。
问题就出在“线路规程先判断”这个环节。对一个物理串口来说,这个机制可能你已经习惯了:比如向调试串口发送 Ctrl+C,串口另一端的程序会收到 SIGINT。可当你在传输二进制协议数据时,协议里如果恰好有一个 0x1A,它可不会管你“这个字节其实就是普通数据”,它只知道“这是 VSUSP,该挂起前台进程”,于是直接照章办事。
2.2 termios 三张标志表:c_iflag、c_lflag、c_oflag
Linux 的 tty 设备参数由 termios 结构管理,其中有几组关键标志位:
c_iflag:输入模式标志,主要控制输入数据进入线路规程后的处理方式。比如ICRNL会把回车转成换行,IXON会让软件流控生效,ISTRIP会把字节剥掉高位。c_lflag:本地模式标志,主要控制终端的行为。比如ISIG决定是否生成信号字符,ICANON决定是否使用规范模式,ECHO决定是否回显。c_oflag:输出模式标志,主要控制数据输出时的后处理。比如OPOST开启时,ONLCR会把换行转成回车换行。c_cc:特殊控制字符数组,保存 VINTR、VQUIT、VSUSP、VEOF 等字符的实际字节值。
用stty -a -F /dev/pts/5可以查看当前整体配置:
stty -a -F /dev/pts/5输出里有isig icanon echo等标识,这些标识对应 termios 里的ISIG、ICANON、ECHO。如果看到isig、icanon、ixon都是开启状态,就说明该设备正处在“终端控制模式”,而不是“原始数据传输模式”。
2.3 0x1A 的“真实身份”:VSUSP 挂起信号
0x1A 是什么?十进制是 26,ASCII 表里叫 SUB,键盘上是 Ctrl+Z。它在线路规程里的角色是 VSUSP,也就是“挂起”控制字符。
当满足以下条件时,向 tty 输入 0x1A 会触发异常行为:
- 该 tty 是某个会话的控制终端;
- 读取端程序处于前台进程组;
ISIG标志开启。
此时线路规程会向当前前台进程组发送SIGTSTP信号。SIGTSTP 的默认动作是“停止进程”,效果等同于你在 shell 里按了 Ctrl+Z。于是,读端进程就被挂起了。从外面看起来,它既不退出,也不继续读数据,整个链路像死掉了一样。
这就解释了标题里的“特殊”:0x1A 对协议来说是普通数据,对 tty 线路规程来说却是“挂起指令”。你的程序不知道发生了什么,系统日志也不会报错,唯一的变化就是进程状态从 S(sleep)变成了 T(stopped)。
3. 完整复现:一个 0x1A 引发的“假死”
3.1 准备一对虚拟串口
我先把之前踩坑的过程完整复现一遍。环境是 Linux 主机,工具用 socat 和常规的 shell。
先创建一对虚拟串口,注意这一步故意不指定 raw,让 pty 保持默认的终端行为:
socat -d -d pty,echo=0 pty,echo=0记住输出的两个设备节点,假设是/dev/pts/5和/dev/pts/6。
如果担心当前环境的 tty 参数被改过,可以先执行一次 sane 恢复:
stty -F /dev/pts/5 sane stty -F /dev/pts/6 sanesane会把终端参数恢复成合理的默认值,其中就包含isig、icanon、ixon这些标志。这一步很重要,否则后面复现可能被其他残留参数干扰。
3.2 从“发送一个字节”到进程被挂起
打开两个终端窗口:
终端 A 执行读取:
cat /dev/pts/5终端 B 执行写入:
printf '\x1a' > /dev/pts/6正常情况下,如果是普通字节,终端 A 会打印出来。但 0x1A 发过去之后,终端 A 的 cat 没有任何反应,光标不动,程序也不退出。按回车也没有输出。
再用另一个终端确认进程状态:
ps -o pid,stat,cmd -C cat你会看到类似这样的输出:
PID STAT CMD 12345 T cat /dev/pts/5STAT 列里的T就是 stopped,进程被暂停了。这看起来就像“假死”。终端 A 里输入 Ctrl+C 也可能没反应,因为进程已经停止,要先用fg或kill -CONT让它恢复。
我在第一次遇到这个问题时,第一反应是 socat 挂掉了,或者虚拟串口驱动有问题。后来用ps一看,才发现根本不是传输层的问题,cat 进程还活着,只是被 SIGTSTP 停住了。
3.3 strace 拿到“犯罪证据”
要确定是 SIGTSTP 干的,最直接的方式是用 strace 跟踪信号。
先把终端 A 里的 cat 停掉,换成:
strace -f -e trace=signal,read -o /tmp/trace.log cat /dev/pts/5然后在终端 B 再次发送:
printf '\x1a' > /dev/pts/6查看/tmp/trace.log,会看到类似:
read(0, "\x1a", 4096) = 1 --- SIGTSTP {si_signo=SIGTSTP, si_code=SI_KERNEL} ---先读到字节,紧接着进程收到 SIGTSTP,然后被挂起。证据链非常清晰。si_code=SI_KERNEL表示信号是内核产生的,不是其他进程主动kill过来的。
这个排查思路也适用于其他tty异常:只要怀疑数据被“拦截”,先用 strace 看系统调用和信号,远比瞎猜驱动问题高效。看到 SIGTSTP 之后,问题就从“虚拟串口坏了”转到了“tty 参数设置不对”。
3.4 关闭线路规程的“控制欲”:进入 raw 模式
知道了原因是 ISIG 导致的信号生成,解决办法就是让 tty 进入 raw 模式。raw 模式会关闭大部分输入/输出转换,把所有字节当作原始数据透传。
命令行方式:
stty -F /dev/pts/5 raw -echo此时再让终端 A 执行cat /dev/pts/5,终端 B 发送 0x1A,你会看到终端 A 打印出这个字节(或者显示成一个乱码符号),cat 进程不会被挂起。
不过 stty 命令只是给当前设置用的。如果是写程序,就需要在打开设备后,用代码把 termios 设置成 raw 模式。
C 语言里可以直接用cfmakeraw():
struct termios tty; tcgetattr(fd, &tty); cfmakeraw(&tty); tcsetattr(fd, TCSANOW, &tty);Python 里没有现成的 cfmakeraw,需要手动改标志位。我自己常用的完整函数是:
import termios, os, sys def set_raw(fd): attrs = termios.tcgetattr(fd) # c_iflag: 关闭输入转换和软件流控 attrs[0] &= ~( termios.IGNBRK | termios.BRKINT | termios.PARMRK | termios.ISTRIP | termios.INLCR | termios.IGNCR | termios.ICRNL | termios.IXON ) # c_oflag: 关闭输出后处理 attrs[1] &= ~termios.OPOST # c_cflag: 设置为 8 位数据位,无奇偶校验 attrs[2] &= ~(termios.CSIZE | termios.PARENB) attrs[2] |= termios.CS8 # c_lflag: 关闭信号生成、规范模式、回显等 attrs[3] &= ~( termios.ECHO | termios.ICANON | termios.ISIG | termios.IEXTEN ) # c_cc: 设置 VMIN/VTIME attrs[6][termios.VMIN] = 1 attrs[6][termios.VTIME] = 0 termios.tcsetattr(fd, termios.TCSANOW, attrs) left_fd = os.open('/dev/pts/5', os.O_RDWR) set_raw(left_fd)这段代码就是把cfmakeraw做的事情手动做了一遍。设置后再去收发 0x1A,就会发现数据完全是透明的,不会再有进程挂起问题。
提示:raw 模式并不影响波特率,虚拟串口本身也不关心波特率。如果你后面接真实串口,还需要单独设置
cfsetispeed和cfsetospeed。
4. 其它“特殊字节”与虚拟串口联调的其它坑
4.1 tty 层还会偷偷处理哪些字符
0x1A 只是其中一个特殊字节。排查过程中我还总结了一张表,常见容易被 tty 拦截的字节有这些:
| 字节 | ASCII 名称 | termios 角色 | 默认行为 | 相关标志 |
|---|---|---|---|---|
| 0x03 | ETX/Ctrl+C | VINTR | 产生 SIGINT,中断进程 | ISIG |
| 0x1A | SUB/Ctrl+Z | VSUSP | 产生 SIGTSTP,挂起进程 | ISIG |
| 0x1C | FS/Ctrl+\ | VQUIT | 产生 SIGQUIT,终止并生成核心转储 | ISIG |
| 0x04 | EOT/Ctrl+D | VEOF | 规范模式下 read 返回 0,表现为数据结束 | ICANON |
| 0x11 | DC1/Ctrl+Q | VSTART | 恢复被暂停的输出 | IXON |
| 0x13 | DC3/Ctrl+S | VSTOP | 暂停输出,之后的数据可能堆积在缓冲区 | IXON |
| 0x0D | CR | 无 | ICRNL 开启时转成 NL;ONLCR 开启时输出 NL 转 CRNL | ICRNL/ONLCR |
| 0x7F | DEL | VERASE | 规范模式下作为退格符删除前一个字符 | ICANON |
我之前调试一个串口拍照协议时,数据流里出现某个指令会让另一端“卡住”。一开始怀疑是硬件流控,后来才发现问题出在 0x13 上:对方设备开启了 IXON,收到 0x13 后输出被暂停,后续所有指令都堵死在缓冲区里。直到超时时间到了才恢复。这也是典型的不带 raw 模式联调吃过的亏。
如果发送的数据以 0x0D 结尾,而且 tty 开着 ICRNL,那么对端应用 read 出来的 0x0D 可能已经变成了 0x0A。很多自定义二进制协议里,帧尾的 CRC 校验就是这么对不上的。
4.2 虚拟串口与真实串口的行为差异
很多教程会说“Linux 下串口要用 raw 模式”,但没有说为什么,也没有区分虚拟和真实。实际上,虚拟串口和真实串口走的是同一套 tty 核心,所以 termios 标志的影响是一致的。
两者的差别主要体现在:
- 真实串口的读端程序通常只是普通进程,不一定会让该串口成为自己的控制终端,所以 ISIG 触发的信号未必能作用到它身上;
- 虚拟串口如果挂在 shell 终端上,
cat /dev/pts/5这种进程天然处于前台进程组,0x1A 造成的 SIGTSTP 就会立刻生效,现象更明显; - 真实串口的线路规程同样会处理 ICANON、IXON、ICRNL 等,哪怕不是控制终端,数据转换一样会在底层发生。
因此,我的建议是:不管真实还是虚拟,串口联调时只要传的是二进制数据,就一律把 tty 设置成 raw 模式。这不是“可选项”,而是必须项。否则你会遇到很多诡异问题,比如对端明明正确回包了,但这边读到的数据就是多了一个字节或者少了一个字节。
4.3 全字节链路测试:把 0x00 到 0xFF 都发一遍
后来我养成一个习惯:新环境联调前,先做一次“全字节体检”。具体做法是把 0x00 到 0xFF 这 256 个字节依次通过虚拟串口发到对端,对端原样回传,比对结果。这样就能直观看到 tty 层“吞”了哪些字节、改了哪些字节。
简单 Python 脚本如下:
import termios, os, time def set_raw(fd): attrs = termios.tcgetattr(fd) attrs[0] &= ~(termios.IGNBRK | termios.BRKINT | termios.PARMRK | termios.ISTRIP | termios.INLCR | termios.IGNCR | termios.ICRNL | termios.IXON) attrs[1] &= ~termios.OPOST attrs[2] &= ~(termios.CSIZE | termios.PARENB) attrs[2] |= termios.CS8 attrs[3] &= ~(termios.ECHO | termios.ICANON | termios.ISIG | termios.IEXTEN) attrs[6][termios.VMIN] = 1 attrs[6][termios.VTIME] = 0 termios.tcsetattr(fd, termios.TCSANOW, attrs) a_fd = os.open('/dev/pts/5', os.O_RDWR) b_fd = os.open('/dev/pts/6', os.O_RDWR) set_raw(a_fd) set_raw(b_fd) test_data = bytes(range(256)) os.write(a_fd, test_data) received = b'' while len(received) < len(test_data): chunk = os.read(b_fd, 256) if not chunk: break received += chunk mismatch = [] for i in range(min(len(test_data), len(received))): if received[i] != test_data[i]: mismatch.append((i, hex(test_data[i]), hex(received[i]))) if mismatch: print("发现差异:") for pos, send, recv in mismatch: print(f"位置 {pos:#04x}: 发送 {send}, 收到 {recv}") else: print("256 字节全部透传正常")把这段脚本跑一遍,就能在几分钟内确认虚拟串口链路是否“干净”。raw 模式之前和 raw 模式之后各跑一次,对比尤其明显。我之前跑出来的结果是:默认模式下错得离谱,0x0D 被转成 0x0A,0x11 和 0x13 干脆消失,0x1A 直接把另一端进程停住;raw 模式之后,全部一致。用这个脚本做回归验证,比在协议层反复查快得多。
4.4 排查口诀与常用工具清单
围绕这类 tty 问题,我整理了下面几条排查口诀,基本可以对应九成以上的“诡异现象”:
socat -d -d pty,echo=0 pty,echo=0之后,先ls -l /proc/$$/fd确认设备节点;- 出问题先
stty -a -F /dev/pts/5,看 isig、icanon、ixon、opost 这几个标志是不是都开着; strace -f -e trace=signal,read,write抓系统调用,确认是进程问题还是数据问题;- 怀疑特殊字节被拦截时,把 256 字节全扫一遍,直接看差异;
- 发送端、接收端都设成 raw 模式再联调,不要依赖默认参数;
- 如果现象是“进程还在,但不再继续读”,优先查 isig 相关信号,而不是怀疑 socat 断了。
工具方面,最小必要集是 socat、stty、strace、Python 的 termios 模块。socat 负责搭链路,stty 负责查看和修改参数,strace 负责定位信号来源,Python 适合做收发的自动化验证。这一套下来,几乎能应对所有虚拟串口相关的字节处理问题。
另外提一句:socat 创建 pty 时可以直接带raw,echo=0选项,一上来就禁用终端行为:
socat -d -d pty,raw,echo=0 pty,raw,echo=0这个写法在多数情况下能直接避开上面的坑。但如果对端设备或者真实串口被其他程序重新设置了 termios,问题依然会回来,所以掌握排查方法比记住一条命令更重要。
5. 写在最后:这个特殊字节给我的启示
踩过 0x1A 这个坑之后,我最大的体会是:调试虚拟串口,本质上是和 Linux tty 子系统早年残留的“终端习惯”打交道。你以为是数据在传输过程中被丢了,其实是线路规程在“替你着想”,把某个字节理解成了控制指令。所以我后来只要遇到串口联调“莫名卡死”“数据少字节”“对端没反应”,第一反应就是先跑一遍 stty 看参数,再做全字节巡检。大多数情况下,问题都不是出在设备或驱动上,而是出在“这个 tty 仍然处于非 raw 状态”上。
如果你手边也有一条虚拟串口,建议你现在就写个小脚本,把 0x00 到 0xFF 全部扫一遍。亲眼看看哪些字节会被转换、哪些字节会被丢弃、哪些字节能直接把进程挂住,会比看一百篇文章都印象深刻。然后再对比 raw 模式前后的差异,你就能彻底理解“特殊字节”到底特殊在哪里。后面再做真实串口项目,这套经验同样适用,因为底层都是同一个 tty 机制。