4. 版本号管理策略:全局版本号 vs 分区版本号、版本号存储位置选择、版本号验证流程
版本号这东西,看着简单,其实坑特别多。我早年做一款IoT网关时,就因为在版本号上偷了个懒,差点导致整批设备变砖。那次之后,我对版本号管理就再也不敢马虎了。
说白了,版本号就是固件的「身份证」。防回滚的核心逻辑,就是靠它来判断新固件是不是比当前固件老。但问题来了——这个身份证该怎么设计?存哪里?怎么验证?
4.1 全局版本号 vs 分区版本号
先聊聊最基础的选择题:用一个版本号管整个固件,还是给每个分区单独设版本号?
全局版本号,就是整个固件镜像只有一个递增的数字。比如 V1.0、V2.0、V3.0。每次升级,整个镜像一起替换,版本号一起更新。
这种做法简单粗暴,适合小型MCU项目。我在做智能插座时就用过,Flash总共才256KB,分两个区做A/B切换,版本号写在固定偏移位置。代码量少,逻辑清晰。
但全局版本号有个硬伤——颗粒度太粗。你想想看,如果只更新了Bootloader,应用层没动,版本号要不要加?加了,应用层其实没变;不加,Bootloader的漏洞又没修。两难。
分区版本号就灵活多了。每个独立分区都有自己的版本号,比如:
- Bootloader 分区:v1.2
- 固件A 分区:v3.5
- 固件B 分区:v3.4
- 配置参数分区:v1.0
每个分区独立管理版本,互不干扰。升级时只替换需要更新的分区,版本号也只检查对应分区。
我做过一个车载T-Box项目,就用了分区版本号。Bootloader几乎不改,但应用层迭代很快。如果每次升级都要改全局版本号,Bootloader也得跟着刷,风险太大了。分区管理后,Bootloader只检查自己的版本,应用层随便折腾。
| 对比维度 | 全局版本号 | 分区版本号 |
|---|---|---|
| 实现复杂度 | 低,一个变量搞定 | 高,需要管理多个版本号 |
| 升级灵活性 | 差,必须整体替换 | 好,支持分区独立升级 |
| 防回滚粒度 | 粗,整个固件一起防 | 细,每个分区单独控制 |
| 存储开销 | 小,4字节足够 | 大,每个分区至少4字节 |
| 适用场景 | 小MCU、简单产品 | 复杂系统、多分区架构 |
我的建议:如果Flash空间充裕,优先选分区版本号。虽然实现复杂一点,但后期维护成本低很多。我曾经在一个项目里从全局版本号改成分区版本号,改得我头皮发麻——所以一开始就选对,比后面重构强百倍。
4.2 版本号存储位置选择
版本号存哪里?这个问题看似简单,但选错了地方,后果很严重。
常见的存储位置有这几个:
- 固件镜像头部:版本号写在固件二进制文件的前几个字节里
- 专用存储区域:在Flash里划出一块独立区域,专门存版本号
- OTP(一次性可编程)区域:写进去就不能改,用于记录最高版本
- 外部存储:比如EEPROM、外部Flash
我个人习惯把版本号同时放在两个地方:固件头部 + 专用存储区。为什么?
固件头部的版本号,用于升级前的快速校验。Bootloader读取新固件时,先看头部版本号,如果比当前版本低,直接拒绝,不用继续往下读。省时间,也省Flash擦写寿命。
专用存储区的版本号,用于记录当前运行固件的版本。这个区域通常放在Flash的末尾扇区,单独管理,不会被固件升级意外覆盖。
我踩过一个坑:某次升级时,固件写入一半断电了。重启后,Bootloader检查固件头部的版本号——没问题,但实际固件是坏的。从那以后,我就在专用存储区里多存了一份「已确认可运行的版本号」。每次固件启动成功后,才把这个版本号更新到专用区。这样即使升级失败,回滚时也能知道哪个版本是好的。
注意:不要把版本号只存在固件镜像里。如果固件被擦除了,版本号也跟着没了。你拿什么来判断该不该回滚?
4.3 版本号验证流程
好了,版本号选好了,位置也定了。接下来就是验证流程——这是防回滚的最后一道防线。
标准的验证流程是这样的:
- 读取当前版本号:从专用存储区读出当前固件的版本号
- 读取新固件版本号:从新固件镜像头部读出版本号
- 比较版本号:新版本号 > 当前版本号?继续。否则,拒绝升级。
- 写入新固件:通过比较后,才允许写入Flash
- 启动验证:新固件启动后,再次确认版本号是否正确写入
- 更新存储区版本号:确认无误后,更新专用存储区的版本号
这里有个细节:版本号比较必须是单调递增的。不能只比较主版本号,还要比较次版本号、修订号。我一般用32位无符号整数,高位做主版本,中位做次版本,低位做修订号。这样一次比较就能搞定。
// 版本号编码示例
#define MAKE_VERSION(major, minor, patch) \
(((major) << 24) | ((minor) << 16) | (patch))
uint32_t current_ver = read_version_from_storage();
uint32_t new_ver = read_version_from_image_header();
if (new_ver <= current_ver) {
// 拒绝升级,触发回滚
rollback_to_previous();
return ERROR_ROLLBACK;
}
// 继续升级流程...
我曾经遇到过一个奇葩问题:版本号比较用的是有符号整数,结果版本号从0x7FFFFFFF升级到0x80000000时,直接判定为降级。因为0x80000000在有符号数里是负数。从那以后,我所有版本号都用无符号整数,比较逻辑也统一用无符号比较。
核心要点:版本号验证必须在Bootloader里做,不能在应用层做。应用层可能被篡改,但Bootloader是只读的,相对安全。如果让应用层自己检查自己,那防回滚就是个笑话。
下面这张图展示了完整的版本号验证流程:
这个流程看起来简单,但实际落地时要注意几个细节:
- 版本号比较必须在Bootloader里做,不能在应用层。应用层可能被篡改,Bootloader相对安全。
- 版本号要防篡改。我一般会用CRC或哈希对版本号做校验,防止有人直接改Flash里的版本号。
- 回滚后要记录日志。每次回滚都写一条日志,方便后期排查问题。
避坑指南:我曾经遇到过一种攻击方式——攻击者把版本号改成0xFFFFFFFF,然后刷入旧固件。因为0xFFFFFFFF比任何正常版本号都大,比较逻辑直接放行了。解决方案:版本号不能全0或全1,要留出保留值。我一般把0x00000000和0xFFFFFFFF都作为非法版本号处理。
好了,版本号管理这块就聊到这儿。记住一句话:版本号不是用来好看的,是用来保命的。设计时多想一步,生产时就少踩一个坑。