4. 固件包格式设计:固件头结构、CRC校验、版本号管理

好,咱们接着聊固件升级。前面几章把升级流程和存储分区讲清楚了,但有个关键问题还没解决——固件包本身长什么样?

你想想看,MCU收到一个二进制文件,它怎么知道这文件是不是给它的?版本对不对?传输过程中有没有损坏?

嗯,这就需要我们给固件包设计一个「身份证」——也就是固件头结构。再加上CRC校验保证数据完整性,版本号管理确保升级不乱套。

这一章,我就把这三块内容揉碎了讲给你听。

4.1 固件头结构:给固件办张身份证

我个人习惯,设计固件头时遵循一个原则:固定长度、顺序解析、冗余校验

什么叫固定长度?就是不管固件包多大,头部永远是那么多字节。这样解析代码写起来简单,不容易出bug。

下面是我在血糖仪项目里用过的固件头结构,你可以直接参考:

// 固件头结构定义(共32字节)
typedef struct {
    uint32_t magic;          // 魔数,固定为0x474C5543 ("GLUC")
    uint32_t version;        // 版本号,高16位主版本,低16位次版本
    uint32_t firmware_size;  // 固件数据区大小(不含头部)
    uint32_t crc32;          // 固件数据区的CRC32校验值
    uint32_t timestamp;      // 编译时间戳(Unix时间戳)
    uint16_t device_type;    // 设备类型,0x01=血糖仪A型,0x02=血糖仪B型
    uint16_t header_crc;     // 头部自身的CRC16校验
    uint8_t  reserved[8];    // 保留字节,用于未来扩展
} firmware_header_t;

这里有几个关键点,我重点说一下:

  • 魔数(Magic):说白了就是个固定标记。MCU收到数据后先读前4个字节,如果对不上0x474C5543,直接扔掉。我在项目中遇到过有人把别的设备的固件包刷进来,魔数一比对就拦住了。
  • 设备类型:这个字段容易被忽略。同一家公司可能有好几款血糖仪,固件不能混用。加上设备类型,升级程序就能判断「这包是不是给我的」。
  • 保留字节:一定要留!我吃过这个亏。第一次设计没留保留位,后来要加个「强制升级标志」,结果只能改整个结构,兼容性全乱了。

重要原则:固件头一旦发布,结构就不要再改了。真要改,就留保留字节做扩展。

4.2 CRC校验:别让坏数据害了设备

固件升级最怕什么?最怕传输过程中数据出错,MCU刷了个坏固件进去,设备变砖。

CRC校验就是干这个的。它不像MD5、SHA那么复杂,计算量小,非常适合嵌入式环境。

我建议做双重校验

  1. 头部CRC16:校验固件头本身是否完整
  2. 数据区CRC32:校验整个固件数据是否完整

为什么头部用CRC16,数据区用CRC32?

头部只有32字节,CRC16够用了,计算快。数据区可能几百KB,用CRC32更保险。说白了就是性价比——用最小的代价达到足够的安全等级。

下面是我常用的CRC32计算代码,查表法,速度很快:

// CRC32查表法实现
static const uint32_t crc32_table[256] = {
    0x00000000, 0x77073096, 0xEE0E612C, 0x990951BA,
    // ... 省略中间表项,实际代码中需要完整256项
};

uint32_t calculate_crc32(const uint8_t *data, uint32_t length) {
    uint32_t crc = 0xFFFFFFFF;
    
    for (uint32_t i = 0; i < length; i++) {
        uint8_t index = (crc ^ data[i]) & 0xFF;
        crc = (crc >> 8) ^ crc32_table[index];
    }
    
    return crc ^ 0xFFFFFFFF;
}

小技巧:CRC计算时,初始值和最终异或值都用0xFFFFFFFF。这是CRC-32标准做法,和很多上位机工具兼容。

我曾经遇到过一个坑:上位机用Python的binascii.crc32()算出来的值,和MCU对不上。后来发现是Python默认用CRC-32,但初始值是0,而MCU代码初始值是0xFFFFFFFF。统一标准后问题就解决了。

4.3 版本号管理:别让升级乱了套

版本号看着简单,但管理不好会出大问题。

我见过最离谱的情况:某设备固件版本从V1.2直接跳到V2.0,中间跳过了V1.3到V1.9。结果升级程序判断「V2.0 > V1.2,可以升级」,但实际V2.0的存储布局和V1.2完全不兼容,刷完就死机。

所以版本号管理要遵循几个原则:

  • 语义化版本:主版本.次版本.修订号(如1.2.3)
  • 主版本不同:表示不兼容的变更,需要特殊处理
  • 次版本不同:表示功能新增,向下兼容
  • 修订号不同:表示bug修复,完全兼容

在固件头里,我习惯用一个32位整数表示版本号:

// 版本号编码:高16位主版本,低16位次版本
// 修订号单独用一个字节表示(放在保留字段里)
#define MAKE_VERSION(major, minor) (((major) << 16) | (minor))
#define GET_MAJOR(ver) ((ver) >> 16)
#define GET_MINOR(ver) ((ver) & 0xFFFF)

// 版本比较函数
bool is_version_newer(uint32_t new_ver, uint32_t old_ver) {
    // 主版本不同时,只允许递增
    if (GET_MAJOR(new_ver) != GET_MAJOR(old_ver)) {
        return GET_MAJOR(new_ver) > GET_MAJOR(old_ver);
    }
    // 主版本相同,比较次版本
    return GET_MINOR(new_ver) > GET_MINOR(old_ver);
}

警告:版本比较不能简单用数值大小判断。比如V1.10和V1.2,数值上1.10 < 1.2,但实际V1.10应该比V1.2新。所以一定要按「主版本.次版本」分段比较。

4.4 完整的固件包结构

把上面三块内容合起来,一个完整的固件包长这样:

偏移地址 内容 大小 说明
0x0000 固件头 32字节 包含魔数、版本、CRC等
0x0020 固件数据 N字节 实际的二进制固件代码
0x0020+N 尾部CRC 4字节 可选,对整个包再做一次CRC32

升级程序收到固件包后,解析流程是这样的:

  1. 读取前4字节,检查魔数
  2. 读取整个头部,计算头部CRC16,和头部的header_crc比对
  3. 从头部解析出版本号、设备类型,检查是否匹配
  4. 读取固件数据区,计算CRC32,和头部的crc32比对
  5. 全部通过后,才开始写入Flash

每一步校验都通过,才允许升级。任何一个环节失败,直接报错退出。

核心思想:校验前置,失败早退。不要在写入一半Flash后发现数据有问题,那时候恢复起来就麻烦了。

好了,固件包格式设计就讲到这里。下一章我们聊聊升级过程中的异常处理与回滚机制——毕竟,再好的设计也挡不住意外,关键是怎么从意外中安全恢复。