When the operating system disappears
You plug a microcontroller into your development board, flash the firmware, and watch the serial monitor stay completely silent. The code compiles without warnings. The logic looks sound. But the hardware refuses to respond. The problem is not your algorithm. It is the environment. Rust's standard library assumes an operating system. It expects malloc, file descriptors, a scheduler, and a panic handler that prints a stack trace to stderr. Bare metal hardware provides none of those. You have to strip the language down to its bare bones and talk directly to silicon.
The empty warehouse
Writing a device driver in Rust starts with #![no_std]. This attribute tells the compiler to stop linking the standard library and only pull in core. Think of it like moving into an empty warehouse. The standard library is a fully furnished apartment with plumbing, electricity, and a front door. no_std gives you concrete floors and steel beams. You have to wire the lights yourself. You have to define how the program starts and how it ends. You also have to handle what happens when something goes wrong, because there is no operating system to catch your panics.
Hardware registers are just memory addresses with a twist. Writing to them triggers physical actions. Reading them returns the current state of a sensor or a peripheral. The twist is that these values change independently of your CPU. The compiler does not know that address 0x40021000 controls a UART transmitter. It only sees a pointer to a u32. If you read it twice, the compiler assumes the value did not change and optimizes the second read away. That assumption breaks hardware communication. You need to tell the compiler that every access matters.
Bring your own memory layout. The compiler cannot guess where your chip's RAM begins.
The bare minimum skeleton
Every bare metal Rust project begins with a skeleton that replaces the operating system's entry point and panic handler.
#![no_std]
#![no_main]
use core::panic::PanicInfo;
/// Handles panics by halting the CPU. The OS won't catch this, so we must.
#[panic_handler]
fn panic(_info: &PanicInfo) -> ! {
// Infinite loop prevents the CPU from executing garbage instructions.
// Returning here would invoke undefined behavior on a corrupted stack.
loop {}
}
/// The entry point the linker and bootloader expect.
#[no_mangle]
pub extern "C" fn _start() -> ! {
// Hardware initialization happens here before the main loop.
// Clocks, memory controllers, and peripherals must be configured first.
loop {}
}
The #![no_std] line disables the standard library. #![no_main] stops the compiler from generating the default main wrapper that expects libc. The #[panic_handler] attribute replaces the default panic behavior. Without it, the linker fails because bare metal targets do not provide a panic implementation. The #[no_mangle] attribute on _start prevents the compiler from changing the function name during compilation. Linkers and bootloaders look for exact symbol names. If the name changes, the firmware never starts.
Keep your entry point lean. Initialize hardware, then hand control to your driver logic.
How the toolchain assembles your firmware
When you compile this code, the Rust toolchain stops at the object file stage for your logic. It does not link libc or the standard library runtime. Instead, you provide a linker script that maps your code and data to specific memory regions on the chip. The script defines where the flash memory begins, where RAM starts, and where the stack pointer should point. The bootloader or hardware reset vector jumps to the address specified in that script. It expects to find the reset handler, which eventually calls your _start function.
At runtime, the CPU executes _start. You initialize clocks, configure memory controllers, and set up interrupts. Once the hardware is ready, you enter an infinite loop or yield to a real-time operating system. If a panic occurs, execution jumps to your panic handler. The handler must never return. Returning from a panic handler invokes undefined behavior because the stack is already corrupted. An infinite loop is the safest fallback. It freezes the device in a known state so you can debug the crash with a JTAG probe.
Convention aside: most embedded developers use the cortex-m or riscv crates to handle target-specific startup code. These crates provide the reset handler and interrupt vector table so you only write _start or main. Rolling your own _start works for learning, but production firmware relies on these established crates. They also handle CPU-specific initialization like enabling the FPU or configuring cache controllers.
Verify your linker script against the datasheet. A single wrong offset will overwrite your own variables.
Talking to silicon
Talking to hardware requires reading and writing memory-mapped registers. The core::ptr module provides the tools, but you must mark every access as volatile. Volatile tells the compiler that the memory location can change at any time and that optimizations must not reorder or eliminate the access.
use core::ptr;
/// Base address for the GPIO controller on this specific microcontroller.
const GPIO_BASE: u32 = 0x5000_0000;
/// Offset for the data output register.
const GPIO_DATA: u32 = 0x0000_0014;
/// Writes a value to a hardware register.
/// SAFETY: The address must point to a valid, aligned hardware register.
/// 1. The address is a constant defined by the chip's memory map.
/// 2. The value is a u32, matching the register width.
/// 3. The access is marked volatile to prevent compiler optimization.
pub fn write_register(value: u32) {
unsafe {
// Cast the calculated address to a mutable raw pointer.
let addr = (GPIO_BASE + GPIO_DATA) as *mut u32;
// Volatile write ensures the compiler emits the instruction.
ptr::write_volatile(addr, value);
}
}
/// Reads the current state of a hardware register.
/// SAFETY: The address must point to a valid, aligned hardware register.
/// 1. The address is a constant defined by the chip's memory map.
/// 2. The value is a u32, matching the register width.
/// 3. The access is marked volatile to prevent compiler optimization.
pub fn read_register() -> u32 {
unsafe {
// Cast the calculated address to an immutable raw pointer.
let addr = (GPIO_BASE + GPIO_DATA) as *const u32;
// Volatile read forces a fresh fetch from the hardware bus.
ptr::read_volatile(addr)
}
}
The unsafe block is necessary because raw pointer dereferences bypass Rust's safety guarantees. The // SAFETY: comments document the invariants that make this specific usage safe. The community convention is to keep unsafe blocks as small as possible and wrap them in safe functions. This limits the blast radius of a potential bug. You also see ptr::write_volatile and ptr::read_volatile instead of standard dereference operators. The standard dereference operators assume the value is stable across multiple reads. Hardware registers are never stable.
Higher-level drivers usually abstract this pattern. The embedded-hal crate defines traits like digital::OutputPin that let you write driver logic without hardcoding register addresses. You implement the trait for your specific chip, then your driver code works across any microcontroller that provides the same trait. This separation keeps your business logic portable while isolating the hardware-specific unsafe code in a single layer.
Wrap every raw pointer in a safe API. The rest of your codebase should never see unsafe.
Where things go wrong
The most common trap is forgetting volatile. If you use *addr = value instead of ptr::write_volatile, the compiler will optimize your hardware writes away. It sees a write to a local pointer and assumes no other code can observe the change. The result is a device that appears to do nothing. The compiler will not warn you. It follows your instructions perfectly. You must explicitly request volatile semantics.
Another frequent issue is missing the panic handler. If you forget #[panic_handler], the linker throws an error about an undefined symbol. The exact message varies by target, but it always points to rust_begin_unwind or panic_impl. You cannot compile a no_std binary without defining how panics behave.
Memory layout mistakes cause silent crashes. If your linker script places the stack in a memory region that overlaps with your data section, the program will overwrite its own variables as soon as it calls a function. The compiler rejects mismatched types with E0308, but it cannot catch overlapping memory regions. You have to verify the linker script against the chip's datasheet.
Interrupt handlers introduce their own pitfalls. An interrupt service routine runs concurrently with your main loop. If you share mutable state between them, you must use atomic operations or critical sections. The portable-atomic crate provides atomic types that work without standard library support. Disabling interrupts globally for too long will cause your peripherals to miss data. Scope your critical sections tightly.
Trust the borrow checker. It will catch data races in your main loop, but you must manually enforce safety in interrupt contexts.
Choosing your approach
Use #![no_std] when you are writing bare metal firmware for microcontrollers or bootloaders. Use embedded-hal traits when you want to write drivers that work across multiple microcontroller families without duplicating register logic. Use std when your driver runs inside a Linux kernel module or a userspace application with full operating system support. Reach for vcell or core::cell::Cell when you need interior mutability for hardware registers that must be accessed from multiple places without violating borrow rules. Pick raw pointers with volatile semantics when you are implementing the lowest layer of a driver that maps directly to memory addresses. Trust the datasheet. The compiler cannot verify hardware timing constraints.