DEV Community

Masahiro Morino
Masahiro Morino

Posted on AI-assisted

What Happens Before Rust Calls Your main Function?

When we write a Rust program, fn main() is where our code begins.

Does the CPU jump straight into that function? Who puts the executable into memory, and where does execution actually begin?

Let's inspect a small Rust program and follow what runs before the main we wrote. Looking inside the executable, it turns out there is more than one main.

This experiment uses an Apple Silicon Mac and an ordinary executable that uses the standard library, std. My environment is rustc 1.96.0 on macOS 26.6.2, with tools from Xcode Command Line Tools. Entry mechanisms, internal function names, and addresses can change across operating systems and Rust versions.

An executable needs some preparation before it can run

An executable on disk contains machine code and data. When the CPU executes instructions, though, it uses addresses in the process's memory.

At startup, the OS reads information from the executable and maps its code and data into the process's virtual address space. This does not mean copying the entire file into memory before anything can run. The system can establish mappings and load individual pages as they are needed.

For this macOS executable, the dynamic linker, dyld, also runs. It makes shared libraries available, performs the required reference resolution and initialization, and passes control to the executable's entry point. See the Mach-O format definitions and dyld startup implementation.

The dynamic linker works when the program starts. The build-time linker combines object files into an executable; the dynamic linker loads the shared libraries that executable uses and connects references to functions and other symbols.

Once that preparation is done, do we finally reach our own main?

The OS and dyld prepare the program, the compiler-generated main calls Rust startup code, and that code reaches the user's main

Figure 1: The path we will follow in this executable. Helper functions and details such as library initialization are omitted. The dyld and Rust startup routines are themselves code already running on the CPU.

Build an executable with an ordinary main

No special attributes or unsafe are needed. Save this as main.rs:

fn main() {
    println!("Hello from Rust main");
}
Enter fullscreen mode Exit fullscreen mode

Compile and run it:

rustc main.rs --edition 2024 \
  -C opt-level=0 -C debuginfo=2 --emit=link,llvm-ir
./main
Enter fullscreen mode Exit fullscreen mode
Hello from Rust main
Enter fullscreen mode Exit fullscreen mode

--emit=link,llvm-ir produces both the executable, main, and the LLVM IR, main.ll. I disable optimization for our code to make the calls easier to observe, and include debug information so a debugger can follow functions and source lines. This does not disable optimization in the already-built standard library. See rustc output options and code generation options.

Two main functions?

Disassemble the executable. --demangle makes Rust's encoded symbol names easier to read.

xcrun llvm-objdump --disassemble --demangle main
Enter fullscreen mode Exit fullscreen mode

The output is long. For now, here are just two function headings:

00000001000009f4 <main::main::h2dd6e7c25b480450>:
0000000100000a2c <_main>:
Enter fullscreen mode Exit fullscreen mode

Wait. Two mains? And at different addresses?

In main::main, the first main is our crate's name. The second is the function we defined. A hash is appended to distinguish symbols. Inside this function, I found the code that prints our message.

The other function, _main, is an entry wrapper generated by the compiler for this target. In Mach-O symbol output, its name has a leading underscore.

The fn main() in our source and the externally called entry function are separate functions. We did not write the entry wrapper ourselves.

Which one does the executable identify as its entry point?

Names alone do not establish which function is the entry point. Let's inspect the executable's metadata too.

otool -l main
Enter fullscreen mode Exit fullscreen mode

Mach-O has load commands that describe information needed for memory mapping and startup. These are relevant excerpts from my output:

  segname __TEXT
   vmaddr 0x0000000100000000
  fileoff 0
Enter fullscreen mode Exit fullscreen mode
          cmd LC_LOAD_DYLINKER
      cmdsize 32
         name /usr/lib/dyld (offset 12)
Enter fullscreen mode Exit fullscreen mode
       cmd LC_MAIN
   cmdsize 24
  entryoff 2604
 stacksize 0
Enter fullscreen mode Exit fullscreen mode

LC_LOAD_DYLINKER identifies the dynamic linker, while LC_MAIN records the entry location. Here, entryoff gives the entry's byte offset in __TEXT. See Mach-O's entry_point_command.

2604 in hexadecimal is 0xa2c. In this executable, __TEXT starts at file offset zero and has a preferred virtual address of 0x100000000, giving:

0x100000000 + 0xa2c = 0x100000a2c
Enter fullscreen mode Exit fullscreen mode

That matches _main, not the 0x1000009f4 address of our main::main.

These are addresses in the layout recorded in the file. ASLR can shift the actual load addresses at runtime. Also, LC_MAIN does not mean "the first instruction the CPU executes at startup." It identifies this executable's entry point, reached after preparation by dyld and other startup code.

The entry wrapper does not call our main directly

Run the same disassembly command and extract the section starting with <_main>:.

xcrun llvm-objdump --disassemble --demangle main
Enter fullscreen mode Exit fullscreen mode
0000000100000a2c <_main>:
100000a2c: a9bf7bfd    stp  x29, x30, [sp, #-0x10]!
100000a30: 910003fd    mov  x29, sp
100000a34: aa0103e2    mov  x2, x1
100000a38: aa0003e8    mov  x8, x0
100000a3c: 93407d01    sxtw x1, w8
100000a40: 90000000    adrp x0, 0x100000000 <dyld_stub_binder+0x100000000>
100000a44: 9127d000    add  x0, x0, #0x9f4
100000a48: 52800003    mov  w3, #0x0
100000a4c: 97ffff8f    bl   0x100000888 <std::rt::lang_start::hf66e9cda02e72e37>
100000a50: a8c17bfd    ldp  x29, x30, [sp], #0x10
100000a54: d65f03c0    ret
Enter fullscreen mode Exit fullscreen mode

Each instruction line contains its address, the machine-code encoding, and a readable representation of the instruction. For example:

100000a34:  aa0103e2    mov x2, x1
Address     Encoding    Instruction and operands
Enter fullscreen mode Exit fullscreen mode

mov x2, x1 copies the value in register x1 into x2. Registers are locations inside the CPU that hold values and addresses temporarily.

We do not need to decode every instruction. Start with bl, the instruction that calls a function. Under the calling convention used here, the first argument is passed in register x0.

The call goes to std::rt::lang_start, rather than our main. Before that, adrp and add construct 0x1000009f4 in x0. That was the address of our main::main. Focus on the address calculation here, rather than the symbolic annotation printed beside adrp.

The wrapper passes the location of our main as an argument to the standard library's startup routine. In effect: "Start this Rust program using this function."

We can check the same relationship in LLVM IR. Here is the entry wrapper from main.ll:

define i32 @main(i32 %0, ptr %1) unnamed_addr #4 {
top:
  %2 = sext i32 %0 to i64
; call std::rt::lang_start
  %3 = call i64 @_ZN3std2rt10lang_start17hf66e9cda02e72e37E(ptr @_ZN4main4main17h2dd6e7c25b480450E, i64 %2, ptr %1, i8 0)
  %4 = trunc i64 %3 to i32
  ret i32 %4
}
Enter fullscreen mode Exit fullscreen mode

Inside the parentheses on the line calling lang_start, the first argument is:

ptr @_ZN4main4main17h2dd6e7c25b480450E
Enter fullscreen mode Exit fullscreen mode

ptr is the pointer type, and @_ZN4main4main… refers to our main::main function. This argument is a pointer to that function.

Replacing the long names with readable descriptions gives the following argument order. This is explanatory notation, not literal LLVM IR:

lang_start(pointer to our main, argc, argv, setting)
           ↑ first argument
Enter fullscreen mode Exit fullscreen mode

The remaining arguments are the command-line argument count, argc; argv, which points to the argument strings; and a setting related to signal handling.

Our own main has no declared parameters. The entry wrapper still handles information passed in from the operating system.

What does lang_start prepare?

The entry wrapper passed our main to std::rt::lang_start instead of calling it directly. What does lang_start do before calling our function? Let's look at the standard library implementation.

In library/std/src/rt.rs shipped with my Rust toolchain, lang_start is defined as follows. Its attributes are omitted.

fn lang_start<T: crate::process::Termination + 'static>(
    main: fn() -> T,
    argc: isize,
    argv: *const *const u8,
    sigpipe: u8,
) -> isize {
    lang_start_internal(
        &move || crate::sys::backtrace::__rust_begin_short_backtrace(main).report().to_i32(),
        argc,
        argv,
        sigpipe,
    )
}
Enter fullscreen mode Exit fullscreen mode

The first parameter, main: fn() -> T, is the function pointer received from the entry wrapper. lang_start wraps the work of calling that function and converting its result into an exit code in a closure, then passes that closure to lang_start_internal. The &move || ... part creates the closure and passes a reference to it.

Next, here is an excerpt from the body of lang_start_internal. Comments mark omitted code; this excerpt is not independently compilable.

// Excerpt from the body of lang_start_internal
panic::catch_unwind(move || {
    unsafe { init(argc, argv, sigpipe) };

    let ret_code = panic::catch_unwind(main).unwrap_or_else(move |payload| {
        // Panic handling omitted
        101
    });
    let ret_code = ret_code as isize;

    cleanup();
    // Coordination of concurrent thread exits omitted

    ret_code
})
.unwrap_or_else(handle_rt_panic)
Enter fullscreen mode Exit fullscreen mode

It first calls init, then runs the supplied closure through panic::catch_unwind(main). That leads to our own main. Afterward, it calls cleanup and returns the exit code, ret_code.

Leaving the detailed error handling aside, the sequence is:

lang_start_internal
    ↓
Initialize Rust runtime state
    ↓
Invoke the supplied closure, reaching our main
    ↓
Handle the return value as an exit code
    ↓
Clean up stdout and other runtime state
    ↓
Return the exit code to the entry wrapper
Enter fullscreen mode Exit fullscreen mode

Initialization records the main thread's ID and proceeds to platform-specific preparation. In the Unix implementation used here, that includes checking the standard I/O file descriptors, configuring SIGPIPE behavior, and preparing stack overflow handling. On macOS, it also sets the main thread's name. See std startup and Unix initialization.

SIGPIPE is a signal relevant to situations such as writing to a pipe whose reader has gone away. Startup handles settings we rarely think about when using println!.

Rust does not prepare everything from scratch, though. The Apple-specific command-line argument implementation uses information already prepared by dyld. Rust's preparation builds on work done by the OS and dynamic linker. See the Apple argument implementation.

When main returns normally, execution proceeds to cleanup. The call is also wrapped in catch_unwind to handle panic unwinding. With a configuration that aborts on panic, however, the process terminates without unwinding.

Asking whether Rust "has a runtime" can be ambiguous. What we can see here is that this ordinary executable using std contains startup routines implemented in Rust.

Stop inside main and look at the path we took

We can also check this while the program runs. Use LLDB to place a breakpoint in our own main:

lldb ./main
Enter fullscreen mode Exit fullscreen mode

Inside LLDB, run:

breakpoint set -n main::main
run
bt
Enter fullscreen mode Exit fullscreen mode

bt walks the current call stack: the current function, its caller, that caller's caller, and so on. My program stopped at the println! line. See the LLDB tutorial.

Here are the relevant frames, with long arguments, hashes, and line numbers removed. The frame numbers are unchanged.

frame #0:  main::main
frame #1:  core::ops::function::FnOnce::call_once
frame #2:  std::sys::backtrace::__rust_begin_short_backtrace
frame #3:  std::rt::lang_start::{closure}
           (frames #4 through #11 omitted)
frame #12: std::rt::lang_start_internal
frame #13: std::rt::lang_start
frame #14: main
frame #15: dyld`start
Enter fullscreen mode Exit fullscreen mode

The current location is at the top, so read from bottom to top to follow the calls that brought us here.

From dyld, through the entry main, lang_start, and lang_start_internal, we finally reach our own main::main. Along the way are a closure and helper functions used to invoke it.

Now "code runs before our main" is something we can see in an actual call stack.

Returning from main is not the final step either

Our main has no explicit return type, so it returns (). lang_start uses the Termination trait's report method to convert that result into an exit status. For (), the status indicates success. See Termination.

The startup code does more than invoke our function: it translates a Rust return value into a result for the program as a whole. After standard library cleanup, the entry wrapper returns an exit code, leading to the system's process termination handling.

There are other ways to terminate, such as process::exit and aborting, which do not return through this entire path. We followed the normal completion of this small program.

Even for a three-line program, the OS and dyld prepare it, and Rust's standard library performs initialization, before the main we wrote is called.

Top comments (0)