4. 固件镜像格式:二进制镜像、ELF与HEX格式、镜像头部结构
说到固件升级,第一个绕不开的话题就是——固件镜像到底长什么样?
我刚开始做嵌入式那会儿,拿到一个.bin文件就直接往Flash里烧,从来没想过里面是什么结构。直到有一次产品量产,发现一批设备升级后变砖了,排查了三天才发现是镜像格式搞错了。嗯,从那以后我再也不敢小看这个环节了。
这一章,我们就来把固件镜像的几种常见格式、它们的内部结构,以及头部设计的关键点讲清楚。你想想看,搞懂了这些,后面做升级方案的时候心里就有底了。
4.1 三种主流镜像格式:Bin、HEX、ELF
嵌入式世界里,最常见的固件镜像格式就这三种。它们各有各的用途,也各有各的坑。
| 格式 | 全称 | 典型后缀 | 是否包含元数据 | 是否可执行 | 典型用途 |
|---|---|---|---|---|---|
| 二进制镜像 | Raw Binary | .bin | 否 | 否(需加载到指定地址) | 量产烧录、OTA升级包 |
| HEX格式 | Intel HEX | .hex | 是(地址、校验) | 否 | 烧录器、调试器传输 |
| ELF格式 | Executable and Linkable Format | .elf / .out | 是(符号表、段信息、调试信息) | 是 | 编译输出、调试、链接 |
4.1.1 二进制镜像(Raw Binary)
说白了,二进制镜像就是固件最原始的样子——从Flash起始地址开始,一个字节一个字节地排列,没有任何多余信息。你把它读到内存里,PC指针指过去,CPU就开始执行了。
优点:
- 体积最小,没有冗余数据
- 解析最简单,直接memcpy就能加载
- 适合OTA传输,省带宽
缺点:
- 没有地址信息,你必须知道它该烧到哪个地址
- 没有校验,传输过程中出错了你也不知道
- 没有符号表,出问题了很难定位
4.1.2 Intel HEX格式
HEX格式是文本形式的固件,每一行都是一条记录。它包含了地址信息和校验,所以烧录器可以根据地址把数据放到正确的位置。
一个典型的HEX文件长这样:
:10000000C0001E0000000000000000000000000000F0
:100010000000000000000000000000000000000000E0
:100020000000000000000000000000000000000000D0
:00000001FF
每一行的结构:
- 起始码:冒号(:)
- 字节长度:1字节,表示本行数据字节数
- 地址:2字节,数据加载的起始地址
- 记录类型:1字节(00=数据,01=文件结束,02=扩展段地址,04=扩展线性地址)
- 数据:N字节
- 校验和:1字节,前面所有字节之和取补码
4.1.3 ELF格式
ELF是编译器的最终输出格式,包含了所有段信息、符号表、调试信息。它本身是可以被操作系统加载执行的,但在裸机MCU上,我们通常只取其中的代码段和数据段。
ELF文件的核心结构:
- ELF头部:魔数、架构、入口点、段表偏移等
- 程序头表:描述如何加载到内存
- 节头表:描述各个段(.text, .data, .bss等)
- 段数据:实际的代码和数据
我建议在开发阶段保留ELF文件,方便调试。但到了发布阶段,一定要转成BIN或者HEX。为什么?因为ELF里包含了大量调试信息,体积可能是BIN的10倍以上,而且直接烧录ELF到Flash可能会把符号表也写进去,浪费空间。
4.2 镜像头部结构:给固件加个身份证
好了,现在我们知道该用BIN格式做升级包了。但裸BIN有个问题——你怎么知道这个固件是给哪个设备用的?版本对不对?有没有被篡改过?
所以我们需要在BIN数据前面加一个头部(Header),就像给固件办了一张身份证。
一个典型的固件头部长这样:
typedef struct {
uint32_t magic; // 魔数,用于识别固件格式
uint32_t version; // 固件版本号
uint32_t length; // 固件数据长度
uint32_t checksum; // 校验和
uint32_t reserved[4]; // 保留字段,用于扩展
uint8_t signature[256]; // 数字签名(可选)
// 后面跟着固件数据
} firmware_header_t;
4.2.1 Magic Number(魔数)
魔数就是一个固定的标识符,用来告诉Bootloader:嘿,这是个合法的固件镜像!
常见的做法是用4个字节的ASCII字符,比如:
0x4649524D→ "FIRM"0x554E494F→ "UNIO"0xDEADBEEF→ 经典调试用魔数
我在项目中习惯用公司缩写+产品代号,比如0x544C4D31("TLM1"),这样一眼就能看出是哪个产品的固件。
4.2.2 版本号
版本号的设计看似简单,但坑不少。我见过有人用一个uint32_t表示版本号,结果升级时发现新版本号比旧版本号小,导致降级判断出错。
推荐的做法:
- 主版本号:8位,重大架构变更
- 次版本号:8位,功能新增
- 修订号:16位,Bug修复
或者直接用时间戳:
uint32_t version = (year << 16) | (month << 8) | day;
我个人更倾向于用递增的版本号,因为比较大小方便。时间戳版本号有个问题——如果同一天发布了两个版本,版本号就一样了。
4.2.3 校验和(Checksum)
校验和用来检测固件在传输或存储过程中有没有损坏。常用的算法有:
| 算法 | 输出长度 | 速度 | 安全性 | 推荐场景 |
|---|---|---|---|---|
| CRC32 | 4字节 | 快 | 低(可碰撞) | 一般固件校验 |
| MD5 | 16字节 | 中等 | 中等(已不推荐安全场景) | OTA完整性校验 |
| SHA256 | 32字节 | 慢 | 高 | 安全升级、签名验证 |
| 简单累加和 | 1-4字节 | 极快 | 极低 | 快速检测(不推荐) |
4.3 镜像签名:给固件加个防伪标签
校验和只能检测数据是否损坏,但无法防止恶意篡改。如果有人截获了你的升级包,改了里面的代码,重新计算CRC,你的设备就会执行恶意固件。
这就是签名的作用。
签名的基本流程:
- 生成密钥对:私钥保存在你的构建服务器上,公钥烧录在设备里
- 计算哈希:对固件数据计算SHA256哈希
- 签名:用私钥对哈希值进行RSA或ECDSA签名
- 打包:将签名附加到固件头部
- 验证:设备收到固件后,用公钥验证签名
一个带签名的固件头部结构:
typedef struct {
uint32_t magic; // 魔数
uint32_t version; // 版本号
uint32_t length; // 固件长度
uint32_t checksum; // CRC32校验
uint8_t hash[32]; // SHA256哈希
uint8_t signature[256]; // RSA-2048签名
uint32_t sig_len; // 签名长度
uint8_t reserved[8]; // 保留
} secure_firmware_header_t;
4.4 知识体系总览
下面这张图把本章的核心内容串起来了,你可以对照着回顾一下:
4.5 实战建议:如何选择镜像格式
讲了这么多,最后给几个实际项目中的建议:
- 开发阶段:用ELF,方便调试和符号定位
- 本地烧录:用HEX,烧录器自动处理地址映射
- OTA升级:用BIN + 自定义头部,体积小、传输快
- 量产烧录:用BIN,配合烧录工具直接写入Flash
- 安全要求高:BIN + 签名,头部里放SHA256和RSA签名