抓到一段串口或 TCP 的十六进制报文,对着设备文档却怎么也对不上——这是嵌入式、工业控制、物联网调试里的日常。问题几乎总是收敛到三件事:字节怎么排(字节序)、校验怎么算(CRC 变体)、长度藏在哪(变长字段)。这篇把这三座大山讲透,最后手撕一帧真实的 Modbus-RTU。
配合 HEX 协议帧编解码 工具边读边试效果最好——它用一行一字段的模板描述协议,粘进 HEX 就按字段拆解,也能反过来填值生成报文。
为什么私有协议这么难读
JSON、XML 这类格式自带字段名,拿到就能读。而私有二进制协议为了省带宽和算力,把所有结构约定都写在文档里、不写进数据。wire 上只有一串裸字节:
01 03 04 00 64 00 C8 BA 7A
它可能是地址+功能码+数据+校验,也可能是别的——没有文档或模板,这串字节没有唯一解。所谓”解析私有协议”,本质就是把文档里的字段约定翻译成一份模板,让工具照着切。
第一步:把帧切成字段
模板的最小单位是一行 名称 长度 类型:
addr 1 uint # 从站地址
func 1 uint # 功能码
count 1 uint # 后续字节数
data count hex # 变长数据,长度 = count
crc 2 crc16/modbus le # 校验,低字节在前
- 长度:字节数 / 引用前面某字段名(变长)/
*(吃掉剩余全部) - 类型:
uintinthexasciiutf8floatdoublebcdboolbitsskip+ 一组校验类型 #之后是注释
切好之后真正的坑才开始——同样的字节,类型和字节序不同,读出来天差地别。
字节序:90% 的”乱码”根源
整数的大端与小端
多字节整数有两种排法。以 00 64(十进制 100)为例:
| 字节序 | 含义 | 00 64 读作 | 64 00 读作 |
|---|---|---|---|
| 大端 BE | 高位字节在前 | 100 | 25600 |
| 小端 LE | 低位字节在前 | 25600 | 100 |
Modbus、多数网络协议用大端;x86、多数 MCU 内部用小端。读出的数大得离谱或小得离谱,先怀疑字节序反了。
浮点的四种字序(Modbus 重灾区)
32 位浮点占 4 字节,工业设备里这 4 字节有四种主流排法。以 1.0(IEEE754 标准大端为 3F 80 00 00)为例:
| 修饰符 | 字节排列 | 常见于 |
|---|---|---|
abcd | 3F 80 00 00 | 标准大端 |
dcba | 00 00 80 3F | 标准小端 |
badc | 80 3F 00 00 | 字内字节交换 |
cdab | 00 00 3F 80 | 字交换,大量 Modbus PLC |
根源:Modbus 一个寄存器是 16 位,一个浮点要拆进两个寄存器,两个寄存器谁在前(字交换)和寄存器内两字节谁在前(字节交换)两两组合,就是这四种。读出来不是合理量程内的数,就逐个换字序试。double 用 be/le 即可,把字交换修饰符用在整数或 double 上工具会直接报错提醒。
有符号数与二补码
int 类型按二补码解释。单字节 0x9C:
- 按
uint读:156 - 按
int读:156 ≥ 128,减 256 = -100
温度、利润、坐标偏移这类可能为负的字段必须声明成 int,否则负值会被读成一个很大的正数。
变长字段:长度藏在报文里
私有协议里 payload 长度经常写在报文中间的某个字节。处理方法是让变长字段引用那个长度字段的名字:
len 1 uint # 后续数据长度
data len hex # 长度 = len 字段的值
解析时先读出 len,再据此截取 data。反向编码时是甜点:你只填 data 内容,len 会自动算成 data 的字节数回填,不用手数;被引用为长度的字段在表里显示”自动”,无需手填。
位域:一个字节塞多个状态
状态寄存器、报警字常把多个 bit flag 压进一个字节。用 bits 拆:
status 1 bits
> online 1
> alarm 1
> mode 2
> reserved 4
子项从最高有效位到最低位排列,位数合计须等于字段字节数×8。字节 0xA0(二进制 1010 0000)拆出:online=1、alarm=0、mode=2(10)、reserved=0。
BCD:给人看的十六进制
BCD 让每个十进制数字单独占 4 位,所以字节 0x25 在 BCD 里读作 25,而按 uint 读是 37。仪表的时间、表号、金额常用 BCD——看到像日期/计数器、但按 uint 读数值偏大的字段,多半是 BCD。
校验:CRC 不是只有一种
Rocksoft 参数模型
CRC 是一族算法,由五个参数完全确定:
| 参数 | 含义 |
|---|---|
| poly | 生成多项式 |
| init | 寄存器初值 |
| refin | 是否对每个输入字节按位反转 |
| refout | 是否对最终结果按位反转 |
| xorout | 结果再异或的值 |
光”CRC16”就有几十个变体。多项式相同、初值不同也是两种算法——比如 CCITT-FALSE 和 XMODEM 都用多项式 0x1021,但初值一个 0xFFFF 一个 0x0000,结果完全不同。
一个常见困惑:Modbus 的多项式文档里写 0x8005,代码里却是 0xA001——0xA001 是 0x8005 的位反转形式,因为 Modbus 是 refin/refout 都为真的”反射式”CRC,按低位优先实现时用反转后的多项式。所以下面两种写法结果相同(对 "123456789" 都得 0x4B37):
crc 2 crc16/modbus
crc 2 crc(poly=0x8005,init=0xFFFF,refin,refout)
内置变体对照表
| 类型 | 多项式 | 初值 | 反转 | 备注 |
|---|---|---|---|---|
crc16/modbus | 0x8005 | 0xFFFF | 是 | 串口仪表标配,校验低字节在前,记得加 le |
crc16/ccitt | 0x1021 | 0xFFFF | 否 | CCITT-FALSE |
crc16/xmodem | 0x1021 | 0x0000 | 否 | 多项式同上、初值不同 |
crc8 | 0x07 | 0x00 | 否 | CRC-8/SMBus |
sum | — | — | — | 累加和,取低 8/16 位 |
xor | — | — | — | 逐字节异或(LRC) |
crc(...) | 任意 | 任意 | 任意 | 非标准变体照文档填,位宽由字段字节数决定 |
校验范围
默认校验从帧首到该校验字段之前的所有字节。如果协议只校验中间一段(比如不含帧头),用 range(起字段..止字段) 指定,含两端:
sum 1 sum range(len..data) # 只校验 len 到 data
完整实战:手撕一帧 Modbus-RTU
读保持寄存器的响应帧:
01 03 04 00 64 00 C8 BA 7A
模板:
addr 1 uint # 从站地址
func 1 uint {3:读保持寄存器} # 功能码
count 1 uint # 后续字节数
data count hex # 寄存器数据
crc 2 crc16/modbus le # CRC 低字节在前
逐字节分析:
01→ addr = 从站 103→ func = 3,枚举显示”读保持寄存器(3)”04→ count = 后面有 4 字节数据00 64 00 C8→ data,两个 16 位寄存器:0x0064=100、0x00C8=200BA 7A→ CRC。因为是 Modbus,低字节在前,实际 CRC 值是0x7ABA
工具对前 7 字节(01 03 04 00 64 00 C8)算 CRC16/Modbus 得 0x7ABA,与帧里一致 → 显示 ✓。忘记加 le 是这里最常见的错——会把 CRC 读成 0xBA7A 而判定校验失败。
反向构造:填值生成报文
结构确认后,调试时常要手动发指令。切到编码:在字段表”值”列填入各字段内容点「编码」,CRC 和长度字段自动算好回填,复制 HEX 丢给串口助手即可。填超出字段宽度的值(如 1 字节填 300)会给出截断告警,避免静默出错。
逆向未知协议的实用顺序
完全不知道结构时:
- 多帧对比找锚点——不变字节多半是帧头、地址、功能码;跟着变的是 payload。
- 末尾试校验——拿整帧(或除校验外的部分)试 Modbus / CCITT / 累加和 / 异或,对上就锁定算法和范围。
- 找长度字段——某字节值恰好等于其后剩余字节数,它就是 len,把变长字段挂上去。
- 定类型与字序——浮点读不出合理值就换 abcd/dcba/badc/cdab;数值偏大怀疑 BCD 或字节序。
每锁定一段就在模板里固化,逐步逼近完整结构。全程在浏览器本地运行,不上传,处理内部设备协议数据也放心。
配套工具
- HEX 协议帧编解码 — 本文主角,模板化双向编解码
- Hex 二进制查看 — 按已知文件格式/魔数看二进制结构
- Hex ↔ 文本互转 — 十六进制与文本/字节快速互转
- 进制转换计算 — 二进制/十六进制/二补码逐位换算
- Protobuf 解码 — 另一种二进制协议,自描述但仍需 schema 才精确
把字节序、校验、变长这三件事吃透,私有协议逆向就从”碰运气”变成”按部就班”——剩下的只是照着设备文档把字段一行行填进模板。