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升级包 |
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.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;
}
4.4 签名——防篡改的最后一道防线
CRC 只能检测随机错误,防不了恶意篡改。如果有人故意修改了你的固件,CRC 照样能算对——因为他可以重新算一个 CRC 填进去。
这时候就需要签名。
我常用的方案是 RSA-2048 + SHA256:
- 打包时,先算整个镜像的 SHA256 哈希值。
- 用私钥对这个哈希值做 RSA 签名,得到 256 字节的签名数据。
- 把签名写入镜像头的 signature 字段(或者追加在镜像末尾)。
- Bootloader 启动时,用公钥验签。验签通过才允许启动。
这里有个实际工程问题:公钥放哪?
- 放 Bootloader 里?可以,但 Bootloader 本身不能 OTA 更新,一旦密钥泄露就得换芯片。
- 放芯片 OTP(一次性可编程)区域?更安全,但产线要多一道工序。
- 我个人的做法:公钥哈希值放 OTP,公钥本体放 Bootloader。启动时先校验公钥的哈希是否匹配 OTP 里的值,再用公钥验签。这样既保证了公钥不可篡改,又能在必要时通过升级 Bootloader 来更换密钥对。
4.5 一张图看懂整个流程
说了这么多,我们来画一张图,把 Bin/Hex/ELF 的转换、镜像头的组装、CRC 和签名的关系串起来:
从这张图可以看得很清楚:ELF/Hex/Bin 是输入,经过打包工具加上镜像头,再经过 CRC 和签名,最终生成一个带“身份证”和“防伪标签”的固件包。Bootloader 拿到这个包,先验 CRC,再验签名,全部通过才敢启动。
4.6 实际工程中的选择建议
最后,我根据实际项目经验,给几个具体建议:
- 开发阶段:用 ELF 文件 + GDB 直接加载调试,别折腾格式转换。
- 产线烧录:用 .bin 文件,配合镜像头里的 HW_ID 做防错。我见过产线拿错固件烧进去,整批板子报废的案例。
- OTA 升级:必须用 .bin,而且一定要加签名。CRC 只能防误码,防不了攻击。
- 跨平台兼容:如果你的固件要在不同 MCU 之间迁移,镜像头里一定要留足保留字段。我吃过这个亏,后来加字段导致 Bootloader 不兼容旧固件,只能强制全网升级。