干嵌入式或者工控协议调试的朋友,大概率都遇到过这种场面:一台老设备还在产线上跑,可协议文档早就不知道丢到哪个档案柜里去了,原厂电话打过去不是空号就是"这个产品停产了,资料没留"。上位机同事甩给你一段抓包记录,就留下一句"你研究研究"。我盯着 Hex 窗口里那排没有灵魂的字节,前半小时脑子里全是问号。后来帮我解决这个问题的,正是今天要聊的核心技巧——"二进制试填"。
二进制试填,说白了就是有章法地把预设的二进制值填进未知字段,然后观察目标设备的反应,靠反馈差异反推数据的帧结构、字段含义和校验机制。它不需要图纸,不需要文档,甚至不需要太高深的调试工具,只要你能往目标设备发一串十六进制数据、能收回来一段回包,就能把协议逐步摸出来。这套方法特别适合解决三类问题:老设备协议对接、私有报文格式分析、测试用例里边界值的构造。下文我会把原理、工具选型、具体操作步骤和踩过的坑一次讲完,内容偏实战,照着做基本能复现同样的流程。
1. 什么是二进制试填:先用人话把它讲明白
1.1 没文档的老设备怎么救
先还原一下典型的困境。你手里有三五组抓回来的合法通信报文,格式大概是这样的十六进制串:
AA 55 07 01 07 D0 01 2C B0 33 AA 55 07 02 07 D0 00 64 45 21 AA 55 09 03 07 D0 01 2C 00 64 18 A2你大概能猜到前两个字节可能是帧头,后面应该是长度、类型、数据、校验,但具体哪几个字节是长度、哪几个字节是数据、校验怎么算,两眼一抹黑。这时候你要是拿一本《串口协议从入门到精通》来翻,翻到天亮也解决不了问题,因为这是私有协议,全世界可能只有那台老设备的固件作者知道答案,而他还离职了。
二进制试填的思路是反过来想:我不需要知道协议"应该长什么样",我只需要知道"我改哪个字节,设备会有什么反应"。设备本身就是一个最权威的协议解析器,它收到合法帧就干活,收到非法帧就报错、丢弃或者是没反应。只要你有办法让这个解析器"开口说话",就等于有了一个知道全部答案的裁判在场。
1.2 试填本质上是什么
我习惯把试填看作"黑盒实验反推白盒结构"。白盒是指协议文档里的帧结构定义,黑盒是指设备只暴露了收发接口。你不打开盒子,但是可以通过反复输入、观察输出,逐步逼近盒子内部的规则。
举一个生活化的例子。你拿到一个密码保险柜,不知道密码,但柜子是开了"错误提示"功能的。你转一位数字,柜子响一声,转对了就咔哒一声开锁。二进制试填就是干这事的:把报文里的每一位字节都当成密码锁的一位,改一改看设备是"响一声"还是"咔哒一声"。区别在于,协议字段没有密码锁那么干净,一个字节可能既是长度又参与校验,改了一位之后设备会因为校验不过而直接不理你。所以试填还要学会排除干扰、单独验证变量,这就涉及到后面的原理和步骤了。
2. 试填为什么能奏效:黑盒反馈与试探逻辑
2.1 设备的每一种反应都是信息
试填的前提是目标设备会以某种方式反馈。我总结了一下,平时最常用的反馈信号有这么几类。
第一类是回包差异。设备收到不同指令后返回的数据不一样,这是最直接的反馈。比如你把某个字节从 01 改成 02,回包里的状态字从正常运行变成配置模式,这基本就说明该字节是功能类型字段。
第二类是回包的有无。设备对合法帧有回包,对非法帧完全沉默。这种"沉默"本身也是信息:它说明你改的那个字节参与了某种校验或长度计算,导致整个帧被丢弃在入口处。
第三类是物理表现。有的设备没有通信回包,但会有指示灯、蜂鸣器、电机动作等外部表现。比如填了一个超大的长度值,设备直接重启,说明它解析长度的代码有缺陷,这是一个重要的边界信号。
试填的重点不在于你发了多少组数据,而在于你从每一组数据的反馈里读到了什么。所以做试填前,一定要把设备的回包记录下来,带着之前的报错状态一起看,最好做成一列一列的对照表。
2.2 四种基础试探法
试填看起来像是盲目穷举,实际是有套路的。我常用的试探法就四种,互相配合基本能解决九成问题。
第一种是模式填充法。把某个字段填成全 0x00、全 0xFF、交替的 0xAA/0x55 这样的特征值。0xAA 的二进制是 10101010,0x55 是 01010101,专门用来暴露位序错乱、位掩码、信号极性这类问题。比如一个字节如果高位是使能位,你填 0x55 和 0xAA 设备会有完全不同的行为,因为这两个数的比特位刚好相反。
第二种是长度探测法。在疑似数据区填一长串相同的字符,最常见的是填 ASCII 的 'A',也就是 0x41。如果设备会回显,你就能在回包里直接看到 0x41 的字数,从而准确判断数据区的边界。
第三种是单点扰动法。只改动字节序列里的某一个字节,其他字节保持不变,观察回包差异。这是定位字段边界最有效的手段。一个字节改了有反应,说明它属于有效字段;改了没反应,说明它可能是保留位、填充位或者校验位的一部分。
第四种是边界试探法。把长度、序号、参数这类数值字段填到 0x00、0x01、0x7F、0x80、0xFF 这些临界值,观察设备是否出现异常响应。很多设备对不同取值区间有不同的处理逻辑,边界值最能暴露这些区间。
四种试探法不是孤立的,实际使用中经常交叉着来。比如先用长度探测法确认数据区边界,再用单点扰动法确认字段归属,最后用模式填充法确认位序和校验,就是个比较顺的流程。
3. 实操准备:工具、模式与五步法
3.1 工具准备与脚本化基础
做二进制试填,最忌讳的就是用鼠标点界面。一次两次手工填还行,一旦需要试几十上百组数据,人手点串口助手能把你点崩溃,而且容易漏记。我的主力工具是 Python 加 pyserial,配合一个十六进制查看器就够用。
串口工具负责原始收发,脚本负责生成数据、记录回包、自动对比。第一次和某个设备打交道,我会先写一个最基础的发包函数,把"发报文"和"收回包"封装起来,后面所有试填都基于它,省得每次重写。
下面这个函数是我平时项目的骨架,你直接照着改改串口号和波特率就能用:
import serial import time ser = serial.Serial('/dev/ttyUSB0', 9600, timeout=0.5) def test_hex(hex_str: str) -> str: """发送一段十六进制报文,返回设备的完整回包""" frame = bytes.fromhex(hex_str.replace(' ', '')) ser.reset_input_buffer() ser.write(frame) time.sleep(0.2) resp = ser.read(ser.in_waiting if ser.in_waiting else 1024) return resp.hex().upper()有了这个基础函数,刚才说的"单点扰动"就变成了一个简单的循环:
base = "AA 55 07 01 07 D0 01 2C B0 33" for pos in range(2, 8): # 只扰动从长度字段开始的几个字节 data = bytearray(bytes.fromhex(base.replace(' ', ''))) data[pos] ^= 0x01 # 把第 pos 个字节翻转一位 resp = test_hex(data.hex()) print(f"位置 {pos:2d} 改值后回包: {resp or '(无回包)'}")这段代码一次性告诉你每个字节是不是有效字段。如果某个字节改动后回包变了,它就很有可能是功能字段;如果始终没反应,多半是校验区或者保留位。脚本化的意义就在这里:手动做十次实验的功夫,脚本已经跑完几十种组合,而且结果整齐。
3.2 常用试填模式对照表
试填的数据模式不是随便拍的,每个模式都对应一类探查目标。我把常用的整理成一张表,实际用的时候照着选就行。
| 填充模式 | 十六进制形态 | 主要目的 |
|---|---|---|
| 全零 | 00 00 00 ... | 确认字段默认值、观察设备最小输入行为 |
| 全一 | FF FF FF ... | 确认字段饱和值、触发上限判断逻辑 |
| 交替位 | AA AA / 55 55 | 排查位序、极性、掩码问题 |
| 递增序列 | 01 02 03 04 ... | 定位命令码、模式字这类按值分发的字段 |
| 单字节扰动 | 只改一个字节 | 用差分对比确定字段边界 |
| 长串填充 | 41 41 41 ... | 探测数据区长度、缓冲区边界、回显透传行为 |
带 0xAA 和 0x55 经常被新手忽略,其实这两个值非常好用。0xAA 是 10101010,0x55 是 01010101,比特位完全互补。如果一个字段内部包含多个位标志,你填 0xAA 和 0x55 得到的反馈往往截然不同,这能帮你快速判断设备是按整个字节解析,还是按位解析。
3.3 五步试填法
我把整套流程拆成五步,每步解决一个问题,顺序尽量不要乱。
第一步定边界。先用抓包样本找出固定不变的字节,一般是帧头,比如最常见的 0xAA 0x55。再找出长度随之变化的字节,这个通常是长度字段。定边界为的是把"报文能被设备识别"这件事先稳住,后面试填才有效。
第二步定长度。对疑似长度字段做边界试探,改大、改小、甚至改成 0,观察设备的接受和拒绝模式,确认长度字段的覆盖范围和计算基准。
第三步定类型。对疑似命令码或类型字段做递增序列试探,从 0x01 一路试上去,看设备回包跟随变化的情况。这一步通常能收获设备支持的指令列表。
第四步定数据。在确认的数据区填充已知字符,比如 0x41 序列,如果设备会回显,直接看回包里 0x41 的数量和位置。如果是参数类字段,就做单点扰动,改一个值看功能是否联动。
第五步定校验。把前面几步确认的合法帧单独拿出来,翻转任意一个字节然后观察设备是否丢弃,由此确认校验是否存在、覆盖范围多大,再用枚举法匹配校验算法。校验这一步通常要单独做,因为不解决校验,后续任何试填都会被设备在入口处拦掉。
五步法不是绝对线性的,很多人试到第二步就顺手把第四步一起做了,这没问题。但"先稳住帧头、再解决校验"这个大顺序,我建议不要乱。校验没搞定之前,所有改数据的实验都会被干扰,你根本分不清设备是"拒绝了这个值"还是"拒绝了校验不过的帧"。
4. 完整案例:拆解一个无文档的温控串口帧
4.1 三组样本与初步观察
下面用一个脱敏简化的案例把整个过程走一遍,字段值做了调整,但流程和判断方式是我实际在项目里用过的。假设目标是一台老温控器,手上只有三组从正常通信中抓到的样本:
样本1: AA 55 07 01 07 D0 01 2C B0 33 样本2: AA 55 07 02 07 D0 00 64 45 21 样本3: AA 55 09 03 07 D0 01 2C 00 64 18 A2先把三组样本竖着排,相同字节对齐,一眼就能看出前两字节 AA 55 在三组里完全不变,基本锁定是帧头。第三字节分别是 07、07、09,和整帧长度同步变化,样本1、2总长10字节,样本3总长12字节,而 10 减 3 等于 7,12 减 3 等于 9,正好等于第三字节的值。于是可以假设:第三字节是从它自己开始算的字节数,或者说是长度字段,表示后面还跟着多少字节。
这时候不要急着下结论,先拿样本1做一次单点扰动验证。把第三字节从 07 改成 08,其余字节不动,发过去,设备直接没回包。这个反馈说明设备确实在按第三字节读取后续内容,长度对不上就整帧丢弃。至此,帧头和长度字段都有了初步答案。
4.2 用试填锁定长度字段
为了进一步确认长度字段的计算基准,再做两组对称实验。第一组保持第三字节为 07,但删掉数据区的一个字节,让实际剩余字节数变成 06;第二组保持第三字节为 07,在数据区末尾多加一个 0x00,让实际剩余字节数变成 08。两组实验设备都无回包,而恢复原样后立即有回包。
这组实验非常干净地证明了:设备以第三字节为基准,从它后面开始读取"刚刚好"数量的字节,多读少读都失败。我倾向于判断这个长度字段的含义是"本字段之后的所有字节数",也就是 帧头(2字节) + 长度(1字节) + 类型(1字节) + 数据区 + 校验(2字节),而长度字段本身不把自己算进去。
顺带也明确了总长度的换算关系:总字节数 = 3(帧头+长度) + 长度字段值。以后想构造一条长度为 N 的报文,直接把 N-3 填进第三字节就行。这一步做完,报文的外壳就已经焊死了。
4.3 锁定类型字段与数据区
长度之后的第四字节,三组样本分别是 01、02、03。先别管它什么意思,用递增序列试探一遍。从 0x01 试到 0x05,发现 0x01 回包是查询状态,0x02 回包变成设置某项参数后的确认,0x03 回包是批量写入结果的回显,改到 0x04 和 0x05 设备回包统一变成错误代码 0xFF。这说明第四字节是类型字段,设备按值分派处理逻辑,而且当前固件只支持到 0x03。
再看后面五个字节。样本1和样本3里都有 07 D0 和 01 2C,样本2里是 07 D0 和 00 64。07 D0 换算十进制是 2000,01 2C 是 300,00 64 是 100。参数通常是带放大倍数的温度值,所以这里假设 07 D0 可能是温度设定值的原始量,放大倍数是100,对应20.00度。做出假设后继续试填:把样本1数据区里的 07 D0 改成 07 D1,正常修正校验发出去,设备回包确认成功,而且再读状态时回包里的 07 D1 原样跑回来了。这说明数据区确实生效了,不是摆设。
然后做一次长串填充实验验证数据区边界。把第五到第八字节均填成 0x41,修正校验后发出,设备的回包中出现四个连续的 0x41,恰好对应刚才改动的四个字节。这样一来,数据区边界也被试填钉死了。
4.4 揪出校验位的完整过程
最后两字节 B0 33、45 21、18 A2,一开始以为是普通数据,但单点扰动实验早就暗示它们不简单:不管改数据区哪个字节,只要最后两字节不动,设备基本不回包。这强烈说明最后两字节是校验码,且校验覆盖了它前面的所有内容。
揪校验算法我用的是枚举比对法。写一个脚本,把已知合法帧去掉最后两个字节,按常见 CRC 参数组合计算一遍,和真实校验字节比对。枚举项包括多项式、初始值、结果异或值和字节序,常见的就那么几组,很快能筛出来。在我这个案例里,锁定的是 CRC16 类算法,确认方法是:拿样本1算出的校验字节和样本2、样本3的校验字节用同一套参数全部匹配,然后随便翻转一位再算,校验就不对了。
这里有个必须记住的经验:校验一旦确定,立刻把它做成一个自动工具函数。后续每构造一条测试帧,都自动算好校验位再发。我见过不少人栽在这一步,前面字段全分析对了,因为手动改校验改错了一个字节,白试了一下午。把这个函数准备好,后面的试填效率会高一个量级。校验代码的枚举思路大致是下面这样,你可以根据实际情况补充参数组合:
def crc16(data: bytes, poly: int, init: int, xorout: int, refin: bool = False, refout: bool = False) -> int: crc = init for b in data: crc ^= b << 8 for _ in range(8): crc = ((crc << 1) ^ poly) & 0xFFFF if crc & 0x8000 else (crc << 1) & 0xFFFF if refin: crc = int(f'{crc:016b}'[::-1], 2) return crc ^ xorout # 枚举常用参数组合,用合法样本去比对真实校验字节 candidates = [ (0x1021, 0xFFFF, 0x0000, False, False), # CRC16/CCITT-FALSE (0x1021, 0x0000, 0x0000, False, False), # CRC16/XMODEM (0x8005, 0xFFFF, 0x0000, True, True), # CRC16/MODBUS ]到这一步,这个温控器的串口协议已经可以完整描述了:帧头 AA 55,第三字节是长度,第四字节是类型,中间是数据区,最后两字节是 CRC16 校验。整个过程没有打开设备、没有读固件,全是靠二进制试填试出来的。样本虽然脱敏,但流程完全真实。
5. 常见问题与排查技巧实录
5.1 填了没反应:先查物理层,再查校验
试填最打击人的现象就是改完字节发出去,设备一点反应都没有。新手这时候容易怀疑自己的分析方向,其实大多数时候问题不在协议分析,而在更底层。先确认串口参数对不对,波特率、数据位、停止位、校验位,是不是和抓包时一致;再确认收发引脚有没有接反,用回环测试或者示波器看一眼电平;最后确认设备有没有进入可通信状态,有的设备上电后要等几秒或者按一个按键才启动通信。
排除物理层后,再把目标转向校验。如果你构造的帧用了错误的校验位,设备会在逻辑入口把帧丢掉,表现同样是"无回包"。这时候先别继续试字段,老老实实回到校验枚举那一步,把校验算法搞定再回来。我的习惯是:任何一次"无回包"都要记录当时发送的完整报文,方便回头对比是不是校验问题。因为人工记忆不可靠,十条校验收错的报文长得都差不多。
5.2 全 0 全 FF 都能通过:位域与掩码
还有一种奇怪现象:你把某字节填成 0x00 和 0xFF 设备都正常响应,感觉这个字段无效,但它明明在合法帧里。这通常说明字段是按位解析的,设备只关心其中的某几个位,其他位被掩码忽略。
举个例子,一个控制字节 0b00000001,设备可能只读最低位作为使能开关,上面的七个位随便填都没事。这时候模式填充法的价值就出来了,用 0xAA 和 0x55 分别填充,对比回包差异。0xAA 和 0x55 的每一位都不同,如果设备按位解析,不同位会被单独触发,反馈差异会非常明显。然后逐步收敛:先锁定字节,再锁定具体的位。每次只翻转一位,按位做二进制试填,通常能把位域定义完整还原出来。
5.3 设备死机或重启:越界试填的代价
试填是一个对设备有实际影响的操作,特别是填长度字段和参数数据。我就遇到过把长度字段改成很大的值导致设备缓冲区溢出重启的情况,也遇到过把参数填成负数结果设备进入异常模式的尴尬现场。这可能让现场设备停机,甚至让上位机系统出现联锁动作。
所以我的原则是:能脱机试的设备绝不联机试;联机试的时候,一次只改一个字段,改完观察、记录、恢复,再改下一个;绝不把长度和参数同时改大。更重要的是,现场的设备如果有看门狗或者会自动重启,千万别把这当成"设备本身有这个功能"而忽略。设备重启是严重的反馈信号,说明它在解析你给的报文时走到了异常分支,这条路径要么是你发现了固件缺陷,要么是你填错了值,都要停下来认真排查。
5.4 提效工具:差分对比脚本
试填到后期,回包会越积越多,人工对比十六进制串非常容易看漏。我的解决办法是做一个小差分脚本,把两次报文的字节逐位对比,只输出差异位置和差异值。这样设备对两个字段微小的反应变化都能被立刻发现,特别是那些只差一个 BIT 就会导致功能切换的字段。
def hex_diff(a: str, b: str) -> list: ba, bb = bytes.fromhex(a.replace(' ', '')), bytes.fromhex(b.replace(' ', '')) mx = max(len(ba), len(bb)) diff = [] for i in range(mx): x = ba[i] if i < len(ba) else None y = bb[i] if i < len(bb) else None if x != y: diff.append((i, x, y)) return diff resp_base = test_hex("AA55070107D0012CB033") resp_trial = test_hex("AA55080107D0012CB033") print(hex_diff(resp_base, resp_trial))配合脚本批量试填,一次能对比几十组回包。脚本要记录的不只是设备有没有回包,还包括回包内容的变化、回包时间的快慢。回包时间本身也是一个很有价值的反馈维度,有些协议对非法帧会做延时丢弃,时间差就是信号。
6. 写在最后:试填高手的几条原则
这几年代码调试、协议对接做下来,我自己的体会是:二进制试填看着像一种手工技巧,本质上是"假设-实验-验证"的科学方法。最忌讳的是贪快,一上来就想着改三个字节同时看效果,结果反馈混在一起,什么都分析不出来。每次只动一个变量,一次只验证一个假设,看起来慢,实际总时长最短。
还有一条原则是边做边记录。我会把每一次发送的报文、回包、注释写在一个文本文件里,最后整理成一张字段对照表。很多时候你以为自己在朝 A 方向验证,试到后面发现反馈指向 B,回头翻记录才能找到证据链。试填不是靠灵光一闪,是靠记录堆出来的确定性。
最后再分享一个实用的扩展方向:当你用试填把一套协议摸透了,不妨顺手把协议解析和组包逻辑写成一个完整的小库,下次再遇到同家族的老设备,前面的成果就能直接复用。试填解决的是"眼前这台设备"的问题,但你沉淀下来的方法和工具,才是这类工作里真正值钱的部分。