文章目录

ST通信协议解析:用位操作拆解 Modbus/TCP 报文字节

发布于 2026-03-20 07:07:17 · 浏览 6 次 · 评论 0 条

ST通信协议本身并不存在——这是一个常见误解。工业现场常被误称为“ST协议”的,实际是 Modbus/TCP 在施耐德(Schneider Electric)EcoStruxure 系统中通过 Unity Pro 或 EcoStruxure Control Expert 编程软件实现的 标准 Modbus/TCP 通信封装方式,其底层报文完全遵循 RFC 1006 和 Modbus Application Protocol (MBAP) 规范。所谓“ST”实为施耐德编程环境中的数据类型前缀(如 ST_W16 表示16位字)、或用户对“Schneider TCP”“Structured Transfer”的模糊简称,并非国际标准协议。本文直击本质:用纯位操作逐字节拆解 Modbus/TCP 报文,不依赖任何库、不调用抽象函数,只靠移位、掩码、按位与/或,还原每一个比特的意义。


一、先确认你面对的是真正的 Modbus/TCP 报文

Modbus/TCP 不是“Modbus + TCP”简单叠加,而是将原始 Modbus RTU 帧(含地址、功能码、数据、CRC)去掉串口层的地址字段和 CRC 校验,再嵌入一个固定的 7 字节 MBAP 头部(Modbus Application Protocol Header),最终封装在标准 TCP 数据段中。

你抓到的网络包若满足以下全部条件,就是标准 Modbus/TCP:

  1. TCP 目标端口为 502(Modbus/TCP 默认端口);
  2. TCP 载荷长度 ≥ 7 字节;
  3. 前 7 字节符合 MBAP 头格式(见下表);
  4. 第 7 字节之后的内容,即为标准 Modbus 功能码及后续数据(无 CRC)。

若不符合,则可能是:

  • 施耐德私有扩展(如使用端口 503 的“Modbus Plus over TCP”伪协议);
  • 非标准封装(如某些国产 PLC 将 MBAP 头压缩为 4 字节);
  • 或根本不是 Modbus(如 Ethernet/IP、Profinet、OPC UA)。

因此,第一步永远是:提取 TCP 载荷,检查前 7 字节


二、MBAP 头:7 字节,字字皆可位操作

MBAP 头结构严格固定,共 7 字节,按网络字节序(大端)排列。以下是其字节布局与位级解读:

| 字节偏移 | 名称       | 长度 | 含义说明                                                                 |
|----------|------------|------|--------------------------------------------------------------------------|
| 0–1      | Transaction ID | 2 字节 | 客户端自定义事务标识符,用于匹配请求与响应。**高位在前**,即 byte[0] 是高字节。 |
| 2–3      | Protocol ID  | 2 字节 | 固定为 `0x0000`。**非零值表示非标准 Modbus 扩展**(如施耐德某些固件用 `0x0001` 表示带诊断头)。 |
| 4–5      | Length     | 2 字节 | 后续字节数(即从功能码开始到报文末尾的总长度),**不含 MBAP 头本身**。例如读保持寄存器返回 4 个字,Length = `0x0006`(功能码1字节 + 寄存器数量1字节 + 数据4字节)。 |
| 6        | Unit ID    | 1 字节 | 从站设备地址(0–255)。在纯 TCP 场景中本应废弃,但多数网关仍保留该字段以兼容 Modbus RTU 映射。 |

✅ 关键事实:所有字段均为无符号整数,大端编码(Big-Endian)。这意味着:

  • byte[0]byte[1] 组成 Transaction ID:ID = (byte[0] << 8) | byte[1]
  • byte[2]byte[3] 组成 Protocol ID:PID = (byte[2] << 8) | byte[3]
  • byte[4]byte[5] 组成 Length:len = (byte[4] << 8) | byte[5]
  • byte[6] 即 Unit ID:unit = byte[6]

无需查表,直接位运算即可解析。例如,某抓包得到 MBAP 头 7 字节为:
0x1A 0x2B 0x00 0x00 0x00 0x06 0x01

则:

  • Transaction ID = (0x1A << 8) | 0x2B = 0x1A2B = 6699
  • Protocol ID = (0x00 << 8) | 0x00 = 0x0000 → 符合标准;
  • Length = (0x00 << 8) | 0x06 = 6
  • Unit ID = 0x01

由此确认:这是一个标准请求/响应,其后紧跟 6 字节有效载荷。


三、功能码与数据:从第 7 字节开始,逐位定位

MBAP 头之后的第一个字节(即整个报文的 byte[7])是 功能码(Function Code),1 字节,取值范围 0x010x6F(常用 0x01, 0x03, 0x06, 0x10 等)。

功能码决定后续字节的语义结构。下面以最常用的 0x03 读保持寄存器(Read Holding Registers) 为例,拆解其请求与响应报文的每一位。

▶ 请求报文(客户端 → 服务器)

格式:[MBAP 头 7B] + [0x03] + [起始地址高字节] + [起始地址低字节] + [寄存器数量高字节] + [寄存器数量低字节]

共 12 字节(7+5)。假设请求:读地址 40001(即寄存器 0x0000)起的 2 个保持寄存器:

  • 起始地址 = 0x0000 → byte[8]=0x00, byte[9]=0x00
  • 寄存器数量 = 2 → byte[10]=0x00, byte[11]=0x02

完整请求字节流(十六进制):
1A 2B 00 00 00 06 01 03 00 00 00 02

现在用位操作验证:

  1. 确认功能码位置与值
    byte[7] == 0x03 → 是读保持寄存器请求;

  2. 解析起始地址(16位)
    addr = (byte[8] << 8) | byte[9] = (0x00 << 8) | 0x00 = 0x0000

  3. 解析寄存器数量(16位)
    count = (byte[10] << 8) | byte[11] = (0x00 << 8) | 0x02 = 2

✅ 全部由位移与或完成,零函数调用。

▶ 响应报文(服务器 → 客户端)

格式:[MBAP 头 7B] + [0x03] + [字节数 N] + [N 字节数据]

其中:

  • N = count × 2(每个寄存器 2 字节);
  • 数据按寄存器顺序连续排列,每个寄存器高字节在前(大端)。

接上例,返回 2 个寄存器,值分别为 0x12340xABCD,则响应为:

  • MBAP 头 Length = 2(功能码)+ 1(字节数)+ 4(数据)= 70x0007
  • byte[7] = 0x03
  • byte[8] = 0x04(4 字节数据);
  • byte[9] = 0x12(寄存器0高字节);
  • byte[10] = 0x34(寄存器0低字节);
  • byte[11] = 0xAB(寄存器1高字节);
  • byte[12] = 0xCD(寄存器1低字节)。

完整响应(14 字节):
1A 2B 00 00 00 07 01 03 04 12 34 AB CD

位操作提取数据:

// 假设 recv_buf 指向接收到的字节流首地址
uint8_t *buf = recv_buf;
uint16_t trans_id = (buf[0] << 8) | buf[1];
uint16_t len = (buf[4] << 8) | buf[5];
uint8_t unit_id = buf[6];
uint8_t func = buf[7];

if (func == 0x03) {
    uint8_t byte_count = buf[8]; // 必为偶数
    uint16_t reg0 = (buf[9] << 8) | buf[10]; // 0x1234
    uint16_t reg1 = (buf[11] << 8) | buf[12]; // 0xABCD
}

注意:buf[8] 是字节数,不是寄存器数;buf[9] 开始才是第一个寄存器的高字节。


四、关键陷阱:功能码异常响应的位级识别

当服务器无法执行请求(如地址越界、从站离线),不返回错误数据,而是返回 异常响应报文:功能码最高位置 1(即 0x80 + 原功能码),后跟 1 字节异常码。

例如,请求 0x03 失败,返回:
[MBAP 头] + 0x83 + 0x02
其中 0x83 = 0x80 | 0x030x02 是异常码(0x02 = “非法数据地址”)。

位操作判断异常只需一行:

if ((buf[7] & 0x80) != 0) { // 最高位为1
    uint8_t original_func = buf[7] & 0x7F; // 清除最高位,得原功能码
    uint8_t exception_code = buf[8];       // 异常码在下一字节
}

✅ 这比字符串匹配或 switch-case 更底层、更可靠。


五、施耐德 Unity Pro 中的“ST”实践:如何用 Structured Text 实现位解析

Unity Pro 使用 IEC 61131-3 标准 Structured Text(ST),其位操作能力极强。以下代码片段可直接粘贴至 ST 函数块中,实现 Modbus/TCP 报文解析:

// 输入:ModbusTCP_Buffer : ARRAY[0..255] OF BYTE (接收缓冲区)
// 输出:TransID, ProtocolID, Length, UnitID, FunctionCode, ExceptionFlag, RegValue1, RegValue2
VAR
    TransID         : UINT;
    ProtocolID      : UINT;
    Length          : UINT;
    UnitID          : BYTE;
    FunctionCode    : BYTE;
    ExceptionFlag   : BOOL;
    RegValue1       : UINT;
    RegValue2       : UINT;
    i               : INT;
END_VAR

// 解析 MBAP 头
TransID      := SHL(MODBUS_Buffer[0], 8) + MODBUS_Buffer[1];
ProtocolID   := SHL(MODBUS_Buffer[2], 8) + MODBUS_Buffer[3];
Length       := SHL(MODBUS_Buffer[4], 8) + MODBUS_Buffer[5];
UnitID       := MODBUS_Buffer[6];
FunctionCode := MODBUS_Buffer[7];

// 判断是否异常响应
ExceptionFlag := (FunctionCode AND 16#80) <> 0;

IF NOT ExceptionFlag THEN
    // 正常响应:读保持寄存器(0x03),假设有2个寄存器
    IF FunctionCode = 16#03 THEN
        RegValue1 := SHL(MODBUS_Buffer[9], 8) + MODBUS_Buffer[10];
        RegValue2 := SHL(MODBUS_Buffer[11], 8) + MODBUS_Buffer[12];
    END_IF
ELSE
    // 异常响应:取异常码
    ExceptionCode := MODBUS_Buffer[8];
END_IF

✅ Unity Pro ST 中:

  • SHL(x, 8) = 左移 8 位(等价于 x << 8);
  • 16#03 = 十六进制字面量;
  • AND 为按位与,<> 为不等于;
  • 所有运算均为确定性位操作,无浮点、无指针、无内存分配。

六、进阶:用位掩码处理非对齐字段(如位读取)

Modbus 功能码 0x01(读线圈)和 0x02(读离散输入)返回的是 位流(bit stream),而非字。例如读 10 个线圈,返回字节数 = CEIL(10 / 8) = 2,即 0x02 字节,共 16 位,仅前 10 位有效,低位在前(LSB first)。

假设响应为:[MBAP] + 0x01 + 0x02 + 0xA5 + 0x01
0xA5 = %101001010x01 = %00000001
→ 合并为 16 位:%00000001_10100101,但注意:第一个字节对应线圈0–7,第二个字节对应线圈8–15,且每个字节内 LSB 为最低地址线圈

因此线圈状态(从 0 开始编号)为:

  • 线圈0:0xA5 AND 1 ≠ 0 → TRUE
  • 线圈1:(0xA5 SHR 1) AND 1 ≠ 0 → FALSE
  • 线圈2:(0xA5 SHR 2) AND 1 ≠ 0 → TRUE
  • ……
  • 线圈8:0x01 AND 1 ≠ 0 → TRUE
  • 线圈9:(0x01 SHR 1) AND 1 = 0 → FALSE

ST 中实现单线圈提取函数:

FUNCTION GetCoilState : BOOL
VAR_INPUT
    Buffer      : ARRAY[0..255] OF BYTE;
    CoilIndex   : INT; // 0-based
END_VAR
VAR
    ByteIndex   : INT;
    BitOffset   : INT;
    ByteValue   : BYTE;
END_VAR

ByteIndex := CoilIndex / 8;        // 整除得字节索引(从 MBAP 后第2字节起)
BitOffset := CoilIndex MOD 8;      // 余数得位偏移(0=LSB)
ByteValue := Buffer[8 + ByteIndex]; // Buffer[8] 是字节数字段,Buffer[9] 起是数据

GetCoilState := (ByteValue AND SHL(1, BitOffset)) <> 0;

SHL(1, BitOffset) 生成掩码(如 BitOffset=30x08),AND 后非零即该位为1。


七、为什么不用现成库?位操作的不可替代性

  • 确定性:无动态内存分配、无异常抛出、无 GC 停顿,满足 PLC 循环周期硬实时要求(如 1ms 周期内必须完成解析);
  • 可审计性:每一行代码对应一个比特,调试时可逐字节比对 Wireshark;
  • 跨平台:C、ST、C++、Rust、甚至汇编均可复用同一套位逻辑;
  • 最小依赖:无需链接 libmodbus、无需 Python pymodbus,裸机 MCU 也能跑;
  • 故障隔离:当网关返回非标 Length(如 0xFFFF),位操作仍能提取 byte[7] 判断功能码,而高级库可能直接崩溃。

八、真实排障案例:施耐德 M340 PLC 返回 Length=0x0000 的原因

某项目中,M340 在响应读寄存器时,MBAP Length 字段恒为 0x0000,导致上位机解析失败。Wireshark 抓包确认:

... 00 00 00 00 01 03 04 12 34 AB CD

byte[4]=0x00, byte[5]=0x00Length = 0

常规库会直接报“无效报文长度”,但用位操作继续读:

  • byte[6] = 0x01(Unit ID 正常);
  • byte[7] = 0x03(功能码正常);
  • byte[8] = 0x04(字节数字段存在,值为4);
    → 结论:该固件将 MBAP Length 字段置零,但保留了标准功能码+数据结构

解决方案(ST):

IF Length = 0 THEN
    // 启用容错模式:跳过 Length,手动计算
    CASE FunctionCode OF
        16#03: Length := 1 + 1 + (RegCount * 2); // 功能码1B + 字节数1B + 数据
        16#06: Length := 1 + 2 + 2;                // 功能码+地址2B+值2B
    END_CASE
END_IF

✅ 这种修复只能建立在你亲手拆过每一个字节的基础上。


九、终极验证:手算一个完整报文

请求:读 40010(寄存器地址 0x0009)起的 1 个保持寄存器(功能码 0x03
→ MBAP:Transaction ID=0x0001,Protocol ID=0x0000,Length=0x0006,Unit ID=0x01
→ PDU:0x03 + 0x0009 + 0x0001

组合(十六进制,空格分隔):
00 01 00 00 00 06 01 03 00 09 00 01

响应(假设值=0xCAFE):
MBAP Length = 0x0007(功能码1 + 字节数1 + 数据2)
PDU = 0x03 + 0x02 + 0xCA + 0xFE
→ 完整:00 01 00 00 00 07 01 03 02 CA FE

现在,不看本文答案,拿出纸笔,用位操作反向推导响应中的 0xCAFE

  1. byte[4]=0x00, byte[5]=0x07 → Length=7;
  2. byte[7]=0x03
  3. byte[8]=0x02
  4. reg = (byte[9] << 8) | byte[10] = (0xCA << 8) | 0xFE = 0xCAFE

推导一致,即验证通过。


直接用位操作解析 Modbus/TCP,不是炫技,是掌控通信链路最底层的唯一方式。

评论 (0)

暂无评论,快来抢沙发吧!

扫一扫,手机查看

扫描上方二维码,在手机上查看本文