Rust Programming for Embedded Systems: Writing Safe Firmware for Memory-Constrained Devices

Learn how to use Rust for embedded programming on microcontrollers with limited memory. Master no_std development, hardware abstraction, and memory management for reliable firmware development.

Rust Programming for Embedded Systems: Writing Safe Firmware for Memory-Constrained Devices

Let’s talk about Rust for small machines. I’m writing software for devices with kilobytes of memory, not gigabytes. A thermostat, a sensor module, a motor controller. In this world, every byte matters, and a crash isn’t an error message—it’s a bricked device. Rust has become my tool of choice here, not just for its speed, but for its strictness. It stops me from making the kinds of mistakes that are easy to make in C or C++, mistakes that are very hard to debug when your only output might be a single, sad LED.

The transition requires a shift in thinking. You leave behind the comforts of a full operating system. You can’t just allocate memory whenever you want. You’re talking directly to hardware. But Rust’s design helps you manage this complexity with clarity. Here are the ways I structure my code to work effectively in these constrained environments.

First, you must step away from the standard library. On your laptop, Rust’s std provides things like files, threads, and network sockets. A microcontroller has none of those. It’s bare metal. You start by telling Rust not to use the standard library. This is done with #![no_std] at the very top of your main file.

#![no_std]
#![no_main]

use core::panic::PanicInfo;

#[panic_handler]
fn panic(_info: &PanicInfo) -> ! {
    // On a small device, a panic might just halt.
    // In a real project, you might blink an LED or reset.
    loop {}
}

// The entry point is not Rust's usual `main`
#[no_mangle]
pub extern "C" fn main() -> ! {
    // Initialize your system here
    loop {
        // Your main loop runs forever
    }
}

This is your new starting point. Notice core is used, not std. core is a subset of Rust that works everywhere, providing basic types like u32 and Result, but not heap-allocated collections. Your binary becomes tiny, often just a few kilobytes to start. It’s a liberating constraint. You think more carefully about data structures from the very beginning.

Now, you need to talk to the hardware. A microcontroller has hundreds of special memory addresses called registers. Writing a 1 or a 0 to a specific bit might turn on an LED, read a button, or configure a timer. In C, you’d use volatile pointers and magic numbers. In Rust, we use a better way: Peripheral Access Crates.

These crates are usually generated from the chip manufacturer’s own descriptions. They give you a structured, type-safe view of all those registers. Instead of guessing bit positions, you call methods.

use stm32f4xx_hal::pac;
use stm32f4xx_hal::prelude::*;

// Take ownership of the device's peripherals. You can only do this once.
let dp = pac::Peripherals::take().unwrap();

// Get the parts that control clocks and power
let mut rcc = dp.RCC.constrain();

// Split the GPIO port A into individual pins
let gpioa = dp.GPIOA.split();

// Configure pin A5 as a push-pull output (like an LED)
let mut led = gpioa.pa5.into_push_pull_output();

// Turn the LED on
led.set_high();

The compiler helps you here. You can’t accidentally configure a pin that’s already in use elsewhere. The act of .split()ing the GPIO port gives you independent pins. The type of led knows it’s an output pin, so you can’t try to read from it like an input. This catches configuration errors at compile time, long before the code is on the device.

Hardware doesn’t just sit there; it interrupts you. A timer finishes, a byte arrives over serial, a button is pressed. These events trigger Interrupt Service Routines. In Rust, you define these as special functions. The key challenge is sharing data between this interrupt handler and your main code. You can’t just use a mutable global variable; that’s a classic source of bugs.

You use synchronization primitives designed for this interrupt-driven world. The cortex_m crate provides a Mutex that works by temporarily disabling interrupts.

use cortex_m::interrupt::{self, Mutex};
use core::cell::RefCell;

// A global, shared variable protected by a mutex.
static SHARED_COUNTER: Mutex<RefCell<u32>> = Mutex::new(RefCell::new(0));

// This function runs in the main execution context
fn setup() {
    // ... configure a timer to trigger an interrupt every second ...
}

// This is the interrupt handler
#[interrupt]
fn TIM2() {
    interrupt::free(|cs| {
        // 'cs' is a "critical section" token
        let mut ref_cell = SHARED_COUNTER.borrow(cs);
        let mut counter = ref_cell.borrow_mut();
        *counter += 1;
    });
}

fn read_counter() -> u32 {
    interrupt::free(|cs| {
        let ref_cell = SHARED_COUNTER.borrow(cs);
        let counter = ref_cell.borrow();
        *counter
    })
}

The interrupt::free block guarantees your code inside is the only thing accessing that data. It’s a clean, safe way to manage shared state in a system with no threads, just one main loop and interrupt handlers that can preempt it.

This leads to a broader pattern: managing global resources. A UART serial port or a network interface might need to be accessible from many parts of your program. The singleton pattern, using tools like cortex_m::singleton! or the shared-bus crate, helps here. The idea is to initialize the peripheral once, then hand out managed references to it.

use cortex_m::interrupt::Mutex;
use core::cell::RefCell;

static SPI_BUS: Mutex<RefCell<Option<SpiBus>>> = Mutex::new(RefCell::new(None));

fn init_spi(spi: SpiBus) {
    interrupt::free(|cs| {
        *SPI_BUS.borrow(cs).borrow_mut() = Some(spi);
    });
}

fn read_sensor() -> u16 {
    interrupt::free(|cs| {
        if let Some(ref mut bus) = *SPI_BUS.borrow(cs).borrow_mut() {
            // Use the shared SPI bus
            bus.read_register(0x00)
        } else {
            0
        }
    })
}

The Option inside the RefCell lets us initialize it later. The Mutex ensures safe access from anywhere. It feels structured, not like the wild west of global variables.

One of Rust’s best ideas for embedded is driver portability. Imagine you find a driver crate for a temperature sensor. You’d hate to rewrite it for every different brand of microcontroller. The embedded-hal project solves this. It’s a set of traits that define how to do basic operations: set a pin high/low, send a byte over SPI, delay for milliseconds.

Driver authors write against these traits.

use embedded_hal::blocking::i2c::{Write, WriteRead};
use embedded_hal::blocking::delay::DelayMs;

pub struct TemperatureSensor<I2C> {
    i2c: I2C,
    address: u8,
}

impl<I2C, E> TemperatureSensor<I2C>
where
    I2C: WriteRead<Error = E> + Write<Error = E>,
{
    pub fn new(i2c: I2C, address: u8) -> Self {
        TemperatureSensor { i2c, address }
    }

    pub fn read_temperature(&mut self) -> Result<f32, E> {
        let mut buffer = [0u8; 2];
        self.i2c.write_read(self.address, &[0x00], &mut buffer)?;
        // ... convert bytes to temperature ...
        Ok(23.5)
    }
}

Now, this sensor driver can work with any microcontroller that has an embedded-hal implementation for its I2C peripheral. I’ve used the same driver code on an STM32, an nRF52, and an ESP32. This separation of concerns is powerful. You write the device logic once.

With the basics of hardware access handled, you need to structure your application’s logic. Embedded systems are often reactive. They wait for an event, do something, and move to a new state. This is a perfect fit for a state machine. Using Rust’s enums and pattern matching makes this explicit and safe.

enum ConnectionState {
    Idle,
    Connecting { attempt: u8 },
    Connected { session_id: u32 },
    Fault,
}

impl ConnectionState {
    fn on_packet_received(&mut self, packet: &Packet) {
        match self {
            ConnectionState::Idle => {
                if packet.is_connect_request() {
                    *self = ConnectionState::Connecting { attempt: 1 };
                    send_handshake();
                }
            }
            ConnectionState::Connecting { attempt } => {
                if packet.is_handshake_ack() {
                    *self = ConnectionState::Connected { session_id: packet.session_id() };
                } else if *attempt >= 3 {
                    *self = ConnectionState::Fault;
                } else {
                    *attempt += 1;
                    send_handshake();
                }
            }
            ConnectionState::Connected { session_id } => {
                process_data_packet(packet, *session_id);
            }
            ConnectionState::Fault => {
                // Log error, attempt reset
            }
        }
    }
}

The compiler checks that I’ve handled every possible state. I can’t accidentally try to send data while in the Idle state; the pattern match won’t let me. This turns runtime logic errors into compile-time checks.

Memory is your most precious resource. Dynamic allocation on a heap is often too risky or simply not available. You learn to live on the stack. For collections, you use fixed-capacity versions from the heapless crate.

use heapless::Vec; // A vector with fixed maximum capacity
use heapless::consts::U32; // A type-level number, 32

// A buffer that can hold up to 32 sensor readings.
// It lives on the stack.
let mut readings: Vec<i16, U32> = Vec::new();

// Pushing returns a Result because it might fail if full.
if readings.push(1023).is_err() {
    // Handle buffer overflow gracefully
}

// You can also use arrays directly.
// This 128-byte buffer is on the stack, zero cost.
let mut network_frame: [u8; 128] = [0; 128];
// Fill it with data...

This forces you to think about worst-case scenarios upfront. How many messages can be queued? What’s the largest possible packet? You define these limits in your types, and the system is predictable. No unexpected allocation failures at runtime.

Finally, for devices running on batteries, power is everything. You spend most of the time asleep. Rust’s type system can help you sleep soundly. You can design your power management so that entering a low-power mode consumes a special type. To get that type, you must have configured a way to wake up.

struct StopMode {
    // This struct can only be created by a builder that has configured wakeups.
}

struct PowerBuilder {
    timer_wake: bool,
    pin_wake: bool,
}

impl PowerBuilder {
    fn new() -> Self {
        Self { timer_wake: false, pin_wake: false }
    }
    fn enable_timer_wakeup(mut self) -> Self {
        // ... hardware configuration ...
        self.timer_wake = true;
        self
    }
    fn enable_pin_wakeup(mut self) -> Self {
        // ... hardware configuration ...
        self.pin_wake = true;
        self
    }
    fn build(self) -> Result<StopMode, &'static str> {
        // You must enable at least one wakeup source.
        if self.timer_wake || self.pin_wake {
            Ok(StopMode {})
        } else {
            Err("Cannot enter stop mode without a wakeup source")
        }
    }
}

// Usage:
let low_power_mode = PowerBuilder::new()
    .enable_timer_wakeup()
    .enable_pin_wakeup()
    .build()
    .unwrap();

enter_stop_mode(low_power_mode); // The compiler guarantees we set it up.

If you forget to call enable_timer_wakeup, the build() method will return an Err. The type system guides you toward a correct configuration. You can’t even construct a valid StopMode without defining how the device will wake up. This moves a critical, battery-draining bug from a runtime issue to a compile-time error.

These techniques form a toolkit. They change how you approach problems. You start to see the hardware not as a collection of dangerous, mutable addresses, but as a set of resources with ownership rules. You see your application flow as state transitions, not just a tangle of flags and callbacks. You think in terms of fixed capacities and explicit power states.

The result is firmware that has the performance and control of C, but with far more confidence in its correctness. For me, that’s the real value. It means less time debugging mysterious resets, and more time building the features that make the device useful. When your code controls a physical device in the real world, that confidence isn’t just convenient; it’s essential. Rust provides a path to get there.


// Keep Reading

Similar Articles