2、升级包结构设计:升级包文件格式、元数据定义、分区表设计、版本号管理策略
好,咱们接着聊升级包的结构设计。这一块儿,说白了就是定规矩——你的升级包长什么样,里面装了什么,怎么装,装到哪儿。规矩定好了,后面做签名、做校验、做升级流程,才能顺风顺水。
我见过不少团队,一上来就急着写升级逻辑,结果升级包结构没想清楚,后面改得死去活来。嗯,咱们先把地基打牢。
2.1 升级包文件格式
升级包本质上就是一个二进制文件。但你不能把固件裸数据直接扔进去,得有个壳。这个壳,我习惯叫它「容器格式」。
常见的做法有两种:
- 自定义格式:自己定义文件头、数据段、尾部校验。灵活,但解析代码得自己写。
- 标准容器格式:比如用 ZIP、TAR、甚至 YMODEM 协议。省事,但可能带一些不必要的开销。
我个人更倾向于自定义格式,尤其是在资源受限的嵌入式设备上。为什么?因为你可以把结构压到最简,解析逻辑也最可控。
一个典型的升级包文件结构,我通常这样设计:
+----------------------------+
| Magic Number (4字节) | // 比如 0x4F5441 ("OTA")
+----------------------------+
| Header Version (1字节) | // 当前头结构版本号
+----------------------------+
| Package Type (1字节) | // 全量包、差分包、增量包
+----------------------------+
| Image Count (2字节) | // 包含多少个镜像文件
+----------------------------+
| Metadata Length (4字节) | // 元数据段长度
+----------------------------+
| Metadata (变长) | // JSON 或 TLV 格式的元数据
+----------------------------+
| Image Entry 1 | // 第一个镜像:头 + 数据
+----------------------------+
| Image Entry 2 | // 第二个镜像
+----------------------------+
| ... |
+----------------------------+
| Package Signature (变长) | // 对整个包的签名
+----------------------------+
| Trailer (4字节) | // 尾部标记,比如 0x454E44 ("END")
+----------------------------+
Magic Number 很重要。我在项目中遇到过,设备上电后误把普通数据当成升级包来解析,结果直接跑飞了。加个 Magic Number,先校验一下,能挡掉大部分误操作。
2.2 元数据定义
元数据就是升级包的「身份证」。它描述了包里装的是什么、从哪来、到哪去、安不安全。
我建议用 TLV(Type-Length-Value) 格式来存元数据。为什么不用 JSON?JSON 解析起来太费内存了,在只有几十 KB RAM 的 MCU 上,你解析一个 JSON 树,内存可能就爆了。TLV 是二进制格式,解析就是指针跳一跳,几乎不占额外内存。
元数据里必须包含哪些字段?我列一下我的「必选清单」:
| 字段 | 类型 | 说明 |
|---|---|---|
| Product ID | uint32 | 产品型号标识,防止刷错固件 |
| Hardware Version | uint32 | 硬件版本,不同硬件可能不兼容 |
| Source Version | uint32 | 当前固件版本(差分包需要) |
| Target Version | uint32 | 目标固件版本 |
| Image Count | uint16 | 镜像数量 |
| Image Table | TLV数组 | 每个镜像的偏移、长度、哈希值 |
| Timestamp | uint32 | 打包时间(Unix时间戳) |
| Signature Algorithm | uint8 | 签名算法标识,比如 0x01 = ECDSA P-256 |
嗯,这里要注意:Source Version 这个字段,很多人会忽略。全量包确实不需要,但如果你要做差分升级,没有源版本号,设备根本不知道这个包能不能用。我曾经见过一个案例,设备收到了一个针对旧版本的差分包,结果打上去之后系统直接变砖。就是因为没校验源版本。
2.3 分区表设计
分区表,就是告诉升级程序:固件该写到哪儿,备份在哪儿,配置在哪儿。
嵌入式设备上,我推荐用 A/B 分区方案。说白了就是准备两份固件区:A 区和 B 区。当前运行在 A 区,升级就写到 B 区。升级完成后切换启动分区。如果 B 区启动失败,还能回滚到 A 区。
一个典型的分区表长这样:
+------------------+------------------+
| 分区名称 | 起始地址 / 大小 |
+------------------+------------------+
| Bootloader | 0x08000000 / 64KB|
| Partition Table | 0x08010000 / 4KB |
| Firmware A | 0x08011000 / 1MB |
| Firmware B | 0x08111000 / 1MB |
| Config A | 0x08211000 / 64KB|
| Config B | 0x08221000 / 64KB|
| Factory Reset | 0x08231000 / 64KB|
+------------------+------------------+
你想想看,为什么 Config 也要分 A/B?因为固件升级后,配置格式可能变了。如果新固件用旧配置,可能解析出错。所以配置也要跟着固件版本走。
分区表本身怎么存?我建议用 固定结构体 存到 Flash 的固定地址。不要用链表,不要用动态分配。嵌入式系统里,简单就是可靠。
2.4 版本号管理策略
版本号这东西,看着简单,但管不好就是灾难。
我见过最离谱的版本号策略是「今天打一个包,明天打一个包,版本号随便写」。结果设备升级后,根本分不清哪个是最新版。
我推荐用 语义化版本号 + 内部构建号 的组合策略:
- 主版本号:不兼容的 API 修改,或者重大功能变更。
- 次版本号:向下兼容的功能新增。
- 修订号:向下兼容的问题修正。
- 构建号:自动递增,每次构建唯一。
举个例子:2.1.3.456,表示主版本 2,次版本 1,修订 3,第 456 次构建。
在升级包的元数据里,我建议存两个版本号:
- 固件版本号:语义化版本,给人看的。
- 兼容性掩码:给机器看的。比如用位图表示兼容哪些主版本。
为什么要加兼容性掩码?因为光靠版本号比较,你没法判断两个版本是否兼容。比如 2.1.0 和 2.2.0,次版本不同,但可能完全兼容。而 2.0.0 和 3.0.0,主版本不同,但可能只是加了个新功能,也兼容。所以,让开发者在打包时手动指定兼容性,比自动推断要靠谱得多。
最后,提一个我踩过的坑:版本号不要用字符串比较。比如 "2.1.3" 和 "2.1.13",字符串比较会认为 "2.1.13" 小于 "2.1.3",因为 '1' 小于 '3'。一定要转成整数再比较,或者用固定位数补齐。
好了,升级包结构这块儿,核心就是这些。文件格式定好容器,元数据写清楚身份,分区表规划好地盘,版本号管好兼容性。四件事做好,升级包的骨架就立起来了。下一节,咱们聊聊签名和校验,给这个骨架加上「安全锁」。