Linux串口编程实战:termios配置、权限与收发排错 📅 发布时间:2026/9/18 4:55:21 👁 浏览次数: 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_siottyACM 依赖 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/CH341ch341是较新内核老内核可能识别为未知设备CP2102/CP2104cp210x是基本即插即用FT232/FT2232ftdi_sio是多通道设备要区分接口PL2303pl2303是部分山寨芯片兼容性差判断驱动有没有正确加载最直接的方法是一边插拔设备一边看内核日志。插上设备后执行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.rulesSUBSYSTEMtty, ATTRS{idVendor}1a86, ATTRS{idProduct}7523, SYMLINKsensor0, MODE0666重新加载规则生效sudo udevadm control --reload-rules sudo udevadm trigger这条规则做了两件事创建/dev/sensor0这个稳定软链接同时把权限设成0666所有人可读写。之后你的程序就固定打开/dev/sensor0就行了插哪个口都不影响。注意如果有多个相同 VID/PID 的设备就得再加条件区分比如用ATTRS{serial}...匹配设备的序列号或者用KERNELS1-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信号处理比如 CtrlC 会触发信号——全关。c_iflag里的IXON/IXOFF/IXANY软件流控、ICRNL/INLCR/IGNCR回车换行转换——全关。c_oflag里的OPOST输出后处理——关掉否则你发出去的字节会被内核改写。我最早写串口代码时没关OPOST发了两个字节的数据包过去设备死活不认。抓了半天协议才发现内核把其中的\n偷偷转成了\r\n数据包结构直接被破坏。这个坑非常隐蔽因为它不会报错只是数据“悄悄地多了或少了”。所以记住做二进制协议OPOST、ICRNL 这些一定要关。3.2 波特率、数据位、校验位、停止位怎么配这四个参数必须和通信对端严格一致错一个就是乱码或者完全收不到。波特率用cfsetispeed/cfsetospeed设置输入输出通常设成一样。注意波特率是“每秒符号数”实际还要看数据位配置很多人误以为它等于字节速率。数据位用CSIZE掩码清掉再设最常用CS88位。先用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()的行为非常关键但很容易被忽略。它们的组合有四种语义VMINVTIMEread 行为00立即返回读到多少算多少纯非阻塞00最多等 VTIME×100ms有数据立即返回00至少读到 VMIN 个字节才返回阻塞00或读满 VMIN 字节、或超时 VTIME 就返回我一般用VMIN0, VTIME10语义是“读一次最多等1秒有数据就立刻返回”。这样应用层不会死等能及时处理其他逻辑。但要注意这种配置下read()返回 0 并不代表出错它只是“超时了没数据”需要和真正的连接断开区分开。这正是 4.3 要讲的坑。4.3 read 返回 0 到底意味着什么read返回 0 在普通文件里意味着“读到文件末尾 EOF”但在串口上含义完全不同。在串口里当你设置了VMIN0, VTIME0read返回 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、socatstty -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,echo0 pty,raw,echo0方便在没有真实硬件的情况下测试程序也可以做串口和网络的桥接。我调试时最常用的组合是先用stty配好参数然后cat看原始流确认设备在发数据再用echo发指令验证设备能收。这一套跑通说明硬件链路没问题问题就在你自己的代码里了。6.2 图形化调试助手的价值“串口调试助手”“xcom串口助手”“友善串口助手”这类工具在Windows下很流行Linux下其实也有对应选择。它们的好处是能按十六进制显示、能定时发、能做帧格式标记观察协议帧特别直观。我在做私有协议开发时一般会先用图形助手把协议帧格式确认清楚再动手写代码能避免很多“代码发出去的字节和我想的不一样”的问题。不过图形助手也有局限它不能替代代码里对边界情况超时、断连、粘包的处理。它更适合做协议验证和临时观测不适合作为长期方案。6.3 用虚拟串口对做无硬件测试如果你手头暂时没有硬件或者想自动化测试socat创建虚拟串口对是神技socat -d -d pty,raw,echo0 pty,raw,echo0它会在输出里告诉你两个 pty 设备路径一个当作发送端一个当作接收端。你的程序和测试脚本分别打开这两端就能模拟完整的收发。这个技巧我在写回归测试时用得很多不需要每次插板子CI 环境里也能跑。7. 把串口接进真实项目最后落到实际应用。不同语言、不同框架对串口的封装程度不同理解底层之后用上层就轻松了。7.1 Python 的 pyserial 和 ROS2 里的 serialPython 里最常用的是 pyserial用法非常干净import serial ser serial.Serial( port/dev/sensor0, baudrate115200, bytesizeserial.EIGHTBITS, parityserial.PARITY_NONE, stopbitsserial.STOPBITS_ONE, timeout0.1 # 相当于 VMIN0, VTIME1 ) ser.write(bAT\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 模型是不会变的。