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,先校验一下,能挡掉大部分误操作。

小技巧:Magic Number 最好选一个可读的 ASCII 字符串,比如 "OTA1"、"FWUP"。调试的时候用十六进制查看器一眼就能认出来。

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?因为固件升级后,配置格式可能变了。如果新固件用旧配置,可能解析出错。所以配置也要跟着固件版本走。

避坑指南:我曾经在分区表里犯过一个低级错误——把分区表本身放在了 Bootloader 内部。结果升级 Bootloader 时,分区表也被覆盖了,整个设备变砖。后来我把分区表单独划了一个固定区域,Bootloader 只读不写,才彻底解决。

分区表本身怎么存?我建议用 固定结构体 存到 Flash 的固定地址。不要用链表,不要用动态分配。嵌入式系统里,简单就是可靠。

2.4 版本号管理策略

版本号这东西,看着简单,但管不好就是灾难。

我见过最离谱的版本号策略是「今天打一个包,明天打一个包,版本号随便写」。结果设备升级后,根本分不清哪个是最新版。

我推荐用 语义化版本号 + 内部构建号 的组合策略:

  • 主版本号:不兼容的 API 修改,或者重大功能变更。
  • 次版本号:向下兼容的功能新增。
  • 修订号:向下兼容的问题修正。
  • 构建号:自动递增,每次构建唯一。

举个例子:2.1.3.456,表示主版本 2,次版本 1,修订 3,第 456 次构建。

在升级包的元数据里,我建议存两个版本号:

  1. 固件版本号:语义化版本,给人看的。
  2. 兼容性掩码:给机器看的。比如用位图表示兼容哪些主版本。

为什么要加兼容性掩码?因为光靠版本号比较,你没法判断两个版本是否兼容。比如 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'。一定要转成整数再比较,或者用固定位数补齐。

好了,升级包结构这块儿,核心就是这些。文件格式定好容器,元数据写清楚身份,分区表规划好地盘,版本号管好兼容性。四件事做好,升级包的骨架就立起来了。下一节,咱们聊聊签名和校验,给这个骨架加上「安全锁」。