锆石 VDSO

Zircon vDSO 是访问 Zircon 中系统调用的唯一方式。vDSO 代表“虚拟动态共享对象”。“动态共享对象”这一术语用于 ELF 格式的共享库。) 它是虚拟的,因为它不是从位于文件系统中的 ELF 文件加载的。而是直接提供 vDSO 映像。

使用 vDSO

系统调用 ABI

vDSO 是 ELF 格式的共享库。它以 ELF 共享库的常规使用方式使用,即根据 ELF 动态符号表.dynsym 部分,通过 DT_SYMTAB 找到)中的符号名称查找入口点。ELF 定义了一种哈希表格式,以按符号表(.hash 部分,通过 DT_HASH 找到)中按名称优化查找;GNU 工具定义了一种经过改进的哈希表格式,包括 .gnu_hashDT_GNU_HASH 中的共享查询格式。DT_GNU_HASH(也可以通过线性搜索直接使用符号表,忽略哈希表。)

vDSO 使用简化布局,该布局没有可写段,也不需要动态重定位。这样可以更轻松地使用系统调用 ABI,而无需实现通用 ELF 加载器和完整的 ELF 动态链接语义。

ELF 符号名称与具有外部关联的 C 标识符相同。每个系统调用都对应于 vDSO 中的一个 ELF 符号,并且具有 C 函数的 ABI。vDSO 函数仅使用特定于机器的基本 C 调用规范,规范机器寄存器和堆栈的使用,这在许多使用 ELF 的系统(如 Linux 和所有 BSD 变体)中很常见。它们不依赖于 ELF 线程本地存储等复杂功能,也不依赖于 Fuchsia 特定的 ABI 元素(例如 SafeStack 不安全的堆栈指针)。如需详细了解系统调用的生命周期及其与 vDSO 的关系,请参阅 Fuchsia 系统调用的生命周期

vDSO 展开信息

vDSO 有一个 PT_GNU_EH_FRAME 类型的 ELF 程序标头。它指向采用 GNU .eh_frame 格式的展开信息,该格式与标准 DWARF Call Frame Information 格式相近。利用这些信息,可以从 vDSO 代码的调用帧中恢复寄存器值,从而通过 vDSO 代码中的 PC 值根据任何线程的寄存器状态重建完整的堆栈轨迹。这些格式及其在 vDSO 中的用法与在 Fuchsia 或使用通用 GNU ELF 扩展的其他系统(例如 Linux 和所有 BSD 变体)上的任何常规 ELF 共享库中都是一样的。

vDSO build ID

与使用通用 GNU 扩展构建的其他 ELF 共享库和可执行文件一样,vDSO 具有 ELF build ID。build ID 是唯一的位字符串,用于标识该二进制文件的特定 build。它以 ELF 备注格式存储,并由 PT_NOTE 类型的 ELF 程序标头指向。名为 "GNU" 且类型为 NT_GNU_BUILD_ID 的备注的载荷是构成 build ID 的一系列字节。

build ID 的一个主要用途是将二进制文件与其调试信息和构建它们的源代码相关联。vDSO 二进制文件本身会与内核二进制文件关联(并嵌入其中),并包含特定于每个内核 build 的信息,因此 vDSO 的 build ID 也可以用于区分内核。

zx_process_start() 参数

程序加载器通过 zx_process_start() 系统调用告知内核启动新进程的第一个线程执行。最后一个参数(zx_process_start() 文档中的 arg2)是传递到寄存器中新线程的普通 uintptr_t 值。

按照惯例,程序加载器会将 vDSO 映射到每个新进程的地址空间(在系统选择的随机位置),并将图片的基础地址传递给 arg2 寄存器中新进程的第一个线程。此地址是在内存中找到 ELF 文件标头的位置,指向查找符号名称并因此进行系统调用所需的所有其他 ELF 格式元素。

PA_VMO_VDSO 句柄

vDSO 映像在编译时嵌入内核。内核会将其作为只读 VMO 公开给用户空间。

当程序加载器设置新进程时,使该进程能够进行系统调用的唯一方式是程序加载器在其第一个线程开始运行之前将 vDSO 映射到新进程的地址空间。因此,每个要启动能够进行系统调用的进程的进程都必须有权访问 vDSO VMO。

按照惯例,在发送到每个新进程的 zx_proc_args_t 引导消息中,vDSO 的 VMO 句柄会从进程传递到进程(请参阅 <zircon/processargs.h>)。句柄表中的 VMO 句柄条目由句柄信息条目 PA_HND(PA_VMO_VDSO, 0) 标识。

vDSO 实现详情

古筝工具

zither 工具会生成构成公共系统调用 API 的 C/C++ 函数声明,以及用于 vDSO 实现的一些 C++ 和汇编代码。公共 API 以及内核与 vDSO 代码之间的私有接口均由 //zircon/vdso 中的 .fidl 文件指定。

系统调用分为以下几组,通过系统调用名称后面是否存在属性来区分:

  • 既不含 vdsocall 也不含 internal 的条目是公共 API 和私有 API 完全相同的简单情况(大多数系统调用)。这些完全由生成的代码实现。公共 API 函数的名称以 _zx_zx_(别名)为前缀。

  • vdsocall 条目只是公共 API 的声明。这些函数通过内核源代码中的普通手写 C++ 代码实现。这些源文件 #include "private.h",然后为系统调用定义 C++ 函数(其名称以 _zx_ 为前缀)。最后,它们会对系统调用的名称使用 VDSO_INTERFACE_FUNCTION 宏,并以 zx_ 为前缀(无前导下划线)。此实现代码可以针对任何其他系统调用条目(无论是公开生成的调用、手写的公开 vdsocall,还是 internal 生成的调用)调用 C++ 函数,但必须使用其私有入口点别名(带有 VDSO_zx_ 前缀)。否则,代码是普通(最小)C++,但必须是无状态且可重入(仅使用其堆栈和寄存器)。

  • internal 条目是私有 API 的声明,仅供 vDSO 实现代码用于进入内核(即由实现 vdsocall 系统调用的其他函数使用)。这些函数在 vDSO 实现中生成函数,其 C 签名与根据系统调用条目签名在公共 API 中声明的相同。不过,这些函数只能通过带有 VDSO_zx_ 前缀的 #include "private.h" 使用,而不是使用 _zx_zx_ 前缀命名。

只读动态共享对象布局

vDSO 是一个常规的 ELF 共享库,可像对待其他库一样进行处理。但它会被特意保留为 ELF 共享库通常允许执行的操作的一小部分。这有几个好处:

  • 将 ELF 映像映射到进程非常简单,且不涉及对 ELF PT_LOAD 程序头文件提供常规支持的任何复杂极端情况。vDSO 的布局可以由特殊情况代码处理,其中不存在仅从 ELF 标头读取几个值的循环。
  • 使用 vDSO 不需要功能完善的 ELF 动态链接。具体来说,vDSO 没有动态重定位。唯一需要完成的设置是在 ELF PT_LOAD 细分中进行映射。
  • vDSO 代码是无状态且可重入的代码。它仅引用调用它时所用的寄存器和堆栈。这使其可在各种上下文中使用,而用户代码本身的组织方式几乎没有限制,这适合操作系统的强制性 ABI。它还使代码更易于推断和审核,以确保稳健性和安全性。

布局就是两个连续的片段,每个片段都包含对齐的整个页面:

  1. 第一个区段是只读的,包含用于动态链接的 ELF 标头和元数据,以及 vDSO 实现专用常量数据。
  2. 第二个代码段是可执行文件,其中包含 vDSO 代码。

整个 vDSO 映像仅包含这两个部分的页面,这些页面在 ELF 映像中呈现,就像它们应该出现在内存中一样。要在 vDSO 中进行映射,只需从 vDSO 的 ELF 标头中收集两个值:每个片段中的页面数。

启动时只读数据

有些系统调用只是返回在整个系统的整个运行时期间保持不变的值,不过系统的 ABI 必须在运行时查询这些值,并且这些值无法编译成用户代码。这些值要么在编译时在内核中固定,要么由内核在启动时根据硬件或启动参数确定。例如 zx_system_get_version_string()zx_system_get_num_cpus()zx_ticks_per_second()

由于这些值是常量,因此无需支付进入内核以读取它们的开销。相反,这些 vDSO 实现是简单的 C++ 函数,仅返回从 vDSO 的只读数据段读取的常量。在编译时固定的值(例如系统版本字符串)会直接编译到 vDSO 中。

对于在启动时确定的值,内核必须修改 vDSO 的内容。这是通过设置 vDSO VMO 的启动时代码实现的,随后它会启动第一个用户空间进程并为其提供 VMO 句柄。在编译时,系统会从将嵌入到内核中的 vDSO ELF 文件提取 vdso_constants 数据结构的 vDSO 映像的偏移量。在启动时,内核会暂时将涵盖 vdso_constants 的 VMO 页面映射到其自己的地址空间,时间足够长,以使用系统当前运行的正确值来初始化结构。

违规处置

vDSO 入口点是进入内核进行系统调用的唯一途径。用于输入内核的机器特定指令(例如,x86 上的 syscall)不是系统 ABI 的一部分,用户代码直接执行此类指令无效。内核与 vDSO 代码之间的接口属于不公开的实现细节。

由于 vDSO 本身是在用户空间中执行的常规代码,因此内核必须可靠地处理从用户空间进入内核模式的所有可能条目。不过,通过强制规定每个内核条目仅来自正确的 vDSO 代码,可以在一定程度上减少潜在的内核 bug。这项强制执行还可以避免用户空间代码规避 ABI 规则(出于无知、恶意或被误导的意图,试图规避官方 ABI 的一些感知限制),这可能会导致专用内核 vDSO 接口成为应用代码的实际 ABI。

内核通过以下两种方式强制正确使用 vDSO:

  1. 它限制了将 vDSO VMO 映射到进程的方式。

    使用 vDSO VMO 调用 zx_vmar_map() 并请求 ZX_VM_PERM_EXECUTE 时,内核会要求映射的偏移量和大小与 vDSO 的可执行段完全匹配。也只允许进行一次此类映射。在进程中建立有效的 vDSO 映射后,便无法将其移除。如果尝试再次将 vDSO 映射到同一进程、从进程取消映射 vDSO 代码,或者对未使用正确偏移量和大小的 vDSO 进行可执行映射,则会失败并显示 ZX_ERR_ACCESS_DENIED

    在编译时,系统会从 vDSO ELF 文件中提取 vDSO 代码段的偏移量和大小,并将其用作内核的映射强制执行代码中的常量。

    在一个进程中建立有效的 vDSO 映射后,内核会记录该进程的地址,以便快速检查它。

  2. 它限制了可用于进入内核的 PC 位置。

    当用户线程进入内核进行系统调用时,寄存器会指示正在调用哪个低级别系统调用。低级系统调用是内核与 vDSO 之间的专用接口;许多系统调用直接与公共 ABI 中的系统调用相对应,但其他的则不对应。

    对于每个低级别系统调用,vDSO 代码中都有一组固定的 PC 位置用于调用该调用。vDSO 的源代码定义了用于标识每个此类位置的内部符号。在编译时,这些位置会从 vDSO 的符号表中提取,并用于生成内核代码,该代码会为每个低级别系统调用定义 PC 有效性谓词。由于系统中所有用户进程使用的 vDSO 代码只有一个定义,因此这些谓词仅检查从 vDSO 代码段开头的已知有效常量偏移量。

    在进入内核进行系统调用时,内核会检查 syscall 指令在 x86 上(或其他机器上的等效指令)的 PC 位置。它会从 PC 中减去 zx_vmar_map() 时为进程记录的 vDSO 代码的基础地址,并将生成的偏移量传递给要调用的系统调用的有效性谓词。如果谓词规则导致 PC 无效,发起调用的线程将无法继续进行系统调用,而是接受类似于因调用未定义或特权机器指令而导致的机器异常的合成异常。

变体

TODO(mcgrathr):vDSO 变体是尚未实际使用的实验性功能。我们提供了概念验证实现和简单的测试,但为了稳健实现此概念并确定将提供哪些变体,还需要执行更多工作。其概念是提供 vDSO 映像的变体,这些变体仅导出完整 vDSO 系统调用界面的一部分。例如,仅供设备驱动程序使用的系统调用可能从用于普通应用代码的 vDSO 变体中省略。