资讯详情

资讯详情

C++ Modbus从站实战:寄存器映射、功能码与MBAP/RTU报文

简介围绕 Modbus 从站服务端通信实现的 C 小型项目源码面向工业自动化、嵌入式通信方向的学习者以及需要搭建协议原型的开发者重点演示 0x03 功能码读取保持寄存器的请求解析、寄存器数据组织、响应报文打包及 CRC 校验等关键环节。压缩包共 49 个文件、约 13.45MB包含 cpp/h 源文件与头文件、sln/vcxproj 工程配置、可执行程序及日志调试产物等既可对照阅读协议处理流程也便于在现有工程上编译验证和二次扩展。已有 1886 人学习说明其在 Modbus 入门与工程实践方面具有一定参考价值。代码围绕请求识别、地址与数量提取、异常响应和 TCP/IP 或串行链路通信展开并提供面向其他功能码的扩展接口适合用来理解主从请求-响应模型、梳理报文结构与排错思路。1. 为什么一个 C Modbus 从站小项目值得先跑通再改调试一个 C Modbus 从站项目时最让人翻车的往往不是协议本身而是寄存器地址从 0 还是从 1 开始、16 位寄存器的高低位怎么排、TCP 粘包怎么切。这份 C modbus 服务端从站小项目源码把 Modbus TCP/RTU 从站的核心链路拆成可编译的工程寄存器映射、功能码 03/04/06/16 处理、异常响应和连接管理都在里面。适合正在做设备侧数据采集、上位机对接、工控网关原型的开发者尤其适合想用 C 直接接管从站逻辑、又不想从零造轮子的人。先别急着加业务寄存器按下面的顺序把最小链路跑通后面改功能码才不容易把锅甩给“协议玄学”。2. 工程解剖从 CMake 到第一个监听 1502 端口的从站拿到源码包先别急着改功能码。C Modbus 从站小项目最怕一上来就动协议层结果编译不过、端口起不来连是工程问题还是协议问题都分不清。先按“构建入口—传输层—寄存器层—功能码层”四块拆开跑通一条最小链路再谈业务。这个项目我一般会按下面的目录去对源码包里未必文件名完全一致但模块边界基本是这几块。文件/目录职责先看什么CMakeLists.txt构建入口声明 C 标准和源文件有没有漏掉Threads或 Windows 的ws2_32src/main.cpp参数解析、配置加载、事件循环启动--mode、--port、--unit三个参数src/modbus_tcp_server.*TCP 监听、连接管理、MBAP 切帧接收缓冲区有没有按长度字段循环切src/modbus_rtu_server.*串口打开、帧边界、CRC 校验串口参数和 3.5 字符定时器src/register_map.*四类数据区、读写接口、越界检查地址是 0-based 还是 1-basedsrc/function_code.*功能码分发、异常响应不支持的功能码返回 01 还是直接断开conf/modbus.conf端口、从站地址、寄存器初值寄存器数量是“个数”不是“字节数”2.1 源码目录与模块边界传输层只负责收字节、切帧功能码层只认 PDU寄存器层不关心 TCP 还是 RTUmain只做参数解析和事件循环。这样拆的好处是后面加 RTU 不用把 TCP 的解析逻辑复制一遍换寄存器存储方式也不用动 socket 代码。常见误用是把recv和寄存器解析写在一个函数里第一版能跑第二版加串口就开始出现“TCP 能读、RTU 读不到”的血泪经验。另一个误用是把从站做成主动轮询Modbus 从站是被动应答客户端不发请求从站就不应该主动往总线上写数据。从站的事件循环一般用poll或select做非阻塞监听收到完整帧后交给功能码层功能码层返回 PDU再由传输层拼回 MBAP 或加 CRC。这个顺序不要反先解析出完整请求再碰寄存器不要在字节流还没切完时就去读地址字段半包状态下读到的地址大概率是错的。模块边界清楚以后调试时看日志就能判断问题落在哪一层连接建立失败看传输层返回异常码看功能码层值不对看寄存器层。2.2 编译链路CMake 与最小依赖这个项目不需要第三方协议库C17 标准库加系统 socket 就够。CMake 里最重要的是别漏链接Linux 下用了std::thread要链接Threads::ThreadsWindows 下用了 Winsock 要链接ws2_32。下面是一个能直接对照的构建文件源码包里的目标名可能不同但结构等价。cmake_minimum_required(VERSION 3.16) project(modbus_slave_demo LANGUAGES CXX) set(CMAKE_CXX_STANDARD 17) set(CMAKE_CXX_STANDARD_REQUIRED ON) add_executable(modbus_server src/main.cpp src/modbus_tcp_server.cpp src/modbus_rtu_server.cpp src/register_map.cpp src/function_code.cpp ) target_include_directories(modbus_server PRIVATE src) # Linux 下串口用 termios不需要额外库Windows 下 socket 需要 ws2_32 if(WIN32) target_link_libraries(modbus_server PRIVATE ws2_32) endif() # 如果源码里用了 std::thread必须显式链接线程库 find_package(Threads REQUIRED) target_link_libraries(modbus_server PRIVATE Threads::Threads)这段 CMake 的逻辑很直白CMAKE_CXX_STANDARD 17是为了std::optional、std::shared_mutex这些类型target_include_directories把src加进头文件搜索路径Threads::Threads不是可选项少了它会出现undefined reference to pthread_create。参数怎么改新增.cpp就加进add_executable新增子目录就加target_include_directories。失败时先看完整编译命令里的-std是不是 C17再看链接行有没有-pthread。不要为了“跑得快”开-ffast-math之类的浮点优化Modbus 里大量是整数位运算开了反而容易在 32 位浮点拼接上出怪值。编译和启动用下面三条命令。测试阶段把端口改成 1502避开 502 的特权限制生产环境再按部署方式处理端口权限。cmake -S . -B build -DCMAKE_BUILD_TYPERelease cmake --build build -j ./build/modbus_server --mode tcp --port 1502 --unit 1 --config conf/modbus.conf--mode tcp选择 TCP 从站--port 1502是监听端口--unit 1是从站地址--config指定寄存器初值和数量。TCP 下很多客户端不校验单元标识但网关把请求转发到 RTU 时这个值会派上用场所以别随手填 0。启动失败先看两个报错bind: Address already in use说明端口被占用ss -lntp | grep 1502找进程bind: Permission denied说明用了 502 但没有绑定特权端口的权限。生产环境不建议直接用 root 跑整个进程给可执行文件加cap_net_bind_service更合适。提示第一次跑先用 1502等客户端能读能写再换 502 验证部署权限。端口换得越晚越容易把权限问题误判成协议问题。配置文件建议单独放不要把所有参数硬编码在main.cpp。下面是一份最小配置寄存器数量按实际业务开不要一上来就分配 65536 个。# conf/modbus.conf [tcp] listen 0.0.0.0 port 1502 unit 1 max_connections 16 [registers] holding_count 100 input_count 32 coil_count 64 discrete_count 16listen 0.0.0.0表示监听所有网卡调试时也可以改成127.0.0.1只允许本机max_connections限制并发连接防止文件描述符被耗尽holding_count是 16 位保持寄存器的个数不是字节数100 个寄存器占 200 字节。配置项拼错时程序应该报错退出不要静默用默认值否则后面读不到数据会查很久。2.3 第一次启动日志、端口与最小读取启动日志至少打印四样东西监听地址和端口、从站地址、四类寄存器区的数量、配置文件路径。没有日志就先加日志别靠猜。端口起来后用ss确认监听状态能连上只说明 TCP 层通了不代表 Modbus 协议对。ss -lntp | grep 1502 # 期望看到类似LISTEN 0 128 0.0.0.0:1502 0.0.0.0:* users:((modbus_server,pid...,fd3))-l看监听-n显示数字端口-t只看 TCP-p带进程名。没有ss就用netstat -anp | grep 1502。接着用nc -vz 127.0.0.1 1502做一次连接探测服务端日志里应该出现accept记录。如果连不上先查绑定地址是不是写成了某个不存在的网卡地址再查本机防火墙如果连上了但没有accept日志检查事件循环是不是被某个阻塞调用卡住了。第一次读取建议只读一个保持寄存器数量填 1地址填协议偏移 0。读到了再扩到两个寄存器确认响应里的字节数从 2 变成 4。这个顺序看起来慢但能把“地址错、数量错、字节序错”分开定位。等最小读取通过再去看写单个寄存器和写多个寄存器别一上来就用批量写压测否则日志里全是失败请求反而看不出第一处错误在哪。3. 寄存器映射四类数据区与 03/04/06/16 功能码怎么落地寄存器映射是从站的核心。很多对接问题不是协议没通而是地址和数据类型对不上客户端按 40001 读服务端按偏移 1 取读回来的值永远差一个寄存器。先把四类数据区摆清楚再写功能码分发最后统一字节序。下面这张表建议直接贴在项目文档里代码和测试脚本都以它为单一来源。数据区访问方式位宽常用功能码PLC 习惯地址线圈可读可写1 bit01 / 05 / 1500001 起离散输入只读1 bit0210001 起输入寄存器只读16 bit0430001 起保持寄存器可读可写16 bit03 / 06 / 1640001 起3.1 四类数据区与地址基址协议层地址是 0-basedPLC 习惯地址是 1-based 且带前缀。保持寄存器的 40001 对应协议偏移 040002 对应偏移 1。这个转换只做一次放在配置加载或请求入口不要在功能码里到处减一。下面这个转换函数只处理“展示地址到协议偏移”返回std::optional是为了把非法地址挡在入口。// 把 PLC 习惯的 4xxxx 展示地址转换成协议里的 0-based 偏移 // base 传 40001 表示保持寄存器输入寄存器传 30001线圈传 00001 std::optionaluint16_t plcAddrToOffset(uint32_t plcAddr, uint32_t base) { if (plcAddr base) return std::nullopt; uint32_t offset plcAddr - base; // 协议地址字段只有 16 位超过 65535 的偏移直接判非法 if (offset 0xFFFF) return std::nullopt; return static_castuint16_t(offset); }这段代码的关键参数是base保持寄存器用 40001输入寄存器用 30001线圈用 00001。plcAddr必须用 32 位整数接收因为 40001 放进uint16_t会溢出成 0x9C41后面怎么减都不对。offset 0xFFFF的判断不能省Modbus 请求里的起始地址只有两个字节超过这个范围客户端本来就发不出来但配置文件里可能写错。失败时看客户端返回的异常码地址越界一般返回 02数量或值不合法返回 03。业务寄存器建议单独做一张映射表代码里不要出现两套地址。比如设备状态放 40001温度放 40002累计产量放 40003 到 40004启停命令放线圈 00001。映射表里同时写清楚数据类型和权限只读区被写请求命中时返回异常码 02 或 03不要悄悄忽略。业务点PLC 习惯地址协议偏移类型权限初值设备状态400010uint16只读0温度400021int160.1℃只读250累计产量40003-400042-3uint32只读0启停命令000010coil读写03.2 功能码分发03/04/06/16 的边界功能码入口先校验数量和地址范围再分发。03 和 04 读寄存器单次最多 125 个06 写单个寄存器16 写多个寄存器单次最多 123 个。超过上限不要“尽量处理”直接返回异常码 03。不支持的功能码返回 01不要直接断开连接否则客户端只会看到超时排查时像面对一个黑匣子。// 功能码入口先做通用校验再分发异常响应统一由 buildException 生成 ModbusResponse dispatch(const ModbusRequest req, RegisterMap map) { switch (req.func) { case 0x03: if (req.count 0 || req.count 125) return buildException(req, 0x03); return readHoldingRegisters(req, map); case 0x04: if (req.count 0 || req.count 125) return buildException(req, 0x03); return readInputRegisters(req, map); case 0x06: return writeSingleRegister(req, map); case 0x10: if (req.count 0 || req.count 123) return buildException(req, 0x03); return writeMultipleRegisters(req, map); default: return buildException(req, 0x01); // 非法功能码 } }req.func是功能码req.start是协议偏移req.count是数量或字节数具体看功能码。异常响应的功能码要把原功能码最高位置 1比如 0x03 的异常响应是 0x83后面跟一个异常码。异常码 01 表示非法功能02 表示非法数据地址03 表示非法数据值04 表示从站设备故障。客户端显示IllegalAddress时先查地址范围显示IllegalValue时先查数量和写入值。失败时不要只看客户端提示服务端日志里把func/start/count打出来一眼就能对上。写多个寄存器还要注意字节数字段。请求里的字节数是后续数据区的长度必须等于count * 2不相等直接返回 03不要按count硬读否则会把后面的字节当成寄存器值。读响应里的字节数等于count * 2这个字段客户端会校验算错了客户端可能直接丢包。3.3 写操作加锁与 16 位字节序Modbus 寄存器按大端传输高字节在前低字节在后。本机是小端时不能把网络字节直接memcpy到uint16_t就完事否则 0x1234 会被解释成 0x3412。统一用下面两个函数读的时候拼写的时候拆。// Modbus 寄存器按大端传输p[0] 是高字节p[1] 是低字节 uint16_t readU16BE(const uint8_t* p) { return static_castuint16_t((static_castuint16_t(p[0]) 8) | p[1]); } void writeU16BE(uint8_t* p, uint16_t v) { p[0] static_castuint8_t((v 8) 0xFF); p[1] static_castuint8_t(v 0xFF); }readU16BE里先把p[0]转成uint16_t再左移避免uint8_t提升后符号位出问题writeU16BE先取高字节再取低字节。32 位浮点占两个寄存器字节序和字序是两件事字节序是每个 16 位寄存器内部的高低位字序是两个寄存器谁在前。Modbus 规范只规定 16 位寄存器大端不规定 32 位组合顺序所以配置里要显式写word_order和byte_order用 1.0 的十六进制0x3F800000做基准值验证。多客户端并发写同一片寄存器时读和写要加锁。读多写少的场景用std::shared_mutex读用共享锁写用独占锁越界检查和数据拷贝放在同一个临界区发送响应前先把数据拷贝出来不要持有锁做 socket 发送。// 读保持寄存器共享锁内完成越界检查和拷贝出锁后再发响应 std::optionalstd::vectoruint16_t readHolding(uint16_t start, uint16_t count) { std::shared_lock lock(mtx_); if (start count holding_.size()) return std::nullopt; return std::vectoruint16_t(holding_.begin() start, holding_.begin() start count); }start count要用 32 位计算再比较两个uint16_t相加可能溢出。std::nullopt表示地址越界上层转成异常码 02。写多个寄存器时整段加std::unique_lock先检查范围再一次性写入不要一个寄存器一个寄存器地加锁否则客户端可能读到一半新一半旧的数据。4. 报文层实战MBAP、粘包与 RTU CRC 的三道关报文层是从站的黑匣子。TCP 和 RTU 的差别在帧边界和校验功能码层可以共用。TCP 用 7 字节 MBAP 头描述长度RTU 用静默间隔分帧再加 CRC16。先把这两套边界处理对再谈业务数据。下面用一个读保持寄存器的请求把字段拆开后面写代码时可以直接对照。字段请求示例说明事务标识00 01客户端自增服务端原样返回协议标识00 00固定 0长度00 06单元标识 P本文还有配套的精品资源点击获取
觉得有用,分享给同行:

为您的企业打造数字门面

稳重轻奢商务风格,端正雅致视觉,长效耐看不易过时。

立即咨询 →