4. 固件包格式设计:Bin 文件、Hex 文件、自定义打包格式(含头部校验)

说到固件升级,第一个要面对的问题就是:你的固件到底长什么样?

说白了,就是怎么把编译出来的二进制数据,打包成一个能让 Bootloader 认得出、解得了、验得过的格式。我见过不少新手,上来就写个 Bootloader,结果发现上位机发下来的文件格式五花八门,解析逻辑写得比升级逻辑还复杂。嗯,这一章咱们就把这事彻底理清楚。

4.1 三种主流固件格式对比

先看一张总览表,心里有个底:

格式 本质 是否含地址信息 是否含校验 适用场景
Bin 纯二进制数据流 否 否 MCU 内部 Flash 直接烧录
Hex (Intel HEX) ASCII 文本 + 地址 + 校验 是 每行校验和 烧录器、调试器、分块烧录
自定义打包格式 头部 + 数据 + 尾部校验 可选 CRC32/SHA256 OTA 升级、安全升级

4.2 Bin 文件:最纯粹的固件

Bin 文件就是编译器输出的原始二进制数据。没有头部,没有地址,没有校验。你给它一个起始地址,它就从那里开始写。

核心特点:

  • 体积最小,没有冗余信息
  • 必须知道烧录起始地址
  • 没有完整性校验,传输错误无法发现

我在项目中遇到过一个问题:用 Bin 文件做 OTA,结果网络传输丢了一个字节,Bootloader 直接写进去,设备变砖。从那以后,我只要用 Bin 文件,一定会在传输层加 CRC 校验。

Bin 文件的生成很简单,以 GCC 为例:

# 从 ELF 生成 Bin 文件
arm-none-eabi-objcopy -O binary firmware.elf firmware.bin

# 查看 Bin 文件大小
ls -l firmware.bin

你想想看,Bin 文件就像一张白纸,上面只有数据。Bootloader 拿到它,必须知道往哪里写。所以通常我们会把起始地址硬编码在 Bootloader 里,或者通过其他方式传递。

4.3 Hex 文件:自带地址和校验

Intel HEX 格式是我个人比较喜欢的中间格式。它把二进制数据转成了 ASCII 文本,每行都包含地址、数据类型和校验和。

先看一个典型的 Hex 行:

:10000000 00 112233445566778899AABBCCDDEEFF F0

拆开来看:

字段 长度 说明
: 1 字节 起始标记
10 1 字节 本行数据长度(16 字节)
0000 2 字节 起始地址偏移
00 1 字节 记录类型(00=数据,01=结束)
1122...FF N 字节 实际数据
F0 1 字节 校验和

Hex 格式有几种记录类型,常用的就两个:

  • 00:数据记录,包含实际固件内容
  • 01:文件结束记录,表示 Hex 文件到此为止

小技巧:Hex 文件的校验和算法很简单:0x100 - (所有字节之和 & 0xFF)。我写 Bootloader 解析 Hex 时,会先校验每行的 checksum,再提取数据。这样能提前发现传输错误。

Hex 文件的解析代码,核心逻辑大概这样:

// 解析一行 Hex 记录
int parse_hex_line(const char *line, uint32_t *addr, uint8_t *data, int *len) {
    if (line[0] != ':') return -1;
    
    int byte_count = hex_to_byte(&line[1]);      // 数据长度
    uint16_t offset = hex_to_word(&line[3]);     // 地址偏移
    int rec_type = hex_to_byte(&line[7]);        // 记录类型
    
    // 校验和检查
    uint8_t checksum = 0;
    for (int i = 1; i < 9 + byte_count * 2; i += 2) {
        checksum += hex_to_byte(&line[i]);
    }
    if (checksum != 0) return -2;  // 校验失败
    
    if (rec_type == 0x00) {
        *addr = offset;
        *len = byte_count;
        for (int i = 0; i < byte_count; i++) {
            data[i] = hex_to_byte(&line[9 + i * 2]);
        }
        return 0;  // 数据记录
    } else if (rec_type == 0x01) {
        return 1;  // 结束记录
    }
    return -3;
}

Hex 格式的好处是:每个烧录器都能解析。但缺点也很明显——体积比 Bin 大 2-3 倍,而且解析起来比 Bin 慢。对于 OTA 升级来说,这可不是什么好事。

4.4 自定义打包格式:为 OTA 而生

做 OTA 升级,我个人强烈建议用自定义打包格式。为什么?因为你可以把版本号、硬件兼容性、校验码、签名等信息全部塞进头部,Bootloader 拿到包的第一时间就能做决策。

这是我常用的打包格式:

+------------------+------------------+------------------+------------------+
|    Magic(4B)     |  Version(4B)     |  HW_ID(4B)       |  PayloadLen(4B)  |
+------------------+------------------+------------------+------------------+
|  HeaderCRC(4B)   |  Reserved(8B)    |                                  |
+------------------+------------------+                                  |
|                              Payload (固件数据)                         |
|                                  ...                                   |
+------------------------------------------------------------------------+
|                          PayloadCRC(4B)                                |
+------------------------------------------------------------------------+

各字段说明:

字段 大小 说明
Magic 4 字节 魔数,用于快速识别固件包(如 0xDEADBEEF)
Version 4 字节 固件版本号,主版本+次版本+修订号
HW_ID 4 字节 硬件兼容性标识,防止刷错设备
PayloadLen 4 字节 固件数据长度
HeaderCRC 4 字节 头部校验(CRC32),保护头部完整性
Reserved 8 字节 保留字段,用于扩展(如签名、加密标志)
Payload N 字节 实际的固件二进制数据
PayloadCRC 4 字节 固件数据校验(CRC32),保护数据完整性

注意:HeaderCRC 只校验头部字段(Magic 到 Reserved),不包含 Payload。这样 Bootloader 可以先校验头部,确认版本和硬件兼容性,再决定要不要继续下载 Payload。我曾经见过一个设计,头部和数据混在一起校验,结果版本号错了还得等整个包下载完才发现,白白浪费带宽。

打包代码示例:

typedef struct {
    uint32_t magic;
    uint32_t version;
    uint32_t hw_id;
    uint32_t payload_len;
    uint32_t header_crc;
    uint8_t  reserved[8];
} __attribute__((packed)) firmware_header_t;

// 打包固件
int pack_firmware(const uint8_t *payload, uint32_t payload_len,
                  uint32_t version, uint32_t hw_id,
                  uint8_t *output, uint32_t *output_len) {
    firmware_header_t header;
    header.magic       = 0xDEADBEEF;
    header.version     = version;
    header.hw_id       = hw_id;
    header.payload_len = payload_len;
    header.header_crc  = 0;  // 先置零
    
    // 计算头部 CRC
    header.header_crc = crc32((uint8_t *)&header, sizeof(header));
    
    // 写入头部
    memcpy(output, &header, sizeof(header));
    uint32_t offset = sizeof(header);
    
    // 写入 Payload
    memcpy(output + offset, payload, payload_len);
    offset += payload_len;
    
    // 写入 Payload CRC
    uint32_t payload_crc = crc32(payload, payload_len);
    memcpy(output + offset, &payload_crc, sizeof(payload_crc));
    *output_len = offset + sizeof(payload_crc);
    
    return 0;
}

4.5 知识体系:固件包格式设计全景

下面这张图,把三种格式的关系和选择逻辑串起来了:

固件包格式设计全景图 固件包格式设计全景图 Bin 文件 Hex 文件 自定义打包格式 纯二进制数据 无地址信息 无校验 体积最小 ASCII 文本格式 每行含地址 行校验和 体积大 2-3 倍 头部 + 数据 + 校验 版本/硬件兼容性 CRC32 完整性校验 可扩展签名/加密 本地烧录 / 量产 调试器 / 分块烧录 OTA 远程升级 建议:OTA 升级优先选择自定义打包格式,兼顾安全性和灵活性 Bootloader 解析流程:校验 Magic → 校验 HeaderCRC → 检查版本/HW_ID → 下载 Payload → 校验 PayloadCRC → 写入 Flash → 重启

4.6 避坑指南

做固件包格式设计,有几个坑我踩过,分享给你:

我曾经犯过的错:

  • 头部结构体对齐问题:不同编译器对结构体的对齐方式不同。我曾在 GCC 和 IAR 之间切换,结果头部解析全乱套。解决方案:强制使用 __attribute__((packed)),或者手动序列化每个字段。
  • 大小端问题:ARM Cortex-M 默认小端,但有些 MCU 是大端。如果上位机是 x86(小端),MCU 也是小端,那没问题。但万一混用,记得统一字节序。
  • Magic 选得太随意:有人用 0x00000000 或 0xFFFFFFFF 做魔数,结果 Flash 擦除后的状态正好匹配,导致误判。我习惯用 0xDEADBEEF 或 0xFEEDFACE 这种不太可能撞车的值。

4.7 小结

三种格式各有千秋。Bin 文件简单直接,适合本地烧录;Hex 文件自带地址和校验,适合调试器;自定义打包格式灵活可控,是 OTA 升级的首选。

我个人建议:量产用 Bin,调试用 Hex,OTA 用自定义格式。别想着一种格式打天下,那只会让你的 Bootloader 变得臃肿不堪。