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

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

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

问题
Gerrit 更改
  • 485181
作者
审核人
提交日期(年-月-日)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 的软件需要进行修改,以便直接将 Fuchsia 系统接口用于这些用例(例如 zx_task_kill 函数)。

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

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

设计

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

Fuchsia 旨在让您可以自带运行时,这意味着 Fuchsia 系统不会对组件的内部结构施加任何意见。如需作为一等公民与 Fuchsia 系统进行互操作,组件只需通过适当的 zx::channel 对象发送和接收格式正确的消息。

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

为了执行给定的 Linux 二进制文件,starnix 会手动创建一个初始内存布局与 Linux ABI 相匹配的 zx::process。例如,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,并维护一个映射到该进程底层 zx::process 句柄的表 pid_t。为了实现 kill 系统调用,starnix 将查找此表中的给定 pid_t,并在关联的 zx::process 句柄上发出 zx_task_kill 系统调用。

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

每个 starnix 实例还将公开其全局状态,以供其他紫红色进程使用。例如,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 进程的权限。

Executive

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 的确切版本。

Syscall 机制

starnix 的初始实现将使用 Zircon 异常来捕获客户端进程中的系统调用。具体而言,每当客户端进程尝试发出系统调用时,Zircon 都会拒绝该系统调用,因为 Zircon 要求从 Zircon 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. 出于安全问题的考虑,这些系统调用在正式版中已停用。
  2. 这些系统调用比直接读取和写入内存的开销要大得多。

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

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

互操作性

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

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

性能

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

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

安全注意事项

该方案在安全方面有许多微妙的注意事项。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 内核,可以使用 User-Mode 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

Futexe 工作在 Fuchsia 和 Linux 上有所不同。在 Fuchsia 上,futexe 通过虚拟地址进行键控,而 Linux 提供了用于脱离物理地址的 futex 键的选项。此外,Linux futex 提供了许多 Fuchsia futexe 所不具备的选项和操作。

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

早期技术和参考资料

关于在非 POSIX 系统上运行 Linux(或 POSIX)二进制文件,已有大量技术经验。本部分介绍了两个相关系统。

WSL1

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

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

我们应该从 WSL1 中吸取一个重要的提醒经验,那就是 starnix 的性能将取决于 starnix 向客户端程序公开的底层系统服务的性能。例如,如果我们希望 Linux 软件在 Fuchsia 上顺畅运行,则需要提供性能与 ext4 相当的文件系统实现。

QNX 中微子

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