How Rust Bootstraps in a Bare-Metal Environment
Every Rust developer has seen the line: fn main() {
println!("Hello, world!");
} But what if there’s no OS to call println!()? No file descriptors, no libc, no standard output, no main function in the traditional sense. That’s where the story of Rust in bare metal begins — a place where the compiler doesn’t just build your code; it builds your world. This is the story of how Rust bootstraps itself on hardware that knows nothing about files, memory, or even text. A story about how main() eventually runs on an empty chip, and why it works at all. The Context: Rust Without a Safety Net Most programming languages rely on an operating system to handle memory, I/O, and process management. Bare-metal Rust doesn’t have that luxury. In an embedded environment — think microcontrollers, Raspberry Pi, or custom hardware — there’s:
- No heap allocator
- No operating system
- No file system
- No preloaded runtime
And yet, Rust runs beautifully there. How? By stripping itself bare — literally. The no_std World Normally, Rust crates link against the std (standard) library. But std depends on system calls like write() or malloc(). In a bare-metal world, you replace it with core and alloc:
- ![no_std]
- ![no_main]
use core::panic::PanicInfo;
- [panic_handler]
fn panic(_info: &PanicInfo) -> ! {
loop {}
} Here:
- #![no_std] tells the compiler not to link the standard library.
- #![no_main] means we’re defining our own entry point — because the OS isn’t calling main() for us.
- The panic handler defines what happens when something goes wrong (in this case, an infinite loop).
There’s no stdout, no heap, no file descriptors — just you and the silicon. What Actually Happens Before main() In a normal Rust binary, the operating system:
- Loads your program into memory
- Sets up the stack and heap
- Calls the entry point (main or _start)
In bare metal, you have to do all three. Here’s what your Rust program looks like conceptually: ┌──────────────────────────────────────┐ │ Firmware Binary (.elf) │ ├──────────────────────────────────────┤ │ Startup Assembly (reset vector) │ │ → sets stack pointer │ │ → initializes .bss and .data │ │ → jumps to Rust entry (_start) │ ├──────────────────────────────────────┤ │ Rust runtime init (crt0.rs) │ │ → sets up global variables │ │ → calls main() │ ├──────────────────────────────────────┤ │ Your Code (fn main) │ └──────────────────────────────────────┘ Let’s look at how that works in practice. The Reset Vector: Your Real “Main” When a microcontroller boots, it jumps to a fixed reset vector address (often 0x00000000 or 0x08000000). In Rust, we define this entry in an assembly file or linker script: .section .text._start .global _start _start:
ldr sp, =_stack_start // Initialize stack pointer bl rust_entry // Jump to Rust entry point
This tiny snippet is what gives life to your chip. It doesn’t know Rust, or even what a stack is — but it sets the stage. Now, rust_entry is where Rust finally begins. The Rust Entry Point Here’s what the Rust side of the bootstrap might look like:
- [no_mangle]
pub extern "C" fn rust_entry() -> ! {
init_data_bss();
main();
loop {} // No OS to return to
}
fn init_data_bss() {
extern "C" {
static mut _sbss: u32;
static mut _ebss: u32;
static mut _sdata: u32;
static mut _edata: u32;
static mut _sidata: u32;
}
unsafe {
// Zero initialize .bss section
let mut bss = &mut _sbss as *mut u32;
while bss < &mut _ebss as *mut u32 {
core::ptr::write_volatile(bss, 0);
bss = bss.add(1);
}
// Copy .data from flash to RAM
let mut src = &_sidata as *const u32;
let mut dst = &mut _sdata as *mut u32;
while dst < &mut _edata as *mut u32 {
core::ptr::write_volatile(dst, core::ptr::read_volatile(src));
dst = dst.add(1);
src = src.add(1);
}
}
} This is handcrafted runtime initialization — no libc, no crt0.o, no runtime magic. You’re manually setting up the .data and .bss sections in RAM before calling main(). That’s how Rust gets from raw flash memory to executable code. Architecture Diagram: Rust Bootstrapping on Bare Metal ┌──────────────────────────────────┐ │ Power On │ ├──────────────────────────────────┤ │ Reset Vector (Assembly) │ │ → Initialize stack pointer │ │ → Jump to Rust entry │ ├──────────────────────────────────┤ │ Rust Runtime Init (rust_entry) │ │ → Zero .bss │ │ → Copy .data │ │ → Setup heap (optional) │ ├──────────────────────────────────┤ │ User Code (fn main) │ │ → Runs application logic │ │ → Never returns │ └──────────────────────────────────┘ This flow is what allows Rust to boot even without an OS, shell, or runtime — pure determinism from reset to logic. Code Flow Example: Bare-Metal “Hello World” (via UART) There’s no println!, but you can still talk to the outside world — through hardware registers.
- ![no_std]
- ![no_main]
use core::fmt::Write;
use cortex_m_rt::entry;
use stm32f4xx_hal::{pac, prelude::*};
- [entry]
fn main() -> ! {
let dp = pac::Peripherals::take().unwrap();
let gpioa = dp.GPIOA.split();
let tx_pin = gpioa.pa2.into_alternate();
let serial = dp.USART2.tx(tx_pin, 115_200.bps());
let mut tx = serial.unwrap();
write!(tx, "Hello from Rust on bare metal!\r\n").unwrap();
loop {}
} Here:
- cortex_m_rt provides the runtime setup and vector table.
- The HAL crate (stm32f4xx_hal) gives safe abstractions over registers.
- The compiler emits fully static code that runs directly on the MCU.
That’s your println!() moment — hardware edition. The Real Reason Rust Works So Well on Bare Metal Rust is uniquely capable of running at this level because of three design choices:
- no_std modularity – Rust’s standard library is optional. You can drop std and keep core, giving you minimal dependencies.
- Zero-cost abstractions — Traits and generics compile down to pure functions and inlined code.
- Safety through ownership — Even at hardware register level, Rust enforces aliasing and mutability rules.
You can write register drivers that are type-safe — something C has never been able to do. For example: struct Register<T> {
addr: *mut T,
}
impl<T> Register<T> {
fn write(&self, val: T) {
unsafe { core::ptr::write_volatile(self.addr, val) }
}
} Even though it’s low-level, the compiler ensures no accidental aliasing or misuse of hardware addresses. Architecture of a Bare-Metal Rust System ┌──────────────────────────────────┐ │ Core Crates │ │ ├── core (no heap, no I/O) │ │ ├── alloc (optional heap) │ │ ├── panic handler │ │ └── startup runtime (rt crate) │ ├──────────────────────────────────┤ │ HAL Layer (peripherals) │ │ ├── GPIO, UART, SPI, etc. │ ├──────────────────────────────────┤ │ Application Logic │ │ ├── fn main │ │ └── loops forever │ └──────────────────────────────────┘ Each part is fully controlled — no dynamic linking, no runtime allocations unless you choose to. Real-World Example: Rust in the Linux Kernel and Firmware Rust is now officially supported in the Linux kernel — and the same bare-metal startup logic applies. Each Rust driver module defines its own initialization logic using macros that resemble the embedded startup model. The same patterns are used in:
- Tock OS (an embedded Rust operating system)
- RTIC (Real-Time Interrupt-driven Concurrency framework)
- Redox OS bootloader
- micro:bit Rust examples
All of them start the same way: no_std, no runtime, just core. The Emotional Reality of Bare-Metal Rust There’s something almost poetic about watching Rust run on bare metal. When you write: loop {
blink_led();
} That loop is not a metaphor. It’s literally electricity toggling pins on a chip because your code told it to. No abstractions left to hide behind. No OS safety net. And yet, you still have Rust’s safety model protecting you from the mistakes that haunt C firmware engineers. It’s you, Rust, and the machine — in complete sync. Final Thoughts When Rust boots on bare metal, it’s more than just a compiler story — it’s a statement: “You can have both control and correctness.” The journey from _start to main() isn’t just technical — it’s philosophical. Rust doesn’t assume an OS. It builds one from first principles if it has to. It’s the closest thing we have to machine empathy — code that respects hardware and the developer’s sanity. So the next time you flash firmware and see your LED blink, remember: That little blink started with nothing — and Rust built everything else.
Read the full article here: https://medium.com/@theopinionatedev/how-rust-bootstraps-in-a-bare-metal-environment-46f8a49320b4