资讯详情 纯C实现串口与TCP双向透传:单线程select与termios实战
📅 2026/10/29 5:35:22
简介这份代码包围绕串口与网口之间的双向数据转发面向具备C语言基础、从事工业控制、远程监控或嵌入式上下位机通信的开发者用于解决串口设备与TCP/IP网络之间数据互通的问题。压缩包仅4KB共5个文件以2个C源文件和1个头文件为核心配合Makefile完成编译组织并附带说明文档便于快速理解程序结构与构建方式。目前已有1709人学习下载。内容涉及串口termios配置、Socket收发、select多路I/O监听、多线程并发处理及线程同步同时覆盖协议格式转换、异常处理与测试思路通过共享队列衔接两个方向的数据流读者可据此梳理从现场设备到上位机的数据链路掌握双端转发程序的关键实现方法与排错要点。1. 现场那根串口线不够长了产线上有台老仪表只有一路 RS-485 口轮询协议跑得好好的可采集程序在八十米外的机房服务器上。拉长线不现实中间加中继又是一笔开销最省事的办法是放一台小盒子在仪表边上串口进去、网口出来把这套「串口和网口数据相互转发」的活干稳。这份源码就是这件事的最小实现纯 C串口读到的字节原样塞进 TCPTCP 收到的字节原样写回串口不做协议解析不做缓存重排跑在任意 Linux 上。适合两类人手里有老设备要联网、又不想上重型组态网关的现场工程师以及想搞明白 termios 和 socket 两套 API 怎么在同一个 select 循环里和平共处的 C 开发者。2. 架构选型一个 select 循环管住串口和 socket 两条腿2.1 线程模型为什么不用「一个线程读串口、一个线程写网络」刚接触这类透传需求的人第一反应几乎都是开两个线程串口线程 read 完丢进队列网络线程从队列取出来 send。写起来直观但实际维护起来是一堆麻烦。队列要加锁收尾要处理线程退出串口拔插时读线程卡在 read 上主线程还得想办法把它捅醒。更麻烦的是调试——出了丢包你得同时怀疑队列、锁和两个线程的调度顺序黑匣子属性直接拉满。这个项目选了另一条路单线程 select 多路复用。串口 fd 和监听 fd 一起挂到 fd_set 上谁有事件处理谁处理完继续下一轮。好处是没有并发状态所有缓冲区都在一个线程里访问不需要任何锁坏处是一旦某次 write 阻塞整条链路都卡住——所以所有 fd 都必须以非阻塞方式打开这是硬约束。方案优点代价双线程 队列读写互不阻塞代码分支直观需要锁和退出机制问题定位跨线程单线程 select无锁、状态集中、易调试任一 fd 阻塞会拖垮全局必须全非阻塞单线程 epoll连接数多时扩展性好这类场景通常只有一个串口一个客户端收益不大现场设备基本是「一个串口对一个客户端」select 的 1024 fd 上限完全够用没必要为了扩展性提前上 epoll。等真要做多串口汇聚再换成 epoll 也只是把 fd_set 换成 epoll_event主循环骨架不变。2.2 缓冲区与数据结构转发链路里到底存了什么整条转发链路的运行状态就那么几个字段我把它们收在一个结构体里避免全局变量散落一地。核心是两块缓冲串口来的数据和网络来的数据各一块长度固定够放下现场一帧报文就行。#define BUF_SZ 4096 typedef struct { int uart_fd; /* 串口设备 fd非阻塞 */ int listen_fd; /* TCP 监听 fd */ int client_fd; /* 当前连上的客户端-1 表示无 */ unsigned char uart_rx[BUF_SZ];/* 串口读上来的原始字节 */ unsigned char net_rx[BUF_SZ]; /* 网络收到的原始字节 */ unsigned long stat_uart_bytes;/* 串口侧累计字节排查用 */ unsigned long stat_net_bytes; } bridge_t;BUF_SZ取 4096 是个经验值现场轮询报文一般几十到几百字节4096 足够一次 read 把积压数据全带走又不会因为缓冲区太大让延迟变高。client_fd初始化为 -1后面所有判断都靠它区分「有没有客户端」比另起一个 bool 更省事。两个stat_*字段平时不用出问题时打印出来一眼能看出是不是只有单向通——这是排查时最省时间的一招。两块缓冲都是「读进来立刻写出去」的用法不做跨轮次缓存。这意味着如果对端写满、write返回EAGAIN当前这批数据就丢了。要做得更稳得给每条方向加一个待发送队列代价是代码量翻倍。现场设备大多能容忍偶发丢帧协议本身有重试所以我选择先简单后完善。2.3 目录结构与模块边界工程没有引入任何第三方库只依赖 libc 和 pthread 之外的标准头文件。模块按「设备操作」和「网络操作」切开主循环只负责编排不碰具体系统调用。com-ip-bridge/ ├── Makefile ├── README.md ├── src/ │ ├── main.c # 参数解析、select 主循环 │ ├── uart.c / uart.h # 打开、配置、读写串口 │ ├── net.c / net.h # socket 监听、accept、收发 │ └── bridge.c/.h # 双向转发与统计 └── conf/ └── bridge.conf # 二次开发后的配置样例uart.c对外只暴露四个函数uart_open、uart_read、uart_write、uart_close。net.c同样只暴露net_listen、net_accept、net_recv、net_send。这种划分的意义在于以后想把串口换成 SPI 或者虚拟设备只改uart.c一个文件主循环一行不动。转发逻辑单独放在bridge.c是因为后面要在转发路径上加协议钩子有个独立文件改起来干净。2.4 编译与第一次跑通Makefile 就三条规则不开优化方便调试-g -Wall是标配。-D_GNU_SOURCE是为了拿到cfmakeraw这些 GNU 扩展。CC ? gcc CFLAGS ? -g -O0 -Wall -Wextra -D_GNU_SOURCE SRC : $(wildcard src/*.c) bridge : $(SRC) $(CC) $(CFLAGS) -o $ $(SRC) clean : rm -f bridge跑起来只需要四个参数串口设备、波特率、监听地址、监听端口。make # 串口 /dev/ttyUSB0115200 8N1监听所有网卡的 5000 端口 ./bridge -d /dev/ttyUSB0 -b 115200 -l 0.0.0.0 -p 5000 # 另一端验证bash 内置的 /dev/tcp 就能当客户端用不依赖额外工具 exec 3/dev/tcp/127.0.0.1/5000 printf hello 3 timeout 2 cat 3第一次验证建议先拿虚拟串口做别直接怼真设备。用 pty 模拟出一对串口把其中一端给程序另一端自己读写能立刻确认「串口→网络」和「网络→串口」两个方向都通再上现场硬件。参数含义很直白-d是设备节点-b是波特率必须和设备一致写错就是收一串乱码-l和-p是监听地址端口-l 127.0.0.1只给本机连现场部署改成0.0.0.0或者指定内网网卡地址。提示程序默认阻塞在 select 上等事件日志级别用-v打开。第一次上现场务必先把-v打开跑十分钟看看有没有反复的 accept/断线这比事后猜要省事得多。3. 串口侧用 termios 把 tty 调成一条裸管道3.1 打开串口O_NOCTTY 与 O_NONBLOCK 的取舍打开串口设备节点时open的 flag 里有两个参数必须想清楚。O_NOCTTY表示「别把这个终端当成本进程的控制终端」少了它串口设备发过来的某些字节序列可能被行规程当成控制字符甚至导致进程收到信号。O_NONBLOCK是前面说的硬约束select 循环里任何一个 fd 阻塞整个转发就停了。int fd open(dev, O_RDWR | O_NOCTTY | O_NONBLOCK); if (fd 0) { fprintf(stderr, open %s: %s\n, dev, strerror(errno)); return -1; }非阻塞模式有个副作用没有数据时read直接返回 -1 并把errno置成EAGAIN。这跟「读失败」看起来一样必须显式区分。O_NOCTTY则常被省略因为省略后大部分场景也「看起来正常」——直到某天设备回了一串包含 0x03 的数据进程莫名其妙退出才开始怀疑人生。这类玄学问题翻车的代价远高于多写一个 flag。3.2 raw 模式与 8N1termios 里每个 c_cflag 位到底改了什么串口默认是「终端」语义按行缓冲、回显输入的字符、把\n翻译成\r\n、把 CtrlC 解释成中断信号。做数据透传这些行为一个都不能留。cfmakeraw能一次性关掉绝大部分但保险起见我还是把关键位显式写一遍让后来接手的人看得懂每一行在干什么。struct termios tio; if (tcgetattr(fd, tio) ! 0) { close(fd); return -1; } cfmakeraw(tio); /* 关回显、关行缓冲、关信号字符 */ 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 位数据位 */ tio.c_cc[VMIN] 1; tio.c_cc[VTIME] 0; cfsetispeed(tio, baud); cfsetospeed(tio, baud); if (tcsetattr(fd, TCSANOW, tio) ! 0) { close(fd); return -1; }逐项说。CLOCAL忽略载波检测串口线没接好也不会卡住CREAD允许接收不写这个就读不到任何东西。CRTSCTS是硬件流控现场设备十台有九台没接 RTS/CTS 线开着它等于自断收发必须清掉。CS8配合前面对CSTOPB、PARENB的清除构成最常见的 8N1 组合。VMIN1、VTIME0表示「至少来一个字节才返回」配合非阻塞 fd实际行为是「有数据就返回没数据返回 EAGAIN」。波特率这里有个坑cfsetispeed和cfsetospeed必须在tcsetattr之前调用顺序反了不生效。另外115200 以上的非标准波特率比如 921600在部分平台的speed_t里没有常量得用termios2结构体加BOTHER走ioctl标准 termios 搞不定。真遇到这种设备别在cfset*上死磕换条路。3.3 VMIN/VTIME读超时怎么设才不丢半包VMIN和VTIME的组合决定了read什么时候返回这两参数在透传场景里需要想明白。四种组合的行为差异很大VMINVTIME行为适用场景00立即返回有就全给没有返回 0纯轮询不配合 select 时别用10至少 1 字节才返回无超时本项目的选择配合 select0n最多等 n×100ms超时返回定时上报型设备mn满 m 字节或超时返回定长帧协议这个项目选VMIN1, VTIME0因为等待的活交给了 select。select 报告串口可读read立刻就能拿到数据不会在里面再睡一次。反过来如果设成VMIN0, VTIME5select 醒了之后read还要再等 500ms 才返回转发延迟直接翻倍。真正需要注意的是「一次 read 不等于一帧」。串口是字节流设备发一帧 20 字节内核缓冲区可能只到了 8 字节就触发可读read返回 8。这不是 bug是正常行为。透传模式下无所谓读到多少转多少字节顺序不乱就行但如果你在转发路径上做协议解析就必须自己攒帧攒够再处理。这一点在后面加帧钩子时会重点说。3.4 部分写与 tcdrain串口写不完怎么办串口侧最常被忽略的是写。网络侧send返回部分写很多人会写循环串口侧同样会部分写而且更容易——波特率 9600 时往串口写 4KB 大约需要 4 秒多这段时间里 UART 的发送缓冲区一直是满的write返回实际写入的字节数远小于请求值。static int uart_write_full(int fd, const unsigned char *buf, size_t len) { size_t off 0; while (off len) { ssize_t n write(fd, buf off, len - off); if (n 0) { off (size_t)n; continue; } if (n 0 errno EINTR) continue; /* 被信号打断重试 */ if (n 0 (errno EAGAIN || errno EWOULDBLOCK)) { usleep(1000); /* 发送缓冲满让出 1ms */ continue; } return -1; /* EIO/ENODEV 等真错误 */ } return 0; }参数len是本次要写的总字节数off是已写偏移循环直到写满或者遇到不可恢复错误。EAGAIN分支里usleep(1000)是妥协做法——严格来说应该把剩余数据挂进待发送缓冲由 select 的可写事件驱动但那样代码复杂度上一个台阶。1ms 的让步在 115200 波特率下能腾出约 11 字节的空间实际测试下来够用而且不会明显拖慢主循环。EIO和ENODEV必须和EAGAIN分开前者通常意味着 USB 转串口被拔了设备节点已经失效继续重试没有意义正确做法是关掉 fd、重新扫描设备节点、重新打开。把所有负返回值都当成「再等等」程序就会卡在重试循环里表现是进程还在但数据全断这种状态最难查。注意tcdrain会一直等到发送缓冲区彻底排空才返回在非阻塞 fd 上同样会阻塞线程。主循环里不要调它只在程序退出前需要确保最后一帧发完时用一次。4. 网口侧TCP 服务端模式的监听与转发4.1 监听与接受SO_REUSEADDR 与单客户端策略网络侧默认工作在 TCP 服务端模式设备主动连过来。监听流程就是标准三步socket、bind、listen。中间有个容易忘的选项SO_REUSEADDR作用是允许端口在 TIME_WAIT 状态下被重新绑定。调试期间频繁重启程序没这个选项就会看到Address already in use然后等上一两分钟。int lfd socket(AF_INET, SOCK_STREAM, 0); int on 1; setsockopt(lfd, SOL_SOCKET, SO_REUSEADDR, on, sizeof(on)); struct sockaddr_in addr {0}; addr.sin_family AF_INET; addr.sin_port htons(port); addr.sin_addr.s_addr inet_addr(bind_ip); /* 0.0.0.0 表示全部网卡 */ bind(lfd, (struct sockaddr *)addr, sizeof(addr)); listen(lfd, 4);inet_addr只支持 IPv4 点分十进制传0.0.0.0表示监听所有网卡。listen的 backlog 给 4 就够了这类场景不会有并发连接洪峰。生产环境如果只允许内网某台采集服务器连把bind_ip写成具体网卡地址比在应用层做白名单简单可靠。这里明确采用单客户端策略同一时刻只服务一个 TCP 连接。理由是串口是独占资源两个客户端同时往串口写数据在物理层就交织了对端设备根本解不出来。所以accept到新连接时如果client_fd已经不是 -1直接关掉旧连接换新的——现场设备重连时通常不会先发 FIN旧 fd 会一直占着不主动替换就再也连不上了。4.2 select 事件循环骨架主循环是整个项目的心脏逻辑就三块把关心的 fd 塞进集合、等事件、按事件分派。串口 fd 和监听 fd 一直都在客户端 fd 有时有时没有所以要动态判断。for (;;) { fd_set rset; FD_ZERO(rset); FD_SET(b.uart_fd, rset); FD_SET(b.listen_fd, rset); int maxfd b.uart_fd b.listen_fd ? b.uart_fd : b.listen_fd; if (b.client_fd 0) { FD_SET(b.client_fd, rset); if (b.client_fd maxfd) maxfd b.client_fd; } struct timeval tv {1, 0}; /* 1 秒超时留出做心跳和统计的口子 */ int n select(maxfd 1, rset, NULL, NULL, tv); if (n 0) { if (errno EINTR) continue; break; } if (n 0) continue; /* 超时本轮无事件 */ if (FD_ISSET(b.listen_fd, rset)) { int c net_accept(b.listen_fd); if (c 0) { if (b.client_fd 0) close(b.client_fd); b.client_fd c; } } if (b.client_fd 0 FD_ISSET(b.client_fd, rset)) { ssize_t r net_recv(b.client_fd, b.net_rx, BUF_SZ); if (r 0) uart_write_full(b.uart_fd, b.net_rx, (size_t)r); else if (r 0 || (r 0 errno ! EAGAIN)) { close(b.client_fd); b.client_fd -1; /* 对端关闭或出错 */ } } if (FD_ISSET(b.uart_fd, rset)) { ssize_t r uart_read(b.uart_fd, b.uart_rx, BUF_SZ); if (r 0 b.client_fd 0) net_send(b.client_fd, b.uart_rx, (size_t)r); } }maxfd 1是 select 的第一个参数必须比集合里最大的 fd 大 1写成maxfd会导致最后一个 fd 永远不被监视这是新手最常犯的错之一。net_recv返回 0 表示对端正常关闭收到 FIN返回负值且errno ! EAGAIN表示出错两种情况都该关 fd。串口侧读到数据但当前没有客户端数据就直接丢弃——这是设计选择避免在没有接收方的时候无限堆积缓冲撑爆内存。4.3 网络侧收发的边界EAGAIN、半关闭与 TCP_NODELAYsend在非阻塞 socket 上也可能部分写或者返回EAGAIN尤其是对端处理慢的时候。net_send内部同样要循环逻辑和uart_write_full一致遇到EAGAIN短暂让步。区别在于网络侧更该在意延迟所以还有两个 socket 选项要设。int on 1; setsockopt(cfd, IPPROTO_TCP, TCP_NODELAY, on, sizeof(on)); /* 关 Nagle */ setsockopt(cfd, SOL_SOCKET, SO_KEEPALIVE, on, sizeof(on)); /* 开保活探测 */TCP_NODELAY关掉 Nagle 算法。Nagle 会把小包攒起来等 ACK 再发现场轮询报文往往就几十字节开启状态下可能平白多出 40ms 延迟做实时控制的场景完全不能接受。这个选项默认是关的很多人忘了设然后抱怨「程序明明没卡数据就是慢半拍」。SO_KEEPALIVE的默认探测间隔是两小时实际意义不大但配合应用层心跳主循环里 select 超时那一秒正好可以用来判断最后收包时间才真正管用。半关闭场景要留意如果客户端只关写端shutdown(SHUT_WR)recv会返回 0但 socket 本身还能写。透传场景不需要这种复杂语义一律按「收到 FIN 就关连接」处理简单不出错。4.4 断线与重连如何不把串口搞乱现场最典型的故障是网线松动或者交换机重启。TCP 连接断了但串口设备完全不知道它还在按自己的节奏往串口吐数据。这时候程序的行为决定了恢复速度如果串口 fd 保持不变只是client_fd置 -1那么设备数据会被持续丢弃新客户端连上后立刻就能续上中间的空白由上位机协议自己重试补上。反过来如果在断线时把串口也关了重开tcflush一执行设备正在发的半帧数据就被丢掉重连后对端可能收到半帧乱码。所以串口 fd 的生命周期和网络连接完全解耦网线断一百次串口也只打开一次。这条经验是一次现场事故换来的当时上位机一直报校验错误查了半天才发现是断线重连时把串口缓冲清了。重连后还有个小细节新客户端连上来的瞬间串口可能正好有一批积压数据。我的做法是 accept 之后先不清空任何缓冲直接开始转发让上位机自己按协议丢弃无效帧。清缓冲看着干净实际会让对端的两帧数据粘在一起反而更难处理。5. 避坑与排查串口网口转发里最容易翻车的五个点5.1 串口侧的三类高频故障现象程序正常运行但一按 CtrlC 进程就退出同时串口设备收到一个 0x03 字节。原因是open时漏了O_NOCTTY串口被当成了本进程的控制终端行规程把键盘输入的特殊字符转义后写给了设备。解决很简单open的 flag 补上O_NOCTTY同时确认cfmakeraw在tcsetattr之前调用过。这个问题在虚拟串口上复现不了只在真硬件上暴露所以现场首次部署一定要测。现象小报文一切正常一上大报文就少最后几个字节且丢失量随时间累积。原因是write到串口返回了部分写代码只判断 0就认为写完了。9600 波特率下1KB 数据要发一秒钟中间返回个 64 太正常了。解决就是第 3 章那个uart_write_full循环写直到off len。顺带说一句errno EAGAIN和errno EIO必须分开处理前者是缓冲满后者是设备没了。现象跑了几小时内存没涨但转发延迟越来越大重启立刻恢复。原因是串口写的速度长期慢于网络收的速度数据在主循环里排队但没人管或者对端 socket 的发送窗口被填满send一直返回EAGAIN而代码在死循环重试。解决的抓手是两个给每条方向加一个待发送队列并设上限比如 64KB超了就丢最旧的同时观察stat_net_bytes和stat_uart_bytes的差值差值持续扩大就说明有一侧堵了。5.2 网络侧的两类高频故障现象拔插一次 USB 转串口进程还在但数据全断ps看进程状态正常。原因是设备节点消失后read返回 -1 且errno是EIO或ENODEV而代码把它和EAGAIN归成一类继续在 select 上等一个永远不会再有事件的 fd。解决是在主循环里对串口错误做分类EAGAIN忽略EIO/ENODEV走重连流程——关 fd、休眠几百毫秒、按设备路径重新open成功了继续用失败了重试。这一条不加任何一次 USB 松动都得人工重启服务。现象客户端重连之后收到的数据里混着上一轮的残留或者干脆串台。原因是accept前没有关掉旧client_fd两个 fd 指向同一个串口旧连接的对端还在慢慢发数据。解决是accept成功的第一件事就close旧 fd 并置 -1再赋新值。另外一个隐蔽的变体是端口复用没开服务重启时绑定失败但程序只打印了一行日志就退出了运维没注意以为服务在跑。SO_REUSEADDR和启动失败时明确非零退出码两个都得有。提示现场排查先看统计。串口字节数在涨、网络字节数不涨问题在客户端或网络两个都不涨问题在串口配置或设备本身两个都在涨但对端报错多半是波特率或者校验位不一致。这个二分法能省掉大半时间。6. 二次开发把写死的转发改成配置驱动的协议网关6.1 配置化把命令行参数搬进 conf命令行参数在单串口场景够用一旦要同时转发两三路参数就没法看了。我一般会加一个极简的 keyvalue 配置文件用getline逐行读strtok切分二三十行代码搞定不引第三方库。格式长这样uart.dev /dev/ttyUSB0 uart.baud 115200 net.listen 0.0.0.0 net.port 5000 frame.mode rawframe.mode是留给下一步的开关raw表示纯透传改成modbus之类的值就走解析路径。配置文件解析失败的默认值要写死一套能跑的别让配置缺失直接崩掉——现场改配置的人可能根本不懂代码。6.2 加一层帧钩子做协议转换真正的改造点在转发路径上插一个回调。在bridge.c里定义函数指针类型int (*frame_hook)(unsigned char *buf, size_t len, size_t *out_len)raw模式下这个指针是 NULL直接原样转发需要做协议转换时注册一个实现在字节流进入对端之前处理一遍。比如现场常见的场景是把设备侧带 CRC 的二进制帧转成带 ASCII 头的文本帧给上位机钩子里做校验、拆帧、重新封装raw模式的代码一行不用改。要注意钩子的入参是「一次 read 的字节」而不是「一帧」所以钩子内部必须有攒帧逻辑返回 0 表示「还没攒够先别转发」。这个设计把「字节流」和「帧」的边界清晰地放在了钩子里主循环继续只管搬运职责不混。6.3 验证改造是否生效改完别急着上现场先在本地验证三个点透传模式下行字节数必须和改造前完全一致说明没引入额外拷贝问题钩子模式下构造一帧带错误校验的数据看是否被正确丢弃连续跑两小时看stat_uart_bytes和stat_net_bytes是否还在同步增长。第三点最容易被跳过但恰恰最能暴露缓冲泄漏。我现在的习惯是每次动转发路径都先用 pty 模拟一对串口跑一遍全链路回归确认字节级一致再碰真设备。那次在客户现场因为清空串口缓冲导致上位机连报半小时校验错误之后我就再也不敢跳过这一步了。希望帮到你。本文还有配套的精品资源点击获取