在Rust中,main函数之前与之后的生命

Lobsters Hottest 工具

摘要

深入探讨Rust二进制文件中main函数之前所发生的事情,探索运行时初始化、入口点以及可变数据初始化的新颖技术。

<p><a href="https://lobste.rs/s/rs1t8s/there_is_life_before_after_main_rust">评论</a></p>
查看原文
查看缓存全文

缓存时间: 2026/06/11 17:35

# Rust 中 `main` 之前和之后的生命周期 来源:https://grack.com/blog/2026/06/11/life-before-main/ **声明** 🧠 本文 100% 由人类撰写(https://grack.com/assets/2026/06/remarkable_life_after_main.pdf)。Claude 被用于反馈,并协助绘制链接器符号图。Cursor 被用于反馈,并确保示例可编译。本文作者对“main 之前的生命周期”这一主题深感兴趣:他是 `ctor` crate(https://crates.io/crates/ctor)的作者,也是 `linktime` 项目(https://github.com/mmastrac/linktime)的创建者,我们将在下文示例中使用该项目。 每个 Rust 二进制文件都有一个共同点:`fn main()`。如果你来自 C 语言世界,你可能更熟悉 `int main(argc, argv)`。有些平台可能遮掩得更多一些,但在底层,每个二进制文件都有一个入口点。我们将讨论在 `main` *之前* 发生的事情,以及在那个阶段我们可以做哪些有趣的事情。此外,我们还将展示一些*新颖的技术*,用于处理可变数据,这些技术在当今的 Rust 生态系统中并不常见。本文深入探讨了 Rust 源代码如何变成 Rust 二进制文件的一些技术细节。读者可能需要一些背景知识,包括: - Rust 引用(https://doc.rust-lang.org/book/ch04-02-references-and-borrowing.html) - 非安全 Rust(https://doc.rust-lang.org/book/ch20-01-unsafe-rust.html) 但大多数开发者可能不熟悉的是,你是*如何*进入 main 函数的。你看,每种语言底层都有一个**运行时**。C 语言有一个:你可能认识为 `libc` 的 C 运行时(https://en.wikipedia.org/wiki/C_standard_library)。Rust 也有自己的运行时:Rust 标准库。而且由于 C 语言是大多数可执行代码运行时的通用语言¹(https://grack.com/blog/2026/06/11/life-before-main/#fn:0),Rust 将自身的运行时构建在 C 运行时之上,有效地构建了更高层的抽象,封装了 C 运行时。 定义“运行时”有些模糊。它既是磁盘上的可执行代码,也是编译时使用的可编译头文件和库。但运行时的目的始终如一:将开发者代码与平台的操作系统集成。 在你声明的 `main` 函数启动之前,存在一整个处理过程。C 语言利用这段时间来配置分配、文件访问、线程本地存储以及其他 C 运行时服务。Rust 利用这段时间来配置自身语言和运行时的部分。具体来说,Rust 有处理 panic 和展开的底层设施。Rust 还需要将 C 风格的程序参数转换为其自身的 `std::env::args`(https://doc.rust-lang.org/beta/std/env/fn.args.html)接口。所有这些机制都可以在 Rust 编译器项目中看到(https://github.com/rust-lang/rust/blob/main/compiler/rustc_codegen_ssa/src/base.rs#L501)。 运行时利用这个 pre-main 阶段,是因为它能保证:(1)在用户代码之前运行,(2)单线程、高度一致且可预测顺序的环境,这使得可靠且确定性的初始化成为可能。如果不好好利用这个环境,你就错过了一个非常有用的引导阶段。我们将在本文后面看到如何利用 main 之前的生命周期构建一些有用的原语。 ## 入口点 当操作系统加载器²(https://grack.com/blog/2026/06/11/life-before-main/#fn:1)(即操作系统中将二进制文件加载到内存并设置环境的部分)移交控制权时,二进制文件开始运行。运行时负责接受加载器的移交。在每个操作系统上都有一个特定于平台的钩子来接受这个移交——在一定程度上,这才是*真正的* main 函数。在 Linux 上,这个钩子通常命名为 `_start`(https://en.wikipedia.org/wiki/Executable_and_Linkable_Format#:~:text=e_entry),并且链接器自动将具有该名称的任何符号添加到二进制文件中。类似的钩子存在于 Windows 上(https://learn.microsoft.com/en-us/windows/win32/debug/pe-format#:~:text=AddressOfEntryPoint),并在名为 `_WinMainCRTStartup` 的函数中启动可执行文件(https://stackoverflow.com/questions/1583193/what-functions-does-winmaincrtstartup-perform)。 此时,C 运行时有机会进行自我配置,而所有运行时都是通过初始化函数来实现这一点的。在早期的运行时版本中,引导是一个静态的函数调用树:初始化文件 I/O,初始化分配器,等等。随着运行时变得越来越复杂,这个函数调用树也变得更加复杂,二进制文件的大小也随之增加,以吸收他们可能需要也可能不需要的更多 C 运行时功能。随着时间的推移,链接器发展出了在将二进制文件写入磁盘之前丢弃未使用代码的能力(包括 C 运行时未使用的部分),随之而来的是需要一种替代静态初始化调用树的方法。 声明初始化代码最流行的³(https://grack.com/blog/2026/06/11/life-before-main/#fn:2)方法来自 GCC:`__attribute__((constructor))`。它的工作方式是将一系列初始化函数放入二进制文件磁盘上的一个连续块中。当 C 运行时启动时,它可以遍历这些函数并逐个调用,使得 C 运行时的各个部分能够请求初始化,而无需强耦合子系统,并且允许链接器抛弃未使用的子系统、初始化代码以及所有相关内容。 最终,构造函数的排序需求变得足够重要,以至于可以为构造函数赋予优先级,并按特定顺序运行,从而允许运行时在各个子系统之间进行初始化。例如,内存分配(`malloc`)子系统可能被缓存的文件 I/O 所需要。在大多数平台上⁴(https://grack.com/blog/2026/06/11/life-before-main/#fn:3),链接器被调用来完成优先级排序工作:每个平台最终都找到了一种方法来优先级排序数据写入节(section)的顺序,这使得 C 运行时最终得到一个排序良好的函数指针列表⁵(https://grack.com/blog/2026/06/11/life-before-main/#fn:4)。 我们甚至可以在 Rust 中手动构建一个示例,使用 `#[unsafe(link_section = "...")]` 属性(在 Rust Playground 中尝试:https://play.rust-lang.org/?version=stable&mode=debug&edition=2024&gist=61a54d3cde75e732db52558f4ef9381c): ```rust /// Linux 示例:现代 glibc 运行时使用 `.init_array` 来保存函数 /// 指针,数字后缀允许它们排序。注意,优先级小于或等于 100 是为运行时 /// 本身保留的,因此任何想使用 C 运行时的代码必须使用优先级 101 或更高。 // 在 Linux 上,`.init_array` 保存的是 _函数指针_,而不是函数。 // 我们可以使用以下代码块之一将函数转换为函数指针,这等价于: // // #[used] <-- 如果没有这个,Rust 可能会认为初始化函数未被使用而删除它 // #[unsafe(link_section = ".init_array.NNNNN")] <-- 放置函数指针的节 // static INIT_ARRAY_FN_PTR: extern "C" fn() // = function; <-- 函数指针数据:我们将函数赋值给它 // // extern "C" fn function() { ... } <-- 函数本身 #[used] #[unsafe(link_section = ".init_array.101")] static INIT_FN_FIRST: extern "C" fn() = const { extern "C" fn init() { println!("正在初始化(第一个!)"); } init }; #[used] #[unsafe(link_section = ".init_array.201")] static INIT_FN_SECOND: extern "C" fn() = const { extern "C" fn init() { println!("正在初始化(第二个!)"); } init }; fn main() { println!("Main!") } ``` ## linktime:ctor、link-section 等 本文中的示例可以在 Linux 和各种 BSD 系统上运行,但并非设计为跨平台示例。例如,macOS 有 `start` 和 `stop` 符号,但命名方式不同⁶(https://grack.com/blog/2026/06/11/life-before-main/#fn:c0)。Windows 不支持 `start` 和 `stop` 符号,但有一套排序节的规则,实际效果是等价的。由于平台差异巨大,我们将引入 `ctor`(https://crates.io/crates/ctor)和 `link-section`(https://crates.io/crates/link-section)crate(来自 `linktime` 项目(https://github.com/mmastrac/linktime))来抽象平台差异并隐藏链接器工作的通用复杂性。优秀的 `inventory`(https://crates.io/crates/inventory)和 `linkme`(https://crates.io/crates/linkme)crate 也是基于相同原理构建的另外两个非常流行的 crate,但它们有一些限制⁷(https://grack.com/blog/2026/06/11/life-before-main/#fn:c1),使其不太适合本文的示例。如果您想了解更多,`link-section` crate 包含一份关于平台特定行为的详细报告(https://crates.io/crates/link-section#:~:text=Platform%20Support)。 `ctor` crate(https://crates.io/crates/ctor)旨在以跨平台方式处理注册构造函数的所有样板代码。这使我们能够将上面的示例简化为: ```rust use ctor::ctor; #[ctor(unsafe, priority = 101)] fn init1() { println!("正在初始化(第一个!)"); } #[ctor(unsafe, priority = 201)] fn init2() { println!("正在初始化(第二个!)"); } fn main() { println!("Main!") } ``` 请注意,两个示例都没有显式调用 init 函数。链接器以某种方式组织它们,使得 C 运行时为我们调用了它们! ## 节和链接脚本 构造函数链接的过程并不神秘。事实上,编译器允许你命名二进制文件中的位置(在大多数平台上称为“节”),你可以在其中放置任何数据和/或代码。并且,正如我们上面所见,Rust 也允许这样做。挑战在于如何利用这种组织特性。 链接器长期以来一直是 C 语言能够针对任何形式的二进制文件进行编译的关键。大多数链接器允许开发者提供**链接脚本**——与源代码(编译成目标文件)一起存在的文本文件,并指示链接器如何组装这些目标文件。使用链接脚本,单个 C 文件可能变成 Linux 可执行文件,或者变成居住于硬盘引导扇区的原始汇编块。链接脚本还允许定义虚拟符号——即,不在任何源文件中存在,但可以被 C 代码用来访问已加载二进制文件中底层数据的指针的符号。 链接脚本是一个复杂的话题,超出了本文的范围,但我们可以在实践中轻松找到相关示例(https://wiki.osdev.org/Linker_Scripts): ``` // 改编自 https://wiki.osdev.org/Linker_Scripts SECTIONS { .text.start (_KERNEL_BASE_) : { startup.o( .text ) } .text : ALIGN(CONSTANT(MAXPAGESIZE)) { _TEXT_START_ = .; *(.text) _TEXT_END_ = .; } .data : ALIGN(CONSTANT(MAXPAGESIZE)) { _DATA_START_ = .; *(.data) _DATA_END_ = .; } } ``` 在上面的示例中,虚拟符号 `_TEXT_START_` 和 `_TEXT_END_` 被显式定义为指向 `.text` 节的开头和结尾。 `_TEXT_START_ = .;` 中的句点是一种特殊语法,指的是一个*位置计数器*(https://sourceware.org/binutils/docs/ld/Location-Counter.html),它大致解析为二进制文件中的当前输出地址。 ## 链接器符号 这会让第一次遇到它的开发者感到困惑,但链接器是*设置开始和结束符号的地址*,因此也是设置具有相同名称的 `static` 放置的位置,而*不是*设置作为指针的符号的值。也就是说:开始和结束符号本身不是 `*const Type`。开始和结束符号本身不携带任何数据,它们的值仅用于它们的地址!节由开始(包含)和结束(排除)符号之间的数据范围组成。 节 | 静态值 | 链接器符号 ---|---|--- `my_numbers` | `_DATA_1` | ----- `11` | ⎫ | ⎬ | `_DATA_1, _start_my_numbers` `22` | `_DATA_2` | ⎭ | `_DATA_2` | `33` | `_DATA_3` | | `_DATA_3` | `44` | `_DATA_4` | | `_DATA_4` | (超出末尾) | ↤ | `_stop_my_numbers` 在链接脚本中为每个节指定开始和结束符号可能复杂且繁琐,因此许多链接器⁸(https://grack.com/blog/2026/06/11/life-before-main/#fn:5)最终获得了一个特性,可以自动定义界定可执行文件中所有节的符号。例如,对于 GNU 工具链,一个名为 `MY_SECTION` 的节将自动拥有已定义的符号 `__start_MY_SECTION` 和 `__stop_MY_SECTION`。macOS 有一个类似的模式(https://discourse.llvm.org/t/lld-support-for-ld64-mach-o-linker-synthesised-symbols/45145),它为每个节合成一个 `section$start` 和 `section$end` 符号。在 GNU 链接器中,未在链接脚本中显式定义的节被称为“孤儿节”⁹(https://grack.com/blog/2026/06/11/life-before-main/#fn:6)。 需要注意的一点是:如果(且仅当!)节名称与 C 符号名称兼容,链接器将自动为该节定义以 `_start` 和 `_stop` 为前缀的符号。在下面的示例中,我们使用的节名称 `our_strings` 可以工作,但如果我们选择了 `our.strings` 或 `.our_strings`,则不会工作! 你将在下面的示例中看到,开始和结束符号是 `MaybeUninit<()>` 类型。边界符号不包含任何数据,只有它们的地址有意义。对于 Rust 来说,理想的类型是“不透明外部类型”(这将由 `extern_types` 特性实现(https://doc.rust-lang.org/beta/unstable-book/language-features/extern-types.html))。由于这些目前在稳定版 Rust 中尚未实现,`MaybeUninit` 是一个替代品。它向编译器表示数据未初始化,并且通常通过引用读取是不安全的。然而,由于取 `&raw const` 指针(https://blog.rust-lang.org/2024/10/17/Rust-1.82.0/#native-syntax-for-creating-a-raw-pointer)指向一个 `static` 项总是有效的,我们仍然可以安全地捕获其地址而无需读取其值。在 Rust Playground 中尝试:https://play.rust-lang.org/?version=stable&mode=debug&edition=2024&gist=1696bdc67f02992cfde9de752e117e0e ```rust use std::mem::MaybeUninit; #[used] #[unsafe(link_section = "our_strings")] static FIRST_STRING: &'static str = "Hello, "; #[used] #[unsafe(link_section = "our_strings")] static SECOND_STRING: &'static str = "world!"; // 注意:这些不是指针。相反,链接器将边界符号 STATIC_STRING_START 和 STATIC_STRING_END // 放置在该节的开头和结尾! unsafe extern "C" { #[link_name = "__start_our_strings"] static STATIC_STRING_START: MaybeUninit<()>; #[link_name = "__stop_our_strings"] static STATIC_STRING_END: MaybeUninit<()>; } fn main() { let strings: &'static [&'static str] = unsafe { // 安全性:获取开始和结束符号的地址而不读取它们。 let start = &raw const STATIC_STRING_START as *const &'static str; let end = &raw const STATIC_STRING_END as *const &'static str; std::slice::from_raw_parts(start, end.offset_from(start) as usize) }; // "Hello, world!" println!("String: {}", strings.join("")); } ``` `link-section` crate(https://crates.io/crates/link-section)旨在抽象这些链接器节的细节,并将它们转换为传统的 Rust 切片,并提供所有标准的切片操作。我们可以用它来将上面的示例简化为: ```rust use link_section::{in_section, section}; #[section(typed)] static OUR_STRINGS: link_section::TypedSection<&'static str>; #[in_section(OUR_STRINGS)] static FIRST_STRING: &'static str = "Hello, "; #[in_section(OUR_STRINGS)] static SECOND_STRING: &'static str = "world!"; fn main() { println!("String: {}", OUR_STRINGS.join("")); } ``` 在这些示例中,我们是在单个 crate 的单个模块中将项提交到链接节,但这并不是一个限制。你可以在任何你想要的地方将项放入节中——它们会自动链接到一起。

相似文章

自举 Rust 被认为有害

Lobsters Hottest

对 Rust 编译器自举过程的批判性分析,指出与 OCaml 等其他语言相比,其体积过大和依赖臃肿的问题,并主张采用更轻量的方法。

谁在运行你的 Rust Future?动手实践入门异步 Rust

Hacker News Top

这是一套动手实践教程系列,旨在弥合理解异步 Rust 内部机制(Future、poll、Pin、执行器)与使用 Tokio 部署实际异步代码之间的差距,面向熟悉 JavaScript 异步和基础 Rust 的开发者。

Rust 零拷贝页面:我是如何停止焦虑并爱上生命周期的

Hacker News Top

# Rust 零拷贝页面:我是如何停止焦虑并爱上生命周期的 来源:[https://redixhumayun.github.io/databases/2026/04/14/zero-copy-pages-in-rust.html](https://redixhumayun.github.io/databases/2026/04/14/zero-copy-pages-in-rust.html) *你可以在[这里](https://github.com/redixhumayun/simpledb/)找到该项目的源代码* 零拷贝是一种旨在消除内核与用户空间缓冲区之间 CPU 数据复制的技术,尤其在数据处理等高吞吐量应用中极具价值。

Bun 已转换为 Rust。接下来怎么办?

Hacker News Top

Anthropic 收购了 Bun,并使用 Claude Code 智能体在九天内将整个运行时从 Zig 重写为 Rust。该重写通过了 99.8% 的测试,但引入了超过 10,000 个 unsafe 块,引发了对内存安全性益处的质疑。