接手一份没人讲得清的固件,我做了个工具
干嵌入式这行,最怕听到一句话:“这个项目之前是XX负责的,他离职了,你帮忙看一下。”接过固件项目的人都知道,这话翻译过来就是:代码能跑,但没人知道它为什么能跑;文档有,但版本对不上;编译链有,但换台机器就编不过。
我这次接手的,是一个基于GD32F10x平台的IoT网关固件。整份工程代码量大概十几万行,但核心业务逻辑和板级支持代码全搅在一起,没有任何分层。前任留下的说明文档只有一页,写的还是“根据硬件原理图调整GPIO配置,如有问题请参考芯片手册”,等于什么都没写。
更麻烦的是版本管理。本地有七八个带日期的压缩包,Git仓库里的历史和它们对不上,生产环境烧的到底是哪一版,竟然要拆开看镜像里的版本字符串才能确认。我花了两周时间,把工程脉络理清楚,期间顺手写了一个固件分析辅助工具,专门干拆镜像、扫字符串、对比版本、追代码变更这些脏活。
这篇就记录一下整个过程:我遇到了什么、为什么选择自己写工具、工具是怎么设计的、实际排查中发现了哪些问题,以及最终如何把一个“讲不清”的固件变成一套可复现、可追溯的构建体系。如果你也接过类似的烂摊子,希望能少走点弯路。
1. 内容整体设计与思路拆解
接手这种项目,首要任务不是改代码,而是搞清楚“现状是什么”。我给自己定的第一个目标是:三天内回答三个问题——编译能否复现、烧录的是哪个版本、代码库里哪些文件是活的。
1.1 问题从哪来:为什么原项目“讲不清”
先说原因。这类遗留固件项目讲不清,往往不是某一个人的问题,而是几个因素叠加的结果。
第一是硬件迭代快,原理图改版频繁。这个网关产品前后出过三版硬件,每版改动都不小,但固件工程并没有按硬件版本建分支,而是用宏定义切换。结果就是#ifdef散落各处,有的宏在头文件里定义,有的在编译命令里传入,还有的直接在代码里写死了。新接手的人根本不知道哪块代码对应哪版硬件。
第二是构建环境不固定。原开发者在一台老旧的Ubuntu 14.04机器上装了交叉编译工具链,编译脚本里写死了绝对路径。一旦换机器,要么重新配环境,要么各种莫名其妙报错。偏偏没有人把环境搭建步骤记录下来。
第三是发布流程随意。固件编译通过后,直接用脚本打包生成镜像,然后拷贝到Windows下刷机。镜像文件名只带日期不带版本号,刷过几轮之后,谁也不能确定出厂设备里烧的是哪一版。
听完这些描述,你应该能理解为什么我需要一个工具了。因为光靠人眼去翻代码、翻压缩包,效率太低,而且容易漏。我需要把很多东西量化出来:镜像文件的结构是什么、里面有哪些字符串能当版本线索、不同镜像之间的二进制差异有多大、Git历史里到底改了哪些文件。
1.2 我给自己定的几条规矩
在动手写工具之前,我先明确了三条原则,避免为了用工具而用工具。
第一,能撬现成的就不重复造轮子。固件分析领域有很多成熟工具,比如Binwalk、strings、hexdump、readelf,我的工具只需要把它们编排起来,解决“一键执行、统一输出”的问题,而不是全部自己实现。
第二,所有分析结果必须可重复、可留痕。每跑一次分析,自动生成带时间戳的报告文件。这样后面追溯时,能清楚地知道“某年某月某日,我基于哪一个镜像得出了什么结论”。
第三,工具只做只读分析,不做修改操作。分析工具定位是“看清现状”,不是“修复问题”。任何重打包、改格式的活,必须经过人工确认,绝不能让工具自动写回。这样能最大限度避免分析工具引入二次故障。
这三条规矩定下来,后面所有设计都围绕它们展开。
1.3 工具的整体架构:一个“把脏活自动化”的组合
我做的这个工具,本质上是一组Python脚本的集合,通过命令行入口统一调度。为什么选Python?因为固件分析涉及的杂活多,Python调用os.system或者subprocess很方便,处理二进制也用得上struct模块,写起来比shell脚本舒服多了,而且跨平台。
整体分三层:
- 底层调用现成的系统工具和开源工具,比如
strings、hexdump、readelf、Binwalk。 - 中间层是Python封装的各个分析模块,每个模块负责一类任务,比如解析固件头、提取版本字符串、比对两个镜像。
- 上层是命令行入口,提供类似
fwtool analyze xxx.bin、fwtool diff old.bin new.bin这样的子命令。
下面我把每个核心模块和实战中踩过的坑详细展开。
2. 核心细节解析与实操要点
这一节重点说工具模块的设计思路,以及每个模块解决的是什么问题。如果你也想自己写类似工具,可以参考这个拆解方式。
2.1 固件头解析器:先弄懂镜像文件从哪开始
拿到一个固件镜像文件,第一件事不是急着解包,而是先看它的文件头。大多数固件格式都有固定的魔数(Magic Number)和头部结构,里面藏着镜像类型、版本号、分区偏移、校验和等关键信息。
我这个项目里的固件是从U-Boot引导的,镜像头部结构大致是这样的:前四个字节是魔数,用于校验文件是否是合法固件;接着是头部长度字段,通常是16字节对齐;再往后是镜像总长度和校验值;随后才是真正的代码和数据。
解析模块的工作就是把这些字段读出来,用可读的方式打印到终端。比如:
import struct def parse_header(data): magic = data[:4] if magic != b'\xA5\x5A\xA5\x5A': print("[!] 魔数不匹配,可能不是有效固件镜像") return None total_len = struct.unpack('<I', data[12:16])[0] version = data[16:32].split(b'\x00')[0].decode('ascii', 'ignore') print(f"[+] 镜像长度: {total_len} (0x{total_len:X})") print(f"[+] 版本字符串: {version}")这里有个小细节:不同平台的固件头字段偏移完全不同,写模块时不要把偏移硬编码在业务逻辑里,建议用一个配置字典集中管理。我第一次接手时没注意这点,解析到第三个镜像就发现偏移对不上,回头改代码浪费了半天。
实际使用中你会发现,光靠魔数判断还不够。很多固件会用相同魔数但头部尺寸不同,或者魔数被刻意隐藏以增加逆向难度。所以解析器里要加容错逻辑:魔数不对时,尝试跳过若干字节再匹配;头部长度的字段也做一个合理性检查,比如是否落在整个镜像文件大小范围内。这些都是实战中非常有用的防御性写法。
2.2 字符串与调试残留扫描:从固件里挖出版本线索
很多固件开发者在发布时不会做严格的代码裁剪和符号清理,导致固件里残留大量可读字符串。这些字符串是逆向分析的宝藏,都不用太高深的技术,直接跑strings命令就能挖出不少信息。
我的工具里做了一个find_strings模块,流程是这样的:
- 先对镜像文件执行
strings -n 8 -t x,提取所有长度不少于8字节的可打印字符串,并记录它们在文件中的偏移地址。 - 然后做关键词过滤,重点找这些模式:
v1.、V1.、FW_、BUILD_、2024之类的年份数字、/home/开头的编译路径,以及git相关的提交哈希。 - 最后把命中的字符串连同偏移地址写入报告。
最典型的一次排查经历,是通过这个模块找出了编译日志里留下的源文件路径,路径里居然带着开发者本机用户名和一个SVN目录结构。顺着这条线索,我在工程代码里搜索对应文件名,定位到了几个被长期闲置的模块,最后确认这些模块连编译都没进,纯属“僵尸代码”。
为什么要专门强调字符串扫描?因为版本信息往往不写在明面上。很多项目的版本号只在发布脚本里更新,源码里的版本宏常年不维护。固件镜像里的字符串是你反推版本来源最直接的物证。如果你接手时手头有好几份bin文件,先各自跑一遍字符串扫描,把版本字符串列成一张对照表,比挨个刷机测试要快得多。
2.3 镜像对比与版本追溯:两两diff找差异
我收到的那批固件压缩包,按文件名根本不可能确定版本顺序。我的做法是:先把所有镜像文件解压出来,然后两两做二进制diff,记录差异字节的比例和分布区域。
diff模块的实现,我不会直接用diff命令,因为二进制文件用cmp -l能看到逐字节差异,但输出量巨大,不适合人眼直接看。我更推荐的做法是分块计算哈希:
把整个镜像按256字节分块,对每一块算SHA256,然后比较两个镜像哪些块的哈希不一致。这样能快速定位差异集中在哪个区域,再结合符号表或字符串偏移推断差异属于哪部分代码。
import hashlib BLOCK_SIZE = 256 def block_hashes(data): hashes = [] for i in range(0, len(data), BLOCK_SIZE): hashes.append(hashlib.sha256(data[i:i+BLOCK_SIZE]).digest()) return hashes def diff_images(bytes1, bytes2): h1 = block_hashes(bytes1) h2 = block_hashes(bytes2) changed_blocks = [] for i, (a, b) in enumerate(zip(h1, h2)): if a != b: changed_blocks.append(i * BLOCK_SIZE) print(f"[+] 差异块数量: {len(changed_blocks)}") for offset in changed_blocks[:20]: print(f" offset: 0x{offset:X}")这个方法有一个非常实用的产出:它能帮我判断两个固件之间的差异是“结构性变更”还是“小修补”。如果差异块散布在整个镜像中,说明两个版本之间代码变动范围很大;如果差异集中在个别区域,很可能只是配置参数或局部逻辑调整。
加上Git历史里的提交时间、提交说明,基本可以把固件版本的演进时间线复原出来。后来我对照生产设备上跑的实际固件,再结合研发记录,确定了出厂烧录的是某个日期目录下的镜像,于是把这个镜像明确标记为v1.0基线。版本追溯到这里才算闭环。
3. 实操过程与核心环节实现
接下来分享完整的实操过程,从接手的乱摊子到最终建立可复现构建,这里面的关键操作、命令和参数选择,我会尽量按时间顺序写清楚。
3.1 还原编译环境:工具链和依赖库的版本对账
这个步骤没有任何捷径可言,只能一步一步对齐。我翻遍工程里的Makefile、build.sh脚本和README,先把编译依赖列出来。这个项目用的是arm-none-eabi-gcc交叉编译链,依赖的库包括CMSIS、一个RTOS内核,以及一堆私有静态库。
麻烦的点在于,原开发环境里的一些库文件被打包进了工程里,但版本号没标。我用了两个办法确定版本:
- 查看静态库的构建时间戳。
ar -t可以列出库内的目标文件,每个目标文件通常带有编译时间。对比几个候选版本库的时间戳,能大致判断哪个是原版。 - 反查一些库内符号的地址布局。比如说某个库的新版本增加了一个结构体字段,那么结构体的大小必然变化。我写了一段脚本编译一个小的测试程序,链接不同版本的库,输出结构体大小,以此确定版本。
工具链版本怎么选?我优先选择与原先Makefile注释里出现的版本接近的。如果实在找不到原版本,选择同大版本号的最新小版本,然后编译测试程序做ABI验证。
环境还原完成后,我做的第一件事就是把整套编译命令封装成一个独立的build.sh,并且大胆做了个决定:烧录一次固件做冒烟测试。实测下来,编译出的镜像功能和原有镜像基本一致,至少串口日志输出正常,外设驱动工作正常。这一步给了我很大的信心,说明编译链路对齐了。
3.2 分区表与烧录地址的验证
固件开发中,烧录地址不对是新手最容易犯的错误。分区表信息通常写在bootloader或者专门的描述文件里。我这个平台没有使用标准的分区表,而是在链接脚本里直接指定了各段加载地址。
我用readelf -S查看编译出来的elf文件,观察.text段的起始地址,再对比芯片手册规定的Flash起始地址以及bootloader跳转地址,确认三者一致。
这里分享一个排查工具,作用非常大:arm-none-eabi-objdump -h xxx.elf打印所有段的起始地址、大小和对齐方式。我照着这个输出,在分区表配置文件里逐一核对,确实发现了一个问题——原本的配置里把一个日志存储分区的起始地址写错了,和代码段的地址重叠。这个问题在之前的开发周期里一直没人发现,因为日志分区只在特定功能触发时才被写入,平时根本不会暴露。如果没有认真做地址验证,我大概率也会漏掉。
3.3 自研工具的完整使用流程
前面说的解析、扫描、diff三个模块是我工具的第一版。在后续使用中,我又添加了两个实用子命令:extract-kernel和trace-build。
extract-kernel负责从固件镜像中剥离掉头部和填充数据,提取出纯二进制部分,再用binwalk尝试识别内嵌压缩流。这个命令看似简单,但它的价值在于自动忽略了不同固件头的长度差异。只要维护一份平台头长度配置,就能一键把不同固件处理成可用于diff的统一格式。
trace-build则记录每次编译的哈希值清单。在编译成功后,自动扫描工程里所有参与编译的源文件的SHA256,汇总成一份构建指纹文件,并写入最终的镜像末尾。这样后面任何人拿到镜像,都能反查它是由哪些文件、什么时间构建出来的。构建指纹的好处在于,它让“可复现”从一句口号变成了可验证的事实。
工具整体使用流程大致如下:
- 接收新固件,先跑
fwtool analyze,生成镜像整体概览。 - 跑
fwtool strings,看关键版本信息。 - 和已知基线做
fwtool diff,判断变更范围。 - 如果需要解包,跑
fwtool extract-kernel配合binwalk做进一步分析。 - 每次构建后跑
fwtool trace-build,自动生成构建指纹并归档。
这套流程磨合了一周之后,我终于不用再开好几个终端窗口、手动敲一堆零散命令了。所有分析都在一个入口下完成,结果统一输出到报告文件。更重要的是,这些报告能被Git追踪,后续任何人接手分析过程,都知道我之前做了什么。
4. 常见问题与排查技巧实录
这里整理我在这两周里遇到的高频问题,做成速查表,外加几个值得说说的排查思路。
4.1 典型问题速查表
| 问题现象 | 可能原因 | 排查思路 |
|---|---|---|
| 编译通过,烧录后完全没反应 | 链接脚本的Flash起始地址与bootloader不一致 | 用objdump -h查段地址,对照芯片手册 |
| 串口打印乱码 | 晶振频率或波特率配置错误 | 核对工程里的系统时钟初始化代码,确认是否匹配实际板子 |
| 同一份源码不同机器编译出不同大小镜像 | 编译优化选项或工具链版本不同 | 统一编译器版本,并在Makefile里固定-O2参数 |
| 固件里字符串扫描不到版本号 | 版本号被存储在外部配置分区而非代码段 | 查看字符串偏移区域是否在头部或独立分区里 |
| 两个固件diff差异区域超出预期 | 内部私有库版本不同或编译时间戳被打进代码 | 先反汇编对比目标代码,排除非源码差异 |
表里列出的问题,几乎每一个都是真实遇到过的。特别是串口乱码那个,原代码用的是HSE 8MHz晶振,而新版本板子换成了25MHz外部晶振。这类硬件差异很难在纯软件代码里看出来,必须在接手时就和硬件工程师确认清楚。
4.2 避坑心得:那些文档里不会写的事
越过这些坑之后,我再分享几条心得。
第一,不要相信文件名和注释。任何版本判断,都要以镜像内容里的实际信息为准。我的工具里加了一行自动校验:把解析出的版本字符串和文件名做个比对,不一致时输出告警。这样能立刻发现命名习惯造成的误判。
第二,对“僵尸代码”要有明确处置策略。很多遗留工程里,大量源文件没有被任何构建目标引用。我在遍历代码时发现,有大约三成文件从未被编译过。有些文件内容看起来还很完整,但仔细看都是早期硬件的适配逻辑,已经没有任何实际作用。对于这些文件,我没有急着删除,而是先加进了.gitignore旁边的deprecated/目录,保证它们还在版本库里,但不再参加编译。这样做的好处是:如果后面有需求说“把老版本功能恢复一下”,还找得到代码,不会被误杀。
第三,构建指纹工具必须有。以前的经验告诉我,团队里只要有人“手动编译然后拷贝出来”,就有可能出现因环境差异导致的镜像不一致。自从加了构建指纹,任何镜像文件都能追溯到源文件清单,版本问题才算真正管住了。
4.3 关于固件安全的一点补充
做固件逆向分析时,安全是一件绕不开的事。我这次分析的镜像没有加密,直接就能读到大量明文信息。而我在搜索相关资料时也注意到,很多IoT设备固件存在类似问题:固件中明文存放WiFi密码、云平台密钥、后台管理地址等敏感信息。
虽然这不是我这篇文章的核心主题,但我还是建议每个接手的固件工程师,在条件允许的情况下顺手做一次敏感字符串扫描。也推荐你调研现有的固件安全扫描工具和加密方案,提前建立安全意识,不要等到设备上线被攻破了再回头补课。我自己的工具里只是预留了扩展接口,后续可以接上更专业的安全扫描模块。
5. 复盘与小结:这套打法的通用价值
文章写到这里,核心经验基本都交代清楚了。最后做几个复盘层面的总结,给接手类似项目的朋友一点参考。
先说说工具的定位。它不是万能的,也不能替代对芯片手册、硬件原理图的理解。它的核心价值是“把大量琐碎、重复的分析工作自动化,让人集中精力做判断”。你完全不必依赖我写的这套东西,只要理解了它的设计思路,根据你手上的工程特点自己实现一个,也许更顺手。
再说说整个接手过程的通用方法论。我始终认为,接遗留固件项目最关键的三步是:重建可复现的编译环境、锁定发布版本来源、建立构建与镜像的对应关系。这三件事做完,项目就已经从“讲不清”变成“讲得清”了,剩下的代码重构、功能优化,反而都是常规工作。
最后分享一个个人习惯。我现在每接手一个新项目,都会先花一整天时间制作一份“项目诊断报告”,内容包含:可复现编译验证结果、镜像版本清单、字符串扫描结论、diff差异概览、代码库活跃文件统计。也许有人觉得这浪费时间,但事实证明,这份报告在后续数月乃至一年的维护中,帮我省下的沟通和排查时间,远超那一天的成本。
如果你也在面对一份说不清的固件,别急着动手改代码,先做分析,再做一个趁手的工具。把“说不清”变成“看得清”,这一步跨过去之后,你会发现后面的路好走得多。