4. 镜像文件结构:Bin/Hex/ELF格式区别、镜像头设计、CRC校验与签名

说到固件打包,第一个绕不开的问题就是:你的原始文件到底是什么格式?

我见过不少刚入行的工程师,拿到一个 .bin 文件就开始烧录,结果板子跑不起来。查了半天,发现是文件格式搞错了。嗯,今天我们就来把这几种格式彻底理清楚。

4.1 Bin / Hex / ELF 到底差在哪?

这三种格式,说白了就是不同阶段的产品。我习惯用一个比喻:

  • ELF 就像一道菜的完整菜谱——里面有食材清单、步骤、甚至厨师的备注。调试的时候你需要它。
  • Hex 像是分装好的调料包——每包上面写了地址和内容,但你需要按顺序拆开用。
  • Bin 就是做好的菜——直接端上桌,没有多余信息,从第一口开始吃就行。

来看一个具体的对比表格,这样更清楚:

特性 ELF Hex (Intel HEX) Bin (Raw Binary)
包含信息 代码、数据、符号表、调试信息、段表 地址 + 数据 + 校验和 纯二进制数据
地址信息 有(段表描述) 有(每行记录地址) 无(从起始地址连续排列)
是否可执行 需要加载器解析 需要烧录工具解析 直接映射到内存
文件大小 最大(含调试信息) 中等(ASCII编码,膨胀约2倍) 最小(纯二进制)
典型用途 开发调试、GDB调试 烧录器、编程器 量产烧录、OTA升级包
我的经验:量产固件我几乎只用 .bin 格式。为什么?因为 .hex 文件是 ASCII 文本,解析起来慢,而且容易在传输过程中被换行符搞坏。有一次产线反馈烧录失败率奇高,查了一整天,最后发现是 .hex 文件里多了一个回车符。从那以后,量产脚本里我强制要求先转成 .bin。

4.2 镜像头设计——固件的“身份证”

你想想看,一个 .bin 文件扔给 Bootloader,Bootloader 怎么知道它是不是给这个板子用的?版本对不对?有没有被篡改过?

这就需要镜像头。它就像固件的身份证,贴在二进制数据的最前面。

我个人习惯的镜像头结构长这样:

typedef struct {
    uint32_t magic;          // 魔数,例如 0xDEADBEEF
    uint32_t version;        // 固件版本号
    uint32_t length;         // 固件总长度(含头)
    uint32_t checksum;       // CRC32 校验值
    uint32_t signature;      // 签名(可选)
    uint32_t hw_id;          // 硬件平台ID
    uint32_t timestamp;      // 编译时间戳
    uint8_t  reserved[8];    // 保留字段,对齐到32字节
} image_header_t;

这里有几个关键点,我踩过坑:

  • 魔数一定要选好。别用 0x00000000 或 0xFFFFFFFF,Flash 擦除后的默认值就是这些,容易误判。我一般用 0xA5A5A5A5 或 0xFEEDBEEF。
  • 长度字段要包含头本身。不然 Bootloader 算总大小的时候会少算 32 字节,导致拷贝不完整。
  • 保留字段别空着。我习惯在里面放一个“固件类型”标记,比如 0x01 表示完整包,0x02 表示差分升级包。这样以后扩展不用改结构体大小。
注意:镜像头的大小必须是 4 字节对齐的。很多 MCU 的 DMA 控制器要求源地址和长度对齐到 4 字节。我曾经因为头大小是 30 字节,导致 DMA 搬运时最后一个字只搬了 2 字节,数据直接错位。血的教训。

4.3 CRC 校验——别让数据“悄悄”变坏

固件在传输或存储过程中,一个 bit 翻转就可能让设备变砖。CRC 就是用来检测这种错误的。

我推荐使用 CRC32,特别是硬件 CRC 模块支持的版本。为什么?

  • CRC8 太弱,碰撞概率高。
  • CRC16 勉强够用,但工业级场景不推荐。
  • CRC32 是业界标准,ZIP、以太网都在用。

计算范围要注意:CRC 覆盖的是镜像头之后的所有数据,还是整个镜像(含头)?

我的做法是:头里的 checksum 字段先填 0,然后计算从镜像头开始到文件末尾的 CRC32,再把结果写回 checksum 字段。这样 Bootloader 校验时,把整个镜像读进来,重新算一次 CRC32,如果结果等于 0,说明数据完整。

// 计算整个镜像的 CRC32(含头,但头内 checksum 字段为 0)
uint32_t calculate_image_crc(uint8_t *image, uint32_t len) {
    image_header_t *hdr = (image_header_t *)image;
    uint32_t saved_checksum = hdr->checksum;
    hdr->checksum = 0;  // 先清零
    uint32_t crc = crc32_hw(image, len);
    hdr->checksum = saved_checksum;  // 恢复
    return crc;
}
避坑指南:我曾经在 STM32F4 上用过软件 CRC32,结果发现 OTA 升级时每次校验要花 3 秒。后来切到硬件 CRC 模块,时间降到 200 毫秒。如果你的 MCU 有硬件 CRC,一定要用。不仅快,而且结果和标准 CRC32 完全兼容。

4.4 签名——防篡改的最后一道防线

CRC 只能检测随机错误,防不了恶意篡改。如果有人故意修改了你的固件,CRC 照样能算对——因为他可以重新算一个 CRC 填进去。

这时候就需要签名。

我常用的方案是 RSA-2048 + SHA256:

  1. 打包时,先算整个镜像的 SHA256 哈希值。
  2. 用私钥对这个哈希值做 RSA 签名,得到 256 字节的签名数据。
  3. 把签名写入镜像头的 signature 字段(或者追加在镜像末尾)。
  4. Bootloader 启动时,用公钥验签。验签通过才允许启动。

这里有个实际工程问题:公钥放哪?

  • 放 Bootloader 里?可以,但 Bootloader 本身不能 OTA 更新,一旦密钥泄露就得换芯片。
  • 放芯片 OTP(一次性可编程)区域?更安全,但产线要多一道工序。
  • 我个人的做法:公钥哈希值放 OTP,公钥本体放 Bootloader。启动时先校验公钥的哈希是否匹配 OTP 里的值,再用公钥验签。这样既保证了公钥不可篡改,又能在必要时通过升级 Bootloader 来更换密钥对。
重要:签名不能替代 CRC。CRC 是轻量级校验,每次启动都做,耗时短。签名计算量大,只在关键节点(如 OTA 升级完成后)做一次。两者是互补关系,不是替代关系。

4.5 一张图看懂整个流程

说了这么多,我们来画一张图,把 Bin/Hex/ELF 的转换、镜像头的组装、CRC 和签名的关系串起来:

固件打包与校验流程 ELF 文件 Hex 文件 Bin 文件 打包工具 objcopy + 自定义脚本 镜像头 魔数 | 版本 | 长度 CRC32 | 签名 | HW_ID 时间戳 | 保留 CRC32 校验计算 RSA 签名 签名结果写回镜像头 最终固件包 (.bin) 注意 CRC 覆盖整个镜像(含头) 签名只对哈希值签名

从这张图可以看得很清楚:ELF/Hex/Bin 是输入,经过打包工具加上镜像头,再经过 CRC 和签名,最终生成一个带“身份证”和“防伪标签”的固件包。Bootloader 拿到这个包,先验 CRC,再验签名,全部通过才敢启动。

4.6 实际工程中的选择建议

最后,我根据实际项目经验,给几个具体建议:

  • 开发阶段:用 ELF 文件 + GDB 直接加载调试,别折腾格式转换。
  • 产线烧录:用 .bin 文件,配合镜像头里的 HW_ID 做防错。我见过产线拿错固件烧进去,整批板子报废的案例。
  • OTA 升级:必须用 .bin,而且一定要加签名。CRC 只能防误码,防不了攻击。
  • 跨平台兼容:如果你的固件要在不同 MCU 之间迁移,镜像头里一定要留足保留字段。我吃过这个亏,后来加字段导致 Bootloader 不兼容旧固件,只能强制全网升级。
一句话总结:Bin 是最终交付物,Hex 是中间格式,ELF 是调试工具。镜像头是固件的身份证,CRC 是健康检查,签名是防伪标签。三者缺一不可。