The most surprising thing about bare-metal Rust is how much of the language you don’t need to get started.

Let’s boot up a tiny microcontroller, say an STM32F4 discovery board, with nothing but a blinking LED. No operating system, no standard library, just raw hardware.

#![no_std]
#![no_main]

use panic_halt as _; // The panic handler, otherwise the program would crash and do nothing

#[no_mangle]
pub extern "C" fn DefaultHandler() {
    // This is the entry point. In a real application, you'd set up interrupt vectors
    // and jump to the correct handler. For this simple example, we'll just loop.
    loop {}
}

#[no_mangle]
pub extern "C" fn Reset() -> ! {
    // This is the actual reset handler, where execution begins after power-on or reset.
    // We need to make sure this function never returns.

    // Initialize the GPIO peripheral for the LED.
    // This involves enabling the clock to the GPIO port and configuring the pin.
    // The exact addresses and bitmasks depend on the specific microcontroller.
    // For STM32F407, GPIOD is on AHB1 bus.
    let rcc = 0x40023800 as *mut u32; // RCC base address
    let gpiod_enable = 1 << 3; // Bit 3 for GPIOD enable

    unsafe {
        // Enable clock for GPIOD
        (*rcc) |= gpiod_enable;
    }

    // Let's assume the LED is connected to PD12.
    // We need to configure PD12 as an output pin.
    let gpiod_base = 0x40020C00 as *mut u32; // GPIOD base address
    let moder_offset = 0x00 as usize; // MODER register offset (Mode Register)
    let moder_pd12_mask = 3 << (2 * 12); // Bits for PD12 (pins 12 and 13)
    let moder_pd12_output = 1 << (2 * 12); // Set bits to 01 for General purpose output mode

    let odr_offset = 0x14 as usize; // ODR register offset (Output Data Register)
    let odr_pd12_mask = 1 << 12; // Bit for PD12 output

    unsafe {
        // Configure PD12 as output
        let moder = &mut * (gpiod_base.add(moder_offset) as *mut u32);
        (*moder) &= !moder_pd12_mask; // Clear bits for PD12
        (*moder) |= moder_pd12_output; // Set bits to 01 for output

        // Turn the LED on
        let odr = &mut * (gpiod_base.add(odr_offset) as *mut u32);
        (*odr) |= odr_pd12_mask; // Set PD12 high
    }

    // Infinite loop to keep the program running
    loop {}
}

This code, when compiled for the STM332F4xx target, will run directly on the hardware. The #![no_std] directive tells the Rust compiler to not link against the standard library, which is full of OS-dependent features and abstractions. #![no_main] indicates that we’ll provide our own entry point.

The panic_halt crate is a common choice for bare-metal. It simply halts the processor on a panic, preventing undefined behavior.

The #[no_mangle] attribute ensures that the function names (DefaultHandler, Reset) are not mangled by the Rust compiler, making them callable from C-compatible startup code. The extern "C" specifies the C calling convention.

The Reset function is our main. It’s marked with -> ! to signify that it never returns. Inside, we’re directly manipulating memory-mapped registers. The addresses like 0x40023800 (RCC base) and 0x40020C00 (GPIOD base) are specific to the STM32F4 family and are documented in the microcontroller’s reference manual. We’re enabling the clock for the GPIOD peripheral, then configuring pin PD12 as a general-purpose output and setting it high to turn on an LED.

The magic happens during compilation. When you target a microcontroller, the build system (usually Cargo with the flip-binutils or rust-lld linker) uses a linker script specific to that chip. This script defines the memory layout of the microcontroller, including where code, data, and the stack should reside. The Reset function becomes the entry point specified in the linker script’s ENTRY point.

The core abstraction Rust provides here is safety. Even though we’re doing low-level memory manipulation, Rust’s ownership and borrowing rules prevent common C-style errors like use-after-free or data races within the safe parts of your code. For direct hardware access, you’ll use unsafe blocks, but you’re encouraged to minimize them and encapsulate the raw operations.

The hardest part is usually mapping the C-style register definitions from the datasheet into Rust’s type system. Crates like svd2rust are invaluable for generating Rust bindings from SVD (System View Description) files, which are XML files describing a microcontroller’s peripherals. Instead of raw addresses, you’d use types like RCC.AHB1ENR.modify(|_, w| w.gpiod_en().set_bit());.

The next step is handling interrupts, which involves setting up the Nested Vectored Interrupt Controller (NVIC) and defining interrupt handlers.

Want structured learning?

Take the full Rust course →