3、双备份架构(A/B系统):原理、实现方式、切换逻辑、优缺点分析

双备份架构,圈里人更喜欢叫它 A/B 系统。说白了,就是在设备里准备两套完整的固件副本——一个在干活(Active),一个在待命(Standby)。

我最早接触这个方案是在做智能电表项目的时候。那会儿客户要求固件升级失败后设备必须能自动恢复,不能派人去现场刷机。你想想看,几万只电表分布在全省各地,要是升级挂了还得挨个跑,那画面太美我不敢看。

A/B 系统的核心思路其实很简单:永远保证有一个可启动的固件存在。不管你怎么折腾,系统里总有一份能跑起来的代码。

3.1 原理:为什么需要两份固件?

传统单分区升级有个致命问题——写入即覆盖。升级过程中一旦断电、通信中断或者固件校验失败,原来的固件就没了,新的固件又不完整。设备直接变砖。

A/B 系统把存储区一分为二:

  • Slot A:当前运行的固件
  • Slot B:备份/待升级的固件

升级时,Bootloader 把新固件写入 非活动分区。写入完成后做完整性校验,校验通过才切换启动分区。如果校验失败或者新固件启动不了,Bootloader 自动回退到原来的分区。

核心原则:任何时候,至少有一个分区包含可启动的固件。

嗯,这里要注意:A/B 系统不是简单的「存两份」,关键在于 切换逻辑 和 回滚机制。我见过不少团队把两份固件写进去就完事了,结果切换逻辑没写好,该回退的时候回退不了,照样变砖。

双备份架构(A/B系统)流程图 双备份架构(A/B系统)升级与回滚流程 Slot A(活动) 固件版本 v2.1 Slot B(待命) 固件版本 v2.0 运行中 写入新固件 版本 v2.2 → Slot B 升级触发 完整性校验 CRC/SHA256 验证 写入完成 校验通过 Slot A(待命) 固件版本 v2.1 Slot B(活动) 固件版本 v2.2 校验失败 回滚到 Slot A 版本 v2.1 继续运行 任何时候至少有一个分区包含可启动的固件,这是 A/B 系统的核心保障

3.2 实现方式:存储布局与 Bootloader 设计

A/B 系统的实现主要涉及两个层面:存储分区布局 和 Bootloader 逻辑。

3.2.1 存储分区布局

典型的 Flash 布局长这样:

分区名称 起始地址 大小 用途
Bootloader 0x08000000 64 KB 启动加载、分区选择、回滚逻辑
Slot A 0x08010000 512 KB 固件副本 A(活动/待命)
Slot B 0x08090000 512 KB 固件副本 B(活动/待命)
参数区 0x08110000 16 KB 启动计数、升级状态、校验标志
用户数据 0x08114000 剩余空间 配置、日志、文件系统

个人经验:参数区一定要用独立的 Flash 页,别跟固件分区混在一起。我曾经遇到一个项目,参数区和 Slot B 共用同一块擦除块,升级时参数被连带擦掉,Bootloader 读不到启动标志,直接死循环了。

3.2.2 Bootloader 切换逻辑

Bootloader 的决策流程,我习惯用状态机来描述:

// 伪代码:A/B 系统 Bootloader 切换逻辑
enum BootState {
    BOOT_FROM_A,
    BOOT_FROM_B,
    BOOT_UNKNOWN
};

BootState selectBootPartition() {
    // 1. 读取参数区中的启动计数和状态标志
    uint8_t bootCount = readParam(BOOT_COUNT);
    uint8_t upgradeFlag = readParam(UPGRADE_FLAG);
    
    // 2. 判断是否有待完成的升级
    if (upgradeFlag == UPGRADE_PENDING) {
        // 尝试从新分区启动
        if (validateFirmware(SLOT_B)) {
            setParam(BOOT_PARTITION, SLOT_B);
            setParam(BOOT_COUNT, 0);
            return BOOT_FROM_B;
        } else {
            // 新固件校验失败,回滚
            setParam(UPGRADE_FLAG, UPGRADE_FAILED);
            setParam(BOOT_PARTITION, SLOT_A);
            return BOOT_FROM_A;
        }
    }
    
    // 3. 正常启动:读取上次成功启动的分区
    uint8_t lastGood = readParam(BOOT_PARTITION);
    if (lastGood == SLOT_A) {
        return BOOT_FROM_A;
    } else {
        return BOOT_FROM_B;
    }
}

这里有个关键点:启动计数。每次 Bootloader 启动一个新分区,先把计数加 1。如果应用层启动成功,会主动把计数清零。如果计数超过阈值(比如 3 次),Bootloader 就认为这个分区有问题,自动切回另一个分区。

避坑指南:我曾经在某个项目里忘了实现「应用层确认启动成功」这个环节。结果 Bootloader 每次启动都计数,明明固件没问题,计数却一直涨,到阈值后自动回滚了。用户反馈说「设备每隔几天就自己回退版本」,排查了两天才找到原因。

3.3 切换逻辑:什么时候切?怎么切?

A/B 系统的切换逻辑,说白了就三种场景:

  1. 正常升级切换:新固件写入完成 → 校验通过 → 标记待切换 → 重启 → Bootloader 切到新分区 → 应用层确认启动成功
  2. 升级失败回滚:新固件写入失败/校验不通过 → Bootloader 保持原分区启动 → 标记升级失败
  3. 运行时回滚:新固件启动后运行异常(看门狗复位、应用层主动请求)→ Bootloader 检测到启动计数超限 → 切回旧分区

我个人比较推荐 「延迟切换」 策略。什么意思呢?新固件写入完成后,不立即切换,而是先标记为「待验证」。设备重启后从新分区启动,但旧分区保留不动。应用层运行一段时间(比如 5 分钟)后,确认一切正常,再提交「切换确认」。这样做的好处是:

  • 如果新固件有运行时 bug,设备会在 5 分钟内触发看门狗复位,Bootloader 自动回滚
  • 用户数据不会因为频繁切换而丢失
  • 给了应用层一个「反悔」的机会

3.4 优缺点分析

没有银弹。A/B 系统也不是万能的,咱们客观聊聊它的优缺点。

维度 优点 缺点
可靠性 升级失败可自动回滚,几乎不会变砖 需要额外的 Flash 空间(翻倍)
升级速度 升级过程中设备可继续运行,零停机 升级完成后需要重启一次
实现复杂度 逻辑清晰,Bootloader 代码量不大 需要管理分区表、启动计数、状态标志
成本 无需外部硬件支持,纯软件方案 Flash 容量需求翻倍,芯片成本上升
维护性 版本回退方便,运维友好 固件打包工具需要支持双分区镜像

我的建议:如果你的产品 Flash 容量够用(剩余空间 > 固件大小的 2.5 倍),优先上 A/B 系统。它是最成熟、最可靠的升级失败恢复方案。但如果 Flash 很紧张(比如低成本 MCU),那就得考虑其他方案了,比如后面会讲到的恢复模式。

3.5 实际项目中的注意事项

做了这么多年固件,踩过的坑不少。关于 A/B 系统,有几个点我想特别提醒你:

  • 参数区要防写坏:Flash 写入有寿命限制,启动计数频繁写入会磨损参数区。我一般用「写一次擦一次」的策略,或者把计数分散到多个地址。
  • 校验要覆盖完整:别只校验固件头部的 CRC,要校验整个固件镜像。我见过有人只校验了 256 字节的头部,结果固件主体写坏了,Bootloader 照样放行。
  • 考虑跨版本兼容:如果新固件改了分区布局或者数据结构,回滚后旧固件可能不兼容新数据。这时候需要额外的数据迁移逻辑。
  • 测试要覆盖异常场景:升级过程中断电、升级完成后立即断电、新固件启动后反复崩溃……这些场景都要在测试中覆盖到。

嗯,A/B 系统就聊到这儿。它不是什么新技术,但确实是最实用的。下次你设计固件升级方案的时候,不妨先问问自己:我的 Flash 够放两份固件吗?如果够,那就别犹豫了。