一块板子插上USB转串口线,ls /dev/ttyUSB*什么都没看到;或者设备节点出来了,代码里open也成功,read却一直返回0;再或者数据是收到了,但全是乱码,调了半天波特率还是不对。这些场景我在做嵌入式Linux、工业网关、机器人底盘的这几年里反复遇到。Linux串口应用编程这件事,说难不难,说简单也真不简单——内核的tty子系统帮你屏蔽了大部分硬件细节,但剩下那一层termios配置、设备权限、非阻塞IO和帧解析,才是真正决定项目能不能跑稳的地方。
这篇内容我会把Linux下串口编程从头到尾捋一遍:硬件链路怎么走、/dev/ttyS*和/dev/ttyUSB*有什么区别、termios里每个标志位是干什么的、VMIN和VTIME怎么配、select和epoll怎么选、粘包断帧怎么处理、Python和C两套实现怎么写、出了问题按什么顺序排查。不管你是刚接触串口通信的学生,还是要在项目里接一堆传感器和PLC的工程师,都应该能从里面找到能直接抄的代码和能直接用的排查思路。
1. 先把Linux下的串口链路彻底搞明白
1.1 从物理引脚到 /dev/ttyS0 的完整路径
很多人一上来就写代码,结果连数据从哪来到哪去都说不清楚,出了问题时根本无从下手。串口在Linux里的完整链路其实是一条很长的链子:芯片上的UART控制器产生TTL电平的TX/RX信号,经过电平转换芯片(比如MAX3232)变成RS-232的±12V,或者经过RS-485收发器变成差分信号,再通过线缆接到对端设备。主机侧如果是SoC自带的UART,内核启动时就会注册成/dev/ttyS0、/dev/ttyS1这样的平台设备;如果是USB转串口芯片,插上之后内核的USB串口驱动会动态创建一个/dev/ttyUSB0或者/dev/ttyACM0。
这两类节点在应用层看起来一样,底层差别却不小。ttyS*对应的是物理UART,波特率由SoC的时钟分频得到,高波特率下容易出现误差累积;ttyUSB*背后是USB协议转换,芯片内部有自己的缓冲区,还会引入额外的延迟抖动。ttyACM*则是CDC-ACM类设备,常见于STM32的USB虚拟串口、Arduino这类带原生USB的板子。
理解这条链子的价值在于排查。比如你发现/dev/ttyUSB0存在但读写全无反应,问题可能出在USB枚举阶段;如果ttyS0收数据总是丢字节,那更可能是内核中断处理或者FIFO配置的问题,而不是你的应用代码。把问题定位到链子的哪一段,比盲目改代码有效得多。
还有一种情况是设备节点创建了,但名字每次都变。插两个CH340,这次是ttyUSB0和ttyUSB1,重启之后顺序就反了。这种时候不要靠猜,后面会讲怎么用udev规则固定设备名。
1.2 USB转串口芯片选型:CH340、CP2102、FT232 的真实差异
做项目绕不开选芯片。市面上最常见的就是沁恒的CH340系列、Silicon Labs的CP2102/CP2104、FTDI的FT232系列,还有国产的CH9102、CH343这些新片。它们在Linux下的表现差异,直接决定了你要不要额外折腾驱动。
CH340系列最大的优势是便宜,板子上随处可见,但它早期版本的驱动在部分内核上需要手动编译,而且不同批次的芯片在某些波特率下误差偏大。好消息是现在的Linux内核(大概从4.x后期开始)已经内置了ch341驱动,热词里提到的“ch340串口驱动”“ubuntu ch340串口驱动”这类问题,绝大多数情况只需要确认内核模块加载了就行:
lsmod | grep ch341 # 没有输出就手动加载 sudo modprobe ch341 dmesg | tail -20 # 通常会看到 ch341-uart 转换器被识别,以及 ttyUSB0 attachedCP2102的驱动cp210x同样在内核里,稳定性口碑更好,波特率精度也更高,缺点是价格贵一些。FT232是老牌选手,驱动ftdi_sio非常成熟,支持各种自定义波特率,缺点是芯片本身贵,而且市场上假货多,买到山寨片会出现“能识别但通信不稳”的诡异现象。
我自己的经验是:调试阶段用什么都行,量产项目如果要跑115200以上且对丢包敏感,优先考虑CP2102或者FT232正片;如果是低速、成本敏感的场合,CH340完全够用,但要接受它偶尔需要modprobe这一下。判断芯片型号最直接的办法是看lsusb里的VID/PID:
| 芯片 | VID | PID | 内核驱动 |
|---|---|---|---|
| CH340/CH341 | 1a86 | 7523 | ch341 |
| CH9102 | 1a86 | 55d4 | ch343 |
| CP2102 | 10c4 | ea60 | cp210x |
| FT232R | 0403 | 6001 | ftdi_sio |
| CDC-ACM | 各种 | 各种 | cdc_acm |
这张表建议存下来,写udev规则和排查设备不识别时反复要用。
注意:如果
lsusb能看到设备但dmesg里没有任何 tty 相关日志,先怀疑线材供电或者USB Hub问题,而不是驱动问题。供电不足导致USB设备反复枚举,是现场最容易忽略的坑。
1.3 设备权限、用户组与 udev 规则
Linux下串口节点默认属主是root、属组是dialout,权限通常是crw-rw----。普通用户直接打开会得到Permission denied,这是新手最先撞的墙。最粗暴的办法是sudo chmod 666 /dev/ttyUSB0,但重启之后就失效了,而且每次插拔设备节点都可能重新创建。
正规做法是把当前用户加进dialout组:
sudo usermod -aG dialout $USER # 需要重新登录或者 newgrp dialout 才会生效更工程化的做法是写udev规则,既固定设备名又放权限。在/etc/udev/rules.d/下新建99-my-serial.rules:
SUBSYSTEM=="tty", ATTRS{idVendor}=="1a86", ATTRS{idProduct}=="7523", SYMLINK+="ttySensor", MODE="0666", GROUP="dialout"这样不管插拔多少次、插在哪个口,应用代码里永远打开/dev/ttySensor就行,代码里硬编码ttyUSB0的做法在有多台设备的场景下迟早出事。改完规则执行:
sudo udevadm control --reload-rules sudo udevadm trigger ls -l /dev/ttySensor能看到软链接指向实际的ttyUSBx,说明规则生效了。这一步看着简单,但它是把“实验室能跑”变成“现场能跑”的关键动作。
2. termios:串口编程真正的核心战场
2.1 一份可以直接抄的 termios 配置模板
打开串口只是第一步,真正决定通信质量的是termios结构体。很多人写串口代码是从网上抄一段,跑通了就不管,结果换台设备就出问题。我建议至少把下面这份模板理解透,它覆盖了95%的常见场景:
#include <termios.h> #include <fcntl.h> #include <unistd.h> #include <stdio.h> #include <string.h> int serial_open(const char *dev, int baud) { /* O_NOCTTY:不要把串口当控制终端,避免Ctrl+C被串口吃掉 O_NDELAY:先在非阻塞下打开,防止DCD信号不对时一直卡住 */ int fd = open(dev, O_RDWR | O_NOCTTY | O_NDELAY); if (fd < 0) { perror("open serial"); return -1; } /* 打开成功后立刻清掉非阻塞,改成阻塞+超时模型 */ if (fcntl(fd, F_SETFL, 0) < 0) { perror("fcntl"); close(fd); return -1; } struct termios opt; if (tcgetattr(fd, &opt) != 0) { perror("tcgetattr"); close(fd); return -1; } /* 原始模式:关回显、关行缓冲、关所有字符转换 */ cfmakeraw(&opt); speed_t spd; switch (baud) { case 9600: spd = B9600; break; case 19200: spd = B19200; break; case 38400: spd = B38400; break; case 57600: spd = B57600; break; case 115200: spd = B115200; break; case 230400: spd = B230400; break; case 460800: spd = B460800; break; default: spd = B115200; break; } cfsetispeed(&opt, spd); cfsetospeed(&opt, spd); opt.c_cflag |= (CLOCAL | CREAD); /* 忽略调制解调器控制线,允许接收 */ opt.c_cflag &= ~CSIZE; opt.c_cflag |= CS8; /* 8位数据位 */ opt.c_cflag &= ~PARENB; /* 无校验 */ opt.c_cflag &= ~CSTOPB; /* 1位停止位 */ #ifdef CRTSCTS opt.c_cflag &= ~CRTSCTS; /* 默认关硬件流控 */ #endif opt.c_cc[VMIN] = 0; opt.c_cc[VTIME] = 10; /* 单位是0.1秒,即1秒超时 */ tcflush(fd, TCIOFLUSH); /* 清掉打开前残留的数据 */ if (tcsetattr(fd, TCSANOW, &opt) != 0) { perror("tcsetattr"); close(fd); return -1; } return fd; }这段代码里有两个细节特别容易踩坑。第一,O_NDELAY打开之后必须用fcntl改回阻塞,否则read会立刻返回不等待。第二,cfmakeraw内部已经把VMIN设成1、VTIME设成0,所以设置VMIN/VTIME必须放在它后面,顺序反了就白设了。
2.2 波特率、数据位、校验位、停止位到底怎么定
这四个参数必须和通信对端完全一致,差一点都不行。波特率是最常出问题的:115200对上9600,收到的就是一堆乱码或者干脆没反应。判断波特率是否匹配有个小技巧——如果收到的数据长度大致对但内容是乱码,通常是波特率接近但不精确;如果一个字节都收不到,那更可能是位宽或校验配置完全不对。
数据位一般是8位,这是绝大多数协议的选择。7位只在一些老式设备上出现,而且7位模式下需要配合ISTRIP之类的标志处理,非常麻烦,能不用就不用。
校验位分无校验(None)、奇校验(Odd)、偶校验(Even)、标记(Mark)、空格(Space),实际项目里99%用的是无校验。要开偶校验的话:
opt.c_cflag |= PARENB; /* 使能校验 */ opt.c_cflag &= ~PARODD; /* 偶校验;要奇校验就 |= PARODD */软件接收端如果想检查校验错误,可以打开INPCK和PARMRK,但要注意PARMRK会在出错字节前插入0xFF 0x00前缀,处理起来更复杂。我的建议是:除非对端强制要求,否则校验交给上层的帧校验(CRC或者校验和)去做,比硬件校验更可靠也更好调试。
停止位通常是1位,老式电传设备可能用2位(CSTOPB)。这个参数出错的表现是间歇性丢帧,很难查,所以接线之前一定跟对端的技术文档核实清楚。
2.3 VMIN 和 VTIME:阻塞行为的总开关
这两个字段是串口读超时机制的核心,理解它们能省掉大量调试时间。VMIN是“最少读多少个字节才返回”,VTIME是“等待多久(0.1秒为单位)”。
它们的组合有四种典型语义:
VMIN=0, VTIME=0:纯非阻塞,read立刻返回缓冲区里现有的数据,没有就返回0。适合配合select/poll用。VMIN=0, VTIME>0:读超时模式,最多等VTIME个0.1秒,期间有数据就返回。这是最常用的模式,我上面模板用的就是这种。VMIN>0, VTIME=0:一直阻塞到读满VMIN个字节才返回。适合定长帧协议。VMIN>0, VTIME>0:等待第一个字节最多VTIME,收到第一个字节后开始计时,字节间隔超过VTIME就返回。适合不定长帧。
实践中我推荐VMIN=0, VTIME=10,也就是1秒超时,再配合select做多路复用。这样既能保证及时返回,又不会因为超时太短而频繁唤醒浪费CPU。如果通信对方发送间隔很长(比如几秒一帧的传感器),可以调大VTIME,但更优雅的做法是用select设置无限制超时,让内核帮你等。
注意:
VTIME=0且VMIN=0时,如果串口配置成了非阻塞,read可能返回EAGAIN。别把它当成错误,这是正常的“暂时没数据”信号。
2.4 硬件流控和软件流控:什么时候该开,什么时候坚决不开
流控的作用是防止接收方缓冲区满了之后丢数据。硬件流控用RTS/CTS两根额外的线,可靠性高,但要求线缆必须接这两根线,很多三线制的调试线根本没接,开了之后反而所有数据都发不出去。
硬件流控的开关是c_cflag里的CRTSCTS。打开:
opt.c_cflag |= CRTSCTS;软件流控用XON(0x11)和XOFF(0x13)这两个控制字符,通过c_iflag的IXON、IXOFF控制。问题是如果你的数据里恰好包含0x11或0x13,就会被误认为流控字符,导致数据被吞。二进制协议绝对不要开软件流控。
我的建议很明确:除非对端设备和协议明确要求,否则两者都关掉。如果确实存在高速率大流量场景下的丢数据,优先排查是不是应用层读取太慢,而不是急着开流控。真正需要开硬件流控的场合,通常是115200以上、连续传输大块数据的场景,比如通过串口传固件。
3. 收发数据:从能读到读得稳
3.1 open 的模式选择与 O_NONBLOCK 的取舍
open的三个标志经常被搞混,我这里理一遍:O_RDWR是读写,这个没争议;O_NOCTTY告诉内核不要把串口当作进程的控制终端,不加的话在某些环境下串口上的信号会影响到你的进程;O_NONBLOCK或O_NDELAY控制打开时不等待载波信号。
这里有个经典陷阱:串口设备打开时,如果对方设备的DCD(数据载波检测)没有拉高,阻塞模式下open会一直卡住。而很多设备根本不接DCD线。所以先用O_NDELAY打开、成功后再用fcntl清掉非阻塞标志,这个组合是最稳妥的。
如果应用本身是事件驱动的,也可以全程保持非阻塞模式,用select/poll/epoll来管理。多路串口、同时又要在同一个线程里处理网络IO的场景,这种做法更自然。
3.2 read 和 write 的返回值处理
串口的read和write都不是“一次调用就完成整个请求”的语义。write返回的是实际写入了多少个字节,可能小于你请求的长度,尤其是数据量大、内核缓冲区满的时候。正确写法是循环写:
ssize_t write_all(int fd, const void *buf, size_t len) { const unsigned char *p = buf; size_t left = len; while (left > 0) { ssize_t n = write(fd, p, left); if (n < 0) { if (errno == EINTR) continue; /* 被信号打断,重试 */ if (errno == EAGAIN || errno == EWOULDBLOCK) { /* 非阻塞下缓冲区满,简单sleep一下或者用poll等可写 */ usleep(1000); continue; } return -1; } p += n; left -= n; } return len; }read也是一样,返回0不一定是出错——在VMIN=0超时模式下返回0就是“这一轮没读到数据”。EINTR必须重试,信号打断是常态。EAGAIN在非阻塞模式也是正常现象。把这些都当成错误直接退出程序,是新手代码最常见的毛病。
3.3 select、poll、epoll 怎么选
单串口的简单应用,一个阻塞read加超时就够了。但只要涉及多路串口、或者还要同时处理socket和定时器,就必须上多路复用。
select是老接口,最大文件描述符数有FD_SETSIZE(通常1024)的限制,而且每次调用都要重新构造fd_set,效率一般。优点是跨平台好、写起来直观。
poll用数组传fd,没有数量上限,语义也更清晰,是我在纯Linux项目里最常用的。
epoll在fd数量多的时候优势明显,但串口场景一般不会开几百个口,所以边际收益有限。不过在ROS2那种一个节点里要管好几个串口和话题的场景,用epoll统一事件循环会更整洁。
select的典型骨架:
fd_set rfds; struct timeval tv; FD_ZERO(&rfds); FD_SET(fd, &rfds); tv.tv_sec = 1; tv.tv_usec = 0; int ret = select(fd + 1, &rfds, NULL, NULL, &tv); if (ret < 0) { if (errno == EINTR) continue; perror("select"); break; } else if (ret == 0) { /* 超时,可以做心跳或者状态检查 */ } else { if (FD_ISSET(fd, &rfds)) { unsigned char buf[1024]; ssize_t n = read(fd, buf, sizeof(buf)); if (n > 0) { /* 喂给帧解析器 */ } } }select的timeout参数在返回后会被修改,如果放在循环里,每次都要重新赋值,这个坑我见过不止一个人踩。
3.4 粘包与断帧:环形缓冲区加状态机
串口是字节流,不保证一次read正好读到一帧完整数据。可能半帧、可能两帧粘在一起、可能一帧被拆成三次。所以应用层一定要有缓冲和帧同步机制。
最基础的做法是环形缓冲区加帧头扫描。假设协议是AA 55 | LEN | PAYLOAD | CRC16,解析流程是:先把收到的字节全部写入环形缓冲区,然后从读指针位置找AA 55,找到之后判断缓冲区里的可用长度是否够读到LEN字段,够的话再判断整帧是否完整,完整就校验CRC并提交,不完整就等下一批数据。
typedef struct { unsigned char buf[4096]; size_t head; /* 写位置 */ size_t tail; /* 读位置 */ } ring_t; static int frame_parse(ring_t *r, unsigned char *out, size_t out_len) { while (1) { size_t avail = (r->head - r->tail + sizeof(r->buf)) % sizeof(r->buf); if (avail < 4) return 0; /* 连帧头+LEN都不够 */ /* 找帧头 AA 55 */ size_t pos = r->tail; if (!(r->buf[pos] == 0xAA && r->buf[(pos + 1) % sizeof(r->buf)] == 0x55)) { r->tail = (r->tail + 1) % sizeof(r->buf); /* 滑动一字节重新同步 */ continue; } size_t len = r->buf[(pos + 2) % sizeof(r->buf)]; size_t total = 2 + 1 + len + 2; /* 头 + 长度字段 + 负载 + CRC */ if (avail < total) return 0; /* 帧不完整,等下次 */ /* 这里做CRC校验并把负载拷贝到out,然后移动tail */ /* ... 省略细节 ... */ r->tail = (r->tail + total) % sizeof(r->buf); return 1; } }环形缓冲区的容量要按最大帧长的几倍来设计,太小会丢帧,太大会浪费内存。我一般按“最大帧长 × 4”来算,4096字节对大多数场景够用。
这里有个细节值得强调:滑动重新同步时不要一次跳一个字节就重置整个缓冲区,而是逐字节滑动找帧头,这样即使前面有一堆噪声也能重新对上。另外,如果协议里带时间戳或者序列号,务必在解析成功后记录,方便事后分析丢帧情况。
4. 调试排查:不写代码也能定位问题
4.1 stty 加 cat 加 echo:命令行三板斧
很多时候根本不用写代码就能验证串口是否正常。stty可以查看和修改串口参数:
# 查看当前参数 stty -F /dev/ttyUSB0 -a # 配置成 115200 8N1 原始模式 stty -F /dev/ttyUSB0 115200 cs8 -cstopb -parenb -echo raw # 一个终端里持续读 cat /dev/ttyUSB0 # 另一个终端里发 echo -n "hello" > /dev/ttyUSB0如果cat能看到对端发来的数据,说明硬件链路、驱动、参数配置全部正常,问题一定在你的代码里。如果cat也没反应,那就要往驱动和硬件层查了。这个二分法能节省大量时间。
还有一个常被忽略的动作:把TX和RX短接做自环测试。发什么就应该收到什么,如果自环都不通,说明串口本身或者参数配置有问题,跟对端设备无关。
4.2 常见故障速查表
| 现象 | 最可能原因 | 排查动作 |
|---|---|---|
/dev/ttyUSB0不存在 | 驱动未加载/线材/供电 | lsusb、dmesg、modprobe ch341 |
open返回 Permission denied | 用户不在 dialout 组 | groups、usermod -aG dialout |
open卡住不返回 | DCD 未拉高 | 用O_NDELAY打开后再清标志 |
| 收到全 0x00 | 波特率严重不匹配/接线错 | 核对波特率、TX/RX 是否交叉 |
| 收到乱码但长度大致对 | 波特率接近但不精确 | 换标准波特率、检查晶振 |
| 偶发丢字节 | 应用层读取太慢/缓冲区溢出 | 加大读取频率、用 select |
| 数据粘在一起 | 流式字节流本质 | 应用层加帧解析 |
| 发送无输出 | 流控被误开/CTS 未接 | 关掉 CRTSCTS |
| 长时间运行后卡死 | 未处理 EINTR/EAGAIN | 检查所有 IO 返回值处理后重试 |
这张表是我自己这几年一点点攒起来的,遇到问题先对号入座,比漫无目的地试要快得多。
4.3 丢数据、乱码、烧写失败背后的真实原因
热词里“linux从串口接收数据丢失”“串口烧写失败”这两个问题出现频率特别高,值得单独说说。
丢数据通常有三个层次的原因。第一层是硬件:USB转串口芯片的FIFO溢出,尤其是CH340在高速率下,如果应用层读取间隔太长,芯片内部缓冲就满了。第二层是内核:中断处理延迟,在高负载系统上,如果串口中断被长时间屏蔽,数据就会丢。第三层是应用:读取循环太慢,或者单次read的缓冲区太小。
定位方法是先看丢包的规律。如果是固定长度丢,多半是帧解析问题;如果是随机丢单字节,优先怀疑FIFO溢出或者中断延迟;如果高负载时才丢,那就是系统调度问题。解决方向上,提高应用读取频率、用select代替sleep轮询、把串口线程优先级调高,这三招能解决大部分问题。
烧写失败是另一个大类。用串口给单片机或者板子烧写固件时,失败往往不是烧写工具的问题,而是:一是流控被打开导致数据被拦;二是波特率在烧写时会被临时切换,某些USB转串口芯片切换速率时不稳定;三是烧写协议依赖精确的时序,而USB转串口的延迟抖动破坏了时序。遇到这种问题,换FT232或者CP2102这类延迟更可控的芯片,成功率会明显提升。
注意:烧写场景下不要用
cat之类的工具占用串口,很多烧写失败其实是端口被别的进程占着,fuser /dev/ttyUSB0可以查出是谁在用。
5. 工程化实现:Python 与 C 的两套落地方式
5.1 pyserial:快速验证和脚本化的首选
做原型验证、写测试脚本、做数据采集,pyserial是效率最高的选择。它的参数命名和termios一一对应,很容易迁移:
import serial import time ser = serial.Serial( port='/dev/ttyUSB0', baudrate=115200, bytesize=serial.EIGHTBITS, parity=serial.PARITY_NONE, stopbits=serial.STOPBITS_ONE, timeout=0.1, # 读超时(秒),None 表示永久阻塞 write_timeout=1.0, ) try: while True: # 方法一:按长度读 data = ser.read(ser.in_waiting or 1) if data: print(data.hex(' ')) # 方法二:按分隔符读到一帧 # line = ser.read_until(b'\r\n') finally: ser.close()in_waiting属性可以拿到接收缓冲区里现有字节数,配合read能避免不必要的阻塞。但要注意,in_waiting只是内核缓冲区的快照,不保证read时数据还在(虽然串口场景下基本没问题)。
如果要处理粘包,pyserial也提供了read_until,但只适合文本协议比如以换行结尾的AT指令。二进制协议还是要自己维护缓冲区做状态机。我一般会在Python里也写一个小的缓冲区类,逻辑和C版本保持一致,这样两端调试时行为统一。
一个实际经验是:Python的GIL不会影响串口IO本身(IO释放GIL),但如果解析逻辑很重,会拖慢读取频率。数据量大的时候,把读取放到独立线程,用一个queue.Queue把原始字节传给解析线程,能明显减少丢包。
5.2 C 版本的可复用封装思路
C版本适合嵌入到实际产品里。我通常会把串口封装成一个小模块,对外只暴露几个函数:打开、关闭、写、注册接收回调。内部用独立线程跑select循环,收到数据就喂给解析器,解析出完整帧再回调。
线程模型的要点是:接收线程只做IO和帧同步,业务处理放到回调里或者另一个队列,避免阻塞接收。退出时用一个原子标志位通知线程结束,然后join,不要用cancel强杀,容易在持有锁的时候死掉。
还有一点容易被忽略:串口关闭前要确保接收线程已经退出,否则可能出现线程还在read、主线程已经close了fd的情况,导致未定义行为。正确的顺序是先置退出标志、join线程、再close。
5.3 长时间运行的稳定性验证
串口程序上线之后最怕的是跑几天就卡死。做压力测试的时候,我一般会准备一个对端程序,以固定频率持续发送数据,同时记录发送序号,主机侧统计收到的序号分布,看有没有丢帧和乱序。跑满24小时不出问题,才算基本可用。
测试中要重点观察几件事:内存有没有持续增长(缓冲区没清理)、CPU占用是否稳定(有没有忙等待)、fd数量是否恒定(有没有泄漏)。这三项只要有一项异常,跑得越久问题越大。
如果系统负载很高,还可以用taskset把串口线程绑到固定的CPU核上,减少调度带来的抖动。这个做法在工控场景里效果很明显。
6. 几个值得单独聊的实践经验
6.1 协议层校验比硬件校验更值得投入
很多新手在termios上折腾半天校验位,其实把CRC16加到帧尾更划算。硬件校验只能发现单比特错误,而且一旦出错怎么重传是个问题;帧尾校验加上重传机制,能覆盖绝大多数通信异常。工业协议比如Modbus RTU用的就是CRC16,配上超时重发,在电磁环境差的现场也能稳定跑。
6.2 超时和重试策略要分级设计
串口通信的超时不能只有一个值。我一般分三层:单字节读取超时、单帧接收超时、命令响应超时。单字节几毫秒到几十毫秒,单帧根据帧长和波特率算,命令响应超时typically几百毫秒到几秒。重试次数也不要一刀切,读超时重试1到2次即可,命令失败重试3次后报错上抛。这样既不会因为偶发抖动误判,也不会在设备真掉线时傻等。
6.3 把调试日志做成可开关的
上线之后最想有的是日志,但全量打印又会拖慢程序。我的做法是编译期或者运行期一个DEBUG宏控制,默认只记录错误和状态变化,打开后记录每一帧的十六进制内容。十六进制打印要带时间戳和方向(收/发),事后分析时能直接还原通信过程。
#define DBG_RX 1 #define DBG_TX 1 #define HEXDUMP(tag, buf, len) do { \ if (DBG_##tag) { \ fprintf(stderr, "%s [%zu]:", #tag, (size_t)(len)); \ for (size_t i = 0; i < (size_t)(len); i++) \ fprintf(stderr, " %02X", ((unsigned char *)(buf))[i]); \ fprintf(stderr, "\n"); \ } \ } while (0)这个宏在排查现场问题时特别好用,不需要重新设计日志框架,加两行就能看到原始数据。
最后分享一个我自己踩过的坑:有次调试一个传感器,代码在开发机上跑得好好的,放到工控机上就一直收到乱码,换了三根线、重装了驱动都没用,最后发现是工控机的USB口供电不稳,换到带独立供电的Hub上立刻就正常了。所以当你确信软件没问题、参数也没问题的时候,别忘了把怀疑的目光投向电和线,串口这东西,物理层的问题占的比例远比想象中高。