简介这是一份面向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、长度字段和单元 IDModbus TCP 和串口 RTU 最大的不同是帧头TCP 帧不携带 CRC也不怕丢字节因为它跑在 TCP 之上可靠性由传输层保证。真正需要自己处理的额外头叫 MBAP Header固定 6 个字节字段排布如下字段长度取值说明事务标识符 Transaction ID2 字节请求方随机或递增响应必须原样回拷用于区分并发请求协议标识符 Protocol ID2 字节恒为 00 表示 Modbus 协议长度字段 Length2 字节后续字节数从单元 ID 开始算到帧末尾单元标识符 Unit ID1 字节0x00-0xFF相当于从站地址多设备网关会用到长度字段是拆包的关键。比如一个读保持寄存器请求负载部分由 1 字节功能码、2 字节起始地址、2 字节寄存器数量组成加上单元 ID 1 字节总共 6 字节所以 Length 的十六进制值就是 0x0006。响应帧同样按这个规则推导1 字节单元 ID 1 字节功能码回显 1 字节字节数 2*N 字节寄存器值Length 就是 32N。很多新手在这里翻车是因为把 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 B0x04读输入寄存器Input Register地址 2B 数量 2B字节数 1B 字数据 2*N B0x05写单线圈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(rsppos, 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_serviceep /usr/local/bin/modbus_tcp_server注意 setcap 只对编译好的二进制文件有效脚本或解释型程序不行。如果你的服务端是 Python 跑的要么用特权进程代理端口要么干脆配置上位机连 1502。还有一种方式是让系统服务管理器在启动前把 socket 打开再让应用继承但配置复杂度明显高小场景没必要。6. 从能跑到能上线加日志、做压测还有两个保命习惯每次请求都打一行日志是后期排查现场问题的保命手段。我在服务端每个功能码分发入口都会打印事务 ID、功能码、起始地址、数量和本帧处理耗时。事务 ID 要按十六进制原样打印这样和抓包工具比对时不会眼花。上线前做一次最少 30 分钟的压测。最有效的工具不是复杂压测框架而是一段几十行的 Python 脚本开 20 个 TCP 连接每个循环发读请求并校验响应的事务 ID 和数据长度统计成功率。这一步能暴露并发下的脏读和半包处理问题。寄存器表结构一改我会立刻跑一遍这个自测脚本曾经有一次改完点表长度后越界写入就是靠自测抓出来的。日志级别在生产环境保留 DEBUG但不输出到终端而是落到文件并做轮转。现场调试时打开抓包工具对比服务端日志和实际流量看 MBAP 长度字段是否一致功能码是否被正确分发。这套组合下来大部分问题都能在十分钟内定位。严格来说Modbus TCP 服务端不难难的是你愿不愿意在协议边界和内存安全上多花一天。我的习惯是每写一个新功能码就手动拼一帧请求用脚本回读验证而不是直接交付给上位机联调。这个习惯救过我很多次希望也能帮到你。本文还有配套的精品资源点击获取