Android A/B 与 Virtual A/B 更新机制的工程笔记:槽位状态机、boot_control 流程与调试命令
先说结论:A/B 的核心不是“双系统”,而是“可回滚的启动状态机”
A/B(seamless)更新把系统分区拆成 A、B 两组。当前运行槽是 active slot,另一组在正常运行时完全不碰。这样更新写坏了一个槽,bootloader 还能引导另一个,系统不会变砖。
但别把“双槽”理解成“双系统”。它真正复杂的不是分区备份,而是每个槽的 successful / retry-count / unbootable 状态怎么流转。这套状态机才是 bootloader、update_engine、update_verifier 协作的核心。
槽位属性与 bootloader 选槽逻辑
每个槽有两个关键属性:
successful:由用户空间设置,标记这个槽能启动、能运行、能自我更新。retry-count:bootloader 启动一个未标记successful的槽时递减。
一个槽如果可 boot,但多次尝试都没被标记 successful,bootloader 应当把它标记为 unbootable,并把 active 切到另一个可 boot 的槽。通常就是切回“这次尝试启动前正在运行的那个槽”。
bootloader 选择槽的逻辑,按顺序判断:
- 不加载被标记为
unbootable的槽。 - 如果当前槽未标记
successful且retry-count减到 0,则把它标记为unbootable,然后选择另一个非unbootable且successful的槽。 - 如果找不到任何可用槽,进 recovery 或显示错误。
- 启动时如果当前槽没标记
successful,递减retry-count。
注意:set_active 是唯一能清除 unbootable 标志的方法。它会同时清掉 successful、重置 retry count。这意味着调试时如果把槽搞成 unbootable 且没有别的可启动槽,只有靠 bootloader 的 set_active(HAL 层是 setActiveBootSlot)才能救回来。
boot_control HAL 流程:update_engine 的三个关键节点
HAL 定义在 hardware/libhardware/include/hardware/boot_control.h。update_engine 的典型流程,踩过坑的人会记住这三步:
更新开始时:把目标槽标记为
unbootable(setSlotAsUnbootable)。同时当前槽总被标记为successful。原因很直接:防止 bootloader 在更新过程中回退到“即将被写入无效数据的目标槽”。写入完成后:把目标槽标记为 active(
setActiveBootSlot)。但注意,标记 active 不保证它能完成启动。bootloader 或系统在读到successful状态之前,随时可以把 active 切回去。新槽启动并完成后重启检查:调用
markBootSuccessful把当前槽(原目标槽)标记为successful。只有走到这一步,回滚窗口才正式关闭。
坑在于:很多人以为 setActiveBootSlot 就是“切换完成”,其实它只是“下次尝试启动”。真正承诺的是 markBootSuccessful。中间任何一步失败,bootloader 都会按状态机回退。
update_verifier:为什么必须在 zygote 之前做检查
重启进入新槽后,update_verifier 用 dm-verity 做完整性检查。它必须在 zygote 启动前执行,原因很现实:
Java 服务一旦跑起来,可能做不可逆的系统修改(写数据、改配置),导致即使 dm-verity 检测到损坏,也无法安全回滚。
检查期间,bootloader/kernel 如果检测到 verified boot 或 dm-verity 损坏,可以触发重启。检查完成后,update_verifier 才标记启动成功。
所以“启动成功”不是系统能开机就算,而是完整性验证通过之后才算。这是 A/B 回滚机制的最后一道闸门。
A/B 化对分区布局的影响
- 不再需要
recovery分区和cache分区。下载的 OTA 包放在data分区,recovery 镜像代码放进boot分区。 - A/B 化分区命名:
boot_a、boot_b、system_a、system_b、vendor_a、vendor_b。 - 当前 slot 后缀通过 DT 节点
/firmware/android/slot_suffix或内核 cmdline/bootconfig 的androidboot.slot_suffix传递。
调试时要特别注意,如果你在 fastboot 里刷了 boot_a,但内核读到的 slot 后缀不是 _a,那就是 DT/cmdline 没传对,别去查分区表。
测试与调试工具
bootctl(system/extras/bootctl)
常用命令:
# 查看当前运行槽(注意:运行槽和 active 槽可能不一致!)
bootctl get-number
# 查看下次启动要加载的槽
bootctl get-active-boot-slot
# 切换下次启动槽
bootctl set-active-boot-slot SLOT
# 把某槽标记为 unbootable
bootctl set-slot-as-unbootable SLOT
# 查询某槽是否可 boot
bootctl is-slot-bootable SLOT
get-number 和 get-active-boot-slot 不一样,前者是当前跑的,后者是下次启动的。切换时要确认自己改的是不是“下次启动”的目标。
fastboot 对槽的支持
fastboot --slot _a flash boot boot.img # 指定槽刷写
fastboot --set-active _b # 设置 active 槽
fastboot getvar current-slot
fastboot getvar slot-count
fastboot getvar slot-successful:_a
fastboot getvar slot-unbootable:_a
fastboot getvar slot-retry-count:_a
默认情况下,fastboot 刷当前槽。如果你刚切了 active,忘了带 --slot,可能会刷到意料之外的槽。
Virtual A/B(Android 11+):不再保留完整第二套分区
Virtual A/B 的主机制变了:动态分区不再为另一个槽保留完整副本,而是把增量写入 snapshot(COW,写时复制),确认启动成功后再合并回基础分区。合并由内核 dm-user 和用户空间 snapuserd daemon 完成。
压缩快照实验数据:全量 OTA 体积缩小约 45%,增量约 55%。这是官方给的数字,实际取决于分区内容和压缩算法。
合并过程可被中断,重启后恢复。这意味着你不需要在 OTA 后立刻 “等合并完”,重启也不怕,系统会接着合并。
坑点在于:
- boot 时一阶段 init 必须从 ramdisk 启动
snapuserd才能挂载分区。这牵扯到 sepolicy 上下文问题:如果snapuserd的 SELinux 规则没配对,合并流程起不来,分区挂载失败,系统直接无法启动。 - fastboot 新增命令和状态:
fastboot getvar snapshot-update-status
# 返回:merging / snapshotted / none
fastboot snapshot-update merge
fastboot snapshot-update cancel
- 在
MERGING状态下,erase、wipe和set_active会被中止,防止变砖。这是有意为之的防护,不是 bug。调试时别硬来。 flashall前必须先查snapshot-update-status,如果是merging或snapshotted,先执行snapshot-update cancel再刷。否则 fastboot 会拒绝 erase/wipe,导致刷机流程卡住。
什么情况下别这么做 / 坑在哪
- 别在 OTA 后立刻手动 erase 或 wipe。如果 snapshot 还在
MERGING状态,fastboot 会拒绝,甚至可能破坏合并状态导致重启后无法挂载分区。 - 别把
setActiveBootSlot当成启动成功的确认。它只决定“下次尝试启动哪个槽”。真正的成功标志是markBootSuccessful。如果新槽起不来,bootloader 会回退到前一个槽,active 也会被切回去。 - 调试时不要随意把当前槽标记为
unbootable。一旦当前槽和另一个槽都变成unbootable,且没有第三个槽,就只剩 bootloader 的set_active/ HAL 的setActiveBootSlot能清掉unbootable。如果连这个入口都进不去,只能进 recovery 或返厂。 - Virtual A/B 下不要绕过
snapuserd手动挂载分区。尤其是一阶段 init 阶段,没有snapuserd的 COW 映射,挂载的就是合并前的数据,或者直接挂载失败。sepolicy 上下文务必在调试早期就验证。 fastboot flashall前一定先getvar snapshot-update-status。如果返回merging或snapshotted,先snapshot-update cancel。这条是刷机流程里的标准前置检查,省略的话会遇到莫名的FAILED (remote: cannot erase in merging state)。
状态机速查(写给记性不好的人)
| 事件 | 槽状态变化 | 下一次启动结果 |
|---|---|---|
| 更新开始,目标槽设为 unbootable | 目标槽 unbootable=1, successful=0 | 不会启动目标槽 |
| 写入完成,目标槽设为 active | 目标槽 unbootable 被清除(set_active 唯一清除途径),successful=0, retry-count 重置 | 尝试启动目标槽 |
| 启动时发现目标槽未 successful | retry-count 递减 | 如果 retry-count 到 0,标记 unbootable |
| 新槽完成 verifier 检查,markBootSuccessful | successful=1 | 正常驻留 |
最后一条建议:调试 A/B 和 Virtual A/B 时,把 bootctl get-number、get-active-boot-slot、getvar snapshot-update-status 写进你的脚本开头。这三条输出能告诉你:我现在在哪、下次去哪、合并做到哪一步。比看 log 快得多。