4、双分区升级策略(A/B分区):A/B分区的工作原理、切换逻辑、回滚机制

双分区升级,圈里人常叫它 A/B 分区,或者无缝升级。说白了,就是设备里准备了两套完整的系统分区——一套在跑,一套等着被升级。

我最早接触这个方案是在做车机项目的时候。客户要求升级不能黑屏,不能中断音乐播放,还得保证万一升级失败能自己恢复。嗯,A/B 分区几乎是唯一的选择。

4.1 核心思想:冗余换安全

传统升级是原地擦写——系统在跑,你把它跑的程序给擦了,再写新的。这就像给飞行的飞机换引擎,风险极高。

A/B 分区的思路完全不同:

  • Slot A:当前运行的系统
  • Slot B:空闲的备份槽位

升级时,新固件直接写入 Slot B,Slot A 照常运行。写完后重启,引导程序决定从哪个槽启动。如果 Slot B 启动成功,它就变成新的活动槽;如果失败,自动回滚到 Slot A。

关键点:整个升级过程对用户完全透明。用户该干嘛干嘛,升级在后台静默完成。

4.2 工作原理:三阶段流程

我习惯把 A/B 升级拆成三个阶段。每个阶段都有明确的职责和边界。

阶段一:更新写入(Update)

系统在 Slot A 正常运行,升级服务把新固件下载到 Slot B。这个阶段有几个要点:

  • 校验先行:下载过程中逐块校验哈希,确保数据完整性
  • 断点续传:如果下载中断,下次从断点继续,不用重头来
  • 写入保护:写入期间禁止对 Slot B 的意外访问

我在项目中遇到过一个问题:下载完成后没做全量校验,结果有个坏块没发现,重启后系统直接挂了。从那以后,我坚持在写入完成后做一次全分区校验。

阶段二:切换尝试(Switch & Boot)

写入完成后,系统会设置一个 boot_control 标志,告诉引导程序下次启动用 Slot B。然后执行重启。

引导程序(比如 U-Boot 或 ABL)读取这个标志,尝试从 Slot B 启动。这里有一个关键机制——启动计数:

// 伪代码示意
if (slot_b.boot_successful) {
    slot_b.active = true;
    slot_a.active = false;
} else {
    slot_b.tries_remaining--;
    if (slot_b.tries_remaining <= 0) {
        rollback_to_slot_a();
    }
}

一般我会设置 3 次尝试机会。如果连续 3 次启动失败,就判定新系统不可用。

阶段三:提交确认(Commit)

新系统成功启动后,需要显式执行一次 commit 操作,告诉系统:这个槽位是好的,以后就用它了。

注意:如果不做 commit,下次重启可能还会回滚。很多开发者在调试阶段吃过这个亏——明明系统跑得好好的,一重启又回到旧版本了。

4.3 切换逻辑:谁来决定启动哪个槽?

切换逻辑的核心在引导程序里。我画了一张图,把决策流程说清楚。

A/B 分区切换决策流程 设备上电 读取 boot_control 标志 判断:哪个槽位标记为 active? Slot A 从 Slot A 启动 Slot B 从 Slot B 启动 启动成功? 启动成功? 是 正常使用 Slot A 否 尝试 Slot B 是 执行 Commit 否 回滚到 Slot A 说明:启动计数耗尽后触发回滚,引导程序自动切换回旧槽位

这个流程里有一个容易被忽略的细节:启动成功的判断标准是什么?

我个人习惯的做法是:

  1. 引导程序加载内核并传递 boot_slot 参数
  2. 内核启动后,init 进程检查这个参数
  3. 关键服务(如网络、存储)启动完成后,调用 update_engine 的 commit 接口
  4. 如果 30 秒内没有收到 commit,系统自动重启并尝试下一个槽位

4.4 回滚机制:最后的防线

回滚不是简单的「切回去」,它需要处理很多边界情况。我把它分为两种场景。

场景一:主动回滚

新系统虽然启动了,但运行不稳定。比如某个驱动频繁崩溃,或者关键服务反复重启。这时候系统可以主动触发回滚:

// 主动回滚示例
int trigger_rollback() {
    // 1. 设置回滚标志
    boot_control_set_active_slot(OLD_SLOT);
    
    // 2. 清理新槽位的启动计数
    boot_control_reset_tries(NEW_SLOT);
    
    // 3. 重启
    sync();
    reboot(RB_AUTOBOOT);
    
    return 0;
}

场景二:被动回滚

新系统根本起不来,或者启动到一半就挂了。这时候引导程序会接管:

  • 启动计数递减:每次启动失败,计数减 1
  • 计数归零触发回滚:引导程序自动切换回旧槽位
  • 错误日志记录:在旧槽位的特定分区记录失败原因

经验之谈:启动计数不要设太大。3 次足够了。设多了反而会延长故障恢复时间。我曾经见过一个设备设了 10 次,结果用户等了 10 次重启才回到正常系统,体验极差。

4.5 关键数据结构

A/B 分区的状态信息通常存储在 misc 分区或 boot_control 分区。我常用的数据结构如下:

字段 大小 说明
magic 4 字节 校验魔数,用于识别数据结构有效性
active_slot 1 字节 当前活动槽位(0=A,1=B)
boot_slot 1 字节 下次启动尝试的槽位
tries_remaining[2] 2 字节 每个槽位的剩余启动尝试次数
successful_boot[2] 2 字节 每个槽位是否已成功启动并 commit
crc32 4 字节 整个结构的校验和

这个结构体必须保证原子写入。我一般先写数据,最后写 crc32。读取时先校验 crc32,如果校验失败,使用默认值(从 Slot A 启动)。

4.6 避坑指南

做 A/B 分区这些年,我踩过不少坑。挑几个典型的说说。

坑一:分区大小不一致

我曾经遇到过 Slot A 和 Slot B 大小不一样的情况。新固件刚好比 Slot B 大了一点点,写入时直接越界。从那以后,我强制要求两个槽位必须完全一样大,并且在编译时做大小检查。

坑二:commit 时机太早

有个同事在 init 进程刚启动就调了 commit。结果后面有个关键服务挂了,系统已经回不去了。我建议的 commit 时机是:所有核心业务都确认正常后再提交。对于车机来说,至少要等到仪表盘显示正常、网络连接建立、CAN 总线通信恢复。

坑三:忽略用户数据兼容性

新系统可能改了数据库结构或配置文件格式。如果回滚到旧系统,旧系统可能读不了新格式的数据。我的做法是:在升级前备份用户数据,回滚时一并恢复。或者采用向前兼容的数据格式。

4.7 适用场景与局限

A/B 分区不是银弹。它适合对可用性要求高的场景,但代价也很明显。

优势 代价
升级过程不中断业务 存储空间翻倍
升级失败自动恢复 引导程序复杂度增加
支持后台静默升级 需要额外的分区管理逻辑
用户无感知 调试和测试成本更高

如果你的设备存储空间紧张,或者对成本极度敏感,A/B 分区可能不是最佳选择。但对于车机、医疗设备、工业控制器这类「不能停」的设备,它几乎是标配。

嗯,A/B 分区的内容就这些。核心思路其实很简单——用空间换时间,用冗余换安全。但真正落地的时候,细节决定成败。希望这些经验能帮你少走弯路。