Back to writing
Rust7 min read

What Happens Before main() in Rust

Understanding Rust's startup process - from crt0 to the runtime to your code

What Happens Before main() in Rust

If you've written or seen any basic program in Rust, there's a main() function. You write your code, run cargo run, and your program executes.

But have you ever wondered how a program actually starts running? It's not as simple as just calling main(). There's an entire startup sequence that happens before your code gets its turn.

Let's dive into the world of Rust's startup process.

crt0: The C Runtime Zero

First up: crt0 (C runtime zero).

This tiny but mighty C library kicks things off. Before any of your code runs, before Rust even gets involved, crt0 is doing the groundwork:

  • Sets up the stack: Your program needs a stack for function calls, local variables, return addresses. crt0 initializes this.
  • Initializes the heap: Dynamic memory allocation needs a heap. crt0 sets this up.
  • Preps command-line arguments: Those args you access in your program? crt0 parses them from what the OS provides.
  • Calls the entry point: Once setup is done, crt0 calls into the next stage.

Think of crt0 as setting the stage for everything that follows.

Why Start with C?

But wait. Why does a Rust program start with C code?

This is a practical decision. Rust leverages existing systems to be efficient and compatible. Every operating system already has conventions for how programs start. These conventions were established decades ago, primarily for C programs.

Using crt0 allows Rust to integrate seamlessly with the underlying OS and hardware. The linker knows how to work with crt0. The OS knows how to load executables that use crt0. Debuggers know how to handle it.

It's about building on solid foundations rather than reinventing the wheel. Rust is pragmatic like that.

The Rust Runtime

Next, the Rust runtime enters.

Marked by the start language item, this is where Rust takes over. But unlike Java or Go, Rust's runtime is minimalist. There's no garbage collector spinning up, no virtual machine initializing. Rust's runtime is lean because Rust is all about zero-cost abstractions.

So what does Rust's runtime actually do?

1. Stack Overflow Guards

Stack overflow guards are crucial. They help prevent one of the nastiest bugs: stack overflows.

When you make too many nested function calls, or allocate too much on the stack, you can overflow it. Without protection, this corrupts memory. Your program might crash, or worse, keep running with corrupted state, causing undefined behavior.

Rust's runtime sets up guard pages at the end of the stack. If your program tries to write past the stack boundary, it hits the guard page, the OS catches this as a memory violation, and your program terminates cleanly instead of corrupting memory.

The runtime has your back here.

2. Panic Handler Initialization

The panic handler defines what happens when your program panics. A panic is Rust's way of handling unrecoverable errors: failed assertions, out-of-bounds access, explicit panic!() calls.

The runtime initializes the panic infrastructure so that when something goes wrong, there's a defined path to handle it. By default, this means printing an error message and unwinding the stack (or aborting, depending on your settings).

The #[panic_handler] attribute lets you customize this, but the runtime sets up the default.

3. Backtrace Preparation

When things go wrong, you want to know where. Backtraces show you the call stack at the point of failure.

The runtime preps the infrastructure for backtrace collection. This involves setting up the necessary hooks so that when a panic occurs, the system can walk the stack and report where you were.

This is why you can set RUST_BACKTRACE=1 and get useful debug information.

4. Thread-Local Storage

Thread-local storage (TLS) lets you have variables that are unique to each thread. The runtime initializes TLS for the main thread.

This matters for things like thread-local random number generators, per-thread caches, or any state that shouldn't be shared across threads.

Language Items: The Compiler Contract

The start function is a "language item", a special function the Rust compiler expects to find. Language items are like a contract between your code and the compiler.

The compiler says: "I need certain things to exist for the program to work." The standard library provides these things. When you use std, all the language items are already implemented for you.

Key language items include:

  • start: The entry point after crt0
  • panic_impl: What to do on panic
  • eh_personality: Exception handling personality (for unwinding)

It's like an agreement. The compiler promises to generate correct code if you provide these items. The standard library fulfills the agreement by default.

Finally: main()

After all these initializations, checks, and setup, main() finally gets its moment.

fn main() {
    println!("Hello, world!");
}

By the time this runs:

  • The stack is set up and protected
  • The heap is ready for allocations
  • Panic handling is in place
  • Thread-local storage is initialized
  • Command-line arguments are parsed and accessible

A lot happened behind the scenes to get here. Your simple main() function stands on the shoulders of the startup sequence.

No Standard Library? No Problem

Here's where it gets interesting for systems programmers.

In no_std environments, like embedded systems or when you're writing your own kernel, you don't have the standard library. No crt0 integration, no default runtime.

You can use #![no_main] and provide your own entry point:

#![no_std]
#![no_main]

use core::panic::PanicInfo;

#[no_mangle]
pub extern "C" fn _start() -> ! {
    // Your entry point
    loop {}
}

#[panic_handler]
fn panic(_info: &PanicInfo) -> ! {
    loop {}
}

In this world, you're responsible for everything:

  • Define your own entry point (_start or whatever your target expects)
  • Implement your own panic handler
  • Set up your own stack (if the bootloader/hardware doesn't)
  • Initialize any hardware you need

This is what OS developers and embedded programmers do. They bypass the standard startup sequence and build their own from scratch.

It's more work, but it gives you complete control. You decide exactly what happens before your code runs. No hidden initialization, no assumptions about the environment.

Why This Matters

Understanding the startup sequence helps in several ways:

Debugging: When something goes wrong early in your program, knowing about crt0 and the runtime helps you understand error messages and stack traces.

Embedded development: If you're writing no_std code, you need to know what the standard startup provides so you can replace it.

Performance: The runtime is minimal, but it's not zero. In extreme cases, knowing what it does helps you understand your program's baseline overhead.

Systems understanding: This is how programs actually work. The abstractions are convenient, but knowing what's underneath makes you a better programmer.

Wrapping Up

So the next time you write fn main() and hit run, remember:

  1. The OS loads your executable
  2. crt0 sets up the stack, heap, and arguments
  3. Rust's runtime initializes guards, panic handling, backtraces, and TLS
  4. Finally, main() runs

It's a journey from bare metal to your code. Most of the time you don't need to think about it. But when you do need to understand it, like in embedded systems, OS development, or deep debugging, this knowledge is invaluable.

Rust's startup is lean compared to managed languages, but it's not nothing. There's always something happening before main().

Topics

rustsystems-programmingruntimeno-std