1. 从“字节被吞”说起:虚拟串口的那些诡异现象
搞嵌入式或者通信开发的朋友,对串口调试肯定不陌生。但如果你试着在Linux上通过虚拟串口(pty,Pseudo Terminal)传输一些“特殊字节”,比如 0x00、0x0A、0x0D、0xFF 这类数据,很快就会发现一些让人抓狂的问题:明明发送端写进去的是完整数据包,接收端读出来的却变了样,要么丢了字节,要么多出几个莫名奇妙的字符,甚至某些字节被“翻译”成了别的值。
这个标题里说的“x一个特殊的字节踊”,其实就是一个典型的虚拟串口传输场景——你有一个特殊的字节序列,想通过虚拟串口在两个进程之间搬运,结果发现数据并不像想象中那样原封不动地到达。我最初遇到这个问题时,还以为是设备驱动出了问题,排查了半天,最后定位到根因竟然是 pty 的线路规程(line discipline)在中间动了手脚。
先说结论:虚拟串口在设计之初,并不是为了做“干净的字节搬运”而生的,它继承了很多终端设备的古老行为。理解这些行为,是解决特殊字节传输问题的前提。无论你是做自动化测试、模拟串口设备,还是在容器里调试串口程序,这篇文章要聊的内容都值得你花几分钟看完。我会把原理、代码、踩坑记录一并拆开讲,争取一次性把这个题目整明白。
2. 虚拟串口选型与核心机制解析
2.1 为什么选 pty 而不是 TCP/Unix Socket
很多人做双进程通信,第一反应是 TCP Socket 或者 Unix Domain Socket。这两种方式确实通用、稳定,但如果你要模拟的是一个真实的串口设备,它们有一个致命缺陷:行为对不上。
真实的串口设备在应用层看来,是一个字符设备文件,比如/dev/ttyS0或/dev/ttyUSB0。串口驱动的语义包括:打开、读、写、ioctl 设置波特率、流控模式、数据位校验位、以及各种线路规程行为。而 TCP/Socket 只提供流式读写,没有设备文件的语义,也没有终端线路规程那一套机制。
嵌入式调试场景下,目标程序往往被写死为操作某个串口路径,也可能直接依赖 termios 参数配置。所以最贴近真实环境的方案,就是用 Linux 的 pty 机制。它天生就具备设备文件形态,Linux 上每一对 pty 都对应一个主设备和一个从设备,程序可以通过操作/dev/pts/N来读写数据,就像操作真实串口一样。更重要的是,pty 自带了终端线路规程行为,这正是我们今天话题的真正要害。
2.2 pty 工作机制与那两把“隐形闸门”
pty 分成两个端点:主端(master)和从端(slave)。从端通常暴露为/dev/pts/N,应用程序打开它当作串口使用;主端由创建者持有,负责往里灌数据或读取从端发来的数据。
听起来很简单,但注意一个细节:数据从主端写到从端,或者从从端写到主端,途中都要经过内核里的线路规程(line discipline)处理。默认的线路规程是n_tty,它最初是为了人机交互终端设计的,所以里面塞满了很多“贴心”的转换规则:
- 把输入里的 CR (
\r, 0x0D) 转换为 NL (\n, 0x0A),取决于 termios 配置; - 把输出里的 NL 转换为 CR-NL 序列;
- 对某些控制字符(如 Ctrl-C、Ctrl-Z)做信号处理;
- 对缓冲区的边沿条件(如行缓冲)进行特殊处理。
这套规则在键盘终端上是合理的,但你想通过 pty 传裸二进制数据,它就是“闸门”——这些转换规则会把你的字节数据悄悄改掉。这就是“特殊字节被破坏”的直接原因。
提示:如果你并非模拟终端,而是想得到一对行为干净、类似串口但无额外干预的通道,可以考虑
socat创建的 pty,再配合 raw 模式;或者直接用openpty配合cfmakeraw来做,后面会具体展开。
2.3 三种常见实现路径对比
列出我在实践中经常用的方案,按推荐程度排序:
| 方案 | 行为纯净度 | 配置复杂度 | 典型场景 |
|---|---|---|---|
| openpty + cfmakeraw | 高 | 中 | 需要精确控制数据面行为,嵌入测试框架 |
| socat 创建 pty | 中 | 低 | 快速验证、临时调试 |
| Python pty 模块 / pexpect | 中 | 低 | 自动化交互场景,非纯二进制 |
如果你只是想快速搞一对虚拟串口来测试程序,socat一条命令很省事:
socat -d -d pty,raw,echo=0 pty,raw,echo=0这个命令会创建两个 pty 从端,名字打印在终端里,比如/dev/pts/3和/dev/pts/4。注意其中raw的语义是设置 raw 模式,避免 n_tty 的字节转换;echo=0是关闭从端回显。这个用法适合临场验证,但如果你要长期稳定的虚拟串口对,用代码创建更可控。
3. 实现一个“干净”的虚拟串口对
3.1 准备基础:Linux 下的 openpty 与 cfmakeraw
动手之前先把基础补上。openpty()是 glibc 封装好的接口,它内部调用posix_openpt()等函数替我们准备好主从端,使用方式如下:
#include <pty.h> #include <termios.h> #include <unistd.h> #include <fcntl.h> int master_fd, slave_fd; struct termios tty_attrs; if (openpty(&master_fd, &slave_fd, NULL, NULL, NULL) == -1) { // 处理错误 }关键在于第四个参数:termp。如果不传入 termios 配置,openpty 会用默认值,而这个默认值就是上面说的 n_tty 的默认行为——这正是我们不想要的东西。所以正确做法是:先用tcgetattr拿到当前的 termios 结构,然后用cfmakeraw(&tty_attrs)把它的所有“花哨行为”关掉,再传给openpty。
cfmakeraw()做了这些事:
- 关闭输入、输出的各种转换映射;
- 关闭回显、信号生成;
- 关闭很多扩展功能;
- 把行缓冲改为字节直通。
从代码层面看,它本质上就是把ICRNL、OPOST、ECHO、ISIG、IXON这些标志位全部清零。让我把关键标志位整理一下,方便你对号入座:
| 标志位 | 作用 | 影响 |
|---|---|---|
| ICRNL | 将 CR 映射为 NL | 0x0D 会被改成 0x0A |
| IGNCR | 忽略 CR | 0x0D 会被直接丢弃 |
| INLCR | 将 NL 映射为 CR | 0x0A 会被改成 0x0D |
| OPOST | 输出处理开关 | 导致 NL 被展开为 CR-NL |
| ECHO | 输入回显 | 主端写入会被回显出来,造成数据翻倍 |
| ISIG | 信号生成 | 0x03 触发 SIGINT、0x1C 触发 SIGQUIT |
| IXON | 输出流控 | 收到 Ctrl-S/Ctrl-Q 会暂停/恢复输出 |
如果你发现传 0x0D 收端变成了 0x0A、传 0x0A 收端变成了 0x0D 0x0A、传 0x03 直接把进程给“掐死”了,不用怀疑,就是这些标志位在作祟。
3.2 编写虚拟串口对的完整代码示例
下面给你一个完整可用的 C 示例,构建一对干净透传的虚拟串口。这个代码可以直接嵌入到你的测试框架里:
#include <pty.h> #include <termios.h> #include <unistd.h> #include <fcntl.h> #include <stdio.h> #include <stdlib.h> #include <string.h> #include <errno.h> int create_virtual_serial_pair(int *master_fd, int *slave_fd, char *slave_name, size_t name_len) { struct termios tty_attrs; // 先把 termios 初始化为当前值,再做 raw 化 if (tcgetattr(STDIN_FILENO, &tty_attrs) == -1) { memset(&tty_attrs, 0, sizeof(tty_attrs)); } cfmakeraw(&tty_attrs); // 关键:把 raw 配置传给 openpty if (openpty(master_fd, slave_fd, slave_name, &tty_attrs, NULL) == -1) { perror("openpty"); return -1; } return 0; } int main(void) { int master_fd, slave_fd; char slave_name[64]; if (create_virtual_serial_pair(&master_fd, &slave_fd, slave_name, sizeof(slave_name)) != 0) { exit(EXIT_FAILURE); } printf("slave device: %s\n", slave_name); printf("master fd: %d\n", master_fd); // 在主端写入特殊字节,然后从从端读取 unsigned char test_data[] = {0x00, 0x0D, 0x0A, 0x1A, 0x03, 0xFF, 0x80}; ssize_t written = write(master_fd, test_data, sizeof(test_data)); if (written == -1) { perror("write master"); } // 从 slave_fd 读回数据 unsigned char buffer[16]; ssize_t nread = read(slave_fd, buffer, sizeof(buffer)); if (nread == -1) { perror("read slave"); } else { printf("read back %zd bytes:", nread); for (ssize_t i = 0; i < nread; i++) { printf(" %02X", buffer[i]); } printf("\n"); } close(master_fd); close(slave_fd); return 0; }编译运行:
gcc -o vspair vspair.c ./vspair注意cfmakeraw和openpty在 glibc 里默认是通过<pty.h>提供的,编译时无需额外链接库。不过某些精减系统可能需要-lutil,如果报openpty未定义,编译命令改成:
gcc -o vspair vspair.c -lutil3.3 读写线程与 EOF 边界处理
如果你运行上面代码,很可能出现“读到 0 字节”或“读不到全部数据”的情况。这并不代表代码错了,而是 pty 的一个特性:主端写入的数据不保证在从端立即全部可见。内核缓冲区、调度时机、以及从端是否处于阻塞模式都会影响结果。
更稳妥的测试方式是:启动两个线程,一个从 slave_fd 阻塞读取,另一个从 master_fd 写入,然后睡眠一小段时间再检查读取线程拿到的数据。但这只是测试逻辑,如果用在真实项目中,你还需要处理一套状态机——比如“从端读到多少字节算一个包”“超时未收到完整包如何处理”。
另外值得留意的点是 EOF 行为。当主端关闭时,从端会读到 EOF(read 返回 0)。这在串口场景上是合理的,但如果你两个程序都在跑,主端不小心退出,对端立刻感知到链路断开。所以要设计好重连或退出的逻辑。
实际项目里,我更推荐给从端设置非阻塞模式,配合poll()或epoll()去轮询主端和从端的读写事件。虚拟串口本质上是文件描述符,能用标准 IO 多路复用来管理,这比裸阻塞读写更从容。
注意:非阻塞模式下,如果缓冲区为空,
read会直接返回-1并把 errno 置为EAGAIN。这是正常现象,不是异常,别写成错误处理。
4. 特殊字节传输:为何“特殊”如此难缠
4.1 特殊字节的分类与触发行为
我这里将“特殊字节”做一个分类,方便你区分不同的坑:
第一类是“控制类字节段”:0x00、0x03、0x04、0x1A、0x1C。它们在 n_tty 模式下有特殊含义。0x03 对应 Ctrl-C,开启 ISIG 后会触发 SIGINT;0x04 是 EOF 字符,从从端输入时会触发一次行结束;0x1A 是 Suspend 字符,默认触发 SIGTSTP。如果这些字节是你的业务数据的一部分,终端机就会抢在应用层之前把它们“消费”掉,所以必须关闭 ISIG。
第二类是“格式类字节段”:0x0A 与 0x0D。它们会被默认可爱的换行/回车映射规则改写,上面表格已经列过。只要 OPOST 打开,输出时 NL 会被扩展为 CR-NL;只要 ICRNL 打开,输入时 CR 会被翻译成 NL。这个改写的性质是“内容变更”,不是仅丢字节,危害更大。
第三类是“流控类字节段”:0x11(XON)和 0x13(XOFF)。开启 IXON 后,从端收到 XOFF 会暂停输出,直到收到 XON 才恢复。如果你的数据里恰好包含 0x13,后面的数据就全部卡住,且没有任何报错提示。这个坑排查起来十分隐蔽——程序没有崩溃,也没有报错,就是“卡住了一会儿”,数据量少时甚至注意不到。
把这些行为写在一起,是因为它们之间会叠加生效,比如 0x0D 先被 ICRNL 转成 0x0A,然后 0x0A 又被 OPOST 展开成 0x0D 0x0A。一个字节的输入,最后变成两个字节的输出,数据包长度直接对不上。
4.2 raw 模式之外的防线:VMIN 与 VTIME
即使你用cfmakeraw配置了纯净的通道,还会遇到一个“半历史遗留”的问题:读取行为上的黏滞。
当从端以阻塞模式打开且没有设置VMIN/VTIME时,read()的返回时机并不以“数据准备好多少”为准,而是以“行缓冲有数据”为准。虽然 raw 模式下不会再按行聚合,但 termios 里还有 VMIN 和 VTIME 两个参数在控制最小读取字节量和超时。
- 设置 VMIN=1、VTIME=0:read 最少读到 1 个字节才返回,否则阻塞。
- 设置 VMIN=0、VTIME=5:read 最多等待 5 秒,时间内没有新字节就返回 0。
- 设置 VMIN=N、VTIME=0:必须凑够 N 个字节才返回,这会导致数据不齐时不返回,容易卡死。
在我自己的实现里,通常这样设置:
tty_attrs.c_cc[VMIN] = 1; tty_attrs.c_cc[VTIME] = 0;然后配合非阻塞 IO 使用。注意:非阻塞模式下 VMIN/VTIME 的意义会退化,read 行为主要受文件描述符状态影响。
4.3 二进制传输场景下要不要加转义
如果你处理的是帧协议(例如自定义帧头帧尾的通信协议),你可能还需要考虑“转义”的问题。虚拟串口本身没有强制的帧边界概念,它就是一个流。所以如果你的协议包含 0x7E 之类的帧标志,传输数据里也出现相同的字节,就需要做 0x7D 转义。这种问题并不属于虚拟串口特有的,但既然聊到特殊字节,就顺便提一句:别把帧同步的可靠性押注在虚拟串口的“透传”上,充分测试各种边界字节才是王道。
我在测试协议栈时,会把 0x00 到 0xFF 的全部 256 个字节都发送一遍,然后比对回传结果。只有全字节比对通过,我才认为这条虚拟串口链路可以用于二进制协议测试。
5. 实战踩坑记录:那些在文档里查不到的细节
5.1 主从两端“回显”导致数据翻倍
有一次我做一个自动化串口回环测试,程序设计是主端写 8 个字节,从端读回来打印。结果从端打印了 16 个字节,前 8 个是原数据,后 8 个重复了一遍。
当时第一反应是代码里的 buffer 重复使用了,排查半天没发现问题。后来才意识到:openpty 的时候没有关闭 ECHO 标志位,主端写入的数据被 pty 的从端默认回显,于是同样数据在外设回环里又被读了一遍。
这类问题会从两个方向出现:
- 从端读到的数据 = 主端写入数据 + 回显数据
- 主端读到的数据 = 从端写入数据 + 回显数据
处理方式很简单,在 termios 配置里把ECHO以及相关家族的ECHONL全部关掉即可。用cfmakeraw已经帮你把它关了,但如果你是手写 flag 配置,千万别漏了。
5.2 0x03 导致进程被 SIGINT 打断
这条坑极其经典。我在某个测试进程里,通过虚拟串口下发一条包含字节0x03的帧数据,结果接收端程序直接收到一个 SIGINT 信号,然后退出了。从应用日志完全看不出来,因为进程不是正常退出,属于被信号终止。
查dmesg没有异常,查进程退出码发现是130或-2,才意识到是 SIGINT。定位之后发现问题出在 pty 默认的 ISIG 标志位。内核在从端把输入字节化作信号发给前台进程之前,应用层什么都没看见。
解决办法就是我前面反复强调的cfmakeraw。如果你因为某些原因不能用这个函数,至少要手动清掉 ISIG,以及可能干扰数据的ICRNL、IGNCR、INLCR、OPOST。
5.3 0x13 导致数据流静默暂停
还有一次,测试脚本里有一段数据正好包含0x13,然后对端程序像是被“冻结”了一样,后面的数据全部收不到。程序既不崩溃,也不报错,就那么挂着。
这种“静默卡死”是排查成本最高的一类问题。最后是在文档里翻到 IXON 流控语义才想起来:XOFF 字节被解释成“暂停输出”,然后一端等着数据,一端等着恢复,双方死等。这个场景下,Ctrl-S/Q不是你在终端里按过才有作用,而是数据流中一旦出现对应字节就会触发。
解决的思路不复杂,要么严格 raw 模式,要么确保你的二进制流里永远不可能出现 0x11/0x13。但后者显然不可控,所以永远优先改配置。
5.4 多个程序抢占同一 pty 从端的行为
虚拟串口从端文件可以同时被多个进程打开吗?技术上可以,但语义上会很怪。各有各的 fd,都去 read,数据会被内核分发,谁先抢到算谁的。这跟真实串口一样,一旦某个进程把从端打开并做了 termios 设置,配置是对全体生效的。
所以在做测试时,务必遵循“谁打开,谁配置,谁负责关闭”的原则。如果测试脚本里一个进程 open slave,另一个进程另外 open slave,两边配置不一致,很可能出现读到一半行为突变的情况。
建议:用一个明确的守护进程来持有从端配置,其他进程只打开它暴露的链路(例如通过主端转运),避免竞争配置。
5.5 关闭文件描述符时的数据丢失问题
最后提醒一个很多人忽略的点:从端关闭时,主端会收到 HUP 信号或读到 EOF;但主端关闭时,从端的读写也可能出现异常。尤其在你写入数据后立即 close,可能对端还没把缓冲区数据读完,链路已经断了。
哪怕在 raw 模式下,数据传输也依赖内核缓冲区的落盘时机。稳妥的做法是:写完数据之后tcdrain()或fsync()一下,等待缓冲数据处理完成再关闭 fd。这个动作在真实串口驱动中同样适用,能有效规避“数据还没走完就断连”的假故障。
6. 虚拟串口在项目中的应用扩展与调试技巧
6.1 如何把虚拟串口接到容器里做设备模拟
现在不少嵌入式 CI 环境跑在容器里。容器里如果程序需要打开/dev/ttyS0,直接在宿主机创建一对 pty,然后将 slave 端路径映射进容器,并在容器内做软链接:
# 宿主机上创建 pty 对 socat -d -d pty,raw,echo=0,link=/tmp/vserial0 pty,raw,echo=0,link=/tmp/vserial1 # 容器启动时挂载 docker run --device /tmp/vserial0:/dev/ttyS0 ...这种方式能让你在容器里用常规的串口调试工具访问到宿主机另一端的数据,体验上和真实串口设备非常接近。但注意--device映射的是字符设备,需要确保 pty 主进程在容器整个生命周期内一直存活,否则容器内的/dev/ttyS0会变成一个悬空节点。
6.2 用 Python 快速验证虚拟串口对行为
如果你想快速验证上面聊到的各种字节行为,用 Python 更快。代码大致是这样:
import os import pty import termios import tty import time master_fd, slave_fd = pty.openpty() attrs = termios.tcgetattr(slave_fd) tty.setraw(slave_fd) termios.tcsetattr(slave_fd, termios.TCSANOW, attrs) slave_name = os.ttyname(slave_fd) print("slave:", slave_name) test_data = bytes([0x00, 0x0D, 0x0A, 0x03, 0x13, 0xFF]) os.write(master_fd, test_data) time.sleep(0.5) data = os.read(slave_fd, 64) print("read back:", data.hex())你自己跑一下,把tty.setraw那一行注释掉再跑一次,对比 read back 的结果差异,立刻就能直观地感受到线路规程做了什么手脚。这是我很推荐的一个实验,花一分钟就能把原理刻进脑子里。
6.3 调试工具推荐:ttyd、socat、strace
排查 pty 问题时,趁手的工具有几个:
socat创建/测试链路,看两端数据转发是否正常;strace跟踪read/write/ioctl调用,看清应用层发起的行为;ttyd这类工具把 pty 暴露成 web 终端,用于调试人机交互场景;cat /proc/<pid>/fd查看进程打开的 fd 指向哪个 pty,方便确认是否误打开了多个。
我排查数据被修改的问题时,第一反应永远是strace -e trace=read,write,ioctl挂到两个进程上,分别观察它们读写的内容。屏幕上能看到数据在哪一个环节变了,基本可以立刻锁定是应用层处理问题,还是 pty 的 termios 在作怪。
6.4 自动化测试中如何构造压力数据
最后聊一下自动化测试的压测方式。如果你要验证自己的串口协议栈足够健壮,建议这样做:
for (int i = 0; i < 256; i++) { unsigned char byte = (unsigned char)i; write(master_fd, &byte, 1); read(slave_fd, &byte, 1); if (byte != (unsigned char)i) { printf("mismatch at byte: 0x%02x\n", i); } }单字节全遍历之后,再做一次随机长度的分片组合测试。重点观察以下两类问题的出现规律:
- 分片边界处是否有字节丢失或重复;
- 特殊字节落在数据包首尾时,行为是否异常。
这种测试方式也能顺便验证协议栈里的转义逻辑是否完备。别的我不多讲,反正实测下来,全字节遍历能找到一堆你以为不可能出问题的 bug。
7. 一些实际操作中的体会
虚拟串口的坑说到底是“终端历史包袱”和“二进制干净传输”这两套诉求的冲突。我对它的操作体会可以总结为三句话:
第一,只要是通过 pty 传业务数据,一律先cfmakeraw,再调VMIN/VTIME,别相信自己手写的 termios 配置能完美覆盖所有场景——你很难在一开始就记住所有需要关闭的标志位。
第二,遇到数据静默卡死,优先怀疑流控字符(0x11/0x13)和信号字符(0x03/0x1A),这两类问题行为隐蔽,且会同时影响主从两端,排查成本极高。
第三,虚拟串口只是测试手段,不要把它当成绝对可靠的传输通道。严谨的链路上仍然需要校验、确认和超时重传机制。如果你在自己的项目里设计了一套“数据经过 pty 永远按原样到达”的假设,那迟早会在特殊字节面前栽跟头。