双分区(A/B)系统架构:原理、切换逻辑与回退机制

聊到OTA升级,就绕不开A/B分区。我入行那会儿,很多设备还是单分区升级,一旦升级过程中断电或者网络断了,设备就变砖了。后来接触A/B分区,才觉得这玩意儿真是为工业级可靠性设计的。

说白了,A/B分区就是给系统准备了两套完整的运行环境。一套在跑,另一套闲着。升级的时候,往闲的那套写新固件,写完了再切过去。整个过程不影响当前业务,就算升级失败,也能立刻回退。

A/B分区的核心原理

先看一张整体架构图,我习惯用这张图给团队新人讲清楚A/B分区的布局。

A/B双分区系统架构图 A/B双分区系统架构图 存储设备(eMMC / NAND Flash) 分区 A(当前运行) boot_a system_a vendor_a data_a(用户数据) 分区 B(待升级) boot_b system_b vendor_b data_b(用户数据) 切换 关键点:A/B分区完全对称,各自包含完整的启动和系统镜像

你看这张图,A和B两套分区是完全对称的。每个分区都包含boot、system、vendor等子分区,还有独立的用户数据区。我见过不少新手问:为什么用户数据也要分两份?其实是为了保证切换后应用层也能无缝衔接。

核心要点:A/B分区的本质是「空间换时间」——用双倍存储空间,换取零停机升级和即时回退能力。

切换逻辑:Bootloader如何决定启动哪个分区

切换逻辑的核心在Bootloader里。设备上电后,Bootloader会读取一个叫「misc分区」或者「BCB(Bootloader Control Block)」的区域,里面存着当前应该启动哪个槽位。

我参与过一个车机项目,当时用的就是这种机制。Bootloader的决策流程大致如下:

  1. 读取槽位状态:检查A/B分区的「健康标记」和「重试计数」。
  2. 判断优先级:如果当前槽位标记为成功,直接启动;如果标记为失败,切换到另一个槽位。
  3. 更新计数:每次启动尝试,重试计数减1。减到0还没成功,就标记为失败。
  4. 提交确认:系统正常启动后,用户空间程序会向Bootloader发送「提交成功」信号,锁定当前槽位。

这里有个细节,我当年踩过坑——提交确认的时机。如果系统刚启动就提交,万一后面几分钟内崩溃了,就没机会回退了。所以我建议等关键服务都启动完毕,再执行提交操作。

我的经验:提交确认最好延迟到系统启动后30秒到1分钟,等网络、显示、核心服务都稳定了再提交。这样能避免「假启动成功」导致的回退失效。

回退机制:升级失败后的保命手段

回退机制是A/B分区最大的价值所在。升级过程中,如果出现以下情况,系统会自动触发回退:

  • 固件校验失败(哈希值不匹配)
  • 升级过程中断电或通信中断
  • 新系统启动后关键服务崩溃
  • 用户手动触发回退(比如通过Recovery模式)

回退的流程其实很简单:Bootloader检测到当前槽位启动失败,直接切到另一个槽位。因为另一个槽位还是旧版本,所以设备能立刻恢复到升级前的状态。

我曾经处理过一个案例:某款智能音箱在升级过程中网络闪断,导致system_b分区只写了一半。设备重启后,Bootloader发现system_b的哈希校验失败,自动切回system_a。用户完全没感觉到升级失败,只是第二天发现版本号没变。

注意:回退机制不是万能的。如果两个分区都损坏了(比如Flash物理坏块),设备还是会变砖。所以A/B分区通常配合「恢复模式」和「工厂镜像」一起使用,形成三重保险。

槽位切换的完整状态机

为了让你更清楚地理解切换过程,我整理了一张状态表。这张表在我做过的几个项目里都直接拿来当设计文档用。

状态 描述 触发条件 下一步动作
Active 当前正在运行的槽位 设备正常启动 等待升级指令
UpdatePending 另一个槽位已写入新固件,等待切换 升级包下载并校验完成 重启后尝试启动新槽位
Try 新槽位正在试运行 Bootloader切换到新槽位 等待提交或超时回退
Successful 新槽位被确认为稳定版本 用户空间提交成功信号 锁定槽位,下次继续启动
Failed 新槽位启动失败 重试计数归零或校验失败 自动切回旧槽位

嗯,这里要注意「Try」状态。这个状态是回退机制的关键窗口期。在Try状态下,如果设备崩溃或者用户强制重启,Bootloader会认为新版本不稳定,自动切回旧槽位。只有收到「Successful」信号后,新版本才算正式转正。

实际项目中的切换代码示例

下面这段代码是我在一个Linux嵌入式项目中实际用过的槽位切换逻辑,简化后分享给你。它展示了Bootloader如何读取槽位信息并做出决策。

/* 槽位控制块结构体 */
struct slot_control {
    uint8_t slot_a_retry;      /* A槽位剩余重试次数 */
    uint8_t slot_b_retry;      /* B槽位剩余重试次数 */
    uint8_t active_slot;       /* 当前活动槽位:0=A, 1=B */
    uint8_t slot_a_successful; /* A槽位是否已提交成功 */
    uint8_t slot_b_successful; /* B槽位是否已提交成功 */
};

/* Bootloader启动决策函数 */
int select_boot_slot(struct slot_control *ctrl) {
    /* 1. 先检查当前活动槽位是否有效 */
    if (ctrl->active_slot == 0) {
        if (ctrl->slot_a_successful) {
            return 0;  /* A槽位已确认,直接启动 */
        }
        if (ctrl->slot_a_retry > 0) {
            ctrl->slot_a_retry--;
            return 0;  /* A槽位还有重试机会 */
        }
        /* A槽位重试用完,标记失败,切到B */
        ctrl->active_slot = 1;
        return 1;
    } else {
        /* B槽位逻辑与A对称 */
        if (ctrl->slot_b_successful) {
            return 1;
        }
        if (ctrl->slot_b_retry > 0) {
            ctrl->slot_b_retry--;
            return 1;
        }
        ctrl->active_slot = 0;
        return 0;
    }
}

这段代码看着简单,但实际项目中要考虑的东西更多。比如重试次数设多少合适?我一般设3次。太少了容易误判,太多了会反复尝试损坏的固件,浪费启动时间。

A/B分区的优缺点对比

任何架构都有取舍。我整理了一个对比表,方便你评估是否适合你的项目。

维度 优势 劣势
可靠性 升级失败可即时回退,几乎零风险 双分区同时损坏时仍可能变砖
用户体验 升级过程无感,不中断当前业务 升级后首次启动需要切换,略有延迟
存储开销 — 需要双倍存储空间(对低端设备不友好)
实现复杂度 逻辑清晰,Bootloader改动较小 需要维护两套分区表,OTA包生成稍复杂
适用场景 车机、医疗设备、工业控制器等 IoT传感器、内存小于128MB的设备

我个人觉得,如果你的设备存储空间够用(至少256MB以上),A/B分区是首选方案。它带来的可靠性提升,远超过那点存储成本。

避坑指南:我踩过的几个坑

最后分享几个实战中容易忽略的问题,都是我曾经的血泪教训。

  • 分区大小不一致:A和B分区必须完全一样大。我见过有人为了省空间,把B分区做小了,结果新固件写不进去。记住,对称是铁律。
  • 忽略data分区:有些方案只给system做双分区,data不分。结果切换后应用数据全乱了。我建议data也做双分区,或者至少做好迁移逻辑。
  • 重试计数不持久化:重试计数必须写在非易失存储里。如果只存在内存里,掉电后就失效了,回退机制形同虚设。
  • 提交确认太早:前面提过,等系统完全稳定了再提交。我一般等网络连接成功、核心服务注册完毕后再发提交信号。