Zircon 程序加载和动态链接

在 Zircon 中,内核并不直接参与正常的程序加载。相反,内核会提供构建用户空间程序加载所用的构建块,例如虚拟内存对象进程虚拟内存地址区域线程

ELF 和系统 ABI

标准 Zircon 用户空间环境对机器代码可执行文件使用可执行且可链接的格式 (ELF),并提供了基于 ELF 的动态链接器和 C/C++ 执行环境。Zircon 进程只能通过 Zircon vDSO 使用系统调用,vDSO 由内核以 ELF 格式提供,并采用基于 ELF 的系统通用的 C/C++ 调用规范。

用户空间代码(如果具备适当的功能)可以使用系统调用直接创建进程和加载程序,而无需使用 ELF,但 Zircon 的机器代码标准 ABI 使用 ELF,如此处所述。

背景:传统 ELF 程序加载

ELF 在 Unix System V 第 4 版中引入,并已成为大多数类似 Unix 的系统的常见标准可执行文件格式。在这些系统中,内核使用 POSIX execve API 将程序加载与文件系统访问集成。这些系统在加载 ELF 程序的方式上存在一些变化,但大多数都遵循以下模式:

  1. 内核按名称加载文件,并检查它是 ELF 还是系统支持的其他类型的文件。您可以在此处完成 #! 脚本处理,并支持非 ELF 格式(如果存在)。
  2. 内核会根据 ELF 映像的 PT_LOAD 程序头文件映射该映像。对于 ET_EXEC 文件,这会将程序的片段放置在 p_vaddr 中指定的内存中的固定地址上。对于 ET_DYN 文件,系统会选择加载程序的第一个 PT_LOAD 的基本地址,并根据其相对于第一个片段的 p_vaddrp_vaddr 放置后续片段。通常,基址是随机选择的 (ASLR)。
  3. 如果存在 PT_INTERP 程序标头,则系统会查找其内容(由 p_offsetp_filesz 提供的 ELF 文件中的字节范围)作为文件名,以查找另一个名为 ELF 解释器的 ELF 文件。此文件必须是 ET_DYN 文件。内核加载可执行文件的方式与加载可执行文件的方式相同,但始终是在自己选择的位置。解释器程序通常是名称类似于 /lib/ld.so.1/lib/ld-linux.so.2 的 ELF 动态链接器,但内核会加载任意命名的文件。
  4. 内核设置堆栈并注册初始线程,然后在选定的入口点地址启动与 PC 一起运行的线程。

    • 入口点是 ELF 文件标头中的 e_entry 值,由基地址调整。如果存在 PT_INTERP,则入口点是解释器的入口点,而不是主可执行文件。
    • 内核设置了一个寄存器和堆栈内容的汇编级协议,供程序接收其参数和环境字符串以及实用值的辅助矢量。如果存在 PT_INTERP,则包含主可执行文件的 ELF 标头中的基地址、入口点和程序标头表地址。利用此信息,动态链接器可以在内存中查找主可执行文件的 ELF 动态链接元数据并完成工作。动态链接启动完成后,动态链接器会跳转到主可执行文件的入口点地址。

Zircon 程序加载的灵感来自这种传统,但具体方式有所不同。采用传统模式(在加载动态链接器之前加载可执行文件)的一个关键原因是,动态链接器随机选择的基地址不得与 ET_EXEC 可执行文件使用的固定地址相交。Zircon 根本不支持固定地址的程序加载(ELF ET_EXEC 文件),仅支持位置独立的可执行文件或 PIE(即 ELF ET_DYN 文件)。

文件系统不属于 Zircon API 的较低层。相反,程序加载基于 VMO 和通过通道使用的 IPC 协议。

程序加载请求以下列内容开头:

  • 包含可执行文件的 VMO 的句柄(需要 ZX_RIGHT_READZX_RIGHT_EXECUTE 权限)
  • 参数字符串列表(在 C/C++ 程序中变为 argv[]
  • 环境字符串列表(在 C/C++ 程序中变为 environ[]
  • 初始句柄的列表,每个句柄都有一个句柄信息条目

系统会处理三种类型的文件:

#! 开头的脚本文件

文件的第一行以 #! 开头,长度不得超过 127 个字符。#! 之后的第一个非空白单词是脚本解释器名称。如果之后有任何内容,它们会共同成为脚本解释器参数。

  • 脚本解释器名称会附加到原始参数列表的前面(变为 argv[0])。
  • 如果有脚本解释器参数,该参数会插入解释器名称和原始参数列表之间(变为 argv[1],原始 argv[0] 变为 argv[2])。
  • 程序加载器通过加载器服务查找脚本解释器名称,以获取新的 VMO。
  • 程序加载程序会在该脚本解释器 VMO 上重启(带有修改后的参数列表),但其他方面都相同。原始可执行文件的 VMO 句柄刚刚关闭;脚本解释器只会获取可用的原始 argv[0] 字符串,而不会获取原始 VMO。 有一个嵌套上限(目前为 5 个),限制了在程序加载失败之前允许的此类重启次数。

不含 PT_INTERP 的 ELF ET_DYN 文件

  • 系统会为第一个 PT_LOAD 区段随机选择一个基址,然后在每个 PT_LOAD 区段中相对于该基址映射。为此,您需要创建一个 VMAR,覆盖从第一个片段的第一页到最后一个片段的整个范围。
  • 系统会创建一个 VMO,并将其映射到另一个随机地址,以保存初始线程的堆栈。如果存在具有非零 p_memszPT_GNU_STACK 程序标头,这将确定堆栈的大小(向上舍入为整个页面)。否则,使用合理的默认堆栈大小。
  • vDSO 也会映射到进程(另一个包含 ELF 映像的 VMO)中,同样是随机基地址。
  • 系统会使用 zx_thread_create() 在进程中创建一个新线程。
  • 创建了一个称为引导加载程序的新渠道。程序加载器以 processargs 协议格式向此通道写入一条消息。此引导消息包含参数和环境字符串,以及来自原始请求的初始句柄。该列表还扩充了以下句柄:

    • 进程本身
    • 其根 VMAR
    • 其初始线程
    • 涵盖可执行文件加载位置的 VMAR
    • 刚刚为堆栈创建的 VMO
    • (可选)默认作业,以便新进程本身可以创建更多进程
    • vDSO VMO(可选),以便新进程可以让其创建的进程自行进行系统调用

    然后,程序加载器会结束其通道的末尾。

  • 初始线程通过 zx_process_start() 系统调用启动:

    • entry 将新线程的 PC 从可执行文件的 ELF 标头设置为 e_entry(按基地址进行调整)。
    • stack 会将新线程的堆栈指针设置为堆栈映射的顶部。
    • arg1引导通道的句柄传输到 C ABI 中的第一个参数寄存器。
    • arg2 将 vDSO 的基地址传递到 C ABI 中的第二个参数寄存器。

    因此,程序入口点可以写为 C 函数:

    noreturn void _start(zx_handle_t bootstrap_channel, uintptr_t vdso_base);
    

包含 PT_INTERP 的 ELF ET_DYN 文件

在这种情况下,程序加载器在读取包含 ELF 可执行文件的 VMO 后,不会直接使用其 PT_INTERP 标头,而是将 PT_INTERP 内容用作 ELF 解释器的名称。此名称用在对加载器服务的请求中,以获取包含 ELF 解释器(另一个 ELF ET_DYN 文件)的新 VMO。然后加载该 VMO,而不是主可执行文件的 VMO。启动方式如上文所述,但有以下不同:

  • processargs 协议中将一条额外的消息写入引导通道,该消息在主引导消息之前。ELF 解释器应使用此加载器引导消息本身,以便它能够执行自己的工作,但会将第二条引导消息保留在通道中,并将引导通道句柄移交给主程序的入口点。加载器引导消息仅包含由程序加载器添加的必要句柄,而不包含加入主引导消息的完整集合以及以下内容:

    • 主 ELF 可执行文件的原始 VMO 句柄
    • 加载器服务的通道句柄

      这些内容允许 ELF 解释器自行从 VMO 加载可执行文件,并使用加载器服务为共享库加载额外的 VMO。该消息还包含参数和环境字符串,以便 ELF 解释器在其日志消息中使用 argv[0],并检查是否存在 LD_DEBUG 等环境变量。

  • PT_GNU_STACK 程序标头会被忽略。相反,程序加载器会选择最小的堆栈大小,该大小刚好足以容纳加载器引导消息,并且还留出一些留白空间供 ELF 解释器的启动代码用作调用帧。这种“呼吸空间”大小在源代码中为 PTHREAD_STACK_MIN,并且经过调整,使得整个堆栈的引导消息大小较小,但只有一个页面,但谨慎的动态链接器实现将有足够的空间进行处理。动态链接器应读取主可执行文件的 PT_GNU_STACK,并切换到一个大小合理的堆栈以供正常使用,然后再跳转到主可执行文件的入口点。

processargs 协议

<zircon/processargs.h> 定义程序加载器在引导加载程序通道上发送的引导消息的协议。进程启动时,会有一个此引导通道的句柄,并且可通过 vDSO 访问系统调用。进程只有这一个句柄,因此在它通过引导通道获得更多信息和句柄之前,只能看到全局系统信息和自己的内存。

processargs 协议是用于在引导通道上发送的消息的单向协议。新进程绝不需要回写到该通道上。程序加载器通常会发送其消息,然后在新进程开始之前关闭通道的末尾。这些消息必须传达新进程所需的所有内容,但用于接收和解码这种格式消息的代码必须在非常受限的环境中运行。当库设施可能不可用时,堆分配不可行且非常重要。

如需详细了解消息格式,请参阅标头文件。预计此临时协议最终将被替换为基于 IDL 的正式协议,但其格式将保持足够简单,以便通过简单的手写代码进行解码。

引导消息传达:

  • 初始句柄的列表
  • 一个对应于每个句柄的 32 位句柄信息条目
  • 标识名信息条目可以引用的名称字符串列表
  • 参数字符串列表(在 C/C++ 程序中变为 argv[]
  • 环境字符串列表(在 C/C++ 程序中变为 environ[]

处理信息输入

标识名有多种用途,由标识名信息条目类型表示:

  • 进行系统调用的进程的基本句柄:processVMARthreadjob
  • channel 传递给加载器服务
  • vDSO VMO
  • 与文件系统相关的句柄:当前目录、文件描述符、命名空间绑定(它们对名称字符串列表中的索引进行编码)
  • 系统进程的特殊句柄:资源VMO
  • 用于较高层或专用协议的其他类型

其中大多数只是由程序加载器传递,不需要知道它们的用途。

加载器服务

在动态链接系统中,可执行文件是指并在运行时使用包含共享库和插件的其他文件。动态链接器作为 ELF 解释器进行加载,并负责在主程序的入口点获得控制权之前访问所有这些附加文件以完成动态链接。

Zircon 的所有标准用户空间都使用动态链接,一直到由 userboot 加载的第一个进程。设备驱动程序和文件系统由以这种方式加载的用户空间程序实现。因此,不能像传统系统那样根据更高层的抽象层(例如文件系统范式)定义程序加载。相反,程序加载仅基于 VMO 和基于通道的简单协议。

加载器服务协议是动态链接器获取 VMO 的方式,这些 VMO 表示它需要作为共享库加载的额外文件。

这是一个简单的 RPC 协议,在 <zircon/processargs.h> 中定义。在动态链接器启动期间,发送加载器服务请求并接收其回复的代码可能无法访问重要的库工具。

ELF 解释器会在其 processargs 引导消息(由句柄信息条目 PA_HND(PA_LDSVC_LOADER, 0) 标识)中接收其加载器服务的通道句柄。所有请求都是使用 zx_channel_call() 发出的同步 RPC。请求和回复都以 zx_loader_svc_msg_t 标头开头;有些包含额外的数据,有些包含 VMO 句柄。请求运算码为:

  • LOADER_SVC_OP_LOAD_SCRIPT_INTERP:字符串 -> VMO 句柄

    程序加载器从 #! 脚本发送脚本解释器名称,并返回 VMO 以代替该脚本执行。

  • LOADER_SVC_OP_LOAD_OBJECT:字符串 -> VMO 句柄

    动态链接器会发送对象(共享库或插件)的名称,并返回包含该文件的 VMO 句柄。

  • LOADER_SVC_OP_CONFIGstring -> reply ignored

    动态链接器会发送一个字符串,用于标识其加载配置。这将影响以后的 LOADER_SVC_OP_LOAD_OBJECT 请求如何决定为给定名称提供哪个特定实现文件。

  • LOADER_SVC_OP_DEBUG_PRINTstring -> reply ignored

    这是一个简单的临时日志记录工具,用于调试动态链接器和早期程序启动问题。这样做非常方便,因为前期启动代码使用的是加载器服务,但还无法访问许多其他句柄或复杂的设施。这项功能日后将被替换为一些简单易用的日志记录工具,该工具无需通过加载器服务执行。

  • LOADER_SVC_OP_LOAD_DEBUG_CONFIG:字符串 -> VMO 句柄

    这是一项面向开发者的功能,可能通常不适用于生产环境。

    程序运行时会发送一个命名某种调试配置的字符串,并返回 VMO 以从中读取配置数据。Sanitizer 运行时会使用此 API 将大型选项文本存储在文件中,而不是直接传递在环境字符串中。

  • LOADER_SVC_OP_PUBLISH_DATA_SINK:string、VMO 句柄 -> reply ignored

    这是一项面向开发者的功能,可能通常不适用于生产环境。

    程序运行时会发送一个名称为“数据接收器”的字符串,并将唯一句柄传输到其想要在其中发布的虚拟机。数据接收器字符串用于标识一种数据类型,VMO 的对象名称可具体标识此 VMO 中的数据集。客户端必须将唯一的句柄转移到 VMO(这样可以防止在接收器不知情的情况下调整 VMO 大小),但它可能仍然会映射 VMO 并继续向其写入数据。代码插桩运行时使用此功能来提供大型二进制轨迹结果。

Zircon 的标准 ELF 动态链接器

上述 ELF 惯例以及 processargs加载器服务协议是程序加载的永久性系统 ABI。程序可以使用符合基本 ELF 格式惯例的机器代码可执行文件的任何实现。该实现可以使用 vDSO 系统调用

ABI、processargs 数据和它认为的加载器服务设施。程序通过这些协议接收的内容和数据的确切详情取决于更高级别的程序环境。Zircon 的系统进程使用 ELF 解释器来实现基本 ELF 动态链接,并通过简单的加载器服务实现。

Zircon 的标准 C 库和动态链接器具有最初派生自 musl统一实现。它由 PT_INTERP 字符串 ld.so.1 标识。它使用 DT_NEEDED 字符串将共享库命名作为加载器服务 对象名称。

简单的加载器服务将请求映射到文件系统访问:

  • script Explainerdebug configuration 名称必须以 / 开头,并用作绝对文件名。
  • 数据接收器名称成为 /tmp 中的子目录,每个发布的 VMO 都会成为该子目录中一个包含 VMO 的对象名称的文件
  • 对象名称作为系统 lib/ 目录中的文件形式搜索。
  • 加载配置字符串会视为子目录名称,后跟 ! 字符。在搜索系统 lib/ 目录中,相关名称的子目录会在 lib/ 本身之前搜索。如果存在 ! 后缀,则仅搜索这些子目录。 例如,排错程序运行时使用 asan,因为该插桩与未插桩的库代码兼容,而使用 dfsan!,因为该插桩要求对进程中的所有代码进行插桩。

使用 LLVM AddressSanitizer 进行插桩的标准运行时版本由 PT_INTERP 字符串 asan/ld.so.1 标识。此版本会在加载共享库之前发送加载配置字符串 asan。启用 SanitizerCoverage 后,它会将 VMO 发布到数据接收器名称 sancov,并使用包含进程 KOID 的 VMO 名称。