Embedded Rust Patterns

Writing Rust for a microcontroller is not quite the same as writing Rust for a desktop application. You have no operating system, no allocator (usually), and every byte of RAM counts. This chapter covers the patterns that experienced embedded Rust developers reach for again and again.

No Heap: The heapless Crate

On a microcontroller, calling malloc is risky. Heap fragmentation can cause allocation failures hours or days into a mission — the worst kind of bug. The standard Vec, String, and VecDeque all use the heap. The heapless crate gives you fixed-capacity alternatives that live entirely on the stack or in static memory.

#![allow(unused)]
fn main() {
use heapless::Vec;
use heapless::String;

// A Vec that holds at most 64 readings — no heap allocation
let mut readings: Vec<f32, 64> = Vec::new();

readings.push(23.5).ok();  // Returns Err if full
readings.push(24.1).ok();

// A String with a maximum of 128 bytes
let mut msg: String<128> = String::new();
core::fmt::write(&mut msg, format_args!("Temp: {:.1}C", readings[0])).ok();
}

The capacity is part of the type. Vec<f32, 64> and Vec<f32, 128> are different types. The compiler knows exactly how much memory each one needs, and it is allocated at compile time.

TypeHeap VersionHeapless VersionNotes
Growable arrayVec<T>Vec<T, N>Fixed max capacity N
StringStringString<N>N is max byte length
QueueVecDeque<T>Deque<T, N>Ring buffer, fixed size
MapHashMap<K,V>LinearMap<K,V,N>Linear search, small N only
Producer/Consumermpsc::channelspsc::Queue<T, N>Lock-free, single producer/consumer

Fun Fact: The heapless crate's spsc::Queue is interrupt-safe without disabling interrupts. It uses atomic operations to let one context produce and another consume without locks. This is exactly what you need for passing data from an interrupt handler to a main loop.

Handle Every Error

In desktop Rust, calling .unwrap() on a Result panics with a nice error message and stack trace. On a microcontroller, a panic usually means the chip resets or hangs. There is no terminal to print a stack trace to (unless you set up defmt).

#![allow(unused)]
fn main() {
// BAD: panic if the sensor read fails
let temperature = sensor.read_temperature().unwrap();

// GOOD: handle the error gracefully
let temperature = match sensor.read_temperature() {
    Ok(t) => t,
    Err(_) => {
        defmt::warn!("Sensor read failed, using last known value");
        last_known_temperature
    }
};
}

Common embedded error handling strategies:

Fallback value — Use the last known good value. Good for telemetry where a stale reading is better than no reading.

Retry with backoff — Try again after a delay. Good for communication interfaces that might be temporarily busy.

Degrade gracefully — Switch to a simpler mode of operation. If the barometer fails, continue flying with GPS altitude only.

Reset the peripheral — Some peripherals get into bad states. Reinitializing the SPI bus or the sensor can clear the fault.

#![allow(unused)]
fn main() {
// Retry pattern with a maximum attempt count
async fn read_with_retry(sensor: &mut Bmp280<I2c>) -> Option<f32> {
    for attempt in 0..3 {
        match sensor.read_pressure().await {
            Ok(p) => return Some(p),
            Err(e) => {
                defmt::warn!("Read attempt {} failed: {:?}", attempt, e);
                Timer::after_millis(10).await;
            }
        }
    }
    defmt::error!("Sensor read failed after 3 attempts");
    None
}
}

Think About It: In a safety-critical system, "what happens when this fails?" is the most important question you can ask about every single line of code. The Rust compiler forces you to answer it for every Result — use that to your advantage.

State Machines with Enums

Embedded systems are full of state machines. A motor controller might be in Idle, Spinning Up, Running, or Fault. A communication protocol might be WaitingForHeader, ReceivingPayload, or ProcessingMessage. Rust enums model these perfectly.

#![allow(unused)]
fn main() {
#[derive(Debug, Clone, Copy, PartialEq, defmt::Format)]
enum FlightMode {
    Disarmed,
    Armed,
    Stabilize { throttle: f32 },
    ReturnToHome { target_lat: f32, target_lon: f32 },
    Emergency,
}

fn next_mode(current: FlightMode, event: Event) -> FlightMode {
    match (current, event) {
        (FlightMode::Disarmed, Event::ArmSwitch) => FlightMode::Armed,
        (FlightMode::Armed, Event::ThrottleUp(t)) => {
            FlightMode::Stabilize { throttle: t }
        }
        (_, Event::LowBattery) => FlightMode::Emergency,
        (_, Event::DisarmSwitch) => FlightMode::Disarmed,
        (state, _) => state, // Ignore unhandled events
    }
}
}

The compiler guarantees exhaustive matching. If you add a new variant to FlightMode, every match statement that does not handle it will fail to compile. This is enormously valuable — it means you cannot accidentally forget to handle a new state.

The Newtype Pattern for Units

Mixing up units is a classic embedded bug. Is that angle in degrees or radians? Is that distance in meters or centimeters? The newtype pattern uses Rust's type system to make unit confusion a compile-time error.

#![allow(unused)]
fn main() {
#[derive(Debug, Clone, Copy)]
struct Meters(f32);

#[derive(Debug, Clone, Copy)]
struct Degrees(f32);

#[derive(Debug, Clone, Copy)]
struct Radians(f32);

impl Degrees {
    fn to_radians(self) -> Radians {
        Radians(self.0 * core::f32::consts::PI / 180.0)
    }
}

fn set_heading(heading: Degrees) {
    // Unambiguous — this function takes degrees, not radians
    let rad = heading.to_radians();
    // ...
}

// This will NOT compile:
// set_heading(Meters(100.0));  // Error: expected Degrees, found Meters
}

This costs nothing at runtime. The compiler strips away the wrapper type and works directly with the inner f32. You get type safety for free.

Fun Fact: The Mars Climate Orbiter was lost in 1999 because one software module produced thrust values in pound-force seconds while another expected newton-seconds. A newtype pattern would have caught this at compile time.

Portable Drivers with embedded-hal

The embedded-hal crate defines traits for common hardware interfaces: SpiDevice, I2c, InputPin, OutputPin, and so on. If you write a sensor driver that is generic over these traits, it works on any microcontroller that implements them — STM32, nRF52, ESP32, RP2040.

#![allow(unused)]
fn main() {
use embedded_hal::i2c::I2c;

/// A BMP280 barometer driver that works on any platform
pub struct Bmp280<I2C> {
    i2c: I2C,
    address: u8,
}

impl<I2C: I2c> Bmp280<I2C> {
    pub fn new(i2c: I2C, address: u8) -> Self {
        Self { i2c, address }
    }

    pub fn read_pressure(&mut self) -> Result<f32, I2C::Error> {
        let mut buf = [0u8; 3];
        self.i2c.write_read(self.address, &[0xF7], &mut buf)?;
        let raw = ((buf[0] as u32) << 12)
                | ((buf[1] as u32) << 4)
                | ((buf[2] as u32) >> 4);
        Ok(self.compensate_pressure(raw))
    }

    fn compensate_pressure(&self, raw: u32) -> f32 {
        // Compensation algorithm from BMP280 datasheet
        // (simplified for illustration)
        raw as f32 / 256.0
    }
}
}

This driver compiles for thumbv7em-none-eabihf (STM32) and thumbv6m-none-eabi (RP2040) and riscv32imc-unknown-none-elf (ESP32-C3) without changing a single line.

Zero-Cost Abstractions

Rust's generics and trait system are monomorphized at compile time. When you write a function generic over a trait, the compiler generates a specialized version for each concrete type. There is no vtable lookup, no dynamic dispatch, no overhead.

#![allow(unused)]
fn main() {
// This generic function:
fn read_sensor<S: I2c>(spi: &mut S) -> u16 {
    let mut buf = [0u8; 2];
    spi.read(&mut buf).ok();
    u16::from_be_bytes(buf)
}

// Compiles to the SAME assembly as:
fn read_sensor_stm32(spi: &mut embassy_stm32::i2c::I2c<'_>) -> u16 {
    let mut buf = [0u8; 2];
    spi.read(&mut buf).ok();
    u16::from_be_bytes(buf)
}
}

You can verify this yourself on Compiler Explorer. The generic version and the concrete version produce identical machine code.

Const Generics for Buffer Sizes

Const generics let you parameterize types and functions over constant values. This is perfect for embedded systems where buffer sizes are fixed at compile time but might differ between use cases.

#![allow(unused)]
fn main() {
struct RingBuffer<T, const N: usize> {
    buf: [T; N],
    head: usize,
    tail: usize,
}

impl<T: Copy + Default, const N: usize> RingBuffer<T, N> {
    const fn new() -> Self {
        Self {
            buf: [T::default(); N],
            head: 0,
            tail: 0,
        }
    }

    fn push(&mut self, item: T) -> bool {
        let next = (self.head + 1) % N;
        if next == self.tail {
            return false; // Full
        }
        self.buf[self.head] = item;
        self.head = next;
        true
    }

    fn pop(&mut self) -> Option<T> {
        if self.head == self.tail {
            return None; // Empty
        }
        let item = self.buf[self.tail];
        self.tail = (self.tail + 1) % N;
        Some(item)
    }
}

// Different sizes for different needs — all statically allocated
static mut IMU_BUF: RingBuffer<ImuReading, 128> = RingBuffer::new();
static mut BARO_BUF: RingBuffer<BaroReading, 16> = RingBuffer::new();
}

Think About It: Every pattern in this chapter shares a common theme: move decisions to compile time. Fixed-size collections, exhaustive state matching, unit types, monomorphized generics, const-sized buffers. The more the compiler knows, the fewer bugs can hide until runtime.

Summary

PatternProblem It SolvesRuntime Cost
heapless collectionsHeap fragmentationZero — stack allocated
Explicit error handlingSilent failuresZero — compiler enforced
Enum state machinesForgotten statesZero — compiler enforced
Newtype unitsUnit confusionZero — erased at compile time
embedded-hal traitsPlatform lock-inZero — monomorphized
Const genericsHardcoded buffer sizesZero — compile-time constants

These patterns are not just nice-to-haves. In embedded systems, they are the difference between a prototype that works on your desk and a product that works in the field for years.