首页 / 资讯中心 / 文章详情

Linux虚拟串口为何吞掉0x13?stty与termios定位字节丢失

Linux虚拟串口为何吞掉0x13?stty与termios定位字节丢失 ★ FEATURED ARTICLE
当我在一轮项目联调中再次盯着0x13这个字节发呆时我就知道这段经历值得写下来。那是个很普通的场景Linux 主机上一个组件需要和另一台设备做串口通信为了不依赖真实硬件我在开发环境里用虚拟串口搭了一条调试链路。所有配置看起来都没问题数据也能来回流动但有一个字节始终不正常。只要数据里出现0x13接收端就永远少一个字节。不是随机丢包也不是缓冲区溢出。帧结构非常稳定[0x01][0x02][0x13][0x04][0x05]接收端拿到的永远是[0x01][0x02][0x04][0x05]。最开始我怀疑是发送逻辑里对它做了特殊处理又在模拟器接收侧加了全量日志结果仍然一样。后来我把发送内容改为单字节0x13上一秒还在怀疑代码下一秒就该怀疑系统了这个字节根本没有进入接收缓冲区它在更底层的地方就已经消失了。这篇内容就从这里展开带你完整走一遍 Linux 虚拟串口的底层机制、调试方法和最终解决方案。你不需要有很深的内核功底只要写过简单的串口程序、用过几个 Linux 命令就能看懂。看完之后你至少能明白三件事为什么虚拟串口会“偷吃”字节如何用stty和od这类工具迅速定位问题以及怎样让虚拟串口表现得像一条真正直通的硬件串口链路。1. 从一次字节丢失开始虚拟串口的“幽灵现象”1.1 现象缩到最小后坏字节依然顽固我开始调试时第一反应是检查发送代码。发送端是一个 C 语言实现的模拟器程序代码里用了标准的write()系统调用逻辑非常简单把缓冲区里的帧原封不动发出去。我甚至把发送函数改成了死循环只发一个字节0x13然后在接收端用read()读取结果依然读不到。这个结果非常关键。它把嫌疑从应用层排除了一大半因为如果是我自己的代码把它过滤掉那我无论如何也能在业务层看到痕迹。既然应用层看不到那问题就只能在数据从write()到对方read()之间的那一段传输路径上而这段路径恰好就是 Linux 的虚拟串口。我当时用的虚拟串口创建方式是socat数据链路大概长这样发送进程 - /dev/ptmx - 内核 - /dev/pts/N - socat - /dev/pts/M - 接收进程这中间任何一个环节做了字节转换都会造成这类“无声无息丢失”的现象。为了拿到第一手证据我放弃了应用层的所有日志直接在链路两端挂上了od命令去观察原始字节。1.2 为什么这事值得单独写一篇可能有人会觉得一个字节被吞掉而已换一个通道不就行了但真实项目里不能这么任性。很多嵌入式设备、工控板卡的通信协议里0x13、0x11、0x0D、0x0A都是合法的数据字节而不是控制字符。协议里定义了长度字段、校验和任何中间某个字节被改动或丢失整个帧都会解析失败。而且虚拟串口在研发阶段几乎不可避免你没有真实硬件的时候总得有一个东西充当目标设备。如果连虚拟串口的字节路径都搞不清楚后面所有联调结论都不可信。这就像你拿一把不准的尺子去量一个零件的尺寸读数再漂亮结果也是错的。所以我才下定决心把这条虚拟串口彻底翻个底朝天。2. 虚拟串口是什么以及它和管道、真实串口的区别2.1 伪终端的基本结构内核里藏着一个“翻译官”Linux 的虚拟串口最底层的实现是伪终端PTY。伪终端由一对设备组成主设备如/dev/ptmx或者程序打开的 pty master fd和从设备/dev/pts/N。数据的流动方式和普通管道很像往主设备写从设备能读到往从设备写主设备也能读到。但和管道不同的是伪终端并不只是一个传递字节的管子。在主设备和从设备之间内核会挂一个叫“行规程”的逻辑层这套东西原本是为了人机交互设计的。你在终端里敲键盘它会处理回车换行、删除键、CtrlC、CtrlS、横向制表符等一堆行为。而这些行为全部都会作用在字节流上。用生活化的话说管道像一根透明的吸管你放进去什么对面拿到什么伪终端像一个自带翻译机的话筒你以为你在说“0x13”翻译机认为你要表达“暂停一下”然后它自己就把这个发音吞掉了根本没有传到另一头。2.2 和真实串口的一头热对比真实串口设备节点也就是/dev/ttyS0、/dev/ttyUSB0这些它们背后是 UART 驱动数据收发直接对应硬件引脚上的电平和时序。如果没有额外开启termios里的转换选项驱动程序就是一块“透明的玻璃”你给它什么字节它就通过物理链路发什么字节。虚拟串口的 PTY 节点不一样它背后是一套终端模拟逻辑默认面向的是“人 终端模拟器”的场景而不是“机器 机器”。所以你在默认配置下使用它相当于让一堆设计给人用的功能强行作用在二进制协议上自然会出各种幺蛾子。这不是说 PTY 不能用于串口模拟而是说使用之前必须做显式的配置把“人机交互”相关的功能全部关闭。很多人第一次接触虚拟串口时没意识到这一点于是很容易遇到和我一样的“幽灵字节”问题。2.3 socat 创建虚拟串口的最常用命令我用得最多的命令是这一条socat -d -d pty,raw,echo0 pty,raw,echo0它会创建两个伪终端并且让它们互相连接。加了-d -d之后输出里会显示新生成的设备路径大概像这样2025/01/12 10:00:01 socat[12345] N PTY is /dev/pts/3 2025/01/12 10:00:01 socat[12345] N PTY is /dev/pts/4然后我就可以开两个终端窗口一个执行cat /dev/pts/3另一个执行echo hello /dev/pts/4。第一个窗口会看到 hello。这就是一条最基础的虚拟串口通道。但注意命令里的raw,echo0只影响 socat 自己打开端点的方式。如果我再开一个普通终端用cat去直接读取某个/dev/pts/N那个 cat 进程是从普通终端 shell 启动的shell 已经把它的标准输入配置成了交互模式而不是 raw 模式。数据到达/dev/pts/N之后照样会被行规程处理。很多“必现”的字节丢失问题就是在这种配置不对等的情况下产生的。3. 为什么 0x13 会被系统无情吞掉3.1 规范模式字节不是立即投递而是排队进“行”终端默认工作在规范模式对应 termios 里的ICANON标志。这个模式的本意是用户按回车之前允许修改输入内容内核先把这一行攒在缓冲区里等收到换行符\n或者缓冲区满了再把整行内容一次性交给read()。对串口通信来说这个模式几乎不可用。你发一个字节对面程序调用read()正常情况下应该立刻返回一个字节。但在规范模式下如果后面没有\n内核会一直憋着不给你。有些初学者会看到“程序读不出来数据”的典型症状第一反应是波特率或者接线问题其实就是ICANON没关。0x13在这个环节并不会直接消失但它会让整个链路的“数据节流”变得不可预测。如果某些字节之后没有及时跟上一个换行符接收端就可能长时间收不到完整数据给你制造一种“链路假死”的错觉。3.2 软流控0x13 被当成了暂停键真正让0x13消失的元凶是软流控功能对应的标志叫IXON。终端协议里有两个特殊字符VSTART默认值是0x11也就是 CtrlQVSTOP默认值是0x13也就是 CtrlS。当IXON开启时从终端输入方向收到0x13内核会把它解释为“暂时暂停输出”收到0x11之后再恢复输出。重点在于0x13和0x11这两个字符会被内核直接消费不会出现在最终的数据流里。这就能完美解释我遇到的丢字节现象。数据帧里其他字节都顺利通过只有0x13被“按下了暂停键”直接消失。而且更麻烦的是如果0x13出现在数据中间那它后面的所有内容都会暂时停在缓冲区里直到遇到0x11才继续前进。这会导致两个症状同时出现一个字节消失一段数据延迟。3.3 不只是 0x13还有一整排容易出问题的字节顺着软流控这个思路我把其他终端标志也梳理了一遍发现能“偷吃”字节的远不止0x13。整理出来大概是这么一张表现象可能原因涉及字节程序收到信号被杀ISIG开启0x03、0x1C、0x1A0x0D 变成 0x0AICRNL开启0x0D0x0D 直接消失IGNCR开启0x0D0x0A 变成 0x0DINLCR开启0x0A0x13、0x11 被吞IXON开启0x13、0x11输出时 0x0A 变成 0x0D 0x0AOPOSTONLCR开启0x0A自己看不到控制字符但调试时出现^XECHOCTL开启可视化回显这些标志都是 Linux tty 默认配置里的一部分。只要你没有显式关闭它们就全部生效。所以如果你想用虚拟串口传二进制数据就不能只躲开一个0x13而是要关闭一整组标志。3.4 为什么真实串口没有这么“热情”真实串口有时也有类似问题因为串口驱动同样实现了termios的翻译机制。比如我在真实串口上就遇到过0x0D被换成0x0A的经典案例。但真实串口默认的转换行为相对简单而且很多工业场景会把串口设置成 raw 通信模式所以问题不容易出现。虚拟串口则不同。它走的是 PTY 的终端模拟路径只要你的进程是用默认方式打开/dev/pts/N的内核就默认你是在“用终端”。它会把IXON、ICANON、ISIG、OPOST全部打开确保你像一个人在终端里敲键盘那样与它交互。因此虚拟串口上遇到字节被改的概率比真实串口高得多。4. 定位过程一步一步把真凶揪出来4.1 缩小链路用最小场景复现我在排查时没有一上来就钻代码而是先把链路缩到最小。三个终端窗口一个 socat 进程一组固定字节。第一个终端启动虚拟串口socat -d -d pty,raw,echo0 pty,raw,echo0第二个终端用od读取对端设备不信任任何应用层打印od -A x -t x1z /dev/pts/4第三个终端用printf发送一组字节printf \x01\x02\x03\x13\x04\x05\n /dev/pts/3这里特意用了printf而不是echo因为echo在不同 shell 里的转义规则不一致容易把\x13拆成多个字符。用printf才能保证发的确实是单字节0x13。第一次测试读端看到的是000000 01 02 03 04 05 0a0x13确实不见了。其他字节都还正常这已经强烈暗示问题出在终端行规程上因为应用层的 bug 很难做到如此精准地丢失一个特定值。4.2 stty 是查看终端“内核配置”的第一工具接下来我用stty查设备当前的完整配置stty -a -F /dev/pts/4输出很长但真正有用的就那几行speed 38400 baud; rows 0; columns 0; line 0; intr ^C; quit ^\; erase ^?; kill ^U; eof ^D; eol undef; eol2 undef; swtch undef; start ^Q; stop ^S; ... isig icanon iexten echo echoe echok -echonl -noflsh ... ixon opost ...两处信息非常重要。第一stop ^S说明停止字符是 CtrlS正好是0x13。第二ixon处于开启状态说明软流控逻辑正在工作。这两个条件加在一起0x13被吞就是必然结果。我当初看到这里心里已经明白问题所在。但为了严谨我还是补了一个对照实验把接收端设成原始模式再测试一遍。4.3 对照实验改成一个参数字节立刻回来执行下面这条命令把读取侧改成 raw 模式stty raw -F /dev/pts/4然后重新发送刚才的数据帧printf \x01\x02\x03\x13\x04\x05\n /dev/pts/3再看od的输出000000 01 02 03 13 04 05 0a0x13回来了。这个对比实验足以证明代码没改程序没动只是改了终端标志位字节路径就完全正常了。做了这一步我还不过瘾又补测了几个特殊字节。在关闭IXON之前发0x11也是消失的关闭ISIG之前发0x03会让进程收到 SIGINT 信号关闭ICRNL之前发0x0D会被转成0x0A。每一个字节的“失踪”背后都对应着一组终端标志。4.4 第 4.3 节需要的对照实验为何不能省有人可能会问既然stty输出已经说明问题了为什么还要做对照实验因为在没有对照的情况下你只能得出“终端配置不对”这个推测但不能直观看到修改前后的字节差异。这个差异是你在和同事讨论、写问题报告时最有说服力的证据。我在那次调试里最后提交的问题总结里就附了两张od的输出截图一张是默认配置下丢字节一张是 raw 模式下字节齐全。所有看到的人都立刻理解了问题不需要再听我解释一大段 termios 原理。5. 虚拟串口桥下流量控制隐藏问题0x13 只是一个代表人物5.1 字节说丢就丢的原因不只是终端层有时候你以为关闭了IXON就万事大吉但遇到多路转发场景还会有另一层问题。比如你用 socat 把/dev/pts/3桥接给一个 TCP 服务端这时链路两端各有一个 sock 缓冲区和一个 PTY 缓冲区。0x13即使没有被终端层吞掉也可能会在某个缓冲压力较高的瞬间被丢弃。我后来还碰到过一次类似现象发送端是一个二进制协议帧里同时包含0x13和0x11关闭软流控之后数据帧基本正常但还是偶发丢字节。查到最后发现是 socat 的-u/-U参数方向配错了导致数据在某一侧只写了没读缓冲区溢出后把多余的字节挤掉了。这也提醒我字节丢失不一定只有一个根因。碰到疑难杂症时不要急着下结论先用最小链路确认终端层设置无误再往上检查缓冲区和桥接方向。5.2 收到 0x13 却多出其他字符另一种“特殊字节”现象是收到的数据里出现了本不该出现的字符比如发送端发了一个字节0x13接收端却看到了0x5E 0x53也就是文本形式^S。这不是内核把 0x13 编译成了两个字节而是接收端的终端模拟器开了ECHOCTL它在回显时把控制字符显示成可打印形式。^S是控制字符显示法并不是数据流里真的有两个字节。如果接收端程序用十六进制打印读取到的数据它读到的仍然是单个0x13。但如果你用cat在终端屏幕上直接看屏幕上看到的^S会非常迷惑人。我建议所有涉足串口调试的人都养成一个习惯不要用cat直接观察原始数据统一用od、xxd或hexdump。5.3 调试时的“自问清单”经过好几次字节消失的排查后我给自己总结了一份“自问清单”每次遇到异常都会先过一遍是否确认了发送端真的发出了目标字节我用printf或十六进制文件验证。接收端是用什么工具看的如果是终端cat先换成od。当前 tty 是否处于 raw 模式用stty -a -F查看。显式关闭了IXON、ICANON、ISIG、OPOST吗不要只依靠某一层的默认值。链路里有没有多层转发每一层是否都可能存在缓冲和重排这份清单帮我省了大量时间。遇到字节问题我按顺序排查基本都能在十分钟内定位到层。6. 排查手段大全从 od 到 strace 的实战组合拳6.1 od 和 xxd 是虚拟串口调试的地基如果你问我虚拟串口调试最该随身带的工具是什么我会说od -A x -t x1z。这条命令能让你看到最真实的字节序列而不是经过终端解释后的文本。它有几个好处一是十六进制显示方便和协议对照二是-A x显示地址方便定位偏移三是-t x1z会在末列打印 ASCII 可打印字符方便快速扫一眼数据形态。它的用法很简单od -A x -t x1z -v /dev/pts/4-v选项很关键它避免 od 把重复行折叠成*否则你会漏掉连续重复的字节。我第一次调试时没加-v看到一堆*还以为是数据中断了后来才发现是 od 自己偷懒。6.2 tcpdump 不只能用在实际网卡也能用来排虚拟串口如果你的虚拟串口链路里经过了一个 TCP 中转比如用 socat 把 PTY 桥接到 TCP 端口那排查链路两端字节差异时可以在 TCP 这一端抓包。虽然这不是 PTY 层的问题但能帮你判断数据是在进入 PTY 之前就丢了还是在 PTY 之后才丢的。具体做法是tcpdump -i lo -X port 9000配合发送端和接收端的od输出就能画出一条完整的数据路径发送进程写入 PTY 的字节、socat 转发到 TCP 的字节、TCP 到达对端进程的字节。只要把三处日志对齐问题出在哪一段立刻清楚。6.3 strace观察系统调用层面的字节流还有一种情况应用层日志里显示数据已经发出去但接收端就是收不到。这时候可以上strace观察发送进程到底调用了哪些write()和read()以及返回值是多少。我试过一次发现发送进程确实调用了write()返回长度是 6但发送的缓冲区里第 4 个字节根本不是0x13而是一个被业务代码替换过的0x00。应用层日志之所以显示0x13是因为日志打印在替换操作之前。这个坑用strace一抓就准能少吵好几轮“不是我的问题”。strace对虚拟串口调试还有一个好处可以直接看到open()打开的是哪个设备节点以及后续是否调用了tcgetattr()、tcsetattr()改变终端属性。很多程序打开 tty 后会在内部重置设置这通过strace一目了然。6.4 保存现场写一个可以反复执行的调试脚本排查结束后我习惯把整个环境固化成脚本方便以后回归测试。脚本内容大致是#!/bin/bash # 启动虚拟串口 SOCAT_PID$(socat -d -d pty,raw,echo0 pty,raw,echo0 21 | tee /tmp/vpty.log | ... ) # 从日志里解析出两个 PTY 设备路径 PTY_A$(grep PTY is /tmp/vpty.log | sed -n 1p | awk {print $NF}) PTY_B$(grep PTY is /tmp/vpty.log | sed -n 2p | awk {print $NF}) # 把两个端点都设置为 raw stty raw -F $PTY_A stty raw -F $PTY_B # 使用 od 读取 PTY_A od -A x -t x1z -v $PTY_A echo PTY_A$PTY_A echo PTY_B$PTY_B这个脚本把“创建虚拟串口 设置 raw 挂载观测”一次性完成后续测试直接复用。我在文章开头提到的问题后来就是在这个脚本基础上加上一个发送 0x13 的测试用例作为整套回归的固定步骤。7. 不常被提起的几个边角细节7.1 终端退出时可能悄悄恢复默认配置这是我最想提醒的一条经验不要以为你已经设置好了终端属性它就永远是那个状态。很多终端模拟器或测试工具在退出时会恢复之前的终端参数导致你下一次测试又回到了默认的交互模式。我在一次调试里把虚拟串口完全调好了第二天同事运行同一个测试脚本又遇到了字节丢失。我一开始也懵了检查了半天最后发现是前一次测试用的expect脚本退出时把 tty 恢复成默认状态。后来我在所有测试脚本开头都加了stty raw这个问题再也没出现过。7.2 对称性问题两端都有行规程虚拟串口链路的两侧都是 PTY 端点每一侧都可能有行规程。你只改一边是不够的。socat 命令里的raw参数通常会同时影响它打开的两个端点但如果你自己用代码分别打开这两个设备就必须各自调用tcsetattr。实际操作中我经常看到只把发送端设成 raw、接收端用cat读取的情况。接收端的默认配置照样会把0x0D转成0x0A。所以排查时我习惯把两侧的 stty 输出都打出来对比确保两边的标志位一致。7.3 特殊字节 0x00 也要留意虽然0x13是那次事件的主角但0x00也值得一提。终端行规程本身不会吞掉0x00但很多编程语言里的字符串处理库会把它当成字符串结束符导致后续数据丢失。如果协议里允许出现0x00建议接收端不要用 C 字符串函数处理数据直接按长度读取并写十六进制日志。我在另一个项目里就踩过这个坑数据帧明明是[0x00][0x01][0x02]接收端用strlen判断长度结果认为长度是 0整个帧直接被丢弃。这个和虚拟串口无关但二者叠加时排查难度会加倍。遇到字节问题永远要记得把“字节是什么”和“上层怎么处理字节”分开看。8. 一张速查表 最后的经验建议8.1 虚拟串口常见症状对照表症状最可能原因快速验证修复方案数据帧缺 0x13IXON开启stty -a看 stop 字符stty -ixon或stty raw数据帧缺 0x11IXON开启发送 0x11 观察同上0x0D 变 0x0AICRNL开启发送 0x0D 后用 od 查看stty -icrnl或stty raw0x0D 直接消失IGNCR开启同上stty -igncr0x03 触发中断ISIG开启发送 0x03进程收到信号stty -isig数据一直读不出来ICANON开启发送内容后加\nstty -icanon数据里多出 0x0DOPOSTONLCRod查看换行位置stty -opost或stty raw屏幕上出现^SECHOCTL回显关 echo 再看stty -echo数据偶发丢失桥接层缓冲区溢出检查 socat 方向参数修正桥接配置8.2 我个人处理“特殊字节”问题的固定套路我把这次调试验证过的方法固化成了一套流程遇到任何虚拟串口字节异常先按顺序执行先用od -A x -t x1z -v同时观测链路两端确保看到的是真实字节再用stty -a -F查看两端终端标志接着在最简链路上做一次“默认配置与 raw 配置”的对照实验如果还没有结论就给链路加日志抓包从strace的write()/read()返回值确认应用层行为。这套流程不依赖具体工具也不限定编程语言。它要解决的核心问题只有一个在哪个层面、由哪个机制把哪个字节变成了什么样。只要答案出来了修复方案几乎都是现成的。8.3 最后的经验体会这次调试给我最大的收获不是学会了stty raw而是明白了“虚拟串口并不等于真串口”这句话的分量。你在真实串口上可以天然直通的字节路径在 PTY 上需要显式配置才能得到。平时用虚拟串口做开发时我会把“两端都设置 raw”写进项目规范而不是等到出问题再排查。以后你再遇到某个特殊字节莫名消失可以先别怀疑业务代码检查一下终端的IXON、ICANON、ISIG这三个标志位。它们看起来只是几个开关却实实在在决定了一个字节是走完全程还是半路消失。
阅读完成 · 觉得有帮助?
咨询建站