☰
Linux虚拟串口0x13字节被XOFF拦截:排查与修复实践
2026/10/22 16:36:15 网站建设 项目流程

从一行“数据不对”开始说起。某跨平台系统里,外设模拟器通过Linux虚拟串口和主控程序通信,双方约定的私有协议里有一个固定字段是“一个特殊的字节”,在仿真链路里始终传不过去。主控端要么收不到完整帧,要么收到后校验错乱,丢包率非常随机。折腾了半天,最后发现不是时序问题、不是波特率问题、也不是缓存溢出,而是Linux的tty层把这个特定字节“拦截”了——我说的是0x13,也就是XOFF。在这个虚拟串口的世界里,一个字节不只是字节,它还可能被内核当作流控指令。这篇文章会从虚拟串口的本质讲起,逐步拆解这个特殊字节究竟是怎么“卡”住的,再给出排查和修复的完整路径。

1. 项目整体设计与思路拆解

1.1 从“为什么用虚拟串口”说起

严格说,用虚拟串口是很多嵌入式/工控项目的常规操作。物理串口不够用、UART协议需要单测、多进程需要共享同一个串口数据流时,Linux下的pty(伪终端)体系就成了最直接的替代方案。典型做法是用openpty()拿到一对fd,一端模拟串口设备,另一端当作主控程序读写的数据口。也可以用socat直接创建一对互相连接的pty设备,链路结构更灵活,适合快速联调。

我这次做的模拟项目X,目标是用一个纯软件环境替代原本挂在串口上的外设。外设协议是二进制帧格式,每帧长度不固定,最高有256字节,帧头两个字节,中间是报文内容,结尾两个字节CRC校验。由于模拟器要按固定频率发送数据帧,普通pty的读写吞吐和延迟也足够。理论上,进程A往slave端写,进程B从master端读,两边的工作方式和操作真实串口文件几乎一致。这也是为什么团队一开始都没怀疑是“虚拟串口”本身的问题。

直到那个特殊字节出现。

我们的协议里,数据字段允许出现0x13这个值,正常情况下它就是一个普通负载字节,不涉及任何控制语义。但虚拟串口背后是一套完整的终端模拟层,它默认开启了很多“适合人机交互”的字符处理规则。软件流控、信号生成、输入模式转换、输出模式转换……这些都是为老式终端准备的产物,在二进制裸数据场景下,它们反而成了比物理串口更隐蔽的坑。

1.2 为什么不能只靠“能通就行”来评估

做这类仿真链路时,最容易犯的错误是:只要两端程序能对上收发包,就认为虚拟串口“没问题”。其实这掩盖了一个关键事实——Linux的pty在无配置状态下会像终端一样对待数据流。比如你在shell里敲cat、vim时,按键和回车符都会被层层加工,这是交互体验的需要。但二进制协议里没有“回车”“Ctrl-C”“XOFF”这些概念,每个字节都是数据。

所以问题的本质不是“数据丢没丢”,而是“数据在虚拟串口内部被哪个环节动了手脚”。排查的时候不能只盯应用层,得把视角下放到内核tty层。一个特殊的字节传输失败,往往不是传输问题,而是字节被解释成了控制事件。0x13被当成XOFF就是一个非常典型的例子。

这意味着,做项目设计时就要对终端的默认行为有预期,而不是等出问题再踩雷。接下来我会把pty的机制、termios的关键标志位、一个特殊字节从写入到被读取的完整路径拆开讲,然后再回来看这个项目里0x13到底经历了什么。

2. 虚拟串口的本质与核心原理

2.1 透过/dev/ptmx看穿pty机制

要理解特殊字节为什么会被拦截,先要搞清楚虚拟串口的内部结构。Linux的pty由两部分组成:master(主端)和slave(从端)。master端的文件描述符通过open("/dev/ptmx")得到,slave端的设备节点是/dev/pts/N,由内核动态分配。master是控制端,slave是模拟出来的终端设备,看起来就是一个串口设备文件。

数据流向是这样的:从slave端写入的数据会经过线路规程(line discipline)处理,然后再从master端被读取。反过来,master端写入的数据也会经过同样的线路规程处理后出现在slave端的read结果中。这条路径中间站着一个关键的模块,叫n_tty,也就是默认的终端线路规程。它负责实现所有终端语义:擦除字符、信号处理、回车换行转换、软件流控等等。

这就是“字节不再是字节”的地方。普通文件读写是直通模式,数据进去什么样子出来还是什么样子。但pty的默认行为就像给数据包加了一道“自动翻译程序”,0x0D会被转成0x0A,0x1A会被读成EOF,0x13更特殊——它会被当成暂停输出的指令。这些机制单独看都是特性,但放进二进制协议仿真里,就成了串口通信的隐形杀手。

2.2 termios才是真正的“幕后黑手”

pty的行为由termios结构体控制,关键体现在c_iflag、c_oflag、c_lflag这三组标志位上。每一个标志位都对应一种“字符改写规则”。我用一个表格把容易干扰二进制数据的核心标志位列出来:

标志位所属组作用影响的典型字节
IXONc_iflag启用软件流控0x11(XON)、0x13(XOFF)
IXOFFc_iflag启用软件流控(反向)0x11(XON)、0x13(XOFF)
ICRNLc_iflag回车转成换行0x0D
INLCRc_iflag换行转成回车0x0A
ISTRIPc_iflag剥离字节最高位0x80以上字节
ISIGc_lflag启用信号生成0x03(Ctrl-C)、0x1C(Ctrl-\)
ICANONc_lflag启用规范模式换行符触发行缓冲提交
ECHOc_lflag回显所有输入字节
OPOSTc_oflag输出后处理0x0A转成0x0D 0x0A等

默认情况下,这些标志位大多是置位的。也就是说,你从slave端写入一个0x13,如果IXON开着,内核会把这次写入解释成“对方要求暂停输出”,master端后续的读操作会因此一直阻塞,直到后面又收到了0x11。让我再强调一次:0x13并没有真正丢失,而是它的到来触发了线路规程内部的一个“软暂停”状态,导致你实际要读的数据被留在了tty缓冲区里,读不出去。

这类问题在物理串口上也存在,但物理串口的终端属性通常是硬件驱动初始化时设置成裸数据模式,而虚拟串口尤其是直接openpty创建的场景,默认配置基本就是“终端模式”,没有显式清标志位的话,坑就埋下了。

2.3 为什么偏偏是0x13最容易坑到人

我的调试经验里,大部分虚拟串口字节异常都集中在几个特殊值上:0x00、0x03、0x0D、0x0A、0x11、0x13、0x1A。0x00的问题多数是应用层用C字符串函数处理数据导致截断,不怪内核。0x03和0x1A则可能被ISIG解释成信号和EOF标志。0x0D/0x0A会被ICRNL和OPOST做转换。

但0x13最隐蔽,因为它不会报错、不会触发信号、也不会造成数据截断,它只是让读操作“停住”。表现形式很随机:有时候多传几个字节就恢复,有时候永远卡住。从应用视角看,既不是死锁也不是丢包,特别容易误导人去查发送端定时、线程调度这些完全不相关的地方。

0x13的另一个阴险之处在于,如果数据流里0x13后面很快跟了0x11,问题就自动恢复了。很多偶发性的“慢了一下”或“卡了一瞬间”并没有被当作bug,直到数据量大了以后,0x13连续出现,链路被长时间暂停,问题才暴露出来。

这让我形成了一条经验:在做仿真链路试验时,先把特殊字节全部列出来自查一遍,比事后抓瞎强得多。

3. 实操过程与核心环节实现

3.1 先复现问题:稳定制造一个卡住的链路

为了定位这个问题,我先把场景最小化。程序A往slave端不断发送一帧数据,帧内容故意设计成包含两个0x13字节,中间隔几个普通字节。程序B从master端读取并打印收到的字节和数量。最初用的是最朴素的openpty()加tcgetattr无脑保持默认配置的方式。

第一步当然是写一个能创建pty的辅助函数。简化后的代码大概长这样:

#include <pty.h> #include <stdlib.h> #include <fcntl.h> #include <termios.h> #include <unistd.h> int create_virtual_serial(int* master_fd, int* slave_fd) { char slave_name[64]; struct termios tty; if (openpty(master_fd, slave_fd, slave_name, NULL, NULL) != 0) { perror("openpty"); return -1; } // 先获取当前终端属性 tcgetattr(*slave_fd, &tty); (void)tty; // 暂时不做任何修改,用于复现问题 return 0; }

发送端用一个线程持续写入包含0x13的帧,接收端直接读。实际跑下来,接收端在第N帧之后突然不再输出任何字节。整个进程没有崩溃,也没有报错,只是读操作像是被按下了暂停键。为了进一步确认,我在接收端加了超时判断,超时打印"stalled"。现象非常稳定:只要发送端发出0x13并且后续没有0x11,读端必然停住。

异常被稳定复现后,下一步就是定位它到底发生在哪个环节。

3.2 用strace和hexdump确认数据没有丢

说实话,最开始我也怀疑是发送端的字节在进入pty之前就被过滤了。这个猜测很合理,毕竟虚拟串口也经过线路规程。为了验证,我在程序A的写入点前后各加了一行日志,同时用strace跟踪两个fd上的系统调用。strace的结果很有说服力:

[pid 12345] write(6, "\x01\x13\x02\x03\x13\xff", 6) = 6 [pid 12346] read(7, "", 4096) = ? ERESTARTSYS (To be restarted) [pid 12346] read(7, "", 4096) = 0

注意倒数第二步,read返回了ERESTARTSYS,这是“系统调用被信号/内部状态打断,准备重启”的状态。实际上,它是被tty层的暂停状态阻塞了。write的返回值是6,说明发送端数据完整进入了pty缓冲区,字节并没有在发送过程中丢失。

再用hexdump确认一下master端可读的数据区域。这里就要区分两个缓冲区:slave端写进去之后,数据先进入tty接收缓冲;如果线路规程判定暂停,master端就是读不到任何数据。从strace来看,数据躺在缓冲区里的时间和我期望的完全不符——它被“扣留”了,而不是被“弄丢”了。

这个阶段必须做两个验证:一是确认写入端的write调用都成功了;二是确认read端确实卡在内核态,而不是用户态程序bug。strace里的read一直返回不了正值,且fcntl和ioctl都没有异常,基本就能把嫌疑锁定到tty线路规程。

3.3 用ioctl和TCGETS检查线路规程的标志位

锁定了内核tty层之后,我用一个小脚本直接获取slave端口的termios配置,看看那些关键标志位到底是什么状态。可以用C代码直接打印,也可以在shell里用stty -a -F /dev/pts/xx。

stty -a -F /dev/pts/3

输出中能看到这一行:

ixon ixoff isig icanon echo ...

ixon在,ixoff也在,isig、icanon、echo全都在。这基本就是“终端模式”全家桶,没有一项是不需要的。对二进制数据流来说,它简直就是一个定时炸弹。

到这里问题定位已经非常明确了:0x13被线路规程当成了XOFF。接下来做修复就很简单了——清掉不需要的标志位,调成raw模式。

3.4 修复:正确配置线路规程

修复的核心就一句话:让tty层变成“透传模式”,不做任何解释、转换、回显、信号处理。最省事的做法是调用cfmakeraw(),它会把输入输出标志位全部清掉,只保留必要的波特率设置(pty里其实没有真正的波特率,这个值是形式上的)。它清掉的标志位恰好包括IXON、IXOFF、ISIG、ICANON和ECHO。

修改后的初始化函数:

#include <pty.h> #include <stdlib.h> #include <fcntl.h> #include <termios.h> int create_virtual_serial_raw(int* master_fd, int* slave_fd) { char slave_name[64]; struct termios tty; if (openpty(master_fd, slave_fd, slave_name, NULL, NULL) != 0) { perror("openpty"); return -1; } // 获取终端属性 if (tcgetattr(*slave_fd, &tty) != 0) { perror("tcgetattr"); return -1; } // 关键步骤:把终端设置为raw模式 // 这一步会清掉 IXON/IXOFF/ISIG/ICANON/ECHO/OPOST 等一系列字符处理标志 cfmakeraw(&tty); // 写回从端 if (tcsetattr(*slave_fd, TCSANOW, &tty) != 0) { perror("tcsetattr"); return -1; } return 0; }

改完之后重新跑复现程序,包含0x13、0x11、0x03、0x0D、0x0A这些字节的帧全部透明通过,接收端的read也再没有卡住过。0x13这个特殊的字节,回到了它本来的位置——一个普普通通的负载字节。

3.5 如果不想写C代码:用socat快速搭一条safe链路

很多场景下并不需要嵌入openpty,只要用socat创建一对pty用于调试,也可以直接指定raw选项,避免踩同一个坑。命令很简单:

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

这条命令会创建两个pty设备,比如一个听说是/dev/pts/5、另一个是/dev/pts/6,这两端之间是直连的。raw选项相当于把line discipline调成透传模式,echo=0关掉回显。用这种方式建立虚拟串口链路,从一开始就不会有XOFF/信号/转换之类的干扰。

我自己调试外部工具的时候经常这么干:一端用socat连接我的模拟器,另一端挂一个串口调试助手之类的东西,极其顺手。而且它能做到不需要写一行配置代码就把链路保护在“裸数据”模式下,比Python pty或者C openpty的默认状态稳多了。

4. 常见问题与排查技巧实录

4.1 我踩过的几个高频问题

在搞定0x13之后,我把过去虚拟串口联调中碰到的问题整理成了一组速查表。这些问题经常一股脑儿同时出现,尤其是当你以为“一个特俗字节”的问题解决后就万事大吉时。

现象真正原因解决办法
发送端write返回成功,接收端read永远阻塞0x13触发XOFF暂停,或0x11之后未正常恢复清IXON/IXOFF,或改用raw模式
数据里的0x0A变成了0x0D 0x0A,帧长度变化OPOST和ONLCR做了换行转换清OPOST
收到Ctrl-C字符0x03时进程收到SIGINTISIG启用信号生成清ISIG
数据里0x1A之后的内容消失0x1A被当成EOF处理清ISTRIP? 不对,是清ICANON或查看是否为EOF语义
从master端读到的数据和slave端写入不一致多个flag同时作用,比如ICRNL、INLCR统一用cfmakeraw()
程序退出后pty状态残留,下次启动异常pty状态没有复位启动时强制tcsetattr初始化

这里特别提醒一点:0x1A这个值在某些库和某些模拟器里还会被当成“文件结束符”,但tty层本身对0x1A有两个不同的机制,一处是VEOF控制字符(默认往往是0x04,而不是0x1A),另一处是某些模式下的特殊处理。遇到具体协议时,不要只凭“默认值”想当然,要真正读一下当前termios的控制字符表。

4.2 排查工具的正确打开方式

踩坑之后的总结是:工具不在于多,而在于你知道每个工具能回答什么问题。

  • strace -e trace=read,write,ioctl:解决“数据到底进没进入内核”的问题,也能看ioctl是否被拒绝。
  • hexdump -C:解决“数据是否被改写”的问题,用于确认原始字节流。
  • stty -a -F /dev/pts/N:解决“当前线路规程配置是什么”的问题,是判断特殊字节会不会被处理的直接手段。
  • socat -v:在建立虚拟串口时打开verbose模式,能直接在终端打出来两边传输的原始字节,用于快速确认链路的“洁净度”。
  • cat /proc/xx/fdinfo/N:查看fd的flags,排查是否有人用非阻塞模式干扰了读写判断。

这几个工具帮我快速绕开了一个大坑:不要一上来就用gdb或者加打印日志排查这种问题。内核tty层的行为用gdb根本看不到,printf级别的东西也只能证明“read没返回”,但读不出来原因。先用strace确认系统调用返回码、再用stty看标志位,往往两三分钟就能定位到根因。

4.3 虚拟串口联动场景的避坑清单

如果项目里虚拟串口不止一对,而是多个外设模拟器并联,还会有一些联动性的坑。我整理了几条踩过之后觉得值得分享的经验:

  1. 每一对pty的master和slave端都要分别设置属性,同一对链路内建议统一使用raw模式。如果master端保持默认配置、slave端却是raw模式,某些控制字符仍然可能在master侧的读路径上被处理。
  2. 不要在信号处理的回调函数里直接去读写pty,UART语义里没有“信号中断”这个概念,但tty层有,如果ISIG开着,信号处理逻辑会与读写逻辑交错产生更难复现的卡顿。
  3. 如果模拟器在slave端写数据时使用的是write,但主控端用poll监控master端,记得检查每次poll之后是否要立即调用read直到EAGAIN。因为tty buffer可能滞留若干字节,一次poll就只读一次的做法会让人误以为数据“时有时无”。
  4. 使用socat做永久调试链路时,尽量固定slave设备号的分配方式,否则不同机器上启动顺序不同,设备节点会漂移。这个问题不影响数据本身,但影响自动化脚本的健壮性。
  5. 0x11和0x13这一对字节尤其要警惕。很多协议里XON/XOFF没有被用到,但可能是因为第三方的终端程序把流控关了。换一个环境或者换一个终端工具后,流控又恢复默认,原有模拟器就瞬间“卡死”。

5. 写在最后的一些调整

这次问题排查过程中我反复用的一个心法就是:以“字节”为最小切面来观察。虚拟串口和物理串口看似一样,但数据处理路径上隔着一套为上世纪终端设计的解释层。字节能不能透明通过,完全取决于termios的标志位是否符合二进制数据的期望。

后来我再搭虚拟串口链路,初始化代码里几乎固定会带着cfmakeraw()或者socat ... raw,然后才敢把协议逻辑放到上面去跑。这就像国内很多串口调试工具打开之后默认就是“无转换、不回显”一样,先让链路保持纯净,再谈功能。

最后给一个小技巧:如果某个字节在虚拟串口里表现得很诡异,优先怀疑那些“看起来无意义”的控制字节,尤其把0x03、0x11、0x13、0x1A这四个值单独拉出来测试一遍。哪怕你确定协议里不会用到它们,也要确认链路不会因为它们而产生停顿。“数据正常”和“字节完全透明”在虚拟串口场景下其实是两回事,后者才是模拟仿真的底线。

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

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

立即咨询