1. 先把“串口”在Linux里的真身找出来
如果你刚开始接触Linux串口应用编程(Serial),大概率会遇到这样一幕:代码明明写得没问题,可打开/dev/ttyS0报 Permission denied,换成/dev/ttyUSB0又提示 No such file or directory,好不容易弄到一个能打开的节点,收上来的却是一堆乱码。这不是你的编程能力问题,而是因为你还没搞清楚Linux把“串口”这层硬件抽象成了什么形态。搞不清抽象层,后面所有的配置和调试都是盲人摸象。
我做嵌入式上位机那几年,前前后后接触过工业网关、传感器采集板、定位模块、单片机升级工具,几乎每个项目都要跟串口打交道。踩过的坑加起来的教训就一句话:在Linux里操作串口,本质上是操作一个字符设备文件。你open()的不是硬件,是一个由内核和驱动层共同维护的设备节点;你read()/write()的不是电平信号,是一个带缓冲的字节流。理解这个模型,后面关于配置、阻塞、丢数据的各种诡异现象才有统一的解释框架。
这篇文章想做的事,是把“Linux下用代码把串口跑通”这件事拆到底:从设备节点识别、驱动和权限处理,到 termios 这个真正控制串口行为的核心结构体,再到收发数据的代码骨架、丢包乱码的排查链路,最后落到 Python、ROS2、Qt 这些实际项目里怎么接。无论你是在树莓派上接个传感器,还是在工控机上和 STM32 通信,又或者只是想让 Python 脚本读一条串口日志,这里面的东西都是相通的。
1.1 ttyS、ttyUSB、ttyACM 到底差在哪
很多人以为串口就是串口,节点名随便用哪个都行,实际上不同的命名背后是完全不同的硬件路径,行为也有差异。
- /dev/ttyS0、/dev/ttyS1…:对应主板或SoC上原生的 UART 控制器。这类节点通常是芯片引脚直接引出的串口,很多开发板把 ttyS0 保留给内核控制台(console),你拿它收发业务数据会出问题。ttyS 的编号是固定的,插拔不会变。
- /dev/ttyUSB0…:对应通过 USB 转串口芯片扩展出来的串口,常见的就是 CH340、CP2102、FT232、PL2303 这类。它们的特征是“插上去才有、拔下来就消失”,编号会跟着插拔顺序变化。
- /dev/ttyACM0…:对应 USB CDC ACM 类设备,典型代表是 Arduino、部分 STM32 的 USB 虚拟串口,还有各种调试器的虚拟串口通道。它的行为和 ttyUSB 类似,但走的是 USB 通信设备类标准协议,通常不需要额外装厂商驱动。
这三类的关键区别在于驱动来源和热插拔特性。ttyS 是平台驱动一启动就注册好的;ttyUSB 依赖 usbserial 加具体芯片驱动(比如 ch341、cp210x、ftdi_sio);ttyACM 依赖 cdc_acm。你在排查“设备找不到”的时候,第一步就该判断自己用的是哪一类,因为它们的排查路径完全不同。
提示:不要迷信节点编号。用
ls -l /dev/serial/by-id/可以看到基于设备唯一 ID 生成的稳定软链接,比/dev/ttyUSB0这种序号靠谱得多。
1.2 为什么设备名每次插拔都在变
这是新手最容易被绊倒的地方:脚本里写死了/dev/ttyUSB0,第一次跑得好好的,重启或者换了个USB口,设备跑到/dev/ttyUSB1去了,脚本直接报错。原因在于,内核给 USB 转串口设备分配编号是按枚举顺序来的,谁先被识别谁就是0。你插了两个设备、或者系统启动时枚举顺序变了,编号就跟着变。
解决思路有两条。一是按物理路径固定,用/dev/serial/by-path/下的软链接,它绑定的是USB控制器和端口位置,插同一个口名字就不变。二是按设备ID固定,用 udev 规则给特定 VID/PID 的设备起一个自定义别名,比如把某个 CH340 固定叫/dev/sensor0。后面第2节我会给出完整的 udev 规则写法。
理解这一点之后,你应该建立一个习惯:任何要长期跑的程序,都不要把/dev/ttyUSBx硬编码进代码,要么做成配置项,要么用稳定软链接。这个习惯能帮你省掉大量“昨天还能用今天怎么就不行了”的深夜排查。
1.3 内核和驱动层是怎么接住这串数据的
当你write()数据到串口节点时,数据并不会立刻变成引脚上的电平。它要经过几层:应用层 → 内核 tty 层(负责线路规程 line discipline、缓冲、流控)→ 具体串口驱动(平台 UART 驱动或 USB 转串口驱动)→ 硬件(FIFO、移位寄存器)→ 物理引脚。接收方向则反过来。
这个分层模型解释了很多现象。比如应用层write()返回成功,只代表数据被放进了内核发送缓冲区,不代表对方已经收到;再比如接收时数据先进内核的 tty 缓冲,如果你的程序读得太慢,缓冲满了之后新数据就可能被丢掉——这就是“Linux从串口接收数据丢失”这类问题的根源之一。tty 层还负责 canonical(行)模式的处理,默认情况下它会把回车换行做转换、会回显、会按行给你数据,这对终端交互很友好,但对二进制协议通信就是灾难,所以必须切成原始模式(raw mode)。
2. 驱动、权限、设备识别这三道坎
在真正写收发逻辑之前,有三个环境层面的坎必须先过。我见过太多人代码逻辑写得漂亮,结果全卡在这三步上。这一节把 CH340 驱动、udev 固化设备名、权限设置三件事一次讲透。
2.1 CH340、CP2102、FT232 的驱动差异
不同 USB 转串口芯片在 Linux 下的支持情况差别很大,直接影响你要不要额外装驱动。
| 芯片 | 内核驱动模块 | 主流发行版是否内置 | 常见问题 |
|---|---|---|---|
| CH340/CH341 | ch341 | 是(较新内核) | 老内核可能识别为未知设备 |
| CP2102/CP2104 | cp210x | 是 | 基本即插即用 |
| FT232/FT2232 | ftdi_sio | 是 | 多通道设备要区分接口 |
| PL2303 | pl2303 | 是 | 部分山寨芯片兼容性差 |
判断驱动有没有正确加载,最直接的方法是一边插拔设备一边看内核日志。插上设备后执行dmesg | tail -20,如果看到类似ch341-uart converter now attached to ttyUSB0的字样,说明驱动认到了。如果只看到new full-speed USB device却没有转成 tty,那多半是驱动没匹配上,或者内核缺模块。
“Ubuntu ch340串口驱动”“ch340串口驱动”是高频搜索词,说明很多人在这一步犯难。我的经验是:现代内核(5.x 以上)基本都自带 ch341 模块,不需要手动编译。真要确认,用lsmod | grep ch341看模块是否加载,用modinfo ch341看模块信息,必要时sudo modprobe ch341手动加载。只有在极老的发行版或者裁剪过的嵌入式系统上,才需要自己编译驱动。
2.2 用 udev 规则给设备一个固定名字
假设你有个传感器板,长期插在工控机上,设备名一会儿 ttyUSB0 一会儿 ttyUSB1,很烦。解决办法是写一条 udev 规则,按 VID/PID 给它固定别名。
首先用lsusb或者udevadm info拿到设备的厂商和产品ID。比如 CH340 通常是1a86:7523。然后创建规则文件/etc/udev/rules.d/99-serial.rules:
SUBSYSTEM=="tty", ATTRS{idVendor}=="1a86", ATTRS{idProduct}=="7523", SYMLINK+="sensor0", MODE="0666"重新加载规则生效:
sudo udevadm control --reload-rules sudo udevadm trigger这条规则做了两件事:创建/dev/sensor0这个稳定软链接,同时把权限设成0666(所有人可读写)。之后你的程序就固定打开/dev/sensor0就行了,插哪个口都不影响。
注意:如果有多个相同 VID/PID 的设备,就得再加条件区分,比如用
ATTRS{serial}=="..."匹配设备的序列号,或者用KERNELS=="1-1.2"匹配物理端口。否则几条规则会互相抢名字。
2.3 权限问题:加组还是改规则
普通用户默认是没有权限打开/dev/ttyUSB0这类设备的,因为它们的属主通常是 root,权限是crw-rw----,组是dialout。两种解决方式:
- 把当前用户加入
dialout组:sudo usermod -aG dialout $USER,然后重新登录生效。这是最干净的做法,推荐。 - 通过 udev 规则把设备权限设成 0666。方便,但等于让所有用户都能访问串口设备,在多用户服务器上不太合适。
我第一次搞这个时用的是直接sudo chmod 666 /dev/ttyUSB0,当时能用,重启之后设备重新枚举权限又变回去了,白忙活。后来才明白,chmod对热插拔设备是临时的,正确姿势要么改组,要么写 udev 规则。这是典型的“看起来能用其实不持久”的坑。
3. termios 才是串口配置的心脏
串口的绝大多数行为——波特率、数据位、校验、停止位、流控、阻塞方式、字符转换——全部由termios这一套结构体和tcsetattr系列函数控制。可以说,会不会串口编程,就看你会不会正确配置 termios。这一节把最关键的几个字段掰开讲。
3.1 原始模式到底关掉了什么
tty 默认工作在“规范模式”(canonical mode),这是为终端交互设计的:你敲一行回车它才把整行数据交给你、会处理退格键、会回显、会把\r转成\n。用在人机终端上很贴心,用在二进制协议通信上就是灾难。
所谓“原始模式”(raw mode),就是把这些行处理全部关掉,让串口变成一个纯粹的字节流通道。核心要清掉这些标志:
c_lflag里的ICANON(行缓冲)、ECHO/ECHOE(回显)、ISIG(信号处理,比如 Ctrl+C 会触发信号)——全关。c_iflag里的IXON/IXOFF/IXANY(软件流控)、ICRNL/INLCR/IGNCR(回车换行转换)——全关。c_oflag里的OPOST(输出后处理)——关掉,否则你发出去的字节会被内核改写。
我最早写串口代码时没关OPOST,发了两个字节的数据包过去,设备死活不认。抓了半天协议才发现,内核把其中的\n偷偷转成了\r\n,数据包结构直接被破坏。这个坑非常隐蔽,因为它不会报错,只是数据“悄悄地多了或少了”。所以记住:做二进制协议,OPOST、ICRNL 这些一定要关。
3.2 波特率、数据位、校验位、停止位怎么配
这四个参数必须和通信对端严格一致,错一个就是乱码或者完全收不到。
- 波特率:用
cfsetispeed/cfsetospeed设置,输入输出通常设成一样。注意波特率是“每秒符号数”,实际还要看数据位配置,很多人误以为它等于字节速率。 - 数据位:用
CSIZE掩码清掉再设,最常用CS8(8位)。先用tty.c_cflag &= ~CSIZE;清除,再tty.c_cflag |= CS8;。 - 校验位:
PARENB开启校验,PARODD决定奇偶。大多数场景用无校验~PARENB。 - 停止位:
CSTOPB表示两位停止位,清零即一位停止位。
设置波特率有个常见误区:不是所有平台都支持任意波特率。标准波特率(9600、19200、115200、460800 等)有对应宏(B9600、B115200);非标准波特率需要用termios2或者ioctl配自定义波特率。工业设备里偶尔会用到像 921600 这种,在部分驱动上会有偏差,导致高波特率下丢包。
提示:如果遇到“低波特率正常、高波特率丢数据”,先怀疑波特率误差或者接收缓冲区太小,而不是先怀疑代码。
3.3 硬件流控和软件流控的取舍
流控解决的是“发送方发太快,接收方来不及处理”的问题。两种:
- 硬件流控 RTS/CTS:通过额外的两根信号线协商,实时性好,适合高速率、大数据量场景。配置方法是
tty.c_cflag |= CRTSCTS;。 - 软件流控 XON/XOFF:通过在数据流里插入特殊控制字符(0x11/0x13)来暂停恢复,占用了数据传输通道,不适合二进制协议,一般只在特定设备上启用。
大多数简单场景(比如和单片机通信、发AT指令)都不需要流控,把CRTSCTS清掉、IXON/IXOFF清掉即可。但如果你做的是高速数据采集,比如用串口传图像或者大批量传感器数据,硬件流控能显著降低丢包。要不要用流控,本质是看你的数据速率和接收端处理能力是否匹配。
我做过一个 921600 波特率的采集项目,一开始没上流控,接收端偶尔丢几十个字节,加了 RTS/CTS 之后稳定了。这个经历告诉我:流控不是可有可无的选项,而是速率和可靠性之间的一个必要开关。
4. 收发数据的代码骨架怎么搭
配置完 termios,接下来就是打开、读、写的循环。这一节给一个可以直接抄的C语言骨架,再讲清楚阻塞模式、多路复用、以及read返回 0 这几个坑。
4.1 从打开到配置的完整C示例
下面这段代码把前三节的知识串起来,是一个最小可用的串口初始化和收发框架:
#include <fcntl.h> #include <termios.h> #include <unistd.h> #include <stdio.h> #include <string.h> int serial_open(const char *path) { /* O_NOCTTY 防止把串口当作控制终端 */ int fd = open(path, O_RDWR | O_NOCTTY | O_NDELAY); if (fd < 0) { perror("open"); return -1; } struct termios tty; if (tcgetattr(fd, &tty) != 0) { perror("tcgetattr"); close(fd); return -1; } cfsetospeed(&tty, B115200); cfsetispeed(&tty, B115200); tty.c_cflag |= (CLOCAL | CREAD); /* 忽略调制解调器状态、使能接收 */ tty.c_cflag &= ~CSIZE; tty.c_cflag |= CS8; /* 8 数据位 */ tty.c_cflag &= ~PARENB; /* 无校验 */ tty.c_cflag &= ~CSTOPB; /* 1 停止位 */ tty.c_cflag &= ~CRTSCTS; /* 关闭硬件流控 */ tty.c_lflag &= ~(ICANON | ECHO | ECHOE | ISIG); /* 原始模式 */ tty.c_iflag &= ~(IXON | IXOFF | IXANY); /* 关闭软件流控 */ tty.c_iflag &= ~(ICRNL | INLCR | IGNCR); /* 关闭回车换行转换 */ tty.c_oflag &= ~OPOST; /* 关闭输出后处理 */ tty.c_cc[VMIN] = 0; /* 非阻塞读数语义 */ tty.c_cc[VTIME] = 10; /* 读超时 1 秒 */ tcflush(fd, TCIFLUSH); /* 清空输入缓冲 */ if (tcsetattr(fd, TCSANOW, &tty) != 0) { perror("tcsetattr"); close(fd); return -1; } return fd; }几个点值得强调。O_NOCTTY是防止这个串口变成进程的控制终端,否则某些信号会打到你的程序上;CLOCAL表示忽略调制解调器的载波检测信号,做普通通信必须设,否则在没接调制解调器时会一直读不到数据;CREAD使能接收,不设的话你根本收不到东西。tcflush在配置后清一次输入缓冲,可以避免读到配置之前残留的垃圾数据。
4.2 VMIN 和 VTIME 决定的读行为
c_cc[VMIN]和c_cc[VTIME]这两个字段控制read()的行为,非常关键但很容易被忽略。它们的组合有四种语义:
| VMIN | VTIME | read 行为 |
|---|---|---|
| 0 | 0 | 立即返回,读到多少算多少(纯非阻塞) |
| 0 | >0 | 最多等 VTIME×100ms,有数据立即返回 |
| >0 | 0 | 至少读到 VMIN 个字节才返回(阻塞) |
| >0 | >0 | 或读满 VMIN 字节、或超时 VTIME 就返回 |
我一般用VMIN=0, VTIME=10,语义是“读一次最多等1秒,有数据就立刻返回”。这样应用层不会死等,能及时处理其他逻辑。但要注意:这种配置下read()返回 0 并不代表出错,它只是“超时了没数据”,需要和真正的连接断开区分开。这正是 4.3 要讲的坑。
4.3 read 返回 0 到底意味着什么
read返回 0 在普通文件里意味着“读到文件末尾 EOF”,但在串口上含义完全不同。在串口里,当你设置了VMIN=0, VTIME>0,read返回 0 只表示在超时时间内没有收到任何数据,不是错误。很多新手看到返回 0 就以为设备断开了,开始疯狂重连,其实人家好好的。
真正要处理的是返回 -1 的情况。这时errno才是判断依据。比如errno == EAGAIN表示非阻塞模式下暂时没数据,EINTR表示被信号打断可以重试。所以正确的读循环应该长这样:
char buf[256]; ssize_t n = read(fd, buf, sizeof(buf)); if (n > 0) { /* 正常处理 n 个字节 */ } else if (n == 0) { /* 超时,无数据,继续循环即可 */ } else { if (errno == EINTR || errno == EAGAIN) { /* 可重试,不算错误 */ } else { perror("read"); /* 真正的错误 */ } }搞清楚这三种返回的语义,你的串口程序就不会动不动“自己把连接断了”。
4.4 需要同时管多个串口时上 select 或 poll
如果程序里只有一个串口,阻塞读写就够了。但如果你要同时监听两个串口,或者还要响应键盘输入、网络事件,就得上多路复用。select和poll都能用,逻辑是把串口 fd 加到监听集合里,哪个 fd 可读就处理哪个。
fd_set rfds; FD_ZERO(&rfds); FD_SET(fd, &rfds); struct timeval tv = { .tv_sec = 1, .tv_usec = 0 }; int ret = select(fd + 1, &rfds, NULL, NULL, &tv); if (ret > 0 && FD_ISSET(fd, &rfds)) { /* 串口有数据可读了 */ read(fd, buf, sizeof(buf)); }select的缺点是有 fd 数量上限(默认1024)和每次都要重建集合;poll用数组更灵活;epoll适合大量 fd 的场景。对串口这种通常只有几个设备的情况,poll是最舒服的选择。这里的关键认知是:串口 fd 本质就是一个普通的可读可写文件描述符,所有针对 fd 的多路复用机制都能直接用在它上面。
5. 数据丢失与乱码的排查链路
“Linux从串口接收数据丢失”和“串口通信乱码”是两个最高频的实战问题。发生的时候不要盲目改代码,按下面这条链路一层层排除,能最快定位。
5.1 先判断丢在哪一层
数据丢失可能发生在四个地方:物理链路(线材、干扰、电平)、驱动和FIFO、内核tty缓冲、应用层读得太慢。判断方法是用一个已知的、固定的测试数据流(比如设备持续发固定模式的数据),然后:
- 用
cat /dev/ttyUSB0直接看原始数据。如果这里就丢了,说明问题在应用层之上(tty缓冲或驱动); - 用
stty -F /dev/ttyUSB0看当前配置是否和你预期一致,波特率对不对; - 用逻辑分析仪或者示波器抓物理线,确认信号本身没问题。
很多时候问题出在应用层:你的读循环里有耗时操作(比如写数据库、发网络请求),导致 tty 缓冲被填满后新数据被丢。这时候要么开个专门的读线程,要么用 select/poll 保证及时读。
5.2 乱码八成是波特率和数据格式
乱码比丢数据更常见,原因通常就是双方参数不一致。逐一核对:波特率、数据位、校验位、停止位、流控。任何一项不一致,大部分数据都会变成乱码。
还有一种隐形乱码来源:时钟误差。UART 是异步通信,靠双方各自的时钟采样。如果发送端晶振不准(比如某些廉价模块用 RC 振荡器),在高速率下累计误差超过半个位就采错。所以如果你的波特率设得没错但还是偶尔乱码,试试降低波特率,看是否改善。这个技巧我在调试一个国产无线模块时用过,921600 乱码,降到 115200 就好了,最后确认是模块时钟精度不够。
另外一个常被忽视的点:接收缓冲区大小。Linux 内核 tty 缓冲默认不大,高速率下如果应用层读得慢,超出部分直接丢。可以通过调整驱动参数或者提高读取频率来缓解。
5.3 串口被占用和配置残留
“串口关闭”“串口被占用”也是常见问题。典型现象是open返回EBUSY,或者能打开但读不到数据。原因通常是:
- 有别的进程正抱着这个串口(比如 ModemManager 会自动去探测新插上的串口设备,某些发行版上它会持续占用,导致你的程序打不开);
- 上一个程序异常退出,没恢复 termios 配置,导致串口处于奇怪状态;
- 用的不是同一个进程,但串口配置被别的程序改过。
排查用lsof /dev/ttyUSB0或fuser /dev/ttyUSB0看谁占着。如果是 ModemManager 作怪,可以给它加 udev 规则排除你的设备,或者干脆systemctl stop ModemManager(如果不需要它)。配置残留的问题,一般做法是在程序退出时用tcsetattr恢复原始配置,或者干脆不恢复——每次打开都重新配一遍。
提示:程序退出前一定要
close(fd)。fd 泄漏会让串口在下一次打开时报占用,而且这种问题在长时间运行的服务里非常隐蔽。
6. 调试工具链:让排查有据可依
光靠写代码排查效率太低,配一套趁手的工具能省一大半时间。这一节把我常用的命令行和图形工具列出来,并说明各自适合什么场景。
6.1 命令行工具:stty、cat、socat
stty -F /dev/ttyUSB0 -a:查看当前串口的所有配置,波特率、数据位、流控一目了然。这是排查配置问题的第一把钥匙。stty -F /dev/ttyUSB0 115200 cs8 -cstopb -parenb raw:直接在命令行把串口配成 115200、8N1、原始模式,之后cat就能看到原始数据。cat /dev/ttyUSB0:最简单的接收工具。但注意它在你没设置 raw 的情况下会受行模式影响,看到的数据可能被转换过。echo -e "AT\r" > /dev/ttyUSB0:最简单的发送。测试 AT 指令、发控制命令很方便。socat:瑞士军刀级别的工具。可以用它做虚拟串口对(socat -d -d pty,raw,echo=0 pty,raw,echo=0),方便在没有真实硬件的情况下测试程序;也可以做串口和网络的桥接。
我调试时最常用的组合是:先用stty配好参数,然后cat看原始流确认设备在发数据,再用echo发指令验证设备能收。这一套跑通,说明硬件链路没问题,问题就在你自己的代码里了。
6.2 图形化调试助手的价值
“串口调试助手”“xcom串口助手”“友善串口助手”这类工具在Windows下很流行,Linux下其实也有对应选择。它们的好处是能按十六进制显示、能定时发、能做帧格式标记,观察协议帧特别直观。我在做私有协议开发时,一般会先用图形助手把协议帧格式确认清楚,再动手写代码,能避免很多“代码发出去的字节和我想的不一样”的问题。
不过图形助手也有局限:它不能替代代码里对边界情况(超时、断连、粘包)的处理。它更适合做协议验证和临时观测,不适合作为长期方案。
6.3 用虚拟串口对做无硬件测试
如果你手头暂时没有硬件,或者想自动化测试,socat创建虚拟串口对是神技:
socat -d -d pty,raw,echo=0 pty,raw,echo=0它会在输出里告诉你两个 pty 设备路径,一个当作发送端,一个当作接收端。你的程序和测试脚本分别打开这两端,就能模拟完整的收发。这个技巧我在写回归测试时用得很多,不需要每次插板子,CI 环境里也能跑。
7. 把串口接进真实项目
最后落到实际应用。不同语言、不同框架对串口的封装程度不同,理解底层之后用上层就轻松了。
7.1 Python 的 pyserial 和 ROS2 里的 serial
Python 里最常用的是 pyserial,用法非常干净:
import serial ser = serial.Serial( port='/dev/sensor0', baudrate=115200, bytesize=serial.EIGHTBITS, parity=serial.PARITY_NONE, stopbits=serial.STOPBITS_ONE, timeout=0.1 # 相当于 VMIN=0, VTIME=1 ) ser.write(b'AT\r\n') data = ser.read(64)pyserial 本质是对 termios 的封装,timeout参数对应的就是 VTIME 语义。理解底层之后,遇到 pyserial 收不到数据、SerialException报错这类问题,你就知道该去stty看配置、用lsof看占用了。
ROS2 场景下(比如 humble 版本),通常用 pyserial 加一个节点定时读取,然后发布成 topic。这里有个经验:不要在 ROS2 的回调里做阻塞读,要单独开线程或者用定时器轮询,否则会拖垮整个 executor。我见过有人把ser.read()直接放在回调里,结果整个节点卡住,其他话题都发不出去。
7.2 Qt 和嵌入式 ARM Linux 下的串口
Qt 有QSerialPort类,跨平台封装得不错,信号槽机制在 GUI 场景里很舒服。它的readyRead信号就是基于底层 fd 可读触发的,本质还是我们前面讲的 select/poll 那一套。Qt 5.5 这类老版本在 ARM Linux 上偶尔有兼容问题,比如某些平台没有 poll 支持导致 readyRead 不触发,这时候可以退回到定时器轮询读取。
在 ARM 嵌入式板子上做串口,除了前面讲的通用问题,还要特别注意别把业务串口和调试串口搞混。很多开发板把 ttyS0 留作 console,你往上面写数据会直接进系统日志或者影响启动。做应用前先用cat /proc/cmdline看看 console 参数指向哪个串口,避开它。
7.3 协议层设计:帧头、校验、超时重传
串口本身只保证字节流,不保证“消息边界”。设备发两帧数据,你读上来的可能是“第一帧后半 + 第二帧前半”粘在一起,这叫粘包。解决办法是在应用层设计协议帧:
- 帧头:用固定字节(比如 0xAA 0x55)标记一帧开始;
- 长度字段:告诉接收方这一帧有多少字节;
- 校验:CRC 或者累加和,用来判断帧有没有传错;
- 超时重传:发送方等不到应答就重发。
我踩过最深的坑就是没做帧同步。设备重启时串口上会有半截垃圾数据,如果协议没有帧头帧尾,接收端就会把垃圾当命令解析,触发各种莫名其妙的行为。加了帧头、CRC 和长度校验之后,接收端能主动丢弃非法帧,稳定性立刻上了一个台阶。
对于需要可靠传输的场景,还要加序号和重传:每帧带递增序号,接收方校验序号连续性,丢了就发 NAK 请求重传。当然不是所有场景都需要,AT 指令这种一问一答的模式天然就是同步的,不需要额外设计。什么时候加协议层、加到什么程度,取决于你对丢包和错包的容忍度。
我自己这些年用串口做项目的体会是:串口这东西看着简单,一对线两根,收发两个函数,但真要做到长期稳定运行,功夫在配置和协议这两头。配置层面,termios 的每一个开关背后都有它要解决的问题;协议层面,字节流的边界和可靠性必须靠应用层自己兜底。把这两块想清楚,再配合 stty、lsof、dmesg 这套排查工具,绝大多数串口问题都能自己定位。至于具体用 C、Python 还是 Qt,那只是外壳,底下的 tty 模型是不会变的。