Hotdry.

Article

Rust 程序启动前的隐藏世界:pre-main 初始化与链接器协作实践

深入解析 Rust 程序 main 函数之前的执行流程,涵盖 ELF .init_array 段、#[ctor] 属性、链接器符号与可变数据预初始化的工程实践与权衡。

2026-06-13systems

每个 Rust 程序都有一个共同的入口:fn main()。然而,在这个看似简单的函数被调用之前,操作系统加载器、C 运行时和 Rust 运行时已经完成了大量准备工作。理解这个 "pre-main" 阶段不仅能帮助我们编写更高效的初始化代码,还能解锁一些在常规 Rust 开发中难以实现的模式 —— 比如跨模块的自动注册、零开销的全局数据聚合,以及无需锁机制的可变状态预初始化。

从 _start 到 main:运行时的接力

当操作系统加载器将二进制文件映射到内存后,控制权会转移给 ELF 头中 e_entry 字段指定的地址。在 Linux 上,这个位置通常存放着 _start 符号,它标志着 C 运行时的起点。C 运行时的首要任务之一,就是执行一系列初始化函数,为后续的用户代码铺平道路。

早期的运行时采用静态函数调用树来完成初始化:先初始化内存分配器,再初始化文件 I/O,依此类推。随着运行时功能膨胀,这种硬编码的调用链变得难以维护,且导致二进制体积膨胀。链接器的进化解决了这个问题 —— 通过支持死代码消除,未使用的运行时组件可以被剥离。但这也带来了一个新需求:如何让各个子系统在不强耦合的情况下注册自己的初始化代码?

GCC 引入的 __attribute__((constructor)) 机制成为了事实标准。它的核心思想是将初始化函数的指针放入一个连续的段中,运行时只需遍历这个段并依次调用即可。Linux 上的 glibc 使用 .init_array 段来实现这一机制,并且支持通过数字后缀指定优先级。值得注意的是,优先级 0-100 被运行时保留,用户代码必须使用 101 或更高的优先级。

手动操控 .init_array:底层视角

在 Rust 中,我们可以通过 #[unsafe(link_section = ".init_array.NNNNN")] 属性手动将函数指针注入到初始化段。关键在于理解 .init_array 存储的是函数指针而非函数本身,因此需要一个静态变量来承载这个指针:

#[used]
#[unsafe(link_section = ".init_array.101")]
static INIT_FN: extern "C" fn() = const {
    extern "C" fn init() {
        println!("Running before main");
    }
    init
};

#[used] 属性至关重要 —— 它告诉编译器这个符号必须被保留,防止链接器将其视为死代码而剔除。extern "C" 确保函数使用 C 调用约定,与运行时的期望保持一致。

这种手动方式虽然直观,但跨平台兼容性极差。macOS 使用不同的符号命名规则(section$startsection$end),Windows 则采用完全不同的段排序机制。这正是 ctor crate 存在的价值:它封装了平台差异,提供了简洁的声明式 API。

使用 ctor crate,上述代码可以简化为:

use ctor::ctor;

#[ctor(unsafe, priority = 101)]
fn early_init() {
    println!("Initializing before main");
}

#[ctor] 属性会自动处理函数指针的提取、段的放置以及平台特定的符号命名。unsafe 标记提醒开发者:在 pre-main 阶段,Rust 的标准库可能尚未完全初始化,panic 会导致未定义行为。

ctor 配套的 link-section crate 则解决了另一个问题:如何将分散在多个模块甚至多个 crate 中的数据聚合到连续的内存区域。这依赖于链接器的一个强大特性 —— 自动为段生成边界符号。对于名为 my_data 的段,GNU 链接器会自动创建 __start_my_data__stop_my_data 符号,标记段的起点和终点。

在 Rust 中,我们可以通过 unsafe extern "C" 块引用这些链接器生成的符号:

use std::mem::MaybeUninit;

unsafe extern "C" {
    #[link_name = "__start_my_data"]
    static DATA_START: MaybeUninit<()>;
    #[link_name = "__stop_my_data"]
    static DATA_END: MaybeUninit<()>;
}

MaybeUninit<()> 是一个巧妙的类型选择 —— 这些边界符号本身不携带数据,只有地址有意义。使用 &raw const 可以安全地获取其地址而不触发读取操作。

可变数据的预初始化:无锁安全的窗口期

pre-main 阶段的一个独特优势是单线程保证。在 main 被调用之前,除非显式创建,否则不存在任何线程。这意味着我们可以安全地修改全局可变数据,而无需原子操作或锁机制 —— 这在常规 Rust 代码中几乎是不可能的。

实现这一模式需要几个关键要素。首先,数据必须包装在 UnsafeCell 中,以告知编译器这里存在内部可变性。其次,边界符号也必须使用 MaybeUninit<SyncUnsafeCell<()>> 类型,确保与段的 mutability 属性一致。最后,所有修改必须在 pre-main 阶段完成,且完成后不再持有可变引用。

一个典型的应用场景是字符串驻留池(string interning pool)。我们可以在编译时通过 link_section 收集所有需要驻留的字符串,然后在 pre-main 阶段对它们进行排序,为运行时的二分查找做准备:

#[ctor(unsafe)]
fn sort_interned_strings() {
    let strings: &mut [&'static str] = unsafe { INTERNED_STRINGS.as_mut_slice() };
    strings.sort_unstable();
}

这种模式的优势在于零运行时分配 —— 所有数据在编译时就已经确定并连续存储,pre-main 阶段只是进行原地重排。相比使用 HashMapVec 的动态收集方案,这避免了多次扩容带来的内存碎片。

工程权衡:何时使用与何时避免

pre-main 初始化并非银弹。首先,同优先级内的初始化顺序是不确定的,且高度依赖平台。如果模块 A 和模块 B 都使用优先级 101,无法保证 A 总是在 B 之前执行。其次,#[used] 属性会阻止死代码消除 —— 即使某个注册项从未被使用,它也会留在最终的二进制中。

更严重的是调试工具的兼容性问题。Miri 目前不完全支持 pre-main 构造函数和链接段构造,这意味着依赖这些特性的代码无法通过 Miri 进行 undefined behavior 检测。开发者需要退回到 LLVM 的 sanitizers(ASan、TSan)进行验证。

另一个隐性成本是控制流的可审计性。依赖注入模式虽然解耦了提供者和消费者,但也使得追踪 "谁注册了什么" 变得更加困难。在大型项目中,这可能导致难以定位的初始化顺序问题。

因此,pre-main 初始化最适合以下场景:

  • 需要跨模块自动注册的类型(如 CLI 子命令、HTTP 路由、序列化器)
  • 编译时可确定的全局常量聚合
  • 需要在 main 之前完成的、无依赖关系的初始化工作

而不适合:

  • 依赖复杂初始化顺序的状态
  • 可能失败并需要错误报告的操作(pre-main 无法安全 panic)
  • 需要频繁变更的动态配置

结语

Rust 的 pre-main 阶段提供了一个独特的机会窗口:单线程、高确定性、零竞争。通过理解 ELF 的 .init_array 机制、链接器的段符号生成,以及 Rust 的 UnsafeCell 语义,我们可以实现常规手段难以达成的初始化模式。ctorlink-section 等 crate 将这些底层机制封装为可移植的抽象,使得这种能力对普通开发者也可及。

然而,强大的能力伴随着责任。pre-main 代码运行在标准库尚未完全就绪的环境中,调试工具的支持也尚不完善。在使用这些技术时,必须仔细权衡收益与成本,确保代码的可维护性和可移植性不会被牺牲。


参考来源

  • Grack, "There Is Life Before Main in Rust", 2026
  • ctor crate 文档与 linktime 项目

systems

内容声明:本文无广告投放、无付费植入。

如有事实性问题,欢迎发送勘误至 i@hotdrydog.com