4、固件镜像结构:常见的固件打包格式(Bin/Hex/ELF)、固件头部、元数据与负载分离

说到固件签名,很多人第一反应就是“把签名数据塞进固件里”。但塞在哪儿?怎么塞?塞完之后启动代码怎么找到它?这些问题,说白了都跟固件的打包结构有关。

我刚开始做嵌入式安全那会儿,接过一个项目——客户说他们的固件已经签名了,结果我一分析,签名数据被硬编码在代码段中间,启动时根本没校验。嗯,这就是典型的“结构没设计好”。

这一章,我们就来拆解固件镜像的底层结构。你搞懂了这些,后面做签名、做校验,才能有的放矢。

4.1 三种常见的固件打包格式

嵌入式世界里,固件通常以三种格式存在:Bin、Hex 和 ELF。它们各有各的用途,也各有各的坑。

格式 全称 特点 典型场景
Bin Binary 纯二进制数据,无任何元数据 烧录到 Flash、OTA 升级包
Hex Intel HEX ASCII 文本,包含地址和校验 烧录器、调试器传输
ELF Executable and Linkable Format 包含段表、符号表、调试信息 编译输出、调试、链接
我的习惯:开发阶段用 ELF,调试方便。发布阶段一律转成 Bin,再打包成带签名的固件镜像。Hex 格式我很少用于生产,除非烧录器强制要求。

4.2 固件头部:镜像的“身份证”

不管用哪种格式,最终烧录到 Flash 里的固件,都需要一个头部。头部就是固件的身份证,告诉启动代码:你是谁、从哪来、到哪去、有没有被篡改。

一个典型的固件头部长这样:

typedef struct {
    uint32_t magic;          // 魔数,用于识别固件合法性
    uint32_t version;        // 固件版本号
    uint32_t image_size;     // 负载大小(不含头部)
    uint32_t load_address;   // 加载地址
    uint32_t entry_point;    // 入口地址
    uint32_t checksum;       // 头部校验和
    uint32_t signature_offset; // 签名数据偏移
    uint32_t signature_size;   // 签名数据长度
    uint8_t  reserved[32];   // 保留字段,用于扩展
} firmware_header_t;

这里我特别想强调 magic 字段。我曾经见过一个产品,启动代码只检查了 magic 值,没做任何签名校验。结果攻击者直接替换了固件,magic 值照填,系统就跑了。所以 magic 只是“快速过滤”,不是安全措施。

避坑指南:头部字段一定要有明确的字节对齐。我遇到过因为编译器默认对齐导致头部结构体在不同芯片上解析不一致的惨案。建议手动指定 1 字节对齐,或者用 __attribute__((packed))。

4.3 元数据与负载分离:为什么必须分开?

你想想看,签名校验的时候,启动代码需要先读取头部,找到签名数据,然后对负载做哈希,最后比对签名。如果头部和负载混在一起,启动代码就得先“猜”哪里是头部、哪里是负载——这不靠谱。

所以,成熟的固件结构都遵循一个原则:元数据与负载严格分离。

  • 元数据区:存放头部、签名、证书链、版本信息等。这部分通常放在固件的最前面,固定大小。
  • 负载区:存放真正的可执行代码、数据。这部分是签名保护的对象。

分离的好处很明显:

  1. 启动代码可以快速定位元数据,不需要扫描整个固件。
  2. 签名计算时,只需要对负载区做哈希,元数据可以单独保护。
  3. 支持增量更新——只替换负载,元数据里的版本和签名跟着更新就行。
核心原则:签名覆盖的范围是负载区,元数据本身不参与签名计算。但元数据需要额外的完整性保护(比如头部校验和)。

4.4 固件镜像结构图

下面这张图展示了一个典型的带签名固件镜像布局。我建议你把它存下来,后面做签名工具和启动校验时,会反复用到这个结构。

带签名固件镜像结构 固件头部(Firmware Header) 元数据区(Metadata) 签名算法标识 | 签名数据 | 证书链 | 版本信息 | 哈希值 负载区(Payload) 可执行代码 | 只读数据 | 初始化数据 (签名保护范围:整个负载区) 签名覆盖范围(Signing Scope) 尾部填充 / 对齐填充(Padding) 说明:头部和元数据不参与签名计算,但需要额外的完整性保护(如校验和)

4.5 实战:如何从固件中提取元数据和负载

光说不练假把式。我们写一段简单的 C 代码,演示如何解析一个带头部的固件镜像。

#include <stdio.h>
#include <stdint.h>
#include <string.h>

#define FIRMWARE_MAGIC 0xDEADBEEF

typedef struct __attribute__((packed)) {
    uint32_t magic;
    uint32_t version;
    uint32_t image_size;
    uint32_t load_address;
    uint32_t entry_point;
    uint32_t checksum;
    uint32_t signature_offset;
    uint32_t signature_size;
    uint8_t  reserved[32];
} firmware_header_t;

int parse_firmware(const uint8_t *image, size_t image_size) {
    if (image_size < sizeof(firmware_header_t)) {
        printf("镜像太小,无法包含头部\n");
        return -1;
    }

    firmware_header_t *hdr = (firmware_header_t *)image;

    if (hdr->magic != FIRMWARE_MAGIC) {
        printf("魔数不匹配,非法固件\n");
        return -1;
    }

    printf("固件版本: %u\n", hdr->version);
    printf("负载大小: %u 字节\n", hdr->image_size);
    printf("加载地址: 0x%08X\n", hdr->load_address);
    printf("入口地址: 0x%08X\n", hdr->entry_point);

    // 定位签名数据
    uint32_t sig_offset = hdr->signature_offset;
    uint32_t sig_size   = hdr->signature_size;

    if (sig_offset + sig_size > image_size) {
        printf("签名数据超出镜像范围\n");
        return -1;
    }

    uint8_t *signature = (uint8_t *)image + sig_offset;
    printf("签名数据位于偏移 0x%X,长度 %u 字节\n", sig_offset, sig_size);

    // 负载就是头部 + 元数据之后的部分
    uint8_t *payload = (uint8_t *)image + sig_offset + sig_size;
    uint32_t payload_size = hdr->image_size;

    printf("负载起始偏移: 0x%lX\n", (unsigned long)(payload - image));
    printf("负载大小: %u 字节\n", payload_size);

    return 0;
}
我的经验:在实际项目中,我习惯把签名数据放在头部之后、负载之前。这样启动代码读取头部后,紧接着就能拿到签名,不需要跳转到镜像末尾。而且签名数据固定大小,方便做内存映射。

4.6 不同格式的签名适配策略

你可能会问:如果原始固件是 ELF 或 Hex 格式,怎么加签名?

我的做法是:

  • ELF 格式:先用 objcopy 转成 Bin,再打包。ELF 里的段信息在运行时不需要,签名只保护实际执行的二进制数据。
  • Hex 格式:解析出地址和数据,重组为连续的 Bin 镜像,然后加头部和签名。注意 Hex 里可能有地址空洞,需要填充。
  • Bin 格式:直接在前面拼接头部和元数据,尾部做对齐填充。
避坑指南:我曾经遇到一个 Hex 转 Bin 的坑——Hex 文件里有一段地址跳到了 0x10000000(外设地址),结果转出来的 Bin 镜像有几百 MB 的空洞。所以解析 Hex 时一定要做地址范围检查,只提取有效区域。

4.7 小结

这一章我们拆解了固件镜像的底层结构。Bin、Hex、ELF 各有适用场景,但最终烧录的固件一定要有清晰的头部和元数据区。元数据与负载分离,是签名校验能够正确执行的前提。

你掌握了这些结构,后面做签名工具和启动校验时,就能清楚地知道:签名该放在哪里,校验该从哪里开始。