干嵌入式这些年,打交道最多的就是串口。调试工装、采集PLC数据、升级固件、连扫码枪,哪一样都绕不开UART。今天想聊的这份“工业开启串口”自用无bug版本,是我在Linux环境下沉淀下来的一个串口封装函数——别看只是把串口打开,这里面的门道真不少,全是工业现场逼出来的细节。如果你也需要在Linux板卡上用串口对接工业设备,或者正打算自己封装一套串口工具库,这篇内容应该能帮你省掉不少踩坑时间。
我以前也喜欢到处抄串口初始化的代码,今天拷一段、明天改一行,结果每次换设备、换接线方式就出问题。后来花了两个晚上把这些零散经验整理成一个open_serial()函数,不断补边界条件,才敢叫它“自用无bug版本”。这篇文章我不会只丢一段代码给你,而是把为什么要这么写、为什么不那样写,全部拆开讲清楚。
1. 项目背景:为什么“开启串口”这么点事也能水出一篇长文
1.1 工业场景下的串口到底有什么不一样
很多人觉得串口就是“发数据、收数据”,写个调试助手一分钟就搞定。但在工业现场,串口要承担的东西比实验室里重得多。现场有变频器、PLC、触摸屏、仪表、扫码枪,线缆动不动就拉出去几十米,周围还有电机、电源带来的电磁干扰。这种环境下,串口的开启和参数配置如果不规范,会出现各种乱七八糟的软故障:偶尔乱码、丢一个字节、长时间跑下来卡死,甚至设备一上电就连接不上。
工业串口还有一个特点:设备类型五花八门。有的用RS232,有的用RS485,有的只要两线半双工,有的必须带硬件流控。你面对的这些设备,往往不会主动告诉你它内部是怎么配置的,只能靠现场对接。更麻烦的是,工业设备经常需要长时间稳定性测试,可能连续运行几天几夜,一旦串口底层打开的时候留下一个隐患,后面就是断断续续的折磨。
所以“开启串口”这件事,在工业项目里并不是简单调用一个open()就行。它背后至少包括:
- 打开设备节点时,阻塞和非阻塞方式怎么选;
- 波特率、数据位、校验位、停止位如何正确设置;
- 内核默认的终端处理模式要不要关掉;
- 软件流控、硬件流控该不该开;
- 串口被占用、设备拔插、权限不足该怎么处理;
- RS485半双工时,方向引脚怎么切换才不丢最后一个字节。
这些东西全堆在“开启串口”四个字下面,单独拎出来写一篇长文一点不夸张。
1.2 自用版本的衡量标准:不挑设备、不乱码、不丢帧
我给自己订的规矩很简单:这个版本换到任何一台Linux板卡上,遇到任何常见的工业串口设备,只要参数填对,第一次打开就能稳定通信。不能因为设备节点是/dev/ttyUSB0和/dev/ttyS0有差异就改代码,也不能因为某根线没接RTS/CTS就彻底收不到数据。
所谓“无bug”,我的标准是三个维度:
第一,不挑设备。USB转串口也好,板载UART也好,工控机原生COM口也好,打开方式一致,配置思路一致。
第二,不乱码。波特率、数据位、校验位这些必须精确映射到系统termios结构体,一个标志位错了,协议就全乱了。
第三,不丢帧。不只要打开设备,还要把内核的缓冲、流控、原始模式全部理干净,确保数据进来之后不会被内核二次加工,也不会因为软件流控被莫名暂停。
后面这几点,就是这篇文章的主要内容。
2. 串口开启函数的核心设计:从open到termios
2.1 打开设备节点:只写O_RDWR还不够
在Linux下,串口设备就是文件,一般出现在/dev/ttyS0、/dev/ttyUSB0、/dev/ttyAMA0这些节点上。第一次写串口程序的人,最常见的是直接fd = open("/dev/ttyUSB0", O_RDWR | O_NONBLOCK),然后就拿着这个fd去配置了。
这里有个很多教程不会讲的坑:open的时候必须带O_NOCTTY,意思是不要把当前进程变成这个终端的控制终端。如果不加,一旦你的进程在后台运行,可能收到终端的SIGTTOU、SIGTTIN之类的信号,程序会莫名其妙被挂起甚至被杀掉。工业现场用systemd服务跑程序的情况非常多,这个标志尤其重要。
打开方式上,我建议第一版就做两个路径:阻塞模式open(dev, O_RDWR | O_NOCTTY)和非阻塞模式open(dev, O_RDWR | O_NOCTTY | O_NONBLOCK)。注意不要用O_NDELAY,那是历史遗留,语义在各种平台上有差异,不如O_NONBLOCK清晰。
我自用版本里,block_mode参数控制的就是这个标志位。日常调试一般用阻塞模式,配合后面要讲的VMIN和VTIME实现超时控制;如果要写复杂的数据收发逻辑,再用非阻塞模式配合select或poll。
还有一点:打开之前最好确认权限。很多USB转串口设备挂载后属于dialout组,当前用户不在这个组里,open会返回Permission denied。现场的工控机如果图省事,很多人直接chmod 777 /dev/ttyUSB0,我不推荐,正规做法是把用户加进dialout组,或者写udev规则。这个问题看起来很低级,但每次换新机器都会遇到。
2.2 参数配置:波特率、数据位、校验、停止位如何映射成termios
打开设备之后,核心工作就是配置结构体struct termios。Linux的串口参数都放在这里面,不能凭感觉瞎填,每个位都有明确含义。
首先要做的不是memset清零,而是先调用tcgetattr(fd, &opt),把内核当前对这个设备的配置读回来。这样做的原因很简单:内核默认有些标志是合理的,比如CLOCAL和CREAD,如果你全部清零再从头设,很容易漏掉东西。正确做法是保留现有配置,然后修改你关心的一小部分。
波特率这一块,最容易被新手误解。cfsetispeed和cfsetospeed接受的不是数字115200,而要传入B115200这种宏。常见的宏有B9600、B19200、B38400、B57600、B115200、B230400、B460800、B921600。所以我在函数里加了一个switch映射,把用户传入的普通整数转换成对应的宏。
数据位、校验位、停止位的组合,常见就下面几种:
| 配置 | 数据位 | 校验位 | 停止位 | 需要设置的标志 |
|---|---|---|---|---|
| 8N1(最常见) | 8 | None | 1 | CS8 |
| 8E1 | 8 | Even | 1 | CS8 + PARENB |
| 8O1 | 8 | Odd | 1 | CS8 + PARENB + PARODD |
| 7E1 | 7 | Even | 1 | CS7 + PARENB |
| 8N2 | 8 | None | 2 | CS8 + CSTOPB |
数据位这里必须先清掉CSIZE位再设置,否则可能残留上一个设备的数据位。校验位要同时操作PARENB和PARODD:奇校验英文是Odd,所以PARENB | PARODD;偶校验只设PARENB;无校验两个都不设。停止位只有1和2两种,2停止位需要设置CSTOPB。
这里多提一句:非标准波特率,比如某些惯导设备用的250000、某些扫码枪用的921600,标准termios接口不一定直接支持。Linux下可以通过termios2或ioctl设置自定义波特率,但工业设备绝大多数还是落在常用波特率档位上,所以我自用版本先覆盖标准档,够了。真遇到非标的,再单独写一个open_serial_custom_baud(),思路是一样的。
2.3 原始模式还是规范模式:别让内核帮你“加工”数据
这一节可能是整个串口编程里最重要的。
Linux的终端驱动默认工作工作在“规范模式”(canonical mode),内核会把你从串口收到的数据先缓存起来,直到收到换行符\n之后,才把整行数据交给调用read()的程序。这是给键盘终端设计的,对串口二进制协议来说就是灾难。你明明发过来一串帧,程序read()却永远等不到那一行结束。
所以开启串口之后,必须把ICANON关掉。同时还要关掉一组相关的标志:
ECHO:关闭回显,否则程序会把收到的数据原封不动发回串口;ISIG:关闭按键产生的SIGINT、SIGQUIT信号;IEXTEN:关闭终端扩展功能;OPOST:关闭输出处理,否则内核会把\n转为\r\n之类的,直接破坏协议内容。
我习惯把这些标志位一次性清掉,让串口进入所谓的“原始模式”(raw mode)。代码里经常看到一段:
opt.c_iflag &= ~(IGNBRK | BRKINT | PARMRK | ISTRIP | INLCR | IGNCR | ICRNL | IXON | IXOFF | IXANY); opt.c_oflag &= ~OPOST; opt.c_lflag &= ~(ECHO | ECHONL | ICANON | ISIG | IEXTEN);其中INLCR、IGNCR、ICRNL是对\r和\n做映射的,对串口数据来说全部不需要。IGNBRK、BRKINT、PARMRK涉及到中断和校验错误,有些场景可以留着,但我统一关掉,避免内核因为奇偶校验错误往数据流里插入0xFF 0x00之类的字节。
另外,ISTRIP要关闭,意思是不要把所有字节的高位剥掉。这个特别隐蔽,一旦被内核剥掉最高位,8位数据帧直接变成7位语义,数据就错了。
2.4 流控:工业设备最容易被忽略的坑
流控分两种:软件流控和硬件流控。工业串口对接,我建议默认全部关闭,除非你非常确定设备需要。
软件流控就是IXON、IXOFF、IXANY,用XON/XOFF字符(对应十六进制0x11和0x13)来暂停和恢复数据。听起来很智能,但在二进制协议里就是个隐患。如果设备通过串口发送的数据中包含0x13,内核会认为对方发来XOFF,然后自动暂停发送,你的程序就表现为“写到一半卡死”。这就是很多串口程序跑着跑着突然停止发送的经典原因。因此工业场景必须关掉软件流控。
硬件流控对应的是CRTSCTS,也就是RTS/CTS握手。很多USB转串口,或者工控机原生串口,不接RTS和CTS这两根线。如果不小心开了CRTSCTS,程序write()的时候会一直等CTS信号,而对方根本没拉这个信号,结果就是数据发不出去,程序看着像死锁。所以默认也是关闭。
只有一种情况我才会开硬件流控:你明确知道设备的说明书里要求使用RTS/CTS,并且接线确实把RTS/CTS连上了。开发阶段先用默认关闭跑通协议,再决定要不要开流控,这是最稳妥的顺序。
2.5 完整代码:自用无bug版open_serial()
把上面这些原则整合起来,就是我一直在用的版本。函数不长,但是每个分支、每个错误码都经过反复打磨:
#include <stdio.h> #include <string.h> #include <unistd.h> #include <fcntl.h> #include <errno.h> #include <termios.h> #include <sys/ioctl.h> int open_serial(const char *dev, int baud, int data_bits, char parity, int stop_bits, int block_mode) { int fd; struct termios opt; speed_t speed_baud; fd = open(dev, O_RDWR | O_NOCTTY | (block_mode ? 0 : O_NONBLOCK)); if (fd < 0) { perror("open serial"); return -1; } if (tcgetattr(fd, &opt) != 0) { perror("tcgetattr"); close(fd); return -2; } switch (baud) { case 9600: speed_baud = B9600; break; case 19200: speed_baud = B19200; break; case 38400: speed_baud = B38400; break; case 57600: speed_baud = B57600; break; case 115200: speed_baud = B115200; break; case 230400: speed_baud = B230400; break; case 460800: speed_baud = B460800; break; case 921600: speed_baud = B921600; break; default: fprintf(stderr, "unsupported baud: %d\n", baud); close(fd); return -3; } cfsetispeed(&opt, speed_baud); cfsetospeed(&opt, speed_baud); opt.c_cflag &= ~CSIZE; switch (data_bits) { case 8: opt.c_cflag |= CS8; break; case 7: opt.c_cflag |= CS7; break; case 6: opt.c_cflag |= CS6; break; case 5: opt.c_cflag |= CS5; break; default: fprintf(stderr, "invalid data bits: %d\n", data_bits); close(fd); return -4; } opt.c_cflag &= ~(PARENB | PARODD); if (parity == 'E') { opt.c_cflag |= PARENB; } else if (parity == 'O') { opt.c_cflag |= PARENB | PARODD; } else if (parity != 'N') { fprintf(stderr, "invalid parity: %c\n", parity); close(fd); return -5; } opt.c_cflag &= ~CSTOPB; if (stop_bits == 2) { opt.c_cflag |= CSTOPB; } else if (stop_bits != 1) { fprintf(stderr, "invalid stop bits: %d\n", stop_bits); close(fd); return -6; } opt.c_cflag |= CLOCAL | CREAD; opt.c_cflag &= ~CRTSCTS; opt.c_iflag &= ~(IGNBRK | BRKINT | PARMRK | ISTRIP | INLCR | IGNCR | ICRNL | IXON | IXOFF | IXANY); opt.c_oflag &= ~OPOST; opt.c_lflag &= ~(ECHO | ECHONL | ICANON | ISIG | IEXTEN); opt.c_cc[VMIN] = block_mode ? 1 : 0; opt.c_cc[VTIME] = 0; tcflush(fd, TCIOFLUSH); if (tcsetattr(fd, TCSANOW, &opt) != 0) { perror("tcsetattr"); close(fd); return -7; } return fd; }这个函数我最满意的一点是:任何参数非法,都会立刻返回负数,并且关闭已经打开的fd。不会出现“参数写错但函数看起来成功,然后后面莫名其妙收不到数据”的悬案。错误码从-1到-7,每一项都有明确含义,调用方拿到负数就知道是哪里出了问题。
block_mode参数控制O_NONBLOCK,同时我配置了VMIN=1, VTIME=0,意思是阻塞模式下read()至少要等到一个字节才返回。如果你希望有超时返回,可以把VTIME设成比如5,那么一个字节没到也会最多等500毫秒返回。这个组合在实际项目中很常用。
3. 把“开启串口”做成工业级:超时、锁、RS485切换
3.1 阻塞与非阻塞:不同场景下怎么选
open_serial()只是打开串口,真正麻烦的是后面怎么收发数据。阻塞模式最简单,一个线程专门读串口,来了数据就处理,没数据就睡着。但缺点是一旦read()阻塞,很难中途退出。工业程序里我更喜欢非阻塞加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) { ssize_t n = read(fd, buf, sizeof(buf)); if (n > 0) { // 处理收到的数据 } else if (n < 0 && errno != EAGAIN) { // 真正的错误,需要考虑重新开启串口 } } else if (ret == 0) { // 超时,可以定期检查其他状态 }这里有两个容易踩的坑。第一,select()返回可读并不代表能读满你想要的字节数,read()实际上可能只返回一部分数据,所以上层一定要自己处理“粘包、半包”。第二,read()返回-1不一定是错误,如果errno是EAGAIN,表示现在没数据,继续等就行;如果返回EINTR,说明被信号打断了,应该重新继续读,这些都是真正的工业级代码里必须处理的分支。
所以我的封装思路是:open_serial()只负责打开和配置,而数据收发单独写一个serial_read_frame()之类的状态机,按帧长度、帧头帧尾去解析。千万不要把开启和收发混在一个函数里做,不然以后换协议会很痛苦。
3.2 串口被复用:加锁和排他打开
工业上位机最烦的一个问题:串口被别的进程占用了。西门子组态软件、Modbus调试工具、自己的采集程序,如果同时打开同一个COM口,轻则数据串味,重则直接崩溃。
Linux下做排他比较直接,打开设备后可以调一个ioctl:
int yes = 1; ioctl(fd, TIOCEXCL, &yes);这个调用之后,其他进程再打开这个串口会失败。当前进程关闭fd后,排他自动释放。如果你用的内核或者驱动不支持TIOCEXCL,退一步可以用flock(fd, LOCK_EX | LOCK_NB),简单有效,但是注意flock跟open的引用计数有时会让人困惑,不适合所有驱动。
在Windows下也有类似的共享模式问题,C#的SerialPort默认是“独占”的,如果之前没有正确释放,再打开就会报“Access denied”。开发上位机的时候,一定要把SerialPort对象放在using里,确保异常路径也会被释放。这一点和Linux下close(fd)同样重要。
3.3 RS485半双工的方向切换
RS485是工业现场最常见的总线之一,硬件上是半双工,只有两根线,同一时刻只能收或者发。很多USB转RS485模块,方向上电后由芯片自动处理,但一些板卡上会用GPIO手动控制收发器的DE/RE引脚。这个方向切换如果做得不好,最常见的问题就是发完一帧数据后立刻拉低方向引脚,导致最后一个字节还没有完全发送出去就被截断了。
我自用模块里处理RS485方向的标准步骤如下:
open_serial()打开串口后,单独初始化控制GPIO为输出并拉低,默认接收状态;- 发送数据前,把GPIO拉高,进入发送模式;
write(fd, buf, len)写入数据后,一定要调用tcdrain(fd)等待发送缓冲区全部发送完;- 确认清空后再把GPIO拉低,切回接收模式。
代码就是:
rs485_dir_set(1); // 拉高,进入发送模式 write(fd, buf, len); tcdrain(fd); // 等待发送FIFO真正排空 rs485_dir_set(0); // 拉低,恢复接收模式有些硬件方案是直接用RTS信号去控制方向,这种情况下可以配置struct serial_rs485并调用TIOCSRS485。但前提是底层驱动支持,USB转串口很多不支持这个ioctl,不如GPIO手动控制通用。
还有个小细节:半双工切换需要“安静窗口”,发送结束后不要立刻读串口,最好等几个位的时间再切回接收,否则对方回包的前几个字节可能丢失。具体延时可以用一个字节时长估算:如果波特率是9600,一个字节大约1ms,切到接收后延时2~3个字节时间就很稳。
3.4 设备拔插和重新打开
USB转串口在工业现场是非常普遍的外设,但它也带来一个麻烦:设备和串口线热插拔之后,Linux内核会重新枚举USB设备,原来的/dev/ttyUSB0可能变成/dev/ttyUSB1,如果你的程序一直死等旧节点,那必然连不上。
我处理这个问题分两步。
第一步,用udev固定设备名。在/etc/udev/rules.d/写一条规则,根据USB设备的idVendor和idProduct创建一个稳定符号链接,比如/dev/ttyMeter。这样不管你插到哪个USB口,程序都固定打开同一个路径。
第二步,程序里做“串口状态机”。不能在open失败后直接退出,而是定期重试。检测到串口断开(read返回严重错误),立刻关闭旧fd,清理状态,间隔几百毫秒重新open_serial()。工业设备重启也是同理,经常是串口服务器比程序启动慢,所以要支持自动化重连。
重连的时候有一个必须注意的点:不能只重新open(),一定要重新cfsetispeed、cfsetospeed、tcsetattr。因为内核在设备重新枚举后,终端参数可能已经恢复默认值。光把open_serial()跑一遍,也就是为了这个。
3.5 MCU端串口开启:DMA和中断别漏配置
前面主要讲Linux上位机,其实MCU端开启串口的思路完全一样,只是换了一套寄存器。STM32、GD32这类芯片,初始化串口并不是像很多人想的那样“设置波特率就完事”,你需要同时配置:
- GPIO复用和时钟;
- USART外设时钟;
- 中断优先级和NVIC;
- 如果使用DMA,还要配置DMA方向、数据宽度、环形缓冲区。
很多项目跑起来乱码或丢数据,排查半天最后发现MCU端少了GPIO复用配置,或者DMA只开了半天中断导致接收缓冲区溢出。所以在做整机联调时,我通常让两端都先跑最简轮询模式,确认协议通了,再往MCU端加DMA、加环形缓冲。这样如果出问题,排查面会小很多。
4. 常见问题排查实录与调试工具
4.1 排查速查表:打不开、乱码、丢帧、卡死
下面这个表是我这些年整理出来的,按现象分类,遇到问题直接对着查,大部分情况都很管用。
| 现象 | 常见原因 | 排查手段 | 解决办法 |
|---|---|---|---|
| 打开串口返回失败 | 设备节点不存在 | ls /dev/ttyUSB*,拔插线缆 | 重新插拔,确认驱动加载 |
| 打开串口提示权限不够 | 用户不在dialout组 | 执行usermod -aG dialout $USER | 重新登录,或用sudo |
| 打开成功但收不到数据 | 硬件流控被错误开启 | 检查CRTSCTS是否清除 | 按文中代码关闭流控 |
| 打开成功但收不到数据 | 没设置 `CLOCAL | CREAD` | 用stty -F /dev/ttyUSB0 -a查看 |
| 数据乱码 | 波特率不匹配 | 两端确认波特率 | 修改参数重新打开 |
| 数据乱码 | 校验位不正确 | 查看设备手册 | 统一8N1或8E1 |
| 丢字节、丢帧 | 线缆太长或干扰 | 缩短线缆,检查屏蔽 | 降波特率,或换RS485 |
| 写入后程序卡死 | 开了软件流控,收到XOFF | 确认IXON/IXOFF已关闭 | 用原始模式 |
| 程序偶发退出 | read返回EINTR/EAGAIN未处理 | 打印errno | 循环重读 |
| 多个程序抢串口 | 其他进程占用 | 见4.2 | 用排他锁 |
排查的时候有一个顺序很关键:先硬件、再系统、最后才是应用层。很多新手一上来就改代码,结果发现是USB线接触不良或者对方设备波特率不对。我现在的习惯是:先在串口助手里把数据收通,再怀疑自己的open_serial()。
4.2 Windows下查看串口被哪个程序占用
虽然我主力是Linux,但偶尔也要在Windows上用C#或者WinForm调试。Windows下串口被占用的表现很直接:打开COM3的时候直接抛异常,或者提示“端口已被打开”。但具体哪个程序占用,系统不会直接告诉你,需要借助工具。
我常用的有两个:
第一款是Sysinternals的Process Explorer。打开后按快捷键Ctrl+F,弹出搜索框,输入COM3,它会列出所有打开过这个串口句柄的进程。看到结果后,右键进程选择关闭句柄,或者直接在任务管理器里结束进程。
第二款是Device Monitoring Studio,它不仅能看占用,还能监控这个串口上所有数据收发记录,相当于一个更高级的“串口监听器”。在Windows现场排查一些“底层驱动偷偷收发数据”的问题时,这个工具非常管用。
命令行党也有办法,下载Sysinternals的handle64.exe,执行:
handle64.exe -a COM3它会显示进程名和PID。注意Windows下打开失败的错误码如果是5,那多半就是Access Denied,十有八九是端口被占用,要不就是权限不足。
4.3 USB转串口的坑:CH340、CP2102、FT232 怎么选
USB转串口的芯片是串口调试的第一个拦路虎。市面主流有三类:CH340、CP2102、FT232。
CH340最便宜,国产芯片,很多十几块的USB转TTL小板都用它。Win7经常要手动装驱动,Linux内核自带ch341驱动,一般插上就能用。缺点是抗干扰能力相对弱,有些山寨版会虚焊或者上拉电阻缺失。
CP2102是Silicon Labs的产品,稳定性比CH340好一截,驱动也齐全。很多工规USB转串口用这个,性价比不错。
FT232是FTDI的芯片,算是工业现场的老大哥。贵,但驱动、兼容性、抗干扰都是最靠谱的。如果现场设备协议复杂、线缆长、环境电磁干扰大,我现在会直接选择FT232方案,省下来的排查时间远超那点差价。
另外有三个布线上的坑,比芯片选型更容易出事:
第一,必须共地。USB转串口的GND和板卡GND一定要接好,否则信号参考地不一致,接收数据就是乱的。这是“乱码”最常见的物理原因。
第二,电平要匹配。如果USB转串口输出是5V TTL,而你接的是3.3V单片机,就可能会烧IO口。选板卡的时候看清有没有3.3V/5V跳线,或者加电平转换芯片。
第三,线材质量真的影响大。USB线镀铜好一点的,信号就是稳。别在这上面省钱。
4.4 调试工具体验
工具不在多,顺手就行。Linux下我常用picocom或minicom做简单验证,偶尔用cutecom图形界面。但更重要的一招是“自发自收”:
把串口的TX和RX直接短接,然后在调试工具里发一串数据。如果工具能收回来和自己发出去的完全一样,说明从USB转串口到驱动程序、到系统配置,整条链路都是通的。再往下接设备,问题就只可能在设备和接线上了。这个步骤我每次都会做,特别节省时间。
Windows下用SSCOM或XCOM都很方便,支持定时发送、进制显示、保存日志。工业调试的时候,日志保存功能尤其重要:程序跑了一晚上出问题,如果没存日志,第二天只能从头开始查。所以我自己的工具箱里,每个上位机程序都会加上串口原始数据落盘功能。
5. 版本演进:这个“无bug”版本到底改了什么
5.1 早期版本踩过的三个大坑
最早的版本远没有现在这么干净,光是“开启串口”这一个动作,我就在实际项目里填过不少坑。
第一次写串口程序,什么都没关,就配置完波特率直接read()。结果数据明明发过来了,却永远读不到。后来才明白是规范模式的内核按行缓存导致的。那一次让我知道,串口程序必须主动让自己进入原始模式,默认配置是给终端用的,不是给工业数据用的。
第二次是在写一个和仪表通信的程序,运行一段时间后写数据就卡死了。排查了很久,最后发现仪表发的数据里有一个字节恰好是0x13,也就是XOFF字符。因为我没有关闭软件流控,内核收到0x13以后自动“暂停发送”,设备一直等我继续发,而我在等设备回包,两边就这么死等下去。从那以后,我配置串口的第一件事就是清掉IXON/IXOFF。
第三次是串口打开失败时,我只是简单返回了-1,但忘了关闭已经打开的fd或者设备节点正在被别的东西占用。最后程序重试多次,跑到文件描述符耗尽,系统再也打不开任何串口。后来我把所有错误分支都统一成“关fd、返回负数”,再也不留半开的句柄。
还有一次是USB串口拔掉后没有及时关闭fd,重新插上程序还在读写旧节点。内核那边早就对这个fd做了“设备消失”处理,读写会直接出错,但我当时没判断错误,导致程序崩了。这些经验最后都变成了现在代码里的边界条件。
5.2 边界条件压倒功能:我心中的“无bug”定义
总有人看到我的这段open_serial(),觉得代码量挺小,没什么技术含量。但真正的门槛不在“能打开”,而在“打开失败怎么办”“参数写错怎么办”“设备突然拔掉怎么办”“有没有给后续留一个干净的可复现状态”。
我对“自用无bug版本”的理解是:不是我写的代码永远不会出错,而是它出错的时候,会清楚地告诉你哪里错了,并且不会留下一个半打开的fd、不会静默吞掉错误、不会让后面的人完全摸不着头脑。
一点个人心得收尾:每次接一个新的工业设备,我的调试顺序都是固定的——先用串口助手自发自收取一遍,再跑open_serial()打开端口,然后发送一条固定的握手帧。如果握手都通过,我才会去看应用层协议。这个习惯帮我少走了很多弯路。串口底层脏活累活一大堆,但只要把开启这步做扎实,整个项目后面都会轻松很多。