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那么复杂,计算量小,非常适合嵌入式环境。
我建议做双重校验:
- 头部CRC16:校验固件头本身是否完整
- 数据区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 |
升级程序收到固件包后,解析流程是这样的:
- 读取前4字节,检查魔数
- 读取整个头部,计算头部CRC16,和头部的header_crc比对
- 从头部解析出版本号、设备类型,检查是否匹配
- 读取固件数据区,计算CRC32,和头部的crc32比对
- 全部通过后,才开始写入Flash
每一步校验都通过,才允许升级。任何一个环节失败,直接报错退出。
核心思想:校验前置,失败早退。不要在写入一半Flash后发现数据有问题,那时候恢复起来就麻烦了。
好了,固件包格式设计就讲到这里。下一章我们聊聊升级过程中的异常处理与回滚机制——毕竟,再好的设计也挡不住意外,关键是怎么从意外中安全恢复。