简介:这是一份面向嵌入式与工业控制方向开发者的串口与网口数据互转实现代码,聚焦下位机与上位机之间的双向通信场景。资源包共5个文件,约4KB,以C语言源码为主体,包含核心实现文件、头文件与Makefile构建脚本,另附一份说明文档,便于快速编译与理解整体结构。代码围绕select多路复用同时监听串口与网络套接字,结合多线程分别处理两个方向的数据收发,并涉及串口参数配置、Socket编程、线程同步与异常处理等关键环节,可作为协议转换与远程监控类项目的参考骨架。目前已有1706人学习下载,适合希望掌握底层通信编程、理解I/O模型选择与并发数据转发思路的读者研读与二次开发。
1. 串口网口互转:一个把 RS232 数据搬上 TCP 的轻量网关
车间里那台老 PLC 只有 DB9 串口,上位机却跑在另一栋楼的服务器上,中间隔着交换机和几堵墙。这种场景下,最省事的做法不是换设备,而是在中间架一个协议转换网关——把串口收到的字节流原封不动塞进 TCP 连接,再把网络侧下发的指令写回串口。这份资源做的就是这件事:一个 com/ip/c 三端互转的小工具,串口侧支持 RS232/RS485,网络侧支持 TCP Server、TCP Client 两种模式,C 语言实现,编译出来就是一个可执行文件,扔到工控机或树莓派上就能跑。适合谁?手上有一堆串口设备又不想改硬件、需要快速打通「串口到网络」这条链路的嵌入式开发和工控集成人员。它不解决协议解析,只解决数据搬运,定位非常清楚。
2. 架构拆解:串口线程与网络线程怎么各管一头
2.1 双线程模型与数据流向
这个项目的核心结构是「两个线程 + 两个缓冲区」。串口线程负责read串口数据并写入网络发送缓冲区,网络线程负责recvTCP 数据并写入串口发送缓冲区。两个线程之间不直接调用对方的读写函数,而是通过环形缓冲区解耦,避免串口阻塞拖死网络收发。
常见做法是主线程只做初始化和信号等待,真正干活的是serial_thread和net_thread。串口线程的伪代码逻辑如下:
// 串口线程:读串口 -> 写网络缓冲区 void *serial_thread(void *arg) { unsigned char buf[1024]; while (g_running) { // 阻塞读,超时 100ms,避免线程无法退出 int n = serial_read(g_serial_fd, buf, sizeof(buf), 100); if (n > 0) { // 写入网络发送环形缓冲区,满则丢弃并计数 if (ringbuf_write(&net_tx_rb, buf, n) < 0) { g_drop_count++; // 丢包计数,排查用 } } } return NULL; }serial_read内部封装了select+read,超时参数 100ms 是关键——没有超时,线程收到退出信号也卡在read上退不出来。ringbuf_write返回负值说明缓冲区满,这时候丢数据比阻塞线程更合理,因为串口侧通常不能停。
网络线程对称处理,recv到的数据写进serial_tx_rb,由串口线程的另一个分支负责取出并write到串口。两个方向完全独立,互不等待。
2.2 环形缓冲区的实现要点
环形缓冲区是这份代码里最值得看的部分,因为它决定了高波特率下会不会丢数据。结构体定义大致如下:
typedef struct { unsigned char *buf; int size; // 缓冲区总大小,建议 4096 或 8192 int head; // 写指针 int tail; // 读指针 pthread_mutex_t lock; } ringbuf_t; // 写入:返回实际写入字节数,-1 表示空间不足 int ringbuf_write(ringbuf_t *rb, const unsigned char *data, int len) { pthread_mutex_lock(&rb->lock); int free = (rb->tail - rb->head - 1 + rb->size) % rb->size; if (free < len) { pthread_mutex_unlock(&rb->lock); return -1; // 空间不够,调用方决定丢弃还是等待 } // 分两段拷贝,处理回绕 int first = rb->size - rb->head; if (first > len) first = len; memcpy(rb->buf + rb->head, data, first); memcpy(rb->buf, data + first, len - first); rb->head = (rb->head + len) % rb->size; pthread_mutex_unlock(&rb->lock); return len; }size取 4096 是经验值:115200 波特率下每秒约 11.5KB 数据,4096 的缓冲区能扛住 300ms 左右的网络抖动。如果现场网络延迟大,把size调到 8192 或 16384,代价只是多占几 KB 内存。head和tail的回绕用取模处理,first变量负责跨边界时的两段拷贝,这是环形缓冲区最容易写错的地方——少拷贝一段就会出现数据错位,而且往往在跑了几小时才暴露。
2.3 TCP Server 与 TCP Client 的切换
网络侧两种模式通过配置文件或命令行参数切换。TCP Server 模式下,程序bind本地端口listen,等上位机来连;TCP Client 模式下,程序主动connect到远端服务器。两种模式共用同一套收发逻辑,区别只在连接建立阶段。
// 网络初始化:根据模式选择 server 或 client int net_init(const char *mode, const char *ip, int port) { int fd = socket(AF_INET, SOCK_STREAM, 0); // 设置 SO_REUSEADDR,避免重启时端口被占用 int opt = 1; setsockopt(fd, SOL_SOCKET, SO_REUSEADDR, &opt, sizeof(opt)); struct sockaddr_in addr; memset(&addr, 0, sizeof(addr)); addr.sin_family = AF_INET; addr.sin_port = htons(port); if (strcmp(mode, "server") == 0) { addr.sin_addr.s_addr = INADDR_ANY; bind(fd, (struct sockaddr *)&addr, sizeof(addr)); listen(fd, 1); // 只允许一个客户端,够用 return accept(fd, NULL, NULL); // 阻塞等待连接 } else { addr.sin_addr.s_addr = inet_addr(ip); if (connect(fd, (struct sockaddr *)&addr, sizeof(addr)) < 0) { close(fd); return -1; // 连接失败,上层重试 } return fd; } }listen的 backlog 设成 1 是有意为之:这个网关只服务一个上位机,多客户端接入反而会让串口数据被瓜分。SO_REUSEADDR必须加,否则程序异常退出后端口会处于 TIME_WAIT,重启时报「Address already in use」,这是现场调试时最常见的翻车点之一。Client 模式下connect失败返回 -1,上层用一个重试循环每隔 3 秒重连,保证网络恢复后能自动接上。
3. 跑起来:从编译到串口回环验证
3.1 编译与依赖
项目是纯 C 实现,依赖只有 pthread 和标准 socket 库,Linux 下直接gcc就能编。Makefile 里核心规则如下:
# 编译:-lpthread 链接线程库,-Wall 打开警告 gcc -o serial_net_gw main.c serial.c net.c ringbuf.c -lpthread -Wall -O2 # 交叉编译到 ARM 工控机(示例工具链前缀) arm-linux-gnueabihf-gcc -o serial_net_gw main.c serial.c net.c ringbuf.c -lpthread -O2-O2建议保留,环形缓冲区的取模运算在优化后会快不少。交叉编译时把gcc换成对应工具链前缀即可,代码里没有平台相关调用,termios和 socket 在 ARM Linux 上行为一致。
3.2 串口参数配置
串口初始化是跑通的第一步,参数不对就是收不到数据。核心配置如下:
// 串口初始化:8N1,无流控,原始模式 int serial_open(const char *dev, int baud) { int fd = open(dev, 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); // 忽略 modem 控制线,允许接收 opt.c_cflag &= ~CSIZE; opt.c_cflag |= CS8; // 8 数据位 opt.c_cflag &= ~PARENB; // 无校验 opt.c_cflag &= ~CSTOPB; // 1 停止位 opt.c_cflag &= ~CRTSCTS; // 无硬件流控 opt.c_lflag &= ~(ICANON | ECHO | ECHOE | ISIG); // 原始模式 opt.c_iflag &= ~(IXON | IXOFF | IXANY); // 无软件流控 opt.c_oflag &= ~OPOST; // 原始输出 tcsetattr(fd, TCSANOW, &opt); return fd; }O_NOCTTY防止串口成为控制终端,O_NDELAY配合后面的select使用。CLOCAL | CREAD是必须的,少了CREAD串口根本收不到数据。ICANON必须关掉,否则串口会按行缓冲,二进制数据里的0x0A会被当成换行截断——这个坑我在调试 Modbus 数据时踩过,现象是数据总是缺一段,查了半天才发现是行模式在作怪。
3.3 回环测试验证
不接真实设备也能验证链路。用socat建一对虚拟串口,或者直接短接串口的 TX 和 RX 做硬件回环:
# 建一对虚拟串口,pty 模式 socat -d -d pty,raw,echo=0 pty,raw,echo=0 # 输出会显示两个设备路径,例如 /dev/pts/3 和 /dev/pts/4 # 启动网关,串口接 /dev/pts/3,网络开 server 模式监听 5000 ./serial_net_gw -d /dev/pts/3 -b 115200 -m server -p 5000 # 另一个终端用 nc 连上去,发数据 nc 127.0.0.1 5000 # 在 nc 里输入字符,同时用 cat 读 /dev/pts/4 验证串口侧收到 cat /dev/pts/4如果cat能看到nc里输入的内容,说明网络到串口方向通了;反过来在/dev/pts/4里echo数据,nc窗口能看到,说明串口到网络方向也通了。两个方向都通,链路就算跑起来了。注意socat建的虚拟串口波特率参数是摆设,真实串口才需要严格匹配。
4. 避坑与排查:现场最容易翻车的五个点
4.1 串口能打开但收不到数据
现象是程序不报错,open返回正常,但串口线程一直读不到字节。原因通常是termios配置里漏了CREAD,或者c_cflag被整体赋值覆盖了默认位。解决方法是按 3.2 的写法用|=和&=逐位操作,不要直接opt.c_cflag = CS8。另外确认串口线是交叉线(TX 接 RX),直连线在 RS232 下收发是反的。
4.2 网络断线后程序卡死
现象是拔掉网线再插上,程序不再转发数据。原因是send在连接断开后会返回 -1 并置EPIPE,如果代码没处理,线程可能卡在send或进入死循环。解决办法是给send加MSG_NOSIGNAL标志避免进程被 SIGPIPE 杀掉,同时检测返回值,出错就关闭 socket 并触发重连。Client 模式下重连逻辑必须有,Server 模式下要重新accept。
4.3 高波特率下数据丢包
现象是 115200 以上波特率时,接收端偶尔少几个字节。原因多半是环形缓冲区太小或read的缓冲区太小。把ringbuf的size调到 8192,serial_read的buf从 1024 提到 4096,基本能解决。如果还丢,检查是不是在串口线程里做了耗时操作(比如打日志到磁盘),把日志改成异步或降级。
4.4 程序退出后端口被占用
现象是Ctrl+C停掉程序后立刻重启,报bind: Address already in use。原因是 socket 处于 TIME_WAIT 状态,而代码没设SO_REUSEADDR。解决方法是 2.3 里那样在bind前setsockopt设置SO_REUSEADDR。串口侧如果报Device or resource busy,检查是不是有别的进程(比如minicom)还占着串口。
4.5 二进制数据被截断
现象是传输含0x0A、0x0D的数据时,接收端收到的内容不完整。原因是串口没配成原始模式,ICANON或ICRNL还在生效,终端驱动把回车换行做了转换。解决方法是确保c_lflag清掉ICANON | ECHO | ISIG,c_iflag清掉ICRNL | INLCR | IGNCR。这三个标志是二进制透传的底线,少清一个都会出玄学问题。
5. 二次开发:加协议解析和断线重连的改造思路
拿到这份代码后,最常见的改造需求有两个:一是加协议解析,二是加断线自动重连。这两个改动都不大,但改的位置要对。
先说协议解析。现在的代码是纯透传,串口收到什么就发什么。如果要接 Modbus RTU 转 Modbus TCP,不能直接在串口线程里解析,因为解析需要等一帧完整数据,会阻塞串口读取。正确做法是在串口线程和网络缓冲区之间插一个解析层:串口线程照常读,读到的数据先喂给解析器,解析器攒够一帧后把整帧写进网络缓冲区。解析器可以做成状态机,按功能码和长度字段判断帧边界。改造后的数据流是「串口 read → 解析器 feed → 帧完整则 ringbuf_write → 网络 send」。解析器本身不碰 IO,纯内存操作,不会拖慢串口线程。
再说断线重连。Client 模式下现在的connect失败只返回 -1,上层没有重试。改造时把网络初始化拆成「建连」和「重连」两个函数,主循环里检测 socket 状态,断开就每 3 秒重试一次。重试期间串口数据不能丢,所以serial_tx_rb要继续攒着,等连上后一次性发出去。这里有个细节:重连成功后要先清空net_tx_rb里的旧数据,否则会把断线期间的历史数据一股脑推给上位机,造成数据错乱。我一般会在重连成功后加一个ringbuf_reset(&net_tx_rb)。
验证改造是否成功,用 3.3 的回环测试就行。协议解析的验证可以造一帧标准 Modbus 报文,用nc发进去,看串口侧cat出来的字节是否和原始帧一致。断线重连的验证更简单:跑起来后拔网线,等 10 秒插回去,看数据是否恢复转发,同时观察日志里有没有重连成功的记录。
还有一个容易忽略的点是线程退出。改造后如果加了重连循环,要确保g_running标志在所有阻塞点都能被检查到。connect本身是阻塞的,重连时用非阻塞 socket 加select做超时控制,否则程序收到退出信号后要等connect超时才能退,体验很差。
从那以后我每次改这类网关代码,都会先把「串口原始模式配置」和「socket 重连路径」这两段单独拎出来测一遍,再跑整体链路。这两个地方出问题最隐蔽,也最耗现场调试时间。希望帮到你。
本文还有配套的精品资源,点击获取