Modbus TCP 从站 C++ 实战:寄存器映射与请求循环 简介这是一份面向工业自动化与嵌入式开发者的 C Modbus 从站服务端小项目源码聚焦 0x03 功能码读取保持寄存器的完整实现适合想深入理解 Modbus 协议报文解析、请求响应流程与网络通信细节的中级 C 学习者。压缩包共 49 个文件约 13.45MB以 cpp 与 h 源码、vcxproj 工程配置、sln 解决方案、exe 可执行文件及 pdb、obj 等调试中间文件为主另含 config 配置与 sample 示例便于直接编译运行与二次开发。项目围绕功能码识别、寄存器数据读取、响应报文与 CRC 校验构建并预留了扩展 0x01、0x06 等功能的接口可作为自建 Modbus 通信系统的起点。目前已有 1885 人学习下载通过阅读源码可掌握报文解析、异常响应处理与 TCP/串口通信的工程化写法。1. Modbus 服务端从站到底在写什么一个 C 小项目的真实骨架很多人第一次接触 Modbus 服务端从站C 小项目源码脑子里浮现的是“协议解析”四个字然后一头扎进功能码表里背 0x03、0x10。但真正动手写一个能跑、能被上位机轮询、能稳定响应好几天的从站程序核心难点根本不在协议本身而在于你如何组织寄存器数据、如何管理连接生命周期、如何让主站轮询时不超时。我见过太多人拿着开源库改两行就上线结果现场调试时主站一读就断查了半天发现是 TCP 粘包处理没做对。这个标题对应的东西说白了就是一个跑在设备侧或网关侧的小型服务程序监听 502 端口等主站来读线圈、读保持寄存器、写单个寄存器然后老老实实返回响应。它适合谁适合做工业数据采集的嵌入式工程师、做上位机联调的自动化从业者以及想拿一个真实协议练手 C 网络编程的开发者。你不需要先成为 Modbus 专家但你需要知道从站是被动的——它从不主动发数据所有交互都由主站发起这个角色定位决定了整个代码结构。2. 从零搭一个 Modbus TCP 从站寄存器映射与请求循环2.1 先定数据模型四张表怎么映射到 C 结构Modbus 协议规定了四种数据区线圈Coils可读写布尔量、离散输入Discrete Inputs只读布尔量、保持寄存器Holding Registers可读写 16 位字、输入寄存器Input Registers只读 16 位字。写从站的第一步不是写 socket而是把这四张表用 C 容器表达出来。常见做法是用std::vectoruint8_t存线圈和离散输入用std::vectoruint16_t存保持寄存器和输入寄存器。为什么不用数组因为从站地址范围可能从 0 到 65535但实际项目里往往只用到前几百个用 vector 可以按需分配也方便后续做边界检查。#include vector #include cstdint #include mutex struct ModbusDataBank { std::vectoruint8_t coils; // 线圈可读写 std::vectoruint8_t discreteInputs; // 离散输入只读 std::vectoruint16_t holdingRegs; // 保持寄存器可读写 std::vectoruint16_t inputRegs; // 输入寄存器只读 std::mutex mtx; // 保护共享数据 ModbusDataBank(size_t coilCnt, size_t diCnt, size_t hrCnt, size_t irCnt) : coils(coilCnt, 0), discreteInputs(diCnt, 0), holdingRegs(hrCnt, 0), inputRegs(irCnt, 0) {} };这段代码的关键在于std::mutex。从站通常至少有两个线程一个负责接收主站请求并解析另一个负责更新输入寄存器比如采集传感器数据。如果不在数据区上加锁主站读到的可能是半新半旧的数据现场表现就是“数值偶尔跳变”。参数说明四个构造函数参数分别对应四张表的初始大小建议按实际点位数量加 20% 余量避免后期扩展时改代码。2.2 请求解析MBAP 头 PDU 的最小处理流程Modbus TCP 的报文结构是 MBAP 头7 字节加 PDU。MBAP 头包含事务标识符2 字节、协议标识符2 字节固定 0、长度2 字节、单元标识符1 字节。很多新手直接读 socket 然后按固定长度解析结果遇到主站合并发送或分片发送就翻车。正确做法是先读 7 字节 MBAP 头从中取出长度字段再读剩余字节。#include arpa/inet.h #include unistd.h #include cstring bool readFull(int fd, uint8_t* buf, size_t len) { size_t got 0; while (got len) { ssize_t n ::read(fd, buf got, len - got); if (n 0) return false; got static_castsize_t(n); } return true; } bool recvModbusFrame(int fd, std::vectoruint8_t frame) { uint8_t mbap[7]; if (!readFull(fd, mbap, 7)) return false; uint16_t protoId (mbap[2] 8) | mbap[3]; if (protoId ! 0) return false; // 协议标识必须为 0 uint16_t length (mbap[4] 8) | mbap[5]; if (length 2 || length 260) return false; // 合理范围检查 frame.resize(6 length); std::memcpy(frame.data(), mbap, 6); if (!readFull(fd, frame.data() 6, length)) return false; return true; }逻辑说明readFull解决 TCP 粘包和分片问题确保读到指定字节数才返回。recvModbusFrame先读 7 字节 MBAP校验协议标识符是否为 0再根据长度字段读取剩余部分。参数说明长度字段最大 260 是 Modbus TCP 的规范上限超过这个值说明报文异常直接丢弃连接比继续解析更安全。注意frame里存的是从 MBAP 第 0 字节到 PDU 结束的完整报文后续功能码解析从frame[7]开始。2.3 功能码分发用 switch 还是查表功能码处理是整个从站的核心逻辑。常见做法是用switch语句分发因为功能码数量有限常用就 0x01、0x02、0x03、0x04、0x05、0x06、0x0F、0x10 这八个而且每个分支的处理逻辑差异较大。查表法适合功能码多且处理逻辑统一的情况但 Modbus 从站显然不属于这种。下面以读保持寄存器0x03和写多个寄存器0x10为例。std::vectoruint8_t handleRequest(const std::vectoruint8_t frame, ModbusDataBank bank) { uint8_t funcCode frame[7]; std::vectoruint8_t resp; resp.resize(8); // MBAP 头占 7 字节功能码占 1 字节 // 事务标识符和单元标识符原样返回 resp[0] frame[0]; resp[1] frame[1]; resp[2] 0; resp[3] 0; resp[6] frame[6]; resp[7] funcCode; std::lock_guardstd::mutex lock(bank.mtx); if (funcCode 0x03) { uint16_t startAddr (frame[8] 8) | frame[9]; uint16_t quantity (frame[10] 8) | frame[11]; if (quantity 1 || quantity 125 || startAddr quantity bank.holdingRegs.size()) { resp[7] funcCode | 0x80; // 异常响应 resp.push_back(0x02); // 非法数据地址 resp[4] 0; resp[5] 3; return resp; } resp[4] 0; resp[5] static_castuint8_t(3 quantity * 2); resp.push_back(static_castuint8_t(quantity * 2)); for (uint16_t i 0; i quantity; i) { uint16_t val bank.holdingRegs[startAddr i]; resp.push_back(static_castuint8_t(val 8)); resp.push_back(static_castuint8_t(val 0xFF)); } } else if (funcCode 0x10) { uint16_t startAddr (frame[8] 8) | frame[9]; uint16_t quantity (frame[10] 8) | frame[11]; if (quantity 1 || quantity 123 || startAddr quantity bank.holdingRegs.size()) { resp[7] funcCode | 0x80; resp.push_back(0x02); resp[4] 0; resp[5] 3; return resp; } for (uint16_t i 0; i quantity; i) { bank.holdingRegs[startAddr i] (frame[13 i * 2] 8) | frame[14 i * 2]; } resp[4] 0; resp[5] 6; resp.push_back(static_castuint8_t(startAddr 8)); resp.push_back(static_castuint8_t(startAddr 0xFF)); resp.push_back(static_castuint8_t(quantity 8)); resp.push_back(static_castuint8_t(quantity 0xFF)); } else { resp[7] funcCode | 0x80; resp.push_back(0x01); // 非法功能码 resp[4] 0; resp[5] 3; } return resp; }逻辑说明响应报文的前 7 字节是 MBAP 头第 8 字节是功能码。异常响应时功能码最高位置 1后面跟异常码。参数说明0x03 功能码单次最多读 125 个寄存器0x10 最多写 123 个这是协议规定的上限超过就返回异常码 0x02。注意resp[4]和resp[5]是长度字段必须根据实际响应内容动态计算写死长度是新手最常见的翻车点之一。2.4 主循环与连接管理单连接还是多连接从站的主循环通常长这样创建监听 socket绑定 502 端口进入accept循环。每来一个主站连接可以开一个线程处理也可以在主循环里用select或epoll做多路复用。对于小型项目我一般会开一个线程专门处理一个连接因为主站数量通常不多一两个线程模型写起来直观调试也方便。但要注意设置SO_REUSEADDR否则程序重启时会因为端口未释放而绑定失败。#include sys/socket.h #include netinet/in.h int createListenSocket(uint16_t port) { int fd ::socket(AF_INET, SOCK_STREAM, 0); if (fd 0) return -1; int opt 1; ::setsockopt(fd, SOL_SOCKET, SO_REUSEADDR, opt, sizeof(opt)); sockaddr_in addr{}; addr.sin_family AF_INET; addr.sin_addr.s_addr INADDR_ANY; addr.sin_port htons(port); if (::bind(fd, reinterpret_castsockaddr*(addr), sizeof(addr)) 0) { ::close(fd); return -1; } if (::listen(fd, 5) 0) { ::close(fd); return -1; } return fd; }逻辑说明SO_REUSEADDR允许程序重启后立即绑定同一端口避免“Address already in use”。listen的 backlog 设为 5 足够小型项目使用。参数说明端口号 502 是 Modbus TCP 的标准端口但非 root 用户无法绑定 1024 以下端口开发阶段可以先用 1502 测试部署时再用 root 或设置 capabilities。3. 避坑指南从站调试中最容易翻车的五个地方3.1 字节序搞反主站读到的值永远是错的现象主站读保持寄存器返回的数值和预期完全对不上比如预期 1000 读到 59395。原因Modbus 规定寄存器内部是大端序但很多开发者在 C 里直接memcpy或者用联合体转换忽略了 x86 是小端序。解决所有多字节数值在写入响应报文前必须手动拆成高字节在前、低字节在后就像 2.3 节代码里那样用移位操作不要图省事用reinterpret_cast。3.2 异常响应格式写错主站直接断开连接现象主站发送一个越界地址请求从站返回了数据而不是异常码主站解析失败后断开。原因异常响应的功能码要或上 0x80且长度字段要相应调整。很多人在switch的default分支里直接返回空 vector 或者原样返回请求这都是错的。解决严格按照协议构造异常响应异常码 0x01 表示非法功能码0x02 表示非法数据地址0x03 表示非法数据值。3.3 多线程读写寄存器不加锁数据出现“幽灵跳变”现象主站轮询时偶尔读到明显不合理的数值刷新一下又正常了。原因采集线程正在更新输入寄存器主站读线程同时读取读到了写了一半的数据。解决在数据区结构体里加std::mutex所有读写操作前先加锁。注意锁的粒度要小只保护数据拷贝那几行不要在锁里做网络发送。3.4 连接断开后没有清理文件描述符越积越多现象从站运行几个小时后不再响应新连接accept返回 -1。原因主站异常断开时没有关闭对应的 socket文件描述符泄漏。解决在每个连接处理线程的退出路径上确保close(fd)被调用可以用 RAII 封装一个SocketGuard类析构时自动关闭。另外建议加一个连接数上限超过就拒绝新连接。3.5 单元标识符处理不当网关场景下响应发错设备现象通过网关连接多个从站时主站收到的响应来自错误的设备。原因Modbus TCP 的单元标识符用于网关场景下区分后端设备从站响应时必须原样返回请求中的单元标识符。很多简化实现直接写死 0x01 或 0xFF。解决从请求报文的第 6 字节取出单元标识符原样填入响应的第 6 字节不要做任何修改。4. 让从站更接近实战三个进阶技巧与验证方法4.1 用寄存器映射表替代硬编码地址小型项目里直接在代码里写holdingRegs[0]表示温度、holdingRegs[1]表示湿度短期没问题但点位一多就乱。我一般会加一个简单的映射表用枚举或常量定义地址偏移再配一个配置文件描述每个点位的类型和地址。这样主站文档和代码能对上后期改点位不用翻遍整个源文件。enum class RegAddr : uint16_t { Temperature 0, Humidity 1, Pressure 2, DeviceStatus 10 }; // 使用示例 bank.holdingRegs[static_castuint16_t(RegAddr::Temperature)] readTemp();4.2 加一个简单的请求日志排查问题时不用抓包现场调试时抓包工具不一定随时可用。在从站里加一行日志把收到的功能码、起始地址、数量打印出来能解决八成“主站说发了但从站没反应”的扯皮。日志建议写到独立文件按天切割避免刷屏。注意不要在日志里打印完整报文只打关键字段否则高频轮询时日志文件会爆炸。4.3 验证从站是否正常的三个自检步骤第一步用netstat -an | grep 502确认端口处于监听状态。第二步用 Modbus 主站模拟工具发一个读保持寄存器请求看是否能收到正确响应。第三步连续发送 1000 次请求观察响应时间是否稳定、有无超时。如果第三步出现偶发超时大概率是锁竞争或日志写磁盘导致的把日志改成异步写或者降低日志级别再测。4.4 一个我踩过的坑不要在主循环里做阻塞操作早期版本我在请求处理函数里直接调用了一个阻塞的传感器读取接口结果主站轮询间隔稍微短一点就超时。后来改成采集线程定时更新输入寄存器请求处理只读内存数据问题消失。这个教训让我养成了一个习惯从站的请求处理路径上任何可能阻塞超过 10 毫秒的操作都要挪到独立线程。希望帮到你。本文还有配套的精品资源点击获取