2. 固件版本管理:版本号规范、存储结构与兼容性检查

版本管理这事儿,看着简单,做起来全是坑。我早年吃过不少亏——有一次现场升级,就因为版本号没对齐,整批设备变砖,差点被客户拉黑。从那以后,我对版本管理就特别较真。

说白了,固件版本管理就三件事:怎么编号、怎么存、怎么判断能不能升。咱们一个一个说。

2.1 语义化版本号规范

版本号不是随便写的。我个人习惯用语义化版本(Semantic Versioning),格式是:

主版本号.次版本号.修订号
例如:2.1.3

规则其实很简单:

  • 主版本号:不兼容的API变更。比如协议改了、数据结构变了,老固件和新固件没法互通。
  • 次版本号:向下兼容的功能新增。加了新功能,但老功能照常工作。
  • 修订号:向下兼容的问题修复。修了个bug,功能没变。

我在项目中遇到过一种情况:研发团队把版本号写成 V20250301,看着像日期,但根本看不出兼容关系。结果现场升级时,A版本能升到B版本,B版本却升不回A版本,乱成一锅粥。

核心原则:版本号必须能表达兼容性信息。日期、流水号都不行。

实际项目中,我还会加一个构建号内部版本号,比如 2.1.3.456。最后一位是构建计数,方便定位具体是哪次编译的固件。

2.2 版本存储结构

版本信息存在哪儿?怎么存?我见过最糙的做法——把版本号写死在代码里。每次改版本都要重新编译,万一编译环境不一样,版本号对不上,调试起来想死的心都有。

我建议的做法是:把版本信息放在固件镜像的固定偏移位置,用一个结构体存起来。

// 版本信息结构体
typedef struct {
    uint8_t  magic[4];      // 魔数,用于校验,比如 "RMCV"
    uint8_t  major;         // 主版本号
    uint8_t  minor;         // 次版本号
    uint16_t patch;         // 修订号
    uint32_t build;         // 构建号
    uint8_t  reserved[8];   // 保留字段,以后扩展用
    uint32_t crc32;         // 整个结构体的CRC校验
} FirmwareVersion_t;

这个结构体放在固件镜像的头部,比如偏移0x100处。这样,不管固件有多大,我只要读前几百字节就能拿到版本信息,不用解压、不用解析整个镜像。

小技巧:魔数用ASCII字符,比如 "RMCV",用十六进制查看器一眼就能认出来。调试时特别方便。

在Flash里,我通常会预留两个区域存版本信息:

  • 当前运行版本区:存当前正在跑的固件版本。每次启动时读这个区,确认固件是否正常。
  • 备份版本区:存上一次成功启动的固件版本。回滚时用。

嗯,这里要注意:Flash写入次数有限,不能每次启动都写版本区。我一般只在升级成功并验证通过后才更新版本区。

2.3 版本兼容性检查

兼容性检查,说白了就是判断「新固件能不能覆盖老固件」。我见过最惨的案例:现场升级时,新固件改了Flash分区表,老固件不认新分区,升级失败后连回滚都做不了。

兼容性检查的核心逻辑是这样的:

// 兼容性检查伪代码
bool check_compatibility(FirmwareVersion_t *old_ver, FirmwareVersion_t *new_ver) {
    // 1. 魔数校验
    if (memcmp(old_ver->magic, "RMCV", 4) != 0) {
        return false;  // 老固件版本信息损坏
    }
    
    // 2. 主版本号检查
    if (new_ver->major != old_ver->major) {
        // 主版本不同,属于不兼容升级
        // 需要特殊处理,比如擦除全部配置参数
        return false;  // 或者返回 true 但标记为"需全量升级"
    }
    
    // 3. 次版本号检查
    if (new_ver->minor < old_ver->minor) {
        // 新固件的次版本号比老固件还低
        // 这属于降级,需要确认是否允许
        return is_downgrade_allowed();
    }
    
    // 4. 修订号检查
    if (new_ver->patch < old_ver->patch) {
        // 修订号回退,一般不允许
        return false;
    }
    
    return true;
}

我曾经踩过的坑:兼容性检查不能只看版本号大小。比如从 2.0.0 升到 3.0.0,主版本变了,但实际协议没改。反过来,从 2.0.0 升到 2.1.0,次版本只加了一位,但Flash布局全改了。所以,版本号只是参考,真正的兼容性信息要放在固件头部的兼容性掩码里。

我现在的做法是加一个兼容性掩码字段:

typedef struct {
    uint8_t  magic[4];
    uint8_t  major;
    uint8_t  minor;
    uint16_t patch;
    uint32_t build;
    uint32_t compat_mask;   // 兼容性掩码
    // ... 其他字段
} FirmwareVersion_t;

兼容性掩码的每一位代表一个特性:

  • bit0:Flash分区布局是否变化
  • bit1:通信协议是否变化
  • bit2:配置参数结构是否变化
  • bit3:加密算法是否变化

升级时,新固件和老固件的掩码做按位与,如果结果不等于老固件的掩码,说明有特性不兼容,需要特殊处理。

你想想看,有了这个掩码,兼容性检查就不再是简单的数字比较了,而是特性级别的精确匹配。这才是工程上靠谱的做法。

2.4 实战建议

最后,给几个我踩过坑后总结的建议:

  1. 版本号从 1.0.0 开始,不要从 0.1.0 开始。0.x 版本容易让人误解为测试版。
  2. 每次发布都更新版本号,哪怕只改了一行注释。不更新版本号,你永远不知道现场跑的是哪个版本。
  3. 版本信息结构体要预留扩展空间。我吃过亏——结构体定死了,后来想加字段,老固件不认新结构,升级兼容性检查直接崩了。
  4. 兼容性检查失败时,要有明确的错误码。不要只返回 true/false,要告诉现场工程师「是主版本不兼容」还是「Flash分区变了」。

一句话总结:版本管理不是写个数字就完事了。它是固件升级的第一道安全防线。版本号规范、存储结构合理、兼容性检查严谨,这三件事做好了,升级事故能减少八成。

固件版本管理核心逻辑 版本号规范 主版本.次版本.修订号 语义化版本 版本存储结构 固件头部固定偏移 魔数+版本号+CRC 兼容性检查 主版本/次版本/修订号 兼容性掩码匹配 三者关系 版本号规范 → 定义存储格式 → 存储结构提供数据 → 兼容性检查使用版本号 三者缺一不可,共同构成固件升级的安全防线
专注资料整理
专注资料整理