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 差分升级的流程

  1. 服务器端:用旧固件和新固件生成差分包。
  2. 设备端:收到差分包后,读取当前运行的旧固件。
  3. 设备端:用差分算法将旧固件 + 差分包 → 合成新固件。
  4. 设备端:校验新固件的完整性(CRC 或哈希)。
  5. 设备端:写入备份区,准备切换。
// 差分解压伪代码
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% 以上的流量,电池设备必备。
  • 退:回滚策略是最后一道防线,一定要设计好。

嗯,下一章我会讲升级过程中的断点续传错误处理。这两个点在实际项目中特别容易出问题,到时候我会把踩过的坑都抖出来。

一句话总结:

固件镜像管理,不是简单的“存个文件”。版本号、差分算法、回滚机制,这三样东西缺一不可。做好了,你的设备就是“打不死的小强”;做不好,那就是“升级一次,变砖一次”。