☰
Linux下Modbus TCP服务端开发实战:协议解析、并发模型与避坑指南
2026/11/4 20:04:53 网站建设 项目流程

简介:面向Linux平台工业自动化通信开发者的TCPModbus服务端实现代码,适合已具备C语言与网络编程基础、需要在嵌入式或工控项目中快速搭建Modbus从站的技术人员参考。压缩包共4个文件,约10KB,以C源码为主体,辅以线程相关头文件、构建脚本及编译后的可执行程序,覆盖从源码阅读到直接运行的完整链路。代码实现了0x02离散输入、0x04输入寄存器的只读查询,以及0x03读保持寄存器、0x06单寄存器写、0x10多寄存器批量写的完整功能码处理,并借助多线程机制应对并发客户端连接,可作为理解Modbus地址映射、请求解析与响应组包的实操样本。目前已有972人学习,适合用作协议学习对照或工控通信模块的起步骨架。

1. 从一台跑在工控机上的 Modbus 服务端说起

去年帮一个做产线采集的朋友排查问题,现场是一台 Linux 工控机,上位机软件连不上,日志里全是超时。最后定位下来不是网络问题,而是那台机器上跑的 Modbus TCP 服务端只支持单连接,第二个客户端一进来就把第一个挤掉了。这件事让我重新把 linux tcpmodbus 服务端这类东西翻出来仔细看了一遍——它看着简单,协议就那几个功能码,但真要在 Linux 上稳定跑起来,监听、并发、字节序、异常码,每一处都能让人翻车。

这个项目解决的就是这件事:在 Linux 环境下实现一个 Modbus TCP 服务端,把寄存器区暴露成标准 Modbus 从站,让 PLC、组态软件、SCADA 或者自己写的采集程序通过 502 端口读写线圈和保持寄存器。适合两类人:一类是手上有个设备或仿真数据源,想快速包一层 Modbus 接口给上位机用;另一类是想搞明白 Modbus TCP 报文到底长什么样、服务端该怎么组织的开发者。下面按「它是什么 → 怎么跑起来 → 代码怎么拆 → 怎么改」的顺序讲。

2. 协议与架构:Modbus TCP 服务端到底在做什么

2.1 Modbus TCP 的报文结构,先把这个搞明白

Modbus TCP 和串口上的 Modbus RTU 最大的区别,是在 PDU 前面加了一个 7 字节的 MBAP 头。很多人第一次写服务端,抓包看到前面多出来一串字节就懵了,其实拆开看很规整:

字段长度说明
事务标识符 Transaction ID2 字节客户端自增,服务端原样回填,用来匹配请求和响应
协议标识符 Protocol ID2 字节Modbus 固定为 0
长度 Length2 字节后续字节数,包含单元标识符 + PDU
单元标识符 Unit ID1 字节从站地址,TCP 场景下常填 1 或 0xFF
功能码 Function Code1 字节读线圈 0x01、读保持寄存器 0x03、写单寄存器 0x06 等
数据域 DataN 字节起始地址、数量、写入值等

服务端要做的事情其实就三步:从 socket 读满 MBAP 头,按 Length 字段再读满 PDU,解析功能码去操作对应的数据区,最后把响应按同样的结构拼回去。事务标识符必须原样带回,否则有些上位机会认为响应不匹配直接丢弃——这个坑我见过不止一次。

2.2 服务端的整体架构:监听、会话、数据区三层

一个合格的 Modbus TCP 服务端,结构上可以拆成三层,这个项目也是按这个思路组织的:

  • 监听层:创建 TCP socket,绑定 0.0.0.0:502,listen 之后进入 accept 循环。502 是 Modbus TCP 的约定端口,低于 1024 的端口在 Linux 上需要 root 或 CAP_NET_BIND_SERVICE 权限,本地调试时我一般先改成 1502 避开权限问题。
  • 会话层:每个 accept 上来的连接对应一个会话,负责收报文、校验、分发、回响应。这一层决定了并发模型,是阻塞单线程、每连接一线程,还是 epoll 事件驱动。
  • 数据区层:线圈(Coils)、离散输入(Discrete Inputs)、输入寄存器(Input Registers)、保持寄存器(Holding Registers)四块存储。前两块按位寻址,后两块按 16 位字寻址。数据区是服务端和业务逻辑之间的边界,采集程序往这里写,Modbus 客户端从这里读。

常见做法是把数据区做成全局的定长数组,比如 65536 个线圈、65536 个保持寄存器,用地址直接索引。这样实现最简单,代价是内存占用固定,但对绝大多数产线采集场景完全够用。

2.3 并发模型怎么选:阻塞、线程还是 epoll

这是整个服务端最需要想清楚的地方。如果只用一个阻塞循环处理一个连接,那么第二个客户端连上来时,第一个连接没断开之前新连接就排不上队,表现就是「能连上但读不到数据」或者直接超时——前面提到的现场问题就是这么来的。

三种常见做法:

  1. 每连接一线程:accept 之后 pthread_create 一个线程跑会话循环。实现直观,连接数在几十个以内没问题,缺点是连接多了线程切换开销大,且要处理好线程退出和资源回收。
  2. epoll 事件驱动:单线程或少量线程管理所有连接,非阻塞 socket + epoll_wait,适合连接数多但报文不密集的场景。代码复杂度高一些,要自己维护每个连接的读缓冲区,处理粘包半包。
  3. 线程池 + 阻塞 IO:accept 后把 fd 丢进任务队列,固定数量 worker 线程去处理。折中方案,连接数中等时比较稳。

这个项目我倾向于按 epoll 或线程池来组织,因为 Modbus 客户端往往不止一个——组态软件一个连接、采集程序一个连接、调试工具再占一个,单连接模型迟早出问题。

3. 跑起来:编译、启动与第一个读写请求

3.1 环境准备与编译

Linux 下这类服务端基本是 C/C++ 写的,依赖很少,通常只需要 gcc、make,可能加一个 pthread。先确认工具链:

# 检查编译工具链 gcc --version make --version # 如果项目用 CMake 组织 cmake --version

如果源码目录里有 Makefile,直接 make 即可;如果是 CMakeLists.txt,走标准流程:

mkdir -p build && cd build cmake .. make -j4

编译产物一般是一个可执行文件,比如 tcpmodbus_server 或 modbus_server。编译报错最常见的是找不到 pthread,补上 -lpthread 就行;如果是 C++ 项目报 std::thread 相关错误,检查是否加了 -std=c++11 及以上。

3.2 启动服务端与端口权限

直接启动:

# 前台启动,方便看日志 sudo ./tcpmodbus_server # 或者指定端口和日志级别(具体参数看项目 main 里的解析) sudo ./tcpmodbus_server -p 502 -l debug

注意 502 端口需要特权。不想用 sudo 的话有两个办法:一是用 setcap 给可执行文件授权,二是调试阶段直接换高端口。

# 给二进制授予绑定低端口的能力,避免每次 sudo sudo setcap 'cap_net_bind_service=+ep' ./tcpmodbus_server # 调试时改用 1502 端口 ./tcpmodbus_server -p 1502

启动后确认监听状态:

ss -lntp | grep 502 # 期望看到 0.0.0.0:502 处于 LISTEN 状态

3.3 用客户端验证读写链路

服务端跑起来只是第一步,得验证报文能正确往返。最省事的是用 modpoll 或 pymodbus 写个小脚本:

from pymodbus.client import ModbusTcpClient # 连接服务端,端口按实际启动的填 client = ModbusTcpClient('127.0.0.1', port=502) client.connect() # 读保持寄存器:从站 1,起始地址 0,读 10 个 rr = client.read_holding_registers(address=0, count=10, slave=1) print("read:", rr.registers if not rr.isError() else rr) # 写单个寄存器:地址 0 写入 1234 wr = client.write_register(address=0, value=1234, slave=1) print("write ok:", not wr.isError()) # 回读确认写入生效 rr2 = client.read_holding_registers(address=0, count=1, slave=1) print("readback:", rr2.registers) client.close()

这段脚本覆盖了最核心的链路:连接建立、功能码 0x03 读、功能码 0x06 写、回读验证。如果读回来全是 0 或者报异常码,先看服务端日志有没有收到请求,再看地址映射对不对——Modbus 地址有 0 基和 1 基两种表述,上位机填 40001 对应的实际报文地址往往是 0,这个换算错位是新手最容易卡住的地方。

提示:验证阶段建议先用 pymodbus 这类脚本客户端,报文可控、异常码能直接打印出来,比拿组态软件盲试高效得多。

4. 核心代码拆解:从 accept 到响应回写的完整链路

4.1 监听与 accept:服务端的入口

监听部分的骨架很固定,但细节决定稳定性。下面这段是典型的初始化逻辑:

int listen_fd = socket(AF_INET, SOCK_STREAM, 0); if (listen_fd < 0) { perror("socket"); return -1; } // 端口复用,避免重启时 TIME_WAIT 导致 bind 失败 int opt = 1; setsockopt(listen_fd, SOL_SOCKET, SO_REUSEADDR, &opt, sizeof(opt)); struct sockaddr_in addr; memset(&addr, 0, sizeof(addr)); addr.sin_family = AF_INET; addr.sin_addr.s_addr = htonl(INADDR_ANY); // 监听所有网卡 addr.sin_port = htons(port); // 主机字节序转网络字节序 if (bind(listen_fd, (struct sockaddr *)&addr, sizeof(addr)) < 0) { perror("bind"); // 常见原因:端口被占用或无权限 return -1; } if (listen(listen_fd, 16) < 0) { perror("listen"); return -1; }

几个参数值得说清楚:SO_REUSEADDR 几乎必加,否则服务端重启时旧连接处于 TIME_WAIT,bind 会直接报 Address already in use,调试期间反复重启时这个坑能烦死人。listen 的 backlog 填 16 到 128 都行,它只是未完成连接队列的上限,不是最大连接数。htonl/htons 不能省,x86 是小端,网络字节序是大端,端口和地址不转换的话绑上去的端口会是错的。

accept 循环里要注意:accept 返回的 fd 是新的连接套接字,和 listen_fd 不是一回事;accept 可能被信号中断返回 EINTR,要判断后 continue 而不是当错误退出。

4.2 报文接收:处理半包和粘包

TCP 是字节流,一次 recv 不保证读到一个完整报文。Modbus TCP 的 MBAP 头里 Length 字段正好给了分帧依据,标准做法是先读满 7 字节头,解析出 Length,再读满剩余部分:

// 读满 n 字节,处理半包 static int recv_all(int fd, uint8_t *buf, int n) { int got = 0; while (got < n) { int r = recv(fd, buf + got, n - got, 0); if (r == 0) return 0; // 对端关闭 if (r < 0) { if (errno == EINTR) continue; return -1; } got += r; } return got; } uint8_t header[7]; if (recv_all(fd, header, 7) <= 0) { /* 连接结束或出错 */ } uint16_t proto_id = (header[2] << 8) | header[3]; uint16_t length = (header[4] << 8) | header[5]; if (proto_id != 0 || length < 2 || length > 260) { // 协议标识非 0 或长度异常,直接断开,防止被畸形报文拖死 close(fd); return; } uint8_t pdu[260]; if (recv_all(fd, pdu, length - 1) <= 0) { /* 出错 */ }

这里有两个关键点。第一,length 字段包含单元标识符和 PDU,所以实际要再读 length - 1 字节(单元标识符已经在 header[6] 里)。第二,必须对 length 做上限校验,Modbus PDU 最大 253 字节,加上单元标识符不超过 254,给个 260 的缓冲足够;不校验的话,恶意或异常客户端发个 length=65535,服务端要么分配巨大缓冲要么越界,这是实打实的安全问题。

4.3 功能码分发与数据区操作

拿到 PDU 之后,第一个字节是功能码,后面是数据。分发逻辑用 switch 最清晰:

uint8_t func = pdu[0]; uint16_t resp_len = 0; uint8_t resp[260]; switch (func) { case 0x03: { // 读保持寄存器 uint16_t start = (pdu[1] << 8) | pdu[2]; uint16_t count = (pdu[3] << 8) | pdu[4]; if (count == 0 || count > 125 || start + count > HOLDING_SIZE) { build_exception(resp, func, 0x02); // 非法数据地址 resp_len = 3; break; } resp[0] = func; resp[1] = count * 2; // 字节数 for (int i = 0; i < count; i++) { uint16_t v = holding_regs[start + i]; resp[2 + i * 2] = v >> 8; // 高字节在前,大端 resp[3 + i * 2] = v & 0xFF; } resp_len = 2 + count * 2; break; } case 0x06: { // 写单个保持寄存器 uint16_t addr = (pdu[1] << 8) | pdu[2]; uint16_t val = (pdu[3] << 8) | pdu[4]; if (addr >= HOLDING_SIZE) { build_exception(resp, func, 0x02); resp_len = 3; break; } holding_regs[addr] = val; memcpy(resp, pdu, 5); // 0x06 响应原样回显 resp_len = 5; break; } default: build_exception(resp, func, 0x01); // 非法功能码 resp_len = 3; }

参数和边界要逐个盯:0x03 单次最多读 125 个寄存器(250 字节数据,受 PDU 253 字节上限约束),count 为 0 或越界都要回异常码 0x02。寄存器值写入响应缓冲时必须高字节在前,因为 Modbus 规定大端;如果直接 memcpy 一个 uint16_t 数组进去,在小端机器上读出来就是字节颠倒的,上位机显示的值会莫名其妙变成 0x3412 这种。0x06 的响应按规范是把请求原样回显,这点和 0x03 不同,别写混了。

4.4 响应封装:MBAP 头回填与事务标识符

PDU 处理完,要把 MBAP 头拼回去。事务标识符和单元标识符从请求头里原样带过来:

uint8_t out[300]; out[0] = header[0]; // 事务标识符高字节,原样回填 out[1] = header[1]; // 事务标识符低字节 out[2] = 0x00; // 协议标识固定 0 out[3] = 0x00; uint16_t total = resp_len + 1; // 单元标识符 + PDU out[4] = total >> 8; out[5] = total & 0xFF; out[6] = header[6]; // 单元标识符原样回填 memcpy(out + 7, resp, resp_len); send_all(fd, out, 7 + resp_len);

事务标识符不回填的后果很直接:部分上位机按事务 ID 匹配请求响应,回填错了它就一直等,表现是「服务端明明回了但客户端超时」。单元标识符同理,有些客户端会校验。send 也要用循环写满,send 返回值小于请求长度时不能当成功,TCP 发送缓冲满的时候部分写是正常现象。

5. 避坑与排查:那些让服务端「看着在跑其实不通」的问题

5.1 现象:客户端能连上,但读数据一直超时

原因通常有两个。一是服务端 accept 之后没有真正进入会话循环,或者单连接模型下第二个连接被阻塞;二是响应发出去了但事务标识符或长度字段算错,客户端认为报文不合法直接丢弃。排查方法:在服务端 recv 和 send 前后打日志,打印收到的功能码和发出的字节数;同时用 tcpdump 抓 502 端口的包,看响应到底有没有出去、MBAP 头对不对。

sudo tcpdump -i any -n port 502 -A

如果抓包看到响应报文完整且事务 ID 与请求一致,问题就在客户端侧;如果根本没看到响应,回到服务端代码查 send 的返回值和分支逻辑。

5.2 现象:读回来的寄存器值高低字节颠倒

原因是在小端机器上直接把 uint16_t 内存拷进了响应缓冲,没有做字节序转换。Modbus 规定线上传输大端,服务端内部存储用什么字节序无所谓,但写进报文前必须手动拆成高字节、低字节。解决办法就是 4.3 里那种v >> 8和v & 0xFF的写法,别图省事用 memcpy。

5.3 现象:服务端重启时报 Address already in use

原因是上一次进程退出后连接处于 TIME_WAIT,端口还没释放。解决:bind 之前加 SO_REUSEADDR;如果还不行,确认是不是有残留进程占着端口,用ss -lntp | grep 502找到 PID 后处理掉。另外注意别把 SO_REUSEPORT 和 SO_REUSEADDR 搞混,前者是多个进程绑同一端口做负载均衡,语义完全不同。

5.4 现象:跑一段时间后连接数越来越多,最终 accept 失败

原因是连接关闭后 fd 没 close,或者线程模型下线程没回收。每连接一线程的模型尤其容易出这个问题:客户端异常断开时 recv 返回 0,代码里只 break 出循环却没 close(fd),fd 泄漏累积到上限就再也接不了新连接。排查用ls /proc/<pid>/fd | wc -l看 fd 数量是否持续增长。解决是在会话结束的所有路径上统一 close,线程用 pthread_detach 或 join 回收。

5.5 现象:某些上位机连上就断,日志显示协议标识非 0

原因是客户端发的不是标准 Modbus TCP,或者中间有设备做了协议转换。标准要求协议标识符为 0,但个别网关会填别的值。处理策略要谨慎:严格校验会拒绝这类客户端,放宽校验又可能放过畸形报文。我的做法是协议标识非 0 时记一条 warning 日志但仍然按 Modbus 解析,同时保留长度上限校验,兼顾兼容性和安全。

6. 二次开发:把数据区接到真实业务上

这个服务端真正有价值的地方,是数据区那一层可以换成任何数据源。默认实现是静态数组,二次开发时最常见的需求是让保持寄存器的值来自实时采集——比如从串口读传感器、从共享内存读另一个进程的计算结果、或者从数据库拉配置。

改造思路是保留协议层不动,把数据区的读写抽成回调。比如定义一组函数指针:

typedef struct { int (*read_holding)(uint16_t addr, uint16_t *out, uint16_t count); int (*write_holding)(uint16_t addr, const uint16_t *in, uint16_t count); int (*read_coils)(uint16_t addr, uint8_t *out, uint16_t count); } data_ops_t; // 协议层不再直接访问数组,而是走 ops if (ops->read_holding(start, tmp, count) != 0) { build_exception(resp, func, 0x04); // 从站设备故障 }

这样业务侧只要实现自己的 read_holding,把实时值填进 out 数组即可,协议解析、异常码、字节序全部复用。改造完的验证方法:先用 pymodbus 脚本读一遍确认值正确,再用 modpoll 连续读 1000 次看有没有内存增长和值跳变,最后模拟客户端异常断开(直接 kill 客户端进程)确认服务端不崩、fd 能回收。

另一个常见改造点是并发模型。如果现场客户端数量超过十个,建议把每连接一线程换成 epoll 或线程池,改动集中在会话层,数据区接口不用动。改的时候注意非阻塞 socket 下 recv 返回 EAGAIN 要正确处理,别当成错误断开连接。

有个血泪教训:我早期改这类服务端时,图快直接在协议处理函数里调用了会阻塞的采集接口,结果一个慢设备把整个服务端拖住,所有客户端一起超时。从那以后我每次做数据区改造,都强制把业务读取放到独立线程或异步队列里,协议层只做内存拷贝,绝不在报文处理路径上做任何可能阻塞的操作。希望帮到你。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询