资讯动态

虚拟串口字节神秘消失?0x18被Linux终端行规程扣留的完整排查

发布时间:2026/10/22 6:13:39 来源:尧图企业网站定制
那晚我在搭一套引导程序升级流程的模拟环境卡在一个看起来特别蠢的问题上上位机向设备端发 YMODEM 文件协议里规定接收方想取消传输时要连续发送几个“取消字节”。文档里把这个字节习惯性地简写成x实际上它指的是0x18也就是键盘上的 Ctrl-XASCII 表里的 CANCancel字符。问题就出在这个字节上。程序跑起来之后取消字节怎么都“送不进去”我在虚拟串口的一端写入了0x18设备端进程的read()却一直阻塞像石沉大海。换成真实物理串口同样的代码一点问题没有。后来我把整条链路拆开排查才发现这个字节既没有丢也没有错而是被 Linux 虚拟串口背后的终端行规程line discipline“扣留”了。这篇文章就把这次排查的完整链路写出来。内容包括虚拟串口的数据到底要经过哪些处理才到对端、为什么0x18会莫名消失、怎么用stty和cfmakeraw让虚拟串口乖乖变成透传通道以及我顺手整理的一份“字节隐形杀手”清单。如果你也经常用 socat 或 pty 做嵌入式协议调试这篇应该能帮你少踩几个坑。1. 先还原现场一个“取消字节”为什么送不进去1.1 socat 建出的“串口”并不是一根直通线我在 Linux 上搭虚拟串口最常用的就是 socatsocat -d -d pty pty加-d -d能直接把两个伪终端节点打印出来比如2025/03/.. socat[12345] N PTY is /dev/pts/3 2025/03/.. socat[12345] N PTY is /dev/pts/5此时/dev/pts/3和/dev/pts/5就像一对串口线往一端写另一端能读到。很多刚接触的人会下意识把它理解成“一根直通线”认为字节从 A 端进去马上就能从 B 端原样出来。这是最大的误解。pty 的每个从端都挂着一个完整的终端驱动数据从某一端进入后要先经过这一端的 termios 标志处理才能进入读取队列反之输出的字节也要经过输出标志处理。换句话说这对“串口线”中间隔着一堆会修改、拦截、暂存字节的逻辑。更准确地说socat 只是把两个 pty 的 master 端用内部管道接了起来。真正决定字节命运的是/dev/pts/5这一侧 slave 端口的icanon、isig、ixon、echo、opost这些标志。这些标志默认全部是“终端模式”不是“串口透传模式”。1.2 复现问题0x18 明明写入了读取端却没有反应为了复现我在/dev/pts/5上挂一个最简单的读取程序# reader.py import os fd os.open(/dev/pts/5, os.O_RDWR) data os.read(fd, 32) print(read:, data.hex())然后在另一个终端往/dev/pts/3写入三个取消字节# writer.py import os fd os.open(/dev/pts/3, os.O_RDWR) os.write(fd, bytes([0x18, 0x18, 0x18]))结果没有任何输出。reader 的read()一直阻塞在那里。我不是第一次看这种“发了没反应”的问题第一反应是查中间链路到底有没有把字节送到。给 socat 加上-x参数它会以十六进制把转发的数据打到日志里socat -d -d -x pty pty日志里清清楚楚写着0x18 0x18 0x18已经从左边的 pty 出来了数据确实送到了右边 pty 的输入口。那问题就出在“输入口之后的处理环节”。为了排除回显干扰我先把左侧从端的 echo 关掉stty -echo -F /dev/pts/3然后再发一次reader 依然不返回。这时候我已经能确定字节到达了目标 pty 的输入队列只是没有被读进程拿到。1.3 为什么偏偏是“x”0x78、0x58、0x18 的纠葛在排查过程中我发现这个“x”的写法本身就很容易引出第二个坑。协议文档里写的是“发送 x 字节”但实际要发的是 Ctrl-X也就是0x18而不是小写字母x0x78。两者在键盘上差一个 Ctrl 修饰键在 ASCII 表里更是完全不同的字符。我把几个容易混淆的字节列了一下方便后面排查时对照字节常见叫法Linux 虚拟串口默认行为0x78小写字母 x没有特殊处理正常透传0x58大写字母 X没有特殊处理正常透传0x18Ctrl-X / ASCII CAN默认不会被信号机制拦截但会被 canonical 模式的行缓冲扣留等待换行符之后才一起交付0x11XON / Ctrl-QIXON 开启时被行规程当作流控字符消费不会交给 read()0x13XOFF / Ctrl-S同上还会挂起对端传输0x0D回车 CRICRNL 开启时被转换成 0x0A0x0A换行 LFONLCR 开启时输出方向会变成 0x0D 0x0A这里有一个背景知识键盘上 Ctrl字母产生的控制字符等于该字母 ASCII 码去掉高位的值也就是字母码 0x1F。0x58 0x1F 0x18这就是 Ctrl-X 的由来。但问题还没完。0x18不是XON/XOFF也不在默认的信号字符表里它为什么会被“扣留”呢下一节说说它到底卡在哪一道关卡。2. 字节穿过虚拟串口时要闯的几道关termios 行规程处理2.1 第一道关ICANON 是不是在“攒词”termios 里有一个叫ICANON的标志决定 tty 是否工作在“规范模式”canonical mode。这个模式设计的初衷是给人用的终端允许退格、行内编辑按回车才把整行交给程序。在这个模式下从 master 端进入 slave 输入队列的字节不会立刻触发read()返回而是先在行规程的缓冲区里攒着直到出现换行符0x0A、行结束符EOL/EOL2或文件结束符VEOF默认 Ctrl-D才会把整段缓冲一次性交给读进程。这就是0x18消失的真正原因。它不是被丢弃而是被行缓冲“押”住了。我后面做了一个补刀验证在 reader 阻塞的情况下往/dev/pts/3再发一个hello\nreader 一次性读到了00000000: 181818 68656c6c6f0a三个取消字节排在hello\n前面一起被交付了。说明字节一直在缓冲区里躺着只是没有换行符这个“钥匙”read()就永远不会返回。很多串口协议帧并不以\n结尾或者应用层有自己的帧格式不会在每个包末尾补换行。如果打开了虚拟串口而没有关闭ICANON就会出现“协议帧前面若干字节都收不到”的现象而且表现非常随机——因为只要凑巧碰到一个0x0A前面攒的所有字节都会涌出来看起来像“延迟到达”而不是“丢失”。2.2 第二道关ISIG 拦截信号字符与 0x18 的关系排查到ICANON之后我还顺手确认了一遍ISIG因为它和“控制字节”的关系特别容易让人踩坑。ISIG开启时终端驱动会检查输入字符是否匹配信号字符匹配就丢弃该字节并向前台进程组发送对应信号。默认的信号字符包括VINTR 0x03Ctrl-C触发 SIGINTVQUIT 0x1CCtrl-\触发 SIGQUITVSUSP 0x1ACtrl-Z触发 SIGTSTP0x18不在默认信号字符表中所以默认情况下ISIG不会吞掉它。但这个字节确实有“变成信号字符”的潜力。如果你在用某个串口库时不小心把VINTR配成了0x18或者程序里自定义了控制字符那取消字节就会在ISIG这一层被消费掉甚至给进程发一个 SIGINT。真到那一步表现就不是“read 不返回”了而是“收到字节后进程直接被信号打死”。所以排查类似问题时我会明确区分“ICANON 的攒词”和“ISIG 的信号拦截”。在我们的场景里0x18被扣留是前者造成的但如果你在别的项目里遇到“控制字节一到达程序就异常退出”优先怀疑ISIG。2.3 第三道关IXON、ECHO、OPOST 这些“隐形修改器”解决了0x18的读取时机问题不代表虚拟串口就彻底“干净”了。还有几个默认开启的标志会在你不注意的时候偷改字节。IXON 软件流控。这个标志开启时输入方向的0x11XON和0x13XOFF会被当作流控命令消费掉不会进入读取队列。如果你的协议恰好把0x11或0x13当作数据字节你会发现收数据时偶尔少一两个但完全找不到规律。我在调某模块的 AT 指令模拟器时遇到过类似问题最后就是用stty -ixon解决的。ECHO 回显。默认情况下终端会把输入字符回显到输出方向。在 pty 场景里某个从端开着 ECHO另一端写入的字节会在本端“弹回去”。从应用层的角度看来就是“发一个字节收到一个自己刚发出的字节”。双向协议里如果开了 ECHO经常会出现响应数据里混着请求数据的情况CRC 怎么算都对不上。OPOST ONLCR 输出转换。从 slave 端往外写数据时如果OPOST开启且ONLCR开启那么0x0A会被自动展开成0x0D 0x0A。一次写一帧带换行结尾的数据对端会多收到一个0x0D帧长直接错掉。很多“串口程序在物理串口上正常换到虚拟串口上就 CRC 错误”的案例根因就是这一条。2.4 为什么默认虚拟串口不能直接当物理串口用物理串口从驱动层面看就是一组 FIFO数据进来就中断通知 CPU基本不做语义解释。但 pty 模拟的是一个带完整终端驱动的“终端设备”它天然要支持行编辑、信号、回显、流控这些“给人用”的功能。所以当你想把 pty 当作一个模拟串口时不能指望它默认就是“透传”的。操作系统并不知道你的应用希望字节原样进出。你必须在打开从端后显式关闭这些标志或者在使用 socat 创建虚拟串口时直接指定raw,echo0告诉驱动“我不要终端行为只要裸字节”。3. 排查链路复盘从“字节明明发出去了”到“抓出 ICANON”3.1 第一步确认字节真的离开了发送端遇到“发了没反应”我第一件事永远是先确认字节有没有真正离开发送端。这里有两个方向一是用 socat 的-x参数看转发日志二是用hexdump抓另一端原始数据。在我们这个场景里socat 日志已经显示0x18 0x18 0x18从左侧 pty 的 master 端口被读出并转发了说明问题不在发送端。为了进一步缩小范围我做了另一个对照实验往/dev/pts/3发送0x78也就是普通的小写字母x。reader 依然没有反应。这其实也很正常因为0x78同样会被 canonical 模式攒住。所以“发普通字节能通”这个经验在这儿并不成立——只要从端处在 canonical 模式任何不跟换行符的字节都可能被暂留。3.2 第二步用 stty -a 看从端 termios 的真实状态排查串口问题时我习惯先把目标从端的termios配置拉出来看一遍stty -a -F /dev/pts/5输出片段大概是这样的speed 38400 baud; rows 0; columns 0; intr ^C; quit ^\; erase ^?; kill ^U; eof ^D; eol undef; ... brkint -imaxbel opost -olcuc ocrnl onlcr ... icrnl -ixon -ixoff ... -icanon isig iexten echo echoe echok ... min 1; time 0;注意看几个关键标志位canon带-前缀才是关闭的不带前缀是开启的。默认情况下这一项是icanon也就是说当前从端处在规范模式。isig默认开启信号字符检查生效。echo默认开启输入会被回显。opost和onlcr默认开启输出方向的换行会变形。icrnl默认开启输入方向的回车会变成换行。这串输出基本确认了我的猜想从端处于完全“终端行为”状态不是透传状态。3.3 第三步逐项开/关标志做对照实验定位到具体标志最直接的办法就是逐个实验看改哪个能改变现象。修改操作现象观察stty -icanon -F /dev/pts/5reader 的 read() 立即返回读到了 0x18 0x18 0x18问题消失stty -isig对 0x18 的读取没有影响ISIG 默认不管这个字节stty -echo消除了另一端多余的回显字节对本次读取没有影响stty -ixon对 0x18 没有影响但如果协议里有 0x11/0x13这一项必须关stty -icrnl0x0D 不再被转换成 0x0A读取行为更符合“串口”预期实验做到这里结论已经很明确当前场景的罪魁祸首是ICANON。这并不代表其他标志不重要。事实上我只是按“影响 0x18 读取”这个目标做了优先级排序。如果你把目标换成“让整个虚拟串口 behave like a real UART”那echo、opost、icrnl、ixon全部都得关。3.4 为什么不能只靠“多发包”绕过理解 VMIN/VTIME 的交互有人可能会想既然0x18是被攒住了那我多发几次或者凑够一定数量是不是就能触发返回这里要特别强调canonical 模式的交付条件不是“攒够多少字节”而是“遇到换行/EOF”。除非你把行缓冲填到MAX_CANON通常是 4096 字节否则发多少普通字节都不会主动交付。而当你关掉ICANON进入非规范模式后read()的返回行为就由两个参数决定VMINread 至少要读到的字节数。VTIME等待时间单位是 0.1 秒。如果VMIN 0且VTIME 0read()会立即返回即使没有数据也返回 0。很多人在cfmakeraw()之后马上read()发现返回 0还以为是对端没发数据其实是 VMIN/VTIME 没配对。推荐的做法是设置VMIN 1、VTIME 0让 read 至少等到一个字节才返回。如果希望“等 1 秒超时返回”就设VMIN 0、VTIME 10。我在排查中遇到过一个更隐蔽的版本程序里用cfmakeraw()设置了 raw 模式但没设 VMIN/VTIME导致 read 每次都立即返回 0上位机把“空包”当成“对方没响应”逻辑上死循环。这个坑和 ICANON 的坑是连在一起的一前一后很容易踩全。4. 让虚拟串口变成真正透传通道的规范做法4.1 快速命令方案创建即 raw如果你的虚拟串口还处在搭建阶段最省事的办法是让 socat 创建 pty 时就直接指定 raw 模式socat -d -d pty,raw,echo0 pty,raw,echo0这样创建出来的从端icanon、echo、isig、ixon、opost这些标志默认就是关闭的直接进入“接近物理串口”的状态。如果 pty 已经创建好了也可以通过 stty 临时修正stty raw -echo -F /dev/pts/5stty raw做的事情本质上就是把那组终端行为标志全部关掉。-echo再单独把回显关了双保险。这里有个细节值得注意不光是读端要从端关闭这些标志写端所在的从端也要处理。在我们的场景里/dev/pts/5是读端/dev/pts/3是写端两边最好都设置成 raw否则写端开着 ECHO数据还是会多出回显。4.2 C 语言打开串口的正确姿势cfmakeraw 的完整用法在 C 程序里打开虚拟串口我习惯写一个自己固定的 setup 函数#include stdio.h #include fcntl.h #include termios.h #include unistd.h int set_tty_raw(int fd) { struct termios tio; if (tcgetattr(fd, tio) 0) { perror(tcgetattr); return -1; } cfmakeraw(tio); tio.c_cc[VMIN] 1; tio.c_cc[VTIME] 0; if (tcsetattr(fd, TCSANOW, tio) 0) { perror(tcsetattr); return -1; } return 0; }使用方式int fd open(/dev/pts/5, O_RDWR | O_NOCTTY); if (fd 0) { perror(open); return -1; } if (set_tty_raw(fd) 0) { close(fd); return -1; }这里有三点值得说明第一不要直接对tio做 memset 清空。termios 里还有 baud rate、控制字符、行规则等字段粗暴清空会导致驱动进入不可预期的状态。正确做法是先用tcgetattr读当前值再用cfmakeraw修改。第二cfmakeraw负责关闭ICANON、ECHO、ISIG、IXON、OPOST等标志但它不保证把VMIN/VTIME设置成你要的值所以一定要手动设置。第三tcsetattr的TCSANOW表示立刻生效不等待输出队列清空。对于调测程序来说这个选择最直接。4.3 Python 侧的手工设置Python 标准库里的tty.setraw(fd)也能把 fd 设成 raw 模式但底层细节不一定完全符合自定义需求。如果你用os.open直接操作推荐手动设置 termios 相关标志import os import termios def make_raw(fd): attrs termios.tcgetattr(fd) iflag, oflag, cflag, lflag, ispeed, ospeed, cc attrs iflag ~(termios.IGNBRK | termios.BRKINT | termios.PARMRK | termios.ISTRIP | termios.INLCR | termios.IGNCR | termios.ICRNL | termios.IXON) oflag ~termios.OPOST lflag ~(termios.ECHO | termios.ECHONL | termios.ICANON | termios.ISIG | termios.IEXTEN) cflag ~(termios.CSIZE | termios.PARENB) cflag | termios.CS8 cc[termios.VMIN] 1 cc[termios.VTIME] 0 termios.tcsetattr(fd, termios.TCSANOW, [iflag, oflag, cflag, lflag, ispeed, ospeed, cc])这段代码做的事情和cfmakeraw基本一致好处是你能看到每一类标志到底关了什么排查的时候心里有数。4.4 验证最终效果取消字节顺利送达把/dev/pts/5用上面的方式设成 raw 之后重新跑升级模拟流程reader 程序一启动read()就收到了0x18 0x18 0x18。设备端的 YMODEM 接收逻辑正确判定用户取消终止了传输。整个流程不再依赖末尾的换行符行为与真实物理串口完全一致。从“发了 20 分钟没反应”到“秒收三个字节”差别就是那一个icanon标志。5. 虚拟串口调测中还要小心的其他“字节隐形杀手”5.1 ONLCR 和 ICRNL 带来的 0x0A/0x0D 变形即使关闭了ICANON如果没有顺便关掉OPOST/ONLCR从端输出方向的每次0x0A都会被展开成0x0D 0x0A。我在调一个自定义帧协议时遇到过对端明明按帧长接收却永远差一个字节。加日志之后才发现发送端写的是0x0A对端收到的是0x0D 0x0A。输入方向对应的坑是ICRNL。这个标志开启时输入方向的0x0D会被转换成0x0A。如果你的协议用0x0D做帧尾收端永远等不到这个字节。所以我在检查清单里固定有一条确认-icrnl和-onlcr都已设置。5.2 ECHO 回显导致协议错帧的假象还有一个我踩过不止一次的坑是 ECHO。某个从端开着 echo数据进去后原样返回给发送端。如果发送端又是半双工轮询会莫名其妙收到自己刚发的请求误以为设备给了响应。判断方法很简单关掉 echo 再跑一遍如果报错消失基本就是回显在捣鬼。在 socat 场景中我建议创建虚拟串口对时直接让两边都带上echo0从根上避免。5.3 多端读写同一 pty 时数据竞争与缓冲滞留虚拟串口和物理串口还有一点本质区别同一个 pty slave 可以被多个进程同时打开。但行规程只有一个同一个字节最终只会被某个进程的read()拿走另一个进程是抢不到的。它不像真实串口那样有“总线广播”概念。我在一个模拟测试环境里曾经让 A 进程和 B 进程同时读同一个 pts 从端结果数据在两个进程之间随机分配看起来就像“丢包”。最后改成单读进程 分发日志的方式才稳定。如果确实需要多个消费者建议在数据进入 pty 之前做分叉比如用 socat 配合 tee而不是让多个进程抢同一个 pts。5.4 我的调测习惯一建立串口对就先“消毒”再谈协议经过这次排查我给自己定了一个固定流程每次用虚拟串口调协议都按这个顺序走创建虚拟串口时显式带raw,echo0或者在打开从端后立刻执行stty raw -echo -F /dev/pts/X。用stty -a -F /dev/pts/X确认没有icanon、echo、isig、ixon、opost、icrnl、onlcr。设置合理的VMIN/VTIME至少保证 read 不会立即返回 0。先做一轮 loopback 自测把两个 pty 的从端都打开往一端写入一串覆盖0x00到0x1F的字节看另一端是否能逐字节还原。协议联调前在数据链路上挂一个十六进制日志比如 socat 的-x选项确认实际字节没有被任何环节修改。这套流程做完虚拟串口基本就接近一个“干净”的物理串口了后面再调协议就不会被这些底层问题反复打断。这次的教训让我印象最深的一点是虚拟串口真正难的地方从来不是创建和连接而是默认终端行为带来的那些隐性字节转换。希望这篇记录能帮你少走一段类似的弯路。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑