对 Zircon 系统调用结构体进行版本控制

本指南介绍了如何在不破坏现有二进制文件的情况下,更改跨越 Zircon 系统调用边界的结构体。此过程在过去称为结构体版本控制或结构体演变。目标受众群体是内核开发者以及修改 Zircon 系统接口的任何人(人工或代理)。

如果您要添加、移除、重新排序、调整大小、重命名或更改在 //zircon/system/public/zircon/ 下声明(或在 //sdk/rust/zx-types 中镜像)的任何结构体中字段的含义,请先阅读本指南。

仅由带有 @next 注解的系统调用(在 zircon/syscalls-next.h 中声明)使用的结构体不在本指南的讨论范围内。这些系统调用不属于稳定 ABI,因此其结构遵循不同的规则。

为什么无法就地更改结构体

系统调用 ABI 是内核与针对受支持的 API 级别构建的每个二进制文件之间的协定,包括不与内核一起重新构建的预构建二进制文件(例如 SDK 使用方、预构建软件包和 CTF 测试)。这些二进制文件具有每个结构的大小、布局和含义。

内核只需支持针对受支持的 API 级别构建的二进制文件。 与平台的其他部分一样,内核可以在某个 API 级别停用后停止对该级别的支持。这会产生两个后果:

  • 当某个 API 级别停用后,内核可以停止支持仅针对该级别的二进制文件使用的结构体版本。请参阅移除旧版本。
  • 当前头文件描述了每个受支持的 API 级别,并在需要时使用 availability.h 中的 API 级别宏(例如 ZX_AVAILABLE_SINCE 和 FUCHSIA_API_LEVEL_AT_LEAST)。您无需深入研究头文件的历史记录,也无需考虑已停用的 API 级别,即可找出内核必须支持的结构体版本。

如需详细了解 API 级别及其适用的兼容性保证,请参阅 RFC-0239:平台版本控制实践和 Fuchsia 平台 API 演进指南。

以下任何更改都会在不发出警告的情况下破坏现有二进制文件:

  • 添加字段,即使是在末尾。例如,对于 zx_object_get_info(),当调用方的缓冲区较小时,内核会复制 sizeof(struct) 个字节并返回 ZX_ERR_BUFFER_TOO_SMALL。传递旧结构体大小的缓冲区的旧二进制文件开始失败。
  • 移除、重新排序或调整字段大小。旧二进制文件读取或写入错误的字节。
  • 更改现有字段的含义或单位。布局保持不变,但旧二进制文件会误解该值。

在不更改字段布局或含义的情况下重命名字段可实现 ABI 兼容,但会破坏源代码兼容性。请参阅来源兼容性。

设计理由

版本控制方案遵循以下原则:

  • 保持调用方简单。内核负责支持多个版本。调用方不需要库来对结构体进行编码、解码或转换(就像使用 protobuf 样式编码时一样)。每种受支持的语言都可以通过其原生 FFI 使用普通 C 结构体布局。
  • 保持广告效果。如果单个系统调用目前获取或设置一组相关值,则新版本仍必须允许在单个系统调用中执行此操作。
  • 允许软过渡。接口会逐步演进:在旧版本旁边添加新版本,迁移调用方,然后移除旧版本。

考虑过的替代方案

  • 只能将新字段附加到结构的末尾。当系统调用读取或写入结构体数组(例如 ZX_INFO_PROCESS_MAPS 或 ZX_INFO_PROCESS_VMOS)时,此方法不起作用,因为元素步幅会发生变化。它还会强制将新字段置于可能因对齐填充而浪费空间的位置。借助版本化结构体,每个新版本都可以将字段放置在逻辑上所属的位置。
  • 向每个结构体添加一个 version 字段。这对于结构体数组来说很麻烦,并且需要更多内核工作来处理输出结构体。由于几乎所有采用或返回结构的系统调用都已具有 topic 或 options 参数,因此版本会编码到该参数中。请勿向系统调用结构体添加版本字段。

版本控制方案

您无需就地修改结构体,而是引入新版本的结构体,并继续提供旧版本,直到没有二进制文件依赖于它:

  1. 在版本化名称(例如 zx_info_vmo_v3_t)下对现有结构体进行快照,并在内核中继续支持它。
  2. 使无版本名称 (zx_info_vmo_t) 引用新布局。
  3. 当所有调用方都已针对新版本重新编译,并且没有针对受支持的 API 级别构建的二进制文件使用旧版本时,请删除旧版本。

仅当调用方可以告知内核其使用的版本时,此方案才有效。每个支持版本控制的系统调用在其参数中都有一个版本通道:

系统调用 版本渠道 编码 示例
zx_object_get_info() topic 参数 主题的高 4 位,使用 object.h 中的 __ZX_INFO_TOPIC(topic, version) ZX_INFO_VMO_V3 = __ZX_INFO_TOPIC(23u, 2), ZX_INFO_VMO = __ZX_INFO_TOPIC(23u, 3)
zx_clock_create()、zx_clock_get_details()、zx_clock_update() options 参数 clock.h 中的 ZX_CLOCK_ARGS_VERSION(n)(第 58 位处的 6 位) zx_clock_update_args_v1_t和zx_clock_update_args_v2_t
zx_job_set_policy() topic 参数 policy.h 中的不同主题常量 ZX_JOB_POL_BASIC_V1和ZX_JOB_POL_BASIC_V2

__ZX_INFO_TOPIC 编码有 4 位版本,因此单个 zx_object_get_info() 主题在其生命周期内最多可以有 16 个版本。

没有版本渠道的系统调用

一些系统调用会接受或返回结构体,但没有版本通道:

  • 其结构体被视为最终版本的系统调用。例如,zx_object_wait_many() 接受 zx_wait_item_t 数组。该结构体已包含等待信号所需的一切,因此更改意味着不同的操作(例如等待信号以外的其他内容)。在这种情况下,请添加新的系统调用。
  • 结构格式由其他系统调用确定的系统调用。例如,zx_port_queue() 和 zx_port_wait() 没有版本通道,但它们会交换 zx_port_packet_t(一种带标记的联合体)。可以将大小相同的新数据包类型添加到联合体中,并且 zx_port_create() 的 options 参数可以为端口选择不同的数据包格式。

否则,如果使用或生成该结构体的系统调用没有主题、选项位或供调用方选择版本的其他方式,则您无法使用此方案。您可以选择添加新的系统调用,也可以选择在新的系统调用中添加版本通道,并将调用方迁移到该通道。这两种选项都会更改 Zircon 系统接口;请参阅流程要求。

在设计采用或返回结构体的新系统调用时,请从一开始就包含版本通道(例如 options 中的预留版本位),以便结构体日后可以演进。系统 API 准则要求这样做。

命名惯例

遵循 object.h 中的现有规范:

  • 无版本名称(zx_info_vmo_t、ZX_INFO_VMO)始终指代最新版本。大多数代码都应使用这些。
  • 旧版本在结构体上添加 _vN 后缀(zx_info_vmo_v1_t、zx_info_vmo_v2_t 等),在主题或常量上添加 _VN 后缀(ZX_INFO_VMO_V1 等)。
  • 发生更改的嵌套结构体与其父级一起进行版本控制(例如,zx_info_maps_v2_t 包含 zx_info_maps_mapping_v2_t)。请参阅嵌套和共享结构体。
  • 版本号绝不会重复使用。移除旧版本后,无版本主题会保留其版本位。例如,即使 ZX_INFO_PROCESS_V1 已被移除,ZX_INFO_PROCESS 仍为 __ZX_INFO_TOPIC(3u, 1u)。

嵌套和共享结构体

版本号用于标识内核针对该调用读取或写入的所有内容,而不仅仅是最外层的结构体。将版本渠道选择的结构体视为根。根版本涵盖的 ABI 包括:

  • 根按值嵌入的每个结构体、数组和联合(以递归方式)。
  • 内核通过根中的指针读取或写入的任何数据。

因此,当您更改结构体时,版本号递增会向上传播到可以访问该结构体的每个根:

  • 按值嵌入:如果嵌套结构体的大小发生变化,则包含该结构体的每个结构体的布局都会发生变化,这些结构体的任何数组的步长也会发生变化。即使大小没有变化,旧二进制文件也会误读含义发生变化的字段。
  • 通过指针访问:外部结构的布局不会更改,但旧二进制文件仍会传递或预期所指向数据的旧格式。内核只能通过根的版本来区分格式,因此根需要一个新版本。

由于内核会继续提供旧根,因此已更改的结构体和根之间的每个结构体也需要一个旧副本。例如,当 zx_arm64_exc_data_t 增长时,旧布局会保留为在 ZX_INFO_THREAD_EXCEPTION_REPORT_V1 下提供的一系列版本化结构体:

typedef struct zx_exception_report_v1 {
    zx_exception_header_t header;
    zx_exception_context_v1_t context;   // contains zx_arm64_exc_data_v1_t
} zx_exception_report_v1_t;

版本控制仅向上(朝向根)传播。更改外部结构体不需要您对它包含的结构体进行版本控制。

共享结构体

由于无版本名称始终引用最新版本,因此更改无版本结构体会更改嵌入该结构体的每个结构体,包括您不打算更改的系统调用所使用的结构体。如果这些系统调用中有任何一个未获得版本提升,则会导致静默 ABI 破坏。

例如,zx_exception_context_t 通过以下几种路径到达内核:

  • 在 zx_exception_report_t 内,由 zx_object_get_info() 使用 ZX_INFO_THREAD_EXCEPTION_REPORT 返回。
  • 作为 zx_thread_raise_exception() 的 context 参数。
  • 在 zx_restricted_exception_t(通过 zx_exception_report_t)中,受限模式写入时不使用任何版本渠道。此结构体目前之所以免检,只是因为它是在不稳定的 zircon/syscalls-next.h 中声明的。

在更改结构体之前,请找到可以访问该结构体的每个根:每个 zx_object_get_info() 主题、通过 options 进行版本控制的每个参数,以及内核遵循的每个指针。对于包含已更改结构的每个结构,请执行以下操作之一:

  • 同时为其设置版本,并与其根智能体保持一致。这需要根节点具有版本渠道。
  • 通过将其更改为显式嵌入 _vN 结构体,将其固定到旧版本,使其布局保持不变。

如果任何受影响的根没有版本通道,请参阅没有版本通道的系统调用。

结构体布局规则

新结构体版本必须遵循系统调用结构体的一般规则:

  • 使用固定宽度类型(uint32_t、uint64_t、zx_koid_t 等)。
  • 使用 uint8_t padding1[4]; 等命名字段使所有内边距显式。 不要依赖隐式编译器填充。
  • 优先使用 uint32_t 或 uint64_t 中的位标志,而不是 bool 字段。
  • 记录每个字段,包括该字段是否仅在某些情况下有效。

分步说明:添加新版本

以下步骤介绍了近期更改(例如引入当前 ZX_INFO_KMEM_STATS (RFC-0262) 以及 RFC-0254 中的 zx_info_vmo_t、zx_info_maps_t 和 zx_info_task_stats_t 版本的更改)所使用的常规单 CL 方法。

1. 更新 C 头文件

在 //zircon/system/public/zircon/syscalls/ 下的相应标头中:

  1. 将当前结构体定义逐字复制到具有下一个 _vN 名称(例如 zx_info_kmem_stats_v1_t)的新结构体。布局必须与当前存在的结构体逐字节相同。
  2. 使用无版本主题的现有数值为该旧结构体添加 _VN 主题或常量。旧二进制文件会继续发送此值。
  3. 将无版本控制的结构体 (zx_info_kmem_stats_t) 修改为新布局。
  4. 提升无版本主题的版本位(例如,从 __ZX_INFO_TOPIC(17u, 0) 提升到 __ZX_INFO_TOPIC(17u, 1))。
  5. 如果您更改的结构体嵌入在其他系统调用结构体中或由其他系统调用结构体指向,请按照嵌套和共享结构体中的说明,对每个结构体及其根结构体进行版本控制或固定。

例如,ZX_INFO_KMEM_STATS 的版本控制方式如下:

-#define ZX_INFO_KMEM_STATS     ((zx_object_info_topic_t) 17u) // zx_info_kmem_stats_t[1]
+#define ZX_INFO_KMEM_STATS     __ZX_INFO_TOPIC(17u, 1)        // zx_info_kmem_stats_t[1]
+#define ZX_INFO_KMEM_STATS_V1  __ZX_INFO_TOPIC(17u, 0)        // zx_info_kmem_stats_v1_t[1]

2. 更新内核

内核必须接受每个受支持的版本。

  • 输出结构体(例如 zx_object_get_info() 主题):以最新的内部形式收集一次信息,然后将其转换为请求的版本,再将其复制出来。请参阅 object_info.rs 中对 ZX_INFO_VMO_V1、ZX_INFO_VMO_V2 和 ZX_INFO_VMO_V3 的处理,以及 memory_stats.rs 中的 vmo_info_to_version 和 MapsInfoVersion 辅助程序。
  • 输入结构体(例如 zx_clock_update() 参数或 zx_job_set_policy() 政策):从版本信道解码版本,验证该版本的缓冲区空间,并将旧版本转换为内部表示法,同时为新字段提供明确定义的默认值。使用 ZX_ERR_INVALID_ARGS 拒绝未知版本。

3. 更新绑定、文档和验证器

在树中搜索现有主题和结构体名称,并更新每个镜像它们的位置。这通常包括:

4. 添加测试

添加或更新 Zircon 核心测试(例如,在 //zircon/system/utest/core/ 下),以测试每个受支持的版本,而不仅仅是最新版本。例如,了解 vmo.cc 如何查询 ZX_INFO_VMO_V1、ZX_INFO_VMO_V2、ZX_INFO_VMO_V3 和 ZX_INFO_VMO,并检查共享字段是否一致。

使用 fx core-tests 运行核心测试,并使用 fx build 进行构建。

源代码兼容性

上述方案可为所有现有二进制文件保留 ABI 兼容性。源代码兼容性取决于更改类型:

  • 仅添加字段:使用无版本名称的代码在重新编译时会采用新版本,并且通常会继续编译。依赖于确切大小或每个字段的聚合初始化的代码可能需要更新。
  • 移除或重命名字段:这会破坏使用这些字段的代码的源代码兼容性。在同一 CL 中更新所有树内用户。树外用户可以固定到旧的 _vN 名称,直到他们完成迁移。

如果破坏源代码的更改会影响大量树外代码,请改用 RFC-0074 中所述的基于别名的软转换:

  1. 同时引入 _V1 和 _V2,并将无版本名称暂时别名为 _V1。
  2. 将所有调用方迁移到 _V2。
  3. 将无版本别名重新指向 _V2。
  4. 将调用方迁移回无版本名称。
  5. 移除“_V1”。

移除旧版本

只有在针对受支持的 API 级别构建的二进制文件都无法再使用旧版本后,您才能移除该旧版本。无需考虑以已停用 API 级别为目标平台的二进制文件。在移除之前,请验证以下内容:

  • 没有树内代码引用 _vN_t 结构体或 _VN 常量。
  • 没有预构建的二进制文件、受支持 API 级别的 SDK 使用方或 CTF 测试依赖于它。如有疑问,请咨询 Zircon 内核所有者。

移除 CL 应从 C 头文件、内核处理、Rust 绑定、ABI 验证器条目、工具解码器、vDSO 文档以及该版本的测试中删除旧结构体和常量。如需查看示例,请参阅移除了 ZX_INFO_PROCESS_V1 的 CL(提交 934bcf504d3)。

请勿对剩余版本重新编号,也不要重复使用已移除的版本号。

流程要求

  • 对系统调用结构的更改是对 Zircon 系统接口的更改,需要 API 委员会成员的 API-Review+1。
  • 添加或移除 Zircon 系统接口,或大幅更改其语义,可能需要 RFC。请参阅 RFC-0006:Zircon 的 RFC 流程附录。
  • 在所有版本中更改现有字段的含义属于例外情况,需要明确的理由和 API 审核,如 RFC-0254 中所述。

在先技术

以下 RFC 使用了此方案:

如需了解添加全新系统调用的机制,请参阅添加新的系统调用。