简介这份源码面向学习工业自动化通信与C网络编程的开发者聚焦Modbus从站服务端实现围绕0x03功能码读取保持寄存器展开可用于理解请求解析、寄存器取值、响应封装、CRC校验、异常返回及功能码扩展等关键流程适合作为协议入门与二次开发的基础样例。压缩包共49个文件约13.45MB包含cpp与h源码、sln与vcxproj工程文件、exe与pdb等调试构建产物以及配置和项目辅助文件目录结构便于直接打开工程进行编译、调试与代码梳理。已有1886人学习说明其在Modbus学习场景中具有一定参考价值。读者可据此梳理TCP或串行链路下的报文处理思路、模块拆分方式与排错路径也能对照源码理解保持寄存器数据组织、服务端响应构造和异常处理细节提升对工业通信协议实现与C工程组织的理解。1. 从一个“读不到数”的现场说起去年帮人排查一条采集链路上位机每隔 500ms 读一次保持寄存器日志里全是超时可抓包看从站明明回了响应。折腾半天问题出在地址理解上上位机按文档从 40001 开始发从站的 C 代码里直接拿这个偏移去索引数组越界返回了异常码而调用方把异常响应当成正常数据丢了。那个从站就是一个 modbus 服务端从站C 小项目源码改出来的——协议核心几百行TCP 和串口两套承载四区数据放在几个数组里没有框架黑匣子改一个功能码半小时能跑通。这类小项目的价值恰恰在链路短。想接 PLC、仪表、传感器又不想被第三方库的 API 绑住的人适合看想搞清楚 Modbus TCP 那 7 个字节到底怎么摆的新手更适合因为每一层都能打断点。下面按结构、报文、承载、踩坑、改造的顺序拆一遍。2. 工程骨架与技术选型目录分层与四区数据模型怎么落地拿到这个源码包第一件事不是编译是搞清楚它把哪些职责切开了。一个从站要干的事其实就三件收字节、按协议解释字节、把结果写到数据区。分得清楚的项目这三件事各占一个目录分得不清楚的项目所有逻辑挤在一个main.cpp的while(true)里改一次加一次血泪经验。2.1 目录结构与 CMake 组织我拿到的这版是标准的 include/src 分离头文件只暴露必要接口实现细节全在 cpp 里。整体长这样modbus-slave/ ├── CMakeLists.txt ├── include/ │ ├── mb_types.h # 基础类型、异常码枚举、字节序工具 │ ├── mb_datastore.h # 四区数据模型核心 │ ├── mb_pdu.h # PDU 编解码与功能码分发 │ ├── mb_transport.h # 传输层抽象接口 │ ├── mb_tcp_server.h # TCP 承载实现 │ └── mb_rtu_slave.h # RTU串口承载实现 ├── src/ │ ├── mb_datastore.cpp │ ├── mb_pdu.cpp │ ├── mb_tcp_server.cpp │ ├── mb_rtu_slave.cpp │ └── main.cpp # 组装读配置、起线程、跑起来 └── tools/ └── mb_poll.py # 一个极简轮询脚本调试用CMakeLists.txt里没花活C11 就能编串口部分用 POSIX 的termios所以 Windows 上要么换CreateFile那套要么干脆只跑 TCP。我一般会加一个开关cmake_minimum_required(VERSION 3.10) project(mb_slave CXX) set(CMAKE_CXX_STANDARD 11) set(CMAKE_CXX_STANDARD_REQUIRED ON) option(ENABLE_RTU build serial(rtu) transport ON) add_library(mbcore src/mb_datastore.cpp src/mb_pdu.cpp src/mb_tcp_server.cpp ) target_include_directories(mbcore PUBLIC include) # socket / pthread 在 Linux 下要显式链接 target_link_libraries(mbcore PUBLIC pthread) if(ENABLE_RTU) target_sources(mbcore PRIVATE src/mb_rtu_slave.cpp) target_compile_definitions(mbcore PUBLIC MB_ENABLE_RTU1) endif() add_executable(mb_slave src/main.cpp) target_link_libraries(mb_slave PRIVATE mbcore)这段的关键不是语法是add_library和add_executable拆开。协议核心编成静态库意味着你后面写单元测试、写压测工具、甚至把这套逻辑塞进自己的网关进程里都只需要链mbcore不用把main.cpp一起拖进去。ENABLE_RTU这个 option 是为跨平台准备的串口代码在非 POSIX 环境编不过时关掉就能先跑 TCP 验证协议层。mb_types.h里我习惯先定死两个东西异常码和字节序工具函数。// mb_types.h #pragma once #include cstdint enum MBException : uint8_t { MB_EX_NONE 0x00, MB_EX_ILLEGAL_FUNC 0x01, // 功能码不支持 MB_EX_ILLEGAL_ADDR 0x02, // 起始地址 数量越界 MB_EX_ILLEGAL_VALUE 0x03, // 数量为 0 或超过上限 MB_EX_DEVICE_FAIL 0x04 // 内部处理失败 }; // Modbus 线上是大端主机可能是小端统一走这两个函数 static inline uint16_t mb_get_u16(const uint8_t* p) { return (uint16_t)((p[0] 8) | p[1]); } static inline void mb_put_u16(uint8_t* p, uint16_t v) { p[0] (uint8_t)(v 8); p[1] (uint8_t)(v 0xFF); }参数说明上没什么玄学mb_get_u16收的是指向两个连续字节的指针返回主机序的 16 位整数mb_put_u16反过来。这两个函数后面会被调用几百次写成static inline放头文件里编译器能直接展开成两条指令比调函数干净。别小看这一步很多“数据对不上”的问题追到根上就是某处直接memcpy了一个uint16_t在大端设备上刚好蒙对换台机器全乱。2.2 四区数据模型把协议语义翻译成四个数组Modbus 协议规定从站对外暴露四块数据且两块只读、两块可写。落到 C 里最直接的做法就是四个容器加一把锁数据区访问方式对应功能码存储类型线圈 Coils读写0x01 / 0x05 / 0x0Fstd::vectoruint8_t每元素 0 或 1离散输入 Discrete Inputs只读0x02std::vectoruint8_t保持寄存器 Holding Registers读写0x03 / 0x06 / 0x10std::vectoruint16_t输入寄存器 Input Registers只读0x04std::vectoruint16_t线圈用uint8_t而不是std::vectorbool原因是vectorbool是位压缩特化取地址、传引用全是坑写多线圈的时候还得考虑位序。用字节存一个线圈一个字节内存多花 7 倍但换来的可读性完全值得——总共几千个点位的场景这点内存无所谓。// mb_datastore.h #pragma once #include cstdint #include vector #include mutex #include mb_types.h class MBDataStore { public: MBDataStore(size_t coils, size_t discrete, size_t holding, size_t input); // 读直接返回 false 表示地址越界由上层转成异常码 0x02 bool readCoils(uint16_t addr, uint16_t qty, std::vectoruint8_t out) const; bool readHolding(uint16_t addr, uint16_t qty, std::vectoruint16_t out) const; bool writeCoil(uint16_t addr, bool on); bool writeHolding(uint16_t addr, uint16_t value); // 供业务线程反向注入数据比如采集到的输入寄存器 void setInputRegister(uint16_t addr, uint16_t value); private: mutable std::mutex mtx_; // 一把粗锁够用且不容易错 std::vectoruint8_t coils_; std::vectoruint8_t discrete_; std::vectoruint16_t holding_; std::vectoruint16_t input_; };这里的设计取舍值得说两句。用一把std::mutex而不是每个区一把是因为从站的真实并发压力是“多个主站连接同时读写”不是高频交易单次操作临界区只有微秒级粗锁带来的争用远小于维护四种锁顺序的错误成本。真要做性能正确方向是每个连接线程操作自己的缓冲区定期合并而不是把锁切细。读接口返回bool而不抛异常是刻意的协议层的越界是“正常业务错误”要翻译成 0x02 异常响应发回去用异常做控制流反而绕。数量上限也要在这一层卡死——读线圈单次最多 2000 个读寄存器最多 125 个写线圈最多 1968 个写寄存器最多 123 个。这些数字来自协议规范写在代码里当常量别靠记忆// mb_datastore.cpp 片段 bool MBDataStore::readHolding(uint16_t addr, uint16_t qty, std::vectoruint16_t out) const { if (qty 0 || qty 125) return false; // 协议上限 std::lock_guardstd::mutex lk(mtx_); if ((size_t)addr qty holding_.size()) return false; out.assign(holding_.begin() addr, holding_.begin() addr qty); return true; }逻辑很直先验数量0 或超上限都是非法数据值再验地址区间最后拷贝。注意(size_t)addr qty这个写法——两个uint16_t相加如果溢出会回绕成一个很小的数越界检查就失效了强转成size_t再比较是必须的。这种细节在真实项目里就是那种“平时没事客户现场跑了三个月才炸”的东西。2.3 传输层与协议层之间那道接口TCP 和 RTU 的差别只在“帧怎么切、在哪结束”PDU 的处理逻辑一模一样。所以抽象要切在这个位置传输层负责凑出完整的一帧 PDU交给协议层协议层处理完返回一段 PDU 字节传输层负责加各自的帧头帧尾发出去。// mb_transport.h #pragma once #include cstdint #include functional #include vector // 输入完整 PDU输出完整响应 PDU。返回空表示不应答如广播 using PduHandler std::functionstd::vectoruint8_t(const uint8_t*, size_t); class ITransport { public: virtual ~ITransport() default; virtual void setHandler(PduHandler h) { handler_ std::move(h); } virtual bool start() 0; virtual void stop() 0; protected: PduHandler handler_; };std::function在这里是合适的接口只有一个不需要虚函数表的热路径优化。PduHandler的参数是指针加长度而不是vector避免每次收帧都拷一份返回值用vector是因为响应长度可变而且这一层不是性能瓶颈。真正需要零拷贝的高频场景会改成传入输出缓冲区指针返回实际写入长度但那是另一个复杂度级别了。3. 报文处理核心MBAP 头解析、功能码分发与异常回应协议层是从站的心脏。抛开 TCP 那 7 个字节的 MBAP 头真正跟业务相关的就是 PDU一个功能码字节后面跟着请求数据。整个分发逻辑能塞进一个switch这也是我推荐新手从这个文件开始读的原因——读完你就知道 Modbus 到底有多简单。3.1 先读七个字节的 MBAP 头Modbus TCP 的每一帧都以 MBAP 开头固定 7 字节----------------------------------------------------------- | Transaction ID | Protocol ID | Length | Unit ID | | 2 bytes | 2 bytes (0) | 2 bytes | 1 byte | ----------------------------------------------------------- | PDU ... |Transaction ID事务号主站生成从站必须原样回填主站靠它匹配请求和响应。Protocol IDModbus 恒为 0非 0 直接丢弃。Length从 Unit ID 开始到帧尾的字节数等于1 PDU 长度。这个字段是切帧的唯一依据别拿recv的返回值当帧长。Unit ID从站地址TCP 下常用于网关后面挂多个从站直连场景随便填。解析代码不长但每个边界都要检查// 返回 true 表示凑出了一帧完整 PDU bool parseMbap(const uint8_t* buf, size_t avail, uint16_t tid, uint8_t uid, const uint8_t* pdu, size_t pduLen, size_t frameLen) { if (avail 7) return false; // 头都没收全 tid mb_get_u16(buf); // 事务号 uint16_t pid mb_get_u16(buf 2); uint16_t len mb_get_u16(buf 4); // 1 PDU 长度 uid buf[6]; if (pid ! 0) { frameLen 7 len; return false; } // 非法协议标识整帧跳过 if (len 2 || len 254) { frameLen 7 len; return false; } // 长度非法 frameLen 6 len; // 整帧长度 6 len if (avail frameLen) return false; // 半包等下次 pdu buf 7; pduLen len - 1; return true; }逐段说明一下。avail是当前接收缓冲区里已有的字节数frameLen是出参无论解析成功与否都告诉调用方这一帧总共多长——这样上层在遇到非法帧时可以整帧丢弃而不是逐字节挪动避免半帧残留把后续所有帧都带偏。len的范围检查给了 2 到 254下限是因为至少要有一个功能码上限对应 PDU 最大 253 字节。注意pdu buf 7和pduLen len - 1长度字段包含了 Unit ID所以要减 1。tid用引用传出作用是在组响应包的时候原样填回去。这个字段被无数人忽略症状是主站那边“响应超时但抓包有回包”——因为事务号对不上主站在等待队列里找不到匹配项直接丢掉。3.2 功能码分发一个 switch 撑起八个功能码PDU 的第一个字节是功能码后面的字节含义随功能码变化。分发函数的结构就是把switch铺开每个 case 里做参数校验、操作数据区、组响应std::vectoruint8_t MBProtocol::handlePdu(const uint8_t* pdu, size_t len) { if (len 1) return {}; uint8_t fc pdu[0]; std::vectoruint8_t resp; switch (fc) { case 0x03: { // 读保持寄存器 if (len ! 5) return exception(fc, MB_EX_ILLEGAL_VALUE); uint16_t addr mb_get_u16(pdu 1); uint16_t qty mb_get_u16(pdu 3); std::vectoruint16_t vals; if (!store_.readHolding(addr, qty, vals)) return exception(fc, MB_EX_ILLEGAL_ADDR); resp.push_back(fc); resp.push_back((uint8_t)(vals.size() * 2)); // 字节数 for (uint16_t v : vals) { resp.push_back((uint8_t)(v 8)); // 大端高字节在前 resp.push_back((uint8_t)(v 0xFF)); } break; } case 0x06: { // 写单个寄存器 if (len ! 5) return exception(fc, MB_EX_ILLEGAL_VALUE); uint16_t addr mb_get_u16(pdu 1); uint16_t val mb_get_u16(pdu 3); if (!store_.writeHolding(addr, val)) return exception(fc, MB_EX_ILLEGAL_ADDR); resp.assign(pdu, pdu len); // 响应就是请求的回显 break; } // 0x01 / 0x02 / 0x04 / 0x05 / 0x0F / 0x10 结构类似 default: return exception(fc, MB_EX_ILLEGAL_FUNC); } return resp; }几个容易写错的点逐个说。第一len的精确校验。读保持寄存器的 PDU 长度恒为 51 字节功能码 2 字节地址 2 字节数量多一个字节少一个字节都该回 0x03 异常。很多人只判 5结果后面解析用了脏数据。第二响应里的字节数字段是vals.size() * 2不是qty——虽然正常情况相等但一旦数据区实际返回的数量被裁剪过这里就会对不上。第三0x06 的响应是请求的原样回显用assign(pdu, pdu len)最省事也符合协议要求。0x0F写多个线圈和 0x10写多个寄存器稍微复杂请求里带一个字节数字段和紧凑排列的数据。写多个线圈时每个字节里 8 个线圈是低位在前case 0x0F: { // 写多个线圈 if (len 6) return exception(fc, MB_EX_ILLEGAL_VALUE); uint16_t addr mb_get_u16(pdu 1); uint16_t qty mb_get_u16(pdu 3); uint8_t bc pdu[5]; // 后续数据字节数 if (qty 0 || qty 1968 || bc ! (qty 7) / 8) return exception(fc, MB_EX_ILLEGAL_VALUE); for (uint16_t i 0; i qty; i) { // 第 i 个线圈在 data[i/8] 的第 (i%8) 位 uint8_t bit (pdu[6 i / 8] (i % 8)) 0x01; if (!store_.writeCoil(addr i, bit ! 0)) return exception(fc, MB_EX_ILLEGAL_ADDR); } resp.assign(pdu, pdu 5); // 回显功能码 地址 数量 break; }(qty 7) / 8是向上取整的经典写法qty 10时需要 2 个字节。位序 (i % 8)低位在前这是 Modbus 规范定的跟很多人的直觉相反写成高位在前的话主站读回来的点位顺序会是乱的。响应长度是 5 而不是整个请求这是 0x0F/0x10 与 0x06 的区别写错了主站会一直等后续字节。3.3 组包回写与异常码的时机异常响应只有 2 字节 PDU功能码最高位置 1后面跟异常码。置位这个动作是协议规定的主站靠它区分正常响应和异常响应std::vectoruint8_t MBProtocol::exception(uint8_t fc, uint8_t code) { return { (uint8_t)(fc | 0x80), code }; // 功能码最高位置 1 }什么时候回异常、什么时候静默丢弃是个判断题。规则我一般按这个来协议标识非 0、长度字段非法、功能码不认识——这些属于“这帧我根本没法处理”TCP 下直接丢因为回什么都没意义地址越界、数量为 0、数量超上限、写值非法——这些是“帧能读懂但请求不合法”必须回对应异常码主站需要这个反馈来定位配置问题。RTU 下还要注意广播地址Unit ID 0广播帧无论成败都不能应答否则总线上多台从站会同时开口直接撞车。组完整响应帧的时候TCP 层要做的事只有三件把tid填回去、pid填 0、len填1 respPdu.size()。std::vectoruint8_t MBTcpServer::buildFrame(uint16_t tid, uint8_t uid, const std::vectoruint8_t pdu) { std::vectoruint8_t f(7 pdu.size()); mb_put_u16(f[0], tid); // 事务号原样回填 mb_put_u16(f[2], 0); // 协议标识恒为 0 mb_put_u16(f[4], (uint16_t)(1 pdu.size())); // 含 Unit ID f[6] uid; std::copy(pdu.begin(), pdu.end(), f.begin() 7); return f; }长度字段这里是个高频翻车点1 pdu.size()那个 1 是 Unit ID。写成pdu.size()的话主站收到的帧长比自己预期少 1表现是“能收到回包但解析报错”而且只在部分主站软件上复现非常耗时间。4. TCP 与 RTU 双承载监听循环、粘包切帧与 CRC16协议层写完剩下的活是把字节可靠地送进去、取出来。TCP 是流没有消息边界RTU 是串口靠时间间隔划帧。两种承载的实现思路完全不同但都能塞进ITransport那个接口后面。4.1 TCP 服务端accept 之后每连接一个读循环服务端的骨架很传统socket→bind→listen→ 循环accept每个新连接起一个线程跑读循环。小项目不追求 C10K一个连接一个线程完全够代码也直白bool MBTcpServer::start() { listenFd_ ::socket(AF_INET, SOCK_STREAM, 0); int on 1; ::setsockopt(listenFd_, SOL_SOCKET, SO_REUSEADDR, on, sizeof(on)); sockaddr_in addr{}; addr.sin_family AF_INET; addr.sin_addr.s_addr htonl(INADDR_ANY); addr.sin_port htons(port_); if (::bind(listenFd_, (sockaddr*)addr, sizeof(addr)) 0) return false; if (::listen(listenFd_, 8) 0) return false; running_ true; acceptThread_ std::thread([this] { while (running_) { int fd ::accept(listenFd_, nullptr, nullptr); if (fd 0) { if (!running_) break; continue; // 被信号打断继续等 } workers_.emplace_back([this, fd] { serveConn(fd); }); } }); return true; }SO_REUSEADDR是必须的不然进程重启时上一个 socket 还处在 TIME_WAITbind直接报Address already in use开发阶段反复重启会烦到想砸键盘。listen的 backlog 给 8 是因为工业现场主站数量通常个位数给大了没意义。accept返回 -1 时要判断是不是停止信号导致的否则stop()之后线程还在空转退出时 join 卡死。serveConn里维护一个每连接独立的接收缓冲区因为 TCP 的字节流可能在任何位置断开void MBTcpServer::serveConn(int fd) { std::vectoruint8_t buf(4096); size_t used 0; while (running_) { ssize_t n ::recv(fd, buf.data() used, buf.size() - used, 0); if (n 0) break; // 对端关闭或出错本连接结束 used (size_t)n; size_t consumed 0; while (true) { uint16_t tid; uint8_t uid; const uint8_t* pdu; size_t pduLen, frameLen 0; if (!parseMbap(buf.data() consumed, used - consumed, tid, uid, pdu, pduLen, frameLen)) { if (frameLen 0) break; // 半包等更多数据 consumed frameLen; // 非法帧整帧跳过 continue; } auto respPdu handler_(pdu, pduLen); if (!respPdu.empty()) { auto frame buildFrame(tid, uid, respPdu); ::send(fd, frame.data(), frame.size(), MSG_NOSIGNAL); } consumed frameLen; } // 把没用掉的部分前移避免缓冲区无限增长 if (consumed 0) { memmove(buf.data(), buf.data() consumed, used - consumed); used - consumed; } if (used buf.size()) buf.resize(buf.size() * 2); // 超长帧兜底 } ::close(fd); }这段是整个 TCP 承载里最需要仔细看的地方逻辑分四步。recv追加到buf尾部used是当前有效字节数内层while反复尝试切帧一个recv里可能塞了三帧请求必须全部处理完每处理完一帧consumed前进内层循环结束最后memmove把残留的半帧挪到缓冲区开头used相应减少。MSG_NOSIGNAL这个 flag 值得单独提一句对端已经关闭的连接调send在 Linux 上会触发SIGPIPE默认行为是直接杀掉整个进程。第一次遇到这个现象的人往往一脸懵——从站跑着跑着没了日志里什么都没有。加上这个 flagsend会返回 -1 而不是发信号。4.2 粘包与半包只认 MBAP 里的长度字段TCP 不保证一次recv对应一次send这是所有自定义协议都要面对的现实。上面那段代码之所以能正确处理核心在于切帧依据是长度字段而不是recv的返回值半包avail frameLen返回 false 且frameLen ! 0外层break等下次数据。粘包一次recv拿到多帧内层while循环连续切consumed累加。非法帧frameLen已知但内容不合法整帧跳过而不是逐字节找避免在一堆垃圾数据里反复扫描。我见过一种写法是用特殊分隔符切帧那套在文本协议里能用二进制协议里必然出问题因为数据字节里什么值都可能出现。Modbus 给了长度字段就是让你用的。还有一个细节buf的扩容。正常 Modbus TCP 帧最大 260 字节左右4096 的缓冲区足够放十几帧。但如果对端发来一个声明长度 60000 的恶意帧frameLen会是个巨大值avail永远不够缓冲区一直等——所以len 254必须判非法并整帧跳过这是防呆也是防攻击。4.3 RTU 帧边界与 CRC16 校验RTU 跑在串口上没有长度字段靠静默间隔判断帧结束波特率 9600 时约 4ms115200 时约 2ms3.5 个字符时间。工程上的做法通常是“收到字节就记时间戳间隔超过阈值就认为一帧结束交给 CRC 校验”。CRC 不过就整帧丢弃不重传。CRC16 用的是 Modbus 变种初值 0xFFFF多项式 0xA001反射后的 0x8005低字节先发uint16_t mb_crc16(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 (crc 1) ^ 0xA001; // 反射多项式 } else { crc 1; } } } return crc; // 发送时低字节在前 }参数和顺序都不能改初值改 0x0000 会算成 CRC-16/IBM 的变种结果跟主站对不上异或的位置反了也对不上0xA001是0x8005的位反转用0x8005配合左移是另一种写法两种不能混着用。校验的时候把收到的整帧含末尾 2 字节 CRC算一遍结果为 0 表示正确——这是 Modbus CRC 的一个便利性质也可以只算前面部分再跟末尾两字节比。串口本身用termios配成 raw 模式关掉一切回显和行缓冲termios tio{}; ::tcgetattr(fd, tio); cfmakeraw(tio); // 关回显、关行缓冲、8 位数据 tio.c_cflag | (CLOCAL | CREAD); // 忽略调制解调器控制线使能接收 cfsetispeed(tio, B9600); cfsetospeed(tio, B9600); tio.c_cc[VMIN] 0; // 读不阻塞 tio.c_cc[VTIME] 0; ::tcsetattr(fd, TCSANOW, tio);cfmakeraw之后 8N1 就齐了不需要再逐位设CS8、清PARENB。VMIN0配合VTIME0是非阻塞读帧间隔判断靠steady_clock自己算不依赖驱动的超时。4.4 线程模型与数据区的锁粒度TCP 每连接一个线程RTU 一个线程业务侧可能还有采集线程往输入寄存器写数据。这些线程共享同一个MBDataStore锁的存在是必然的。前面用了单把std::mutex这里的考量是临界区里只有一次vector拷贝或一次赋值没有 IO、没有日志、没有内存分配out.assign在容量够时也不分配持锁时间在百纳秒级。这种情况下锁竞争的概率极低用读写锁反而增加复杂度。但有个前提必须守住绝不能在持锁期间调用任何可能阻塞的东西。我见过有人在readHolding里边持锁边打日志日志系统一慢所有连接全卡在那里表现就是“从站时不时卡顿几秒”。锁的边界要画在纯数据操作上日志、统计、回调统统放到锁外面。5. 避坑与排查五个把从站调成玄学的现场从站这个东西协议本身简单但现场问题花样多。下面五条是我和周围人真实踩过的每条按现象、原因、解决写遇到类似症状可以直接对照。5.1 现象主站读 40001 拿不到数读 0 反而有值原因Modbus 文档里的 4xxxx 是“数据区编号”不是协议里的地址。保持寄存器区编号从 40001 开始对应的 PDU 地址是 0。主站软件有的要你填 40001有的要你填 0还有的自动减 1五花八门。从站代码如果直接拿收到的地址去索引数组主站填 40001 就变成越界。解决从站侧永远只认 PDU 里的 0 基地址越界就回 0x02 异常别自作聪明做偏移转换。上位机配置时对着抓包看实际发出的地址是多少比看文档可靠。调试期在readHolding入口打一行addr/qty的日志一眼就能定位。5.2 现象寄存器值读出来是 0x3412 而不是 0x1234原因字节序。Modbus 规定线上传输高字节在前如果从站在组包时用了memcpy把主机序的uint16_t直接倒进缓冲区小端机器上就会变成低字节在前。解决所有进出缓冲区的地方统一走mb_put_u16/mb_get_u16禁止memcpy一个 16 位整数。32 位数据两个寄存器表示一个浮点还有“高低字交换”的变种那是应用层约定从站不负责猜文档里写清楚就行。5.3 现象主站并发读两个从站其中一个总是超时原因Transaction ID 没有原样回填。很多简化实现直接写死tid 0或者复用请求里的某个字段主站靠事务号匹配请求和响应对不上就认为超时。解决tid从请求头读出来一路传到组包函数填回去。同时检查 Unit ID 是否也原样回填——网关后面挂多从站的场景Unit ID 错了会把响应发给错误的主站逻辑通道。5.4 现象偶尔丢一帧重发就好频率不高但一直有原因recv只调用一次就切帧剩下的半包被丢弃。或者切帧时用recv返回值当帧长遇到粘包时把两帧拼成一帧解析。解决按 4.1 的写法每连接一个累积缓冲区循环切帧直到切不出完整帧为止剩余字节前移保留。这个改动只有十几行但能消掉绝大多数“偶发丢帧”。5.5 现象RTU 从站偶尔回一帧乱码重启就正常原因帧间隔判断太紧或太松。太紧会把一帧拆成两帧后半段 CRC 必然不过太松会把两帧粘成一帧CRC 也不过。另外半帧残留在缓冲区里下一帧进来又拼上去形成连锁错误。解决帧间隔按波特率算留 1.5 到 2 倍余量CRC 失败时清空整个接收缓冲区而不是只丢一个字节串口读用非阻塞加时间戳判断别指望read自己按帧返回。6. 二次开发加一个自定义功能码并压测验证接手这类项目最常见的需求是“加一个私有功能码把设备序列号读出来”。改造路径很短但要改对地方。第一步在handlePdu的switch里加 case。选功能码要避开标准已用的区间1 到 6、15、16、23、43 等工业上习惯用 0x41 到 0x48 或 0x64 往后。响应格式自己定建议沿用“功能码 字节数 数据”的结构主站侧好解析。case 0x41: { // 读设备信息私有功能码 if (len ! 1) return exception(fc, MB_EX_ILLEGAL_VALUE); static const char kInfo[] MB-SLAVE-V1; resp.push_back(fc); resp.push_back((uint8_t)sizeof(kInfo) - 1); resp.insert(resp.end(), kInfo, kInfo sizeof(kInfo) - 1); break; }第二步写个最小压测脚本验证。用 Python 起 20 个线程各连一条 TCP每个线程循环发读请求看从站是否稳定、响应时间是否可接受import socket, struct, threading, time def worker(host, port, n2000): s socket.create_connection((host, port)) for i in range(n): tid i 0xFFFF pdu struct.pack(BHH, 3, 0, 10) # 读 10 个保持寄存器 s.sendall(struct.pack(HHHB, tid, 0, len(pdu) 1, 1) pdu) head s.recv(7) ln struct.unpack(H, head[4:6])[0] body s.recv(ln - 1) assert body[0] 3, body[0] s.close() threads [threading.Thread(targetworker, args(127.0.0.1, 502)) for _ in range(20)] t0 time.time() [t.start() for t in threads] [t.join() for t in threads] print(done in %.2fs % (time.time() - t0))跑这个脚本时重点看三件事有没有断言失败说明帧处理有误、进程有没有异常退出多半是SIGPIPE或越界、CPU 占用是否线性增长锁粒度或缓冲区泄漏。我一般会把assert换成打印出错时把收到的前 16 字节 hexdump 出来比断点直观。第三步验证边界条件这几项我每次都过一遍读数量填 0、填 126、地址正好等于数据区大小、写多个寄存器时字节数字段跟数量不匹配、Unit ID 填 255、一次send里塞三帧请求。这些用脚本构造比手工点主站软件快得多。从那以后我每次改完从站代码都强制先用脚本把边界用例跑一遍再上现场——因为现场调试的成本往往是从站代码本身的一百倍。这个项目源码小、层次清楚正好适合拿它练这套流程改坏了也不心疼。希望帮到你。本文还有配套的精品资源点击获取
企业数字化 ERP 产品动态
相关推荐
JavaScript模块化:从CommonJS到ES Modules的深度解析 1. 模块化发展背景与核心概念JavaScript模块化的发展历程可以看作是一部解决代码组织问题的进化史。早期的JS代码都是直接写在script标签里,随着前端项目越来越复杂,这种简单粗暴的方式带来了全局污染、依赖混乱等问题。于是社区先后出现了多种模块化方案… · 2026/11/4 10:35:08
POJ基础题单AC指南:判题规则、六类模板与易错点 简介:面向ACM算法入门与课程练习的北大POJ题解代码包,覆盖1000至1008、1011至1014等一批基础题目,适合刚开始接触在线评测、需要对照参考实现来理解题意与边界处理的读者。压缩包共157个文件,其中156个cpp源码和1个txt说明文件&am… · 2026/11/4 10:35:08
ROS2高效学习路线与核心架构解析 1. ROS2学习路线全景解析作为机器人操作系统的最新迭代版本,ROS2在架构设计和功能特性上相比ROS1有着显著提升。对于刚接触ROS2的开发者而言,系统化的学习路径至关重要。我结合三年来的ROS2教学经验,总结出这条经过验证的高效学习路线&#x… · 2026/11/4 10:35:08
Hermes Agent 配置与定制完全指南:5 步装好你的 AI 代理 Hermes Agent 配置与定制完全指南:5 步装好你的 AI 代理
刚装好的 AI 代理不好用:对话不记上下文,开发项目里又总想执行高危命令,每轮响应还慢、还贵。根源往往不在模型,而在配置。Hermes Agent 是一个会自我进化的开… · 2026/11/4 11:22:16
基于Springboot教学资料管理系统【附源码+文档】 /* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views … · 2026/11/4 11:22:16
基于Springboot校园外卖服务平台【附源码+文档】 /* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views … · 2026/11/4 11:22:16
基于Springboot问卷调查管理系统【附源码+文档】 /* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views … · 2026/11/4 11:22:16
字节跳动联合港大推出 Mini-o3!专治 AI 视觉推理 “没耐心”,训练 6 轮能推理几十步 /* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views … · 2026/11/4 11:22:16
基于Springboot火锅店管理系统【附源码+文档】 /* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views … · 2026/11/4 11:21:14
双极步进电机控制实战:DRV8818PWPR与PIC18F86K22驱动调试 /* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views … · 2026/11/4 3:16:49
STM32F207ZG + DRV8818PWPR 步进电机驱动实战解析 /* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views … · 2026/11/4 6:59:29
DRV8818PWPR+dsPIC30F4013工业级双极步进电机控制方案 /* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views … · 2026/11/4 7:02:38
AI辅助技术栈评估:提升研发决策效率60% 1. 项目背景与核心价值技术决策一直是研发团队面临的重大挑战。随着技术生态的快速演进,技术栈的选择、维护和迭代变得愈发复杂。我们团队在过去两年里,先后经历了三次重大技术栈迁移,每次都需要投入大量人力进行技术评估和风险分析。直到去年… · 2026/11/4 0:00:17
从生成Token到直接决策:Jev如何重塑AI Agent工程实践 1. 从"生成文字"到"拍板决策":Jev 到底在解决什么问题大多数人第一次接触大模型,脑子里建立的模型是"输入问题、输出文字"。你问它今天天气怎么样,它给你一段描述;你让它写个周报,它给你… · 2026/11/4 0:00:17
32G统一内存跑大模型:内存账、TPS实测与端云分流 想在一台 32G 统一内存的小主机上把大模型跑成"日常可用",这件事我前后折腾了小半年。标题里说的这台 Mac mini M6,是我们这次讨论的目标平台,具体到手规格以实物为准;但下面这套推算方法和实测思路,对任何统… · 2026/11/4 0:00:17
双极步进电机控制实战:DRV8818PWPR与PIC18F86K22驱动调试 /* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views … · 2026/11/4 3:16:49
STM32F207ZG + DRV8818PWPR 步进电机驱动实战解析 /* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views … · 2026/11/4 6:59:29
DRV8818PWPR+dsPIC30F4013工业级双极步进电机控制方案 /* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views … · 2026/11/4 7:02:38