Bare-Metal Rust: Safety Below the OS
I spent six hours fighting the borrow checker over an interrupt handler. Six hours. The compiler kept rejecting my code, insisting I couldn’t share mutable state between the main loop and the ISR. I was convinced Rust was being pedantic about something that worked fine in C for decades. When I finally compiled a workaround using unsafe, I stress-tested it. Race condition at 847 interrupts per second. The borrow checker had been right. The bug would’ve shipped. In C, it would’ve passed testing — race conditions in embedded systems are timing-dependent nightmares that only manifest under specific hardware states you didn’t think to test. On deployed devices. Where fixes require firmware updates to thousands of units. Why I Chose Rust for Firmware Performance wasn’t negotiable. We needed deterministic timing, minimal overhead, direct hardware access. No garbage collector pauses. No runtime bloat. When you’re writing firmware that controls physical hardware — motors, sensors, safety systems — millisecond delays matter. A null pointer dereference doesn’t just crash your program. It crashes someone’s device. Safety wasn’t optional either. The code had to be correct. I’d written C for embedded systems before. Full control, sure, but also full responsibility for every undefined behavior lurking in your code. Buffer overflows that corrupt peripheral registers. Use-after-free bugs that manifest only after 72 hours of continuous operation. Data races between interrupt handlers and main loops that you discover during a customer demo. Rust promised both: zero-cost abstractions with compile-time safety guarantees. I was skeptical. Zero-cost sounded like marketing. Compile-time guarantees sounded like wishful thinking. Then I looked at the assembly output. Compile-Time Safety Isn’t Overhead — It’s Preventing Hardware Corruption I thought the borrow checker was compiler bureaucracy. Just overhead getting in the way of shipping code. Why did I need a compiler to hold my hand when I knew how to manage memory manually? The timer bug proved otherwise. In C, when you pass a pointer to hardware registers, nothing stops you from accessing it from multiple places simultaneously. Interrupt handlers, main loops, background tasks — all touching the same memory. You document it in comments. You hope other developers read the comments. You write “DO NOT ACCESS FROM ISR” in all caps and pray. Rust makes the synchronization requirement explicit: // This won't compile - and that's the point static mut TIMER_COUNT: u32 = 0;
- [interrupt]
fn timer_interrupt() {
unsafe {
TIMER_COUNT += 1; // Compiler error: mutable static accessed from interrupt
}
} The compiler rejects this. Not at runtime. At compile time. Before you flash the firmware. You fix it with atomic types: use core::sync::atomic::{AtomicU32, Ordering};
static TIMER_COUNT: AtomicU32 = AtomicU32::new(0); // Safe shared state
- [interrupt]
fn timer_interrupt() {
TIMER_COUNT.fetch_add(1, Ordering::Relaxed); // Atomic increment
}
fn main() {
loop {
let count = TIMER_COUNT.load(Ordering::Relaxed); // Atomic read
if count > 1000 { break; }
}
} No unsafe blocks needed. The atomic guarantees are encoded in the type. The compiler verifies correct memory ordering. The generated assembly is identical to what you’d write in C — but now it’s provably correct. This matters because debugging race conditions on embedded hardware is nightmare fuel. You can’t attach a debugger easily. Print statements might change the timing enough to hide the bug. The bug might only appear after thermal cycling or when the ADC noise floor hits a specific threshold. Rust catches these at compile time. Ownership → Lifetimes → Traits → Type States: Building Correctness in Layers Ownership prevents data races. That’s the foundation. You can’t have two mutable references to the same data. Period. The compiler enforces this everywhere — interrupt handlers, DMA transfers, peripheral access. Lifetimes enforce borrowing rules. They ensure references can’t outlive the data they point to. Critical for interrupt handlers that reference hardware registers. The compiler tracks every borrow, verifies every lifetime, prevents use-after-free before you even flash the firmware. Speaking of lifetimes — I didn’t get them at first. The error messages were cryptic. “Cannot borrow as mutable while borrowed as immutable” made sense in theory but not in my code. Then I realized lifetimes weren’t arbitrary restrictions. They were encoding facts about my program’s control flow that I’d been tracking mentally in C. Now the compiler tracked them for me. Traits enable abstraction without overhead. You can write generic embedded code that works across different microcontrollers, with zero runtime cost. The trait implementations get monomorphized — specialized for each concrete type at compile time. You get C++ template-level flexibility without the compilation time explosion or linker nightmares. Type states prevent invalid hardware configurations. You can encode state machines in the type system itself: struct Gpio<State> { pin: u8, _state: PhantomData<State> } // Pin with type-level state struct Input; // Marker type struct Output; // Marker type
impl Gpio<Input> {
fn read(&self) -> bool { /* read pin state */ true }
}
impl Gpio<Output> {
fn write(&mut self, value: bool) { /* set pin state */ }
} // Can't call write() on Input pin - won't compile You can’t call write() on an input pin. The compiler prevents it. Invalid states become unrepresentable. In C, you'd document this in comments and hope. In Rust, the type system enforces it. Each layer builds on the last. Ownership enables safe concurrency. Lifetimes make ownership practical. Traits make it generic. Type states make it correct by construction. But I’m jumping ahead — let me backtrack to when this actually clicked for me. The Timer Bug C Would Have Shipped (And Rust Caught at Compile Time) The first time Rust prevented a production bug, I was converting C firmware to Rust. The C code had a subtle issue — a timer callback function accessed a data structure that could be freed by the main loop under certain conditions. The bug was rare, timing-dependent, impossible to reproduce reliably. I’d seen it crash once in six months of testing. In Rust, the code wouldn’t compile. “Cannot borrow as mutable while borrowed as immutable.” The lifetime conflict was obvious once I saw the error message. The timer callback borrowed the data immutably. The main loop tried to borrow it mutably to free it. Rust requires mutable borrows to be exclusive. The compiler caught the race condition immediately. I refactored the design. The timer callback took ownership of the data it needed, or used a proper synchronization primitive. The compiler verified correctness. The bug couldn’t exist anymore — not “probably won’t happen,” but literally cannot exist in compiled code. That saved weeks of debugging. On deployed hardware. Where you can’t just attach gdb and step through execution. Another win: peripheral drivers. Writing a safe, zero-cost I2C driver in C is hard. You have to track initialization state, prevent concurrent access, ensure proper resource cleanup. Get it wrong and you corrupt bus state or leak resources. I’ve debugged I2C driver bugs that took days to track down — turns out someone was calling transfer() before init() completed, but only when the watchdog timer fired during initialization. Rust makes it straightforward: struct I2c {
base_addr: usize, // Hardware register base
}
impl I2c {
fn new(addr: usize) -> Self {
// Initialize peripheral, configure GPIO
Self { base_addr: addr }
}
fn transfer(&mut self, data: &[u8]) -> Result<(), Error> {
// Write to hardware - requires mutable borrow
Ok(())
}
} impl Drop for I2c {
fn drop(&mut self) {
// Clean up: disable peripheral, release GPIO
}
} The type system enforces initialization order — you can’t call transfer() without constructing the driver first. RAII ensures cleanup happens automatically. The borrow checker prevents concurrent access—you can't have two mutable references to the I2C bus. You can't misuse the API because invalid operations don't type-check. Wait, I need to backtrack on Drop. I said RAII ensures cleanup, but that’s not entirely true in bare-metal contexts. Drop Semantics: The Gotcha That Confused Me for Months I didn’t understand Drop semantics. My resources leaked for weeks. I wrote a driver that initialized hardware in new() and cleaned it up in drop(). Standard RAII pattern, right? Except I was calling core::mem::forget() on the driver in one error path. Just temporarily, to debug something. The hardware never got deinitialized. The peripheral stayed locked. Subsequent initialization attempts failed. Drop isn’t guaranteed to run in bare-metal contexts. If you panic (and panic=abort is common in embedded), Drop doesn’t run. If you forget() something, Drop doesn’t run. If your program just halts, Drop doesn’t run. Critical resources need explicit cleanup, not just Drop. Or you need to ensure Drop always runs using scope guards and avoiding forget(). The type system can’t save you from logic errors in cleanup code. This confused me for months. I kept assuming RAII meant automatic cleanup, but “automatic” has caveats in systems without stack unwinding. You have to design around those caveats. The compiler won’t save you here — you need to understand the semantics. Actually, the compiler does help. It warns you when you leak a value that implements Drop. But you can still shoot yourself in the foot if you’re not careful. Rust forces clarity, but clarity requires understanding what you’re clarifying. Memory Safety Without a GC: The Moment the Tradeoffs Clicked My code was memory-safe without a garbage collector. That realization hit hard. I’d written a complex state machine with multiple concurrent tasks, interrupt handlers, and shared hardware resources. In C, this would’ve required careful manual synchronization, extensive testing, and probably some lurking bugs I’d discover in production. In Rust, it compiled. The first time it compiled, it worked. Not just “ran without crashing” worked — actually correct worked. The borrow checker had forced me to think through every data dependency, every lifetime, every potential race condition. The type system encoded invariants I would’ve documented in comments and hoped developers respected. When the compiler accepted my code, I knew it was correct. Not “probably correct” or “tested and seems to work” — provably correct within the type system’s guarantees. The embedded Rust ecosystem has grown substantially. The Embedded Working Group maintains hardware abstraction layers for major architectures. Peripheral Access Crates (PACs) provide type-safe register access generated directly from vendor SVD files. You point the tool at the chip manufacturer’s specification, and it generates a Rust API with compile-time guarantees that match the hardware’s actual capabilities. Board Support Packages (BSPs) offer ready-to-use drivers for common development boards. Real projects use this. Production firmware for IoT devices, motor controllers, industrial systems — not toys and demos. But the real power isn’t the ecosystem. It’s what Rust does to your thinking. Rust is harder than C. The learning curve is steep and the compiler is unforgiving — but the fights make you better. Each error message teaches you about ownership, about lifetimes, about safe concurrency patterns C lets you violate silently. The compiler doesn’t just reject bad code — it explains why the code is bad and hints at how to fix it. After six months of embedded Rust, I can’t go back to C. Not because C is impossible, but because I’ve seen what’s possible when the compiler enforces correctness. When entire categories of bugs simply can’t exist. When you can refactor fearlessly because the type system catches every mistake. The compiler will reject your code. You’ll think it’s being pedantic. Then you’ll realize your approach was wrong. You’ll redesign with proper ownership. The code will compile. And it’ll be memory-safe, without a garbage collector, running on bare metal. I’m learning async Rust for embedded systems now. Embassy and RTIC offer async runtimes designed for microcontrollers — cooperative scheduling with zero allocations, interrupt-driven execution models, efficient power management. Async brings its own complexity. Pin semantics are subtle. Future combinators require understanding lifetimes at a deeper level. But I’ve fought the borrow checker before. I know how this goes. The compiler will teach me. The error messages will make sense eventually. And the code will be correct when it compiles.