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分区的布局画成下面这张图,这样理解起来更直观:
每个槽位都包含完整的系统分区:boot、system、vendor、product等。Bootloader里有一个slot_suffix变量,用来记录当前该从哪个槽位启动。
我的经验:早期A/B分区实现中,很多厂商只做了system和boot的双备份,忽略了vendor。结果升级后驱动不匹配,WiFi打不开、相机黑屏。我建议所有关键分区都要做A/B,别偷懒。
无缝更新的工作流程
整个流程分三个阶段,我拆开来讲:
阶段一:后台下载与写入
- 用户收到OTA推送,系统在后台下载增量包或完整包
- 下载完成后,
update_engine服务开始将新数据写入非活跃槽位 - 写入过程中,用户正常使用手机,完全无感知
- 写入完成后,设置一个
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分区项目后,我总结了几条实战经验:
- 存储空间要预留够:每个槽位至少需要系统分区大小+20%的余量。别问我怎么知道的——有一次产品经理说"省点存储成本",结果升级时空间不够,回滚都回滚不了。
- Bootloader要支持动态槽位切换:有些老平台的Bootloader写死了启动分区,改起来很麻烦。如果遇到这种情况,建议优先升级Bootloader。
- 测试要覆盖回滚场景:我习惯在测试用例里专门写一个"升级到一半拔电"的用例,反复跑几十次,确保回滚逻辑可靠。
- 用户数据分区不要做A/B:data分区是用户数据,不能随便覆盖。A/B分区只针对系统分区,data分区用FBE(基于文件的加密)保护就好。
重要提醒:A/B分区不是万能的。如果Bootloader本身损坏了,两个槽位都启动不了,那就真的变砖了。所以Bootloader分区一定要有写保护,最好放在只读区域。
好了,A/B分区的内容就讲到这里。这套机制虽然增加了存储成本,但换来了用户无感知的升级体验和可靠的失败回滚。在我看来,这笔买卖是划算的。
公众号:蓝海资料掘金营,微信deep3321