RFC-0082:在 Fuchsia 上运行未经修改的 Linux 程序

RFC-0082:在 Fuchsia 上运行未修改的 Linux 程序
状态已接受
区域
  • 外部 ABI 兼容性
说明

本文档提出了一种在 Fuchsia 上运行未修改的 Linux 程序的机制。

问题
Gerrit 更改
作者
审核人
提交日期(年-月-日)2021-02-11
审核日期(年-月-日)2021-03-25

摘要

本文档提出了一种在 Fuchsia 上运行未修改的 Linux 程序的机制。这些程序在用户空间进程中运行,其系统接口与 Linux ABI 兼容。我们将在名为 starnix 的 Fuchsia 用户空间程序中实现此接口,而不是使用 Linux 内核来实现此接口。在很大程度上,starnix 将充当兼容层,将请求从 Linux 客户端程序转换为适当的 Fuchsia 子系统。为了支持 Linux 系统接口所暗示的所有功能,需要对其中的许多子系统进行详细说明。

设计初衷

目前,为了在 Fuchsia 上运行,软件需要从源代码重新编译为目标 Fuchsia。为了减少在 Fuchsia 上运行所需的源代码修改量,Fuchsia 提供了一个 POSIX 兼容层 POSIX Lite,此软件可以以此为目标平台。POSIX Lite 作为客户端库叠加在底层 Fuchsia 系统 ABI 之上。

不过,POSIX Lite 并非 POSIX 的完整实现。例如,POSIX Lite 不包含暗示可变全局状态的 POSIX 部分(例如 kill 函数),因为 Fuchsia 的设计基于对象功能规范,该规范避免使用可变全局状态,以提供强大的安全保证。相反,使用 POSIX Lite 的软件需要进行修改,以便直接针对这些用例(例如 zx_task_kill 函数)使用 Fuchsia 系统接口。

到目前为止,这种方法效果很好,因为我们可以访问需要在 Fuchsia 上运行的软件的源代码,这让我们能够针对 Fuchsia System ABI 重新编译软件,以及修改需要适应对象功能系统的软件部分。

随着我们希望在 Fuchsia 上运行的软件越来越多,我们遇到了一些我们希望在 Fuchsia 上运行但无法重新编译的软件。例如,Android 应用包含已针对 Linux 编译的原生代码模块。为了在 Fuchsia 上运行此软件,我们需要能够在不修改的情况下运行二进制文件。

设计

在 Fuchsia 上运行 Linux 二进制文件的最直接方法是在虚拟机中运行这些二进制文件,并将 Linux 内核用作虚拟机中的客户机内核。不过,这种方法会导致客户程序难以与 Fuchsia 系统的其余部分集成,因为它们在与系统其余部分不同的操作系统中运行。

Fuchsia 的设计使您可以自行提供运行时,这意味着 Fuchsia 系统不会强制要求您遵循组件的内部结构。为了能够与 Fuchsia 系统作为一等公民进行互操作,组件只需通过适当的 zx::channel 对象发送和接收格式正确的消息即可。

starnix 会在 Fuchsia 中原生创建 Linux 运行时,而不是在虚拟机中运行 Linux 二进制文件。具体而言,Linux 程序可以使用组件清单进行封装,该清单将 starnix 标识为该组件的运行程序。系统会将 Linux 程序的二进制文件提供给 starnix 运行,而不是直接使用 ELF 运行程序

为了执行给定的 Linux 二进制文件,starnix 会手动创建一个 zx::process,其初始内存布局与 Linux ABI 相匹配。例如,starnix 会将程序的 argvenviron 填充为初始线程堆栈上的数据(以及 aux 矢量),而不是将其填充为引导通道上的消息,因为这些数据会填充在 Fuchsia 系统 ABI 中。

系统调用

将二进制文件加载到客户端进程后,starnix 会注册以处理来自客户端进程的所有系统调用(请参阅下文中的系统调用机制)。每当客户端发出系统调用时,Zircon 内核都会将控制权转移给 starnix,后者会根据 Linux 系统调用惯例对系统调用进行解码,并执行系统调用的工作。

例如,如果客户端程序发出 brk 系统调用,starnix 将使用适当的 zx::vmarzx::vmo 操作来操控客户端进程的地址空间,以更改客户端进程的程序断点的地址。在某些情况下,我们可能需要详细说明某个进程(即starnix)来操控另一个进程(即客户端)的地址空间,但早期实验表明,Zircon 已经包含远程地址空间操控所需的大部分机制。

再举一个例子,假设客户端程序发出了 write 系统调用。为了实现与文件相关的功能,starnix 将为每个客户端进程维护一个文件描述符表。收到 write 系统调用后,starnix 会在客户端进程的文件描述符表中查找标识的文件描述符。通常,该文件描述符将由实现 fuchsia.io.File FIDL 协议的 zx::channel 提供支持。如需执行 writestarnix 将设置包含客户端地址空间中数据的 fuchsia.io.File#Write 消息(请参阅内存访问),并通过通道发送该消息,这与 POSIX Lite 在客户端库中实现 write 的方式类似。

全局状态

为了处理暗示可变全局状态的系统调用,starnix 将维护客户端进程之间共享的一些可变状态。例如,starnix 会为其运行的每个客户端进程分配一个 pid_t,并维护一个表,将 pid_t 映射到该进程的底层 zx::process 句柄。如需实现 kill 系统调用,starnix 将在此表中查找给定的 pid_t,并对关联的 zx::process 句柄发出 zx_task_kill 系统调用。

这样,每个 starnix 实例都充当相关 Linux 进程的容器。如果我们希望在两个 Linux 进程之间提供强大的隔离保证,则可以在单独的 starnix 实例中运行这些进程,而无需承担运行多个虚拟机的开销(例如调度复杂性)。

每个 starnix 实例还会公开其全局状态,以供其他 Fuchsia 进程使用。例如,starnix 将维护 AF_UNIX 套接字的命名空间。此命名空间可通过 starnix 运行的 Linux 二进制文件访问,也可通过通过 FIDL 与 starnix 通信的 Fuchsia 二进制文件访问。

Linux 系统接口还暗示了全局文件系统。由于 Fuchsia 没有全局文件系统,因此 starnix 将从自己的命名空间为其客户端进程合成“全局”文件系统。例如,starnix 会将 /data/root 从自己的命名空间挂载为向客户端进程呈现的全局文件系统中的 /。其他挂载点(例如 /proc)可以由 starnix 在内部实现,例如通过查询其正在运行的进程表。

安全

starnix 将尽可能基于底层 Fuchsia 系统的安全机制构建。例如,在与文件系统、网络和图形等系统服务交互时,starnix 主要用作转换层,将请求从 Linux ABI 重新格式化为 Fuchsia 系统 ABI。系统服务将负责强制执行自己的安全不变性,就像对待每个其他客户端一样。不过,starnix 需要实现一些安全机制来保护对其自身服务的访问。例如,starnix 需要确定一个客户端进程是否可以 kill 另一个客户端进程。

为了做出这些安全决策,starnix 将跟踪每个客户端进程的安全上下文,包括 uid_tgid_t、有效 uid_t 和有效 gid_t。需要进行安全检查的操作将使用此安全上下文来做出适当的访问控制决策。最初,我们预计此机制的使用频率不会很高,但随着使用情形变得越来越复杂,我们对访问控制的需求也可能会变得越来越复杂。

她说话时

在选择 starnix 在特定情况下应如何行为时,设计会尽可能让其行为与 Linux 的行为接近。目的是创建一个可运行现有未修改 Linux 二进制文件的 Linux 接口实现。每当 starnix 与 Linux 语义出现分歧时,都有可能导致某些 Linux 二进制文件注意到这种分歧并行为不当。

为了更轻松地讨论这一设计原则,我们会说 starnix 会“按原样”实现 Linux,也就是说,会保留真实 Linux 系统的所有优点、缺点、巧合之处和怪癖。

在某些情况下,按照她所说实现 Linux 接口需要向 Fuchsia 服务添加功能,以提供所需的语义。例如,实现 inotify 需要底层文件系统实现的支持,才能高效运行。我们应该力求以与服务公开的其他功能良好集成的方式将此功能添加到 Fuchsia 服务中。

实现

我们计划将 starnix 实现为 Fuchsia 组件,具体而言,是实现 runner 协议的普通用户空间组件。我们计划在 Rust 中实现 starnix,以帮助避免从客户端进程到 starnix 进程的权限提升。

高管

starnix 的核心部分之一是执行器,它在 Linux 系统接口中实现语义概念。例如,执行器将包含表示线程、进程和文件描述的对象。

该执行器的结构将使其能够独立于 starnix 系统的其余部分进行单元测试。例如,我们将能够进行单元测试,以验证复制文件描述符是否会共享底层文件描述,而无需使用 Linux ABI 运行进程。

Linux 系统调用定义

为了实现 Linux 系统调用,starnix 需要每个 Linux 系统调用的说明,以及任何关联输入或输出参数的用户空间内存布局。这些函数在 Linux uapi 中定义,该文件是一系列独立的 C 头文件。为了在 Rust 中使用这些定义,我们将使用 Rust bindgen 生成 Rust 声明。

Linux uapi 会随时间推移而不断演变。最初,我们将以 Linux 5.10 LTS 中的 Linux uapi 为目标平台,但随着时间的推移,我们可能需要调整所支持的 Linux uapi 的确切版本。

系统调用机制

starnix 的初始实现将使用 Zircon 异常来捕获客户端进程中的系统调用。具体而言,每当客户端进程尝试发出系统调用时,Zircon 都会拒绝该系统调用,因为 Zircon 要求从 Zircon vDSO 中发出系统调用,而客户端进程不知道该 vDSO 的存在。

Zircon 会通过生成 ZX_EXCP_POLICY_CODE_BAD_SYSCALL 异常来拒绝这些系统调用。starnix 进程将通过在每个客户端进程上安装异常处理程序来捕获这些异常。如需接收系统调用的参数,starnix 将使用 zx_thread_read_state 从生成异常的线程中读取寄存器。处理系统调用后,starnix 使用 zx_thread_write_state 设置系统调用的返回值,然后恢复客户端进程中的线程。

此机制可以使用,但性能不太可能足够高,无法发挥作用。构建足够多的 starnix 来运行 Linux 基准测试后,我们可能需要将此系统调用机制替换为更高效的机制。例如,starnix 可能会关联一个 zx::port 来处理来自客户端进程的系统调用,而 Zircon 会将数据包加入到 zx::port 队列中,其中包含客户端进程的寄存器状态。有了基准测试后,我们就可以对各种方法进行原型设计,并选择最佳设计。

内存访问

starnix 的初始实现将使用 zx_process_read_memoryzx_process_write_memory 从客户端进程的地址空间读取和写入数据。这种机制虽然可行,但不受欢迎,原因有二:

  1. 出于安全考虑,这些系统调用在正式版 build 中处于停用状态。
  2. 这些系统调用比直接读取和写入内存的开销要高得多。

构建足够多的 starnix 来运行 Linux 基准测试后,我们希望将此机制替换为更高效的机制。例如,starnix 可能会限制客户端地址空间的大小,并将每个客户端的地址空间映射到其自己的地址空间中(在某个客户端专用偏移量处)。或者,当 starnix 处理来自客户端的系统调用时,Zircon 可能会安排使该客户端的地址空间对该线程可见(例如,类似于内核线程在处理来自这些进程的系统调用时对用户空间进程的地址空间的可见性)。

与 syscall 机制一样,我们可以对各种方法进行原型设计,并在有更多运行代码可用于评估这些方法后,选择最佳设计。

互操作性

我们将使用测试驱动型方法开发 starnix。最初,我们将使用一种简单的实现,足以运行基本的 Linux 二进制文件。我们已经实现了一个原型,该实现可以运行 hello_world.c 程序的 -static-pie build。下一步是清理该原型,并教 starnix 如何运行动态链接的 hello_world.c 二进制文件。

运行这些基本二进制文件后,我们将调出各种代码库中的单元测试二进制文件。这些二进制文件有助于确保我们对 Linux ABI 的实现正确无误(即,与 Linux 所说一致)。例如,我们将运行 Android 源代码树中的某些低级测试二进制文件,以及 Linux 测试项目中的二进制文件。

性能

性能是该项目的关键方面。最初,starnix 的性能会非常差,因为我们将使用效率低下的机制来捕获系统调用和访问客户端内存。不过,一旦我们拥有足够的功能来在 Linux 执行环境中运行基准测试,应该就能大幅优化这些方面。

除了优化这些机制之外,我们还可以将高频率操作分流到客户端。例如,我们可以在将控制权转移到 Linux 二进制文件之前,将代码加载到客户端进程中,从而在客户端地址空间中直接实现 gettimeofday。例如,如果 Linux 二进制文件通过 Linux vDSO 调用 gettimeofdaystarnix 可以提供一个共享库,以替代通过调用 Zircon vDSO 直接实现 gettimeofday 的 Linux vDSO。

安全注意事项

此提案有许多细微的安全注意事项。starnix 进程和客户端进程之间存在信任边界。具体而言,starnix 进程可以包含未完全向客户端公开的对象功能。例如,starnix 进程会为每个客户端进程维护一个文件描述符表。一个客户端进程应能够访问其文件描述符表中存储的句柄,但无法访问其他进程的文件描述符表中存储的句柄。同样,starnix 会维护共享的可变状态,客户端只能在受访问控制的情况下与其交互。

为了提供此信任边界,starnix 在与客户端进程分离的用户空间进程中运行。为避免提升权限,我们计划在 Rust 中实现 starnix,并使用 Rust 的类型系统来避免类型混淆。我们还计划使用 Rust 的类型系统来明确区分客户端数据(例如客户端地址空间中的地址和从客户端地址空间读取的数据)与 starnix 本身维护的可靠数据。

此外,我们还需要考虑 Linux 二进制文件本身的来源,因为 starnix 会直接运行这些二进制文件,而不是在虚拟机或 SFI 容器中运行。我们需要在涉及 Linux 二进制文件的特定端到端产品用例中重新考虑这一点。

starnix 中的访问控制机制需要进行详细的安全评估,最好能让安全团队直接参与其设计和可能的实现。最初,我们预计会采用简单的访问权限控制机制。随着对此机制的要求变得越来越复杂,我们需要进一步的安全审核。

最后,高性能系统调用和客户端内存机制的设计需要仔细的安全审查,尤其是在我们最终为 starnix 使用了异常的地址空间配置或尝试将寄存器状态从客户端线程直接传输到 starnix 线程时。

隐私注意事项

此设计没有任何直接的隐私保护注意事项。不过,一旦我们有了涉及 Linux 二进制文件的特定端到端产品用例,就需要评估该用例对隐私的影响。

测试

测试是构建 starnix 的核心环节。我们将直接对 starnix 执行器进行单元测试。我们还将尝试传递打算在 Linux 上运行的测试二进制文件,以构建 Linux 系统接口的实现。然后,我们将在持续集成环境中运行这些二进制文件,以确保 starnix 不会出现回归问题。

我们还将比较在 starnix 中运行 Linux 二进制文件与在 Fuchsia 上的虚拟机中运行这些二进制文件。我们预计能够在 starnix 中更高效地运行 Linux 二进制文件,但我们应验证这一假设。

文档

在此阶段,我们计划通过此 RFC 记录 starnix。运行非琐碎二进制文件后,我们需要记录如何在 Fuchsia 上运行 Linux 二进制文件。

缺点、替代方案和未知情况

我们可以探索如何在 Fuchsia 上运行未修改的 Linux 二进制文件,这是一个很大的设计空间。本部分总结了主要的设计决策。

Linux 内核

一个重要的设计决策是,是否使用 Linux 内核本身来实现 Linux 系统接口。除了构建 starnix 之外,我们还将构建一种机制,通过在 Machina 虚拟机中运行 Linux 内核来运行未修改的 Linux 二进制文件。这种方法的实现负担较小,因为 Linux 内核旨在在虚拟机中运行,并且 Linux 内核中已包含构成 Linux 系统接口的数百个系统调用的实现。

我们可以通过多种方式使用 Linux 内核。例如,我们可以在虚拟机中运行 Linux 内核,可以使用用户模式 Linux (UML),也可以使用 Linux 内核库 (LKL)。不过,无论我们如何运行它,为了运行 Linux 二进制文件,运行整个 Linux 内核都会产生巨大开销。从本质上讲,Linux 内核的工作是减少高级操作(例如,write)到低级操作(例如DMA 数据到底层硬件)。此核心函数不利于将 Linux 二进制文件集成到 Fuchsia 系统中。我们希望将 write 操作转换为 fuchsia.io/File.Write 操作,而不是将 write 操作还原为 DMA,因为这两种操作在语义层面上是等效的。

同样,Linux 内核附带一个调度器,用于控制其管理的进程中的线程。此功能的目的是将高级操作(例如运行十几个并发线程)简化为低级操作(例如在此处理器上执行此时间片)。同样,这种核心功能会适得其反。如果每个 Linux 二进制文件运行的线程实际上是 Zircon 线程,并且由系统中的所有其他线程所用的调度程序进行调度,那么我们就可以为整个系统计算出更好的调度。

环境

决定直接使用 Fuchsia 系统实现 Linux 系统接口后,我们需要选择在哪里运行该实现。

进程中

我们可以在与 Linux 二进制文件相同的进程中运行实现。例如,POSIX Lite 会使用此方法将 POSIX 操作转换为 Fuchsia 操作。不过,在运行未修改的 Linux 二进制文件时,此方法不太理想,原因有二:

  1. 如果我们在进程中运行实现,则需要从 Linux 二进制文件中“隐藏”实现,因为 Linux 二进制文件不希望系统在其进程中运行(太多)代码。例如,实现对线程本地存储的任何使用都必须注意不要与 Linux 二进制文件的 C 运行时管理的线程本地存储冲突。

  2. Linux 系统接口的许多部分都暗示了可变的全局状态。进程内实现仍需要与进程外服务器协调,才能正确实现接口的这些部分。

出于这些原因,我们选择先从进程外服务器实现入手。不过,为了提升性能,我们可能会将某些操作从服务器分流到客户端。

用户空间

在此方法中,实现会在与 Linux 进程分离的用户空间进程中运行。我们为 starnix 选择了这种方法。这种方法的主要挑战在于,我们需要仔细设计用于系统调用和客户端内存访问的机制,以提供足够的性能。涉及第二个用户空间进程会产生一些不可避免的开销,因为我们需要执行额外的上下文切换才能进入该进程,但其他系统的证据表明,我们可以实现出色的性能。

内核

最后,我们可以在内核中运行该实现。这种方法是传统上为操作系统提供外部个性的方法。不过,我们希望避免采用这种方法,以降低内核的复杂性。内核遵循明确的对象功能规范,有助于更轻松地推理内核的行为,从而提高安全性。

内核内实现相较于用户空间实现的主要优势在于性能。例如,内核可以直接接收系统调用,并且已经具有与客户端地址空间交互的高性能机制。如果我们能够通过用户空间方法实现出色的性能,那么就没有理由在内核中运行实现。

异步信号

Linux 二进制文件希望内核在异步信号处理程序中运行其部分代码。Fuchsia 目前不包含用于直接调用进程中代码的机制,这意味着没有用于调用异步信号处理脚本的明显机制。如果遇到需要支持异步信号处理程序的 Linux 二进制文件,我们就需要设计一种方法来支持该功能。

Futex

Futex 在 Fuchsia 和 Linux 上的运作方式有所不同。在 Fuchsia 上,futex 是根据虚拟地址进行编码的,而 Linux 提供了根据物理地址编码 futex 的选项。此外,Linux futex 还提供 Fuchsia futex 上不提供的各种选项和操作。

为了实现 Linux futex 接口,我们需要在 starnix 中实现 futex,或者向 Zircon 内核添加功能以支持 Linux 二进制文件所需的功能。

在先技术和参考文档

有大量先前技术涉及在非 POSIX 系统上运行 Linux(或 POSIX)二进制文件。本部分介绍了两个相关的系统。

WSL1

本文档中的设计类似于第一个适用于 Linux 的 Windows 子系统 (WSL1),它是 Windows 上 Linux 系统接口的实现,能够运行未经修改的 Linux 二进制文件,包括 Ubuntu、Debian 和 openSUSE 等完整的 GNU/Linux 发行版。与 starnix 不同,WSL1 在内核中运行,并为 NT 内核提供了 Linux 个性。

遗憾的是,WSL1 受到 NTFS 性能特性的阻碍,这些特性与 Linux 软件的预期不符。自那以后,Microsoft 已将 WSL1 替换为 WSL2,后者通过在虚拟机中运行 Linux 内核来提供类似的功能。在 WSL2 中,Linux 软件是针对 ext4 文件系统(而非 NTFS 文件系统)运行的。

我们从 WSL1 中应该得出的一个重要警示是,starnix 的性能将取决于 starnix 向客户端程序公开的基础系统服务的性能。例如,如果我们希望 Linux 软件在 Fuchsia 上能够正常运行,就需要提供与 ext4 性能相当的文件系统实现。

QNX Neutrino

QNX Neutrino 是一种基于微内核的商业操作系统,可提供高品质的 POSIX 实现。本文档中针对 starnix 所述的方法类似于 QNX 中的 proc 服务器,该服务器可处理来自客户端进程的 POSIX 调用,并维护 POSIX 接口隐含的可变全局状态。与 starnix 类似,proc 是 QNX 上的用户空间进程。