3. XY2-100 帧结构详解:字节序列与字段定义

好,咱们直接进入正题。XY2-100 的帧结构,说白了就是一条严格定义的字节序列。你要是把它看懂了,协议解析这块儿就拿下了一半。我最早接触这个协议时,光看文档里的字节排列,愣是绕了好几天。后来我把每个字段的位偏移都画在纸上,才捋顺。

为什么非得这么较真?因为设备之间的通信,底层就是字节的收发。错一个字节,设备可能就罢工了。你想想看,现场机台的报警灯闪个不停,你拿着十六进制日志一看,帧头写错了——这种低级错误,我见过不止一次。

XY2-100 帧结构分层示意图 帧头 同步字符 帧长度 协议版本 数据域 设备地址(目标) 命令字 数据长度 负载数据 校验域 CRC16 高位 CRC16 低位 帧尾 结束标志 预留填充 说明: 1) 完整帧 = 帧头(3B) + 数据域(N+3B) + 校验域(2B) + 帧尾(2B) 2) 字段顺序为固定小端模式(Little-Endian),多字节字段低字节在前 3) 校验范围:从“设备地址”到“负载数据”的全部字节

3.1 整体帧结构:固定长度 vs 可变长度

XY2-100 数据帧的最小长度是 8 个字节,最大不超过 256 字节。为什么是这个范围?因为内部缓冲区的限制。我记得有一次,远程配置下发了一包 300 字节的数据,直接把接收缓冲区撑爆了,机台死机。所以,你在拼装帧的时候一定要检查长度。

帧由 4 个逻辑部分组成:帧头、数据域、校验域、帧尾。我习惯把它拆成 4 块去记忆。

部分 长度(字节) 说明
帧头 3 同步 + 长度 + 版本
数据域 N+3 地址(1) + 命令(1) + 数据长度(1) + 负载(N)
校验域 2 CRC16,低字节在前
帧尾 2 结束标志 0xFE 0xFE + 预留

📌 关键点: 帧头占 3 字节,数据域中的负载长度可以是 0。当负载为 0 时,就是纯查询命令。我在现场调试时,经常用空负载的帧来做链路保活测试。

3.2 帧头详解:从同步到确认

帧头的第一个字节是同步字符,固定为 0x4F。为什么选这个值?反正协议设计者就是这么定的。你如果在日志里看到帧头不是 0x4F,直接丢弃,不用犹豫。

第二个字节是帧总长度,指从帧头开始到帧尾结束的所有字节数。我个人建议,在发送前用代码自动计算,不要手写。我见过有人把长度写死,结果数据域变了长度没变,解析失败。

第三个字节是协议版本号。当前主流版本是 0x01。向后兼容?目前没遇到过。如果收到版本号不一致的帧,建议记录日志后丢弃。

# 帧头结构示例(Python 字节序列)
frame_header = bytes([0x4F, 0x12, 0x01])
# 0x4F - 同步字符
# 0x12 - 总长度 = 18 字节
# 0x01 - 版本号

3.3 数据域:你关心的工作都在这里

数据域是帧的核心。设备地址占 1 字节,范围 0x01~0xFE,0x00 是广播地址。命令字决定了你要干什么——读取、写入、复位、诊断等。命令字的定义我会在第 5 章详细展开。

数据长度字段很关键,它指明负载部分的字节数。注意:它不包含地址、命令和它自己。所以,数据域的总长度 = 地址(1) + 命令(1) + 数据长度(1) + 负载(N)。

负载数据就是你要传输的参数或状态。最多 252 字节。如果你发的数据超过 252 字节,必须分包。我曾经在批量固件升级时踩过这个坑——一包直接发 260 字节,设备不响应,排查了半天才发现是超长。

⚠️ 经验之谈: 我在某项目中发现,一些设备实现中,数据长度字段如果填 0,负载部分完全省略。但有些设备会要求至少一个空字节占位。你最好在联调前跟设备厂商确认清楚,否则容易导致解析错位。

3.4 校验域:CRC16,别偷懒

校验域使用 CRC16-CCITT 算法,多项式 0x1021。计算范围是从“设备地址”到“负载数据”的全部字节,不包括帧头、校验域本身和帧尾。

我个人的习惯是计算完后,把 CRC 的低字节写在前面,高字节在后面。这就是小端序。很多初学者容易搞反字节顺序,导致校验失败。

# Python 计算 CRC16-CCITT 示例(结果低字节在前)
import crcmod

crc16_func = crcmod.mkCrcFun(0x11021, rev=False, initCrc=0x0000, xorOut=0x0000)

data_to_check = bytes([0x01, 0x03, 0x02, 0x12, 0x34])  # 假设的数据域
crc_value = crc16_func(data_to_check)  # 结果为16位整数
# 低字节 = crc_value & 0xFF
# 高字节 = (crc_value >> 8) & 0xFF
checksum_bytes = bytes([crc_value & 0xFF, (crc_value >> 8) & 0xFF])
print(checksum_bytes.hex())  # 打印:低位在前

💡 提示: 实际项目中,不要自己实现 CRC 算法,直接用现成的库。我吃过自己手写 CRC 的亏,一个移位顺序写反,浪费了两天时间。用 crcmod 或者 pycrc 都行,稳定可靠。

3.5 帧尾:最后的确认

帧尾固定为 0xFE 0xFE。有些设备还会在后面跟 2 字节的预留填充,一般填 0x00。如果你看到结束标志不是 0xFE 0xFE,基本可以判定这帧是坏的,直接丢弃。

我曾在抓包时看到过一个奇怪的现象:帧尾后面多了一个 0x00。后来发现是发送端没有正确清理缓冲区。所以,解析帧时一定要严格校验帧尾,不能因为多出来一个字节就放过。

3.6 完整帧的字段定义归纳

下面我按字节偏移,把所有字段的定义整理成一张表。你可以拿这张表当作开发时的速查手册。

偏移 长度(字节) 字段名 值/范围 备注
01 同步字符0x4F 固定,不可更改
11 总长度8~256 整帧字节数
21 版本号0x01 当前主版本
31 设备地址0x01~0xFE 0x00 为广播
41 命令字参考命令表 -
51 数据长度0~252 负载字节数
6N 负载数据任意字节 N=数据长度
6+N2 CRC160x0000~0xFFFF 低字节在前
8+N2 结束标志0xFE 0xFE -

3.7 一个实际例子:手把手拼帧

假设我们要给地址 0x01 的设备发送读取温度的请求(命令字 0x03),负载只有 2 个字节:温度值 0x12 0x34。帧总长度怎么算?

帧头(3) + 地址(1) + 命令(1) + 数据长度(1) + 负载(2) + 校验(2) + 帧尾(2) = 12 字节。所以帧头中的第二个字节应该是 0x0C。

下面是我用 Python 拼出来的完整帧:

# 完整帧拼装示例
frame = bytes([
    0x4F,       # 同步
    0x0C,       # 总长度 12
    0x01,       # 版本
    0x01,       # 设备地址
    0x03,       # 命令字:读取
    0x02,       # 数据长度:2
    0x12, 0x34, # 负载
    # 校验域(占位,需计算)
    0x00, 0x00, # 实际替换为 CRC 低字节、高字节
    0xFE, 0xFE  # 帧尾
])

# 计算校验域并填充
import crcmod
crc_func = crcmod.mkCrcFun(0x11021, rev=False, initCrc=0x0000, xorOut=0x0000)
crc_val = crc_func(frame[3:7])  # 计算 0x01 0x03 0x02 0x12 0x34
frame_with_crc = frame[:8] + bytes([crc_val & 0xFF, (crc_val >> 8) & 0xFF]) + frame[10:]
print(' '.join(f'{b:02X}' for b in frame_with_crc))
# 输出示例:4F 0C 01 01 03 02 12 34 [CRC_L] [CRC_H] FE FE

⚠️ 注意: 上述代码中的 CRC 计算范围是 frame[3:7],也就是数据域的 5 个字节。千万不要把帧头和帧尾包含进去。我曾经写错过计算范围,导致验签失败,排查了很久才发现。

3.8 归纳总结

  • 帧结构核心: 帧头(3) + 数据域(N+3) + 校验(2) + 帧尾(2),总长 8~256 字节。
  • 字节序: 所有多字节字段统一采用小端序(低字节在前),包括 CRC16。
  • 校验范围: 仅对数据域(设备地址 ~ 负载)计算 CRC,不包含帧头、校验域、帧尾。
  • 结束标志: 固定 0xFE 0xFE,出现其他值直接丢弃。