Inside Rust’s no main World: How Binaries Run Without std
Most Rust developers think every Rust program starts with this: fn main() {
println!("Hello, world!");
} But deep down in the guts of embedded systems, kernels, and bootloaders, there’s no println!, no heap, and not even a main function. That’s the #![no_main] world — where Rust becomes bare-metal, and you’re on your own. This isn’t a theoretical curiosity. This is the world of firmware, operating systems, and WASM runtimes — where the Rust compiler doesn’t even link against libstd, and your binary runs directly on hardware or inside an environment that doesn’t have a C runtime (libc). Let’s open that black box. What “no_main” Actually Means Normally, when you compile a Rust binary, it links against libstd, which depends on libc, which depends on your OS. That chain looks like this: Rust code
└── libstd
└── libc
└── OS syscalls (Linux, Windows, macOS)
When you add #![no_main], you tell Rust: “I’m not running on an operating system. Don’t inject any runtime or call conventions. I’ll handle the entry point myself.” That means:
- No automatic main() function
- No stack unwinding
- No standard I/O
- No heap allocation (unless you provide one)
- No threads, no filesystem, no OS help at all
You’re writing code that might run on a microcontroller, bootloader, or custom kernel. Minimal Example: Hello Without a Main Here’s the smallest “Rust program” you can write that actually runs without std or main:
- ![no_std]
- ![no_main]
use core::panic::PanicInfo;
- [no_mangle]
pub extern "C" fn _start() -> ! {
// This is your new 'main'
loop {}
}
// Required: define how to handle panics
- [panic_handler]
fn panic(_info: &PanicInfo) -> ! {
loop {}
} Let’s break it down:
- #![no_std] → Don’t link to libstd; only use core.
- #![no_main] → Don’t expect a fn main().
- #[no_mangle] → Keep the function name _start intact for the linker (so it doesn’t rename it).
- _start() → The true entry point the linker will call when the CPU starts executing your binary.
- -> ! → This function never returns (because you can’t “exit” an OS that doesn’t exist).
So when your hardware boots, it jumps into _start, and that’s it — you’re the OS now. Architecture Breakdown — The Bare-Metal Runtime Let’s visualize what’s missing when you say #![no_main]: ┌───────────────────────────────┐ │ Regular Rust App │ │ fn main() { println!(); } │ │ ───────────────────────────── │ │ Linked with libc + std │ │ Handles I/O, heap, args, etc. │ └───────────────────────────────┘
↓
┌───────────────────────────────┐ │ no_std + no_main Rust App │ │ fn _start() { loop {} } │ │ ───────────────────────────── │ │ No libc, no std, no heap │ │ You manage stack + memory │ └───────────────────────────────┘ You’re no longer writing applications. You’re writing runtimes. The Boot Process: What Really Happens When you compile with #![no_main], the compiler still emits a binary — but it doesn’t generate the normal startup code. Here’s what happens in a no_std + no_main build:
- The linker script (like memory.x for embedded targets) defines where in memory your binary is loaded.
- The CPU starts execution at the reset vector, jumping into your _start function.
- You set up:
- Stack pointer (sp)
- Memory sections (.data, .bss)
- Hardware (like UART or GPIO)
- Then your code runs indefinitely — usually in a loop.
This is what a bootloader or OS kernel does — you are the system. The Relationship Between core, alloc, and std When you go no_std, you lose std, but you still have the core crate. Here’s the hierarchy: core → No heap, no OS, no threading (basic types, math, slices) alloc → Optional, needs a heap allocator std → Needs libc + OS + syscalls So if you want to use heap-allocated structures like Vec or Box in a no_std binary, you can — if you provide a custom allocator:
- ![no_std]
- ![feature(alloc_error_handler)]
extern crate alloc;
use alloc::vec::Vec;
- [global_allocator]
static ALLOC: MyAllocator = MyAllocator; struct MyAllocator; unsafe impl core::alloc::GlobalAlloc for MyAllocator {
unsafe fn alloc(&self, layout: core::alloc::Layout) -> *mut u8 {
my_custom_heap_alloc(layout)
}
unsafe fn dealloc(&self, ptr: *mut u8, layout: core::alloc::Layout) {
my_custom_heap_free(ptr, layout)
}
}
- [alloc_error_handler]
fn oom(_: core::alloc::Layout) -> ! {
loop {}
} You just reimplemented part of an OS memory allocator — in Rust. That’s the level of control you get when you drop std. Real-World Example: Writing an Embedded Startup Let’s look at a real embedded-style startup for an ARM Cortex-M board:
- ![no_std]
- ![no_main]
use cortex_m_rt::entry;
use panic_halt as _; // no panics, just halt
- [entry]
fn main() -> ! {
let peripherals = stm32::Peripherals::take().unwrap();
let gpioa = &peripherals.GPIOA;
gpioa.moder.modify(|_, w| w.moder5().output());
loop {
gpioa.odr.modify(|_, w| w.odr5().set_bit());
}
} This runs without an operating system, directly on the microcontroller. There’s no printf, no threads, no kernel — just you and the silicon. That’s what #![no_main] was built for. Architecture Flow: From Reset Vector to Rust Code ┌──────────────────────────────┐ │ Power-On / Reset │ └──────────────┬───────────────┘
▼
┌──────────────────────────────┐ │ CPU jumps to reset vector │ │ (address defined in linker) │ └──────────────┬───────────────┘
▼
┌──────────────────────────────┐ │ Rust _start() executes │ │ (you define it) │ └──────────────┬───────────────┘
▼
┌──────────────────────────────┐ │ You initialize stack, RAM, │ │ and peripherals manually │ └──────────────┬───────────────┘
▼
┌──────────────────────────────┐ │ main() or infinite loop │ └──────────────────────────────┘ This is how operating systems, bootloaders, and hypervisors start life — in pure Rust, without libc. Why Devs Escape std in the First Place So why do people voluntarily ditch std and write their own runtime? Here are the real reasons:
- Embedded development — no OS, no libc.
- Kernel or OS projects — you are the OS.
- WASM runtimes — no system calls, sandboxed.
- Performance-critical or deterministic systems — no allocator, no background threads.
- Security — total control, no hidden runtime behavior.
Projects like Tock OS, Redox, and rust-osdev’s blog OS all live here — inside the no_std + no_main world. A Glimpse at the Rust Toolchain Side When you compile such a binary, you must use a custom target specification: rustc --target thumbv7em-none-eabihf -O \
-C link-arg=-Tlinker_script.ld \
src/main.rs
That --target tells the compiler: “There’s no operating system here. Just raw hardware.” And the linker script (.ld or .x) defines:
- The memory map (Flash, RAM)
- The stack start
- The entry symbol (_start or Reset)
This is why embedded Rust is so powerful: you’re controlling everything below the OS — with memory safety. Why It Feels Magical The moment you boot a board, and your Rust code runs with no OS, it feels unreal. You see the LED blink, or a UART print — and realize that Rust didn’t need libc, or threads, or even main. It’s not just a systems language. It’s a systems language that can become the system. Key Takeaways
- #![no_main] removes Rust’s runtime entry — you define _start.
- #![no_std] removes libstd — you only get core and optionally alloc.
- You must handle panics, stack, and memory manually.
- Used in OS kernels, firmware, bootloaders, and embedded projects.
- The compiler becomes your bootloader’s best friend — not your babysitter.
Final Thought Writing no_main Rust feels like walking on the moon — terrifying at first, but once you land, you realize how light and powerful the world becomes when you strip away everything you don’t need. You don’t “run” on an OS anymore. You are the runtime.
Read the full article here: https://medium.com/@theopinionatedev/inside-rusts-no-main-world-how-binaries-run-without-std-a0d15d9dcb11