4. 固件镜像结构分析:常见的固件打包格式

做固件验签,第一步得搞清楚——你手里拿到的这个文件,到底长什么样?

我见过不少团队,上来就写验签代码,结果连固件头部在哪、负载从哪开始都没搞明白。最后签名验签都对了,但解析出来的镜像就是跑不起来。说白了,你得先懂固件的“骨架”。

这一章,我们就来拆解三种最常见的固件打包格式:Bin、Hex、ELF。我会结合我在项目里踩过的坑,帮你把它们的头部、负载、尾部校验区彻底搞清楚。

4.1 三种格式的核心差异

先给个总览。这三种格式,本质上代表了固件从开发到烧录的三个阶段。

格式 典型用途 是否包含地址信息 是否分段 是否可直接烧录
Bin 最终烧录镜像 否(裸数据) 否 是
Hex 烧录/编程器用 是(每行记录地址) 是(按行记录) 是
ELF 编译/调试阶段 是(完整段表) 是(多段) 否(需转换)

嗯,这里要注意:Bin 文件是最“干净”的,它就是从起始地址开始,一字节一字节的原始机器码。Hex 和 ELF 都带了额外的元信息。你想想看,验签的时候,我们到底该对哪部分数据做哈希?

核心原则:验签的对象,永远是最终烧录到 Flash 里的那一段 有效负载。头部和尾部校验区,通常是用来存放签名本身和元数据的,它们本身不参与哈希计算(除非设计如此)。

4.2 Bin 格式:最纯粹的裸数据

Bin 格式,说白了就是一块连续的内存转储。没有头部,没有尾部,就是纯粹的指令和数据。

但实际工程中,我们会在 Bin 文件的前后“人为”加上结构。我习惯把它分成三个区域:

  • 头部(Header): 固定偏移处,存放魔数、版本号、镜像长度、校验算法标识等。长度通常是 32 字节或 64 字节。
  • 负载(Payload): 真正的可执行代码和只读数据。这是验签的核心对象。
  • 尾部校验区(Trailer): 紧跟在负载后面,存放签名值(RSA/ECDSA)、哈希值(SHA256)或 CRC。有时也包含一个完整的证书链。

我曾经在一个 IoT 项目里,遇到过厂商把签名直接塞在头部里。结果验签的时候,头部还没解析完,签名就被覆盖了。后来我建议他们把签名放到尾部,头部只放元数据,这样逻辑就清晰多了。

一个典型的 Bin 镜像布局大概长这样:

+-------------------+  <-- 起始地址 (例如 0x08000000)
|   头部 (32字节)   |
|   - 魔数: 0xAA55  |
|   - 版本号: 0x01   |
|   - 负载长度: N    |
|   - 算法ID: 0x01   |
+-------------------+
|   负载 (N字节)     |
|   - 代码段         |
|   - 只读数据段     |
|   - ...            |
+-------------------+
|   尾部校验区       |
|   - 签名 (256字节) |
|   - 填充 (可选)    |
+-------------------+  <-- 结束地址

我的建议: 设计 Bin 格式时,头部里一定要包含 负载长度 和 整个镜像的 CRC。这样在验签前,可以先快速校验镜像是否被截断或损坏,避免浪费算力去验一个坏掉的签名。

4.3 Hex 格式:带地址的“拼图”

Hex 文件(Intel HEX 格式)是另一种常见格式。它不像 Bin 那样是连续的,而是由一条条记录(Record)组成的。每条记录都包含:长度、地址、类型、数据、校验和。

你想想看,Hex 文件里的数据可能是不连续的。比如,代码段在 0x08000000,数据段在 0x20000000。这对验签来说是个麻烦事——你不能直接对整个文件做哈希,因为文件里还包含了地址信息和校验和。

正确的做法是:先解析 Hex 文件,提取出所有类型为 0x00(数据记录)的有效负载,然后按地址顺序拼接成一段连续的二进制数据。最后,再对这个拼接后的数据做验签。

我记得有一次,一个同事直接对整个 Hex 文件做了 SHA256,然后拿去跟芯片里的值比对。结果当然是对不上的,因为芯片里只存了有效数据,而 Hex 文件里还多了地址和校验和。折腾了半天,才发现是解析逻辑出了问题。

Hex 记录类型中,我们最关心这几种:

类型码 含义 验签时是否处理
0x00 数据记录 是(提取负载)
0x01 文件结束记录 否(仅标记结束)
0x02 扩展段地址记录 是(用于计算绝对地址)
0x04 扩展线性地址记录 是(用于 32 位地址计算)
0x05 启动线性地址记录 否(仅指定入口地址)

注意: 有些 Hex 文件会包含多个 扩展线性地址记录,这意味着数据可能跨越多个 64KB 的段。解析时一定要正确维护当前基地址,否则拼接出来的数据就是错的。我曾经见过一个案例,因为基地址计算错误,导致签名验证始终失败,最后发现是解析器少处理了一个 0x04 记录。

4.4 ELF 格式:调试者的“瑞士军刀”

ELF(Executable and Linkable Format)是 Linux 和大多数 RTOS 下编译器的默认输出。它比 Hex 和 Bin 都复杂,因为它包含了完整的符号表、重定位信息、调试信息等。

对于验签来说,ELF 文件里真正有用的只有 可加载段(Loadable Segments)。这些段在程序头(Program Header)中标记为 PT_LOAD。我们需要把这些段的数据提取出来,按它们在内存中的布局顺序拼接好,然后对这个拼接后的数据做验签。

我个人的习惯是:绝对不要直接对 ELF 文件本身做签名。因为 ELF 文件里包含了大量的元数据(比如段名、符号名),这些数据在最终运行时并不会被加载到内存里。如果你对 ELF 文件签名,那芯片在启动时,还得先解析 ELF,提取出负载,再验签——这太慢了,而且增加了攻击面。

正确的流程是:

  1. 用 objcopy 工具把 ELF 转换成 Bin 文件。
  2. 对 Bin 文件做签名。
  3. 把签名附加到 Bin 文件的尾部。
  4. 烧录这个带签名的 Bin 文件。

当然,如果你非要直接在 ELF 上做验签,也不是不行。你需要解析 ELF 的程序头,找到所有 PT_LOAD 段,然后按 p_vaddr 排序,提取 p_data 和 p_filesz,最后拼接。但说实话,这工作量不小,而且容易出错。

避坑指南: 我曾经在解析 ELF 时,忽略了 p_align 字段。结果提取出来的数据,在段与段之间多了很多填充字节。这些填充字节在内存里是存在的,但在 Bin 文件里可能被优化掉了。最后导致验签时,哈希值对不上。记住:提取 ELF 负载时,一定要考虑段对齐。

4.5 三种格式的验签流程对比

为了让你更直观地理解,我画了一张流程图,对比了三种格式的验签处理路径。

三种固件格式验签流程对比 三种固件格式验签流程对比 固件镜像文件 Bin 格式 Hex 格式 ELF 格式 1. 解析头部 获取负载偏移和长度 1. 解析记录 提取 0x00 数据记录 1. 解析程序头 找到 PT_LOAD 段 2. 提取负载 从头部后开始读取 2. 拼接数据 按地址排序拼接 2. 提取段数据 按 p_vaddr 排序 3. 对负载做哈希验签

从这张图可以看得很清楚:无论输入格式是什么,最终我们都要把它“归一化”成一段连续的二进制负载,然后再做哈希和验签。这个“归一化”的过程,就是固件解析的核心。

4.6 尾部校验区的设计要点

最后,聊聊尾部校验区。这个区域通常放在固件的末尾,紧跟在负载之后。它的设计有几个关键点:

  • 固定偏移: 签名或哈希值的位置必须是固定的,或者可以通过头部中的长度字段计算出来。我建议放在负载结束后的第一个对齐地址处。
  • 完整性保护: 尾部校验区本身也应该被保护。通常的做法是,对整个镜像(包括头部和负载)做签名,然后把签名放在尾部。这样,任何对尾部的篡改都会导致验签失败。
  • 多签名支持: 在一些高安全场景下,可能需要支持多个签名(比如,由不同厂商签名)。这时,尾部校验区可以设计成一个签名链表或数组。

一个常见的坑: 不要把签名放在头部里。因为头部在启动时会被最先解析,如果签名也在头部,那你就得先验签,再解析头部——这成了“先有鸡还是先有蛋”的问题。除非你的 BootROM 支持硬件验签,否则,签名请放在尾部。