☰
C语言实现RS-232串口与TCP双向透传转发
2026/10/29 15:07:10 网站建设 项目流程

简介:面向嵌入式、工业控制与网络编程学习者的一套C语言小体积示例,聚焦串口与网口之间的双向数据转发。它把串口通信、Socket网络通信、select多路复用、多线程并发及异常处理串成可运行的工程骨架,适合需要理解上下位机通信、远程监控数据链路或做课程设计参考的读者。压缩包共5个文件,约4KB,包含2个C源文件、1个头文件、1个Makefile和1个说明文件:C文件承载转发主逻辑与测试代码,头文件集中声明接口,Makefile负责编译组织,说明文件补充使用背景。已有1709人学习。读者可从中梳理“串口监听线程—共享队列—网口监听线程”的转发结构,参考select同时监控串口与套接字、termios配置、TCP/UDP收发及线程同步的写法,并借助测试代码与构建脚本快速复现实验,理解协议转换和故障处理的基本思路。

1. 一条 RS-232 连着老设备,后台只认 TCP

产线上那台设备只带 RS-232 口,后台系统却只肯按 TCP 收数据。改固件不现实,加一台网关要排队等预算,最省事的办法是在中间放一个「串口和网口数据相互转发」的小程序:COM 口收到的字节原样丢进 socket,网络回来的报文原样写回串口,两端都不知道中间隔了一层。这份资源做的正是这件事,关键词 com/ip/c 说得很直白——COM 口到 IP 网络的双向搬运,C 实现,不依赖第三方库,编译出来一个可执行文件丢到工控机上就能跑。

它适合三类人:要给老设备做联网改造的现场工程师、需要在自己程序里嵌一段串口转发逻辑的开发者,以及想找一个能真正跑通的 select 多路复用范例来改的学习者。代码量不大,但 termios 原始模式、TCP 服务端与客户端两种角色、断线重连、写阻塞这几件最容易翻车的事都做全了,拿它当骨架比自己从零起一个空文件靠谱。

2. 架构与选型:两条数据通路,一个 select 循环

先把定位说清楚:这是一个字节级透传程序,不是协议网关。它不认识 Modbus,也不解析帧头帧尾,串口收到什么就往网络送什么,网络回来什么就往串口写什么。这个定位决定了后面所有设计——没有状态机、没有协议表、没有会话管理,核心只有“读一端、写另一端”这一个动作重复两次。

整个程序运行起来只持有三个文件描述符:

描述符来源角色关键调用
serial_fd/dev/ttyUSB0、/dev/ttyS0 等串口设备,永远存在open、tcsetattr、read、write
listen_fd命令行给的 IP:PORT仅 server 模式存在,负责接客socket、bind、listen、accept
client_fdaccept 或 connect 得到唯一的活动连接,-1 表示没连上read、write、close

数据通路只有两条,方向相反:

  • 上行:serial_fd 可读 → read 到缓冲区 → 写入 client_fd;
  • 下行:client_fd 可读 → read 到缓冲区 → 写入 serial_fd。

两条通路共用同一个缓冲区大小、同一套错误处理逻辑,这也是为什么下面主循环里那两段 if 长得几乎一样。理解了这个结构,后面看代码就不会迷路。

2.1 串口侧:非 raw 模式是绝大多数“数据不对”的根源

很多人第一次写串口程序,open之后直接read,发现要么读不到数据,要么收到一串多出来的\r,要么数据里偶尔少几个字节。原因几乎都在 termios 的默认配置上:串口默认是规范模式(canonical),内核会帮你做行缓冲、回显、\n到\r\n的转换,甚至把0x11、0x13、0x03当成流控和中断信号吃掉。对透传程序来说,这些“贴心服务”全是要命的噪音。

所以串口初始化的第一原则是:能关的全关,只保留收和发。

#include <termios.h> #include <fcntl.h> #include <unistd.h> #include <errno.h> /* 把串口切成 raw 模式:不做行编辑、不回显、不做字符转换 */ static int serial_setup(int fd, int baud) { struct termios tio; if (tcgetattr(fd, &tio) != 0) { /* 先取当前配置,别在空白结构体上改 */ return -1; } cfmakeraw(&tio); /* 一行顶十行:清掉 ICANON/ECHO/ICRNL/ONLCR 等 */ tio.c_cflag |= (CLOCAL | CREAD); /* 忽略调制解调器控制线,允许接收 */ tio.c_cflag &= ~CRTSCTS; /* 关硬件流控,除非对端明确要求 */ tio.c_cflag &= ~CSTOPB; /* 1 位停止位 */ tio.c_cflag &= ~PARENB; /* 无校验位 */ tio.c_cflag &= ~CSIZE; tio.c_cflag |= CS8; /* 8 位数据位 */ speed_t sp; switch (baud) { /* 波特率必须用 Bxxx 常量,传数字没用 */ case 9600: sp = B9600; break; case 19200: sp = B19200; break; case 38400: sp = B38400; break; case 57600: sp = B57600; break; case 115200: sp = B115200; break; default: sp = B9600; break; } cfsetispeed(&tio, sp); cfsetospeed(&tio, sp); tio.c_cc[VMIN] = 1; /* 至少有 1 字节可读才返回 */ tio.c_cc[VTIME] = 0; /* 不做内核层超时,节奏交给 select 控制 */ return tcsetattr(fd, TCSANOW, &tio); /* TCSANOW:立即生效,不等输出排空 */ }

cfmakeraw其实已经顺带把VMIN设成 1、VTIME设成 0 了,后面那两行属于“写清楚给人看”,不算多余——二次开发的人接手时不用去翻手册确认默认值。真正需要按现场改的是三处:波特率(对不上就是满屏乱码)、校验位(老设备用 7E1 的不少,这时得开PARENB、清CS8设CS7)、硬件流控(CRTSCTS关掉之后如果对端在用 RTS/CTS,会出现发得出去收不回来的怪现象)。

打开串口的姿势也讲究:

/* O_NOCTTY:不要把串口变成控制终端,否则 Ctrl-C 会打到设备上 O_NONBLOCK:配合 select 用,读不到立刻返回 EAGAIN,不阻塞主循环 */ int fd = open("/dev/ttyUSB0", O_RDWR | O_NOCTTY | O_NONBLOCK); if (fd < 0) { fprintf(stderr, "open serial failed: %s\n", strerror(errno)); return -1; } if (serial_setup(fd, 115200) != 0) { fprintf(stderr, "setup serial failed: %s\n", strerror(errno)); close(fd); return -1; }

缺了O_NOCTTY的后果比较隐蔽:程序在前台跑没事,一放进后台服务就收不到信号,kill都费劲。缺了O_NONBLOCK则更直接——read卡在那里,网络那头断了你也感知不到。

2.2 网络侧:server 和 client 是两套代码,不要试图揉成一个

现场部署有两种典型形态:一种是把转发的机器放在设备侧,后台主动来连(server 模式);另一种是设备侧主动连后台的采集服务(client 模式,能穿 NAT 和内网)。两种模式在网络侧的初始化完全不同,硬塞进一个函数只会写出满是 if 的面条代码,分开写反而清楚。

server 模式的监听端:

static int tcp_listen(const char *ip, int port) { int lfd = socket(AF_INET, SOCK_STREAM, 0); if (lfd < 0) return -1; int on = 1; /* 关键:重启程序时端口还处于 TIME_WAIT,没有这一行会 bind 失败 */ setsockopt(lfd, SOL_SOCKET, SO_REUSEADDR, &on, sizeof(on)); struct sockaddr_in addr; memset(&addr, 0, sizeof(addr)); addr.sin_family = AF_INET; addr.sin_port = htons(port); addr.sin_addr.s_addr = inet_addr(ip); /* "0.0.0.0" 表示所有网卡都监听 */ if (bind(lfd, (struct sockaddr *)&addr, sizeof(addr)) < 0) { close(lfd); return -1; } if (listen(lfd, 4) < 0) { close(lfd); return -1; } /* backlog 给 4 够了,单客户端模型 */ return lfd; }

client 模式的主动连接,注意要配合非阻塞 socket 和select判断连接结果,否则connect会在网络不通时把主线程挂住几十秒:

static int tcp_connect_nonblock(const char *ip, int port) { int fd = socket(AF_INET, SOCK_STREAM, 0); if (fd < 0) return -1; fcntl(fd, F_SETFL, fcntl(fd, F_GETFL, 0) | O_NONBLOCK); struct sockaddr_in addr; memset(&addr, 0, sizeof(addr)); addr.sin_family = AF_INET; addr.sin_port = htons(port); addr.sin_addr.s_addr = inet_addr(ip); if (connect(fd, (struct sockaddr *)&addr, sizeof(addr)) == 0) return fd; /* 秒连上 */ if (errno != EINPROGRESS) { close(fd); return -1; } /* 真错了 */ /* 连接进行中:交给 select 等可写,再 getsockopt 确认结果 */ fd_set wfds; FD_ZERO(&wfds); FD_SET(fd, &wfds); struct timeval tv = { .tv_sec = 3, .tv_usec = 0 }; if (select(fd + 1, NULL, &wfds, NULL, &tv) <= 0) { close(fd); return -1; } int err = 0; socklen_t len = sizeof(err); getsockopt(fd, SOL_SOCKET, SO_ERROR, &err, &len); if (err != 0) { close(fd); return -1; } return fd; }

SO_REUSEADDR这个参数看起来不起眼,但它是现场重启时最常见的“端口被占用”报错的解药。至于SO_KEEPALIVE,TCP 默认两小时才探一次,对现场那种“网线松了但没人发现”的情况基本没用,真要靠它保活得自己在应用层发心跳,这部分留到第 6 章再说。

2.3 并发模型:两个 fd 用不着 epoll,单线程 select 最省心

选并发模型时我一般先数 fd 的个数。这个程序常态就是 2~3 个活跃描述符,select完全够用,而且写出来的代码是单线程的——没有锁、没有线程退出同步、没有“串口线程读到的数据怎么交给网络线程”的队列问题。用双线程加阻塞 read 也能实现,看着更直观,但退出时你得想办法把卡在read里的线程叫醒,否则kill之后进程要等上几秒才走,放进 systemd 里就是告警。

select在这里还有个附带好处:它的超时参数天然是个定时器。把超时设成 200 毫秒,主循环每 200 毫秒必然醒一次,正好拿来做事:client 模式下检查要不要重连、统计一段时间内的字节数、判断串口有没有掉线。这些逻辑塞进if (n == 0) continue;那条分支里就行,不用额外起定时器线程。

有两点要说清楚,免得被当成万能药:一是select的 fd 上限是FD_SETSIZE(通常 1024),如果后面要扩成“一台机器挂 8 个串口、接 32 路连接”,就该换成poll或epoll;二是每次循环都要重填fd_set,这是个容易被忘的坑,忘记在循环体内FD_ZERO会导致某些 fd 永远被监视、某些永远不被监视,表现出来就是“连着连着就不响应了”。

3. 目录结构与跑通路径

拿到一份 C 源码,第一件事不是看main有多长,而是看文件怎么切、Makefile 怎么写、启动参数长什么样。这三件事清楚了,改起来才有抓手。

3.1 源码分层:main 只做装配,其余按“端”切

这个项目的目录是一个很典型的四层切法,main.c只负责解析参数和把对象拼起来,具体活儿都在各自文件里:

comip/ ├── src/ │ ├── main.c 程序入口:解析命令行、装配串口端与网络端、装信号处理 │ ├── serial.c 串口打开、termios 配置、波特率映射、掉线检测 │ ├── net.c TCP 监听 / 非阻塞连接 / accept / 重连退避 │ ├── forward.c select 主循环、双向搬运、缓冲调度 │ ├── buf.c 环形缓冲:处理写阻塞和半包 │ └── log.c 带时间戳的日志、字节计数、速率统计 ├── include/ │ ├── serial.h │ ├── net.h │ └── forward.h ├── conf/ │ └── comip.conf 设备路径、波特率、监听地址、模式,一行一个 key=value ├── Makefile └── README.md

这个切法的好处在“改一半不影响另一半”。比如现场串口是 7 位数据位加偶校验,你只动serial.c里serial_setup那几行;比如要换成 UDP 上报,只动net.c,forward.c里的搬运逻辑原样不动。反过来,如果所有东西都堆在main.c里,改一行波特率都得重新读三百行。

forward.c是理解全局的入口,建议先看它,再看它调用的serial_open和net_connect。

3.2 编译:一个二十行的 Makefile 就够,注意 tab

项目没有依赖任何第三方库,单纯用 gcc 加系统头文件就能编出来。Makefile 短到可以贴在墙上:

CC ?= gcc CFLAGS := -O2 -g -Wall -Wextra -Iinclude TARGET := comip SRCS := $(wildcard src/*.c) OBJS := $(SRCS:.c=.o) $(TARGET): $(OBJS) $(CC) $(OBJS) -o $@ $(LDFLAGS) %.o: %.c $(CC) $(CFLAGS) -c $< -o $@ clean: rm -f $(OBJS) $(TARGET)

几点值得说:-Wall -Wextra一定要开着,串口代码里ssize_t和size_t混用、write返回值不检查这类问题,编译器基本都能提示出来;-g留着方便现场 gdb 或者至少addr2line定位崩溃位置;命令前面那个缩进必须是Tab,用空格会得到missing separator这种让人一头雾水的报错,我第一次接触 Makefile 就在这上面卡了半小时。

交叉编译到工控机(ARM)时,只需要换编译器前缀:

make CC=arm-linux-gnueabihf-gcc

程序只用 POSIX 接口,交叉编译一般不缺东西。真要报错,多半是缺termios.h所在的 libc 开发包。

3.3 启动参数:两种模式,几个必填项

命令行参数不多,用getopt_long解析,够用且不引入配置库:

参数说明示例
--serial串口设备路径,必填/dev/ttyUSB0
--baud波特率,默认 9600115200
--modeserver或clientserver
--listenserver 模式的监听地址0.0.0.0:9000
--peerclient 模式的对端地址192.168.1.50:9000
--buf单次读写缓冲大小,默认 15004096
--log日志级别:error、info、debugdebug

两种模式的实际启动命令:

# 机房里那台机器做服务端,等后台来连 sudo ./comip --serial /dev/ttyUSB0 --baud 115200 --mode server --listen 0.0.0.0:9000 # 现场设备侧主动连后台采集服务 ./comip --serial /dev/ttyS1 --baud 9600 --mode client --peer 192.168.1.50:9000 --log info

sudo不是必需的,取决于设备节点的权限。长期跑的话我一般加个 udev 规则把串口固定成dialout组可读写,比每次sudo干净;或者在 systemd 单元里写SupplementaryGroups=dialout。

3.4 没有硬件也能验证:socat 造一对虚拟串口

手边没设备、也没法连现场后台的时候,仍然可以把整条链路跑通。用 socat 造一对互相连通的伪终端,一端给程序当串口,另一端给你敲字:

# 终端 1:造一对 pty,输出里会打印两个 /dev/pts/N socat -d -d pty,raw,echo=0 pty,raw,echo=0 # 假设打印出 /dev/pts/3 和 /dev/pts/4 # 终端 2:程序把 /dev/pts/3 当成串口,起服务端 ./comip --serial /dev/pts/3 --baud 115200 --mode server --listen 127.0.0.1:9000 --log debug # 终端 3:模拟网络侧客户端 nc 127.0.0.1 9000 # 终端 4:往“串口”里写字,终端 3 应该立刻看到 printf 'hello-from-serial\n' > /dev/pts/4

反过来验证下行:在终端 3 里敲几个字符回车,然后cat /dev/pts/4应该能把它们读出来。第一次跑通这条链路的时候,建议把--log debug打开,看看日志里“serial→net 12 bytes”“net→serial 3 bytes”这种计数是不是和预期对得上——这比事后到现场抓瞎强得多。

提示:socat 造出来的 pty 默认可能带回显,如果发现字符被重复打印,先执行stty -F /dev/pts/3 raw -echo再启动程序。

4. 核心代码:转发主循环、缓冲与重连

前面把两端的初始化铺垫完了,这一章看真正干活的部分。转发程序的绝大部分 bug 都出在这三处:一是主循环里read的返回值没判全,二是写不进去的时候死等,三是连接断了不重连。

4.1 主循环骨架:等事件,然后搬

select版本的骨架非常短,短到第一次看会怀疑它是不是漏了什么:

static volatile sig_atomic_t g_running = 1; void forward_loop(ctx_t *ctx) { uint8_t buf[4096]; while (g_running) { fd_set rfds; FD_ZERO(&rfds); /* 每轮必须重置,这是最容易漏的一行 */ int maxfd = -1; FD_SET(ctx->serial_fd, &rfds); maxfd = ctx->serial_fd; if (ctx->client_fd >= 0) { /* 已有连接:等它的数据 */ FD_SET(ctx->client_fd, &rfds); if (ctx->client_fd > maxfd) maxfd = ctx->client_fd; } else if (ctx->listen_fd >= 0) { /* 没连接:等新连接进来 */ FD_SET(ctx->listen_fd, &rfds); if (ctx->listen_fd > maxfd) maxfd = ctx->listen_fd; } /* client 模式下 client_fd 可能是 -1,此时靠超时分支去重连 */ if (ctx->client_fd < 0 && ctx->listen_fd < 0) { maxfd = ctx->serial_fd; } struct timeval tv = { .tv_sec = 0, .tv_usec = 200000 }; /* 200ms 一拍 */ int n = select(maxfd + 1, &rfds, NULL, NULL, &tv); if (n < 0) { if (errno == EINTR) continue; /* 被信号打断,正常,继续 */ log_error("select: %s", strerror(errno)); break; } if (n == 0) { /* 超时分支:干杂活的地方 */ net_tick(ctx); /* client 模式下尝试重连 */ stats_tick(ctx); /* 每秒打一行速率日志 */ continue; } /* 上行:串口 -> 网络 */ if (FD_ISSET(ctx->serial_fd, &rfds)) { ssize_t r = read(ctx->serial_fd, buf, sizeof(buf)); if (r > 0) { if (ctx->client_fd >= 0) { if (write_all(ctx->client_fd, buf, (size_t)r) < 0) { log_warn("net write failed, drop connection"); conn_close(ctx); /* 写不出去就当连接已死 */ } } /* 没有客户端时这里可以选择丢弃,也可以缓存等重连,见 4.2 */ } else if (r < 0 && errno != EAGAIN && errno != EINTR) { log_error("serial read: %s", strerror(errno)); serial_reopen(ctx); /* USB 转串口被拔了会走到这 */ } } /* 下行:网络 -> 串口 */ if (ctx->client_fd >= 0 && FD_ISSET(ctx->client_fd, &rfds)) { ssize_t r = read(ctx->client_fd, buf, sizeof(buf)); if (r > 0) { if (write_all(ctx->serial_fd, buf, (size_t)r) < 0) log_warn("serial write failed: %s", strerror(errno)); } else { /* r == 0 是对端正常关闭,r < 0 是出错,两种都关掉连接 */ log_info("peer closed"); conn_close(ctx); } } /* 有监听端且没有活动连接时,接受新连接 */ if (ctx->listen_fd >= 0 && ctx->client_fd < 0 && FD_ISSET(ctx->listen_fd, &rfds)) { int c = accept(ctx->listen_fd, NULL, NULL); if (c >= 0) { fcntl(c, F_SETFL, fcntl(c, F_GETFL, 0) | O_NONBLOCK); ctx->client_fd = c; log_info("peer accepted, fd=%d", c); } } } }

几处细节值得单独拎出来:

select的第一个参数是“最大 fd 加一”,传少了会导致某些 fd 被静默忽略,表现出来就是“有时候能收有时候不能收”,这种玄学问题的根子往往就在这。缓冲区大小 4096 是拍的,串口单次中断能送来的通常远小于这个数,取 1500(一个以太网 MTU)也完全够,关键在于它要和后面write_all的循环配合,不能假设一次write就能写完。

read返回 0 和返回负数是两回事:串口返回 0 一般不发生(除非设备消失),网络返回 0 是对端close,必须主动关掉本地 fd,否则下一次select会一直报可读,CPU 直接跑满——这是新手最常踩的一个坑,后面避坑章节还会再提。

信号处理要有,不然Ctrl-C之后串口和端口都不释放:

static void on_signal(int sig) { (void)sig; g_running = 0; } /* main 里注册 */ struct sigaction sa = { .sa_handler = on_signal }; sigaction(SIGINT, &sa, NULL); sigaction(SIGTERM, &sa, NULL);

注意别在信号处理函数里做close或者printf,那不是异步信号安全的。把标志位置 0,让主循环自己退出,是唯一稳妥的写法。

4.2 写阻塞与环形缓冲:为什么不能直接 write 了事

write返回成功不代表全部写完,返回小于请求长度也不代表出错,它只是告诉你“内核缓冲区暂时只塞得下这么多”。串口尤其明显:波特率 9600 时每秒只能走 960 字节,网络那头一口气推过来 8KB,write第一次可能只吃下几百字节,剩下的一直重试就会把主循环卡住,卡住期间串口的数据没人读,UART 的硬件 FIFO 一满就直接丢字节。

所以需要一个write_all,把“写不完”变成“等到能写再写”:

/* 返回 0 表示 len 字节全部写出去,-1 表示放弃 */ static int write_all(int fd, const uint8_t *buf, size_t len) { size_t off = 0; while (off < len) { ssize_t w = write(fd, buf + off, len - off); if (w > 0) { off += (size_t)w; continue; } if (w < 0 && errno == EINTR) continue; /* 被信号打断,重试 */ if (w < 0 && (errno == EAGAIN || errno == EWOULDBLOCK)) { /* 非阻塞缓冲满:等可写事件,最多等 500ms,超时就认输 */ struct pollfd p = { .fd = fd, .events = POLLOUT }; if (poll(&p, 1, 500) <= 0) return -1; continue; } return -1; /* 真错误:EPIPE、EIO 等 */ } return 0; }

这段代码解决的是“写不进去时别忙等”。但它还有个副作用:poll等待的那 500 毫秒里,主循环停住了,串口侧可能又积压了数据。数据量不大无所谓,一旦现场有持续的突发流量,就得再往前走一步——上环形缓冲。

#define RB_SIZE 16384 typedef struct { uint8_t data[RB_SIZE]; size_t head; /* 写指针 */ size_t tail; /* 读指针 */ size_t len; /* 当前有效字节数 */ } ring_t; /* 返回实际写入的字节数,满了两者取小 */ static size_t rb_write(ring_t *rb, const uint8_t *src, size_t n) { size_t space = RB_SIZE - rb->len; if (n > space) { n = space; /* 满了就截断,宁可丢也不要环形覆盖 */ log_warn("ring full, drop %zu bytes", space); } for (size_t i = 0; i < n; i++) { rb->data[rb->head] = src[i]; rb->head = (rb->head + 1) % RB_SIZE; } rb->len += n; return n; } /* 从环里取最多 n 字节,返回实际取出量 */ static size_t rb_read(ring_t *rb, uint8_t *dst, size_t n) { if (n > rb->len) n = rb->len; for (size_t i = 0; i < n; i++) { dst[i] = rb->data[rb->tail]; rb->tail = (rb->tail + 1) % RB_SIZE; } rb->len -= n; return n; }

上了环之后,主循环的策略变成:先从串口读到环里,再把环里的内容往网络推;网络写不进去就把 fd 挂到select的写集合上,等可写事件再推。这样串口永远不会因为网络慢而停止读取。

代价是什么?一旦环满了,就必须丢数据。透传场景里这是个业务决策,不是技术决策:有些协议丢了几个字节整帧就废,这时候丢还不如断连接让对端重连重发;有些是周期性的传感器读数,丢几帧无所谓。我一般默认“环满就断连接”,把这行 warning 打出来,让运维看得见。

4.3 客户端模式的断线重连:退避比次数重要

server 模式不用操心重连,客户端断了自然会有新的连接进来。client 模式不行,网络抖一下就得自己爬回去。重连逻辑写在超时分支里调用的net_tick:

static void net_tick(ctx_t *ctx) { if (ctx->client_fd >= 0) return; /* 有连接,什么都不做 */ time_t now = time(NULL); if (now - ctx->last_try < ctx->backoff) return; /* 退避期内不重试 */ ctx->last_try = now; int fd = tcp_connect_nonblock(ctx->peer_ip, ctx->peer_port); if (fd >= 0) { ctx->client_fd = fd; ctx->backoff = 1; /* 连上了就把退避重置回 1 秒 */ log_info("reconnect ok"); } else { /* 指数退避,封顶 30 秒,避免对端宕机时把自己变成压测工具 */ ctx->backoff = ctx->backoff * 2 > 30 ? 30 : ctx->backoff * 2; log_warn("reconnect failed, next try in %ds", ctx->backoff); } }

退避这个设计经常被嫌麻烦跳过去,直接写死每 3 秒重连一次。单机跑没问题,一旦现场有几十台设备、后台服务在升级重启,这几十个客户端会以整齐的节奏同时打过来,后台刚启动就被打满——这类“雪崩式重连”在现场很难查。给每个进程的初始退避加个随机抖动(rand() % 3秒)就更保险了。

4.4 埋点:日志和计数不加,现场就是黑匣子

透传程序出问题时,现象永远是“数据没上来”,你不在现场,也没法装抓包工具。这时候唯一能依靠的就是日志。项目里的log.c只做三件事:带毫秒时间戳、分级、按秒统计流量。

static unsigned long long g_rx_serial, g_tx_net, g_rx_net, g_tx_serial; void stats_add(int dir, size_t n) { switch (dir) { case DIR_SERIAL_TO_NET: g_rx_serial += n; g_tx_net += n; break; case DIR_NET_TO_SERIAL: g_rx_net += n; g_tx_serial += n; break; } } /* 每秒钟由主循环超时分支调一次 */ void stats_tick(ctx_t *ctx) { static time_t last = 0; time_t now = time(NULL); if (now == last) return; last = now; log_info("s2n=%llu n2s=%llu conn=%d", g_tx_net, g_tx_serial, ctx->client_fd >= 0); }

这四个计数的价值在于对比:s2n一直在涨但后台说没收到,问题在网络上;s2n是 0 但串口灯在闪,问题在串口配置或者接线上;n2s一直涨而设备没反应,多半是波特率或数据位不对,设备收到的是乱码。我见过最省事的一次排查,就是靠这行每秒日志发现“上行一直在涨、下行永远是 0”,最后查到是设备只发不收,根本不用改代码。

日志本身也要防撑爆磁盘。长期跑的程序记得加logrotate或者自己按大小切,别让一个/var/log/comip.log涨到几十 G 把工控机的根分区写满——这个坑一旦踩上,整台机器的服务都会跟着挂。

5. 避坑与排查:现场最容易翻车的五个点

以下都是跑这类转发程序时反复出现的故障,按“现象 → 原因 → 解决”整理,遇到时可以直接对着查。

一、串口有数据但内容是乱码,或者偶尔少几个字节

现象:cat设备节点能看出有字节流,但解析出来全是乱码,或者一帧里固定少几个位置。

原因:八成为波特率或帧格式不匹配,包括波特率、数据位、校验位、停止位四项中的任意一项;剩下两成是串口没进 raw 模式,内核把0x0D转成了0x0A,或者把0x11、0x13当流控吞掉了。

解决:先用stty -F /dev/ttyUSB0 -a看当前参数,和设备的说明书对齐;确认代码里调用了cfmakeraw(或手动清掉ICANON、ECHO、ICRNL、ONLCR、IXON、IXOFF);老设备用 7 位偶校验时别忘了CS7 | PARENB。

二、网络连上几秒就断,或者第二个客户端连不进来

现象:后台连上后能通一会儿,然后连接莫名断开;或者第一个客户端还在线时,第二个连上来就把第一个顶掉。

原因:这是单客户端模型的固有行为——client_fd只有一个变量,accept到新连接直接覆盖旧值,旧 fd 既没关也没人读。另外如果是 client 模式,对端服务重启后不重连,看起来也像“连上就断”。

解决:明确业务到底需不需要多客户端。不需要就在accept时先close旧连接再接管,并打日志说明;需要就把client_fd改成数组或链表,每条连接配一个环形缓冲,同时select的fd_set要按最大 fd 重算。client 模式必须把net_tick的重连逻辑接上,别指望 TCP 自己恢复。

三、程序跑一会儿 CPU 占用冲到 100%

现象:top里这个进程单核跑满,日志刷得飞快,但业务数据其实没多少。

原因:典型是对端关闭后没有关本地 fd。read返回 0 之后如果继续留在select集合里,内核会一直报告“可读”,主循环空转;另一种是write返回EAGAIN之后用while忙等重试,没有让出 CPU。

解决:read返回小于等于 0 时一律关连接、把 fd 置 -1、从fd_set里摘掉;写操作用poll(POLLOUT)等待而不是自旋。加个usleep(1000)只能把问题从“跑满”变成“跑一半”,治标不治本。

四、大数据量时丢字节,尤其是下行方向

现象:小报文一切正常,一旦后台连续下发几 KB 的配置块,设备收到的数据就缺一截。

原因:串口的物理速率是硬上限,115200 波特率大约每秒 11.5KB,网络侧瞬间推过来 8KB 之后又立刻来 8KB,中间没有流控,串口写缓冲区一满,write只写进去一部分,剩下的被代码丢掉了,或者进程直接卡在write上导致上行停摆。

解决:走 4.2 的write_all加环形缓冲;如果设备支持硬件流控(RTS/CTS),把CRTSCTS打开是最省事的办法;再不行就在协议层加应答,让发送方等确认再发下一块。

五、USB 转串口拔插之后程序僵死

现象:现场有人碰掉了 USB 转接头,插回去之后程序还在,日志也不再输出,重启程序才恢复。

原因:设备节点消失后,原来的fd变成无效句柄,read返回EIO或ENODEV,很多实现只判了EAGAIN,其他错误被当成“暂时没数据”忽略掉,于是永远读不到东西。另外热插拔后设备名可能从ttyUSB0变成ttyUSB1。

解决:read返回的错误只要不是EAGAIN或EINTR,就关掉 fd 走重开流程,重开时按 1 秒间隔重试直到成功;设备名不要写死路径,用/dev/serial/by-id/下的固定链接,或者在 udev 里给它绑一个别名。

6. 二次开发:从“字节搬运工”改成能按帧处理的数据管道

原版程序只做透传,这在很多场景下已经够用,但真要接到业务里,八成会遇到两个新需求:一是参数不能写死在命令行里,得能读配置文件;二是不能光搬字节,得按帧处理、做校验、加时间戳。这两件事都可以在不破坏原结构的前提下加上去。

先说配置化。命令行的getopt_long保留,配置文件作为补充,优先级是“命令行 > 配置文件 > 默认值”。解析一个只有 key=value 的小文件,二十行足够,不需要引入任何库:

/* 逐行读 conf/comip.conf,形如 serial=/dev/ttyUSB0、baud=115200 */ static void load_conf(const char *path, ctx_t *ctx) { FILE *fp = fopen(path, "r"); if (!fp) return; /* 没有配置文件不算错,用默认值 */ char line[256]; while (fgets(line, sizeof(line), fp)) { char *p = line; while (*p == ' ' || *p == '\t') p++; /* 跳过行首空白 */ if (*p == '#' || *p == '\n' || *p == '\0') continue; /* 注释和空行 */ char *eq = strchr(p, '='); if (!eq) continue; *eq = '\0'; char *key = p, *val = eq + 1; val[strcspn(val, "\r\n")] = '\0'; /* 去掉行尾换行,Windows 编辑过会有 \r */ if (strcmp(key, "serial") == 0) snprintf(ctx->serial_dev, sizeof(ctx->serial_dev), "%s", val); else if (strcmp(key, "baud") == 0) ctx->baud = atoi(val); else if (strcmp(key, "mode") == 0) ctx->is_server = (strcmp(val, "server") == 0); else if (strcmp(key, "listen") == 0) parse_addr(val, ctx->listen_ip, &ctx->listen_port); else if (strcmp(key, "peer") == 0) parse_addr(val, ctx->peer_ip, &ctx->peer_port); } fclose(fp); }

再往上走一步是“按帧转发”。原版是按read到多少就转发多少,这对纯透传没问题,但如果要在中间做校验、加时间戳、或者把数据同时写进数据库,就需要先攒出一个完整帧。做法是在forward_loop的串口分支里不直接write_all到网络,而是先喂给一个分帧器:

/* 常见帧格式:AA 55 | LEN(2字节小端) | PAYLOAD | CRC16(2字节) */ typedef int (*frame_cb)(const uint8_t *frame, size_t len, void *user); /* 从环里尽量多地切出完整帧,每切出一个就回调一次 */ static void frame_scan(ring_t *rb, frame_cb cb, void *user) { for (;;) { if (rb->len < 4) return; /* 头都不够,等 */ uint8_t hdr[4]; rb_peek(rb, hdr, 4); if (hdr[0] != 0xAA || hdr[1] != 0x55) { /* 失步:丢一个字节重新找头 */ rb_drop(rb, 1); continue; } size_t plen = (size_t)hdr[2] | ((size_t)hdr[3] << 8); size_t total = 4 + plen + 2; if (rb->len < total) return; /* 帧还没收全,等下一次 read */ if (plen > 1024) { rb_drop(rb, 1); continue; } /* 长度离谱,当噪声处理 */ uint8_t frame[1100]; rb_peek(rb, frame, total); if (crc16(frame, total - 2) != ((uint16_t)frame[total - 2] | ((uint16_t)frame[total - 1] << 8))) { rb_drop(rb, 1); /* 校验不过,丢掉重找 */ continue; } cb(frame, total, user); /* 交给业务:转发、入库、打时间戳都行 */ rb_drop(rb, total); } }

rb_peek的关键是“看一眼不消费”,只有校验通过才rb_drop掉整帧,校验失败只丢一个字节继续找帧头——这是处理串口噪声的标准姿势,比一失步就清空整个缓冲区温和得多,不会因为一个毛刺把后面几帧好数据一起扔掉。

改完之后必须验证,我的做法是用一个 Python 脚本造假流量,把整条链路压一遍:

import socket, time, struct # 造 1000 帧,每帧 64 字节负载,走网络下发到串口侧 s = socket.create_connection(("127.0.0.1", 9000), timeout=3) for i in range(1000): payload = bytes([i & 0xFF]) * 64 frame = b"\xAA\x55" + struct.pack("<H", len(payload)) + payload frame += struct.pack("<H", sum(frame) & 0xFFFF) # 简化版校验,和固件保持一致即可 s.sendall(frame) time.sleep(0.01) s.close()

对端用socat读/dev/pts/4落盘,再跑一遍脚本统计帧数、字节数、校验通过率。三个数字都对得上,这次改造才算完;只要有一个对不上,就回到frame_scan里加日志,看看是失步重找太频繁还是长度字段解析错了。

顺带提一句扩展方向:多串口场景建议每个串口一个ctx_t,select的fd_set里把所有串口的 fd 都塞进去,网络侧改成连接表;心跳保活放在stats_tick那个每秒分支里发一条自定义控制帧,比依赖 TCP 自带的保活机制靠谱得多。这些改动都在同一套骨架上,不会牵一发动全身。

吃过一次亏之后,我每次加新功能都会先把原始透传模式跑一遍回归——socat 加 nc,两分钟的事。因为很多时候“改了之后不好使”不是新代码写错了,而是顺手把cfmakeraw或者SO_REUSEADDR这类看起来无关的几行删掉了。希望这份拆解能帮你少走一遍这条路。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询