Linux下TCP Modbus服务端实现:从协议解析到并发处理
简介:面向Linux平台工业自动化通信开发者的TCPModbus服务端实现代码,适合已具备C语言与网络编程基础、需要在嵌入式或工控项目中快速搭建Modbus从站的技术人员参考。压缩包共4个文件,约10KB,以C源码为主体,辅以线程相关头文件、构建脚本及编译后的可执行程序,覆盖从源码阅读到直接运行的完整链路。代码实现了0x02离散输入、0x04输入寄存器的只读查询,以及0x03读保持寄存器、0x06单寄存器写、0x10多寄存器批量写的处理逻辑,并借助多线程机制应对并发客户端连接,可作为理解Modbus地址映射、功能码解析与响应组包的实操样本。目前已有972人学习,适合用作协议学习、二次开发或工控通信调试的基础骨架。
1. 一个跑在 Linux 上的 TCP Modbus 服务端,到底能解决什么问题
工业现场里最常见的对接场景,是上位机、组态软件或者边缘网关要按 Modbus TCP 协议来读一批寄存器。这时候你手里往往只有一台 Linux 工控机或者一块 ARM 板子,没有现成的 PLC,也不方便为了调试去搭一整套商业组态环境。这个项目就是干这件事的:在 Linux 上实现一个 TCP Modbus 服务端,监听标准 502 端口,按 Modbus TCP 的报文格式响应功能码请求,把内部维护的寄存器区暴露给任意 Modbus 客户端读写。
它适合两类人。一类是做设备侧开发的,需要给自己的采集程序或控制逻辑套一层标准协议外壳,让第三方客户端能直接连上来;另一类是刚接触 Modbus 的开发者,想找一个能编译、能抓包、能改功能码的最小实现,把协议帧结构吃透。下面按「它怎么组织 → 怎么跑起来 → 核心代码怎么读 → 怎么改」的顺序拆。
2. 架构与目录:一个单进程 TCP 服务端是怎么搭起来的
2.1 整体分层与线程模型
这类服务端的骨架其实很固定,常见做法是三层:网络层负责 socket 监听、accept、收发包;协议层负责解析 MBAP 头和 PDU,校验功能码与数据长度;数据层维护一块寄存器数组,供协议层读写。项目正文没有给出更细的实现描述,按这个场景下合格开发最可能采用的做法,网络层用阻塞式 socket 加每连接一个线程,或者用select/poll做单线程多路复用,两种都能跑,前者代码直观、后者资源占用低。
线程模型上我一般会这样选:连接数少、调试为主,就用「主线程 accept + 每连接一个工作线程」,逻辑清晰,出问题好定位;如果预期几十上百个并发连接,就换成epoll单线程事件循环,避免线程切换和锁竞争。寄存器区是共享资源,无论哪种模型,读写都要加锁,否则多客户端同时写同一个地址会出现读到半更新数据的情况,这是后面避坑章节要重点说的。
协议层的关键是 MBAP 头,7 个字节:事务标识 2 字节、协议标识 2 字节(Modbus 固定为 0)、长度 2 字节、单元标识 1 字节。后面跟 PDU,第一个字节是功能码。服务端要做的事就是:读满 7 字节 → 解析长度 → 再读长度减 1 字节的 PDU → 按功能码分发 → 组响应包原样带回事务标识。事务标识必须原样回填,客户端靠它匹配请求和响应,回错了客户端会一直等超时。
2.2 目录结构与文件职责
一个能编译运行的最小工程,目录大致长这样:
tcpmodbus-server/ ├── Makefile ├── include/ │ ├── modbus.h # 协议常量、功能码、报文结构体 │ ├── regs.h # 寄存器区定义与读写接口 │ └── server.h # 服务端启动、连接处理声明 ├── src/ │ ├── main.c # 参数解析、信号处理、启动监听 │ ├── server.c # socket 监听、accept、连接循环 │ ├── protocol.c # MBAP 解析、功能码分发、响应组包 │ └── regs.c # 寄存器数组与互斥锁 └── test/ └── client.py # 用 pymodbus 或裸 socket 做冒烟测试modbus.h里放的是协议常量,比如功能码0x03读保持寄存器、0x04读输入寄存器、0x06写单个寄存器、0x10写多个寄存器,以及异常码0x01非法功能、0x02非法数据地址、0x03非法数据值。把这些常量集中在一个头文件里,后面加功能码时只改一处。regs.c单独成文件是为了让数据区和协议解析解耦,将来换成从共享内存或配置文件加载初值,只动这一个文件。
2.3 编译与启动参数
Makefile 保持极简,-Wall -Wextra -O2是标配,-pthread在用到线程时加上。启动参数一般给三个:监听地址、端口、寄存器数量。端口默认 502,但 1024 以下端口需要 root 权限,开发阶段我习惯先用 1502 这类高位端口,避免每次sudo。
make # 监听所有网卡的 1502 端口,初始化 1000 个保持寄存器 ./tcpmodbus-server -a 0.0.0.0 -p 1502 -n 1000参数含义:-a绑定地址,0.0.0.0表示所有网卡,只想本机调试就写127.0.0.1;-p端口;-n寄存器数量,决定数据区大小,客户端访问超出范围的地址应返回异常码0x02而不是越界读内存。启动后进程前台运行,收到SIGINT或SIGTERM时关闭监听 socket、等待工作线程退出再释放寄存器区,这套清理逻辑写在main.c的信号处理里,不做的话Ctrl+C之后端口会短暂处于TIME_WAIT,重启时报「Address already in use」。
3. 核心代码拆解:从 accept 到响应组包的完整链路
3.1 监听与连接接入
监听部分的套路是socket → setsockopt(SO_REUSEADDR) → bind → listen → accept。SO_REUSEADDR一定要开,否则调试期间反复重启会被端口占用卡住,这是血泪经验。listen的 backlog 给 16 到 128 都行,工业现场并发连接通常个位数。
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_port = htons(port); inet_pton(AF_INET, bind_ip, &addr.sin_addr); bind(listen_fd, (struct sockaddr *)&addr, sizeof(addr)); listen(listen_fd, 16); while (running) { int conn_fd = accept(listen_fd, NULL, NULL); if (conn_fd < 0) { if (errno == EINTR) continue; // 被信号打断,继续等 break; } // 每连接起一个工作线程,或交给 epoll 注册 handle_connection(conn_fd); }accept返回-1且errno == EINTR是正常现象,信号处理函数打断阻塞调用后要continue而不是退出循环,这一点漏了会导致进程收到无关信号就挂掉。handle_connection里给每个连接设置接收超时SO_RCVTIMEO,比如 30 秒,防止半开连接把线程永久占住。
3.2 MBAP 头解析与粘包处理
TCP 是字节流,一次recv可能只收到半个 MBAP 头,也可能一次收到两个完整请求。正确做法是先读满 7 字节头,从里面取出长度字段,再循环读满剩余字节。下面这个读满函数是整条链路的地基:
// 读满 len 字节,处理短读和 EINTR static int read_full(int fd, uint8_t *buf, size_t len) { size_t got = 0; while (got < len) { ssize_t n = recv(fd, buf + got, len - got, 0); if (n == 0) return 0; // 对端关闭 if (n < 0) { if (errno == EINTR) continue; return -1; } got += (size_t)n; } return 1; } // 解析一个完整请求 uint8_t hdr[7]; if (read_full(fd, hdr, 7) != 1) return; uint16_t trans_id = (hdr[0] << 8) | hdr[1]; // 事务标识,响应原样回填 uint16_t proto_id = (hdr[2] << 8) | hdr[3]; // 必须为 0 uint16_t length = (hdr[4] << 8) | hdr[5]; // 后续字节数,含单元标识 uint8_t unit_id = hdr[6]; if (proto_id != 0 || length < 2 || length > 260) { close(fd); // 非法报文,直接断开 return; } uint8_t pdu[260]; if (read_full(fd, pdu, length - 1) != 1) return; // 长度含单元标识,PDU 少 1这里有两个容易翻车的点。第一,length字段包含单元标识那 1 个字节,所以 PDU 实际长度是length - 1,按length去读会多读一个字节,把下一个请求的头吃掉,表现为客户端偶发超时。第二,length上限要卡住,Modbus TCP 单帧最大 260 字节左右,不校验的话恶意客户端发个超大长度就能让服务端分配巨量内存或者一直阻塞等数据。
3.3 功能码分发与寄存器读写
PDU 第一个字节是功能码,后面跟起始地址 2 字节、数量或数据。以读保持寄存器0x03为例,请求是「功能码 + 起始地址 + 寄存器数量」,响应是「功能码 + 字节数 + 数据」。分发用switch最直观:
uint8_t fc = pdu[0]; uint16_t start = (pdu[1] << 8) | pdu[2]; uint16_t count = (pdu[3] << 8) | pdu[4]; switch (fc) { case 0x03: { // 读保持寄存器 if (count == 0 || count > 125) { send_exception(fd, trans_id, unit_id, fc, 0x03); break; } if (start + count > reg_count) { send_exception(fd, trans_id, unit_id, fc, 0x02); break; } uint8_t resp[260]; int pos = 0; resp[pos++] = fc; resp[pos++] = (uint8_t)(count * 2); // 字节数 pthread_mutex_lock(®_lock); for (int i = 0; i < count; i++) { uint16_t v = holding_regs[start + i]; resp[pos++] = (uint8_t)(v >> 8); // Modbus 是大端 resp[pos++] = (uint8_t)(v & 0xFF); } pthread_mutex_unlock(®_lock); send_response(fd, trans_id, unit_id, resp, pos); break; } case 0x06: { // 写单个寄存器 if (start >= reg_count) { send_exception(fd, trans_id, unit_id, fc, 0x02); break; } uint16_t value = (pdu[3] << 8) | pdu[4]; pthread_mutex_lock(®_lock); holding_regs[start] = value; pthread_mutex_unlock(®_lock); send_response(fd, trans_id, unit_id, pdu, 5); // 原样回显 break; } default: send_exception(fd, trans_id, unit_id, fc, 0x01); // 非法功能码 }参数说明:count上限 125 是协议规定,读保持寄存器一次最多 125 个,超了要回异常码0x03;start + count > reg_count判断越界,回0x02;所有寄存器值按大端序写入响应,小端机器上必须手动移位,直接memcpy会得到字节序反的数据,客户端读出来是乱值。send_response负责重新组 MBAP 头,事务标识和单元标识用请求里的原值,长度字段填PDU 长度 + 1。
3.4 响应组包与异常码返回
异常响应格式是「功能码 | 0x80 + 异常码」,长度固定 2 字节 PDU。组包函数把 MBAP 头和 PDU 拼一起,一次send发出去,避免多次小包:
static void send_response(int fd, uint16_t tid, uint8_t uid, const uint8_t *pdu, int pdu_len) { uint8_t frame[270]; int len = pdu_len + 1; // 长度字段含单元标识 frame[0] = tid >> 8; frame[1] = tid & 0xFF; frame[2] = 0; frame[3] = 0; // 协议标识固定 0 frame[4] = len >> 8; frame[5] = len & 0xFF; frame[6] = uid; memcpy(frame + 7, pdu, pdu_len); send(fd, frame, 7 + pdu_len, MSG_NOSIGNAL); } static void send_exception(int fd, uint16_t tid, uint8_t uid, uint8_t fc, uint8_t code) { uint8_t pdu[2] = { (uint8_t)(fc | 0x80), code }; send_response(fd, tid, uid, pdu, 2); }send用MSG_NOSIGNAL防止对端已关闭时进程收到SIGPIPE直接退出,这是 Linux 下写网络服务的必备习惯,不加的话客户端一断连服务端就没了,现场排查起来很玄学。
4. 跑起来与联调:编译、抓包、客户端验证
4.1 编译与首次启动
拿到源码先make,看有没有缺头文件或者链接错误。编译通过后按 2.3 的参数启动,另开一个终端用ss -lntp确认端口在监听:
ss -lntp | grep 1502 # 期望输出类似:LISTEN 0 16 0.0.0.0:1502 0.0.0.0:* users:(("tcpmodbus-server",pid=1234,fd=3))如果看不到,先查进程是否已经退出(前台运行的话终端里会有报错),再查端口是否被占用。bind失败最常见的原因是上一个实例没退干净,ps aux | grep tcpmodbus找到后kill掉。
4.2 用 Python 客户端做冒烟测试
不装任何 Modbus 库也能测,裸 socket 拼一个读保持寄存器的请求就行,这样能顺便验证字节序和事务标识:
import socket, struct def read_holding(host, port, start, count, tid=1): # MBAP: 事务ID, 协议ID=0, 长度=6, 单元ID=1 req = struct.pack(">HHHBBHH", tid, 0, 6, 1, 0x03, start, count) with socket.create_connection((host, port), timeout=3) as s: s.sendall(req) hdr = s.recv(7) tid_r, pid, length, uid = struct.unpack(">HHHB", hdr) body = s.recv(length - 1) fc = body[0] if fc & 0x80: raise RuntimeError(f"异常码 {body[1]}") n = body[1] vals = struct.unpack(f">{n // 2}H", body[2:2 + n]) return tid_r, vals print(read_holding("127.0.0.1", 1502, 0, 10))跑通的话会返回事务标识和 10 个寄存器值。如果卡在recv不动,八成是服务端长度字段算错或者没回包;如果返回异常码0x02,检查start + count是否超过启动时-n指定的数量;如果值全是乱的,回头查大端序那一步。
4.3 抓包定位协议问题
联调阶段tcpdump比打印日志管用,直接看字节:
sudo tcpdump -i lo -nn -X 'tcp port 1502' -c 20-X把 payload 按十六进制和 ASCII 打出来,对照 MBAP 头逐字段看:前两字节事务标识、三四字节应为00 00、五六字节长度、第七字节单元标识。客户端说「连上了但读不到数」,抓包一看请求根本没发出去或者发的是 RTU 帧(没有 MBAP 头),问题立刻定位。这一步能省掉大量「到底是网络问题还是协议问题」的扯皮。
5. 避坑与排查:五个现场踩过的坑
5.1 客户端偶发超时,重连就好
现象:客户端大部分请求正常,偶尔一次等满超时,重连后恢复。原因通常是粘包处理不对,一次recv收到两个请求只处理了第一个,剩下的字节被丢弃,第二个请求永远等不到响应。解决:按 3.2 的read_full严格读满头部和 PDU,循环处理,不要假设一次recv等于一个请求。
5.2 读出来的寄存器值高低字节颠倒
现象:服务端写入0x1234,客户端读出来是0x3412。原因是 x86 是小端,直接把uint16_t内存拷进发送缓冲区就是小端序,而 Modbus 规定大端。解决:所有出站数据手动>> 8和& 0xFF拆分,入站数据手动移位拼接,别用memcpy偷懒。
5.3 多客户端同时写导致数据错乱
现象:两个客户端同时写相邻寄存器,读出来一半新一半旧。原因是寄存器区没加锁,或者锁的粒度只包住了单个寄存器。解决:读多个寄存器和写多个寄存器都要在整段操作外加同一把互斥锁,保证客户端看到的是某一时刻的一致快照。
5.4 端口 502 绑定失败
现象:bind: Permission denied。原因:1024 以下端口需要特权。解决:开发阶段换 1502 等高位端口;确实要用 502 就配setcap 'cap_net_bind_service=+ep' ./tcpmodbus-server,比每次sudo干净。
5.5 客户端断开后服务端进程消失
现象:客户端一close,服务端进程直接退出,日志里没有任何业务错误。原因是向已关闭的连接send触发SIGPIPE,默认行为是终止进程。解决:send加MSG_NOSIGNAL,或者在main里signal(SIGPIPE, SIG_IGN),两者选其一,我一般两个都做。
6. 二次开发:加功能码、换数据源、做压力验证
改造这个服务端,最常做的三件事是加功能码、换寄存器数据源、验证并发。加功能码的路径很固定:在modbus.h补常量,在protocol.c的switch里加case,按协议算好请求和响应的字节布局,异常分支别漏。比如加0x10写多个寄存器,请求是「功能码 + 起始地址 + 数量 + 字节数 + 数据」,响应回显「功能码 + 起始地址 + 数量」,数量上限 123,越界回0x02,数量为 0 回0x03。
换数据源是更有价值的改造。现在寄存器是进程内数组,实际项目里往往要映射到真实设备状态或者共享内存。做法是把regs.c里的读写接口抽成函数指针表:
typedef struct { int (*read)(uint16_t addr, uint16_t *out, uint16_t count); int (*write)(uint16_t addr, const uint16_t *in, uint16_t count); } reg_backend_t; // 启动时按配置选择:内存数组 / 共享内存 / 采集线程回填 extern const reg_backend_t reg_backend_mem; extern const reg_backend_t reg_backend_shm;协议层只调backend->read/write,不关心底层是数组还是共享内存。这样将来接真实采集逻辑,写一个后台线程周期性刷新寄存器区,协议层一行不用改。注意后端实现里自己保证线程安全,协议层不再重复加锁,避免锁嵌套。
验证方法上,功能正确性用 4.2 的脚本覆盖正常、越界、非法功能码三类请求;并发正确性用wrk不合适(它不是 Modbus 协议),我一般写个多线程 Python 脚本,N 个线程循环写不同地址再读回比对,跑十分钟看有没有不一致。性能上关注单连接 QPS 和连接数上升时的延迟,epoll版本在几百连接下应该保持平稳,如果延迟随连接数线性上涨,检查是不是每连接一个线程且锁竞争过重。
最后说个习惯。早些年我改这类协议服务端,图快直接在主循环里加printf调试,结果日志把recv的时序打乱,问题越查越乱。从那以后我每次动协议层之前,都先用tcpdump存一份正常交互的 pcap 当基线,改完再抓一份对比字节差异,确认只有预期字段变化才继续往下走。这套笨办法救过我好几次,也希望帮到你。
本文还有配套的精品资源,点击获取