简介:本资源是一个基于C语言实现的串口与网口双向数据转发工具,面向嵌入式开发、工业通信及网络协议集成领域的中高级工程师与学习者,解决现场设备(如PLC、传感器)通过串口接入以太网远程监控系统的实际工程问题。压缩包共5个文件,含2个核心C源码(ip2com.c与test.c)、1个头文件(ip2com.h)、1个Makefile构建脚本及1份中文说明文档,总大小仅4KB,轻量易部署,代码结构清晰,体现select多路复用与双线程并发收发的设计思想。已有1705人学习下载,适合深入理解串口termios配置、TCP Socket编程、I/O事件轮询机制及跨接口协议桥接逻辑。读者可直接编译运行,快速掌握串口-网络数据透传的关键实现细节、线程同步策略与典型异常处理方式,为SCADA、远程设备管理等场景提供可复用的技术原型。
1. 串口和网口数据相互转发:不是“透传”两个字就能糊弄过去的事
某高校实验室在做工业传感器组网时,把温湿度探头、电能表、PLC模块全接进一台嵌入式网关——串口接RS485设备,网口连上位机或云平台。本以为写个cat /dev/ttyS0 | nc 192.168.1.100 8080就能跑通,结果数据断续、时序错乱、心跳包超时、Modbus CRC校验失败频发。最后发现:串口是字节流+硬件时序+电平约束的黑匣子,TCP是面向连接+滑动窗口+Nagle算法的可靠通道,二者直接“管道拼接”等于拿螺丝刀撬保险柜——力道不对,门没开,锁芯先崩了。
“串口和网口数据相互转发”本质是跨协议域的双向桥接(bridge)问题:一边是无状态、无帧界、依赖波特率与起止位的异步串行通信;另一边是带会话管理、分段重传、拥塞控制的IP层传输。它不等于“串口转以太网模块”的硬件方案,而是指在通用Linux主机(x86/ARM)上,用软件实现稳定、低延迟、可配置、可监控的双向数据映射。适合需要自定义协议解析、加时间戳、做缓存策略、集成到现有服务(如MQTT/HTTP API)的场景,比如边缘计算节点、协议转换网关、调试代理服务器。新手容易卡在“能通但不可靠”,熟手则纠结于“吞吐量 vs 实时性 vs 容错性”的三角权衡——这正是本文要拆解的实操边界。
2. 为什么不用 socat / ser2net?选型逻辑与最小可行验证
2.1 三类方案对比:从“能跑”到“敢上线”
串口-网口转发看似简单,但落地时必须回答三个问题:
- 是否要求零丢包?(工业Modbus RTU需严格保序,而TCP重传可能打乱原始字节流)
- 是否需双向独立控制?(如网口侧主动断连时,串口侧能否保持open并缓存?)
- 是否要嵌入业务逻辑?(如收到
01 03 00 00 00 02 C4 0B后,自动补上设备ID再转发)
常见工具链能力边界如下(基于Linux 5.10+实测):
| 工具 | 双向实时性 | 断线重连 | 缓存策略 | 协议扩展性 | 典型适用场景 |
|---|---|---|---|---|---|
socat | ⚠️ 中等 | ✅ 原生 | ❌ 无 | ❌ 硬编码 | 快速验证、调试、非关键链路 |
ser2net | ⚠️ 中等 | ✅ 配置化 | ⚠️ 简单环形 | ⚠️ 仅支持标准串口协议 | 多客户端共享单串口(如远程console) |
| 自研Python桥接器 | ✅ 高 | ✅ 可编程 | ✅ 可定制 | ✅ 完全开放 | 工业现场、协议转换、日志审计、安全加固 |
提示:
socat的pty,link=/tmp/vserial,waitslave虽能模拟串口,但实际转发中常因缓冲区溢出导致EAGAIN;ser2net的max-connections参数在高并发下易触发accept()阻塞,需配合systemdsocket activation优化。
2.2 用 socat 跑通最小闭环:验证物理链路是否正常
这是所有后续工作的地基。务必在真实硬件上完成,而非虚拟串口(/dev/pts/*)。假设:
- 串口设备为
/dev/ttyUSB0(USB转串口适配器),波特率115200,8N1 - 目标TCP服务运行在
192.168.1.100:9000(可用nc -l 9000临时监听)
# 方案A:串口→TCP(单向,最简) socat -d -d pty,raw,echo=0,link=/tmp/vserial,waitslave \ tcp:192.168.1.100:9000,broadcast,keepalive,keepidle=30,keepintvl=10,keepcnt=3 # 方案B:双向透传(推荐初验) socat -d -d \ file:/dev/ttyUSB0,raw,echo=0,crtscts=0,nonblock,waitlock=/var/run/ttyUSB0.lock,b115200,cs8,parenb=0,stopb=1 \ tcp:192.168.1.100:9000,keepalive,keepidle=30,keepintvl=10,keepcnt=3参数说明:
crtscts=0:禁用硬件流控(多数传感器不支持RTS/CTS,开启反致阻塞)nonblock:避免read()阻塞主线程,但需配合select()轮询(socat内部已处理)keepalive三参数:TCP保活探测间隔(30秒空闲后发探针,每10秒重试,连续3次失败断连)waitlock:防止多进程同时打开同一串口(/var/run/需提前mkdir -p)
验证方法:
- 在目标机执行
nc -l 9000 | hexdump -C - 本地用
echo -ne '\x01\x03\x00\x00\x00\x02\xc4\x0b' > /dev/ttyUSB0发送Modbus请求 - 观察目标机是否收到完整8字节——若只收到前4字节,大概率是
ser2net默认的max-connections=1或socat缓冲区溢出,需进入下一章排查。
3. 自研Python桥接器:从“能通”到“可控”的核心代码
3.1 架构设计:双线程+环形缓冲+事件驱动
socat满足不了工业场景的核心诉求:
- 无法在转发前插入校验(如Modbus CRC校验失败则丢弃)
- 无法对网口断连做优雅降级(如串口数据暂存至内存队列,恢复后重发)
- 无法添加结构化日志(含时间戳、方向、长度、hex摘要)
我们采用双线程模型:
SerialReader线程:独占串口,read(1)逐字节读取,按协议规则组装完整帧(如遇到\r\n或固定长度)NetworkWriter线程:监听TCP连接,接收网口数据,解析后写入串口- 共享
collections.deque(maxlen=1000)作为环形缓冲区,避免内存无限增长
# bridge.py import serial import socket import threading import time import logging from collections import deque from typing import Optional, Tuple class SerialToTcpBridge: def __init__(self, serial_port: str = "/dev/ttyUSB0", baudrate: int = 115200, tcp_host: str = "192.168.1.100", tcp_port: int = 9000, buffer_size: int = 1024): self.serial_port = serial_port self.baudrate = baudrate self.tcp_host = tcp_host self.tcp_port = tcp_port self.buffer_size = buffer_size self.serial_conn: Optional[serial.Serial] = None self.tcp_socket: Optional[socket.socket] = None self.running = False # 环形缓冲区:存储待转发的串口数据 self.serial_to_net_buffer = deque(maxlen=buffer_size) # 环形缓冲区:存储待转发的网络数据 self.net_to_serial_buffer = deque(maxlen=buffer_size) def connect_serial(self) -> bool: try: self.serial_conn = serial.Serial( port=self.serial_port, baudrate=self.baudrate, bytesize=serial.EIGHTBITS, parity=serial.PARITY_NONE, stopbits=serial.STOPBITS_ONE, timeout=0.1, # 非阻塞读 write_timeout=0.1, rtscts=False, # 关闭硬件流控 dsrdtr=False ) logging.info(f"✅ 串口 {self.serial_port} 打开成功") return True except Exception as e: logging.error(f"❌ 串口打开失败: {e}") return False def connect_tcp(self) -> bool: try: self.tcp_socket = socket.socket(socket.AF_INET, socket.SOCK_STREAM) self.tcp_socket.setsockopt(socket.IPPROTO_TCP, socket.TCP_NODELAY, 1) # 禁用Nagle self.tcp_socket.setsockopt(socket.SOL_SOCKET, socket.SO_KEEPALIVE, 1) self.tcp_socket.setsockopt(socket.IPPROTO_TCP, socket.TCP_KEEPIDLE, 30) self.tcp_socket.setsockopt(socket.IPPROTO_TCP, socket.TCP_KEEPINTVL, 10) self.tcp_socket.setsockopt(socket.IPPROTO_TCP, socket.TCP_KEEPCNT, 3) self.tcp_socket.connect((self.tcp_host, self.tcp_port)) logging.info(f"✅ TCP连接 {self.tcp_host}:{self.tcp_port} 建立") return True except Exception as e: logging.error(f"❌ TCP连接失败: {e}") return False def serial_reader_loop(self): """串口读取线程:逐字节读取,按规则组帧""" while self.running: try: if not self.serial_conn or not self.serial_conn.is_open: time.sleep(1) continue # 一次最多读1字节,避免阻塞 data = self.serial_conn.read(1) if data: # 这里可插入协议解析逻辑:如Modbus RTU需等待3.5字符时间再收尾 self.serial_to_net_buffer.append(data) logging.debug(f"← 串口入: {data.hex()}") except Exception as e: logging.warning(f"⚠️ 串口读取异常: {e}") time.sleep(0.5) def network_writer_loop(self): """网络写入线程:从缓冲区取数据发往TCP""" while self.running: try: if not self.tcp_socket: if not self.connect_tcp(): time.sleep(2) continue # 从缓冲区批量取数据(避免频繁小包) batch = b'' while self.serial_to_net_buffer and len(batch) < 256: batch += self.serial_to_net_buffer.popleft() if batch: self.tcp_socket.sendall(batch) logging.debug(f"→ 网络出: {batch.hex()[:32]}{'...' if len(batch)>32 else ''}") # 检查TCP连接是否存活 try: self.tcp_socket.send(b'') # 空包探测 except (BrokenPipeError, ConnectionResetError): logging.warning("⚠️ TCP连接已断开,准备重连") self.tcp_socket.close() self.tcp_socket = None except Exception as e: logging.warning(f"⚠️ 网络发送异常: {e}") if self.tcp_socket: self.tcp_socket.close() self.tcp_socket = None time.sleep(1) def start(self): self.running = True if not self.connect_serial(): raise RuntimeError("串口初始化失败") # 启动线程 serial_thread = threading.Thread(target=self.serial_reader_loop, name="SerialReader") net_thread = threading.Thread(target=self.network_writer_loop, name="NetworkWriter") serial_thread.daemon = True net_thread.daemon = True serial_thread.start() net_thread.start() logging.info("🚀 桥接器启动完成") try: while self.running: time.sleep(1) except KeyboardInterrupt: self.stop() def stop(self): self.running = False if self.serial_conn: self.serial_conn.close() if self.tcp_socket: self.tcp_socket.close() logging.info("⏹️ 桥接器已停止") if __name__ == "__main__": logging.basicConfig( level=logging.DEBUG, format='%(asctime)s [%(levelname)s] %(message)s', handlers=[logging.StreamHandler()] ) bridge = SerialToTcpBridge( serial_port="/dev/ttyUSB0", baudrate=115200, tcp_host="192.168.1.100", tcp_port=9000 ) bridge.start()关键设计点说明:
timeout=0.1:串口read()设为短超时,避免线程卡死;配合while循环实现准实时读取TCP_NODELAY=1:禁用Nagle算法,确保小包(如Modbus单帧)立即发出,不等待合并deque(maxlen=N):环形缓冲区天然防内存泄漏,popleft()比list.pop(0)快10倍以上send(b'')空包探测:比select()更轻量,且能捕获ConnectionResetError
4. 避坑指南:那些让工程师凌晨三点还在抓头发的典型问题
4.1 现象:串口数据“间歇性丢失”,Wireshark看到TCP包完整,但设备无响应
原因:串口硬件流控(RTS/CTS)未关闭,而传感器不响应RTS信号,导致串口驱动在发送缓冲区满时挂起。socat默认启用crtscts=1,Pythonpyserial默认rtscts=False但部分内核驱动会强制启用。
解决:
- 确认串口设备不支持硬件流控(查手册),在
pyserial中显式设置rtscts=False - Linux层面禁用:
stty -F /dev/ttyUSB0 -crtscts - 若必须用流控,改用软件XON/XOFF:
stty -F /dev/ttyUSB0 ixon ixoff
4.2 现象:TCP连接建立后,串口数据持续涌入,但网口侧只收到首包,后续无数据
原因:TCP Nagle算法将多个小包合并,而Modbus RTU帧长固定(如8字节),合并后破坏帧结构。socat默认启用Nagle,pyserial的write()无此问题,但socket.send()有。
解决:
socket.setsockopt(socket.IPPROTO_TCP, socket.TCP_NODELAY, 1)(代码中已体现)- 验证:
ss -i查看连接的n标志(n表示Nagle启用,-n表示禁用)
4.3 现象:系统重启后,桥接器启动失败,报错/dev/ttyUSB0: Permission denied
原因:udev规则未配置,普通用户无串口设备权限;或/dev/ttyUSB0设备名动态变化(插拔后变/dev/ttyUSB1)。
解决:
# 创建udev规则固定设备名并赋权 echo 'SUBSYSTEM=="tty", ATTRS{idVendor}=="0403", ATTRS{idProduct}=="6001", SYMLINK+="sensor_uart", MODE="0666"' | sudo tee /etc/udev/rules.d/99-usb-serial.rules sudo udevadm control --reload-rules sudo udevadm trigger # 之后用 /dev/sensor_uart 替代 /dev/ttyUSB04.4 现象:高负载下(CPU>80%),串口数据出现乱码或重复
原因:Python GIL导致serial.read()和socket.send()竞争,或time.sleep()精度不足(Linux默认调度周期10ms)。
解决:
- 将串口读取改为
select()轮询(serial对象支持fileno()):
import select # 在serial_reader_loop中替换read逻辑: ready, _, _ = select.select([self.serial_conn.fileno()], [], [], 0.01) if ready: data = self.serial_conn.read(1) # 此时read不会阻塞- 或改用
asyncio+pyserial-asyncio库,彻底规避GIL(但需重构为协程)
4.5 现象:网口侧断连后重连,串口侧发送的数据全部丢失
原因:缓冲区设计为deque,但未区分“已发送”和“待发送”状态,重连时旧缓冲被清空。
解决:
- 引入双缓冲:
pending_buffer(已读未发) +sent_buffer(已发待ACK) - TCP重连后,从
pending_buffer重发(需协议层支持重传标识,如Modbus事务ID) - 更稳妥做法:串口数据落盘(SQLite WAL模式),确保不丢
5. 进阶技巧:给转发器加上“工业级心跳”与协议感知能力
5.1 添加Modbus RTU帧识别:从“字节搬运工”升级为“协议翻译官”
纯透传无法应对Modbus RTU的特殊要求:
- 帧间需3.5字符间隔(如115200波特率下≈3.5×10×1000/115200≈0.3ms)
- CRC16校验失败需丢弃整帧
- 功能码03/04读寄存器需返回固定格式
我们在serial_reader_loop中插入帧识别逻辑:
# 在SerialToTcpBridge类中新增方法 def _parse_modbus_rtu_frame(self, raw_bytes: bytes) -> Optional[bytes]: """解析Modbus RTU帧:返回有效帧或None""" if len(raw_bytes) < 4: # 最小帧:slave_id + func + crc_lo + crc_hi return None # 检查CRC(使用modbus_tk库或自实现) try: from modbus_tk import modbus_rtu # modbus_tk的CRC校验 crc = modbus_rtu.calculate_crc(raw_bytes[:-2]) if crc != int.from_bytes(raw_bytes[-2:], 'little'): logging.warning(f"❌ Modbus CRC校验失败: {raw_bytes.hex()}") return None except ImportError: pass # 无modbus_tk时跳过校验 # 检查帧间间隔:若上一帧结束到当前字节超过3.5字符时间,则为新帧起始 # (此处简化:按固定长度判断,实际需记录时间戳) if raw_bytes[1] in (0x03, 0x04, 0x06, 0x10): # 常见功能码 frame_len = { 0x03: 5, 0x04: 5, 0x06: 8, 0x10: 9 + raw_bytes[6] # 写多个寄存器 }.get(raw_bytes[1], 0) if len(raw_bytes) >= frame_len: return raw_bytes[:frame_len] return None # 修改serial_reader_loop中的data处理逻辑: # ... if data: # 累积到缓冲区 self.raw_serial_buffer += data # 尝试解析完整帧 frame = self._parse_modbus_rtu_frame(self.raw_serial_buffer) if frame: self.serial_to_net_buffer.append(frame) self.raw_serial_buffer = self.raw_serial_buffer[len(frame):] # 截断已解析部分 logging.debug(f"📦 解析Modbus帧: {frame.hex()}")5.2 实现“断网缓存+断电续传”:用SQLite做持久化缓冲
当网口长期离线,内存缓冲区(deque)会溢出。工业场景要求数据不丢,需落盘:
import sqlite3 import os class PersistentBuffer: def __init__(self, db_path: str = "/var/lib/bridge/queue.db"): self.db_path = db_path os.makedirs(os.path.dirname(db_path), exist_ok=True) self.init_db() def init_db(self): with sqlite3.connect(self.db_path) as conn: conn.execute(""" CREATE TABLE IF NOT EXISTS send_queue ( id INTEGER PRIMARY KEY AUTOINCREMENT, direction TEXT NOT NULL, -- 'serial_to_net' or 'net_to_serial' data BLOB NOT NULL, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, sent BOOLEAN DEFAULT 0 ) """) conn.execute("CREATE INDEX IF NOT EXISTS idx_sent ON send_queue(sent)") def push(self, direction: str, data: bytes): with sqlite3.connect(self.db_path) as conn: conn.execute( "INSERT INTO send_queue (direction, data) VALUES (?, ?)", (direction, data) ) def pop_batch(self, limit: int = 100) -> list: with sqlite3.connect(self.db_path) as conn: cursor = conn.execute( "SELECT id, data FROM send_queue WHERE sent = 0 ORDER BY created_at LIMIT ?", (limit,) ) rows = cursor.fetchall() if rows: ids = tuple(r[0] for r in rows) conn.execute( f"UPDATE send_queue SET sent = 1 WHERE id IN ({','.join('?'*len(ids))})", ids ) return [r[1] for r in rows] # 在bridge中集成: self.buffer = PersistentBuffer("/var/lib/bridge/serial_to_net.db") # 替换原deque操作: # self.serial_to_net_buffer.append(data) → self.buffer.push("serial_to_net", data) # 从缓冲区取数据:batch = self.buffer.pop_batch(256)注意:SQLite WAL模式(
PRAGMA journal_mode=WAL)支持高并发读写,避免INSERT阻塞SELECT。
5.3 监控与告警:用Prometheus暴露关键指标
运维不能靠tail -f日志。暴露以下指标供Prometheus抓取:
bridge_serial_bytes_total{direction="in"}:串口接收总字节数bridge_tcp_errors_total{type="connect_fail"}:TCP连接失败次数bridge_buffer_length{buffer="serial_to_net"}:当前缓冲区长度
# 使用 prometheus_client from prometheus_client import Counter, Gauge, start_http_server class BridgeMetrics: def __init__(self): self.serial_in_bytes = Counter('bridge_serial_bytes_total', 'Serial bytes received', ['direction']) self.tcp_errors = Counter('bridge_tcp_errors_total', 'TCP errors', ['type']) self.buffer_len = Gauge('bridge_buffer_length', 'Current buffer length', ['buffer']) def record_serial_in(self, n: int): self.serial_in_bytes.labels(direction='in').inc(n) # 在bridge中初始化: self.metrics = BridgeMetrics() # 在serial_reader_loop中: self.metrics.record_serial_in(len(data)) # 在network_writer_loop中更新buffer_len: self.metrics.buffer_len.labels(buffer='serial_to_net').set(len(self.serial_to_net_buffer))启动HTTP服务:start_http_server(8000),访问http://localhost:8000/metrics即可看到指标。
我干过最血泪的经验是:在某次现场交付中,客户要求“绝对不能丢Modbus帧”,我自信用了内存缓冲+重连,结果断网2小时后恢复,发现缓冲区溢出丢了37帧。后来咬牙上了SQLite WAL+定期fsync,再没翻过车。工业场景里,“能通”只是起点,“不丢、不错、可查、可退”才是交付线。希望帮到你。
本文还有配套的精品资源,点击获取