← 返回信息流
AI 资讯Hacker News·1 天前

Rust 中 main 函数之前仍可执行代码

原标题:There Is Life Before Main in Rust

速览

Rust 传统上依赖 #[init] 或 static 初始化,但存在生命周期和复杂性限制。新特性允许在 main 函数之前定义和执行逻辑,提供更细粒度的控制。这对需要复杂启动逻辑的系统编程具有重要意义。

AI 深度解读

Rust 中 main 之前的世界:深入解析初始化机制与 ctor crate

背景

在 Rust 开发者的普遍认知中,每个二进制程序的入口点都是 fn main()。对于来自 C 语言背景的开发者来说,这对应于 int main(int argc, char *argv[])。尽管不同平台可能会对这些入口点进行一定程度的混淆或封装,但从底层来看,每个可执行文件都有一个明确的入口点。

然而,大多数开发者并不熟悉在 main 函数真正开始执行之前,系统内部发生了什么。正如 C 语言拥有 libc 这样的运行时(Runtime)来处理分配、文件访问和线程局部存储等基础服务一样,Rust 也拥有自己的运行时——即 Rust 标准库。由于 C 是大多数可执行代码运行时的通用语言(lingua franca),Rust 实际上是在 C 运行时之上构建了自己的更高级抽象。

运行时(Runtime)的定义虽然模糊,但其核心目的始终一致:将开发者的代码与操作系统的平台能力集成在一起。在 main 函数启动之前,存在一个完整的预处理生态系统。C 语言利用这一阶段配置分配器和文件系统,而 Rust 则利用这一阶段配置其语言特性,特别是异常处理(panics)和展开(unwinding)基础设施,以及将 C 风格的程序参数转换为 Rust 的 std::env::args 接口。

利用这个“main 之前”的阶段,开发者可以获得一个单线程、高度一致且可预测执行顺序的环境,这对于可靠且确定性的初始化至关重要。忽略这一阶段意味着错过了一个非常有用的引导(bootstrapping)阶段。

核心内容

入口点与运行时接管

二进制程序的启动始于操作系统加载器(Loader)将控制权移交。加载器负责将二进制文件加载到内存并设置环境。运行时负责接受这种移交。每个操作系统都有一个平台特定的钩子(hook)来接受这种移交,这在某种程度上就是真正的“入口点”。

  • Linux:入口点存储在 ELF 头部的 e_entry 字段中。默认情况下,链接器会将名为 _start 的符号地址放置在那里。
  • Windows:存在类似的钩子,以名为 _WinMainCRTStartup 的函数启动可执行文件。

在此阶段,C 运行时有机会配置自身。所有运行时的配置方式都是通过初始化函数(initialization functions)来实现的。

从静态调用树到链接器优化

在早期的运行时迭代中,引导过程是一个静态的函数调用树:初始化文件 I/O、初始化分配器等。随着运行时变得复杂,这种调用树也变得更加复杂,导致二进制文件体积增大,以吸收可能有用也可能无用的 C 运行时功能。

随着时间的推移,链接器开发了在将二进制文件写入磁盘之前就丢弃未使用代码的能力(包括未使用的 C 运行时部分)。随之而来的是,需要一种替代静态初始化调用树的新机制。

GCC 的 __attribute__((constructor)) 方案

目前最流行的声明初始化代码的方法源自 GCC:__attribute__((constructor))。其工作原理是将初始化函数列表放置在磁盘上二进制文件的连续块中。当 C 运行时启动时,它可以遍历这些函数并调用它们。这种方式允许 C 运行时的各个部分请求初始化,而无需强耦合子系统,并允许链接器丢弃未使用的子系统及其初始化代码。

随着需求的发展,构造函数的排序变得至关重要。构造函数被赋予优先级,以便按特定顺序运行。例如,内存分配(malloc)子系统可能需要在缓冲文件 I/O 之前初始化。在大多数平台上,链接器被调用来处理优先级工作:每个平台都有一种方法来确定数据写入段的优先级顺序,从而让 C 运行时获得一个排序良好的函数指针列表。

Rust 中的手动实现示例

我们可以通过 Rust 的 #[unsafe(link_section = "...")] 属性手动构建此类示例。在 Linux 上,.init_array 段用于存放函数指针,数字后缀用于排序。注意,优先级小于或等于 100 的保留给运行时本身,因此使用 C 运行时的代码必须使用 101 或更高的优先级。

#[used]
#[unsafe(link_section = ".init_array.101")]
static INIT_FN_FIRST: extern "C" fn() = const {
    extern "C" fn init() {
        println!("Initializing (first!)");
    }
    init
};

#[used]
#[unsafe(link_section = ".init_array.201")]
static INIT_FN_SECOND: extern "C" fn() = const {
    extern "C" fn init() {
        println!("Initializing (second!)");
    }
    init
};

fn main() {
    println!("Main!")
}

注:上述代码中并未显式调用 init 函数,链接器以某种方式组织了它们,使得 C 运行时自动调用它们。

抽象平台差异:ctorlinktime

由于平台差异巨大(例如 macOS 有 startstop 符号但命名不同,Windows 不支持这些符号但有等效的段排序规则),本文引入了 linktime 项目中的 ctorlink-section crates 来抽象平台特定的差异。

虽然 inventorylinkme 也是基于相同原理构建的流行 crate,但它们存在局限性,不适合本文的示例。ctor crate 的设计目的是以跨平台的方式处理注册构造函数的所有样板代码。

使用 ctor crate 简化后的代码如下:

use ctor::ctor;

#[ctor(unsafe, priority = 101)]
fn init1() {
    println!("Initializing (first)!");
}

#[ctor(unsafe, priority = 201)]
fn init2() {
    println!("Initializing (second)!");
}

fn main() {
    println!("Main!")
}

关键要点

  • main 并非起点:每个 Rust 二进制程序在 fn main() 执行前都有一个关键的引导阶段,由运行时接管。
  • 运行时的作用:Rust 运行时构建在 C 运行时之上,负责处理异常、参数转换等底层任务。
  • 初始化机制的演进:从静态函数调用树演变为基于链接器段的动态初始化列表(如 GCC 的 __attribute__((constructor))),以支持代码裁剪和优先级排序。
  • 平台差异性:Linux、macOS 和 Windows 在入口点符号(如 _start, start)和段名称(如 .init_array)上存在显著差异。
  • 手动实现可行但繁琐:可以使用 #[unsafe(link_section = "...")] 手动控制初始化函数的顺序和位置,但需要处理平台特定的细节(如 Linux 上 .init_array 存放的是函数指针而非函数本身)。
  • 生态工具简化开发ctor crate 提供了跨平台的构造函数注册抽象,隐藏了链接器工作的复杂性,使开发者能以声明式的方式定义初始化逻辑。
  • 单线程确定性main 之前的阶段保证了单线程环境,使得初始化过程具有高度的可预测性和确定性,适合用于建立可靠的基础设施。

意义与影响

这篇文章深入探讨了 Rust 二进制文件形成的技术细节,揭示了 main 函数之前隐藏的世界。对于普通应用开发者而言,理解这一阶段有助于解决复杂的启动问题或优化二进制文件体积。

对于系统级开发者或库作者来说,ctorlinktime 等项目提供了一种强大的机制,可以在程序启动早期执行自定义逻辑,而无需依赖全局状态或复杂的初始化模式。这种“生命在 main 之前”的技术,使得 Rust 能够更灵活地对接底层系统资源,同时保持类型安全和内存安全的优势。

此外,文章指出的 inventorylinkme 的局限性,以及 ctor crate 的替代方案,为 Rust 生态系统中插件系统、钩子(hooks)和动态注册机制的实现提供了新的思路和技术参考。通过利用链接器段的优先级排序,开发者可以构建出更模块化、更可维护的初始化流程。

查看原文 →grack.com