简介:面向嵌入式与工业通信方向开发者的一份小型参考实现,聚焦串口与以太网口之间双向数据转发的核心问题:一个线程监听串口,收到数据后转发至网络套接字;另一个线程监听网口,把数据回写串口,并借助 select 同时监控多个描述符,配合多线程同步避免阻塞与数据竞争。资源压缩包为 rar 格式,共 5 个文件、约 4KB,以 c 源码与 h 头文件为主体,另有 Makefile 负责编译组织,并附一份说明文件,便于快速读懂工程结构与编译运行方式。目前已有 1709 人学习下载,说明其在上下位机通信、远程设备监控、PLC 与 SCADA 数据采集等场景中具有稳定的参考价值。读者可从中获取串口参数配置、套接字收发、I/O 多路复用与协议转换的骨架代码,并借鉴网络断开、串口故障等异常处理与测试思路,作为自建转发程序或课程设计的起点。
1. 串口转网口:真正难的不是转发,而是“边界”与“背压”
很多人第一次做 COM 口和 IP 网口互转,直觉上觉得这就是个搬运活:串口读到字节塞进 socket,socket 收到字节写进串口,十分钟能跑通一个 Demo。可一旦进现场,Modbus 从站偶发 CRC 错、低速波特率下转发像卡住、拔一次网线程序就假死。问题几乎从不出在“转发”这个动作上,而出在两件被忽略的事:字节流没有边界,缓冲区没有出口。把这两件事想透,才算真正理解 com/ip 转发为什么这么设计。
2. 字节流的两种世界观:UART 的帧为什么在 TCP 里会碎掉
2.1 串口“看起来”有帧,只是因为发送方写得连续
UART 在物理层只承诺一件事:把起始位、数据位、校验位、停止位按时序摆到线上。它没有“包”的概念,接收方拿到的永远是一个字节接一个字节。我们在串口调试助手里看到“一帧数据”,本质上是发送方一次write把若干字节连续推出去,字节间隔极短,接收端按时间把它们归成一堆。
这个“帧”是发送方行为的产物,不是链路的保证。一旦中间插入调度延迟、USB 转串口芯片的 latency timer(常见默认 16ms 攒包)、或者对端逐字节发送,帧内字节间隔就会被拉长,按时间断帧立刻失效。
TCP 那边更彻底。应用层调三次send各 100 字节,接收方可能一次read拿到 300 字节,也可能分三次各 100,还可能拿到 100 + 200。原因是一整条链路上的多个独立机制:Nagle 算法会把小包攒起来合并,MSS 会把大块切开,路径 MTU 发现会改变分段大小,接收窗口会限制一次能取走多少,延迟 ACK 会让对端等 40ms 再确认。
提示:把“串口有帧、网络有包”当成前提去写代码,是这类项目里最先翻车的地方。两端都只是字节流,帧必须由应用层自己重建。
2.2 断帧的三种判据,选错一种就等着现场报错
重建边界只有三条路,各有明确的适用面和明确的坑:
| 判据 | 做法 | 适用 | 典型风险 |
|---|---|---|---|
| 定长 | 每 N 字节切一帧 | 传感器定点上报、私有协议 | 长度可变时整条流错位,且永久错位 |
| 帧间隔 | 字节间隔超过阈值即断帧 | Modbus RTU、DL/T 645 类 | 阈值取小了被 latency timer 切碎,取大了粘帧 |
| 协议自描述 | 帧头 + 长度字段 / 结束符 | 绝大多数自定义协议 | 噪声字节冒充帧头,需要状态机兜底 |
帧间隔这一路最容易被低估。Modbus RTU 规定帧间隔 3.5 个字符时间,波特率高于 19200 时固定取 1750μs。这个值在真实链路上非常脆:主站如果是软件模拟的,两次write之间隔了 2ms,从站就会把一帧拆成两帧,第二帧从中间开始解析,CRC 必错。反过来,如果阈值放到 50ms,两条连续指令就会被粘成一帧。
常见的实现思路是:帧间隔判据只作为“兜底”,真正的边界还是靠协议里的长度字段;两者冲突时以协议为准,时间只用来丢弃残帧。
2.3 用一个状态机吃掉噪声、粘包和半包
真正可靠的解析器一定是状态机,而不是“找到帧头就 memcpy 后面 N 个字节”。下面这段是帧头AA 55+ 1 字节长度 + 负载 + CRC16 小端的解析器,逐字节喂入,任何位置出现异常字节都能干净地回到起点:
/* 帧格式:AA 55 | LEN(1B) | PAYLOAD(LEN) | CRC16(2B, 小端) */ typedef enum { S_H1, S_H2, S_LEN, S_BODY, S_CRC } st_t; typedef struct { st_t st; uint8_t len; /* 当前帧负载长度 */ uint8_t idx; /* 负载下标 / CRC 字节下标,两个阶段复用 */ uint16_t crc_calc; /* 边收边算 */ uint16_t crc_recv; uint8_t payload[256]; uint32_t ok_cnt, err_cnt; } framer_t; /* 返回 1 表示凑齐一帧,payload/len 有效;返回 0 表示继续收 */ int framer_feed(framer_t *f, uint8_t b) { switch (f->st) { case S_H1: if (b == 0xAA) f->st = S_H2; break; case S_H2: /* 连续多个 0xAA 时不能退回起点,否则会漏掉真正的帧头 */ if (b == 0x55) f->st = S_LEN; else if (b != 0xAA) f->st = S_H1; break; case S_LEN: f->len = b; f->idx = 0; f->crc_calc = crc16_update(0xFFFF, b); /* 长度字段也参与校验 */ f->st = f->len ? S_BODY : S_CRC; break; case S_BODY: f->payload[f->idx++] = b; /* len 最大 255,不越界 */ f->crc_calc = crc16_update(f->crc_calc, b); if (f->idx == f->len) { f->idx = 0; f->st = S_CRC; } break; case S_CRC: if (f->idx == 0) { f->crc_recv = b; /* 低字节先到 */ f->idx = 1; } else { f->crc_recv |= (uint16_t)b << 8; f->st = S_H1; /* 无论对错都回到起点 */ if (f->crc_recv == f->crc_calc) { f->ok_cnt++; return 1; } f->err_cnt++; } break; } return 0; }三个细节值得单独说。S_H2里对连续0xAA的处理,是这类状态机最常见的 bug 来源——退回S_H1会让AA AA 55这种序列丢掉真正的帧头。S_LEN里把长度字段也纳入 CRC,可以防止长度被噪声改写后引发越界读。S_CRC结束时无条件回S_H1,是因为现场的错误几乎都是离散的单字节错误,重同步比“保上下文”更重要。
crc16_update采用查表或逐位实现都行,关键是它必须支持增量计算,能一边收一边算,而不是收完再遍历一遍。
3. 环形缓冲区:转发链路上唯一必须自己写的数据结构
3.1 memmove 那套写法,为什么会把 CPU 拖死
教科书式的接收写法是:读进buf[0..n],处理掉k字节,剩下的memmove到数组头,下次从n-k继续写。逻辑没错,但代价是每处理一帧就要搬一次剩余数据。在 115200 波特率下(约 11.5KB/s)还撑得住,到了 921600、或者一个网口扇出到四路串口,这个搬运量会随缓冲里残留字节数线性增长,而且每一次搬运都持有着读写双方的锁。
环形缓冲区把“移动数据”换成了“移动指针”。写指针和读指针各自单调递增,靠& mask映射到数组下标,回绕时不需要任何搬移。缓冲区大小取 2 的幂,取模就退化成一次按位与,这在每秒几十万次调用的热路径上是实打实的差异。
3.2 单生产者单消费者下的无锁实现
串口收包通常只有一个中断/DMA 回调在写,转发线程在读,天然就是 SPSC 模型,不需要任何互斥量,只要保证指针的可见性顺序:
typedef struct { uint8_t *buf; uint32_t mask; /* size - 1,size 必须是 2 的幂 */ _Atomic uint32_t head; /* 只由生产者写 */ _Atomic uint32_t tail; /* 只由消费者写 */ } ring_t; /* 返回实际写入字节数,0 表示满 */ uint32_t rb_write(ring_t *r, const uint8_t *src, uint32_t n) { uint32_t head = atomic_load_explicit(&r->head, memory_order_relaxed); uint32_t tail = atomic_load_explicit(&r->tail, memory_order_acquire); uint32_t cap = r->mask + 1; uint32_t room = cap - (head - tail); /* 无符号相减,回绕自动正确 */ if (n > room) n = room; /* 满则截断,策略交给调用方 */ uint32_t first = cap - (head & r->mask); /* 到数组末尾还剩多少 */ if (first > n) first = n; memcpy(r->buf + (head & r->mask), src, first); memcpy(r->buf, src + first, n - first); /* 回绕的那一段 */ atomic_store_explicit(&r->head, head + n, memory_order_release); return n; }head和tail用单调递增的计数器而不是回绕后的下标,head - tail就是已用字节数,无符号运算天然处理了回绕,也就没有“满和空无法区分”的经典困境。room = cap - (head - tail)意味着缓冲区最多只能存cap - 1字节也无所谓,因为公式本身自洽。
内存序的选择是这段代码的核心。生产者在写完数据后才用release更新head,消费者用acquire读head,保证“看到新 head 就一定看得到对应的数据”。tail的读取用acquire是为了让消费者在处理完数据后再更新tail时,不会把“已释放空间”提前暴露给生产者。
注意:
head和tail如果落在同一个 cache line 上,生产者写head会让消费者手里的tail缓存行失效,形成伪共享。把两个字段各自alignas(64)对齐,在高包速率下能省掉可观的周期。
3.3 水位线与背压:满了之后到底丢谁
缓冲区一定会满,问题只是满了之后怎么办。三种策略对应三种现场语义:
丢最新的数据,等价于“保旧”,适合配置下发这类慢速但要求完整的通道;丢最旧的数据,等价于“保新”,适合实时性优先的状态上报;阻塞生产者,在串口侧就是拉低 RTS 做硬件流控,在网口侧就是停止从 socket 读,让 TCP 窗口自己收缩到 0——这是唯一不丢字节的方案,代价是延迟不可控。
工程上通常做两层:设 70% 高水位,触发生成侧降速(比如暂停从 socket 读);设 30% 低水位,恢复读取。中间这段留出的余量,就是用来吸收网络突发和串口慢速之间的速度差。没有水位线、只在“满”的那一刻做判断的实现,会在线路抖动时反复在满和不满之间震荡。
4. 一次转发在系统里的完整路径:从 DMA 空闲中断到 socket 写
4.1 串口收包不该在中断里做业务
串口接收有三代做法。查询式最省事,但会让主循环被波特率绑架。逐字节中断在 921600 下每秒九万次中断,CPU 光进出中断就耗掉大半。现在主流是 DMA + 空闲中断:DMA 把字节直接搬进内存,线路空闲时硬件产生一次 IDLE 中断,CPU 这才进去取一次长度。
这一步的价值不只是省中断。IDLE 中断天然对应“一帧结束”,它给出的边界是硬件级的、和波特率精确匹配的,比软件定时器判断帧间隔要准得多。配合 DMA 的半传输中断做双缓冲,可以做到“前半段在收、后半段在处理”,彻底消除处理期间的丢字节窗口。
带 D-cache 的处理器上有个隐蔽的坑:DMA 写进内存的数据可能还在 cache 里没落盘,CPU 读到的可能是旧值。收包前 invalidate、发包前 clean,这两句不能省。
4.2 线程模型:把 tty 当成一个普通 fd
Linux 下串口就是一个字符设备,可以和其他 socket 一起扔进epoll。这样整个转发器只有一个事件循环,天然避免了“串口线程和网络线程互相加锁”的复杂度:
int uart_open(const char *dev, speed_t baud) { /* O_NONBLOCK 是前提:否则写大包时整个事件循环会被串口拖住 */ int fd = open(dev, O_RDWR | O_NOCTTY | O_NONBLOCK); if (fd < 0) return -1; struct termios tio; tcgetattr(fd, &tio); cfmakeraw(&tio); /* 关行规程:不做回显、不做 CR/LF 转换 */ tio.c_iflag &= ~(IXON | IXOFF); /* 二进制透传最怕 0x11/0x13 被当流控吃掉 */ cfsetispeed(&tio, baud); cfsetospeed(&tio, baud); tio.c_cflag |= (CLOCAL | CREAD); tio.c_cflag &= ~CSIZE; tio.c_cflag |= CS8; /* 8 数据位,和现场设备对齐 */ tio.c_cflag &= ~PARENB; /* 无校验,按对端实际配置改 */ tio.c_cflag &= ~CSTOPB; /* 1 停止位 */ tio.c_cflag &= ~CRTSCTS; /* 需要硬件流控时再打开 */ tio.c_cc[VMIN] = 0; tio.c_cc[VTIME] = 0; /* 纯非阻塞,不依赖读超时 */ tcsetattr(fd, TCSANOW, &tio); tcflush(fd, TCIOFLUSH); /* 丢掉打开瞬间的残留字节 */ return fd; }cfmakeraw那一行是关键。不关行规程,0x0D会被当成回车处理,0x11/0x13会被当成 XON/XOFF 流控字符直接吞掉——二进制协议里这两个字节出现频率不低,症状是“偶尔少一个字节,CRC 错”,排查起来极其费劲。
写方向也要非阻塞。write返回的字节数可能小于请求值,剩余部分必须挂到该连接的输出缓冲里,等EPOLLOUT再续写。很多实现只在write返回值上打了个日志就放过,结果就是高负载下静默丢尾包。
4.3 TCP_NODELAY:延迟和吞吐的拔河
一个典型的现场现象:串口发一条 8 字节的查询指令,转发到网络上,对端 200ms 后才收到。抓包一看,数据在本地就压了 40ms 才发出去。
这就是 Nagle 和延迟 ACK 撞在一起的结果。Nagle 规定“有未确认的小包时,后续小包先攒着”,而接收端的延迟 ACK 又要等最多 40ms 才回确认,两者正好互相等待。对于一问一答式的工业指令,这个组合是灾难。
解法是给转发连接的 socket 打开TCP_NODELAY,禁用 Nagle。代价是小包变多、头部开销占比上升,但在一问一答场景下这个代价可以忽略。反过来说,如果这条链路是大块数据单向传输(比如日志回传),关掉 Nagle 反而会让网络里塞满小包,这时应该做的是在应用层合并写:把一帧数据凑齐再一次send,而不是每个字节send一次。
int on = 1; setsockopt(fd, IPPROTO_TCP, TCP_NODELAY, &on, sizeof(on)); /* 顺带处理:对端异常关闭时 write 会产生 SIGPIPE,默认行为是杀进程 */ signal(SIGPIPE, SIG_IGN);SIGPIPE那一行值得单独记住。默认处置是终止进程,一个客户端异常断开就能让整个转发服务消失,日志里连堆栈都留不下,典型的黑匣子现场。
4.4 连接状态机:重连、退避与心跳
转发服务必须假设网络随时会断。一个不带状态机的实现,通常写成“连不上就while(1) { connect(); }”,网线一拔,CPU 直接跑满,日志疯狂刷屏。
正确的做法是把连接当成一个显式状态机,失败后进入退避:
static uint32_t backoff_ms(int retry) { /* 200, 400, 800, 1600, 3200, 6400,封顶 10s */ uint32_t d = 200u << (retry > 5 ? 5 : retry); return d > 10000u ? 10000u : d; } /* 状态:IDLE -> CONNECT -> UP -> (断开) -> BACKOFF -> CONNECT */ void link_on_disconnect(link_t *l, uint64_t now_ms) { l->state = LK_BACKOFF; l->next_try = now_ms + backoff_ms(l->retry) + (rand() % 100); l->retry = l->retry < 6 ? l->retry + 1 : 6; /* 断开瞬间清空收发缓冲:残留的半帧带到新连接里只会制造脏数据 */ rb_reset(&l->tx); framer_reset(&l->framer); }退避里加的那点随机抖动不是装饰。几十台设备同时断电重启、同时重连,如果没有抖动,连接请求会在同一毫秒涌入,服务端 accept 队列瞬间打满。这类同时重连导致的连接失败,事后看日志毫无规律,只能靠经验提前加抖动。
心跳不要依赖SO_KEEPALIVE,它的默认探测周期是两个小时,对工业现场毫无意义。用应用层心跳,比如每 3 秒发一个空帧、连续 3 次无响应就判定链路失效并主动关闭 socket,把状态机推进到BACKOFF。主动关比等 TCP 自己超时快得多,对端掉电、中间设备静默丢包这些场景都靠它兜底。
还有一个容易漏的点:对端发来 FIN 时,不能立刻把本地 socket 关掉,因为输出缓冲里可能还有没写完的数据。正确顺序是先shutdown(fd, SHUT_WR),等剩下的数据发完、read返回 0 之后再close。少了这一步,最后一次应答就丢在了内核缓冲里。
5. 排查清单:五个上线后才炸的转发故障
5.1 现象:低速波特率下转发“卡住”几秒又自己好了
原因:TCP_NODELAY没开,加上一问一答的小包模型,Nagle 和延迟 ACK 互相等待;串口侧又是逐字节带超时读,两边的等待叠加,表现成周期性卡顿。
解决:转发连接的 socket 打开TCP_NODELAY,串口设为O_NONBLOCK+VMIN=0/VTIME=0,用epoll驱动而不是靠读超时轮询。改完以后在日志里打印每次转发的“入队到写出”耗时,正常情况下应该稳定在毫秒级。
5.2 现象:Modbus 从站偶发 CRC 错,重发就好
原因:帧间隔阈值取太小(比如 1ms),而 USB 转串口芯片的 latency timer 把一帧拆成两段送上来,从站收到两个半帧。也可能是IXON没关,负载里恰好有0x11/0x13被吃掉。
解决:帧间隔阈值按波特率算,高于 19200 时取 1750μs 并留 2 倍余量;同时把协议自描述的长度字段作为主判据,时间判据只用来清残帧。抓一份原始字节流,用示波器或者带时间戳的十六进制日志对照,能一眼看出是拆分还是吞字节。
5.3 现象:多个上位机同时连同一个串口,应答串台
原因:没有做事务串行化。两个连接同时下发指令,串口把两帧连续发出去,从站按顺序回了两帧,但转发器无法知道哪一帧属于哪个连接,于是按到达顺序分配给“当前等待者”。
解决:给串口方向加一个请求队列,一次只允许一个事务在途,收到应答后按队列顺序归还;或者干脆在配置里声明“独占模式”,第二个连接直接拒绝。带事务 ID 的协议(比如某些自定义协议)可以靠 ID 分发,但标准 Modbus RTU 帧里没有这个字段,只能串行化。
5.4 现象:跑几个小时后内存缓慢上涨,重启就恢复
原因:环形缓冲区的tail只在“读满一整帧”时推进,遇到残帧就一直停在原地,head继续涨,实际可用空间越来越小;或者每个连接建立了独立的输出缓冲,连接断了却没释放。
解决:给残帧设最大保留长度和最长存活时间,超过就强制推进tail并累加丢弃计数。所有缓冲区都要能被一个统计接口读出used / capacity / drop_cnt,这三个数打到监控里,涨没涨一眼可见。
5.5 现象:拔掉网线再插回,进程还在但不再转发
原因:只处理了EPOLLIN,没处理EPOLLERR | EPOLLHUP。socket 进入错误状态后一直可写不可读,事件循环每次都被唤醒却读不到数据,形成空转;或者write拿到EPIPE后没有推进状态机。
解决:注册事件时带上EPOLLERR | EPOLLHUP,一旦触发就关闭 fd、清空缓冲、转入BACKOFF。同时给write的每个错误分支都做显式处理,EAGAIN挂EPOLLOUT,EPIPE/ECONNRESET走断开流程,不允许出现“记录了日志但状态没变”的分支。
6. 设计取舍:做“薄透传”还是“厚网关”,以及怎么验证它不丢字节
转发器有两种截然不同的形态,选错了会在项目中期推倒重来。
薄透传只做字节搬运:串口来的字节进环缓冲,直接写 socket;socket 来的字节写串口。它不理解协议,因此对私有协议、二进制协议、加密载荷都通吃,延迟最低,代码量最小。代价是它无法处理多主站冲突,也无法在设备无响应时给出有意义的错误,所有超时和重试都得由上位机自己做。
厚网关要把协议解析出来:知道帧头在哪、事务 ID 是什么、哪个功能码对应哪段数据。它能做多连接扇出、事务串行化、异常码回填、点位缓存,但每加一种协议就要加一套解析器,而且解析器本身会成为新的故障点。
| 维度 | 薄透传 | 协议网关 |
|---|---|---|
| 帧边界 | 时间/长度启发式 | 协议字段精确解析 |
| 多主站 | 独占或广播 | 队列串行化 |
| 端到端延迟 | 一个缓冲的拷贝 | 多一次解析与排队 |
| 设备无响应 | 上层自己超时 | 可主动回异常码 |
| 扩展成本 | 换协议零成本 | 每协议一套解析器 |
我的经验是:先用薄透传打通链路,把字节级完整性验证做扎实,再根据需要往上叠协议层。反过来先写协议解析、再补透传,通常会在遇到一个不在协议清单里的私有设备时卡住。
验证方法不复杂,但必须做。第一步是自环:把一个 socket 收到的数据直接写回串口,串口侧用脚本发 0x00 到 0xFF 的全字节表循环若干轮,收端做逐字节比对,任何一次吞字节、重字节、乱序都会立刻暴露。第二步是加序号:每包带一个自增 32 位序号和时间戳,收端检查连续性,这能同时测出丢包和端到端延迟分布。第三步是故障注入:在转发过程中强制断开连接、强制让串口侧停止读取若干秒,观察恢复后是否出现半帧残留、是否重复发送。
最后一步最容易被跳过,也最值钱:让整个系统连续跑 24 小时,每隔一小时打印一次各缓冲区的used / drop_cnt和帧错误计数。这三条曲线只要有任何一条单调上升,就说明有个出口没被处理干净。
从那以后我每次做串口网口互转,都会在写第一行转发逻辑之前先把环形缓冲和帧状态机的单元测试搭好,再让字节流在自环里跑满一轮全字节表——因为转发这个动作本身太简单,简单到会让你误以为问题不会出现在别处。希望帮到你。
本文还有配套的精品资源,点击获取