| RFC-0082:在 Fuchsia 上运行未修改的 Linux 程序 | |
|---|---|
| 状态 | 已接受 |
| 区域 |
|
| 说明 | 本文档提出了一种在 Fuchsia 上运行未修改的 Linux 程序的机制。 |
| 问题 | |
| Gerrit 更改 | |
| 作者 | |
| 审核人 | |
| 提交日期(年-月-日) | 2021-02-11 |
| 审核日期(年-月-日) | 2021-03-25 |
摘要
本文档提出了一种在 Fuchsia 上运行未修改的 Linux 程序的机制。这些程序在用户空间进程中运行,其系统接口与 Linux ABI 兼容。我们不会使用 Linux 内核来实现此接口,而是会在名为 starnix 的 Fuchsia 用户空间程序中实现该接口。在很大程度上,starnix 将充当兼容性层,将来自 Linux 客户端程序的请求转换为相应的 Fuchsia 子系统。为了支持 Linux 系统接口所隐含的所有功能,需要详细说明许多子系统。
设计初衷
为了能在 Fuchsia 上运行,软件需要从源代码重新编译为 Fuchsia 目标平台。为了减少在 Fuchsia 上运行所需的源代码修改量,Fuchsia 提供了一个 POSIX 兼容性层 POSIX Lite,该软件可以针对此层进行开发。POSIX Lite 作为客户端库分层在底层 Fuchsia 系统 ABI 之上。
不过,POSIX Lite 并非 POSIX 的完整实现。例如,POSIX Lite 不包含暗示可变全局状态(例如 kill 函数)的 POSIX 部分,因为 Fuchsia 是围绕对象功能原则设计的,该原则避开了可变全局状态,以提供强大的安全保证。相反,使用 POSIX Lite 的软件需要进行修改,以便直接使用 Fuchsia 系统接口来处理这些用例(例如,zx_task_kill 函数)。
到目前为止,这种方法效果良好,因为我们有权访问需要在 Fuchsia 上运行的软件的源代码,这使我们能够为 Fuchsia 系统 ABI 重新编译软件,并修改需要适应对象功能系统的软件部分。
随着我们希望在 Fuchsia 上运行的软件范围不断扩大,我们遇到了一些希望在 Fuchsia 上运行但无法重新编译的软件。例如,Android 应用包含已针对 Linux 编译的原生代码模块。为了在 Fuchsia 上运行此软件,我们需要能够运行二进制文件,而无需对其进行修改。
设计
在 Fuchsia 上运行 Linux 二进制文件的最直接方法是在虚拟机中运行这些二进制文件,并将 Linux 内核作为虚拟机中的客户内核。不过,这种方法很难将 guest 程序与 Fuchsia 系统的其余部分集成,因为它们在与系统其余部分不同的操作系统中运行。
Fuchsia 的设计允许您自带运行时,这意味着 Fuchsia 系统不会对组件的内部结构提出任何意见。为了与 Fuchsia 系统进行一流的互操作,组件只需通过相应的 zx::channel 对象发送和接收格式正确的消息。
starnix 不是在虚拟机中运行 Linux 二进制文件,而是在 Fuchsia 中原生创建 Linux 运行时。具体来说,Linux 程序可以封装在组件清单中,该清单将 starnix 标识为相应组件的运行程序。Linux 程序的二进制文件会提供给 starnix 来运行,而不是直接使用 ELF Runner。
为了执行给定的 Linux 二进制文件,starnix 会手动创建一个具有与 Linux ABI 相匹配的初始内存布局的 zx::process。例如,starnix 会填充程序的 argv 和 environ 作为初始线程堆栈上的数据(以及 aux 向量),而不是作为引导通道上的消息,因为此数据是在 Fuchsia 系统 ABI 中填充的。
系统调用
将二进制文件加载到客户端进程后,starnix 会注册以处理来自客户端进程的所有系统调用(请参阅下面的系统调用机制)。每当客户端发出系统调用时,Zircon 内核都会将控制权转移到 starnix,后者会根据 Linux 系统调用惯例对系统调用进行解码,并执行系统调用的工作。
例如,如果客户端程序发出 brk 系统调用,starnix 将使用适当的 zx::vmar 和 zx::vmo 操作来操纵客户端进程的地址空间,以更改客户端进程的程序中断的地址。在某些情况下,我们可能需要详细说明一个进程(即starnix)来操纵另一个进程(即客户端)的地址空间,但早期实验表明,Zircon 已经包含远程地址空间操纵所需的大部分机制。
再举一个例子,假设客户端程序发出 write 系统调用。为了实现与文件相关的功能,starnix 将为每个客户端进程维护一个文件描述符表。收到 write 系统调用后,starnix 会在客户端进程的文件描述符表中查找已识别的文件描述符。通常,该文件描述符将由实现 fuchsia.io.File FIDL 协议的 zx::channel 提供支持。为了执行 write,starnix 将格式化一条包含来自客户端地址空间的数据(请参阅内存访问)的 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_t、gid_t、有效 uid_t 和有效 gid_t。需要进行安全检查的操作将使用此安全上下文来做出适当的访问权限控制决策。最初,我们预计此机制的使用频率不会太高,但随着应用场景变得越来越复杂,我们对访问控制的需求也可能会变得越来越复杂。
As she is spoke
当面临在特定情况下 starnix 应该如何表现的选择时,设计倾向于尽可能接近 Linux 的行为方式。目的是创建一种 Linux 接口实现,该接口可以运行现有的未修改的 Linux 二进制文件。每当 starnix 与 Linux 语义出现分歧时,我们都会面临一些 Linux 二进制文件注意到这种分歧并表现不当的风险。
为了更轻松地讨论这一设计原则,我们说 starnix 实现 Linux as she is spoke,也就是说,它具有真实 Linux 系统的所有优点、缺点、巧合和怪癖。
在某些情况下,按原样实现 Linux 接口将需要向 Fuchsia 服务添加功能,以提供所需的语义。例如,实现 inotify 需要底层文件系统实现的支持,才能高效运行。我们应以一种能与服务公开的其他功能良好集成的方式,将此功能添加到 Fuchsia 服务中。
实现
我们计划将 starnix 实现为 Fuchsia 组件,具体来说,是一个实现 runner 协议的普通用户空间组件。我们计划在 Rust 中实现 starnix,以帮助避免从客户端进程到 starnix 进程的权限升级。
Executive
starnix 的核心部分之一是 executive,它实现了 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 内部发出,而客户端进程并不知道 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_memory 和 zx_process_write_memory 从客户端进程的地址空间读取和写入数据。此机制可行,但出于以下两个原因,不建议使用:
- 出于安全考虑,这些系统调用在正式版 build 中处于停用状态。
- 这些系统调用比直接读取和写入内存的开销要大得多。
在构建出足够多的 starnix 来运行 Linux 基准测试后,我们将希望用更高效的机制来替换此机制。例如,starnix 可能会限制客户端地址空间的大小,并以某个特定于客户端的偏移量将每个客户端的地址空间映射到其自己的地址空间中。或者,也许当 starnix 处理来自客户端的系统调用时,Zircon 会安排从该线程查看相应客户端的地址空间(例如,类似于内核线程在处理来自用户空间进程的系统调用时如何查看这些进程的地址空间)。
与系统调用机制一样,我们可以先设计多种方法,然后在有更多运行代码可用于评估这些方法时,选择最佳设计。
互操作性
我们将使用测试驱动方法开发 starnix。最初,我们将使用一种简单易懂的实现,足以运行基本的 Linux 二进制文件。我们已经制作了一个原型实现,该实现可以运行 hello_world.c 程序的 -static-pie build。下一步是清理该原型,并教 starnix 如何运行动态链接的 hello_world.c 二进制文件。
运行这些基本二进制文件后,我们将从各种代码库中调出单元测试二进制文件。这些二进制文件将有助于确保我们对 Linux ABI 的实现是正确的(即,与 Linux 保持一致)。例如,我们将运行 Android 源代码树中的一些低级别测试二进制文件,以及 Linux 测试项目中的二进制文件。
性能
性能是此项目的关键方面。最初,starnix 的性能会非常差,因为我们将使用低效的机制来捕获系统调用和访问客户端内存。不过,一旦我们有足够的功能在 Linux 执行环境中运行基准测试,就应该能够大幅优化这些方面。
除了优化这些机制之外,我们还可以将高频操作分流到客户端。例如,我们可以通过在将控制权转移到 Linux 二进制文件之前将代码加载到客户端进程中,直接在客户端地址空间中实现 gettimeofday。例如,如果 Linux 二进制文件通过 Linux vDSO 调用 gettimeofday,starnix 可以提供一个共享库来代替 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 内核,可以使用用户模式 Linux (UML),也可以使用 Linux 内核库 (LKL)。不过,无论我们如何运行,为了运行 Linux 二进制文件而运行整个 Linux 内核的成本都非常高。从根本上讲,Linux 内核的任务是减少高级别操作(例如,write)到低级操作(例如DMA 数据到基础硬件)。对于将 Linux 二进制文件集成到 Fuchsia 系统中,此核心功能会适得其反。我们希望将 write 操作转换为 fuchsia.io/File.Write 操作,而不是将 write 操作缩减为 DMA,因为 fuchsia.io/File.Write 操作在语义级别上是等效的。
同样,Linux 内核附带一个调度器,用于控制其管理的进程中的线程。此功能的目的是将高级别操作(例如,运行十几个并发线程)缩减为低级别操作(例如,在此处理器上执行此时间片)。同样,这项核心功能会适得其反。如果每个 Linux 二进制文件运行的线程实际上是 Zircon 线程,并且由与系统中所有其他线程相同的调度程序进行调度,那么我们可以为整个系统计算出更好的调度。
环境
一旦我们决定使用 Fuchsia 系统直接实现 Linux 系统接口,就需要选择在何处运行该实现。
进程中
我们可以在与 Linux 二进制文件相同的进程中运行该实现。例如,POSIX Lite 使用此方法将 POSIX 操作转换为 Fuchsia 操作。不过,在运行未修改的 Linux 二进制文件时,这种方法不太理想,原因有以下两点:
如果我们要在进程内运行实现,就需要向 Linux 二进制文件“隐藏”实现,因为 Linux 二进制文件不希望系统在其进程中运行(大量)代码。例如,实现对线程本地存储的任何使用都必须注意不要与 Linux 二进制文件的 C 运行时管理的线程本地存储发生冲突。
Linux 系统接口的许多部分都暗示了可变全局状态。进程内实现仍需要与进程外服务器协调,才能正确实现接口的这些部分。
出于这些原因,我们选择从进程外服务器实现开始。不过,为了提高性能,我们可能会将一些操作从服务器卸载到客户端。
用户空间
在此方法中,实现会在独立于 Linux 进程的用户空间进程中运行。我们已为 starnix 选择此方法。这种方法的主要挑战在于,我们需要仔细设计用于系统调用和客户端内存访问的机制,以提供足够的性能。涉及第二个用户空间进程会产生一些不可避免的开销,因为我们需要执行额外的上下文切换才能进入该进程,但其他系统的证据表明,我们可以实现出色的性能。
内核
最后,我们可以在内核中运行该实现。此方法是为操作系统提供外部人格的传统方法。不过,为了降低内核的复杂性,我们希望避免采用这种方法。如果内核遵循清晰的对象功能原则,那么推理内核的行为会容易得多,从而提高安全性。
与用户空间实现相比,内核实现的主要优势在于性能。例如,内核可以直接接收系统调用,并且已经具备与客户端地址空间交互的高性能机制。如果我们能够通过用户空间方法实现出色的性能,那么就没有什么理由在内核中运行该实现。
异步信号
Linux 二进制文件希望内核在异步信号处理程序中运行其部分代码。Fuchsia 目前不包含直接调用进程中代码的机制,这意味着没有明显的机制来调用异步信号处理程序。一旦遇到需要支持异步信号处理程序的 Linux 二进制文件,我们就需要设计一种方法来支持该功能。
Futex
Futex 在 Fuchsia 和 Linux 上的运作方式有所不同。在 Fuchsia 上,futex 是基于虚拟地址键控的,而 Linux 提供了基于物理地址键控 futex 的选项。此外,Linux futexes 还提供 Fuchsia futexes 所不具备的各种选项和操作。
为了实现 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 后来用 WSL2 取代了 WSL1,后者通过在虚拟机中运行 Linux 内核来提供类似的功能。在 WSL2 中,Linux 软件针对 ext4 文件系统(而非 NTFS 文件系统)运行。
我们应从 WSL1 中吸取一个重要的警示教训,那就是 starnix 的性能将取决于 starnix 向客户端程序公开的基础系统服务的性能。例如,如果我们希望 Linux 软件在 Fuchsia 上运行良好,就需要提供性能与 ext4 相当的文件系统实现。
QNX Neutrino
QNX Neutrino 是一款基于微内核的商业操作系统,可提供高质量的 POSIX 实现。本文档中针对 starnix 描述的方法类似于 QNX 中的 proc 服务器,该服务器可处理来自客户端进程的 POSIX 调用,并维护 POSIX 接口隐含的可变全局状态。与 starnix 类似,proc 是 QNX 上的用户空间进程。