4、固件镜像格式设计:镜像头部结构、元数据定义、签名区域布局

好,咱们进入第四章节。前面几章我们把签名算法和密钥管理聊透了,但有个问题一直悬着——签名往哪儿放?

你想想看,固件烧进 Flash 里是一整片二进制数据。BootROM 怎么知道哪一段是代码、哪一段是签名、哪一段是配置参数?答案就在镜像格式里。

说白了,镜像格式就是固件和 BootROM 之间的「契约」。我做了这么多年固件,见过太多因为头部定义没对齐导致启动失败的案例。有一次,一个团队把签名偏移算错了 4 个字节,结果整个产线返工。嗯,这种坑咱们今天必须填上。

4.1 镜像头部结构:BootROM 的第一眼

BootROM 上电后做的第一件事,就是读镜像头部。头部必须放在固定偏移,通常是 Flash 起始地址的 0x0 或 0x200 处。我个人的习惯是放在 0x0,省得 BootROM 还要做偏移计算。

一个典型的头部结构长这样:

typedef struct {
    uint32_t magic;             // 魔数,用于识别镜像格式
    uint32_t header_size;       // 头部总长度(含扩展字段)
    uint32_t firmware_size;     // 固件有效载荷长度
    uint32_t firmware_load_addr;// 加载到 RAM 的目标地址
    uint32_t entry_point;       // 入口函数地址
    uint32_t version;           // 固件版本号
    uint32_t flags;             // 标志位(加密、压缩等)
    uint32_t checksum;          // 头部校验和
    uint8_t  reserved[32];      // 保留字段,对齐到 64 字节
} image_header_t;

这里每个字段都有讲究。我重点说几个:

  • magic:我一般用 0x4649524D("FIRM" 的 ASCII)。BootROM 读到这个值才继续往下走,否则直接跳错误处理。
  • header_size:这个字段很关键。头部后面可能跟着证书链、签名值、元数据扩展区。BootROM 需要知道头部到底占多少字节,才能定位到有效载荷。
  • firmware_load_addr:有些芯片支持从 Flash 直接执行(XIP),有些需要先拷贝到 RAM。这个字段告诉 BootROM 把固件放到哪里。
  • checksum:头部自身的校验。我见过有人忘了算这个,结果 Flash 里一个比特翻转,BootROM 把乱码当头部解析,直接跑飞。
注意:头部字段的字节序必须和 BootROM 一致。ARM 默认小端,PowerPC 默认大端。我曾经接手过一个项目,团队把大小端搞反了,BootROM 读到的 magic 是 0x4D524946,直接拒绝启动。排查了两天才找到原因。

4.2 元数据定义:不止是签名

头部之后,通常跟着元数据区。元数据不只是签名,还包括算法标识、证书信息、哈希值等。我习惯把元数据组织成 TLV(Type-Length-Value)格式,这样扩展起来灵活。

一个典型的元数据条目:

typedef struct {
    uint16_t type;      // 元数据类型
    uint16_t length;    // 数据长度
    uint8_t  value[];   // 数据内容
} metadata_entry_t;

常用的元数据类型:

Type 值 含义 说明
0x0001 签名算法 ID 如 0x01=RSA2048, 0x02=ECDSA P-256
0x0002 哈希算法 ID 如 0x01=SHA256, 0x02=SHA384
0x0003 固件哈希值 对有效载荷的哈希摘要
0x0004 签名值 对哈希值的签名结果
0x0005 证书链 X.509 证书 DER 编码
0x0006 固件版本号 用于防回滚检查
0x0007 厂商 ID OUI 或自定义标识
0x00FF 元数据结束标记 TLV 链终止符

为什么用 TLV?因为不同芯片、不同安全等级需要的元数据不一样。低端 MCU 可能只需要签名值和哈希值,高端应用处理器可能需要完整的证书链。TLV 让 BootROM 可以跳过不认识的类型,向前兼容。

我的经验:元数据区一定要留足空间。我见过一个设计只给签名留了 256 字节,后来升级到 RSA4096,签名值 512 字节,直接放不下。最后不得不改 Flash 布局,产线固件全部重刷。建议至少预留 2KB 给元数据。

4.3 签名区域布局:签名放哪儿?

签名区域的位置,直接决定了 BootROM 的验证流程。常见的布局有三种:

  1. 尾部签名:签名放在固件有效载荷之后。BootROM 先计算整个固件的哈希,再验证签名。优点是简单,缺点是 BootROM 必须读完整个固件才能开始验证。
  2. 头部签名:签名放在头部之后、有效载荷之前。BootROM 先读签名,然后边加载边验证。适合大固件,可以流水线处理。
  3. 分离签名:签名单独存放在 Flash 的另一个区域。BootROM 通过指针找到签名。灵活性最高,但管理复杂。

我个人最常用的是尾部签名。为什么?因为实现最简单,BootROM 的代码量最少。对于嵌入式设备,BootROM 通常只有几 KB,能省则省。

一个典型的尾部签名布局:

Flash 地址空间:
+----------------------------+
| 0x0000: 镜像头部 (64B)     |
| 0x0040: 元数据区 (可变)    |
| 0x0100: 固件有效载荷       |
|         ...                |
| 0x8000: 签名值 (256B)      |
| 0x8100: 证书链 (可选)      |
+----------------------------+

这里有个细节:签名值后面通常跟着一个固定长度的填充区。为什么?因为 Flash 的擦除单位是扇区(通常是 4KB),签名区必须对齐到扇区边界,否则擦除时会影响到相邻区域。

核心原则:签名区域必须位于 BootROM 可访问的地址空间内,且不能被固件自身覆盖。如果固件支持自升级,签名区必须放在升级过程中不会被擦除的保护区。

4.4 完整镜像结构图

说了这么多,不如一张图来得直观。下面是我设计的典型安全启动镜像结构:

安全启动镜像结构图 镜像头部 (64B) 魔数 | 头部大小 | 固件长度 | 加载地址 | 入口点 | 版本 | 标志 | 校验和 元数据区 (TLV 格式) 算法 ID | 哈希值 | 版本号 | 厂商 ID | ... | 结束标记 固件有效载荷 加密或明文固件二进制数据 长度由头部 firmware_size 字段指定 签名区 签名值 (256B/512B) | 填充区 (对齐到扇区) BootROM 验证流程:读头部 → 解析元数据 → 计算固件哈希 → 验证签名

4.5 避坑指南:我踩过的几个坑

镜像格式设计看似简单,实际做起来全是细节。我分享几个真实踩过的坑:

  • 对齐问题:头部字段必须按自然对齐。uint32_t 要放在 4 字节边界,否则某些 ARM Cortex-M 内核会触发异常。我见过有人把 uint32_t 放在偏移 2 的位置,结果 BootROM 读出来的值全是错的。
  • 版本号溢出:版本号用 uint32_t,但有些人习惯用 BCD 码(如 0x01020304 表示 v1.2.3.4)。问题来了,BCD 码没法直接比较大小。我建议用纯整数,或者单独用一个字段存版本号数值。
  • 签名区被覆盖:固件自升级时,如果新固件的大小超过了原固件占用的空间,可能会把签名区覆盖掉。解决方案是把签名区放在 Flash 末尾的独立扇区,升级时只擦除固件区。
  • 元数据解析漏洞:TLV 解析器必须做长度检查。我曾经见过一个案例,攻击者构造了一个超长的 Type 字段,导致 BootROM 的缓冲区溢出。嗯,从那以后我所有 TLV 解析都加了长度上限。
安全提醒:镜像头部和元数据区本身不能被篡改。如果攻击者能修改头部中的 firmware_size 字段,就可能诱导 BootROM 只验证部分固件。建议对头部和元数据区也做签名,或者至少做完整性校验。

4.6 小结

镜像格式设计是安全启动的基石。头部告诉 BootROM 怎么加载,元数据告诉 BootROM 用什么算法验证,签名区提供验证数据。这三者缺一不可。

设计时记住几个要点:头部字段对齐、元数据用 TLV 扩展、签名区对齐扇区边界。把这些基础打牢了,后面的安全启动流程才能跑得稳。