How to Write a Bare-Metal Rust Application

Create a no_std project with a panic handler and a no_mangle entry point to run Rust directly on hardware.

The empty lot

You are writing firmware for a temperature sensor, or maybe a tiny operating system that boots before Linux even thinks about loading. There is no operating system underneath you. There is no std::io, no std::fs, no dynamic memory allocator. Just raw registers, a linker script, and the silicon. This is bare-metal Rust. It strips away the safety net of the standard library and hands you the wheel.

How no_std changes the rules

The standard library is a massive convenience. It expects an operating system to handle file descriptors, threads, and heap allocation. When you remove it, you are telling the compiler to stop assuming any external environment exists. Think of std as a fully furnished apartment. You walk in, flip a switch, and the lights work. no_std is an empty concrete box. You have to wire the lights yourself, but you also get to decide exactly where every wire goes.

The core crate is the only thing that remains. It contains the fundamental language primitives: integers, slices, Option, Result, and basic traits. Everything else is optional. You can opt back into heap allocation later with the alloc crate, but you must provide your own allocator. You can write your own I/O drivers. You control the entire stack.

Strip the library. Wire the silicon yourself.

The minimal skeleton

Every bare-metal project starts with the same three attributes and two functions. The code below is the absolute minimum required to produce a valid binary.

#![no_std]
#![no_main]

use core::panic::PanicInfo;

/// Handles panics by freezing the CPU.
/// Bare metal has no terminal to print a backtrace.
#[panic_handler]
fn panic(_info: &PanicInfo) -> ! {
    // Halt execution permanently
    loop {}
}

/// The linker entry point.
/// `no_mangle` preserves the symbol name for the linker script.
/// `extern "C"` prevents Rust from mangling the signature.
#[no_mangle]
pub extern "C" fn _start() -> ! {
    // Initialize hardware here
    loop {}
}

Add this to your Cargo.toml to disable the panic unwinder:

[profile.dev]
panic = "abort"

[profile.release]
panic = "abort"

The panic = "abort" setting is mandatory. The default unwinder expects a heap and a standard library to generate a backtrace. Without them, the linker will fail.

What the compiler actually does

When you compile this, the #![no_std] attribute drops the standard library from the dependency graph. The compiler only pulls in core and compiler_builtins. The #![no_main] attribute stops the compiler from injecting the usual startup glue that calls main. Instead, the linker looks for a symbol named _start.

That is why #[no_mangle] exists. Rust normally scrambles function names to support overloading and namespaces. The linker script expects a plain _start, so you must keep the name exactly as written. The extern "C" calling convention ensures the function signature matches what the linker and bootrom expect. Finally, the -> ! return type tells the compiler this function never returns. The infinite loop guarantees the CPU stays busy. If you let _start return, the program falls off the end of memory and triggers a hardware exception.

The linker does not care about Rust namespaces. Give it the exact symbol it expects.

Structuring a real project

A production bare-metal project separates the entry point from the actual logic. You want a clean main function that the rest of your code can call, while _start handles the low-level boot sequence. The boot sequence usually initializes the stack pointer, clears the BSS segment, and sets up basic interrupts.

#![no_std]
#![no_main]

use core::panic::PanicInfo;

/// Handles panics by freezing the CPU.
/// Bare metal has no terminal to print a backtrace.
#[panic_handler]
fn panic(_info: &PanicInfo) -> ! {
    // Halt execution permanently
    loop {}
}

/// The linker entry point.
/// `no_mangle` preserves the symbol name for the linker script.
/// `extern "C"` prevents Rust from mangling the signature.
#[no_mangle]
pub extern "C" fn _start() -> ! {
    // 1. Initialize stack pointer (usually done by bootrom)
    // 2. Clear BSS segment to zero
    // 3. Call the application entry point
    main();
    loop {}
}

/// Application logic starts here
fn main() -> ! {
    // Configure peripherals
    // Run the main loop
    loop {}
}

Community convention favors keeping _start as thin as possible. Push all initialization into main or a dedicated init() function. It makes testing easier and keeps the boot sequence readable. Some developers prefer #[export_name = "_start"] over #[no_mangle], but the community standard is #[no_mangle for simplicity. Both achieve the same result.

Keep the boot sequence thin. Push the logic into main.

Common traps and linker errors

Forgetting panic = "abort" in Cargo.toml is the most common trap. By default, Rust compiles with panic = "unwind". The unwinder expects a heap and a standard library to generate a backtrace. When you strip std, the linker cannot find the unwinding runtime. You will get a linker error about missing symbols like __rust_dealloc or __rust_allocate. The fix is straightforward. Add panic = "abort" to both [profile.dev] and [profile.release]. This tells the compiler to terminate immediately on panic instead of trying to unwind the stack. The #[panic_handler] attribute then catches the abort and hands control to your infinite loop.

Another frequent mistake is trying to use println!. That macro lives in std. Without it, the code will not compile. You must write your own serial output function or use a hardware-specific logging crate. The compiler will reject the code with a "cannot find macro" error. You can work around it by importing core::fmt and writing a custom Write implementation for your UART peripheral.

A third pitfall involves the alloc crate. Many developers assume no_std means no heap. That is not true. no_std means no standard library. You can still use Vec and Box if you link the alloc crate and provide a global allocator. You must mark your crate with #![feature(alloc_error_handler)] and implement #[alloc_error_handler] to handle out-of-memory conditions. The allocator is entirely your responsibility.

Configure the panic strategy early. The unwinder will not survive without std.

When to strip the standard library

Use no_std when you are writing firmware for microcontrollers that lack an operating system. Use no_std when you are building an operating system kernel or a bootloader that must run before any user-space environment exists. Use no_std when you need deterministic memory usage and want to eliminate the overhead of the standard library's heap allocator. Reach for std when you are writing applications that run on Linux, Windows, or macOS. Reach for std when you need file I/O, network sockets, or dynamic memory allocation without implementing your own allocator. Reach for std when you are prototyping and want to move fast without worrying about linker scripts or panic handlers.

Match the tool to the environment. Bare metal demands bare metal rules.

Where to go next