Skip to content
HN On Hacker News ↗

There Is Life Before Main in Rust

▲ 93 points 25 comments by mmastrac 4w ago HN discussion ↗

Pangram verdict · v3.3

We believe that this document is fully human-written

0 %

AI likelihood · overall

Human
100% human-written 0% AI-generated
SEGMENTS · HUMAN 5 of 5
SEGMENTS · AI 0 of 5
WORD COUNT 1,777
PEAK AI % 0% · §1
Analyzed
Jun 12
backend: pangram/v3.3
Segments scanned
5 windows
avg 355 words each
Distribution
100 / 0%
human / AI fraction
Verdict
Human
Pangram v3.3

Article text · 1,777 words · 5 segments analyzed

Human AI-generated
§1 Human · 0%

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. 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. 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 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 1, Rust builds its own runtime atop of C’s, effectively building its own higher-level abstraction encapsulating C’s. 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. 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 2 into its own std::env::args interface. The machinery for all this is visible in the Rust compiler project.

§2 Human · 0%

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. Entry Points A binary starts when the operating system’s loader 3 - 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. 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. 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. The most popular method 4 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. 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.

§3 Human · 0%

E.g., the memory allocation (malloc) subsystem might be needed for buffered file I/O. On most platforms 5, 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 6. We can even build an example of this by hand in Rust using the #[unsafe(link_section = "...")] attribute (try it in the 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. // 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: // // #[used] // <-- without this, Rust might decide the init function is unused and remove it // #[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 // // extern "C" fn function() { ... } // <-- the function itself #[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!") } linktime: ctor, link-section and more The examples in this post will work on Linux and various BSDs, but are not designed to be cross-platform examples. For example, macOS has start and stop symbols, but they are named differently 7.

§4 Human · 0%

Windows does not support start and stop symbols, but has a set of rules for sorting sections that is effectively equivalent. Because platforms are so widely variable, we’ll be introducing the ctor and link-section crates (from the linktime project) as a way to abstract away platform-specific differences and hide the general complexity of linker work. The excellent inventory and linkme are two other very popular crates built on the same principles, but have limitations 8 that make them less suitable for the examples in this post. If you’d like to learn more, the link-section crate contains a detailed report on platform-specific behaviour. The ctor crate is designed to handle all of the boilerplate of registering constructors in a cross-platform way. This allows us to simplify our examples above to: 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!") } Note that neither example explicitly calls the init functions. The linker organized them in a way that the C runtime called them for us! Sections and Linker Scripts The process in which constructors are linked isn’t mysterious, though. In fact, compilers allow you to name the location in the binary (on most platforms called a “section”) you want to place any of your data and/or code. And by extension, and as we saw above, Rust allows this as well. The challenge, as we will see, is making use of this organizational feature. Linkers have been the key to C’s ability to target any form of binary for some time. Most linkers allow for developers to provide linker scripts - text files that live alongside your source code (which is compiled to object files) and instruct the linker on how those object files are assembled. Using a linker script, a single C file might become a Linux executable, or a block of raw assembly that lives in the boot sector of a hard drive. Linker scripts also allow for defining virtual symbols - that is, symbols that don’t exist in any source file but can be used by C code to access pointers to the underlying data in the loaded binary.

§5 Human · 0%

Linker scripts are a complex topic and beyond the scope of this post, but we can easily find examples of them in the wild: // Adapted from 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_ = .; } } In the above example, the virtual symbols _TEXT_START_ and _TEXT_END_ are explicitly defined to point to the beginning and end of the .text section, respectively. The period in _TEXT_START_ = .; is a special syntax that refers to a location counter that resolves roughly to the current output address in the binary. Linker Symbols This trips up most developers that encounter it for the first time, but the linker is setting the address of the start and end symbols, and therefore where the static with the same name is placed, and not setting the value of symbols that are pointers. That is to say: the start and stop symbols aren’t a *const Type. The start and stop symbols carry no data themselves and are used for their addresses only! The section consists of the range of data between the start (inclusive) and stop (exclusive) symbols. Section Static Value Linker symbol(s) my_numbers _DATA_1 11 ⎫⎬⎭ _DATA_1, _start_my_numbers _DATA_2 22 _DATA_2 _DATA_3 33 _DATA_3 _DATA_4 44 _DATA_4 (past the end) ↤ _stop_my_numbers Specifying start and end symbols for every section can be complex and tedious in linker scripts, so many linkers 9 eventually gained a feature where they could automatically define symbols bounding all sections in the executable. E.g., for GNU toolchains, a section named MY_SECTION will automatically have symbols __start_MY_SECTION and __stop_MY_SECTION defined. macOS has a similar pattern where it synthesizes a section$start and section$end symbol for each section.