A/B(无缝)系统更新

旧版 A/B 系统更新(也称为无缝更新)可确保在 无线下载 (OTA) 更新期间,磁盘上始终保留可正常启动的系统。这种方法降低了更新后设备无法正常使用的可能性,这意味着可以减少维修中心和保修中心更换设备和重刷设备的次数。ChromeOS 等其他商业级操作系统也成功使用了 A/B 更新。

如需详细了解 A/B 系统更新及其工作原理,请参阅分区选择(槽位)

A/B 系统更新具有以下优势

  • OTA 更新可以在系统运行时进行,而不会中断用户。用户可以在 OTA 期间继续使用设备,更新期间的唯一停机时间是设备重启到更新后的磁盘分区时。
  • 更新后,重启所需的时间不会超过常规重启。
  • 如果 OTA 未能应用(例如,由于刷写错误),则用户不会受到影响。用户将继续运行旧版操作系统,客户端可以自由地重新尝试更新。
  • 如果 OTA 更新已应用但未能启动,设备将重启回旧分区并保持可用状态。客户端可以自由地重新尝试更新。
  • 任何错误(如 I/O 错误)都只会影响未使用的分区集,并且可以重试。此类错误发生的可能性也较低,因为 I/O 负载特意保持较低水平,以避免降低用户体验。
  • 更新可以流式传输到 A/B 设备,无需在安装前下载软件包。流式传输意味着用户无需有足够的可用空间来在 /data/cache 上存储更新软件包。
  • 缓存分区不再用于存储 OTA 更新软件包,因此无需确保缓存分区足够大,可以用于未来的更新。
  • dm-verity 保证设备将启动未损坏的映像。如果设备由于 OTA 错误或 dm-verity 问题而无法启动,设备可以重启到旧映像。(Android Verified Boot 不需要 A/B 更新。)

关于 A/B 系统更新

A/B 更新需要更改客户端和系统。但是,OTA 软件包服务器不应需要更改:更新软件包仍然通过 HTTPS 提供。对于使用 Google OTA 基础架构的设备,系统更改都在 AOSP 中,客户端代码由 Google Play 服务提供。不使用 Google OTA 基础架构的 OEM 将能够重复使用 AOSP 系统代码,但需要提供自己的客户端。

对于提供自己客户端的 OEM,客户端需要执行以下操作

  • 确定何时进行更新。由于 A/B 更新在后台进行,因此不再由用户发起。为了避免中断用户,建议在设备处于空闲维护模式(如夜间)且连接到 Wi-Fi 时安排更新。但是,您的客户端可以使用您想要的任何启发法。
  • 与您的 OTA 软件包服务器签到,并确定是否有可用更新。这应该与您现有的客户端代码基本相同,只是您需要发出信号,表明设备支持 A/B。(Google 的客户端还包含一个立即检查按钮,供用户检查最新更新。)
  • 使用更新软件包的 HTTPS 网址调用 update_engine(假设有可用的软件包)。update_engine 将在流式传输更新软件包时更新当前未使用分区上的原始块。
  • 向您的服务器报告安装成功或失败,基于 update_engine 结果代码。如果成功应用更新,update_engine 将告知引导加载程序在下次重启时启动到新的操作系统。如果新的操作系统启动失败,引导加载程序将回退到旧的操作系统,因此客户端无需执行任何操作。如果更新失败,客户端需要根据详细的错误代码决定何时(以及是否)再次尝试。例如,一个优秀的客户端可以识别出部分(“差异”)OTA 包失败,并尝试使用完整的 OTA 包来代替。

可选地,客户端可以

  • 显示一个通知,询问用户是否重启。如果您想实施一项策略,鼓励用户定期更新,则可以将此通知添加到您的客户端。如果客户端不提示用户,那么用户下次重启时仍然会获得更新。(Google 的客户端具有每次更新可配置的延迟。)
  • 显示一个通知,告知用户他们是否启动到新的操作系统版本,或者他们是否应该这样做但回退到旧的操作系统版本。(Google 的客户端通常两者都不做。)

在系统方面,A/B 系统更新会影响以下内容

  • 分区选择(插槽)、update_engine 守护程序和引导加载程序交互(如下所述)
  • 构建过程和 OTA 更新包生成(在 实施 A/B 更新 中描述)

分区选择(插槽)

A/B 系统更新使用两组分区,称为插槽(通常为插槽 A 和插槽 B)。系统从当前插槽运行,而未使用插槽中的分区在正常运行期间不会被运行系统访问。这种方法通过将未使用的插槽作为回退来提高容错能力:如果在更新期间或更新后立即发生错误,系统可以回滚到旧插槽并继续拥有一个可工作的系统。为了实现此目标,作为 OTA 更新的一部分,不应更新当前插槽使用的任何分区(包括仅有一个副本的分区)。

每个插槽都有一个可启动属性,用于声明该插槽是否包含设备可以从中启动的正确系统。当前插槽在系统运行时是可启动的,但另一个插槽可能具有旧的(仍然正确的)系统版本、较新的版本或无效数据。无论当前插槽是什么,都有一个插槽是活动插槽(引导加载程序将在下次启动时从中启动的插槽)或首选插槽。

每个插槽还具有一个由用户空间设置的成功属性,该属性仅在插槽也是可启动的时才相关。一个成功的插槽应该能够启动、运行和自我更新。一个可启动但未被标记为成功的插槽(在多次尝试从中启动后)应被引导加载程序标记为不可启动,包括将活动插槽更改为另一个可启动插槽(通常是紧接在尝试启动到新的活动插槽之前运行的插槽)。接口的具体细节在 boot_control.h 中定义。

Update engine 守护程序

A/B 系统更新使用一个名为 update_engine 的后台守护程序来准备系统启动到新的、更新的版本。此守护程序可以执行以下操作

  • 读取当前插槽 A/B 分区,并将任何数据写入未使用的插槽 A/B 分区,如 OTA 包的指示。
  • 在预定义的工作流程中调用 boot_control 接口。
  • 在写入所有未使用的插槽分区后,从新的分区运行一个后安装程序,如 OTA 包的指示。(有关详细信息,请参阅 后安装)。

由于 update_engine 守护程序不参与启动过程本身,因此在更新期间,它能执行的操作受到当前插槽中 SELinux 策略和功能的限制(这些策略和功能在系统启动到新版本之前无法更新)。为了保持系统的健壮性,更新过程不应修改分区表、当前插槽中分区的内容或无法通过恢复出厂设置擦除的非 A/B 分区的内容。

Update engine 源代码

update_engine 源代码位于 system/update_engine 中。A/B OTA dexopt 文件在 installd 和包管理器之间拆分

有关工作示例,请参阅 /device/google/marlin/device-common.mk

Update engine 日志

对于 Android 8.x 及更早版本,update_engine 日志可以在 logcat 和错误报告中找到。为了使 update_engine 日志在文件系统中可用,请将以下更改补丁到您的构建中

这些更改将最新 update_engine 日志的副本保存到 /data/misc/update_engine_log/update_engine.YEAR-TIME。除了当前日志外,最新的五个日志也保存在 /data/misc/update_engine_log/ 下。具有 log 组 ID 的用户将能够访问文件系统日志。

引导加载程序交互

boot_control HAL 被 update_engine(以及可能的其他守护程序)使用,以指示引导加载程序从哪里启动。常见的示例场景及其相关状态包括以下内容

  • 正常情况:系统从其当前插槽(插槽 A 或插槽 B)运行。到目前为止,尚未应用任何更新。系统的当前插槽是可启动的、成功的和活动的插槽。
  • 更新正在进行中:系统从插槽 B 运行,因此插槽 B 是可启动的、成功的和活动的插槽。插槽 A 被标记为不可启动,因为插槽 A 的内容正在更新但尚未完成。在这种状态下重启应继续从插槽 B 启动。
  • 更新已应用,重启挂起:系统从插槽 B 运行,插槽 B 是可启动的和成功的,但插槽 A 被标记为活动的(因此被标记为可启动的)。插槽 A 尚未标记为成功的,引导加载程序应尝试从插槽 A 启动若干次。
  • 系统重启到新更新:系统首次从插槽 A 运行,插槽 B 仍然是可启动的和成功的,而插槽 A 仅是可启动的,并且仍然是活动的但不是成功的。用户空间守护程序 update_verifier 应在进行一些检查后将插槽 A 标记为成功。

流式更新支持

用户设备上的 /data 空间并不总是足够下载更新包。由于 OEM 和用户都不想在 /cache 分区上浪费空间,因此一些用户没有更新,因为设备没有地方存储更新包。为了解决这个问题,Android 8.0 添加了对流式 A/B 更新的支持,该更新在下载时直接将块写入 B 分区,而无需将块存储在 /data 上。流式 A/B 更新几乎不需要临时存储空间,只需要大约 100 KiB 元数据的存储空间。

要在 Android 7.1 中启用流式更新,请 cherry-pick 以下补丁

无论使用 Google 移动服务 (GMS) 还是任何其他更新客户端,Android 7.1 及更高版本都需要这些补丁来支持流式 A/B 更新。

A/B 更新的生命周期

当 OTA 包(在代码中称为有效负载)可供下载时,更新过程开始。设备中的策略可能会根据电池电量、用户活动、充电状态或其他策略来延迟有效负载的下载和应用。此外,由于更新在后台运行,用户可能不知道更新正在进行中。所有这些都意味着更新过程可能在任何时候因策略、意外重启或用户操作而中断。

可选地,OTA 包本身的元数据指示更新可以是流式的;同一个包也可以用于非流式安装。服务器可以使用元数据来告知客户端它是流式的,以便客户端将 OTA 正确地移交给 update_engine。拥有自己的服务器和设备的设备制造商可以通过确保服务器识别更新是流式的(或假设所有更新都是流式的),并且客户端对 update_engine 进行正确的流式调用来启用流式更新。制造商可以使用包是流式变体的事实来向客户端发送一个标志,以触发移交给框架侧作为流式处理。

在有效负载可用后,更新过程如下所示

步骤 活动
1 当前插槽(或“源插槽”)通过 markBootSuccessful() 标记为成功(如果尚未标记)。
2 未使用的插槽(或“目标插槽”)通过调用函数 setSlotAsUnbootable() 标记为不可启动。当前插槽始终在更新开始时标记为成功,以防止引导加载程序回退到未使用的插槽,该插槽很快将具有无效数据。如果系统已达到可以开始应用更新的程度,则当前插槽将被标记为成功,即使其他主要组件已损坏(例如,崩溃循环中的 UI),因为有可能推送新的软件来修复这些问题。

更新有效负载是一个不透明的 blob,其中包含更新到新版本的指令。更新有效负载包括以下内容
  • 元数据。元数据是更新有效负载中相对较小的部分,其中包含操作列表,用于在目标插槽上生成和验证新版本。例如,一个操作可以解压缩某个 blob 并将其写入目标分区中的特定块,或者从源分区读取,应用二进制补丁,然后写入目标分区中的特定块。
  • 额外数据。作为更新有效负载的大部分,与操作关联的额外数据包括这些示例中的压缩 blob 或二进制补丁。
3 下载有效负载元数据。
4 对于元数据中定义的每个操作,按顺序将关联的数据(如果有)下载到内存中,应用该操作,然后丢弃关联的内存。
5 重新读取整个分区,并根据预期哈希进行验证。
6 运行后安装步骤(如果有)。如果在执行任何步骤期间发生错误,则更新失败,并可能使用不同的有效负载重新尝试。如果到目前为止所有步骤都已成功,则更新成功,并执行最后一步。
7 通过调用 setActiveBootSlot()未使用插槽标记为活动插槽。将未使用插槽标记为活动插槽并不意味着它将完成启动。如果引导加载程序(或系统本身)未读取到成功状态,则可以切换回活动插槽。
8 后安装(如下所述)涉及从“新更新”版本运行程序,同时仍旧在旧版本中运行。如果在 OTA 包中定义了此步骤,则此步骤是强制性的,并且程序必须返回退出代码 0;否则,更新将失败。
9 在系统成功启动到新插槽并完成重启后检查后,现在当前的插槽(以前的“目标插槽”)通过调用 markBootSuccessful() 标记为成功。

后安装

对于定义了后安装步骤的每个分区,update_engine 将新分区挂载到特定位置,并执行 OTA 中相对于挂载分区的指定程序。例如,如果后安装程序在系统分区中定义为 usr/bin/postinstall,则将从未使用插槽挂载此分区到固定位置(例如 /postinstall_mount),并执行 /postinstall_mount/usr/bin/postinstall 命令。

为了使后安装成功,旧内核必须能够

  • 挂载新的文件系统格式。文件系统类型不能更改,除非旧内核支持它,包括诸如压缩文件系统(即 SquashFS)时使用的压缩算法等细节。
  • 理解新分区的后安装程序格式。如果使用可执行和可链接格式 (ELF) 二进制文件,它应与旧内核兼容(例如,如果架构从 32 位构建切换到 64 位构建,则在旧的 32 位内核上运行 64 位新程序)。除非指示加载器 (ld) 使用其他路径或构建静态二进制文件,否则库将从旧系统映像而不是新系统映像加载。

例如,您可以将 shell 脚本用作后安装程序,该脚本由旧系统的 shell 二进制文件解释(顶部带有 #! 标记),然后从新环境中设置库路径,以执行更复杂的二进制后安装程序。或者,您可以从专用的较小分区运行后安装步骤,以允许更新主系统分区中的文件系统格式,而不会产生向后兼容性问题或阶梯式更新;这将允许用户直接从工厂映像更新到最新版本。

新的后安装程序受到旧系统中定义的 SELinux 策略的限制。因此,后安装步骤适用于执行给定设备上按设计要求的任务或其他尽力而为的任务。后安装步骤不适合在重启前需要不可预见的权限的一次性错误修复。

选定的后安装程序在 postinstall SELinux 上下文中运行。新挂载分区中的所有文件都将被标记为 postinstall_file,无论它们在重启到新系统后的属性如何。新系统中 SELinux 属性的更改不会影响后安装步骤。如果后安装程序需要额外的权限,则必须将这些权限添加到后安装上下文中。

重启后

重启后,update_verifier 使用 dm-verity 触发完整性检查。此检查在 zygote 之前启动,以避免 Java 服务进行任何不可逆转的更改,从而阻止安全回滚。在此过程中,如果验证启动或 dm-verity 检测到任何损坏,引导加载程序和内核也可能触发重启。检查完成后,update_verifier 将启动标记为成功。

update_verifier 将仅读取 /data/ota_package/care_map.txt 中列出的块,该文件在使用 AOSP 代码时包含在 A/B OTA 包中。Java 系统更新客户端(例如 GmsCore)提取 care_map.txt,在重启设备之前设置访问权限,并在系统成功启动到新版本后删除提取的文件。