简介:这是一份面向工业自动化通信学习者的 C++ Modbus 从站(服务端)小型项目源码,重点围绕 0x03 功能码读取保持寄存器的请求解析、数据读取、响应打包与 CRC 校验展开,并涉及 TCP/IP 或串行链路下的通信处理、异常响应及后续功能码扩展接口,适合具备一定 C++ 基础、希望理解 Modbus 协议实现细节的开发者参考。压缩包共 49 个文件,约 13.45MB,包含 cpp 与 h 头文件、Visual Studio 的 sln/vcxproj 工程文件、可执行与调试产物以及配置和日志类文件,便于直接编译运行和对照调试。已有 1886 人学习,说明其在协议入门与工程实践中有一定参考价值。读者可从中梳理从站服务的模块划分、端口封装与共享定义方式,理解请求校验、寄存器访问和响应组包流程,并以此为基础扩展线圈读写、单寄存器写入等功能,形成自己的 Modbus 通信系统起点。
1. 从一条 8 字节报文说起:这个 C++ Modbus 从站到底解决什么问题
去年帮一个做产线改造的朋友接设备,现场摆着一台老 PLC,几个传感器挂在上头,甲方要求把这几个值同步给一套上位机采集系统。上位机只认 Modbus TCP,PLC 那边又动不了,最省事的办法就是在中间放一个小程序,自己监听端口,扮演从站(服务端),把数据摆成寄存器让上位机来读。这份 C++ Modbus 从站源码干的就是这件事:一个不依赖任何第三方协议库的纯 C++ 实现,起一个 TCP 服务,把线圈、离散输入、保持寄存器、输入寄存器四类数据区暴露出去,响应 0x01~0x10 这一批常用功能码。它适合谁?适合需要在本地进程里内嵌一个从站、把业务数据伪装成 PLC 的人,也适合想搞清楚 Modbus 报文到底长什么样、不想只停留在调库层面的开发者。整个项目不大,但协议层、连接层、数据层是分得开的,拿来改比拿来读更有价值。
2. 工程骨架与目录结构:四个数据区怎么撑起整个从站
把源码摊开,最值得先看的不是协议解析,而是它怎么划分职责。Modbus 从站看着简单,其实有三件事在同时跑:字节流的接收与拆分、功能码的语义处理、以及被这些功能码读写的四个数据区。这三件事混在一个文件里也能跑,但等到你要把寄存器换成真实设备的实时值时,就会知道分层有多重要。这个项目把连接管理放在net/,把 PDU 处理放在protocol/,把数据区抽成独立的DataStore,中间用一层很薄的会话对象串起来。
2.1 目录布局与构建方式
源码树的组织基本是按「网络 → 协议 → 数据」这条链路切的,没有多余的抽象层,新手翻一遍就能建立完整印象:
modbus-slave/ ├── CMakeLists.txt ├── include/ │ ├── datastore.h # 四类数据区的读写接口 │ ├── mbap.h # MBAP 头结构定义与字节序工具 │ ├── pdu.h # PDU 编解码辅助 │ └── slave_server.h # TCP 监听、连接管理、会话调度 └── src/ ├── datastore.cpp ├── pdu.cpp # 功能码实现:0x01/0x02/0x03/0x04/0x05/0x06/0x0F/0x10 ├── slave_server.cpp └── main.cpp # 命令行参数解析、启动入口把这些文件对上号之后,读代码的路径就很清楚了:main.cpp解析参数并构造SlaveServer,SlaveServer在一个循环里等连接、收字节、凑整帧,凑齐之后把 PDU 交给pdu.cpp里的分发函数,分发函数再去调DataStore拿数据或写数据,最后把响应原路塞回 socket。这个顺序在整个项目里没有变过,改代码时只要守住它,基本不会乱。
构建用的是 CMake,最低 C++11 就能编,实际写的时候建议用 C++14 以上,std::make_unique之类的用起来顺手。一份能跑的CMakeLists.txt长这样:
cmake_minimum_required(VERSION 3.10) project(modbus_slave CXX) set(CMAKE_CXX_STANDARD 14) set(CMAKE_CXX_STANDARD_REQUIRED ON) # Windows 下需要 ws2_32,Linux 下走 POSIX socket if(WIN32) set(PLATFORM_LIBS ws2_32) else() set(PLATFORM_LIBS pthread) endif() include_directories(${CMAKE_SOURCE_DIR}/include) file(GLOB SOURCES ${CMAKE_SOURCE_DIR}/src/*.cpp) add_executable(modbus_slave ${SOURCES}) target_link_libraries(modbus_slave ${PLATFORM_LIBS})PLATFORM_LIBS这一行是跨平台的关键:Linux 上 socket 相关函数默认就在 libc 里,链接线程库就够;Windows 上必须显式链ws2_32,而且启动时要先WSAStartup。很多人在 Windows 上编出来的第一版直接报undefined reference to socket,就是漏了这行,翻车基本都翻在这。CMAKE_CXX_STANDARD设 14 是保守选择,如果你后面的二次开发要用到std::optional或者结构化绑定,改到 17 就行,源码本身没有依赖任何 17 的特性。
2.2 数据模型:线圈、离散输入、保持寄存器、输入寄存器
Modbus 之所以能当工业界的通用语言,靠的就是这套极简的数据模型:一切皆可寻址,每个地址要么是一位(bit),要么是一个 16 位字(register)。四类数据区各自独立编址,互不覆盖,靠功能码来区分访问的是哪一块。
| 数据区 | 位宽 | 访问方式 | 典型功能码 | 地址习惯写法 |
|---|---|---|---|---|
| 线圈 Coils | 1 bit | 读写 | 0x01 / 0x05 / 0x0F | 0xxxx |
| 离散输入 Discrete Inputs | 1 bit | 只读 | 0x02 | 1xxxx |
| 输入寄存器 Input Registers | 16 bit | 只读 | 0x04 | 3xxxx |
| 保持寄存器 Holding Registers | 16 bit | 读写 | 0x03 / 0x06 / 0x10 | 4xxxx |
注意表里「地址习惯写法」这一列,它和协议报文里的偏移量不是一回事,这个区别后面第 5 章会专门讲,是新手最容易栽的地方。数据区在代码里用最朴素的std::vector表示,线圈类用bool或uint8_t一位一个元素,寄存器类用uint16_t一个字一个元素:
// include/datastore.h #pragma once #include <cstdint> #include <vector> #include <mutex> // Modbus 四类数据区,各自独立编址,长度在构造时确定 // 大小给多少完全取决于你的点位表,常见范围是 64 ~ 4096 个地址 class DataStore { public: DataStore(size_t coilCount, size_t discreteCount, size_t holdingCount, size_t inputCount) : coils_(coilCount, false), discretes_(discreteCount, false), holdings_(holdingCount, 0), inputs_(inputCount, 0) {} size_t coilSize() const { return coils_.size(); } size_t discreteSize() const { return discretes_.size(); } size_t holdingSize() const { return holdings_.size(); } size_t inputSize() const { return inputs_.size(); } // 单 bit 读,越界返回 false 由调用方转成异常码 0x02 bool getCoil(uint16_t addr) const { std::lock_guard<std::mutex> lk(mtx_); return addr < coils_.size() ? coils_[addr] : false; } void setCoil(uint16_t addr, bool v) { std::lock_guard<std::mutex> lk(mtx_); if (addr < coils_.size()) coils_[addr] = v; } // 16 位字读,注意这里存的是主机字节序,打包成报文时才转大端 uint16_t getHolding(uint16_t addr) const { std::lock_guard<std::mutex> lk(mtx_); return addr < holdings_.size() ? holdings_[addr] : 0; } void setHolding(uint16_t addr, uint16_t v) { std::lock_guard<std::mutex> lk(mtx_); if (addr < holdings_.size()) holdings_[addr] = v; } // 离散输入 / 输入寄存器同理,此处略 private: mutable std::mutex mtx_; std::vector<uint8_t> coils_; std::vector<uint8_t> discretes_; std::vector<uint16_t> holdings_; std::vector<uint16_t> inputs_; };这段代码里有两个决定要不要改的判断。第一,内部一律用主机字节序存数据,只有序列化到报文缓冲区的那一刻才转成大端,这样业务代码读写寄存器不需要关心网络序,出错面小很多。第二,std::mutex是给多线程场景预留的——如果你的从站只有单线程处理连接,这个锁完全是浪费,可以直接删掉换成裸std::vector访问;但只要后面加了一个后台采集线程去刷新输入寄存器,这把锁就必须留着,否则就是典型的数据竞争,表现是读出来的值偶尔跳变,非常难查。构造函数四个参数就是四个区的容量,直接对应你的设备点位表,宁大勿小,Modbus 允许地址空洞,多出来的没人读也不影响。
2.3 连接层与主循环:一次会话怎么走完
从站的骨架是一个典型的「监听 → accept → 读请求 → 写响应」循环。小型从站没必要上 Reactor,select就够用,连接数在几十个以内时性能完全不是问题,代码也比 epoll 好懂十倍:
// src/slave_server.cpp(节选) void SlaveServer::run() { // 1. 建监听 socket,绑端口,开始 listen int listenFd = createListener(port_); if (listenFd < 0) return; while (running_) { fd_set rfds; FD_ZERO(&rfds); FD_SET(listenFd, &rfds); int maxFd = listenFd; for (int fd : clients_) { FD_SET(fd, &rfds); maxFd = std::max(maxFd, fd); } // 超时 500ms,保证 running_ 能被及时检查到,不然 Ctrl+C 退不干净 timeval tv{0, 500 * 1000}; int n = ::select(maxFd + 1, &rfds, nullptr, nullptr, &tv); if (n < 0) { if (errno == EINTR) continue; // 被信号打断,重来 break; } if (n == 0) continue; // 纯超时,回去看 running_ if (FD_ISSET(listenFd, &rfds)) { int cfd = ::accept(listenFd, nullptr, nullptr); if (cfd >= 0) clients_.push_back(cfd); } for (auto it = clients_.begin(); it != clients_.end(); ) { if (FD_ISSET(*it, &rfds)) { if (!handleOneRequest(*it)) { // 对端关闭或出错 ::close(*it); it = clients_.erase(it); continue; } } ++it; } } }handleOneRequest里干的事情就是第 3 章要展开的部分:先读满 6 字节拿到 MBAP 头的前半段,解出length,再按length读满剩下的字节。这里select的超时参数不能设成nullptr,否则进程收到终止信号时还阻塞在select上,表现是按了 Ctrl+C 半天不退出,只能kill -9,调试期特别烦。clients_用std::vector<int>存裸 fd,规模小的时候完全够用,超过几百个再考虑换成std::unordered_set或者干脆上 epoll,那是另一个量级的改造了。
3. 协议层拆解:ADU、PDU 与功能码分发
Modbus TCP 的报文结构比 RTU 简单得多,没有 CRC,靠 TCP 自己保证完整性,所以整个帧就是一个 7 字节的 MBAP 头加一段 PDU。看着简单,但真正写起来,边界判定和异常返回这两块最容易翻车,几乎每一个自己手写过 Modbus 的人都在这两个地方摔过。
3.1 MBAP 头与帧边界:length 字段到底算不算 unitId
先把帧结构钉死。MBAP 头 7 个字节,后面紧跟 PDU:
// include/mbap.h #pragma once #include <cstdint> // MBAP 头固定 7 字节,全部为大端(网络字节序) // 字段顺序:事务ID(2) | 协议ID(2) | 长度(2) | 单元ID(1) #pragma pack(push, 1) struct MbapHeader { uint16_t transactionId; // 客户端自增,响应必须原样带回 uint16_t protocolId; // Modbus 固定为 0 uint16_t length; // 后续字节数 = 1(单元ID) + PDU 长度 uint8_t unitId; // 从站地址,TCP 下常被忽略但仍需回填 }; #pragma pack(pop) static_assert(sizeof(MbapHeader) == 7, "MBAP header must be 7 bytes");length这个字段是头号血泪经验:它的值等于单元 ID 的 1 字节加上 PDU 长度,而不是只算 PDU。也就是说,一个读 10 个保持寄存器的请求,PDU 是03 00 00 00 0A共 5 字节,length要填 6 而不是 5。填错一位,客户端要么收不到完整响应一直等到超时,要么把下一帧的头当成这一帧的尾巴,表现是「偶尔能读通,大多时候超时」,这种玄学现象八成就是这里错了。
读帧的动作必须分两步,不能想当然一次recv拿全:
// 读满 n 字节,专门对付 TCP 半包 static bool recvAll(int fd, uint8_t* buf, size_t n) { size_t got = 0; while (got < n) { ssize_t r = ::recv(fd, buf + got, n - got, 0); if (r == 0) return false; // 对端正常关闭 if (r < 0) { if (errno == EINTR) continue; // 被信号打断,重试 return false; // 真出错 } got += static_cast<size_t>(r); } return true; } bool SlaveServer::handleOneRequest(int fd) { uint8_t head[6]; if (!recvAll(fd, head, 6)) return false; // 事务ID+协议ID+长度 uint16_t tid = (head[0] << 8) | head[1]; uint16_t pid = (head[2] << 8) | head[3]; uint16_t len = (head[4] << 8) | head[5]; // length 至少包含 1 字节单元ID,PDU 至少 1 字节功能码,所以下限是 2 // 上限按规范 PDU 最大 253 字节,加单元ID 就是 254 if (pid != 0 || len < 2 || len > 254) return false; std::vector<uint8_t> rest(len); if (!recvAll(fd, rest.data(), len)) return false; // 单元ID + PDU uint8_t unitId = rest[0]; const uint8_t* pdu = rest.data() + 1; size_t pduLen = len - 1; std::vector<uint8_t> respPdu; dispatchPdu(pdu, pduLen, respPdu); // 见 3.2 // 回包:MBAP 的 length 同样 = 1 + 响应 PDU 长度 std::vector<uint8_t> out; out.reserve(7 + respPdu.size()); out.push_back(tid >> 8); out.push_back(tid & 0xFF); // 事务ID 原样带回 out.push_back(0); out.push_back(0); // 协议ID 固定 0 uint16_t rlen = static_cast<uint16_t>(respPdu.size() + 1); out.push_back(rlen >> 8); out.push_back(rlen & 0xFF); out.push_back(unitId); out.insert(out.end(), respPdu.begin(), respPdu.end()); return sendAll(fd, out.data(), out.size()); }逐点说明。recvAll是必须的,因为 TCP 是字节流,客户端一次send的 12 字节,服务端可能分三次recv才收全,直接一个recv就解析等于给自己埋雷。pid != 0的判断可以拦住一些乱发数据的端口扫描器,避免它们把日志刷爆。长度上下限的校验既是防御也是规范要求,超出范围直接断连接比返回异常码更合适,因为这时候连帧边界都不可信了。响应里tid必须原样带回,这是客户端把请求和响应配对用的,丢了它客户端就会认为请求失败了。unitId在纯 TCP 场景下没有路由意义,但很多调试工具会校验它,所以照样回填,成本为零。
3.2 功能码分发:一张 switch 表把八个功能码拆开
PDU 的第一个字节就是功能码,后面跟参数。分发用switch最直白,也最好调试,比注册表模式实在:
// src/pdu.cpp(节选) void ModbusSlave::dispatchPdu(const uint8_t* pdu, size_t len, std::vector<uint8_t>& out) { out.clear(); if (len < 1) return; // 空 PDU,不回任何东西 uint8_t fc = pdu[0]; switch (fc) { case 0x01: handleReadBits(pdu, len, out, /*isCoil=*/true); break; case 0x02: handleReadBits(pdu, len, out, /*isCoil=*/false); break; case 0x03: handleReadRegisters(pdu, len, out, /*isHolding=*/true); break; case 0x04: handleReadRegisters(pdu, len, out, /*isHolding=*/false); break; case 0x05: handleWriteSingleCoil(pdu, len, out); break; case 0x06: handleWriteSingleRegister(pdu, len, out); break; case 0x0F: handleWriteMultipleCoils(pdu, len, out); break; case 0x10: handleWriteMultipleRegisters(pdu, len, out); break; default: // 不支持的功能码必须回异常,不能沉默 buildException(fc, 0x01, out); break; } }读保持寄存器这个分支最能代表整个协议层的写法,其它几个功能码结构几乎一样,只是数据宽度和数量上限不同:
void ModbusSlave::handleReadRegisters(const uint8_t* pdu, size_t len, std::vector<uint8_t>& out, bool isHolding) { if (len < 5) { buildException(pdu[0], 0x03, out); return; } // 参数不足 uint16_t start = (pdu[1] << 8) | pdu[2]; // 起始地址,从 0 开始 uint16_t qty = (pdu[3] << 8) | pdu[4]; // 读取数量 // 规范限制:0x03/0x04 单次最多 125 个寄存器,超了必须回 0x03 异常 if (qty == 0 || qty > 125) { buildException(pdu[0], 0x03, out); return; } size_t limit = isHolding ? store_.holdingSize() : store_.inputSize(); if (static_cast<size_t>(start) + qty > limit) { buildException(pdu[0], 0x02, out); // 起始地址 + 数量 越界 → 0x02 return; } out.push_back(pdu[0]); // 功能码回显 out.push_back(static_cast<uint8_t>(qty * 2)); // 字节数 = 数量 × 2 for (uint16_t i = 0; i < qty; ++i) { uint16_t v = isHolding ? store_.getHolding(start + i) : store_.getInput(start + i); out.push_back(static_cast<uint8_t>(v >> 8)); // 高字节在前 out.push_back(static_cast<uint8_t>(v & 0xFF)); // 低字节在后 } }三个参数各自的作用说清楚:start是协议地址,从 0 起算,不是 40001;qty是本次要读多少个字,上限 125 来自规范,别自作主张放宽,某些客户端会按这个上限预分配缓冲区;地址越界的判断必须用start + qty > limit,只判start是常见的漏写,会导致读越界地址时返回脏数据而不是异常。字节序上,寄存器内部高低字节顺序是大端,这是 Modbus 规范定死的,和底层 CPU 无关。
3.3 异常响应:沉默比报错更可怕
异常响应的格式极简:功能码最高位置 1,后面跟一个异常码字节。
// 异常 PDU = (原功能码 | 0x80) + 异常码,总长固定 2 字节 void ModbusSlave::buildException(uint8_t fc, uint8_t code, std::vector<uint8_t>& out) { out.clear(); out.push_back(static_cast<uint8_t>(fc | 0x80)); out.push_back(code); }异常码里最常用的三个:0x01非法功能码、0x02非法数据地址、0x03非法数据值。三者的区别要卡死——功能码压根不认识用0x01;功能码认识但地址或地址+数量越界用0x02;地址没错但参数值不合法(比如数量为 0、写单个寄存器时字节数不是 2)用0x03。这个区分不是学术讲究,客户端拿它来决定是重试、报警还是重新下发配置,判错了会导致现场设备反复重连。真正要命的是不回包:请求收到了、处理失败了、什么都不发,客户端只能等自己的超时,通常 1 到 3 秒,然后重试,重试再超时,最后报一个「连接超时」——排查时看到这个提示根本猜不到是服务端某个分支提前return了。凡是能从请求里解析出功能码的情况,就一定得回点东西,这是这个协议层的铁律。
4. 跑起来:从零编译到读到第一个寄存器
代码看懂了和跑起来是两件事。这一章给的是能直接抄的执行路径,从编译、启动到用脚本读通第一个保持寄存器,中间不依赖任何 Modbus 库。
4.1 编译与启动参数
用 CMake 或者直接一行g++都行,先求跑通:
# 方式一:直接编 g++ -std=c++14 -O2 -pthread -Iinclude src/*.cpp -o modbus_slave # 方式二:CMake mkdir -p build && cd build cmake .. && make -j4 # 启动:监听 5020,从站地址 1,保持寄存器给 256 个 ./modbus_slave --port 5020 --unit 1 --coils 128 --holding 256 --input 64| 参数 | 默认值 | 说明 |
|---|---|---|
--port | 502 | TCP 监听端口,调试期建议先用 5020 这类高位端口 |
--unit | 1 | 从站地址,回填到 MBAP 的单元 ID 字段 |
--coils | 128 | 线圈区地址数量 |
--holding | 256 | 保持寄存器数量 |
--input | 64 | 输入寄存器数量 |
--log | off | 打开后逐帧打印十六进制报文,联调时必开 |
--port默认给 502 是 Modbus TCP 的标准端口,但这里有个必踩的坑:Linux 下绑定 1024 以下端口需要 root 权限,普通用户跑会直接bind: Permission denied。调试阶段一律先用 5020,等要上现场了再决定是加权限还是改标准端口。--log这个开关价值极高,联调时把每一帧的原始字节打出来,客户端和从站各执一词的时候,看一眼字节流就知道谁错了。
4.2 用原始 socket 脚本验证,不装任何库
装第三方库验证是绕远路,直接手搓一个报文发过去,能同时验证帧结构理解得对不对:
# test_read.py:读从站 0 号地址起的 4 个保持寄存器 import socket, struct def read_holding(sock, tid, unit, start, qty): # PDU: 功能码(1) + 起始地址(2) + 数量(2),全部大端 pdu = struct.pack(">BHH", 0x03, start, qty) # MBAP: 事务ID(2) + 协议ID(2) + 长度(2) + 单元ID(1) # 长度 = 1(单元ID) + len(pdu) mbap = struct.pack(">HHHB", tid, 0, len(pdu) + 1, unit) sock.sendall(mbap + pdu) head = sock.recv(6) # 先收 6 字节头 rtid, rpid, rlen = struct.unpack(">HHH", head) body = b"" while len(body) < rlen: # 按长度收全,防半包 body += sock.recv(rlen - len(body)) unit_id, fc = body[0], body[1] if fc & 0x80: # 最高位为 1 表示异常响应 raise RuntimeError(f"Modbus exception code=0x{body[2]:02X}") byte_count = body[2] regs = struct.unpack(">" + "H" * (byte_count // 2), body[3:3 + byte_count]) return rtid, regs if __name__ == "__main__": with socket.create_connection(("127.0.0.1", 5020), timeout=3) as s: tid, regs = read_holding(s, tid=1, unit=1, start=0, qty=4) print(f"transaction={tid} registers={list(regs)}")脚本里值得抠的两个点。第一,sock.recv(6)之后必须按rlen循环收——服务端分两次发、客户端一次收不完的情况天天有,直接recv(1024)拿到的可能只有半截,unpack立刻报错。第二,判断异常响应看的是功能码最高位,不是看长度,异常响应只有 3 字节 body(单元 ID + 功能码 + 异常码),正常响应至少 4 字节。跑通之后,把start改成 100、qty改成 200,应该分别看到0x02和0x03两种异常码,能稳定复现这两种异常,说明地址校验和数量校验都写对了。
4.3 Windows 下的启动差异
同一份源码在 Windows 上编译要改两处。一是入口处必须调WSAStartup(MAKEWORD(2,2), &ws),否则所有 socket 调用返回INVALID_SOCKET;二是close()要换成closesocket(),int要换成SOCKET。常见的做法是加一层极薄的头文件把这两个名字统一下:
// include/net_compat.h #pragma once #ifdef _WIN32 #include <winsock2.h> #include <ws2tcpip.h> #pragma comment(lib, "ws2_32.lib") using socket_t = SOCKET; inline void closeSocket(socket_t s) { closesocket(s); } #else #include <sys/socket.h> #include <netinet/in.h> #include <unistd.h> #include <arpa/inet.h> using socket_t = int; inline void closeSocket(socket_t s) { ::close(s); } #endifSOCKET在 Windows 上是UINT_PTR,直接拿int存会在大值句柄上截断,这种错误在调试期基本发现不了,上现场连接一多就炸。用socket_t别名统一掉,是低成本高收益的改法。
5. 避坑与排查:五个把联调拖到半夜的常见问题
这一章全是踩过的坑,按「现象 → 原因 → 解决」写,遇到对得上的直接照方抓药。
现象一:客户端读出来的数整体放大 256 倍,或者高低字节颠倒。原因:序列化寄存器时先压了低字节,或者用memcpy把uint16_t直接拷进缓冲区,在小端机器上就变成了低字节在前。Modbus 规范要求寄存器内部高字节在前,这是网络序,和主机序无关。 解决:所有出报文的地方手写(v >> 8)和(v & 0xFF),绝不memcpy;所有入报文的地方手写(buf[0] << 8) | buf[1],绝不强转指针。这个习惯养成后,字节序类的问题一次都不会再出。
现象二:客户端报「非法数据地址」,但代码里地址范围明明是够的。原因:把 PLC 习惯的 40001 直接当成协议地址发了。协议报文里的地址永远从 0 开始,40001 对应的是偏移 0,40101 对应偏移 100。中间差了整整一个 1 的偏移量,全都偏一位。 解决:在客户端脚本里把「人看的地址」和「协议地址」用不同的变量名分开(比如humanAddr和protoAddr),换算只在入口处做一次:protoAddr = humanAddr - 40001。服务端不参与这个换算,它只认从 0 开始的偏移。
现象三:读单个寄存器一直是好的,一次读 20 个就超时。原因:客户端发来的请求长度是 12 字节,服务端基于「一次recv能收全」的错误假设,只解析了收到的前若干个字节,剩下的被当成下一帧的开头,帧边界从此错位,后面所有请求全乱。 解决:接收侧一律先读满固定长度的头,再从头部解析出剩余长度,循环读满。第 3 章的recvAll就是干这个的。判断标准很简单:把recv缓冲区故意改小到 3 字节编译一次,还能跑通就说明写对了。
现象四:客户端一直转圈直到超时,从不报异常码。原因:服务端某个分支在参数校验失败时直接return,没有构造异常响应。客户端等不到回包,只能等自己的超时,日志里清一色「连接超时」,根本看不出是哪种参数错了。 解决:在分发函数外面套一层,保证「只要解析出了功能码,就一定有回包」——哪怕是异常包。加个断言或者日志,凡是out为空就打印一条警告,联调期能省掉大量猜测。
现象五:在 Linux 上启动直接bind失败,换台机器又好了。原因:要么端口小于 1024 但没有对应权限,要么是端口被占用了,bind失败后错误码被吞掉,看起来像程序没反应。 解决:bind返回非 0 时把errno打出来,EACCES是权限问题,EADDRINUSE是端口占用。端口占用用ss -lntp | grep 502查一下谁在占。另外记得设SO_REUSEADDR,否则进程刚退出、连接还在 TIME_WAIT 时重启会失败。
6. 二次开发:把静态寄存器接成真实数据源
原始代码里寄存器是内存里的一片静态数组,写死的数据只能用来验证协议通不通。真正要落地,第一步就是把「读寄存器」这个动作从数据区里剥出来,改成可替换的数据源。我一般不去改DataStore,而是加一层接口,让pdu.cpp依赖接口而不是具体容器:
// include/register_source.h #pragma once #include <cstdint> // 二次开发时只需要实现这个接口,协议层一行不用动 class RegisterSource { public: virtual ~RegisterSource() = default; virtual uint16_t readHolding(uint16_t addr) = 0; virtual bool writeHolding(uint16_t addr, uint16_t v) = 0; // 返回是否接受 virtual uint16_t readInput(uint16_t addr) = 0; virtual bool readCoil(uint16_t addr) = 0; virtual void writeCoil(uint16_t addr, bool v) = 0; }; // 示例:把一个 float 拆成两个保持寄存器暴露出去,缩放系数由点位表决定 class FloatPointSource : public RegisterSource { public: FloatPointSource(uint16_t baseAddr, float scale) : baseAddr_(baseAddr), scale_(scale), lastValue_(0.0f) {} void update(float engineeringValue) { lastValue_ = engineeringValue; } uint16_t readHolding(uint16_t addr) override { // 工程值 → 整数 → 按 IEEE754 拆成两个字 float v = lastValue_ * scale_; uint32_t bits; std::memcpy(&bits, &v, sizeof(bits)); if (addr == baseAddr_) return static_cast<uint16_t>(bits >> 16); // 高字 if (addr == baseAddr_ + 1) return static_cast<uint16_t>(bits & 0xFFFF); return 0; } bool writeHolding(uint16_t, uint16_t) override { return false; } // 只读点 private: uint16_t baseAddr_; float scale_; float lastValue_; };FloatPointSource的关键设计在于scale_和地址分配。把一个浮点值拆成两个连续寄存器,是工控现场的通用套路,但「高字在前还是低字在前」各家设备不一样,得对着上位机的点位表确认,改一个>>的位置就行。writeHolding返回false表示这个点不接受写入,协议层收到false就该回异常码0x04(从站设备故障)而不是默默成功,否则上位机以为写进去了,实际什么都没发生,这种静默失败在现场最难查。
再加一层点位映射表,就能把「地址 → 数据源」的关系从代码里挪出来:
struct PointBinding { uint16_t addr; // 协议地址(从 0 开始) uint8_t region; // 0=线圈 1=离散 3=输入寄存器 4=保持寄存器 RegisterSource* source; }; // 启动时读配置构建这张表,查表分发,改点位不用重编译改造完怎么验证?两个手段并用。一是把--log打开,抓一帧原始报文,和你预期的手工构造字节逐位对一遍,这一步能拦住绝大多数序列化错误。二是拿真实上位机连一次,别只用自己的脚本——自己的脚本和自己的服务端共享同一套错误理解,两边都错的时候照样跑通,接上第三方客户端立刻原形毕露。
我踩过最深的一次坑,是把一个采集线程直接写进了请求处理函数里,读寄存器的时候顺手去串口捞数据。单客户端测试一切正常,现场上了三个客户端之后,串口数据开始串帧——原因是三个连接并发调同一个串口句柄。从那以后我每次做这类从站,都强制让请求处理只做内存读写,真实数据靠一个独立线程定时刷新进缓冲区,中间用一把锁隔开。这样协议层的耗时就稳定在微秒级,客户端再多也不会互相拖累。希望这些能帮到你,少走两趟我走过的夜路。
本文还有配套的精品资源,点击获取