Modbus-TCP帧结构不等于Modbus-RTU加TCP头,其核心是7字节MBAP头(含transaction_id、protocol_id=0x0000、length、unit_id),length为大端且不含MBAP自身,需动态缓冲+分次recv解析,寄存器数据字节序须依设备手册配置。

Modbus-TCP帧结构不等于Modbus-RTU加个TCP头
很多人误以为只要把Modbus-RTU的ADU(Application Data Unit)直接塞进TCP payload 就完事了——这是错的。Modbus-TCP 使用 MBAP(Modbus Application Protocol)头,长度固定 7 字节,位于 TCP payload 开头,和 RTU 的 CRC 校验、起始/结束符完全无关。
MBAP 头字段顺序和含义必须严格对齐:transaction_id(2B)、protocol_id(2B,固定为 0x0000)、length(2B,后续字节数,不含 MBAP 自身)、unit_id(1B)。漏读或错序会导致 length 解析错误,进而把后续数据全盘错位。
-
transaction_id是客户端自增标识,服务端必须原样回传;它不参与功能逻辑,但用于匹配请求/响应,丢弃或篡改会导致超时重发或乱序响应 -
length字段值 =unit_id+ 功能码 + 数据区总字节数(例如读保持寄存器请求:1B unit_id + 1B function_code + 4B address + 2B quantity → length = 8) - 常见坑:
length是大端(network byte order),x86 直接memcpy到 uint16_t 后需用ntohs()转换,否则在小端机上会解析出错值
解析时别直接 recv() 一次就认为收完了
TCP 是流式协议,recv() 返回字节数可能远小于一帧长度,尤其在网络拥塞或高并发下。Modbus-TCP 帧最小为 12 字节(7B MBAP + 1B unit_id + 1B function_code + 3B 最小数据区),但实际常达几十到上百字节。靠单次 recv() 拼包必然失败。
正确做法是维护接收缓冲区(如 std::vector<uint8_t></uint8_t>),循环调用 recv() 并追加,每次检查是否满足“MBAP length 字段所声明的总长”:
立即学习“C++免费学习笔记(深入)”;
// 假设 buf 是已累积的 raw bytes
if (buf.size() >= 7) {
uint16_t len = ntohs(*reinterpret_cast<uint16_t*>(&buf[4])); // offset 4 is length field
if (buf.size() >= 7 + len) {
// 完整帧就绪,开始解析 MBAP + ADU
parse_modbus_tcp_frame(buf.data(), buf.size());
buf.erase(buf.begin(), buf.begin() + 7 + len);
}
}
- 永远不要假设
recv()会按帧边界返回;也别用MSG_WAITALL,它在非阻塞 socket 下无效,且无法处理粘包 - 缓冲区需支持动态增长,避免固定数组溢出;建议预分配 1024 字节并 reserve,避免频繁 realloc
- 超时机制必须绑定在“等待完整帧”阶段,而非单次
recv(),否则网络抖动易触发误判
功能码 0x03 / 0x04 响应中寄存器数据的字节序陷阱
读保持寄存器(0x03)或输入寄存器(0x04)的响应,数据区第一个字节是 byte_count,表示后续字节数。但真正寄存器值怎么排?标准没强制规定,工业设备五花八门:
- 多数 PLC(如 Siemens S7-1200、Schneider Modicon)默认每个寄存器 2 字节,高位在前(big-endian),即
0x1234存为0x12 0x34 - 部分国产仪表或旧设备用反序:寄存器内字节倒置(
0x1234 → 0x34 0x12),甚至整个寄存器数组倒序(低地址寄存器放后面) - 浮点数更复杂:IEEE 754 单精度常以 2 个连续寄存器传输,但字节序组合方式有 4 种主流变体(ABCD / BADC / CDAB / DCBA),必须查设备手册确认
别硬编码转换逻辑。应在配置中显式指定 register_byte_order(big_endian/little_endian)和 word_swap(是否交换寄存器高低字节),运行时按需组合:
// 示例:解析 2 个寄存器组成的 float32 uint16_t reg0 = ntohs(*reinterpret_cast<uint16_t*>(data_ptr)); uint16_t reg1 = ntohs(*reinterpret_cast<uint16_t*>(data_ptr + 2)); uint32_t raw = (word_swap ? ((reg1 << 16) | reg0) : ((reg0 << 16) | reg1)); float value = *reinterpret_cast<float*>(&raw);
异常响应(function_code & 0x80)必须被识别和分发
当从站返回功能码最高位置 1(如 0x83 表示 0x03 的异常),MBAP 头不变,但后续只剩 2 字节:exception_code(1B)。此时 length 字段值为 3(1B unit_id + 1B exception_fc + 1B code),不是 2 —— 因为 length 统计的是 MBAP 之后所有字节,包括异常功能码本身。
- 不检查
function_code & 0x80会导致把异常响应当作正常数据解析,比如把0x83 0x02当成“读 0x03 个寄存器”,结果越界访问 - 常见
exception_code:0x01(非法功能)、0x02(非法数据地址)、0x03(非法数据值)、0x04(服务器设备故障) - 异常响应也必须携带原始
transaction_id,否则无法关联到哪个请求失败;丢弃或忽略它会让上层超时重试,加重总线负担
真正难的不是解析帧,而是面对不同厂商设备时,如何让同一套解析逻辑兼容 MBAP 变体、寄存器排列歧义、异常处理粒度和超时恢复策略——这些细节不在标准里,全在设备手册的犄角旮旯里。



















