4、固件镜像管理:镜像版本号、差分升级(Delta OTA)、回滚策略设计
好,咱们接着聊固件升级。前面几章我们把 LoRaWAN 的空中升级流程、协议栈都捋了一遍。但说实话,光能升级还不够,你得把镜像管好。不然设备升级到一半挂了,或者升了个错误版本,那可就麻烦了。
这一章,我重点讲三个核心问题:版本号怎么定、差分升级怎么做、回滚策略怎么设计。这三个点,说白了就是保证你的设备“升得上去、降得回来、不出乱子”。
4.1 镜像版本号:别小看这个数字
版本号看起来简单,不就是 1.0、2.0 嘛?但我在项目里见过太多因为版本号混乱导致的升级事故。比如设备端版本号是“v2.1.3”,服务器端却写成了“v2.1.3_beta”,结果比对失败,设备死活不升级。
我个人习惯用三段式版本号:
| 字段 | 含义 | 示例 |
|---|---|---|
| 主版本号 | 重大架构变更,不兼容 | 2.x.x |
| 次版本号 | 功能新增,向下兼容 | x.3.x |
| 修订号 | Bug 修复,小改动 | x.x.7 |
嗯,这里要注意:版本号必须用纯数字,别加字母或特殊符号。为什么?因为 LoRaWAN 的帧长度有限,你传个“v2.1.3_rc1”过去,光版本号就占了十几个字节,太浪费了。
我的推荐做法:
把版本号编码成一个 32 位整数。比如主版本占 8 位,次版本占 8 位,修订号占 16 位。这样 2.1.3 就变成了 0x02010003。比对时直接比较整数,又快又省空间。
// 版本号编码示例
#define VERSION_MAJOR 2
#define VERSION_MINOR 1
#define VERSION_PATCH 3
// 编码为32位整数
uint32_t version = (VERSION_MAJOR << 24) |
(VERSION_MINOR << 16) |
VERSION_PATCH;
// 解码
uint8_t major = (version >> 24) & 0xFF;
uint8_t minor = (version >> 16) & 0xFF;
uint16_t patch = version & 0xFFFF;
4.2 差分升级(Delta OTA):省流量就是省钱
LoRaWAN 的带宽有多金贵,不用我多说吧?一次全量升级,动辄几十 KB 甚至上百 KB,按 LoRa 的速率,得传好几个小时。而且功耗也扛不住。
所以,差分升级就派上用场了。说白了,就是只传新旧版本之间的差异部分。比如你只改了一个函数,那差分包可能就几百字节。
我记得有一次给一个水表终端做升级,全量固件 48KB,差分包只有 1.2KB。设备在 3 分钟内就完成了升级,电池几乎没掉电。客户当场就竖了大拇指。
4.2.1 差分算法怎么选?
目前主流的差分算法有 bsdiff、xdelta、hdiffpatch 等。但在 LoRaWAN 场景下,我建议用 bsdiff 或者 hdiffpatch。为什么?
- bsdiff:压缩率高,但内存占用大。适合服务器端生成差分包。
- hdiffpatch:专为嵌入式设计,内存占用小,适合在 MCU 上解压。
避坑指南:
我曾经在 STM32L0 上跑 bsdiff 的解压算法,结果内存直接爆了。后来换成 hdiffpatch,只用了 4KB 的 RAM 就搞定了。所以选算法时,一定要先看你的 MCU 有多少 RAM。
4.2.2 差分升级的流程
- 服务器端:用旧固件和新固件生成差分包。
- 设备端:收到差分包后,读取当前运行的旧固件。
- 设备端:用差分算法将旧固件 + 差分包 → 合成新固件。
- 设备端:校验新固件的完整性(CRC 或哈希)。
- 设备端:写入备份区,准备切换。
// 差分解压伪代码
uint8_t old_firmware[OLD_SIZE];
uint8_t delta_data[DELTA_SIZE];
uint8_t new_firmware[NEW_SIZE];
// 读取当前固件
read_flash(OLD_FW_ADDR, old_firmware, OLD_SIZE);
// 解压差分包
hdiffpatch_apply(old_firmware, OLD_SIZE,
delta_data, DELTA_SIZE,
new_firmware, &new_size);
// 校验新固件
if (crc32(new_firmware, new_size) == EXPECTED_CRC) {
write_flash(NEW_FW_ADDR, new_firmware, new_size);
} else {
// 校验失败,回滚
handle_error();
}
4.3 回滚策略设计:给自己留条后路
升级失败怎么办?这是每个做 OTA 的人都要面对的问题。我的原则是:永远不要只保留一份固件。
你想想看,如果设备里只有一份固件,升级写到一半断电了,那设备就变砖了。所以,必须设计回滚机制。
4.3.1 双备份区设计
我个人最推荐的是双备份区方案:
| 分区 | 用途 | 大小 |
|---|---|---|
| Bootloader | 启动引导,不参与升级 | 16KB |
| App_A | 当前运行的应用 | 64KB |
| App_B | 备份/新固件 | 64KB |
| 参数区 | 存储版本号、升级状态 | 4KB |
升级时,新固件写入 App_B。升级完成后,Bootloader 检查 App_B 的合法性。如果合法,就切换启动到 App_B。如果失败,就回滚到 App_A。
4.3.2 回滚触发条件
什么情况下触发回滚?我总结了三种:
- 校验失败:CRC 或哈希值不匹配。
- 启动超时:新固件启动后,在规定时间内没有上报“升级成功”的确认帧。
- 看门狗复位:新固件连续复位超过 N 次,说明有问题。
注意:
我曾经遇到过一个坑:设备升级后,新固件能正常启动,但运行 10 分钟后就会死机。看门狗复位后,又启动,又死机。循环往复。后来我加了一个“启动计数”机制:每次启动时计数加 1,如果连续 3 次启动后都发生了复位,就强制回滚。这才彻底解决了问题。
4.3.3 回滚的代码实现
// Bootloader 中的回滚逻辑
void bootloader_main(void) {
uint8_t boot_count = read_param(BOOT_COUNT_ADDR);
if (boot_count >= MAX_BOOT_RETRIES) {
// 连续启动失败,回滚
rollback_to_app_a();
write_param(BOOT_COUNT_ADDR, 0);
} else {
// 尝试启动新固件
boot_count++;
write_param(BOOT_COUNT_ADDR, boot_count);
jump_to_app_b();
}
}
// 应用层确认升级成功
void confirm_upgrade_success(void) {
write_param(BOOT_COUNT_ADDR, 0); // 清零启动计数
write_param(CURRENT_VERSION, new_version);
}
4.4 总结一下
这一章的内容,说白了就是三个字:稳、省、退。
- 稳:版本号要规范,别搞出“v2.1.3_beta”这种幺蛾子。
- 省:差分升级能省 90% 以上的流量,电池设备必备。
- 退:回滚策略是最后一道防线,一定要设计好。
嗯,下一章我会讲升级过程中的断点续传和错误处理。这两个点在实际项目中特别容易出问题,到时候我会把踩过的坑都抖出来。
一句话总结:
固件镜像管理,不是简单的“存个文件”。版本号、差分算法、回滚机制,这三样东西缺一不可。做好了,你的设备就是“打不死的小强”;做不好,那就是“升级一次,变砖一次”。