There Is Life Before Main in Rust
There Is Life Before Main in Rust
Rust 中 main 函数之前的生活
Disclosures 🧠 This post is 100% human-written. Claude was used for feedback and to assist with the linker symbol diagram. Cursor was used for feedback and to ensure examples were compilable. The author of this post is deeply interested in the topic of life-before-main: he is the author of the ctor crate, and the creator of the linktime project that we’ll be using in the examples below.
披露 🧠 本文由人类 100% 撰写。Claude 被用于提供反馈并协助绘制链接器符号图。Cursor 被用于提供反馈并确保示例代码可编译。本文作者对“main 之前的生活”这一主题深感兴趣:他是 ctor crate 的作者,也是下文中我们将使用的 linktime 项目的创建者。
Every Rust binary has one thing in common: fn main(). If you come from the C world, that might be more familiar as int main(argc, argv). Some platforms might obfuscate it a bit more, but under the hood, every binary has an entrypoint. We’re going to discuss what happens before main and what interesting things we can do there. In addition, we’ll be showing some novel techniques for mutable data that aren’t in common use in the Rust ecosystem today.
每个 Rust 二进制文件都有一个共同点:fn main()。如果你来自 C 语言世界,可能对 int main(argc, argv) 更为熟悉。某些平台可能会对其进行一些混淆,但从底层来看,每个二进制文件都有一个入口点。我们将讨论在 main 之前发生了什么,以及我们可以在那里做哪些有趣的事情。此外,我们还将展示一些在当今 Rust 生态系统中尚不常用的可变数据处理新技术。
This post is a deep dive into some technical details of how Rust source becomes a Rust binary. Some background knowledge may be helpful to the reader, including: References in Rust, Unsafe Rust. 本文深入探讨了 Rust 源码如何转化为 Rust 二进制文件的技术细节。读者具备一些背景知识会有所帮助,包括:Rust 中的引用、Unsafe Rust。
Before main: What might not be familiar to most developers is how you get into the main function. You see, under the hood for every language is the runtime. C has one: the C runtime that you might recognize as libc. Rust also has its own runtime: the Rust standard library. And because C is the lingua franca of runtimes for most executable code, Rust builds its own runtime atop of C’s, effectively building its own higher-level abstraction encapsulating C’s.
在 main 之前:大多数开发者可能不熟悉如何进入 main 函数。你看,每种语言的底层都有一个运行时(runtime)。C 语言有一个:你可能认出的 libc(C 运行时)。Rust 也有自己的运行时:Rust 标准库。由于 C 是大多数可执行代码运行时的通用语言,Rust 在 C 的基础上构建了自己的运行时,实际上是构建了封装 C 运行时的更高层抽象。
A runtime is a bit fuzzy to define. It’s both the executable code that lives on disk and compilable headers and libraries used at compile time. But the purpose of a runtime is always the same: integrating developer code with the platform’s operating system. There’s an entire ecosystem of processing that happens before the function you declared as main starts up.
“运行时”的定义有些模糊。它既指驻留在磁盘上的可执行代码,也指编译时使用的可编译头文件和库。但运行时的目的始终如一:将开发者代码与平台的操作系统集成。在你声明为 main 的函数启动之前,存在着一整套处理生态系统。
C uses this to configure allocation, file access, thread-local storage and other C runtime services. Rust uses this time to configure parts of its own language and runtime. Specifically, Rust has infrastructure to handle panics and unwinding. Rust also needs to translate the C-style program arguments into its own std::env::args interface. The machinery for all this is visible in the Rust compiler project.
C 语言利用这一阶段来配置内存分配、文件访问、线程局部存储和其他 C 运行时服务。Rust 利用这段时间来配置其语言和运行时的部分内容。具体来说,Rust 拥有处理 panic 和栈展开(unwinding)的基础设施。Rust 还需要将 C 风格的程序参数转换为其自身的 std::env::args 接口。所有这些机制都可以在 Rust 编译器项目中看到。
Runtimes make use of this pre-main phase because it guarantees (1) running before user code, and (2) a single-threaded, highly-consistent and predictably-ordered environment, which allow for reliable and deterministic initialization. By not taking advantage of this environment, you are missing out on a very useful bootstrapping phase. We’ll see later on in this post how we can build some useful primitives making use of life before main. 运行时利用这个“main 前阶段”,因为它保证了 (1) 在用户代码之前运行,以及 (2) 一个单线程、高度一致且顺序可预测的环境,这使得可靠且确定性的初始化成为可能。如果不利用这个环境,你就错过了一个非常有用的引导阶段。我们将在本文后面看到如何利用“main 之前的生活”构建一些有用的原语。
Entry Points: A binary starts when the operating system’s loader - the part of the OS that loads the binary into memory and sets up the environment - hands off control. The runtime is responsible for accepting the hand-off from the loader. There’s a platform-specific hook on every OS that accepts the hand-off - to some extent this is the real main.
入口点:当操作系统的加载器(OS 中负责将二进制文件加载到内存并设置环境的部分)移交控制权时,二进制文件就开始运行了。运行时负责接收来自加载器的移交。每个操作系统上都有一个特定于平台的钩子来接收这个移交——在某种程度上,这才是真正的 main。
On Linux, the entry point is stored in the e_entry field of the ELF header, and by default, the linker places the address of a symbol named _start there. A similar hook exists on Windows, and boots the executable in a function named _WinMainCRTStartup. At this point the C runtime has a chance to configure itself, and the way that all runtimes do this is via initialization functions.
在 Linux 上,入口点存储在 ELF 头部的 e_entry 字段中,默认情况下,链接器会将名为 _start 的符号地址放在那里。Windows 上也存在类似的钩子,并在名为 _WinMainCRTStartup 的函数中启动可执行文件。此时,C 运行时有机会配置自身,而所有运行时执行此操作的方式都是通过初始化函数。
In early iterations of runtimes, bootstrapping was a static tree of function calls: initialize file I/O, initialize the allocator, etc. As runtimes became more complex, this tree of function calls became more complex, and binary sizes increased to absorb more C runtime functionality that they may or may not need. Over time, linkers developed the ability to discard unused code before even writing the binary to disk (including unused parts of the C runtime), and with that came a need for a replacement for the static init call trees. 在运行时的早期迭代中,引导是一个静态的函数调用树:初始化文件 I/O、初始化分配器等。随着运行时变得越来越复杂,这个函数调用树也变得更加复杂,二进制文件的大小也随之增加,以容纳更多可能需要或不需要的 C 运行时功能。随着时间的推移,链接器发展出了在将二进制文件写入磁盘之前丢弃未使用代码的能力(包括 C 运行时中未使用的部分),随之而来的是对静态初始化调用树替代方案的需求。
The most popular method of declaring init code came from GCC: __attribute__((constructor)). The way this worked was to place a list of init functions into a contiguous chunk of the binary on disk. When the C runtime started, it could walk through each of these functions and call them, allowing various bits of the C runtime to request initialization without strongly coupling subsystems, and allowing the linker to jettison unused subsystems, init code and all.
声明初始化代码最流行的方法来自 GCC:__attribute__((constructor))。其工作方式是将一系列初始化函数放置在磁盘上二进制文件的连续块中。当 C 运行时启动时,它可以遍历并调用这些函数,从而允许 C 运行时的各个部分请求初始化,而无需强耦合子系统,并允许链接器丢弃未使用的子系统、初始化代码等。
Eventually the need for constructor ordering became important enough that constructors could be given a priority and run in a specific order, allowing the runtime to initialize subsystems before and after each other. E.g., the memory allocation (malloc) subsystem might be needed for buffered file I/O. On most platforms, the linker was called in to do the priority work: each platform ended up with a way to prioritize the order in which data gets written to sections, which allowed for the C runtime to end up with a well-ordered list of function pointers.
最终,对构造函数排序的需求变得非常重要,以至于可以为构造函数分配优先级并按特定顺序运行,从而允许运行时在彼此之前或之后初始化子系统。例如,缓冲文件 I/O 可能需要内存分配 (malloc) 子系统。在大多数平台上,链接器被调用来执行优先级工作:每个平台最终都找到了一种方法来确定数据写入段的顺序,这使得 C 运行时最终获得了一个排序良好的函数指针列表。
We can even build an example of this by hand in Rust using the #[unsafe(link_section = "...")] attribute (try it in the Rust Playground):
我们甚至可以在 Rust 中使用 #[unsafe(link_section = "...")] 属性手动构建一个示例(在 Rust Playground 中尝试):
/// Linux example: the modern glibc runtime uses `.init_array` to hold function
/// pointers, and a numeric suffix allows them to be ordered. Note that priorities
/// less than or equal to 100 are reserved for the runtime itself, so any code that
/// wants to use the C runtime must use a priority of 101 or higher.
// Linux 示例:现代 glibc 运行时使用 `.init_array` 来保存函数指针,
// 数字后缀允许它们进行排序。注意,小于或等于 100 的优先级保留给运行时自身,
// 因此任何想要使用 C 运行时的代码必须使用 101 或更高的优先级。
// On Linux, `.init_array` holds _function pointers_, not functions.
// We can convert a function to a function pointer with one of the below
// blocks which is equivalent to this:
// 在 Linux 上,`.init_array` 保存的是“函数指针”,而不是函数。
// 我们可以通过以下代码块将函数转换为函数指针,这等同于:
// #[used]
// <-- without this, Rust might decide the init function is unused and remove it
// <-- 如果没有这个,Rust 可能会认为初始化函数未被使用并将其移除
// #[unsafe(link_section = ".init_array.NNNNN")]
// <-- the section where we place the function pointer
// <-- 我们放置函数指针的段
// static INIT_ARRAY_FN_PTR: extern "C" fn() = function;
// <-- the function pointer data: we assign the function to it
// <-- 函数指针数据:我们将函数赋值给它