OTA升级机制:A/B分区(Seamless Update)原理,升级失败回滚策略

说到OTA升级,我猜很多开发同学都遇到过这样的场景:用户正在用手机,突然弹出一个系统更新提示,然后手机重启,进入一个黑底绿字的进度条界面,等个几分钟才能继续用。这种体验说实话挺糟糕的——你想想看,万一升级过程中断电了、存储坏了,手机就变砖了。

Google在Android 7.0引入了A/B分区机制,也叫无缝更新(Seamless Update)。我当年第一次接触这个特性时,第一反应是:这不就是双系统备份吗?嗯,本质上确实有点像,但细节上要复杂得多。

什么是A/B分区?

传统OTA升级只有一个系统分区(我们叫它A分区)。升级时,系统会把新固件写入A分区,覆盖掉旧的。如果写入过程中断了,系统就挂了。

A/B分区方案,说白了就是准备两套系统分区:slot A和slot B。当前运行的系统叫活跃槽位,另一个叫非活跃槽位。升级时,新系统写入非活跃槽位,写完后切换槽位,重启进入新系统。

核心思想:升级过程在后台完成,用户无需停机等待。即使升级失败,也能回滚到旧系统。

分区布局与槽位切换

我习惯把A/B分区的布局画成下面这张图,这样理解起来更直观:

A/B分区布局与槽位切换流程 Slot A(当前活跃) boot_a system_a vendor_a product_a Slot B(非活跃/升级目标) boot_b system_b vendor_b product_b 升级写入 升级完成后,系统重启,Bootloader 将活跃槽位切换为 Slot B

每个槽位都包含完整的系统分区:boot、system、vendor、product等。Bootloader里有一个slot_suffix变量,用来记录当前该从哪个槽位启动。

我的经验:早期A/B分区实现中,很多厂商只做了system和boot的双备份,忽略了vendor。结果升级后驱动不匹配,WiFi打不开、相机黑屏。我建议所有关键分区都要做A/B,别偷懒。

无缝更新的工作流程

整个流程分三个阶段,我拆开来讲:

阶段一:后台下载与写入

  1. 用户收到OTA推送,系统在后台下载增量包或完整包
  2. 下载完成后,update_engine服务开始将新数据写入非活跃槽位
  3. 写入过程中,用户正常使用手机,完全无感知
  4. 写入完成后,设置一个boot_control标记,表示新槽位已就绪

这里有个关键点:写入是流式写入的,边下载边解压边写入,不需要额外存储空间。我记得有一次项目里存储空间只剩2GB,传统升级根本跑不动,但A/B方案硬是扛下来了。

阶段二:重启与槽位切换

用户点击"重启安装"后,Bootloader会做以下判断:

// 伪代码:Bootloader槽位选择逻辑
if (slot_b.marker == SUCCESS) {
    slot_suffix = "_b";
    boot_from(slot_b);
} else if (slot_a.marker == SUCCESS) {
    slot_suffix = "_a";
    boot_from(slot_a);
} else {
    // 两个槽位都挂了?进入恢复模式
    enter_recovery_mode();
}

Bootloader会检查新槽位的marker标记。如果标记有效,就切换槽位启动。这个过程很快,用户看到的只是正常重启,没有漫长的"正在安装更新"界面。

阶段三:新系统验证与提交

新系统启动后,update_verifier服务会做两件事:

  • 验证关键分区的哈希值是否匹配
  • 检查核心服务是否正常启动

如果一切正常,系统会提交这次更新,把旧槽位标记为可覆盖。如果验证失败,系统会自动回滚。

注意:提交操作是不可逆的。一旦提交,旧槽位的数据可能被下一次升级覆盖。我见过有些厂商在提交前加了"7天冷静期",给用户留个后悔药。

升级失败回滚策略

回滚策略是A/B分区最核心的价值。说白了,就是给系统上了个"后悔药"。我总结了几种常见的失败场景和对应的回滚机制:

失败场景 触发时机 回滚行为
写入过程中断电 升级阶段一 重启后仍从旧槽位启动,新槽位标记为无效
新系统无法启动 阶段二重启后 Bootloader检测到启动超时,自动切回旧槽位
核心服务崩溃 阶段三验证中 update_verifier触发回滚,重启后切回旧槽位
用户主动回滚 提交前任意时刻 通过fastboot命令或设置界面手动切换槽位

这里有个细节:Bootloader是怎么判断"启动超时"的?它内部有个硬件看门狗,如果新系统在指定时间内没有完成初始化,看门狗就会复位系统,Bootloader再尝试从旧槽位启动。

避坑指南:我曾经遇到一个坑——新系统启动成功了,但某个后台服务一直在崩溃重启,导致系统卡顿。Bootloader认为启动成功了,没有触发回滚。后来我们在update_verifier里加了一个"服务健康度检查",如果关键服务在5分钟内崩溃超过3次,就主动触发回滚。

关键数据结构:boot_control HAL

Android通过boot_control HAL来管理槽位信息。核心数据结构如下:

// hardware/interfaces/boot/1.0/IBootControl.hal
struct Slot {
    uint32_t slotNumber;      // 0 或 1
    bool     isActive;        // 当前是否活跃
    bool     isBootable;      // 是否可启动
    bool     isSuccessful;    // 是否已验证成功
    uint32_t triesRemaining;  // 剩余尝试次数
};

struct BootControl {
    uint32_t numSlots;        // 槽位数(通常为2)
    uint32_t currentSlot;     // 当前槽位索引
    Slot     slots[2];        // 两个槽位信息
};

triesRemaining这个字段很有意思。新系统每次启动时,这个值减1。如果减到0还没被标记为成功,系统就认为这次升级失败了,自动回滚。默认值是7,也就是说新系统有7次启动机会来证明自己没问题。

我的建议:调试阶段可以把triesRemaining设大一点,比如30。但量产时建议保持默认值,否则用户可能反复重启都触发不了回滚,体验反而不好。

增量升级与A/B分区的配合

A/B分区和增量升级(增量OTA)是天生一对。增量包只包含新旧版本之间的差异数据,体积小、下载快。配合A/B分区,整个过程可以做到:

  • 下载增量包到cache分区
  • 在后台对非活跃槽位进行差分合并
  • 合并完成后重启切换

但这里有个坑:增量合并需要读取旧槽位的数据作为参考。如果旧槽位的数据被篡改或损坏,合并就会失败。我建议在合并前先校验旧槽位分区的哈希值,确保数据完整性。

实际项目中的注意事项

做了几个A/B分区项目后,我总结了几条实战经验:

  1. 存储空间要预留够:每个槽位至少需要系统分区大小+20%的余量。别问我怎么知道的——有一次产品经理说"省点存储成本",结果升级时空间不够,回滚都回滚不了。
  2. Bootloader要支持动态槽位切换:有些老平台的Bootloader写死了启动分区,改起来很麻烦。如果遇到这种情况,建议优先升级Bootloader。
  3. 测试要覆盖回滚场景:我习惯在测试用例里专门写一个"升级到一半拔电"的用例,反复跑几十次,确保回滚逻辑可靠。
  4. 用户数据分区不要做A/B:data分区是用户数据,不能随便覆盖。A/B分区只针对系统分区,data分区用FBE(基于文件的加密)保护就好。

重要提醒:A/B分区不是万能的。如果Bootloader本身损坏了,两个槽位都启动不了,那就真的变砖了。所以Bootloader分区一定要有写保护,最好放在只读区域。

好了,A/B分区的内容就讲到这里。这套机制虽然增加了存储成本,但换来了用户无感知的升级体验和可靠的失败回滚。在我看来,这笔买卖是划算的。


公众号:蓝海资料掘金营,微信deep3321