尧图网站设计 尧图网站设计YAOTU DESIGN
ARTICLE DETAIL

资讯详情

深耕网站设计与一线实操的经验洞察。

C++实现Modbus Slave从站:地址模型、TCP/RTU与CRC16全解析

C++实现Modbus Slave从站:地址模型、TCP/RTU与CRC16全解析 简介面向工业通信与嵌入式开发者的Modbus TCP从站仿真工程基于C实现支持灵活配置寄存器起始地址与数据长度可模拟多种寄存器的数据上送行为。压缩包共90个文件以13个h头文件、11个cpp源文件为核心并包含可直接运行的exe程序及调试符号文件整体仅7.12MB便于快速查阅与二次开发。目前已有270人学习下载适合需要验证Modbus主站功能、开展协议联调或入门从站协议栈实现的开发人员。通过该源码可以直观梳理寄存器地址映射、请求帧解析与响应报文构造的关键流程工程内保留的VC6.0项目文件也便于在经典开发环境中直接编译调试结合可执行程序快速搭建仿真从站为工控数据采集、监控软件测试等场景提供实用参考。1. 为什么要在 C 里自己实现一个 Modbus SlaveModbus 是工业现场应用最广泛的传感器总线协议之一但大多数工程师常年只写主站Master一旦需要实现从站Slave很容易被“被动响应”这件事卡住。主站节奏可以自己控制从站却要始终在线毫秒级处理请求地址越界、功能码不存在、CRC 错位都要快速给出异常响应而不是让主站等到超时。在 C 里实现一个 MB_Slave 类不是为了替代成熟商业软件而是当你要把寄存器数据和自己的业务字段绑定、要在产测脚本里模拟设备行为、或是做协议转换网关时一个结构清晰的从站比黑盒更可控。这篇文章按“地址模型 → TCP 最小实现 → RTU 串口扩展 → ModbusPoll 验证”推进把从设计到测试的关键点一次说清。2. 设计从站前必须理清的 Modbus 地址模型与功能码2.1 四个数据区各自独立不要做成一个扁平数组第一次写从站的人最容易把内存简化成一个uint16_t regs[65536]。Modbus 协议对一个设备的存储区划分是四块独立区域每块的访问语义完全不同线圈Coil是 1 位、可读可写离散输入Discrete Input是 1 位、只读保持寄存器Holding Register是 16 位、可读可写输入寄存器Input Register是 16 位、只读。四个区的地址范围互不连续PLC 侧看到的“40001”这类地址编号是主站视图从站 PDU 里传输的其实是从 0 开始的本区偏移量。区域位宽读写属性主站视图的地址区间常用功能码线圈 Coil1 bit可读写00001 ~ 099990x01 / 0x05 / 0x0F离散输入 Discrete Input1 bit只读10001 ~ 199990x02保持寄存器 Holding Register16 bit可读写40001 ~ 499990x03 / 0x06 / 0x10输入寄存器 Input Register16 bit只读30001 ~ 399990x04C 容器选型上位区建议用std::vectoruint8_t寄存器区用std::vectoruint16_t各自按容量在初始化时分配。std::vectorbool是标准库特化operator[]返回代理对象拿不到左值引用批量置位和交接给旧代码都很别扭用uint8_t存 0/1 只是多耗点内存换来的是内存语义完全一致。四个独立容器还有一个好处地址校验简化为addr count 本区容量一次比较不会发生跨区串位。bool MB_Slave::init(size_t coils, size_t discrete, size_t holding, size_t input) { coils_.assign(coils, 0); // 0x 区位状态存 0/1 discrete_.assign(discrete, 0); // 1x 区只读位 holding_.assign(holding, 0); // 4x 区读写字 input_.assign(input, 0); // 3x 区只读字 coils_n_ coils; discrete_n_ discrete; holding_n_ holding; input_n_ input; return true; }容量固定还有一个实际考虑从站的寄存器表通常在设备上电时就要确定地址越界大多意味着上层组态配错了与其让从站动态扩张掩盖问题不如在初始化阶段就把表长锁死。真需要动态扩容的业务可以在类外面包一层“协议地址到容器索引”的映射表不必改动从站核心逻辑。2.2 支持哪些功能码从常见主站的视角倒推从站协议栈不必把 Modbus 应用协议里的功能码全部实现真正被工业主站高频使用的是八个读线圈 0x01、读离散输入 0x02、读保持寄存器 0x03、读输入寄存器 0x04、写单线圈 0x05、写单寄存器 0x06、写多线圈 0x0F、写多寄存器 0x10。像 0x07 读异常状态这类功能码多数设备实现里都作为保留处理直接回 0x01 非法功能即可。这八个功能码按处理逻辑可以分成两组。单寄存器组0x01、0x02、0x03、0x04、0x05、0x06请求里只带地址和数据帧长固定解析简单多寄存器组0x0F、0x10带数量字段数量上限受 PDU 长度约束——线圈和离散输入单帧最多 2000 位保持寄存器和输入寄存器单帧最多 125 个字。这个数字不是拍脑袋定的它由“PDU 最大 253 字节减去功能码、地址、数量、字节数前缀”倒推而来。多寄存器组的返回帧结构也容易搞混读操作返回“字节计数字段 数据”写操作返回“地址 数量”原样回显。判断标准只有一个看功能码不要试图从数据内容反推。实现时建议把单寄存器和多寄存器分两个内部函数处理不要全部堆在同一个 case 分支里否则数量字段的边界判断很容易写得重复且不一致。2.3 异常响应的触发顺序功能码、地址、数量请求总有不合法的时候。Modbus 规定从站对非法请求必须回异常帧功能码位置放“原功能码 | 0x80”后续跟一个异常码。0x01 非法功能、0x02 非法数据地址、0x03 非法数据值是最常用的三个。异常码含义触发场景举例0x01非法功能码收到 0x07、0x08 等未实现功能码0x02非法数据地址起始地址超过容量或地址数量-1 超出0x03非法数据值数量字段为 0或数量超过单帧上限判断顺序值得在联调前确认先判断功能码是否支持再判断地址区间最后判断数量合法性。这个顺序对应 PDU 里字段的解析顺序也能让畸形帧在进入数据拼装前就被拦截。0x02 和 0x03 的边界在协议规范里没有严格定义工控行业的通行做法是地址越界回 0x02数量越限回 0x03。如果你做的是通用协议转换网关按这个划分最稳妥因为主流主站软件对这两个异常码的提示信息是不同文案。提示异常帧的功能码最高位会被置 1例如0x83、0x86。主站就是靠这个最高位区分正常响应和异常响应的实现时不要在异常路径里复用正常响应的组装函数否则会把异常码当成寄存器数据回传。3. 用 C 实现最小可用的 Modbus TCP Slave 内核3.1 类的对外接口、容器与锁粒度MB_Slave 类的设计目标是“一个实例就是一个设备实例”。对外提供两套操作接口一套给业务线程读写寄存器另一套给协议线程收发报文。两套接口在数据区上存在并发访问的可能因此用一把std::mutex把它们统一保护起来。// mb_slave.h #pragma once #include cstdint #include vector #include mutex #include string class MB_Slave { public: explicit MB_Slave(uint8_t unit_id 1) : unit_id_(unit_id) {} bool init(size_t coils, size_t discrete, size_t holding, size_t input); bool start(int port 502); void stop(); // 业务侧接口 uint16_t getHolding(uint16_t addr) const; void setHolding(uint16_t addr, uint16_t val); bool getCoil(uint16_t addr) const; void setCoil(uint16_t addr, bool val); // 协议侧处理一个完整 PDU 帧不含 MBAP 和串口地址 int handleFrame(const uint8_t* pdu, size_t pdu_len, uint8_t* out_pdu, size_t out_cap); // RTU 串口模式 bool open(const std::string dev, int baud); private: uint8_t unit_id_; size_t coils_n_ 0, discrete_n_ 0, holding_n_ 0, input_n_ 0; std::vectoruint8_t coils_, discrete_; std::vectoruint16_t holding_, input_; mutable std::mutex mtx_; int listen_fd_ -1, client_fd_ -1, serial_fd_ -1; bool running_ false; };锁的粒度我选择全局限住整个 handleFrame而不是为每个寄存器上细粒度锁。单帧处理耗时在微秒级互斥量竞争开销已经接近业务耗时再拆分读写锁只会让复杂度上升收益趋近于零。业务侧的 getHolding / setHolding 也走同一把锁避免协议线程读到写了一半的 16 位字段。TCP 监听 socket 初始化要设置SO_REUSEADDR选项开发期频繁重启程序若上一个连接的 TIME_WAIT 还没释放bind 会直接失败这个选项允许地址立即复用。bool MB_Slave::start(int port) { listen_fd_ socket(AF_INET, SOCK_STREAM, 0); if (listen_fd_ 0) return false; int opt 1; setsockopt(listen_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((uint16_t)port); if (bind(listen_fd_, (sockaddr*)addr, sizeof(addr)) 0) return false; if (listen(listen_fd_, 4) 0) return false; return true; }监听队列长度设置为 4对单个从站场景已经够用要做到多主站并发接入就得在 accept 循环里维护客户端 fd 集合并考虑对每个连接做超时管理那已经超出“最小实现”的范畴。单线程模型下一次只服务一个客户端连接是最稳妥的起点。3.2 MBAP 头解析事务标识必须原样回填Modbus TCP 的请求在 PDU 前加 7 字节 MBAP 头顺序是事务标识符2、协议标识符2、长度2、单元标识符1。事务标识符是主站配对请求和响应的标记从站必须原样带回协议标识符固定为 0否则视为非法报文长度字段统计的是“单元标识符加 PDU”的字节数不是整个帧的长度。// 输入一个完整帧输出 PDU 指针和长度 bool parseMbap(const uint8_t* frame, size_t frame_len, uint16_t trans_id, uint16_t proto_id, uint16_t mbap_len, uint8_t unit_id, const uint8_t** pdu, size_t* pdu_len) { if (frame_len 7) return false; trans_id (uint16_t)((frame[0] 8) | frame[1]); proto_id (uint16_t)((frame[2] 8) | frame[3]); mbap_len (uint16_t)((frame[4] 8) | frame[5]); unit_id frame[6]; if (proto_id ! 0 || mbap_len 2) return false; if ((size_t)mbap_len 6 frame_len) return false; *pdu frame 7; *pdu_len mbap_len - 1; // 长度字段含 unit_id减掉才是 PDU return true; }这里常出的偏差是位移多算 1整个帧长度是mbap_len 6而不是mbap_len 7因为 mbap_len 本身包含了 unit_id 那一字节。PDU 长度等于mbap_len - 1如果这个值小于函数码需要的最小长度直接丢弃避免后续 switch 越界读取。3.3 PDU 分发switch 里处理八种功能码handleFrame 接收去掉 MBAP 之后的 PDU输出的也是 PDU这样 TCP 和 RTU 两个传输层可以共用同一套协议解析逻辑。下面以最常用的读保持寄存器 0x03 和写单个寄存器 0x06 为例int MB_Slave::handleFrame(const uint8_t* pdu, size_t pdu_len, uint8_t* out, size_t out_cap) { if (pdu_len 2) return -1; uint8_t func pdu[0]; std::lock_guardstd::mutex lock(mtx_); out[0] func; // 功能码先占位 size_t rp 1; switch (func) { case 0x03: { // 读保持寄存器 if (pdu_len 5) return -1; uint16_t addr (uint16_t)((pdu[1] 8) | pdu[2]); uint16_t cnt (uint16_t)((pdu[3] 8) | pdu[4]); if (cnt 0 || cnt 125) { out[0] 0x83; out[1] 0x03; return 2; } if (addr holding_n_ || (size_t)addr cnt holding_n_) { out[0] 0x83; out[1] 0x02; return 2; } out[rp] (uint8_t)(cnt * 2); for (uint16_t i 0; i cnt; i) { uint16_t v holding_[addr i]; out[rp] (uint8_t)(v 8); out[rp] (uint8_t)(v 0xFF); } return (int)rp; } case 0x06: { // 写单个寄存器 if (pdu_len 5) return -1; uint16_t addr (uint16_t)((pdu[1] 8) | pdu[2]); uint16_t val (uint16_t)((pdu[3] 8) | pdu[4]); if (addr holding_n_) { out[0] 0x86; out[1] 0x02; return 2; } holding_[addr] val; out[1] (uint8_t)(addr 8); out[2] (uint8_t)(addr 0xFF); out[3] (uint8_t)(val 8); out[4] (uint8_t)(val 0xFF); return 5; } default: out[0] (uint8_t)(func | 0x80); out[1] 0x01; // 非法功能码 return 2; } }这段代码里三个细节决定兼容性。地址和数量按大端解析x86 是小端主机不能直接把两字节 memcpy 成uint16_t。0x03 返回的字节计数字段是“寄存器数量 × 2”不是数量本身。异常响应的长度固定是 2TCP 层和 RTU 层不需要区分正常帧和异常帧它们只是 PDU 内容不同。0x06 写成功后回显“地址值”这是协议规定行为主站靠这份回显确认写入已生效不要改成读回再写。多寄存器功能码 0x10 的响应是回显请求里的地址和数量而 0x0F 的响应同样是回显真正写多寄存器的处理逻辑放在解析与校验之后一次性写入对应容器。数量字段拆成高低字节拼装的时候注意循环写出的顺序保持大端。每个 case 的边界检查都要重复“地址越界、数量越限、返回异常码”三个动作保持模式一致便于 code review 时一眼扫出漏判。3.4 粘包与半包接收循环的帧切分TCP 是字节流一次 recv 不一定刚好收到一个请求也可能一包收到多个请求。标准做法是维护接收缓冲依赖 MBAP 长度字段逐帧切分uint8_t buf[1024], frame[512]; size_t have 0; while (running_) { if (client_fd_ 0) { client_fd_ accept(listen_fd_, nullptr, nullptr); continue; } ssize_t n recv(client_fd_, buf have, sizeof(buf) - have, 0); if (n 0) { close(client_fd_); client_fd_ -1; have 0; continue; } have (size_t)n; while (have 7) { uint16_t mbap_len (uint16_t)((buf[4] 8) | buf[5]); if (mbap_len 2 || (size_t)mbap_len 6 have) break; frame[0] buf[0]; frame[1] buf[1]; // 事务 id 原样带回 frame[2] 0; frame[3] 0; // 协议 id 固定 0 frame[6] buf[6]; // 单元标识符原样带回 int pdu_len handleFrame(buf 7, mbap_len - 1, frame 7, sizeof(frame) - 7); if (pdu_len 0) { uint16_t len_field (uint16_t)(pdu_len 1); frame[4] (uint8_t)(len_field 8); frame[5] (uint8_t)(len_field 0xFF); send(client_fd_, frame, (size_t)pdu_len 7, 0); } memmove(buf, buf (size_t)(mbap_len 6), have - (mbap_len 6)); have - (size_t)(mbap_len 6); } }长度回填写的是pdu_len 1不是pdu_len也不是帧总长这正好匹配 MBAP 对长度字段“单元标识符加 PDU”的定义。send 的长度是pdu_len 7二者相差 6 字节正好是事务标识符和协议标识符的长度对不齐就会在 ModbusPoll 里看到 Length Error。处理完用 memmove 前移缓冲区剩余数据下个循环继续消费。这套逻辑同样适用于 UDP 变体只是需要额外保存对端地址。4. 继续推进Modbus RTU 串口从站与 CRC164.1 串口打开的参数配置RTU 模式把协议直接铺在串口线上帧结构只有“从站地址 PDU CRC16”没有 MBAP 头。关键的差异在于帧边界判定依赖链路静默时间。首先解决串口参数配置bool MB_Slave::open(const std::string dev, int baud) { serial_fd_ ::open(dev.c_str(), O_RDWR | O_NOCTTY | O_NDELAY); if (serial_fd_ 0) return false; struct termios tio{}; tcgetattr(serial_fd_, tio); speed_t brate; switch (baud) { case 9600: brate B9600; break; case 19200: brate B19200; break; case 38400: brate B38400; break; case 115200: brate B115200; break; default: ::close(serial_fd_); return false; } // 原始模式关闭行编辑、回显、软件流控 tio.c_iflag ~(ICRNL | IXON | BRKINT | ISTRIP); tio.c_oflag ~OPOST; tio.c_lflag ~(ICANON | ECHO | ISIG); tio.c_cflag | (CLOCAL | CREAD | CS8); tio.c_cc[VTIME] 1; tio.c_cc[VMIN] 0; cfsetispeed(tio, brate); cfsetospeed(tio, brate); tcsetattr(serial_fd_, TCSANOW, tio); return true; }三个参数决定行为O_NDELAY防止 open 在等待 Modem 信号时挂死VTIME1表示 100ms 无数据则 read 返回这是接收循环的安全兜底CS8配置 8 数据位、无奇偶校验是 RTU 最常用的电气参数。需要偶校验的设备再加PARENB同时主站侧串口参数必须一致否则底层就解错位表现为整帧乱码。调试串口前建议先用命令把端口固定成原始模式再跑程序能排除环境参数干扰stty -F /dev/ttyUSB0 115200 raw程序里termios的配置会和这条命令互补最终以代码内的设置为准。4.2 CRC16 计算与发送字节序Modbus RTU 的 CRC 算法是 CRC-16/MODBUS多项式 0xA001初始值 0xFFFF。逐位计算只有十几行测试方便查表法适合连续接收多帧的网关场景空间换时间代码边界也更清晰。static std::arrayuint16_t, 256 make_crc_table() { std::arrayuint16_t, 256 t{}; for (uint16_t i 0; i 256; i) { uint16_t c i; for (int k 0; k 8; k) c (c 1) ? (uint16_t)((c 1) ^ 0xA001) : (uint16_t)(c 1); t[i] c; } return t; } uint16_t crc16_rtu(const uint8_t* data, size_t len, const std::arrayuint16_t, 256 table) { uint16_t crc 0xFFFF; for (size_t i 0; i len; i) { uint8_t idx (uint8_t)((crc ^ data[i]) 0xFF); crc (uint16_t)((crc 8) ^ table[idx]); } return crc; }字节序陷阱在收发两端都会出现发送时 CRC 低字节在前高字节在后。接收端把帧尾两字节按低字节在前拼成uint16_t再与对一帧重新计算的值比较。很多人在这段卡住因为数值明明和抓包工具里显示的一样却总是 CRC error——多半是自己按高字节在前拼接对比值了。现象可能原因检查动作主站持续报 CRC error发送或接收时字节序反了把 CRC 低字节先写入报文接收端低字节在前拼值偶尔一帧 CRC error帧间隔不够从站收进残帧串口打十六进制日志对比每帧长度所有请求超时从站地址不匹配请求被丢弃核对 unit_id 和主站设置偶发数据错位半包拼接进下一帧检查静默时间判定阈值4.3 RTU 接收循环中的帧切分read 循环不断读单字节累积到接收缓冲再判定一帧是否完整。判定方式有三种按功能码推算固定长度、按 3.5 字符时间静默、或用固定超时认定一帧结束。第三方从站和 PLC 对 3.5 字符时间的实现都留了余量所以更简单的 10ms 固定超时也能在多数现场工作稳定。uint8_t rx[256]; size_t rx_len 0; while (running_) { uint8_t b; ssize_t n read(serial_fd_, b, 1); if (n ! 1) { check_frame_timeout(); continue; } rx[rx_len] b; if (rx_len 256) { rx_len 0; continue; } if (timeout_elapsed()) { // 超过 10ms 没有新字节视为一帧结束 if (rx_len 4 rx[0] unit_id_) { uint16_t calc crc16_rtu(rx, rx_len - 2, crc_table); uint16_t got (uint16_t)((rx[rx_len - 1] 8) | rx[rx_len - 2]); if (calc got) { uint8_t resp[256]; int pdu_len handleFrame(rx 1, rx_len - 3, resp 1, sizeof(resp) - 1); if (pdu_len 0) { resp[0] unit_id_; // 回填从站地址 uint16_t crc crc16_rtu(resp, (size_t)pdu_len 1, crc_table); resp[pdu_len 1] (uint8_t)(crc 0xFF); resp[pdu_len 2] (uint8_t)(crc 8); write(serial_fd_, resp, (size_t)pdu_len 3); } } } rx_len 0; } }这里复用 handleFrame 只处理 PDU 的特性RTU 层负责剥地址和 CRC。handleFrame 入参是rx 1长度是rx_len - 3去掉从站地址和两个 CRC 字节出参写到resp 1给地址位留空。CRC 计算要覆盖从站地址加 PDU所以入参长度是pdu_len 1。RTU 响应总长度等于 PDU 长度加 3 字节地址、CRC 两字节三个长度来源各不相同建议在代码里写清楚相邻注释避免日后重构时混用。5. 用 ModbusPoll 和边界用例把从站钉死5.1 搭建本地回归测试的配置组合调试从站最省事的客户端工具是 ModbusPoll关键是把它配成一台会主动触发边界条件的测试机。先连 TCP 跑通数据通路再切 RTU 增加串口变量按下面这组配置覆盖正常路径ModbusPoll 设置项填写的值从站端预期Slave ID1MBAP 的 unit_id 为 1不匹配的帧丢弃Function03 Read Holding Register触发 0x03 分支返回字节计数字段Address0PDU 偏移 0对应 holding_[0]Quantity125返回 250 字节数据单帧上限内的正常路径Quantity126触发异常 0x83 0x03Address60000触发 0x83 0x02 地址越界跑完常规路径切 0x06 写单寄存器写入一个值再切回 0x03 读回来确认数据落进 holding_。0x05 和 0x0F 的位区操作按同样方式验证。ModbusPoll 和 Modbus Slave 的组合使用到这里才不只是“能通”而是把边界也验证过了。5.2 从异常现象倒推根因的排查顺序ModbusPoll 一直转圈或显示错误时按“端口 → 抓包 → PDU 逻辑”的顺序逐层缩窄。日志里的字节要打十六进制只打“received ok”这类消息对错位定位毫无价值。现象根因处理动作Connect Timeout端口未监听、防火墙拦截netstat -tlnp 确认 502 端口状态Connection reset业务代码误关了 client_fd在 accept 循环打印 fd 变化Length errorMBAP 长度回填错误核对长度字段是否等于 pdu_len 1CRC errorCRC 字节序反了低字节优先写入报文偶发超时帧长换算错误半包被吞检查粘包循环里 mbap_len 6 的计算5.3 上线前最后补上的三个 C 细节第一长度计算只能集中在一个函数里。TCP 侧 “MBAP 长度字段 PDU 长度 1” 只应在组装响应的位置出现一次不要在 handleFrame 内部再算一遍帧长否则改动 PDU 结构时会漏掉一处换算。第二handleFrame 已经持有锁不要在业务回调里又调用 getHolding 或 setHoldingstd::mutex不可重入同一线程会直接死锁。第三关闭 client_fd 前先shutdown(SHUT_RDWR)让四次挥手由内核走完否则长跑模拟器会在 TIME_WAIT 上积累连接耗尽端口后所有新请求都失败。SO_REUSEADDR放在 bind 前设置串口打开后先tcdrain清掉残留字节避免把历史乱码误判成帧头。把地址越界、数量越限、非法功能码三个异常路径做一组自动化用例每次改动后用同一组序列回归这比只测正向路径可靠得多。本文还有配套的精品资源点击获取
返回列表