串口转网口转发实现:基于select+非阻塞I/O的工业级协议桥接
简介本资源是一个基于C语言实现的串口与网口双向数据转发工具面向嵌入式开发、工业通信及网络协议集成领域的中高级工程师与学习者解决现场设备如PLC、传感器通过串口接入以太网远程监控系统的实际工程问题。压缩包共5个文件含2个核心C源码ip2com.c与test.c、1个头文件ip2com.h、1个Makefile构建脚本及1份中文说明文档总大小仅4KB轻量紧凑便于嵌入Linux嵌入式环境快速编译部署。已有1707人学习下载代码结构清晰主程序采用select多路复用机制同步监听串口与TCP套接字配合双线程分别处理串→网与网→串转发逻辑并预留协议适配接口便于扩展MODBUS等工业协议转换。读者可直接复用其I/O事件驱动模型、线程同步设计及异常断连重连逻辑快速构建稳定可靠的上下位机通信中间件。1. 串口和网口数据相互转发不是简单“接线”而是协议桥接的实时性黑匣子你手头有个老设备只有 RS232/RS485 接口但新系统跑在 Linux 服务器上只认 TCP/IP或者反过来嵌入式终端要通过以太网接收上位机下发的控制指令再原样转成串口命令发给 PLC——这时候“串口和网口数据相互转发”就不是配个 USB 转串口线就能解决的事。它本质是跨物理层、跨协议栈的数据流桥接一边是字节流无状态、无校验、靠硬件电平驱动的串行通信另一边是带连接管理、分包重传、缓冲区调度的 TCP/IP 协议栈。很多开发者第一次写转发逻辑时卡在“数据粘包”“丢帧”“响应延迟突增”上反复改超时参数却收效甚微——这不是代码 bug而是没理清串口波特率与 TCP 窗口大小之间的隐含耦合关系。本资源是一套经某工业现场连续运行 18 个月验证的轻量级转发实现支持双向透传、断线自动重连、流量镜像调试、波特率动态适配不依赖 systemd 或 Docker纯 C 实现编译后二进制仅 127KB适用于 ARM32/ARM64/x86_64 嵌入式 Linux 环境。适合需要稳定接入 legacy 设备、做协议转换中间件、或快速搭建串口设备远程调试通道的工程师。2. 转发核心逻辑拆解为什么必须用 select() 非阻塞 I/O而不是 while(1) read/write串口和网口转发看似只是“读 A 写 B读 B 写 A”但实际部署中90% 的稳定性问题源于 I/O 模型误选。我们先看一个典型翻车场景某导师让 A 同学用while(1) { n read(fd_uart, buf, 1024); write(fd_tcp, buf, n); }实现单向转发结果设备一发 200 字节长报文TCP 层因 MSS最大分段大小限制拆成两个 TCP 包发送而串口端又因波特率低、起始位/停止位开销大导致接收间隔超过 50ms——这时read()可能只拿到前 120 字节就返回后 80 字节等下一轮循环才到造成应用层报文被硬生生切开。更糟的是如果 TCP 连接临时卡顿如网络抖动write(fd_tcp, ...)会阻塞整个串口读取也停摆设备直接“失联”。2.1 为什么非阻塞 select() 是工业级转发的底线配置正确做法是将串口 fd 和 socket fd 全部设为非阻塞并用select()统一监听可读/可写事件。这样做的本质是把“等待数据到达”这个动作从内核态移交到用户态调度器避免单点阻塞拖垮全局。关键参数如下// 设置串口为非阻塞 int flags fcntl(fd_uart, F_GETFL, 0); fcntl(fd_uart, F_SETFL, flags | O_NONBLOCK); // 设置 socket 为非阻塞 int sock_flags fcntl(sockfd, F_GETFL, 0); fcntl(sockfd, F_SETFL, sock_flags | O_NONBLOCK);提示O_NONBLOCK必须在open()或socket()后立即设置不能等到connect()之后。否则connect()本身会阻塞且select()对未完成连接的 socket 不触发可写事件。2.2 select() 超时时间怎么设不是越小越好也不是越大越稳select()的timeout参数直接决定转发延迟与 CPU 占用的平衡点。我们实测过三组值timeout.tv_sectimeout.tv_usec平均端到端延迟CPU 占用ARM Cortex-A7适用场景00 1ms22%高实时性传感器数据采集如振动分析010000 (10ms)8~12ms3.1%工业 PLC 控制指令Modbus RTU over TCP10 500ms0.2%低功耗远程终端电池供电容忍秒级延迟结论对大多数现场设备如电表、温控器推荐tv_sec0, tv_usec10000。它保证每 10ms 至少轮询一次既避免高频空转又不会让串口缓冲区溢出假设波特率 11520010ms 最多积压 115 字节在 4096 字节内核缓冲区内安全。2.3 数据缓冲区设计双环形缓冲区为何比 malloc/free 更可靠转发过程存在天然异步串口来得快、TCP 走得慢或反之。若用malloc()动态分配内存频繁申请释放易碎片化且在嵌入式环境可能触发ENOMEM。我们采用预分配双环形缓冲区ring bufferuart_to_net_rb: 串口数据 → 网络方向大小 8192 字节net_to_uart_rb: 网络数据 → 串口方向大小 8192 字节每个 ring buffer 由三个原子变量维护head: 下一个写入位置生产者更新tail: 下一个读取位置消费者更新size: 当前有效字节数用于判断满/空typedef struct { uint8_t buf[8192]; volatile size_t head; volatile size_t tail; volatile size_t size; } ring_buffer_t; // 写入检查是否满更新 head 和 size int rb_write(ring_buffer_t *rb, const uint8_t *data, size_t len) { if (len (8192 - rb-size)) return -1; // 缓冲区满 size_t space_to_end 8192 - rb-head; if (len space_to_end) { memcpy(rb-buf rb-head, data, len); rb-head (rb-head len) % 8192; } else { memcpy(rb-buf rb-head, data, space_to_end); memcpy(rb-buf, data space_to_end, len - space_to_end); rb-head len - space_to_end; } __sync_fetch_and_add(rb-size, len); // 原子加 return 0; }注意__sync_fetch_and_add是 GCC 内置原子操作比pthread_mutex_lock开销低 80%且避免死锁风险。在单生产者/单消费者模型下本场景完全满足无需锁即可保证线程安全。3. 配置与启动如何用 5 行命令完成全功能转发服务部署本转发程序不依赖任何配置文件所有参数通过命令行传入便于集成进 init 脚本或容器启动命令。核心参数共 7 个但日常使用只需关注前 4 个。3.1 必填参数详解串口路径、波特率、目标 IP 和端口./serial2net \ --uart /dev/ttyS1 \ --baud 115200 \ --ip 192.168.3.100 \ --port 502 \ --mode tcp_client \ --timeout 30 \ --log-level 2--uart: 必须是 Linux 下真实串口设备节点常见有/dev/ttyS0主串口、/dev/ttyUSB0USB 转串口、/dev/ttyAMA0树莓派蓝牙串口。注意权限确保运行用户对设备有rw权限sudo usermod -a -G dialout $USER。--baud: 支持标准波特率9600/19200/38400/57600/115200/230400/460800/921600。非标值如 128000可能导致串口芯片驱动拒绝设置程序会打印ioctl(TIOCSBRK) failed: Invalid argument并退出。--ip--port: 目标 TCP 服务地址。若为tcp_server模式则此端口是本机监听端口--ip可设为0.0.0.0。--mode: 仅两个值tcp_client主动连接远端 TCP 服务或tcp_server监听本地端口等待客户端连接。不支持 UDP因 UDP 无连接状态无法可靠映射串口的“会话”语义。3.2 可选参数实战价值超时、日志、重连策略参数默认值说明生产环境建议值--timeout30TCP 连接/读写超时秒。设为 0 表示永不超时危险30平衡重连及时性与网络抖动--reconnect5断线后重连间隔秒。设为 0 表示禁用自动重连3快速恢复避免长时失联--log-level1日志等级0error, 1warn, 2info, 3debug。debug 级别会打印每字节收发2info 级别已含连接状态、吞吐量、错误码--mirroroff是否启用流量镜像将所有收发数据同时写入/tmp/serial2net-mirror.logon调试阶段必开定位协议解析问题提示--mirror日志是二进制原始数据用xxd /tmp/serial2net-mirror.log查看十六进制比cat更可靠。某次现场排查发现 Modbus 从站返回的异常响应码0x84服务器忙被上位机忽略正是靠镜像日志定位。3.3 systemd 服务模板让转发进程真正“活”在后台单纯nohup ./serial2net ... 无法处理进程崩溃重启、日志轮转、依赖启动顺序等问题。以下是精简可靠的 systemd unit 文件保存为/etc/systemd/system/serial2net.service[Unit] DescriptionSerial to Network Forwarder Afternetwork.target StartLimitIntervalSec0 [Service] Typesimple Userroot WorkingDirectory/opt/serial2net ExecStart/opt/serial2net/serial2net --uart /dev/ttyS1 --baud 115200 --ip 192.168.3.100 --port 502 --mode tcp_client --timeout 30 --reconnect 3 --log-level 2 Restartalways RestartSec10 StandardOutputjournal StandardErrorjournal SyslogIdentifierserial2net [Install] WantedBymulti-user.target启用服务sudo systemctl daemon-reload sudo systemctl enable serial2net.service sudo systemctl start serial2net.service # 查看实时日志 sudo journalctl -u serial2net.service -f注意RestartSec10是关键。若进程因段错误崩溃systemd 会在 10 秒后拉起避免瞬间重启风暴StartLimitIntervalSec0关闭了默认的 10 秒内最多启动 5 次限制。4. 避坑5 个血泪经验总结的高频故障与根因定位法串口网口转发是典型的“看起来简单调起来抓狂”场景。以下 5 条是我们在某高校实验室、某自动化公司产线、某能源监测项目中反复踩过的坑按“现象 → 原因 → 解决”结构整理每条都附带验证命令。4.1 现象串口数据能发出去但永远收不到响应netstat -tn显示连接 ESTABLISHEDtcpdump却看不到任何回包原因目标 TCP 服务端如 Modbus TCP 从站要求严格遵守“请求-响应”时序而转发程序未实现应用层心跳或超时重发。当请求发出后服务端因负载高延迟响应转发程序read()在select()超时后直接放弃等待导致“发了等于没发”。解决启用--timeout参数如--timeout 5并在代码中对read()返回值做状态机判断若n 0对端关闭或n 0 errno EAGAIN非阻塞无数据不立即重试而是记录“等待响应中”状态下次select()触发可读时再尝试read()。验证tcpdump -i eth0 port 502 -w modbus.pcap抓包对比请求帧与响应帧时间戳差。4.2 现象dmesg持续刷屏ttyS1: 1 input overrun(s)转发丢包严重原因串口 FIFO 缓冲区溢出。常见于高波特率如 921600下CPU 来不及从 UART 寄存器搬走数据新数据覆盖旧数据。根本原因是select()轮询间隔tv_usec过大或中断处理被其他高优先级任务抢占。解决① 将select()超时设为0, 10001ms② 检查内核串口驱动是否启用 FIFOstty -F /dev/ttyS1应显示icanon等字段若无执行setserial /dev/ttyS1 uart 16550A强制指定芯片型号③ 降低波特率至 460800 测试。验证cat /proc/tty/driver/serial查看tx:,rx:计数器溢出时rx后有overrun字样。4.3 现象TCP 连接建立后串口端收到乱码如0xFF 0xFE 0xFD...但用screen /dev/ttyS1 115200直连设备正常原因串口参数未正确初始化。Linux 串口默认开启ICRNL回车转换、IXON软件流控、ECHO回显等标志这些对二进制协议如 Modbus、自定义帧是灾难性的。解决在open()后立即调用tcgetattr()/tcsetattr()清除所有非必要标志struct termios tty; tcgetattr(fd_uart, tty); cfmakeraw(tty); // 关闭所有输入/输出处理 cfsetspeed(tty, B115200); tty.c_cflag | CREAD | CLOCAL; // 启用接收忽略 modem 控制线 tty.c_iflag ~(IXON | IXOFF | IXANY); // 关闭软件流控 tcsetattr(fd_uart, TCSANOW, tty);验证stty -F /dev/ttyS1 -icanon -echo -icrnl -ixon执行后测试。4.4 现象程序运行数小时后top显示 CPU 占用从 3% 爬升至 95%strace -p pid显示大量epoll_wait返回 0原因select()调用前未重置fd_set。每次循环必须用FD_ZERO()清空集合再用FD_SET()添加关心的 fd否则旧 fd 位图残留select()会持续监听已关闭的 fd返回 0 表示“无事件”但循环不休。解决严格遵循select()使用范式fd_set read_fds; struct timeval timeout {0, 10000}; while (running) { FD_ZERO(read_fds); // 关键每次循环必须清零 FD_SET(fd_uart, read_fds); FD_SET(sockfd, read_fds); int ret select(max_fd1, read_fds, NULL, NULL, timeout); if (ret 0) { /* 处理事件 */ } }验证strace -e traceselect ./serial2net ... 21 | grep timeout确认每次select()调用前timeout值一致。4.5 现象同一台设备用 Windows 上的 Serial Port Monitor 软件转发正常但本程序转发失败原因Windows 串口驱动默认启用RTS/CTS硬件流控而 Linux 驱动默认关闭。若设备强制要求硬件流控如某些 GPS 模块Linux 端 RTS 引脚恒高设备拒绝发送数据。解决启用硬件流控tty.c_cflag | CRTSCTS; // 在 tcsetattr 前添加并确保串口线缆包含 RTS/CTS 引脚非简化三线制。验证用万用表测/dev/ttyS1的 RTS 引脚通常为 DB9 第4脚stty -F /dev/ttyS1 crtscts后应为高电平12Vstty -F /dev/ttyS1 -crtscts后为低电平0V。5. 进阶技巧用流量镜像 Wireshark 协议解析3 步定位 90% 的协议级转发异常当基础连通性验证通过ping 通、telnet 端口通、串口echo test /dev/ttyS1能收到但业务数据仍异常时问题大概率出在协议层面比如 Modbus 功能码错、寄存器地址偏移、CRC 校验字节顺序颠倒。此时光看转发程序日志--log-level 3只能看到“收了 12 字节”却不知这 12 字节是否符合协议规范。我的固定动作是三步法已在 12 个不同协议项目中复用成功。5.1 第一步开启镜像日志并提取原始二进制流启动时务必加--mirror参数./serial2net --uart /dev/ttyS1 --baud 115200 --ip 192.168.3.100 --port 502 --mode tcp_client --mirror程序运行 30 秒后/tmp/serial2net-mirror.log会累积收发数据。用xxd提取一段完整请求-响应周期# 查找 Modbus TCP 请求特征事务ID0x0001, 协议ID0x0000, 长度0x0006 xxd /tmp/serial2net-mirror.log | grep -A 5 0000 00 01 00 00 00 06 # 输出类似 # 000001a0: 0001 0000 0006 0104 0000 000a ... # 000001b0: 0001 0000 0006 0104 0000 000a cccd ...复制包含请求和紧随其后的响应的 hex 数据如00010000000601040000000acccd保存为modbus.pcapng的原始数据段。5.2 第二步构造 Wireshark 可识别的 pcapng 文件Wireshark 不能直接解析裸 hex需封装成标准 pcapng 格式。我们用text2pcapWireshark 自带工具# 将 hex 字符串转为二进制文件 echo 00010000000601040000000acccd | xxd -r -p modbus.bin # 封装为 pcapng指定链路层为 Raw IP因是 TCP payload text2pcap -l 101 modbus.bin modbus.pcapng注意-l 101表示链路层类型为Raw IP对应DLT_RAW这是处理 TCP payload 的标准选择。若用-l 1EthernetWireshark 会尝试解析不存在的以太网头导致解析失败。5.3 第三步Wireshark 中加载并深度解析协议字段用 Wireshark 打开modbus.pcapng右键任意数据包 →Decode As...→ 在Current列选择Modbus点击OK。此时 Wireshark 会将 TCP payload 自动解析为 Modbus 结构Transaction Identifier:0x0001Protocol Identifier:0x0000Length:0x0006后续字节数Unit Identifier:0x01从站地址Function Code:0x04读输入寄存器Starting Address:0x0000起始寄存器Quantity of Registers:0x000a读10个若解析失败显示Data而非Modbus说明原始 hex 不完整或格式错误。此时回到xxd输出检查是否漏掉了 CRC 校验Modbus RTU 需 CRCTCP 不需要或 MBAP 头长度字段计算错误。5.4 一个真实案例PLC 响应数据总是错 2 个字节某次调试某国产 PLC镜像日志显示请求000100000006010300000001读保持寄存器 0x0000但响应0001000000050103020000中02表示字节数后面0000是值但实际应为0001。用 Wireshark 解析后发现Quantity of Registers字段被 PLC 错误地设为0x0002应为0x0001导致后续字节全部偏移。根源是 PLC 固件 Bug而非转发程序问题。从此我养成了一个习惯只要协议数据异常第一反应不是改转发代码而是导出镜像日志 → text2pcap → Wireshark 解析用标准协议分析器说话。因为协议解析器比人眼靠谱 100 倍。希望帮到你。本文还有配套的精品资源点击获取