如果你在 Linux 里折腾过虚拟串口多半见过/dev/pts/x这类设备或者直接用socat创建过的伪终端。平时收发字节一切正常直到某天我往虚拟串口里写了一个“很普通”的字节0x11结果对端怎么都收不到而换成其他任意字节都秒到。这个看似不起眼的字节硬生生折腾了大半个下午最后在termios配置里找到了凶手。这篇文章就把这次排查过程和背后的原理原原本本写出来顺便把 Linux 终端行规程那堆暗坑一并理清楚。整个过程适用于任何用 pty、socat、串口模拟工具做通信开发、自动化测试或者协议联调的人尤其是刚接触虚拟串口、总觉得“这不就是个管道嘛”的开发者。读完你就能明白虚拟串口远不是一根透明的管子它在中间帮你做了不少“好事”而这些好事恰恰会在特定字节上给你使绊子。1. 一个字节引发的灵异事件1.1 虚拟串口是什么以及我怎么搭出来的虚拟串口在 Linux 上最常见的实现就是伪终端pty包含 master 端和 slave 端。master 端通常是/dev/ptmx你打开它之后系统会在/dev/pts/N生成一个对应的 slave 端。任何人往 master 写数据数据会出现在 slave 的读取队列里反过来也一样内核在其中做搬运。这个机制最初是为了支持 xterm、ssh 这类终端软件但由于它在用户态看起来跟真实串口几乎一样很多嵌入式开发和网络模拟场景干脆拿它当串口用。我当时要模拟一个下游设备计划用程序写一个虚拟串口另一个进程从这个串口读取协议帧。搭建方式很简单一行命令就能创建一对串口并互相转发socat -d -d PTY,link/tmp/vcom0 PTY,link/tmp/vcom1这样就有了两个虚拟串口往/tmp/vcom0写的字节能从/tmp/vcom1读到反之亦然。链路建立起来后我写了个小工具往/tmp/vcom0里灌协议数据再用另一个读工具从/tmp/vcom1抓包。前几百个字节全都没问题直到我灌入一帧包含0x11的报文读端立刻发现这一帧少了一个字节。1.2 现象定位只有 0x11 会丢第一个直觉是发送代码写错了。检查发送缓冲写入的确实是完整帧write()返回值也正常。接着怀疑读端程序把字节漏掉了来回翻了几遍解析代码没有任何过滤逻辑。为了搞清楚哪一段出错我直接用最原始的方式做验证从/tmp/vcom0端写一串递增字节00 01 02 03 ... FE FF在/tmp/vcom1端用xxd看输出。结果清晰又诡异其他字节都到了唯独0x11不见踪影。我再换一组字节测试又发现0x13也丢了。0x11之外还有同伙。拿着这两字节去查 ASCII 码表答案瞬间变得很明确0x11是 DC1XON0x13是 DC3XOFF传统串口流控里的一对搭档。这个发现基本锁定了问题方向内核的 tty 子系统在中间“自作主张”处理了流控字节。提示遇到虚拟串口丢字节先别怀疑业务代码先在裸链路上做“写全字节、读全字节”的验证两分钟就能把故障范围压缩到链路层。2. 为什么偏偏是 0x11tty 行规程没有你想的那么透明2.1 终端不止是管道cooked 模式和 raw 模式Linux 的 tty 设备在数据链路中间站着一个“行规程”line discipline默认为 N_TTY。行规程的作用是对经过终端的数据做解释和加工包括回显、换行转换、信号生成、行缓冲等等。这个设计源自上世纪的电传打字机时代却一直保留至今。行规程的存在意味着数据从 master 到 slave中间不是简单透传而是先被内核过滤了一遍。当终端处于 cooked 模式默认模式时输入会被处理成“行”为单位收到回车才把数据交给读取方回车还会被转成换行特殊控制字符如 Ctrl-C、Ctrl-Z 会触发信号而不是作为普通字节传递。所以虚拟串口刚创建出来时它的行为跟你在 shell 里用的终端一模一样并不是传说中干净的字节管道。只有把 termios 参数改成 raw 模式行规程的大部分加工才会停手。2.2 IXON/IXOFF流控字符是最大的暗坑0x11XON和0x13XOFF在串口世界里是软件流控的一对控制字符。XOFF 通知对方“暂停发送”XON 通知对方“继续发送”。termios 里控制这对字符开关的有两个标志位IXON控制接收方向的流控处理IXOFF控制发送方向的流控处理。真正让我丢字节的是IXON标志。IXON开启时从输入流里收到的0x13会让内核暂停向用户态交付数据收到的0x11则恢复交付。当这个机制作用在虚拟串口上时如果你发送0x11作为普通数据内核看到它并不把它当作普通字节上交而是当作“恢复输出”的控制命令直接吞掉。对端读应用自然永远收不到0x11。用一句话解释不是数据丢失了而是数据被内核扣住并“消化”了。0x11恰好处于行规程的“特权字节”名单里于是它成了那个特殊的字节。在真实串口硬件上同样配置下也会出现相同行为只是现在大家用 USB 转串口或虚拟串口多了这个老机制很容易被忽略。2.3 还有哪些字节容易“被特殊”搞清楚0x11之后我把 termios 里所有可能拦截字节的标志位都扫了一遍整理出一张容易踩坑的字节清单字节ASCII 含义默认行为相关标志位0x0DCR可能被转成0x0A或忽略ICRNL,INLCR,IGNCR0x0ALF输出时可能被转成0x0D 0x0AOPOST,ONLCR0x03Ctrl-C触发 SIGINT不进入数据流ISIG0x1ACtrl-Z触发 SIGTSTP不进入数据流ISIG0x11XON被吞掉用作恢复发送IXON0x13XOFF被吞掉用作暂停发送IXON0x7FDEL在某些配置下可能是行擦除字符ICANON,VDISCARD等0x08BS在某些配置下参与行编辑ICANON看到这个表格你就明白一个虚拟串口如果沿用默认终端配置发送二进制协议数据时中招几乎是必然的。只是大多数协议报文里恰好没出现这些字节或者数据包丢那么一两个没被察觉问题就被掩盖了。做通信开发必须把 “字节在链路中会被加工” 作为默认假设而不是默认字节会原样到达。3. 一步步定位用 strace、stty 和 hexdump 锁死真凶3.1 先确认数据进了 master 端排查过程最有价值的一步是确认字节到底有没有进入 master 端。用strace跟踪发送进程的write()调用能看到完整的数据是否真的写入成功strace -e tracewrite -xx ./sender输出里能看到write(3, \x00\x01\x02...\x11\x12..., 256) 256。返回值 256 表示所有字节都交给了内核不是应用层丢的。这一步做完嫌疑落到内核 tty 层。3.2 看 slave 端的 termios 状态接下来用stty查看虚拟串口 slave 端的当前配置这一步能直接暴露问题。假设对端设备是/tmp/vcom1命令如下stty -F /tmp/vcom1 -a要看的关键字段是ixon、opost、icanon、isig。我那次排查时stty输出里清清楚楚写着ixon。再对照一下文档IXON开启时输入流中的 XON/XOFF 会被特殊处理结论已经很清楚了。为了做对照实验我立刻关闭该标志再发一次全字节序列stty -F /tmp/vcom1 raw -ixon这次0x11和0x13都原样出现在读端。至此真凶确认。3.3 排除工具和库的干扰如果你不用stty而是直接在 C 或 Python 里做同样的链路验证记得确认工具自己有没有做额外的行处理。比如 Python 的pty模块创建 pseudo-terminal 后slave 端默认还是 cooked 模式同样会吞0x11。C 语言里如果用open(/dev/ptmx)之后不设置 termios也会踩同一个坑。定位这类问题最容易犯的错误是过早钻进业务协议代码里瞎翻用一条裸链路把发送端和接收端直连做测试效率是最高的。排查顺序建议固化下来先strace看发送端再用hexdump看接收端最后stty看链路中间配置三步之内基本能圈定问题所在。4. 解决方案让虚拟串口真正“裸奔”4.1 用 socat 创建时就进 raw 模式最简单的方式是一开始就用 socat 的raw选项创建虚拟串口并且关掉回显socat -d -d PTY,link/tmp/vcom0,raw,echo0 PTY,link/tmp/vcom1,raw,echo0raw关键字对应底层把 termios 设为原始模式关闭ICANON、ECHO、ISIG、OPOST同时保留IXON和IXOFF吗实测不一定。不同版本的 socat 对raw的定义略有差异保险起见要再显式加-ixon和-ixoff或者干脆在创建后跑一句stty -F /tmp/vcom1 raw -ixon -ixoff。我建议像我一样建完链路后统一用stty做一次终态确认把期望配置明明白白打在配置里。4.2 Python 侧创建 pty 的配置要点用 Python 的pty.openpty()创建虚拟串口时通常拿到的是 master 和 slave 两个 fd但 slave 端 termios 默认还是终端模式。手动把它切到 raw 模式是最可靠的import os import pty import termios import tty master_fd, slave_fd pty.openpty() # 将 slave 端设为 raw 模式同时关闭 software flow control attrs termios.tcgetattr(slave_fd) tty.setraw(slave_fd) # setraw 不会关闭 IXON/IXOFF需要手工清掉 attrs[0] ~(termios.IXON | termios.IXOFF | termios.IXANY) termios.tcsetattr(slave_fd, termios.TCSANOW, attrs)注意tty.setraw()不是万能的它主要关闭ICANON、ECHO、ISIG、IEXTEN、OPOST等但IXON和IXOFF并不总是被清除。所以在setraw之后我再手动清一次IXON/IXOFF。这也是很多人配完raw依然丢字节的原因。4.3 C 代码里用 cfmakeraw 的完整写法C 代码里最省事的是cfmakeraw()它一次性把大多数加工功能关掉。但同样地要看具体平台实现是否包含IXON/IXOFF。Linux 的cfmakeraw()在 glibc 实现里会清掉IXON和IXOFF这一点跟 POSIX 标准没有强约束所以最好显式设置。完整打开虚拟串口并配置 raw 模式的代码模板如下#include fcntl.h #include termios.h #include unistd.h #include stdio.h int main(void) { int master_fd open(/dev/ptmx, O_RDWR | O_NOCTTY); if (master_fd 0) { perror(open ptmx); return 1; } // 这里根据实际情况可能需要先 unlockpt、grantpt // unlockpt(master_fd); // grantpt(master_fd); // slave 端路径一般是 ptsname(master_fd) struct termios tio; tcgetattr(master_fd, tio); cfmakeraw(tio); // 手工再关掉软件流控防止 XON/XOFF 被吞 tio.c_iflag ~(IXON | IXOFF | IXANY); tcsetattr(master_fd, TCSANOW, tio); // 之后读写 master_fd 就是干净的字节流 unsigned char buf[3] {0x11, 0x13, 0x00}; write(master_fd, buf, 3); return 0; }需要留意的是cfmakeraw()之后终端不再做任何行编辑、信号生成、回车转换和回显比较适合二进制协议通信。如果你还需要保留一部分终端行为就不要用cfmakeraw()一把梭而是按 bit 精确控制c_iflag、c_oflag、c_lflag、c_cflag。注意在真实串口设备上比如/dev/ttyUSB0配成 raw 模式同样是必须的。很多串口工具默认就帮你设好了 raw所以平时没察觉一旦自己写程序打开设备节点第一件事就应该是cfmakeraw加关闭流控否则波特率对了一样的坑照样出现。5. 预防与排障虚拟串口场景下的避坑手册5.1 收发双方到底谁来设置 termios虚拟串口有个容易误解的地方master 端和 slave 端的 termios 不是互相独立的。实际操作中你的应用如果打开的是 slave 端比如/dev/pts/1那么在这个 fd 上设置 termios 会直接影响整个伪终端的数据处理行为。而 master 端一般比较少去设 termios因为真正决定行规程行为的通常是 slave 端的配置。这一点在 socat 用法上体现得很典型你用 socat 把/tmp/vcom0和/tmp/vcom1连起来实际读写字节的进程可能分别打开了两个 slave 端。哪一端改成 raw 模式就影响从这一端读入的数据处理。如果接收端进程以 cooked 模式读取就算发送端已经 raw某些输出转换仍然可能发生。最稳妥的做法是链路上的每个访问端都设置成符合预期的 termios 配置不要指望一端配置全局生效。5.2 自动化测试中的固定套路统一封装“干净串口”在自动化测试框架里我养成了一个习惯所有打开串口设备或 pty 的代码统一走同一个初始化函数。这个函数固定设置 raw 模式、关闭流控、清空缓冲区并返回当前配置供日志记录。测试开始时把 termios 关键字段打出来一旦出现字节级问题立刻能从日志里判断链路是否处于干净状态。这比问题发生后临时去查stty -a要省事得多。初始化函数的要点可以归纳为几个固定动作保存原始 termios、清c_iflag的IXON/IXOFF/IXANY/ICRNL/INLCR/IGNCR清c_oflag的OPOST/ONLCR清c_lflag的ICANON/ECHO/ISIG/IEXTENc_cflag里设置 8N1然后tcsetattr立即生效同时tcflush清掉之前可能残留的数据。5.3 还有哪些看似“特殊”的字节需要注意即便配好了 raw不表示所有字节都能无脑收发。有些上层库、驱动或中间层还会继续对数据“加戏”。比如某些 USB 转串口芯片驱动对0x00有丢字节的历史问题某些队列库把0xFF当填充字节过滤还有模拟终端类的工具会对0x1BESC开头的转义序列做解析。排查思路是一致的先在链路两端用全字节扫描法测一遍把“物理层”搞干净了再谈上层协议。全字节扫描法听起来简单却特别实用发送端从0x00到0xFF每个字节单独一帧报文接收端自动记录少了哪些值。跑一轮只要几秒钟能定位大量链路层问题。我排查类似的字节丢失问题几乎都是靠这个办法把问题从“玄学”变成“确定的字节”。5.4 最后再分享一个排障小习惯遇到虚拟串口通信异常我通常会先看一眼当前链路是不是被某个进程以 cooked 模式占用着。排查命令是lsof /dev/pts/x或者fuser /dev/pts/x找到占用进程后看一下它的 termios 设置。如果另一边是个 shell 或者复用了终端行规程的加工行为会让链路表现变得捉摸不定。把占用进程全部换成 raw 模式或者干脆在专用测试环境里只保留读写程序问题往往就消失了。这次跟0x11的交手最大的收获是彻底搞清了 tty 行规程的数据流向。以前写串口程序总把cfmakeraw()当成固定模板抄完就行从来没深究它到底关了哪些开关。现在再看那串标志位背后的行为就变得立体了IXON管着流控字符ICRNL管着回车转换OPOST管着输出加工。每个标志都对应一类让你半夜抓狂的“灵异丢字节”现象。希望这篇排障记录能让你少走一次弯路下次再遇到丢字节先想想你传的那个字节是不是有点“特殊身份”。
阅读完成 · 觉得有帮助?