3. 升级流程设计:升级包下载与校验、升级包写入非活跃分区、升级完成后的slot切换

好,咱们接着聊升级流程。这一节是A_B无缝升级的核心实操环节。说白了,就是三个步骤:把升级包拿下来、校验它是不是好的、写到另一个分区里,最后切过去。听起来简单?嗯,每个环节都有坑。

3.1 升级包下载与校验:别让坏包毁了设备

我见过不少团队,下载流程做得稀里糊涂。升级包下载,不只是「从服务器拉个文件」那么简单。

下载策略,我个人习惯用断点续传。为什么?因为设备网络不稳定,下载到一半断了,从头再来?用户会骂娘的。我建议这样设计:

  • 先请求服务器获取升级包元信息(大小、版本、校验值)
  • 分块下载,每块大小建议256KB~1MB
  • 每下载一块,本地记录偏移量
  • 断网后重连,从上次断点继续

校验这块,我踩过坑。曾经有个项目,只校验了升级包的MD5。结果呢?包是完整的,但解压后文件损坏了。后来我改成双层校验:

推荐校验流程:

  1. 下载完成后,校验整个升级包的SHA256
  2. 解压后,对每个文件单独校验CRC32
  3. 关键文件(如bootloader)额外做签名验证

你想想看,如果只校验外层包,内层文件被篡改了怎么办?双层校验就是给安全上了双保险。

// 伪代码示例:升级包校验
bool verify_update_package(const char* package_path) {
    // 第一层:整体校验
    uint8_t expected_hash[32];
    get_package_sha256(package_path, expected_hash);
    
    uint8_t actual_hash[32];
    sha256_file(package_path, actual_hash);
    
    if (memcmp(expected_hash, actual_hash, 32) != 0) {
        log_error("Package SHA256 mismatch");
        return false;
    }
    
    // 第二层:解压后逐文件校验
    extract_package(package_path, "/tmp/update/");
    
    FileList* files = get_file_list("/tmp/update/");
    for (int i = 0; i < files->count; i++) {
        if (!verify_file_crc32(files->entries[i])) {
            log_error("File CRC32 mismatch: %s", files->entries[i]->name);
            return false;
        }
    }
    
    return true;
}

注意:校验失败后,不要直接删除升级包。保留现场,方便后续排查。我曾经因为直接删包,导致问题复现不了,查了三天才找到原因。

3.2 升级包写入非活跃分区:写对地方是关键

A_B升级的核心思想,就是「你升级你的,我跑我的」。写入非活跃分区时,有几个要点必须注意。

分区定位,说白了就是搞清楚当前在哪个槽位。我建议用boot_control HAL来获取:

// 获取当前活跃槽位
Slot current_slot = get_current_slot();  // slot_a 或 slot_b
Slot target_slot = (current_slot == SLOT_A) ? SLOT_B : SLOT_A;

// 挂载目标分区
if (mount(target_slot, "/mnt/update") != 0) {
    log_error("Failed to mount target slot");
    return;
}

写入策略,我推荐流式写入。什么意思?就是边下载边写,不要等全部下载完再写。好处很明显:

  • 节省临时存储空间(设备存储通常吃紧)
  • 缩短整体升级时间
  • 如果写入失败,能尽早发现

但流式写入有个坑:写入顺序。我记得有个项目,分区是顺序写入的,但升级包里的文件顺序和分区布局不匹配。结果呢?写到最后发现空间不够。后来我改成先解析升级包的文件布局,再按分区偏移量写入。

小技巧:写入过程中,每写一个块就做一次sync()。别等到最后才sync,万一掉电,前面写的全白费。我习惯每写10MB强制sync一次。

还有一个容易被忽略的点:坏块处理。尤其是NAND Flash,写入时遇到坏块是常事。我建议:

  1. 写入前先擦除目标分区
  2. 写入时检查每个块的写入状态
  3. 遇到坏块,跳过并记录日志
  4. 写入完成后,回读校验关键区域

3.3 升级完成后的slot切换:最后一哆嗦

升级包写完了,校验通过了。接下来就是切换slot。这一步看似简单,但做不好,设备可能变砖。

切换时机,我建议在下次重启时生效。为什么?因为有些驱动在运行时不能热切换。你想想看,正在用的文件系统突然被切走了,系统不崩溃才怪。

切换的核心操作,就是修改bootloader的配置:

// 设置目标槽位为下次启动槽位
int set_active_slot(Slot slot) {
    // 写入bootloader控制块
    BootloaderControl bc;
    if (read_bootloader_control(&bc) != 0) {
        log_error("Failed to read bootloader control");
        return -1;
    }
    
    bc.active_slot = slot;
    bc.retry_count = 0;  // 重置重试计数
    bc.successful_boot = 0;  // 标记尚未验证启动成功
    
    if (write_bootloader_control(&bc) != 0) {
        log_error("Failed to write bootloader control");
        return -1;
    }
    
    return 0;
}

这里有个关键设计:回滚机制。我曾经吃过亏,切换后新系统起不来,又没有回滚方案,只能返厂。后来我设计了「三次启动验证」策略:

三次启动验证流程:

启动次数 状态 操作
第1次 新系统启动 标记为「试运行」,启动成功则继续
第2次 新系统运行 标记为「验证中」,启动成功则继续
第3次 新系统稳定 标记为「已验证」,正式切换

如果三次中有任何一次启动失败,bootloader自动回滚到旧槽位。用户甚至感觉不到升级失败过。

警告:切换后,旧系统的数据不要立即删除。保留至少一次完整升级周期的数据。我见过有人切换后马上删旧系统,结果新系统有bug,想回退都回不了。

最后说一句,切换完成后,记得通知用户。不是发个通知就完事,而是告诉用户「升级成功,下次重启生效」或者「已重启,新系统运行正常」。用户体验,往往就体现在这些细节里。

嗯,升级流程设计就聊到这儿。下一节咱们讲讲升级过程中的异常处理,那才是真正考验系统健壮性的地方。