简介:这是一份面向工业自动化通信学习者与C++网络编程进阶者的Modbus从站(服务端)项目源码,聚焦主从请求-响应模式及0x03功能码读取保持寄存器的完整实现。代码围绕报文解析、寄存器数据读取、响应组包与CRC校验展开,并涉及TCP/串行链路通信、异常响应和功能码扩展接口,也可据此延伸0x01、0x06等常见功能码,适合理解协议细节或作为工程原型起点。压缩包共49个文件,约13.45MB,包含cpp与h源码、Visual Studio工程及解决方案、可执行文件、调试符号和配置说明等,目录结构便于按模块查阅编译与调试。已有1886人学习,适合希望从源码层面掌握Modbus服务端处理流程、提升工业通信协议编程能力的读者。
1. 一个 Modbus 从站小项目的定位:先看它替你省掉了哪些脏活
产线上那台老 PLC 只肯当 Modbus 主站,每隔 200 毫秒来问一次温度、压力和运行状态,你要做的是另一头——一个能应答的从站。这类需求看着土,踩的坑一点不少:粘包拆得对不对、大端小端有没有搞反、非法地址该不该回 0x02,全在细节里。这份 c++ modbus 服务端(从站)小项目源码,把这类对接拆成可以直接编译运行的骨架:C++11 写成,TCP 与 RTU 两条链路共用一套寄存器数据模型,PDU 解析、功能码分发、异常码返回、CRC16 校验各自独立成文件,读代码不用在几百行 switch 里打转。适合刚接手工控协议对接的后端开发、写上位机的嵌入式工程师,也适合想拿一个干净范例入门 Modbus 协议栈的人。它不追求功能码全集,一把螺丝刀该有的地方都有。
2. 项目结构与技术选型:一个从站该分成几层才不至于乱
先把“项目长什么样”落下来,再谈协议。很多 Modbus 示例代码是一坨:socket 收字节、switch 判功能码、直接往全局数组里读写,跑起来能用,等主站并发一多或者要加一条新点位,改一处崩三处。这个项目在目录层面就把职责切开了,读源码的时候心里有张地图,比什么都重要。
2.1 目录骨架:协议层、数据层、链路层三块切开
源码包解压之后大致是这个结构,各个文件的名字基本自解释:
modbus-slave/ ├── CMakeLists.txt ├── include/ │ ├── mb_types.h # uint16/uint8 别名、be16/put_be16 等大端工具 │ ├── mb_pdu.h # PDU 解析与功能码分发声明 │ ├── mb_datastore.h # 寄存器/线圈数据区 │ ├── mb_tcp_server.h # TCP 链路 │ ├── mb_rtu_slave.h # RTU 串口链路 │ └── mb_crc16.h ├── src/ │ ├── main.cpp # 参数解析、装配、主循环 │ ├── mb_pdu.cpp │ ├── mb_datastore.cpp │ ├── mb_tcp_server.cpp │ ├── mb_rtu_slave.cpp │ └── mb_crc16.cpp ├── config/ │ └── points.json # 二次开发用的点位表样例 ├── tests/ │ └── test_pdu.cpp # 不带 socket 的纯协议单元测试 └── docs/ └── 功能码支持列表.md三个层次的划分很清楚:mb_datastore和mb_pdu属于纯逻辑,不碰 socket、不碰串口,随便扔进单元测试里跑;mb_tcp_server和mb_rtu_slave只负责“把字节收进来、把字节发出去”,中间交给 PDU 层处理;main.cpp负责把两条链路和数据区装配到一起。
我一般会给这种项目定一条规矩:协议层不允许 include 任何<sys/socket.h>或<termios.h>。一旦哪天要把它挪到没有 socket 的裸机环境,或者给主站做自动化测试,这个约束能救你不少时间。测试目录里那个test_pdu.cpp就是这条规矩的产物——构造一段 MBAP 报文喂进去,断言返回的字节数组,跟网络无关,跑得飞快。
2.2 技术选型:C++11 加原生 socket,为什么不上框架
技术栈上这个项目走的是最保守的路线:C++11 标准、POSIX socket、termios 串口、CMake 构建,没有引入任何网络库或序列化框架。工控场景下这个选择很实际,原因有几条。
第一,部署环境往往是台装了老系统的工控机,装个第三方依赖比写代码本身还费劲。原生 socket 加 termios 是系统自带的,交叉编译到 ARM 板子上也基本零配置。第二,Modbus TCP 的报文结构简单到没必要上框架:一个 7 字节 MBAP 头加一段 PDU,自己解析比学框架 API 还快。第三,工业现场的调试手段有限,出错的时候你希望调用栈越短越好,recv返回 -1 时能直接看errno,比穿过三层框架封装去猜原因强得多。
并发模型也很朴素:单线程 select 事件循环,所有客户端连接和串口 fd 挂在同一个循环里。有人会问为什么不上线程池,答案是——Modbus 从站的数据区本身就是个共享资源,多线程反而要引入锁竞争,而这个项目的 QPS 顶天了也就几千,select 完全扛得住。真到了要接几百个连接的规模,再换 epoll 加多线程也不迟,现在的结构留了改的口子。
依赖方面,config/points.json的解析用了一个头文件形式的 JSON 库,如果不想引入,简单改成一个 INI 或者直接用代码里写死的常量数组也能跑,只是二次开发灵活度差一点。这一点后面第 6 章会细讲。
2.3 编译、产物与启动参数
构建流程是最常见的那一套,CMake 加 out-of-source 编译,不污染源码目录:
# 生成构建目录,Release 会因为开了 -O2 而明显降低 CRC16 的耗时 cmake -B build -DCMAKE_BUILD_TYPE=Release cmake --build build -j4编完在build/下会得到可执行文件,启动参数按链路类型分开:
# TCP 从站,监听 502 端口(低于 1024 的端口需要 root 或 CAP_NET_BIND_SERVICE) sudo ./build/modbus_slave --tcp --port 502 --unit 1 # RTU 从站,串口设备、波特率、校验位 sudo ./build/modbus_slave --rtu --dev /dev/ttyUSB0 --baud 9600 --parity N --unit 1--unit是从站地址,TCP 链路里对应 MBAP 头中的单元标识符,RTU 链路里对应帧首字节。如果你的设备后面挂了个网关,网关会把多个从站映射到一个 IP 上的不同 unit id,这时候服务端收到不属于自己的 unit 是要回异常码的,这一点很多示例代码都忘了处理。
提示:用 502 端口跑通之后再做联调,别一上来就用 root 起服务,出问题分不清是权限还是逻辑。先用 1502 这类高位端口验证功能。
main.cpp里的装配逻辑不长,值得看一眼它怎么把两条链路接起来:
// 数据区是全局唯一的一份,TCP 和 RTU 共享 MbStore store(MbStore::kRegCount, MbStore::kCoilCount); MbPdu pdu(store); // 协议层只认 store,不认链路 std::unique_ptr<MbSlaveLink> link; if (opts.mode == Mode::Tcp) { link.reset(new MbTcpServer(opts.port, opts.unitId, pdu)); } else { link.reset(new MbRtuSlave(opts.dev, opts.baud, opts.parity, opts.unitId, pdu)); } link->run(); // 阻塞式主循环,内部维护 running_ 标志MbSlaveLink是一个只有run()和stop()两个纯虚函数的抽象类,MbTcpServer和MbRtuSlave各自实现。这种写法代价很小,收益是main里不用写两套启动流程,以后加个 Modbus over UDP 也只需要再实现一个子类。装配顺序也有讲究——先建数据区,再建协议层,最后建链路层,依赖方向是单向的,不会有循环引用。
3. 协议核心拆解:一个 MBAP 报文从进来到回包走了哪些代码
链路层把字节收上来之后,真正的活儿在 PDU 层。这一章按报文流动的顺序走:先看 MBAP 头怎么拆、粘包怎么处理,再看功能码怎么分发,最后落到寄存器数据区怎么组织。三块内容合起来构成一个完整请求的生命周期。
3.1 MBAP 头解析与粘包处理
Modbus TCP 的报文结构是 7 字节 MBAP 头加一段 PDU,头里依次是事务标识符(2 字节)、协议标识符(2 字节,固定 0x0000)、长度(2 字节)、单元标识符(1 字节)。长度字段最容易理解错——它统计的是“单元标识符 + PDU”的字节数,不包含前面 6 个字节。所以一条完整报文的总长是6 + length。
TCP 是字节流,一次recv可能只收到半个头,也可能一次收到两条完整报文粘在一起。处理方式是维护一个累积缓冲区,每次收到数据都往里追加,然后循环尝试从缓冲区头部切出一条完整报文:
// 累积缓冲区解析循环,调用前 buf_ 已经追加了本次 recv 的数据 void MbTcpServer::parse_buffer(Client& c) { while (true) { if (c.buf.size() < 7) break; // 连 MBAP 头都不够,等下次 uint16_t len = be16(&c.buf[4]); // 长度字段 if (len < 2 || len > 254) { // 单元号+功能码至少 2,PDU 上限 253 close_client(c); // 长度离谱,直接断开,别硬解析 return; } size_t total = 6 + len; // 一条完整报文的总字节数 if (c.buf.size() < total) break; // 半包,等数据补齐 pdu_.handle(c.buf.data(), total, c.out); // 交给协议层,响应写进 c.out c.buf.erase(c.buf.begin(), c.buf.begin() + total);// 消费掉这一条 } }这段代码有四个决策点值得说清楚。c.buf.size() < 7的判断是防止拿着不完整的头去读长度字段,读越界了就是未定义行为。len < 2 || len > 254是合法性校验,Modbus 规范里 PDU 最大 253 字节,加上单元标识符正好 254,超过这个值基本可以断定是错包或者被扫描器打了,直接断开比继续解析安全。total = 6 + len里那个 6 是不可变的(事务号 2 加协议号 2 加长度字段本身 2)。最后的erase用迭代器区间而不是按索引,这样缓冲区收缩的行为是确定的。
c.buf建议用std::vector<uint8_t>并且预留一点容量,reserve(512)就够,避免频繁扩容。如果连接数上百,也可以用环形缓冲区省掉 erase 的拷贝开销,但对这个项目的定位来说属于过度设计。
另外注意协议标识符最好是校验一下,规范要求必须是 0x0000。有些主站实现会填别的值,遇到这种就得决定是宽容还是严格——我的做法是记一条日志然后继续处理,工业现场能通上比讲规范重要。
3.2 功能码分发:0x03 / 0x06 / 0x10 的读写路径
PDU 进入协议层之后,第一件事是把 MBAP 头原样抄回去。响应报文的事务标识符和协议标识符必须跟请求一致,主站靠事务号把请求和响应配对,这里搞错会导致主站那边超时或者错配——表现就是“明明收到回包了,主站还是报超时”,排查起来很费时。
// pdu_.handle() 的核心逻辑 void MbPdu::handle(const uint8_t* req, size_t total, std::vector<uint8_t>& out) { const uint8_t unit = req[6]; const uint8_t fc = req[7]; const uint8_t* body = req + 8; const size_t bodyLen = total - 8; out.clear(); out.insert(out.end(), req, req + 4); // 事务标识符 + 协议标识符,原样回 out.push_back(0); out.push_back(0); // 长度字段先占位,最后回填 out.push_back(unit); if (unit != unitId_ && unitId_ != kBroadcastUnit) { buildException(fc, 0x0B, out); // 网关目标设备无响应 / 单元号不匹配 finishLength(out); return; } switch (fc) { case 0x03: readHolding(body, bodyLen, fc, out); break; // 读保持寄存器 case 0x04: readInput (body, bodyLen, fc, out); break; // 读输入寄存器 case 0x06: writeSingle(body, bodyLen, fc, out); break; // 写单个寄存器 case 0x0F: writeCoils (body, bodyLen, fc, out); break; // 写多个线圈 case 0x10: writeMulti (body, bodyLen, fc, out); break; // 写多个寄存器 default: buildException(fc, 0x01, out); break; // 非法功能码 } finishLength(out); // 回填 out[4..5] = out.size() - 6 }分发结构本身是中规中矩的 switch,几个细节决定了健壮性。单元号校验放在最前面,不匹配直接回 0x0B,避免后面的处理逻辑误改数据。finishLength统一回填长度字段,避免每个处理函数各写一遍容易漏。异常响应由buildException统一构造——它会自动把功能码按位或上 0x80,再追加异常码,这是规范要求,手写容易忘。
具体到 0x03 读保持寄存器的处理,完整的边界校验一个都不能少:
void MbPdu::readHolding(const uint8_t* body, size_t len, uint8_t fc, std::vector<uint8_t>& out) { if (len < 4) { buildException(fc, 0x03, out); return; } // 请求体长度不足 uint16_t start = be16(body); uint16_t count = be16(body + 2); if (count == 0 || count > 125) { // 规范上限 125 个寄存器 buildException(fc, 0x03, out); return; // 0x03 = 非法数据值 } if (!store_.checkRange(start, count)) { // 地址 + 数量越界 buildException(fc, 0x02, out); return; // 0x02 = 非法数据地址 } std::vector<uint16_t> regs; store_.readHolding(start, count, regs); // 内部加锁拷贝,出作用域即释放 out.push_back(fc); out.push_back(static_cast<uint8_t>(count * 2)); // 字节数 for (uint16_t v : regs) { // Modbus 大端,高字节先发 out.push_back(static_cast<uint8_t>(v >> 8)); out.push_back(static_cast<uint8_t>(v & 0xFF)); } }参数说明一下:start是寄存器起始地址(0 基),count是数量。125 这个上限来自规范——响应 PDU 的最大长度 253,减去功能码 1 字节和字节计数 1 字节,剩 251 字节,每个寄存器 2 字节,所以最多 125 个。超了要回 0x03 而不是硬截断,主站会根据异常码判断是请求有问题还是设备有问题。
写操作反过来,0x06 和 0x10 有一个关键差异:0x06 的响应是原样回显请求(地址加值),而 0x10 的响应只回起始地址和数量,不回数据。这个细节写反了主站会认为响应格式错误。0x10 的请求体里有个字节计数字段,必须校验它等于count * 2,否则说明报文被截断或者构造错误。
3.3 寄存器数据区:std::array 加 mutex 的组织方式
数据区是两条链路的交汇点,设计好坏直接影响后面能不能改。核心要求就两条:容量固定、访问加锁。所以用std::array而不是std::vector——不会因为某个操作触发扩容导致其他线程手里的迭代器失效,而且内存布局连续,读取效率高。
class MbStore { public: static constexpr size_t kRegCount = 4096; // 保持寄存器数量 static constexpr size_t kCoilCount = 4096; // 线圈数量 // 范围校验必须用 32 位算,uint16 的 start + count 会回绕 bool checkRange(uint16_t start, uint16_t count) const { if (count == 0) return false; uint32_t end = static_cast<uint32_t>(start) + count; return start < kRegCount && end <= kRegCount; } void readHolding(uint16_t start, uint16_t count, std::vector<uint16_t>& out) const { std::lock_guard<std::mutex> lk(mtx_); out.assign(holding_.begin() + start, holding_.begin() + start + count); } void writeHolding(uint16_t start, const std::vector<uint16_t>& vals) { std::lock_guard<std::mutex> lk(mtx_); std::copy(vals.begin(), vals.end(), holding_.begin() + start); ++writeSeq_; // 写入序号,刷新线程可据此判断是否有变化 } uint64_t writeSeq() const { std::lock_guard<std::mutex> lk(mtx_); return writeSeq_; } private: mutable std::mutex mtx_; std::array<uint16_t, kRegCount> holding_{}; std::array<uint16_t, kRegCount> input_{}; std::array<bool, kCoilCount> coils_{}; uint64_t writeSeq_ = 0; };三个设计点。checkRange里那个static_cast<uint32_t>不是多余的,如果直接写start + count <= kRegCount,当start是 65535、count是 10 的时候,加法在 uint16 上回绕成 9,校验通过,后面的begin() + start直接越界读——这是 Modbus 从站最经典的崩溃点之一。readHolding返回的是拷贝而不是引用,虽然多一次内存拷贝,但锁的作用域能缩到最小,调用方拿着数据慢慢序列化也不会阻塞写操作。writeSeq_是为二次开发准备的钩子,后面讲点位刷新时会用到。
input_区和holding_区故意分开,虽然这个项目里两者都能读写,但规范上输入寄存器是只读的(0x04 读),真接传感器的时候数据流向是“采集线程写 input_,主站读”,分开存语义更清楚。
4. 通信层实现:TCP 从站与 RTU 从站怎么共用一套数据模型
协议层写完之后,剩下的工作是把它接到两种物理链路上。两条链路的差别比想象中大:TCP 靠 MBAP 头天然分帧,RTU 靠时间间隙分帧;TCP 校验交给网卡和 TCP 栈,RTU 得自己算 CRC16。但两者上层完全一致,这正说明第 2 章那次分层是值得的。
4.1 TCP 监听、连接管理与缓冲区策略
TCP 服务端的主循环用 select 实现,监听 fd 和所有客户端 fd 挂在同一个 fd_set 里。这种写法代码量小、可读性高,配合 200 毫秒的超时还能顺便处理退出信号:
void MbTcpServer::run() { listenFd_ = socket(AF_INET, SOCK_STREAM, 0); int on = 1; setsockopt(listenFd_, SOL_SOCKET, SO_REUSEADDR, &on, sizeof(on)); // 重启时端口不被 TIME_WAIT 卡住 sockaddr_in addr{}; addr.sin_family = AF_INET; addr.sin_addr.s_addr = htonl(INADDR_ANY); // 监听全部网卡,现场常有多网段 addr.sin_port = htons(port_); bind(listenFd_, reinterpret_cast<sockaddr*>(&addr), sizeof(addr)); listen(listenFd_, 16); while (running_) { fd_set rfds; FD_ZERO(&rfds); FD_SET(listenFd_, &rfds); int maxFd = listenFd_; for (auto& c : clients_) { FD_SET(c.fd, &rfds); if (c.fd > maxFd) maxFd = c.fd; } timeval tv{0, 200000}; // 200ms,只是为了让 running_ 能被及时看到 int n = select(maxFd + 1, &rfds, nullptr, nullptr, &tv); if (n < 0) { if (errno == EINTR) continue; break; } if (n == 0) continue; // 超时,回去检查退出标志 if (FD_ISSET(listenFd_, &rfds)) acceptClients(); for (auto& c : clients_) { if (FD_ISSET(c.fd, &rfds)) readClient(c); } reapClosedClients(); // 统一清理,避免边遍历边删 } }SO_REUSEADDR这一行在调试阶段能省下大量等待——重启服务时如果上一个进程还处在 TIME_WAIT,不设这个选项会 bind 失败。客户端集合用std::vector<Client>而不是std::list,网络连接数本来就不会多;reapClosedClients把删除操作集中到遍历之后,避免迭代器失效。
每个客户端结构体里两个缓冲区,一个收一个发:
struct Client { int fd; std::string peer; // "192.168.1.20:51234",日志里能直接看到是谁在问 std::vector<uint8_t> buf; // 接收累积缓冲区 std::vector<uint8_t> out; // 待发送队列 };out的存在是因为send可能只发出一部分——非阻塞 socket 上这是常态。简单做法是一次send发不完就把剩余数据留在out里,下次可写时再发;更省事的做法是把 fd 设成阻塞模式,靠 TCP 自己的缓冲区扛,代价是慢客户端会拖住整个循环。这个项目定位是少量主站直连,选了后者,代码里能看到sendAll循环直到发完为止。如果哪天要接几十个客户端,记得回来把EPOLLOUT处理补上。
4.2 RTU 的帧边界判定与 CRC16 校验
RTU 没有长度字段,帧与帧之间靠静默时间来分隔:规范里定义了 3.5 个字符时间的间隔,超过这个间隔就认为上一帧结束。在 9600 波特率、11 位每字符的配置下,3.5 个字符大约是 4 毫秒;到 115200 就只剩 0.33 毫秒了。这个时间必须按波特率算,写死一个固定值是最常见的翻车点。
// 波特率驱动的帧间隔,t35_ 在构造函数里算好,单位微秒 double MbRtuSlave::calcT35(int baud) { double charTime = 11.0 / baud; // 1 起始 + 8 数据 + 1 校验 + 1 停止 double t35 = 3.5 * charTime; if (t35 < 0.00175) t35 = 0.00175; // 规范对高波特率给了 1.75ms 的下限 return t35; }接收循环用read加空闲判定的方式组帧,读到数据就往frame_里追加,同时刷新“最后一次收到字节的时刻”;下一次read之前如果发现距上次接收已经超过t35_且frame_非空,就说明一帧结束,送去解析:
int n = ::read(fd_, tmp, sizeof(tmp)); if (n > 0) { frame_.insert(frame_.end(), tmp, tmp + n); lastRxUs_ = nowUs(); } else if (n == 0) { // 没有新数据不是错误,判断是否到了帧结束时刻 if (!frame_.empty() && nowUs() - lastRxUs_ > t35_ * 1e6) { handleRtuFrame(frame_); // 交给校验和 PDU 层 frame_.clear(); } }串口 fd 用O_NONBLOCK打开,配合select或poll做超时等待,行为才可控。如果设成阻塞模式,read会一直卡在那里,帧结束判断根本没机会执行。
组好帧之后先校验再解析,顺序不能反:
void MbRtuSlave::handleRtuFrame(const std::vector<uint8_t>& f) { if (f.size() < 4) return; // 地址 + 功能码 + CRC 最少 4 字节 uint8_t addr = f[0]; if (addr != unitId_ && addr != 0) return; // 不是我的帧,静默丢弃 // CRC 是低字节在前,这个顺序反了是最常见的“校验不过” uint16_t recvCrc = static_cast<uint16_t>(f[f.size() - 2] | (f[f.size() - 1] << 8)); uint16_t calcCrc = mbCrc16(f.data(), f.size() - 2); if (recvCrc != calcCrc) return; // 校验失败不发任何响应 std::vector<uint8_t> resp; pdu_.handleRtu(f.data() + 1, f.size() - 3, resp); // 去掉地址和 CRC,拿纯 PDU if (!resp.empty()) { resp.insert(resp.begin(), unitId_); // RTU 响应要补地址字节 uint16_t crc = mbCrc16(resp.data(), resp.size()); resp.push_back(static_cast<uint8_t>(crc & 0xFF)); // 低字节先发 resp.push_back(static_cast<uint8_t>(crc >> 8)); writeAll(fd_, resp.data(), resp.size()); } }CRC16 的实现用查表法或者逐位法都行,这个项目用的是逐位法,代码短、便于对照规范验证:
uint16_t mbCrc16(const uint8_t* data, size_t len) { uint16_t crc = 0xFFFF; // 初值固定 for (size_t i = 0; i < len; ++i) { crc ^= data[i]; for (int b = 0; b < 8; ++b) { if (crc & 0x0001) crc = static_cast<uint16_t>((crc >> 1) ^ 0xA001); else crc = static_cast<uint16_t>(crc >> 1); } } return crc; // 结果直接是“低字节在前”的形式 }0xA001是标准多项式0x8005的反射形式,用反射形式配合右移,最后得到的结果正好符合 Modbus 的传输字节序,不需要再翻转。
RTU 还有一个特别行为:广播地址 0 的请求要执行但不响应,因为总线上所有从站都在听。写操作走广播是常见用法,比如统一复位。代码里if (!resp.empty())那层判断就是为广播准备的。
4.3 异常码返回与从站状态维护
异常响应是主站判断问题归属的唯一线索,返回错了就等于把排查方向带偏。常用的几个码要记住:0x01 非法功能(不支持这个功能码)、0x02 非法数据地址(地址越界)、0x03 非法数据值(数量或值不合法)、0x04 从站设备故障(内部执行出错)、0x0B 网关目标设备无响应(unit 号不对)。判断顺序有讲究:先看功能码支不支持,再看地址有没有越界,最后看数量是否合法。
void MbPdu::buildException(uint8_t fc, uint8_t code, std::vector<uint8_t>& out) { out.push_back(static_cast<uint8_t>(fc | 0x80)); // 功能码最高位置 1 out.push_back(code); }除了协议错误,从站自己也得有状态概念。这个项目里维护了一个SlaveState:Init(还没任何主站来过)、Online(最近 N 秒内收到过合法请求)、Offline(超时)。状态切换点分别放在收到合法帧和定时器超时处,通过一个简单的回调暴露给外部——接个看门狗、或者点亮面板上的通信指示灯,都用得上。这类“让外部知道我在不在线”的需求现场提得特别多,留个钩子比事后加省事。
写操作还有一件事要做:更新writeSeq_。很多现场程序需要在主站改写设定值之后触发一次动作,轮询writeSeq()的变化是最省事的实现方式,比在 PDU 层里塞业务回调干净得多。
5. 常见问题排查:从站收不到包、回包不对、主站超时怎么查
这行做了几年,Modbus 从站的问题翻来覆去就那么几种。下面这几条都是真实踩过的,按“现象 → 原因 → 解决”记下来,下次遇到能少花点时间。
5.1 主站一发就断连,或者从站明明回包了主站还说超时
现象:用抓包工具能看到从站确实发出了响应,字节内容看着也对,但主站那边就是报超时或者校验失败。
原因:九成是响应里的长度字段算错了,或者事务标识符没有原样回传。长度字段统计的是“单元标识符加 PDU”,不含前面 6 字节,第一次写很容易把总长度填进去。事务号的问题更隐蔽——有些实现在异常分支里重新构造响应报文,结果把事务号清零了,主站靠事务号配对,对不上就一直等。
解决:在finishLength里加断言out[4..5] == out.size() - 6,所有出口统一走这个函数。异常分支也别绕开,buildException之前已经写好了事务号,不要在别处重建。调试时用十六进制对照规范逐字段核对,比盯着代码看快。
5.2 读出来的寄存器值全是反的,比如 25 变成了 6400
现象:主站读到的数值明显异常,但大小关系还在,比如温度 25.0 读出来是 6400。
原因:大端小端搞反了。Modbus 规定多字节数据统一大端,高字节在前。用memcpy直接把uint16_t塞进缓冲区,在小端机器上就是低字节在前;或者是 RTU 那边 CRC 的字节序放反了,crc & 0xFF和crc >> 8写成了倒过来的顺序。
解决:所有跨字节读写都走统一的工具函数,别用memcpy:
static inline uint16_t be16(const uint8_t* p) { return static_cast<uint16_t>((p[0] << 8) | p[1]); } static inline void putBe16(uint8_t* p, uint16_t v) { p[0] = static_cast<uint8_t>(v >> 8); p[1] = static_cast<uint8_t>(v & 0xFF); }再把所有直接memcpy到uint16_t的地方找出来替换掉。CRC 那个字节序也要单独确认一遍,RTU 帧里 CRC 是低字节在前,与数据字段的字节序刚好相反,容易混。
5.3 读 100 个寄存器返回异常码 02,但地址明明是合法的
现象:单读几个寄存器没问题,一次读一大片就返回非法数据地址。
原因:范围校验只判断了起始地址,没有校验“起始地址 + 数量”。或者更隐蔽的,用的是uint16_t做加法,start + count溢出回绕成一个小数,校验反而通过了,然后在begin() + start那一步越界读,运气不好直接段错误。
解决:校验统一收进checkRange,内部用 32 位计算,并且显式拒绝count == 0。所有读写路径都必须过这个函数,不要在业务分支里再临时判断一次——两份逻辑迟早会不一致。
提示:这类越界在本地测试永远暴露不出来,因为测试脚本不会故意发一个 65535 的地址。压测阶段可以用脚本随机生成起始地址和数量,跑一轮模糊测试,比人工回归靠谱。
5.4 RTU 串口收到的一直是半帧,或者两帧粘在一起
现象:偶尔能正常响应,但大部分时候解析失败,或者响应内容对应的是上一条请求。
原因:3.5 字符静默时间按固定值写死了。9600 波特率下算出来的 4 毫秒,在 115200 下就是 0.33 毫秒,写死 4 毫秒会让两帧被当成一帧;反过来如果波特率降低而超时值没跟着变,半帧就被截断了。另一个可能的原因是串口 fd 是阻塞模式,read卡在那里,帧结束判断没有执行机会。
解决:t35_按波特率算,公式3.5 * 11 / baud,并且套上规范给的 1.75 毫秒下限。串口用O_NONBLOCK打开,配select做超时等待。调试阶段把每次收到的原始字节和判定出的帧边界打到日志里,一眼就能看出是分帧错了还是解析错了。
5.5 多个主站同时读写之后,寄存器数据变得莫名其妙
现象:单主站测试一切正常,接了两三个主站之后偶尔出现某些寄存器被改成预期以外的值。
原因:数据区没加锁,或者用了std::vector并且在某处触发了扩容,导致其他线程手里的指针失效。还有一种情况是写操作没有做“全部成功或全部失败”的保证,count校验通过了但写入过程中途失败,只写进去一半。
解决:std::array固定容量加std::mutex是最省心的组合,锁的粒度压在数据区内部,调用方不用管。写多个寄存器时先完整校验再一次性std::copy,中间不穿插别的操作。如果将来要支持几十个主站并发,再考虑把锁换成读写锁或者按地址分段的细粒度锁,现在没必要。
5.6 服务重启后 bind 失败,报地址已在使用
现象:./modbus_slave --tcp --port 502报Address already in use,但ps看不到进程。
原因:上一个进程还处在 TIME_WAIT 状态,端口没释放。调试阶段频繁重启,这个问题会出现很多次。
解决:监听 socket 上设置SO_REUSEADDR。代码里那行setsockopt就是干这个的,删掉它就会复现。另外select返回 EINTR 时要记得continue,别当成错误退出,否则在调试器里断点一停就被系统信号打断进程。
6. 二次开发:把写死的寄存器表改成点位表驱动
源码能跑通只是起点,真正落地时的第一个改动几乎都是同一件事:把写死的寄存器地址换成一张可以配置的点位表。原因很实际——现场的点位会变,今天加一路压力传感器,明天客户要求把温度寄存器从 0 挪到 20,每次都改代码重新编译,没有哪个项目经得起这么折腾。
这个项目留的扩展点就在MbStore那一层。做法是加一个刷新线程,按固定周期把真实设备的数据写进数据区,同时把这些点位用配置描述出来:
// 点位定义:地址、来源、缩放、只读 struct Point { std::string name; // "temperature" uint16_t addr; // 寄存器地址 0 double scale; // 0.1,原始值 = 物理值 / scale bool readonly; // 主站能不能写 std::function<double()> fetch; // 采集回调,返回物理量 }; class PointTable { public: void load(const nlohmann::json& cfg) { for (const auto& p : cfg["points"]) { Point pt; pt.name = p["name"].get<std::string>(); pt.addr = p["addr"].get<uint16_t>(); pt.scale = p.value("scale", 1.0); pt.readonly = p.value("readonly", true); points_.push_back(std::move(pt)); } } // 由独立线程每 200ms 调一次,把采集值写进数据区 void refresh(MbStore& store) { for (auto& pt : points_) { if (!pt.fetch) continue; double phys = pt.fetch(); uint16_t raw = static_cast<uint16_t>(phys / pt.scale); store.writeHolding(pt.addr, {raw}); } } private: std::vector<Point> points_; };配套的config/points.json大致长这样:
{ "unit_id": 1, "refresh_ms": 200, "points": [ { "name": "temperature", "addr": 0, "scale": 0.1, "readonly": true }, { "name": "pressure", "addr": 1, "scale": 0.01, "readonly": true }, { "name": "setpoint", "addr": 20, "scale": 1.0, "readonly": false }, { "name": "run_status", "addr": 30, "scale": 1.0, "readonly": true } ] }跑起来之后,主站读地址 0 拿到250,业务层看到的就是 25.0℃;主站往地址 20 写800,业务层读到设定值 800。缩放因子放在配置里,代码里完全不用出现“乘以 0.1”这类魔数——这一条在项目后期救过我至少两次,因为客户改量程的时候只需要动一行 JSON。
顺带的几个改造点值得一起做。只读点位在写路径上要拦住,返回异常码 0x02,不要让它写进去再靠上层兜底。刷新线程里对pt.fetch()抛出的异常要捕获,某个传感器挂了不能让整个刷新循环退出。writeSeq_那个序号此时派上用场:业务层轮询它,一变就说明主站改过设定值,比在 PDU 层里塞回调干净。
验证方法也很直接,写个脚本模拟主站,先读一遍所有点位核对量纲,再写几个设定值读回来确认没有缩放错。这里有个习惯我一直保持着:每次改完点位表,先用一个独立的小客户端把整个点位区间从 0 到最大地址扫一遍,确认没有越界、没有负数、没有明显的量纲错位,再去接真实主站。这步花不了五分钟,但省下的现场排查时间是以小时计的。这套流程我后来做任何一个协议对接的项目都强制走一遍,血泪经验,希望帮到你。
本文还有配套的精品资源,点击获取