简介:这是一份面向嵌入式与工业控制方向开发者的串口与网口数据互转实现代码,聚焦串口通信、Socket网络编程与select多路复用I/O模型,解决上下位机之间跨接口数据交换的实际问题。资源包共5个文件,约4KB,以C语言源码为主,包含2个.c实现文件、1个.h头文件、1个Makefile构建脚本及1份说明文档,结构精简,便于直接编译验证与二次移植。内容围绕串口参数配置、TCP/UDP套接字收发、select同时监听串口与网络描述符、多线程并发转发及异常处理等关键环节展开,并涉及MODBUS RTU与网络协议转换的工程思路。目前已有1706人学习下载,适合希望理解底层通信机制、搭建数据转发原型或进行课程设计的读者参考,可据此快速掌握串口与网口双向透传的核心实现与排错方向。
1. 串口网口互转:一个把 COM 口和 TCP 连接打通的转发工具
车间里一台老设备只有 RS232 串口,上位机却只认 TCP 连接,这种场景我遇到过不止一次。要么换设备,要么加一台工控机跑转发程序,前者成本高,后者其实几十行代码就能解决。这个项目做的就是这件事:把串口收到的字节流原样推到网络端口,把网络端口收到的数据原样写回串口,双向透明传输,不解析协议、不改数据内容。适合手里有串口设备但需要远程访问的开发者,也适合想学网络编程和串口通信怎么对接的新手。技术栈是 C 语言加 Socket 编程,Windows 下用 Win32 API 操作 COM 口,Linux 下用 termios 配置 tty,核心逻辑一套代码跨平台编译。关键词 com/ip/c 对应的就是串口、网络、C 语言这三个点,下面从架构到代码逐层拆开讲。
2. 架构拆解:两个线程、两个缓冲区、一条数据通路
2.1 整体数据流与线程模型
这个项目的架构可以用一句话概括:串口和网络各占一个线程,每个线程负责一个方向的读取和写入。串口线程不断从 COM 口读数据,读到之后调用 send 发到 TCP 连接;网络线程不断从 socket 读数据,读到之后调用 write 写到串口。两个线程之间不需要共享缓冲区,也不需要加锁,因为数据流是单向的,各走各的路。
为什么用两个线程而不是单线程 select 轮询?串口读和网络读的阻塞行为不一样。串口读在没数据时会阻塞,网络读在没数据时也会阻塞,如果放在一个线程里用 select 同时监听,代码复杂度会上升,而且串口在某些平台下 select 支持不好。两个线程各管一路,逻辑清晰,调试也方便。常见做法是主线程负责初始化和等待,两个工作线程分别跑串口到网络、网络到串口的数据搬运。
线程之间唯一的共享状态是一个运行标志位,主线程收到退出信号后把这个标志置为 0,两个工作线程检测到标志变化就退出循环。这个标志用 volatile 修饰,防止编译器优化导致死循环。
2.2 串口初始化:Windows 和 Linux 的差异处理
串口初始化是整个项目里最容易翻车的地方,因为 Windows 和 Linux 的 API 完全不同。Windows 用 CreateFile 打开 COM 口,用 DCB 结构体配置波特率、数据位、停止位、校验位,用 SetCommTimeouts 设置读写超时。Linux 用 open 打开 /dev/ttyS0 或 /dev/ttyUSB0,用 termios 结构体配置同样的参数,用 cfsetispeed 和 cfsetospeed 设置波特率。
下面这段代码是跨平台的串口打开函数,用宏区分两个平台:
#ifdef _WIN32 #include <windows.h> HANDLE open_serial(const char *port, int baud) { HANDLE h = CreateFileA(port, GENERIC_READ | GENERIC_WRITE, 0, NULL, OPEN_EXISTING, 0, NULL); if (h == INVALID_HANDLE_VALUE) return INVALID_HANDLE_VALUE; DCB dcb = {0}; dcb.DCBlength = sizeof(dcb); GetCommState(h, &dcb); dcb.BaudRate = baud; // 波特率,如 9600、115200 dcb.ByteSize = 8; // 数据位 8 dcb.StopBits = ONESTOPBIT;// 停止位 1 dcb.Parity = NOPARITY; // 无校验 SetCommState(h, &dcb); COMMTIMEOUTS to = {0}; to.ReadIntervalTimeout = 50; // 读间隔超时 50ms to.ReadTotalTimeoutConstant = 100; SetCommTimeouts(h, &to); return h; } #else #include <termios.h> #include <fcntl.h> #include <unistd.h> int open_serial(const char *port, int baud) { int fd = open(port, O_RDWR | O_NOCTTY | O_NDELAY); if (fd < 0) return -1; struct termios opt; tcgetattr(fd, &opt); cfsetispeed(&opt, baud); // 输入波特率 cfsetospeed(&opt, baud); // 输出波特率 opt.c_cflag |= (CLOCAL | CREAD); opt.c_cflag &= ~CSIZE; opt.c_cflag |= CS8; // 8 数据位 opt.c_cflag &= ~PARENB; // 无校验 opt.c_cflag &= ~CSTOPB; // 1 停止位 opt.c_lflag &= ~(ICANON | ECHO | ECHOE | ISIG); opt.c_oflag &= ~OPOST; tcsetattr(fd, TCSANOW, &opt); return fd; } #endifWindows 这边关键参数是 DCB 里的 BaudRate、ByteSize、StopBits、Parity,分别对应波特率、数据位、停止位、校验位。COMMTIMEOUTS 里的 ReadIntervalTimeout 决定两次读之间最大间隔,超过这个时间没新数据就返回,避免线程卡死。Linux 这边 termios 的 c_cflag 控制帧格式,c_lflag 关掉行缓冲和回显,c_oflag 关掉输出处理,保证原始字节流透传。
注意波特率在 Linux 下要用 B9600、B115200 这种常量,不能直接传整数。我一般会写一个映射函数把 9600 转成 B9600,115200 转成 B115200,避免调用方传错。
2.3 网络端初始化:TCP 服务端与客户端的取舍
网络端有两种模式:作为 TCP 服务端等待上位机连接,或者作为 TCP 客户端主动连接远程服务器。这个项目默认实现的是服务端模式,因为串口设备通常放在本地,上位机从远处连过来更符合实际部署。服务端流程是 socket、bind、listen、accept 四步,accept 返回的 fd 就是和上位机通信的句柄。
int start_tcp_server(int port) { int listen_fd = socket(AF_INET, SOCK_STREAM, 0); int opt = 1; setsockopt(listen_fd, SOL_SOCKET, SO_REUSEADDR, &opt, sizeof(opt)); struct sockaddr_in addr = {0}; addr.sin_family = AF_INET; addr.sin_addr.s_addr = INADDR_ANY; // 监听所有网卡 addr.sin_port = htons(port); // 端口号,如 5000 bind(listen_fd, (struct sockaddr *)&addr, sizeof(addr)); listen(listen_fd, 1); // backlog 设为 1,只允许一个客户端 return accept(listen_fd, NULL, NULL); // 阻塞等待上位机接入 }SO_REUSEADDR 是为了程序重启时端口能立刻复用,不然会报 Address already in use。backlog 设为 1 表示只接受一个连接,因为串口是独占资源,多个上位机同时连会互相干扰。如果确实需要多客户端,得加一个连接管理逻辑,但那就超出这个项目的范围了。
accept 返回之后,网络 fd 就可以和串口 fd 一起进入数据搬运循环。这里有个细节:accept 是阻塞的,如果上位机一直不连,程序就卡在这一步。常见做法是把 accept 放在主线程,连上之后再启动两个工作线程,这样主线程还能响应退出信号。
3. 核心转发逻辑:read/write 循环怎么写才不丢数据
3.1 串口到网络方向的搬运代码
串口到网络方向的逻辑是:从串口 fd 读数据,读到之后原样 send 到网络 fd。看起来简单,但读的时机和缓冲区大小有讲究。
#define BUF_SIZE 1024 void *serial_to_net(void *arg) { thread_arg_t *ta = (thread_arg_t *)arg; unsigned char buf[BUF_SIZE]; while (g_running) { int n = read(ta->serial_fd, buf, BUF_SIZE); if (n > 0) { int sent = 0; while (sent < n) { // 确保全部发完 int ret = send(ta->net_fd, buf + sent, n - sent, 0); if (ret <= 0) { g_running = 0; break; } sent += ret; } } else if (n < 0) { // 读错误,可能是设备拔出 break; } } return NULL; }read 返回实际读到的字节数,可能小于 BUF_SIZE,这很正常,串口数据是流式的,不会按固定长度到达。send 也不保证一次发完所有数据,所以要用 while 循环确保 sent 累加到 n。如果 send 返回小于等于 0,说明网络断了,把 g_running 置 0 让另一个线程也退出。
缓冲区大小 1024 是个经验值,波特率 115200 时每秒最多 11520 字节,1024 够用。如果波特率更高或者数据突发性强,可以调到 4096。但不要太大,栈上分配大数组有溢出风险,我一般用 static 或者 malloc。
3.2 网络到串口方向的搬运代码
反方向的逻辑对称,从网络 fd 读,写到串口 fd:
void *net_to_serial(void *arg) { thread_arg_t *ta = (thread_arg_t *)arg; unsigned char buf[BUF_SIZE]; while (g_running) { int n = recv(ta->net_fd, buf, BUF_SIZE, 0); if (n > 0) { int written = 0; while (written < n) { int ret = write(ta->serial_fd, buf + written, n - written); if (ret <= 0) { g_running = 0; break; } written += ret; } } else if (n <= 0) { // 连接关闭或出错 g_running = 0; break; } } return NULL; }recv 返回 0 表示对端正常关闭连接,返回负数表示出错,两种情况都退出循环。write 到串口一般不会阻塞太久,因为串口发送速度由波特率决定,如果上位机发得太快,write 会阻塞直到发送缓冲区有空位,这是正常的背压机制。
两个线程都退出后,主线程负责关闭 fd 和释放资源。关闭顺序是先关网络再关串口,因为网络关闭会触发对端感知,串口关闭则直接释放设备。
3.3 超时与异常断开处理
串口读如果一直没数据,read 会阻塞,线程就卡在那。Windows 下靠 COMMTIMEOUTS 的 ReadIntervalTimeout 解决,超过 50ms 没新数据就返回 0。Linux 下可以在 open 时加 O_NONBLOCK 变成非阻塞,然后用 select 或 poll 加超时,但这样代码会复杂一些。我一般用阻塞模式加信号处理,收到 SIGINT 时置 g_running 为 0,线程自然退出。
网络断开的情况更常见。如果上位机拔网线或者程序崩溃,send 会返回 -1,errno 是 EPIPE 或 ECONNRESET。这时候要把 g_running 置 0,让串口线程也退出,然后主线程重新 accept 等待新连接。如果希望自动重连,可以在主线程加一个外层循环,accept 返回后启动线程,线程退出后回到 accept 继续等。
注意:串口设备拔出时 read 可能返回 -1 而不是 0,errno 是 EIO。这时候不要反复重试,直接退出让用户检查硬件。
4. 编译运行与参数配置:从源码到可执行文件
4.1 编译命令与依赖说明
这个项目没有第三方依赖,只用系统 API,所以编译很简单。Linux 下用 gcc:
gcc -o serial_bridge main.c serial.c network.c -lpthreadWindows 下用 MinGW 或者 MSVC:
gcc -o serial_bridge.exe main.c serial.c network.c -lws2_32 -lpthread-lpthread 是链接线程库,Windows 下还要加 -lws2_32 链接 Winsock 库。如果用 MSVC,把源文件加到项目里直接编译就行,不需要额外配置。
4.2 命令行参数与配置文件
程序启动时需要指定串口名、波特率、监听端口。我一般用命令行参数传:
./serial_bridge --serial /dev/ttyUSB0 --baud 115200 --port 5000Windows 下串口名是 COM1、COM3 这种:
serial_bridge.exe --serial COM3 --baud 9600 --port 5000参数解析用 getopt(Linux)或手写循环(Windows),不复杂。如果参数多,可以加一个配置文件,用 key=value 格式,启动时读进来覆盖默认值。常见做法是默认串口 /dev/ttyS0、波特率 9600、端口 5000,用户不传参数就用默认值。
4.3 验证转发是否成功
验证分两步。先确认串口能收到数据,用 minicom 或 putty 打开串口看有没有输出。再确认网络能连通,用 telnet 或 nc 连上监听端口:
nc 127.0.0.1 5000如果串口有数据,nc 这边应该能看到同样的内容。反过来,在 nc 里输入字符,串口设备也应该收到。如果串口设备是回显模式,nc 里输入什么就能看到什么,说明双向通路都正常。
如果没数据,先检查串口名对不对,Linux 下用 dmesg | grep tty 看设备有没有识别,Windows 下在设备管理器里看 COM 口号。再检查波特率是否匹配,两边不一致会收到乱码。最后检查防火墙有没有挡住监听端口。
5. 避坑与排查:串口转发最容易翻车的五个地方
5.1 现象:收到乱码,偶尔夹杂几个正常字符
原因:波特率不匹配,或者数据位、停止位、校验位配置不一致。串口通信对帧格式极其敏感,一端 115200 另一端 9600,收到的就是垃圾数据。
解决:确认两端波特率完全一致,用示波器或者串口调试助手看波形。数据位默认 8,停止位默认 1,校验位默认无,如果设备手册写了不同参数,代码里要对应修改。
5.2 现象:程序启动报 Permission denied
原因:Linux 下普通用户没有权限打开 /dev/ttyS0 或 /dev/ttyUSB0。
解决:把用户加到 dialout 组,或者用 sudo 运行。命令是 sudo usermod -a -G dialout $USER,然后重新登录生效。Windows 下一般不会有权限问题,除非串口被其他程序占用。
5.3 现象:网络连上了但收不到数据
原因:串口线程阻塞在 read 上,因为串口没数据。或者网络线程阻塞在 recv 上,因为上位机没发数据。这是正常行为,不是 bug。
解决:先确认串口设备确实在发数据,用串口调试助手单独验证。再确认上位机确实在发数据,用 nc 手动发几个字符测试。如果两边都有数据但转发不通,检查 fd 是否被意外关闭。
5.4 现象:程序运行一段时间后自动退出
原因:网络断开导致 send 返回 -1,g_running 被置 0,两个线程都退出。或者串口设备被拔出,read 返回 -1。
解决:在主线程加外层循环,线程退出后重新 accept 等待新连接。串口设备拔出后需要重新打开,可以在主线程检测到串口错误后重新初始化。如果希望程序常驻,加一个守护逻辑,异常退出后自动重启。
5.5 现象:高波特率下数据丢失
原因:缓冲区太小,或者读写速度跟不上。115200 波特率下每秒 11520 字节,如果缓冲区只有 256 字节,频繁系统调用会导致丢数据。
解决:把 BUF_SIZE 调到 4096 或 8192,减少系统调用次数。如果还丢,检查 CPU 占用率,可能是线程调度延迟。Linux 下可以用 sched_setscheduler 提高线程优先级,Windows 下用 SetThreadPriority。
6. 二次开发:从透明转发到协议解析的改造思路
这个项目最直接的改造方向是在转发链路上加协议解析。现在的逻辑是 read 到什么就 send 什么,如果串口设备用的是 Modbus RTU,可以在 serial_to_net 线程里加一个解析步骤,把 RTU 帧转成 Modbus TCP 帧再发出去。反过来,网络收到的 Modbus TCP 帧转成 RTU 帧写到串口。这样上位机就能用标准 Modbus TCP 协议直接访问串口设备,不需要额外的协议转换网关。
改造的关键是找到帧边界。Modbus RTU 用 3.5 个字符时间的静默间隔作为帧分隔,在 115200 波特率下大约是 300 微秒。代码里可以用一个定时器或者状态机来检测帧结束。下面是一个简化的状态机框架:
typedef enum { WAIT_START, IN_FRAME, FRAME_END } frame_state_t; void process_serial_data(unsigned char *buf, int len) { static frame_state_t state = WAIT_START; static unsigned char frame[256]; static int frame_len = 0; static unsigned long last_byte_time = 0; for (int i = 0; i < len; i++) { unsigned long now = get_ms(); if (now - last_byte_time > 4) { // 超过 4ms 认为新帧开始 if (frame_len > 0) { handle_frame(frame, frame_len); // 处理完整帧 frame_len = 0; } } frame[frame_len++] = buf[i]; last_byte_time = now; } }get_ms 返回毫秒时间戳,4ms 阈值对应 115200 波特率下的 3.5 字符时间。handle_frame 里做协议转换,把 RTU 帧的 CRC 校验去掉,加上 MBAP 头变成 TCP 帧。反方向类似,去掉 MBAP 头,加 CRC 校验,写到串口。
另一个改造方向是加数据日志。在 send 和 write 之前把数据写到文件或者数据库,方便事后排查。日志格式可以是时间戳加十六进制内容,用 fprintf 写文本文件就行。如果数据量大,用二进制格式更省空间。
验证改造是否成功,我一般用两个工具:串口调试助手模拟设备发数据,Modbus Poll 模拟上位机收数据。如果 Modbus Poll 能正常读到寄存器值,说明协议转换正确。如果读不到,先用 Wireshark 抓包看 TCP 帧格式对不对,再用串口助手看 RTU 帧有没有发出去。
从那以后我每次改转发逻辑,都强制走一遍「串口助手发固定帧、Wireshark 抓包、对比输入输出」的流程,不靠感觉判断。希望帮到你。
本文还有配套的精品资源,点击获取