简介:这是一份面向Linux环境下的工业自动化开发者的TCP Modbus服务端实现源码包。基于Modbus/TCP协议,程序可在Linux主机上监听客户端请求,完成输入寄存器(0x04)、离散输入(0x02)、保持寄存器(0x03读/0x06单写/0x10多写)等常用功能码的响应与写入操作,覆盖只读测量数据、开关状态读取以及参数读写等典型应用场景,适合需要快速构建工控通信服务、理解寄存器映射与报文处理逻辑的嵌入式或工业软件开发人员。压缩包共4个文件,主要包含C主程序、线程相关头文件、Makefile构建脚本以及编译后的服务端程序,整体仅10KB,结构轻量,便于阅读和二次修改。已有971人学习下载,具有一定参考热度。阅读代码可以了解客户端连接管理、多线程并发处理、Modbus请求解析与应答构造等关键实现,依托Makefile还能快速完成编译部署,是上手Modbus TCP服务端开发的精简示例。
1. Linux 上的 TCP Modbus 服务端:它到底是什么,值不值得自己写?
先说一个反直觉的结论:Modbus TCP 服务端的核心逻辑,大部分工程师都能在一周内写完,难点从来不是协议本身,而是拆包、并发和地址映射这些边角料。所谓 linux tcpmodbus服务端,就是在一个 Linux 进程里监听 TCP 502 端口,被动接收上位机组态软件、触摸屏或边缘采集器发来的请求帧,查自己的寄存器表,再把标准响应帧按原路返回。你需要它解决的核心问题只有一件:把设备侧的实时数据变成工业网络里其他节点能读到的标准资源。
很多人第一反应是找现成协议栈,这没有错,但我更建议至少在测试环境里手写一遍。原因很简单:只要把 Modbus TCP 的帧边界、字节序和功能码分发逻辑亲手拆过一遍,之后再排查现场问题就不需要把协议栈当黑匣子。适合读这篇的人也有三类:嵌入式工程师想把采集程序变成上位机数据源,边缘网关开发者需要做协议桥接,还有纯粹想搞懂 Modbus 细节的从业者。下面所有内容都围绕这一条主线展开:协议怎么拆,服务端怎么写,生产环境还有什么坑。
2. 先立原理:Modbus TCP 帧结构和服务端必须维护的四张表
2.1 MBAP 头的 6 个字节:事务 ID、协议 ID、长度字段和单元 ID
Modbus TCP 和串口 RTU 最大的不同是帧头:TCP 帧不携带 CRC,也不怕丢字节,因为它跑在 TCP 之上,可靠性由传输层保证。真正需要自己处理的额外头叫 MBAP Header,固定 6 个字节,字段排布如下:
| 字段 | 长度 | 取值 | 说明 |
|---|---|---|---|
| 事务标识符 Transaction ID | 2 字节 | 请求方随机或递增 | 响应必须原样回拷,用于区分并发请求 |
| 协议标识符 Protocol ID | 2 字节 | 恒为 0 | 0 表示 Modbus 协议 |
| 长度字段 Length | 2 字节 | 后续字节数 | 从单元 ID 开始算,到帧末尾 |
| 单元标识符 Unit ID | 1 字节 | 0x00-0xFF | 相当于从站地址,多设备网关会用到 |
长度字段是拆包的关键。比如一个读保持寄存器请求,负载部分由 1 字节功能码、2 字节起始地址、2 字节寄存器数量组成,加上单元 ID 1 字节,总共 6 字节,所以 Length 的十六进制值就是 0x0006。响应帧同样按这个规则推导:1 字节单元 ID + 1 字节功能码回显 + 1 字节字节数 + 2*N 字节寄存器值,Length 就是 3+2N。
很多新手在这里翻车,是因为把 TCP 里的数据当成一串连续字节后习惯性去找校验尾巴。Modbus TCP 确实没有 CRC 字段,因为 TCP 的校验和与重传机制已经兜底了。如果自作主张在解析时按 RTU 的格式扣掉后两个字节,读出的数据就会整体错位,响应帧返回去也必然超时。这里唯一需要做的,就是严格按 Length 字段切帧,不多读也不少读。
2.2 功能码与数据模型:从线圈到保持寄存器
既然要做服务端,就得承担从站角色,维护四张数据表。它们分别是 Coil 位线圈、Discrete Input 位离散输入、Input Register 字输入寄存器、Holding Register 字保持寄存器。前两个是 1 bit 的离散量,后两个是 16 bit 的连续量;区别在于输入类只读,保持类和线圈可读可写。
| 功能码 | 名称 | 操作表 | 请求负载 | 响应负载 |
|---|---|---|---|---|
| 0x01 | 读线圈 | Coil | 地址 2B + 数量 2B | 字节数 1B + 位打包数据 |
| 0x02 | 读离散输入 | Discrete Input | 地址 2B + 数量 2B | 字节数 1B + 位打包数据 |
| 0x03 | 读保持寄存器 | Holding Register | 地址 2B + 数量 2B | 字节数 1B + 字数据 2*N B |
| 0x04 | 读输入寄存器 | Input Register | 地址 2B + 数量 2B | 字节数 1B + 字数据 2*N B |
| 0x05 | 写单线圈 | Coil | 地址 2B + 值 2B | 原请求回显 |
| 0x06 | 写单寄存器 | Holding Register | 地址 2B + 值 2B | 原请求回显 |
| 0x0F | 写多线圈 | Coil | 地址 2B + 数量 2B + 字节数 1B + 数据 | 地址 + 数量回显 |
| 0x10 | 写多寄存器 | Holding Register | 地址 2B + 数量 2B + 字节数 1B + 数据 | 地址 + 数量回显 |
实现服务端时,至少要把 0x03、0x04、0x06、0x10 这几个功能码做稳,因为绝大多数组态软件默认就是用它们读设备点表。位操作码 0x01、0x05、0x0F 虽然简单,但涉及字节内位序,容易和寄存器字序混在一起。
一个常见的地址映射陷阱是:上位机显示地址 40001,协议帧里的起始地址却是 0x0000。Modbus 协议规定寄存器地址从 0 开始编号,而历史遗留的 4x 点表习惯从 40001 开始显示。服务端应该维护协议原始地址,不要自己去减一或加一,否则同一份点表在不同厂家的上位机里会出现错位。
2.3 TCP 是流不是报文:粘包、半包与解析状态机
TCP 没有消息边界,recv 返回的数据长度完全看内核缓冲区和网络切片情况。一次 recv 可能只收到半个请求,也可能包含两三个完整请求。如果服务端每次固定读 12 字节,现场就会出现随机超时,而且极难复现。正确做法是建立帧解析状态机:先攒够 6 字节 MBAP 头,解析出 Length,再等待剩余字节。
#define MBAP_LEN 6 /* 返回缓冲区里完整帧的字节数,没攒够则返回 -1 */ int frame_total_length(const uint8_t *buf, int len) { if (len < MBAP_LEN) return -1; int mbap_length = (buf[4] << 8) | buf[5]; /* 长度字段是大端序 */ return MBAP_LEN + mbap_length; }这段函数只做一件事:判断当前已有字节能不能切出一条完整帧。逻辑上先保证至少 6 字节,再取第 4、5 字节组成大端长度,最后和已收长度比对。收到数据后,就把它追加进接收缓冲区,再循环调用这个函数,直到没有完整帧为止。
if (recv_len > 0) { memcpy(rx_buf + used, tmp, recv_len); used += recv_len; while (1) { int frame_len = frame_total_length(rx_buf, used); if (frame_len <= 0) break; handle_frame(rx_buf, frame_len); /* 分发给功能码处理 */ used -= frame_len; memmove(rx_buf, rx_buf + frame_len, used); /* 处理完的帧立即移出 */ } } if (used > MAX_BUF_SIZE) { used = 0; /* 防止脏数据或攻击者撑爆缓冲区 */ }这里有两个参数要在代码里落死:接收缓冲最大长度,建议至少 1024,因为写多寄存器的大帧可能超过 256 字节;frame_len 如果超出缓冲区长度还返回正值,说明长度字段被污染,这时候直接清缓冲区比强行解析安全。memmove 而不是 memcpy,是因为源和目的区域可能重叠。
3. 从零搭一个能跑的最小实现:socket、线程与请求组包
3.1 选型:手写 socket 还是直接套现成协议栈
写服务端之前,先解决一个实际问题:要不要用现成的 Modbus 开源库。我的判断标准有两个:一是目标系统能不能方便地编译第三方依赖,二是后续要不要在帧层面做私有扩展。库的好处是功能码和拆包都封装好了,几行代码就能建起从站;代价是现场排查时你只能看日志,不能任意改帧处理流程。
| 方案 | 依赖 | 性能 | 可控性 | 适用场景 |
|---|---|---|---|---|
| 纯 socket + 手写解析 | 无 | 高 | 完全可控 | 嵌入式、定制化服务端 |
| 现成 Modbus 库 | 需要编译 | 中 | 受库接口限制 | 快速集成、标准功能够用 |
| 脚本语言封装 | 运行时 | 低 | 中 | 原型验证、测试工具 |
我个人的默认选择是:生产服务端用 C 手写,测试验证用脚本。这篇里的示例一概不依赖第三方库,所有组包拆包都摆在明面上,你照抄也能跑通。
3.2 最小服务端骨架:socket、bind、listen 与 accept 线程分发
先用最低成本把服务器跑起来。监听端口选 502 会涉及特权端口问题,测试阶段建议直接用 1502,后面再讲生产怎么处理。
int server_fd = socket(AF_INET, SOCK_STREAM, 0); int opt = 1; setsockopt(server_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(1502); /* 测试端口,生产用 502 */ bind(server_fd, (struct sockaddr *)&addr, sizeof(addr)); listen(server_fd, 16); while (1) { int cfd = accept(server_fd, NULL, NULL); if (cfd < 0) continue; int *pfd = malloc(sizeof(int)); *pfd = cfd; pthread_t tid; pthread_create(&tid, NULL, client_thread, pfd); pthread_detach(tid); }这里有一个非常常见且隐蔽的血泪坑:如果把&cfd直接传给线程,多重并发时后续 accept 会覆盖同一个局部变量,导致前一个线程处理错误的文件描述符。所以一定要 malloc 一个副本传给线程。SO_REUSEADDR 也要加上,否则服务端重启时端口还在 TIME_WAIT 状态,bind 会直接失败。
listen 队列长度 16 在大多数工业场景足够,因为上位机数量就几个,不需要调大。如果将来接了很多采集器长时间保持连接不通信,重点不是 listen 队列,而是后面的并发模型。
3.3 请求解析与响应组包:MBAP 长度字段怎么回填
收到一个完整帧后,第一件事是从第 6 字节开始解析 PDU。MBAP 头 6 字节,功能码正好在偏移 6。读保持寄存器和写保持寄存器的请求格式都是地址在前、数量在后,统一用一个解析函数就可以。
static int parse_addr_quantity(const uint8_t *frame, int frame_len, uint16_t *addr, uint16_t *quantity) { if (frame_len < 10) return -1; /* MBAP 6 + 功能码1 + 地址2 + 数量2 */ *addr = (frame[7] << 8) | frame[6]; /* 注意这里先取高字节 */ *quantity = (frame[9] << 8) | frame[8]; return 0; }这段代码有一处容易看花眼:因为 frame 是数组,下标 6 是地址高字节,下标 7 是低字节。Modbus 是大端序,所以拼出一个 16 位值时要让高字节左移 8 位再或上低字节。很多人写成了(frame[6] << 8) | frame[7],效果其实一样,但阅读性差,容易在下标上出错,建议统一把下标顺序写成对应关系。
响应组包是另一个容易翻车的地方。以读保持寄存器 0x03 为例,响应要回填三个关键内容:事务 ID 原样复制、长度字段重新计算、寄存器值按大端序填入。
uint8_t rsp[256]; int pos = 0; rsp[pos++] = req[0]; /* 事务 ID 高字节 */ rsp[pos++] = req[1]; /* 事务 ID 低字节 */ rsp[pos++] = 0; rsp[pos++] = 0; /* 协议 ID 固定为 0 */ rsp[pos++] = 0; rsp[pos++] = 0; /* 长度字段先占位,最后回填 */ rsp[pos++] = req[5]; /* 单元 ID 原样带回 */ rsp[pos++] = 0x03; /* 功能码回显 */ uint16_t reg_count = *quantity; rsp[pos++] = (uint8_t)(reg_count * 2); /* 数据字节数 */ for (uint16_t i = 0; i < reg_count; i++) { uint16_t val = holding_regs[*addr + i]; rsp[pos++] = (val >> 8) & 0xFF; /* 大端高字节先发 */ rsp[pos++] = val & 0xFF; } rsp[4] = (pos - 6) >> 8; /* length = 总字节数 - MBAP 6 字节 */ rsp[5] = (pos - 6) & 0xFF; send(fd, rsp, pos, 0);最后两行回填长度是点睛之笔。pos 是响应帧总长度,减去 MBAP 头的 6 字节后,就是长度字段该写的值。如果忘了回填,或者用固定长度 6 塞进去,上位机终端会一直等不到完整帧。这个长度字段必须由实际组包结果推导,而不是拍脑袋写死。
3.4 寄存器表与并发读写:共享内存和锁的取舍
服务端本质是一块带读写保护的内存。我在项目里习惯把四张表全做成静态数组,最常用的保持寄存器给 1000 个点,输入寄存器给 500 个点,位区给 100 个点,具体数量按点表需求扩。
#define REG_MAX 1000 #define BIT_MAX 100 static uint16_t holding_regs[REG_MAX]; static uint16_t input_regs[REG_MAX]; static uint8_t coils[BIT_MAX]; static pthread_mutex_t reg_lock = PTHREAD_MUTEX_INITIALIZER;读请求不打锁问题不大,因为 32 位以下整数的原子读不会撕裂;但写请求必须持锁,尤其是 0x10 写多寄存器一次性可能写几十个字,不加锁会让读请求读到只有一半更新的脏数据。我一般只用一个全局锁,而不是给每条寄存器配锁,因为现场请求频率很低,锁竞争几乎不存在。
地址越界检查要放在访问寄存器表之前。如果上位机请求起始地址 999、数量 10,而数组上限是 1000,直接读会越界踩到其他内存,轻则返回脏数据,重则 segfault。检查逻辑不复杂,但必须考虑起始地址加数量是否会溢出,用无符号加法存在绕回风险,建议先转成 size_t 再比较。
uint32_t beyond = (uint32_t)*addr + (uint32_t)*quantity; if (beyond > REG_MAX) { send_exception(fd, req[5], req[6], 0x02); /* 非法数据地址 */ return; }异常响应也要按规范组包:事务 ID、协议 ID、长度原样,单元 ID 回显,功能码最高位置 1,最后跟一个异常码。0x02 是非法数据地址,0x01 是非法功能码,0x03 是非法数据值。三个码别混用,否则上位机日志里会给出误导性提示。
4. 并发与稳定:单线程轮询不够用,epoll 和超时管理怎么做
4.1 轮询模型决定服务端架构:连接少但不能随便断
Modbus TCP 的典型工业场景是 1 到 4 个上位机或触摸屏,以 100ms 到 1s 的周期持续轮询,每条连接可能保持数小时。这种负载远不如互联网高并发,但它有一个特殊要求:任何一条连接都不能因为服务端的粗心而断掉,否则组态软件会弹报警。
每连接一线程的方案在小规模下足够稳,代码也最直观。但如果现场有几十台采集器同时连过来,每个线程默认栈占用 8MB 虚拟内存,进程地址空间会被白白浪费,而且线程切换开销开始显现。所以当并发连接数超过 30 左右,我会改用 epoll 事件驱动。
4.2 epoll 事件驱动服务端的基本结构
epoll 的模式是把所有 socket 丢给内核,等有事件再逐一出队处理。服务端只用一个线程就能承载大量连接,而且可以从容处理半包。基础结构如下:
int epfd = epoll_create1(0); struct epoll_event ev = {0}; ev.events = EPOLLIN | EPOLLRDHUP; /* 关注读事件和对端关闭 */ ev.data.fd = server_fd; epoll_ctl(epfd, EPOLL_CTL_ADD, server_fd, &ev); struct epoll_event events[64]; while (1) { int n = epoll_wait(epfd, events, 64, 1000); for (int i = 0; i < n; i++) { if (events[i].data.fd == server_fd) { int cfd = accept(server_fd, NULL, NULL); ev.events = EPOLLIN | EPOLLRDHUP; ev.data.fd = cfd; epoll_ctl(epfd, EPOLL_CTL_ADD, cfd, &ev); } else { handle_client_data(events[i].data.fd); } } }这里用的是水平触发,也就是默认模式:只要缓冲区里还有数据,epoll_wait 就会持续报告这个 fd。好处是处理逻辑不必担心漏读,坏处是如果一次读不完,下次循环还会唤醒。工业环境流量小,水平触发更省心,不容易翻车。
每个连接必须单独维护一个接收缓冲区,不能用全局缓冲区,否则两个客户端的帧会互相污染。我习惯给每个 fd 分配一个struct client_ctx,里面放 rx_buf、used 和最近活跃时间戳,用 epoll_event.data.ptr 指向它。
4.3 超时与断连:套接字 keepalive 和应用层心跳
Modbus TCP 本身没有应用层心跳,链路是否存活要依赖 TCP 层。最常见的场景是电缆被拔掉或对端断电,服务端很长时间不知道连接已死,直到写数据时才收到 EPIPE。这里需要用 TCP 保活机制兜底。
int keepalive = 1; int keepidle = 30; int keepintvl = 5; int keepcnt = 3; setsockopt(fd, SOL_SOCKET, SO_KEEPALIVE, &keepalive, sizeof(keepalive)); setsockopt(fd, IPPROTO_TCP, TCP_KEEPIDLE, &keepidle, sizeof(keepidle)); setsockopt(fd, IPPROTO_TCP, TCP_KEEPINTVL, &keepintvl, sizeof(keepintvl)); setsockopt(fd, IPPROTO_TCP, TCP_KEEPCNT, &keepcnt, sizeof(keepcnt));注意 TCP_KEEPIDLE、TCP_KEEPINTVL、TCP_KEEPCNT 这三个选项只有 Linux 支持,定义在<netinet/tcp.h>里。参数含义是空闲 30 秒开始探测,每 5 秒一次,3 次失败判定连接断开。如果目标平台不是 Linux,宏可能不存在,编译阶段要用条件编译包一下。
保活探测不能替代业务超时。即使 TCP 层认为连接正常,设备也可能在应用层卡死。更进一步的做法是在 epoll_wait 返回后扫描所有 client_ctx,把超过 3 分钟没有收到任何请求的连接主动 close。这个值不要设太短,因为组态软件按周期扫描,间隔大是正常的;太激进会把半休眠的上位机踢下线。
5. 排坑:设备连上却读不到数,问题通常出在这五处
5.1 TCP 半包把帧切碎,服务端按固定长度收包导致超时
现象:上位机显示连接已建立,但请求总超时,服务端日志里看不到完整功能码,偶尔收到一串乱码。
原因:recv 返回的数据长度和 Modbus 帧边界完全无关。比如读保持寄存器请求 12 字节,内核可能先给 4 字节,剩下的 8 字节下一轮才到。服务端如果第一次没凑够就回包,回的是半截数据,客户端只能丢掉等超时。
解决:按 2.3 节的 frame_total_length 逻辑做缓冲。每次 recv 后先追加到 rx_buf,再循环尝试解析完整帧,直到没有完整帧为止。如果使用多线程模型,每个连接一个 rx_buf,避免并发覆盖。
5.2 大小端颠倒,寄存器值变成 0x3412
现象:写入 0x1234,上位机读出来却是 0x3412;或者读模拟量数值完全不对,像是换了字节序。
原因:Modbus 规定多字节数据大端传输,x86 主机内存却是小端。直接把寄存器数组的地址塞进响应帧,比如memcpy(rsp+pos, &holding_regs[i], 2),就会把低字节先发出去。
解决:所有 16 位读写都显式按大端拆装,不要依赖 memcpy。读侧用(req[6] << 8) | req[7],写侧用rsp[pos++] = (val >> 8) & 0xFF; rsp[pos++] = val & 0xFF;。如果想省事,调用 htons 和 ntohs 也可以,但要记住这是网络字节序转换,不是数值翻转。
5.3 上位机地址 40001 对应协议地址 0,偏一位让数据全错位
现象:上位机配置读取起始寄存器 40001,服务端在数组下标 40001 段返回数据,结果每个点都比正常值慢一个寄存器,或者直接越界异常。
原因:协议层寄存器地址从 0 开始,40001 这种显示地址是组态软件的人为约定。服务端如果拿显示地址直接查表,自然错位。
解决:服务端统一按协议原始地址索引寄存器数组。如果上位机确实发送了地址 0,就查表下标 0;如果某些触摸屏坚持发 1,就在服务端入口做一次addr = addr - 1的规整化。关键是全项目只用一个约定,并在日志里打印原始地址方便核对。
5.4 只实现单寄存器写,上位机用 0x10 写多寄存器失败
现象:组态软件能正常读,也能写单个值,但批量下置参数时服务端没有任何反应,上位机回读时数值还是旧的。
原因:功能码分发漏了 0x10。有些上位机对非法功能码的默认处理是逐个发 0x06 写单寄存器,能写通;但更严格的组态软件在 0x10 被拒后会直接报配置错误。
解决:把 0x10 写多寄存器补上。解析时要按顺序读起始地址、数量、字节数,再按大端序把数据依次写入寄存器。数量可能超过 120,注意请求帧长度一般不超过 253 字节,数据部分最多 240 字节,超过就要在验证阶段拦下来。
case 0x10: if (frame_len < 12) { send_exception(...); break; } /* frame[6..7]=addr, frame[8..9]=qty, frame[10]=byte_cnt */ uint8_t byte_cnt = frame[10]; for (int i = 0; i < byte_cnt / 2; i++) { holding_regs[addr + i] = (frame[11 + 2*i] << 8) | frame[12 + 2*i]; } break;5.5 非 root 用户绑不上 502 端口,部署到生产直接起不来
现象:测试机用 root 跑一切正常,部署到生产换普通用户启动,进程秒退,日志里报Permission denied或Operation not permitted。
原因:Linux 约定 1024 以下端口是特权端口,普通进程不能 bind。502 正好在范围内,生产环境为了最小权限通常用非 root 用户运行服务,于是起不来。
解决:给二进制文件加上绑定低端口的权限位,或者把服务端口换成 1502。我用得最多的是 setcap 方式,因为它不用改业务代码。
sudo setcap cap_net_bind_service=+ep /usr/local/bin/modbus_tcp_server注意 setcap 只对编译好的二进制文件有效,脚本或解释型程序不行。如果你的服务端是 Python 跑的,要么用特权进程代理端口,要么干脆配置上位机连 1502。还有一种方式是让系统服务管理器在启动前把 socket 打开再让应用继承,但配置复杂度明显高,小场景没必要。
6. 从能跑到能上线:加日志、做压测,还有两个保命习惯
每次请求都打一行日志,是后期排查现场问题的保命手段。我在服务端每个功能码分发入口都会打印事务 ID、功能码、起始地址、数量和本帧处理耗时。事务 ID 要按十六进制原样打印,这样和抓包工具比对时不会眼花。
上线前做一次最少 30 分钟的压测。最有效的工具不是复杂压测框架,而是一段几十行的 Python 脚本:开 20 个 TCP 连接,每个循环发读请求并校验响应的事务 ID 和数据长度,统计成功率。这一步能暴露并发下的脏读和半包处理问题。寄存器表结构一改,我会立刻跑一遍这个自测脚本,曾经有一次改完点表长度后越界写入,就是靠自测抓出来的。
日志级别在生产环境保留 DEBUG,但不输出到终端,而是落到文件并做轮转。现场调试时,打开抓包工具对比服务端日志和实际流量,看 MBAP 长度字段是否一致,功能码是否被正确分发。这套组合下来,大部分问题都能在十分钟内定位。
严格来说,Modbus TCP 服务端不难,难的是你愿不愿意在协议边界和内存安全上多花一天。我的习惯是每写一个新功能码,就手动拼一帧请求用脚本回读验证,而不是直接交付给上位机联调。这个习惯救过我很多次,希望也能帮到你。
本文还有配套的精品资源,点击获取