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 版本兼容性策略
兼容性策略,说白了就是回答一个问题:新固件能不能和旧设备一起工作?
我建议把兼容性分成三个层级来考虑:
- 协议兼容性:通信协议、数据格式是否变化
- 存储兼容性:Flash 分区、参数配置是否变化
- 功能兼容性:新增功能是否影响原有功能
我的经验:在固件中维护一个 兼容性掩码。每个版本都声明自己支持哪些协议版本、存储格式版本。升级时,先检查兼容性掩码,不匹配就直接拒绝升级。
举个例子,假设你的电子标签支持两种协议版本:
// 兼容性掩码定义
#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 |
回滚流程是这样的:
- 新固件下载到 Slot B(非当前运行分区)
- 校验固件完整性(CRC32 或 SHA256)
- 设置启动标志,指向 Slot B
- 重启设备,Bootloader 从 Slot B 启动
- 如果启动成功,标记 Slot B 为当前运行分区
- 如果启动失败(看门狗超时),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 升级流程、测试流程、发布流程紧密配合。你想想看,如果测试环境用的版本号和现场不一样,那测试结果还有什么意义?所以,从项目第一天起,就把版本管理规范立起来,后面会省很多事。