当一台设备和你无法掌控的东西通信时,固件就是一个黑盒。但线不是。一个十块钱的 逻辑分析仪夹在总线上,再加一根共地线,就能把那个黑盒变成一条你能读的字节流。这就是 逆向工程串口协议的全部诀窍:抓取电压,解码总线,然后一个字节一个字节地重建含义。
这是我在自己工作台上用的方法。这里不需要反汇编器,也不需要固件转储——需要的是一台 逻辑分析仪、一双懂得看时序的眼睛,以及耐心。当初解开 BLE 设备 和 私有 HTTP API 的那套纪律,同样适用于两线 UART: 观察、抓包、提出假设、改动一个输入、验证。
先认总线:UART 解码,还是 SPI I2C
在解码任何东西之前,先搞清楚你到底在看什么。三条总线覆盖了绝大多数嵌入式设备,而 它们示波器上的样子各不相同:
- UART——每个方向一根线。设备的 TX 就是你的 RX。空闲为高电平,你会看到一组组 突发的脉冲,中间夹着很长的间隔。没有时钟线。
- I2C——两根线,SCL(时钟)和 SDA(数据),都被上拉到电源轨,空闲为高。 时钟只在一笔传输期间运行。
- SPI——三到四根线:SCLK、MOSI、MISO,以及一根在每个事务期间拉低的 CS/SS 片选。它有时钟,而且几乎总有连续的流量。
判断方法很简单:数一数有几条活跃的线。单条数据线加间隔,是 UART。两根空闲为高、一起 摆动的线,是 I2C。一根时钟加一两条数据线,外加一根片选,是 SPI。搞错了,你就会花一 整个下午去解码噪声。
接好逻辑分析仪:通道、地、电压
永远先接地。把分析仪的 GND 夹到目标板上真正的地——板子的地、排针、连接器的屏蔽层。 没有共同的参考地,每个通道都会浮空,什么都解不出来。这是第一次抓包变成垃圾的最常见 原因。
然后接探头,并在采集之前先在软件里把通道命名好。我用一套固定的约定,好让笔记保持 可读:
D0 -> UART TX(设备 -> 我) # I2C 用 SCL,SPI 用 SCLK
D1 -> UART RX(我 -> 设备) # I2C 用 SDA,SPI 用 MOSI
D2 -> CS / SS # 仅 SPI
D3 -> MISO # 仅 SPI
GND -> 板子的地 # 不可妥协
电压和接线一样要紧。多数逻辑分析仪预期 3.3V 逻辑,而且不容忍 5V。如果目标跑在 5V,要么把跳线拨到 5V 阈值,要么给信号做电平转换。把 5V 灌进 3.3V 输入,就是烧掉 分析仪、浪费一天的办法。
软件这边,用 PulseView / sigrok:它开源,而且几乎支持市面上 所有克隆芯片。采样率是你第一个要做的真决定:要可靠解码 UART,每个比特至少要有 4–10 个采样点。一条 115200 波特的线,一个比特是 8.68 µs;1 MS/s 大约给你每个比特 8 个采样点,这已经是下限。拿不准时,采得更快——采样率太低会把信号混叠成胡言乱语, 而你还会把锅甩给协议。
找出波特率,用眼睛做 UART 解码
UART 的帧格式是固定的,值得背下来:线空闲为高,一个起始位把它拉低一个比特 周期,接着 8 个数据位最低有效位在前到达,一个可选的校验位,再有一到两个停止位 把线拉回高。接收方在每一个比特的中间采样。
如果你设错了波特率,就会得到帧错误和乱码。正确的那个要从抓包里自己找。放大到能测出 最短的那个高脉冲——它大约就是一个比特周期。取倒数:
比特周期 ≈ 104 µs -> 1 / 104µs ≈ 9600 波特
比特周期 ≈ 17.4 µs -> 1 / 17.4µs ≈ 57600 波特
比特周期 ≈ 8.68 µs -> 1 / 8.68µs ≈ 115200 波特
如果解出来的字节差不多对、但隔一个就坏一个,通常你是差了一个倍数——试试 9600 还是 19200,或者 115200 还是 57600。如果字节看起来合理,可停止位始终对不上,那多半是设备 没开校验位而你开了,或者反过来。
拿到数字之后,就让工具干活。sigrok 能直接解码这条流:
sigrok-cli -i capture.sr \
-P uart:baudrate=115200:rx=D0 \
-A uart=rx-data
逆向工程串口协议:假设循环
解出字节还不等于有了协议。这项工作的核心,是一个你不断重复、直到图景闭合的循环:
- 提出一个假设。 「第四个字节是命令码。」「第三个字节是长度。」「前两个字节是 魔数头。」
- 只改一个输入,再抓一次——按另一个按钮,发一段更长的负载,设一个新的值。
- 对两次抓包做 diff。 变了的字段是数据或状态;没变的字段是结构。
把同一个物理动作做两遍,然后 diff。两次完全相同的动作之间任何变化的东西——一个计数 器、一个 nonce、一个时间戳——都是你必须复现的状态。任何恒定的东西,都是你可以依赖的 结构。随手维护一张表:字节偏移、观察到的值、你最好的猜测。那张表就是协议。
比如,我点一下「模式」,第 3 个字节从 01 -> 02 -> 03 走,那它就是模式选择子。我发
一段更长的消息,第 2 个字节恰好按负载增量变大,那它就是长度字段。这不是猜——这是受控
实验,而且收敛得很快。
用校验和与长度字段确认
有两个特征让你能证明一个假设,而不是祈祷:长度字段和校验和。一个始终等于 头部之后字节数的长度字段,是自证的。一个校验和则把这一帧封死。
嵌入式设备里你会遇到的两类校验和,算起来都微不足道。覆盖字节上的 XOR:
def xor8(data: bytes) -> int:
c = 0
for b in data:
c ^= b
return c
以及相加的 sum8,通常对 256 取模:
def sum8(data: bytes) -> int:
return sum(data) & 0xFF
把你候选的那一帧拿来,切掉最后一个字节,把两个函数都跑一遍,和设备真正发出的那个 字节比较。如果有一个对上了,你就同时找到了校验算法和它覆盖的确切字节范围——通常是从 头部一直到校验和之前。如果两个都对不上,就扩大或缩小覆盖范围再试。一个连续十次抓包 都能通过验证的校验和,不是巧合;它就是帧结构,被证实了。
实战示例:解码一帧
假设我按下某个按钮时,从设备抓到这样一帧:
AA 55 05 01 10 40 00 11 BA
我的假设是:一个头部、一个长度、一个命令、一个 16 位地址、一个 16 位值,外加一个
末尾的 XOR 校验和。先验长度:两字节头部之后,05 说有五个负载字节跟上——01 10 40 00 11,正好五个,然后是一个校验和字节。长度字段成立。
再验校验和。把从头部一直到(但不含)最后一个字节的所有内容做 XOR:
AA ^ 55 ^ 05 ^ 01 ^ 10 ^ 40 ^ 00 ^ 11 = BA
BA 恰恰就是末尾那个字节。两个字段都通过了,所以这个结构不再是猜测。
把标签补上:
AA 55 头部 / 同步
05 长度 = 5 个负载字节
01 命令 = 写寄存器
10 40 地址 = 0x1040
00 11 值 = 0x0011
BA XOR8 校验和(已验证)
写一个小的 Python 解码器
结构一旦被证明,就把解码器写出来,好让你再也不用肉眼看十六进制。下面这个把上面那一帧 解析成命名字段,并重新校验校验和:
from dataclasses import dataclass
HEADER = b"\xAA\x55"
@dataclass
class Frame:
cmd: int
addr: int
value: int
checksum_ok: bool
def xor8(data: bytes) -> int:
c = 0
for b in data:
c ^= b
return c
def decode(blob: bytes) -> Frame:
if blob[:2] != HEADER:
raise ValueError(f"bad header: {blob[:2].hex()}")
length = blob[2]
body = blob[3:3 + length]
checksum = blob[3 + length]
calc = xor8(blob[:3 + length])
return Frame(
cmd=body[0],
addr=(body[1] << 8) | body[2],
value=(body[3] << 8) | body[4],
checksum_ok=(calc == checksum),
)
if __name__ == "__main__":
frame = decode(bytes([0xAA, 0x55, 0x05, 0x01, 0x10, 0x40, 0x00, 0x11, 0xBA]))
print(frame)
# Frame(cmd=1, addr=0x1040, value=0x11, checksum_ok=True)
从这里出发,喂给它的应该是整段抓包,而不是单独一帧。从 sigrok 导出解码后的数据流,
按头部字节切开,把每个解析出的帧映射成一行输出。厂商 App 的行为,转眼就变成一张
cmd/addr/value 的行表,你可以在两次运行之间做 diff——和你从 私有
API 里重建出的协议,形状一模一样。
你会卡住的地方
诚实地划出边界,因为总有边界:
- 它既不是 UART、SPI 也不是 I2C。 RS-485 是差分对上的 UART,常常半双工,还有一根 你必须一起探的方向引脚;CAN 和 1-Wire 是各自的世界。
- 负载被混淆了。 在帧结构之上,有些设备会用运行时派生的密钥对正文做 XOR 或加密。 对被加密字节的有效校验和证明了帧结构,却对含义只字未提——那就停下,别去暴力破解 别人的密钥。
- 你误读的长度字段。 「负载长度」和「整帧长度」差一个一,会让你追着一个不存在的 校验和跑。拿真实抓包把两种定义都验一遍,把定义钉死。
- 采样太慢。 混叠会凭空造出线上从未有过的字节。先提高采样率,再去怀疑你自己的 解码。
- SPI 没有片选。 没有 CS,字节边界就是模糊的。找到那根使能线,否则你解的就是噪声。
只在你拥有的硬件上做
把它指向你自己的板子、你自己的传感器、你自己的电器。理解一台你拥有的设备,好让你用 代码驱动它,是正常的工程。去破一道 DRM、去读别人的设备,或者去恢复一个从未交给你的 密钥,不是一个待解的谜题——那是一条不该越过的线。查清你所在之处的规则,以及那件东西 附带的使用条款;如果某一步需要的不属于你的秘密,就停下。
方法本身是中立的,而这正是重点。夹上线,共好地,找出波特率,读懂字节,提出假设,改动 一个输入,用校验和确认,然后交给解码器。那条不透明的两线总线,就会变成一份你拥有 规格的协议。