3. XY2-100 帧结构详解:字节序列与字段定义
好,咱们直接进入正题。XY2-100 的帧结构,说白了就是一条严格定义的字节序列。你要是把它看懂了,协议解析这块儿就拿下了一半。我最早接触这个协议时,光看文档里的字节排列,愣是绕了好几天。后来我把每个字段的位偏移都画在纸上,才捋顺。
为什么非得这么较真?因为设备之间的通信,底层就是字节的收发。错一个字节,设备可能就罢工了。你想想看,现场机台的报警灯闪个不停,你拿着十六进制日志一看,帧头写错了——这种低级错误,我见过不止一次。
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 完整帧的字段定义归纳
下面我按字节偏移,把所有字段的定义整理成一张表。你可以拿这张表当作开发时的速查手册。
| 偏移 | 长度(字节) | 字段名 | 值/范围 | 备注 |
|---|---|---|---|---|
| 0 | 1 | 同步字符 | 0x4F | 固定,不可更改 |
| 1 | 1 | 总长度 | 8~256 | 整帧字节数 |
| 2 | 1 | 版本号 | 0x01 | 当前主版本 |
| 3 | 1 | 设备地址 | 0x01~0xFE | 0x00 为广播 |
| 4 | 1 | 命令字 | 参考命令表 | - |
| 5 | 1 | 数据长度 | 0~252 | 负载字节数 |
| 6 | N | 负载数据 | 任意字节 | N=数据长度 |
| 6+N | 2 | CRC16 | 0x0000~0xFFFF | 低字节在前 |
| 8+N | 2 | 结束标志 | 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,出现其他值直接丢弃。