4. 固件版本管理:版本号编码规则、兼容性策略与回滚机制

版本管理这事儿,说大不大,说小不小。我见过不少团队,前期图省事,版本号随便写个 v1.0、v2.0 就往上怼。结果到了现场,设备升级后出问题,想回退都不知道该退到哪个版本。嗯,今天咱们就把这块彻底聊透。

4.1 版本号编码规则:主版本.次版本.修订号

我个人习惯用 三段式 编码:MAJOR.MINOR.PATCH。说白了就是 主版本.次版本.修订号。这个规则在嵌入式领域非常通用,也符合语义化版本规范。

字段 含义 何时递增
MAJOR 主版本号 不兼容的 API 或协议变更
MINOR 次版本号 向下兼容的功能新增
PATCH 修订号 向下兼容的问题修复

举个例子:v2.3.1 表示主版本 2,次版本 3,第 1 次修订。我在项目中遇到过,有人把修订号从 0 开始,有人从 1 开始。其实都可以,但建议统一从 0 开始,因为 v1.0.0 看起来更像一个正式发布版。

重要原则:版本号一旦发布,就不可修改。如果发现 bug,只能发布新的修订版本,不能回退修改已发布的版本。

你想想看,如果版本号可以随意改,那现场设备到底跑的是哪个固件?根本没法追溯。所以,版本号必须固化在固件二进制文件中,并且可以通过某种方式读取出来。

// 版本号结构体定义示例
typedef struct {
    uint8_t major;
    uint8_t minor;
    uint16_t patch;
    char build_date[12];  // "2024-01-15"
    char build_time[9];   // "14:30:00"
} firmware_version_t;

// 版本号字符串化宏
#define VERSION_STR(major, minor, patch) \
    #major "." #minor "." #patch

// 使用示例
const firmware_version_t g_fw_version = {
    .major = 2,
    .minor = 3,
    .patch = 1,
    .build_date = __DATE__,
    .build_time = __TIME__
};

const char* get_version_string(void) {
    static char buf[32];
    snprintf(buf, sizeof(buf), "v%d.%d.%d-%s-%s",
             g_fw_version.major,
             g_fw_version.minor,
             g_fw_version.patch,
             g_fw_version.build_date,
             g_fw_version.build_time);
    return buf;
}

这里有个小技巧:把编译日期和时间也带进去。我曾经靠这个定位过一个非常隐蔽的 bug——现场设备固件和预期版本对不上,就是因为编译环境时间戳没同步。

4.2 版本兼容性策略

兼容性策略,说白了就是回答一个问题:新固件能不能和旧设备一起工作?

我建议把兼容性分成三个层级来考虑:

  1. 协议兼容性:通信协议、数据格式是否变化
  2. 存储兼容性:Flash 分区、参数配置是否变化
  3. 功能兼容性:新增功能是否影响原有功能

我的经验:在固件中维护一个 兼容性掩码。每个版本都声明自己支持哪些协议版本、存储格式版本。升级时,先检查兼容性掩码,不匹配就直接拒绝升级。

举个例子,假设你的电子标签支持两种协议版本:

// 兼容性掩码定义
#define COMPAT_PROTOCOL_V1  (1 << 0)  // 协议版本1
#define COMPAT_PROTOCOL_V2  (1 << 1)  // 协议版本2
#define COMPAT_STORAGE_V1   (1 << 2)  // 存储格式版本1
#define COMPAT_STORAGE_V2   (1 << 3)  // 存储格式版本2

// 每个固件版本声明自己的兼容性
// v2.3.1 兼容协议V2和存储V2
#define FW_COMPAT_MASK_V2_3_1  (COMPAT_PROTOCOL_V2 | COMPAT_STORAGE_V2)

// v2.2.0 兼容协议V1/V2和存储V1
#define FW_COMPAT_MASK_V2_2_0  (COMPAT_PROTOCOL_V1 | COMPAT_PROTOCOL_V2 | COMPAT_STORAGE_V1)

bool check_compatibility(uint32_t fw_compat_mask, uint32_t device_compat_mask) {
    // 检查固件和设备的兼容性是否有交集
    return (fw_compat_mask & device_compat_mask) != 0;
}

为什么会这样设计?因为现场设备可能已经运行了两年,它的存储格式还是 V1 的。如果你新固件只支持 V2,那升级后设备直接变砖。嗯,这里要注意,向下兼容不是可选项,是必选项

4.3 版本回滚机制

回滚机制,是最后一道防线。我见过最惨的一次,升级后所有电子标签都不亮了,现场工程师急得满头大汗。幸好我们设计了回滚机制,一键恢复到上一个版本,才没酿成大祸。

回滚机制的核心思路是:保留至少两个版本的固件。一个当前运行版本,一个备份版本。

警告:千万不要只保留一个固件分区!如果升级过程中断电或通信中断,设备将无法启动。这就是所谓的「变砖」。

我建议使用 A/B 分区方案

分区 用途 大小
Bootloader 启动加载器,负责选择启动哪个分区 64KB
Slot A 当前运行固件 512KB
Slot B 备份固件(上一个稳定版本) 512KB
Config 配置参数、版本信息、回滚计数 16KB

回滚流程是这样的:

  1. 新固件下载到 Slot B(非当前运行分区)
  2. 校验固件完整性(CRC32 或 SHA256)
  3. 设置启动标志,指向 Slot B
  4. 重启设备,Bootloader 从 Slot B 启动
  5. 如果启动成功,标记 Slot B 为当前运行分区
  6. 如果启动失败(看门狗超时),Bootloader 自动回退到 Slot A
// 回滚控制结构体
typedef struct {
    uint32_t magic;           // 魔数,用于校验结构体有效性
    uint8_t active_slot;      // 当前活动分区:0=Slot A, 1=Slot B
    uint8_t boot_attempts;    // 启动尝试次数
    uint8_t max_attempts;     // 最大尝试次数(通常设为3)
    uint8_t reserved;
    uint32_t fw_version;      // 当前固件版本编码
    uint32_t rollback_count;  // 回滚次数统计
    uint32_t crc32;           // 结构体校验
} boot_config_t;

// Bootloader 中的启动决策逻辑
void bootloader_main(void) {
    boot_config_t *config = (boot_config_t*)CONFIG_ADDR;
    
    // 校验配置有效性
    if (config->magic != BOOT_MAGIC) {
        // 配置损坏,强制从 Slot A 启动
        config->active_slot = 0;
        save_config(config);
    }
    
    // 检查启动尝试次数
    if (config->boot_attempts >= config->max_attempts) {
        // 启动失败次数过多,执行回滚
        config->active_slot = 1 - config->active_slot;
        config->boot_attempts = 0;
        config->rollback_count++;
        save_config(config);
        printf("Rollback to slot %d\n", config->active_slot);
    }
    
    // 增加启动尝试计数
    config->boot_attempts++;
    save_config(config);
    
    // 跳转到对应分区启动
    jump_to_slot(config->active_slot);
}

避坑指南:我曾经遇到过一个问题——回滚后设备反复重启。后来发现是配置参数也跟着回滚了,导致新旧参数不匹配。我的建议是:配置参数单独分区,不要和固件绑在一起。这样回滚固件时,配置参数可以保持不变,或者只回滚到兼容的版本。

另外,回滚次数也要有限制。我一般设一个 max_rollback_count,比如 5 次。超过这个次数,设备就进入安全模式,只接受手动刷机。这是为了防止设备陷入「升级-回滚-再升级-再回滚」的死循环。

最后说一句:版本管理不是写几行代码就完事了。它需要和你的 OTA 升级流程、测试流程、发布流程紧密配合。你想想看,如果测试环境用的版本号和现场不一样,那测试结果还有什么意义?所以,从项目第一天起,就把版本管理规范立起来,后面会省很多事。