3. 恢复策略核心设计:双备份机制(A/B分区)、恢复分区设计、版本回滚策略、断点续传机制

固件升级一旦中断,设备变砖的风险就摆在眼前。我做了这么多年嵌入式,踩过最大的坑就是——升级到一半断电,系统起不来了。用户骂娘,售后返厂,成本全砸在自己手里。

所以这一章,咱们把恢复策略的核心设计掰开揉碎讲清楚。说白了,就是四个东西:双备份分区、恢复分区、版本回滚、断点续传。这四个组合起来,基本能覆盖 99% 的中断场景。

3.1 双备份机制(A/B分区)

双备份,也叫 A/B 分区。原理很简单:系统里放两份固件,一份跑着,一份待命。升级时写空闲的那份,写坏了也不影响当前运行。

核心思想:永远保留一个可启动的固件副本。升级失败时,直接切回旧版本,用户无感。

3.1.1 分区布局

我习惯把 Flash 分成这么几块:

分区 起始地址 大小 用途
Bootloader 0x08000000 64 KB 启动引导,决定从 A 还是 B 启动
Slot A 0x08010000 512 KB 主固件副本 A
Slot B 0x08090000 512 KB 主固件副本 B
Recovery 0x08110000 128 KB 最小恢复系统
Config 0x08130000 16 KB 启动计数、升级状态标记

嗯,这里要注意:Bootloader 必须足够小且稳定。它只做一件事——判断从哪个 Slot 启动。我见过有人把 Bootloader 写得过于复杂,结果 Bootloader 自己先挂了,那才是真·变砖。

3.1.2 启动决策逻辑

Bootloader 的决策流程,我用伪代码表示一下:

void bootloader_main() {
    // 读取升级状态标记
    upgrade_status_t status = read_config(UPGRADE_STATUS_KEY);
    
    if (status == UPGRADE_SUCCESS) {
        // 升级成功,从目标 Slot 启动
        boot_from_slot(status.target_slot);
    } else if (status == UPGRADE_IN_PROGRESS) {
        // 升级中断!回滚到另一个 Slot
        rollback_to_previous_slot();
        // 标记回滚事件
        write_config(ROLLBACK_EVENT, 1);
    } else {
        // 正常启动,从当前活跃 Slot 启动
        boot_from_slot(get_active_slot());
    }
}

为什么要有 UPGRADE_IN_PROGRESS 这个状态?我在项目里遇到过:升级时突然断电,固件只写了一半。下次上电,Bootloader 发现标记还在,就知道「哦,上次没搞完」,直接切回旧版本。用户甚至不知道发生过升级失败。

个人经验:升级状态标记建议写两次。第一次写「准备升级」,第二次写「升级完成」。万一写标记时断电,至少不会出现「标记已清除但固件没写完」的尴尬局面。

3.2 恢复分区设计

双备份虽然好,但有个前提——两个 Slot 都得是好的。如果 A 和 B 都坏了呢?比如用户强行刷了错误的固件,两个分区都覆盖了。这时候就需要一个「兜底」方案:恢复分区。

恢复分区,说白了就是一个最小化的固件。它只包含:

  • 基本的硬件初始化(时钟、内存、Flash)
  • USB 或 UART 通信驱动
  • 固件下载和烧写功能

我把它叫做「救生艇」。平时用不上,但真到了 A/B 都挂了的时候,它就是最后一根稻草。

3.2.1 恢复分区触发条件

Bootloader 里我会加一个「启动计数」逻辑:

  1. 每次启动,在 Config 分区里记一笔。
  2. 如果连续 3 次启动都失败(比如系统跑飞、看门狗复位),Bootloader 就不再尝试从 A/B 启动了。
  3. 直接跳转到恢复分区,等待用户通过 USB 或串口重新烧录固件。

注意:恢复分区必须是只读的。我见过有人把恢复分区放在可擦写的区域,结果升级时不小心把恢复分区也覆盖了……嗯,那设备就真成砖了。

3.3 版本回滚策略

版本回滚,不是简单的「切回旧版本」就完事了。你想想看,如果新版本有严重 bug,用户升级后设备反复重启,这时候自动回滚是好事。但如果新版本只是界面变了,功能没问题,用户想保留新版本呢?

所以回滚策略要分场景:

场景 触发条件 回滚行为
升级中断 升级过程中断电或通信断开 自动回滚到旧版本,无需用户干预
启动失败 新版本连续 3 次启动失败 自动回滚,并记录错误日志
用户主动回滚 用户通过界面或指令触发 手动回滚,保留新旧版本供选择
版本兼容性检查失败 新固件与硬件版本不匹配 拒绝升级,提示用户下载正确版本

我个人习惯在 Config 分区里维护一个版本链表。每次升级成功,就把版本号、时间戳、CRC 校验值写进去。回滚时,Bootloader 遍历这个链表,找到上一个可用的版本。这样即使回滚多次,也不会丢失历史记录。

避坑指南:我曾经遇到过一个问题——回滚后,新版本的一些配置参数(比如 Wi-Fi 密码、校准数据)被旧版本覆盖了。后来我加了一个「配置分区隔离」,把用户配置和固件镜像分开存储。回滚时只换固件,不动配置。

3.4 断点续传机制

断点续传,说白了就是「上次传到哪里,这次接着传」。这个在 OTA 升级里特别重要。你想,一个 2MB 的固件,通过蓝牙传可能要 10 分钟。传了 9 分钟断开了,从头再来?用户不骂娘才怪。

3.4.1 实现思路

断点续传的核心是分块传输 + 进度记录:

  1. 把固件分成固定大小的块(比如 1KB 一块)。
  2. 每传完一块,在 Flash 里记录当前块号。
  3. 如果传输中断,下次连接时先读取已完成的块号,从下一块开始传。

代码实现大概长这样:

// 断点续传状态结构体
typedef struct {
    uint32_t total_blocks;      // 总块数
    uint32_t completed_blocks;  // 已完成块数
    uint32_t block_size;        // 每块大小(字节)
    uint32_t crc32_partial;     // 已接收数据的 CRC
    uint8_t  slot_target;       // 目标分区(A 或 B)
} resume_state_t;

// 保存断点状态
void save_resume_state(resume_state_t *state) {
    write_flash(RESUME_STATE_ADDR, (uint8_t*)state, sizeof(resume_state_t));
}

// 恢复断点
resume_state_t load_resume_state() {
    resume_state_t state;
    read_flash(RESUME_STATE_ADDR, (uint8_t*)&state, sizeof(resume_state_t));
    return state;
}

3.4.2 校验策略

断点续传最怕什么?怕续传时数据对不上。比如上次传的块有 bit 翻转,这次接着传,最后拼出来的固件是坏的。

我的做法是:

  • 每块单独校验:每个数据块都带 CRC,接收端收到后立即校验。校验失败就重传这一块。
  • 整体校验:所有块传完后,对整个固件做一次 SHA256 校验。如果对不上,说明中间有块出了问题,全部重传。

一个小技巧:断点续传的进度记录要写在独立的 Flash 扇区里。为什么?因为进度记录要频繁擦写,如果和固件放在同一个扇区,擦除时会连带把固件数据也抹掉。我一般单独划一个 4KB 的扇区专门存进度。

3.5 核心逻辑总览

说了这么多,咱们用一张图把整个恢复策略串起来:

固件升级恢复策略核心逻辑 固件升级恢复策略核心逻辑 升级开始 分块传输 + 进度记录 传输中断? 是 保存断点状态 等待重连 否 传输完成 整体校验 重连后读取断点 校验通过? 否 全部重传 是 写入目标分区 标记升级状态 重启 → 从新分区启动 回滚触发条件 • 升级中断标记 • 连续 3 次启动失败 • 用户手动回滚

这张图把整个流程串起来了。你从「升级开始」往下走,遇到中断走左边,正常完成走右边。校验失败回退重传,校验通过写入分区。最后重启,Bootloader 根据状态标记决定从哪个分区启动。

嗯,这套设计我在三个量产项目里验证过,覆盖了智能家居网关、工业控制器和车载 T-Box。到目前为止,没有因为升级中断导致返厂的案例。说白了,就是「宁可多花一点 Flash 空间,也要把容错做足」。