8 Essential Rust Techniques for Embedded Systems Programming: From C to Memory-Safe Firmware

Discover 8 practical Rust techniques for embedded programming on microcontrollers. Learn cross-compilation, hardware control, interrupt handling, and power optimization for reliable firmware development.

8 Essential Rust Techniques for Embedded Systems Programming: From C to Memory-Safe Firmware

Let me show you how I write programs for small computers. These aren’t like your laptop or phone. They’re tiny devices with strict limits on memory and power. Think of a thermostat, a smart watch, or the controller in a car door. For years, people used a language called C for this work. It works, but it’s easy to make mistakes that crash the whole system. Now, I use Rust. It helps me avoid those mistakes while still letting me talk directly to the metal of the machine.

I want to share eight ways I use Rust for this kind of work. I’ll explain them as if we’re building something together. We’ll start from the very beginning and work our way to more advanced ideas.

First, I have to speak the right language. My computer speaks x86, but the tiny microcontroller I’m programming might speak ARM or RISC-V. It’s a different dialect. I need to translate my Rust code into instructions that chip understands. This is called cross-compilation.

I use a tool called rustup to handle this. It lets me install compilers for other targets. For a common ARM Cortex-M chip, I’d tell my system to add that target. Then, I configure my project to use it. I also often need a special file called a linker script. This script is a map. It tells the compiler where to put my code in the chip’s memory—what goes in the flash storage, what goes in RAM.

Here’s what that setup might look in my project’s configuration file:

[target.thumbv7m-none-eabi]
runner = "arm-none-eabi-gdb"
rustflags = [
  "-C", "link-arg=-Tlink.x",
]

To get the compiler itself, I run a simple command: rustup target add thumbv7m-none-eabi. Now my tools are ready. My computer can write programs for that other, smaller computer.

The next step is talking to the hardware. A microcontroller isn’t like a regular computer with an operating system. There’s no driver to ask to turn on a light. I have to do it myself by writing to a specific spot in the chip’s memory. These spots are called memory-mapped registers. Writing to one might turn on a light. Reading from another might tell me if a button is pressed.

In Rust, I use volatile operations for this. This tells the compiler: “Don’t try to be clever with this memory. Read it or write it exactly when and how I say, because the value might change from outside the program.” Without this, the compiler might optimize away my read or write, breaking everything.

Let’s say I want to control a simple LED on pin 5. The hardware manual tells me the address of the GPIO port and which offsets control the output. I create a simple structure to represent it.

use core::ptr::{read_volatile, write_volatile};

struct Gpio {
    base_address: usize,
}

impl Gpio {
    fn new(address: usize) -> Self {
        Gpio { base_address: address }
    }

    // Turn a specific pin on
    fn set_pin_high(&mut self, pin_number: u8) {
        let set_register_offset = 0x14;
        unsafe {
            let register_address = (self.base_address + set_register_offset) as *mut u32;
            write_volatile(register_address, 1 << pin_number);
        }
    }

    // Read the state of a pin
    fn is_pin_high(&self, pin_number: u8) -> bool {
        let input_register_offset = 0x10;
        unsafe {
            let register_address = (self.base_address + input_register_offset) as *const u32;
            let value = read_volatile(register_address);
            (value & (1 << pin_number)) != 0
        }
    }
}

// I'd use it like this in my code:
// let mut led_port = Gpio::new(0x4002_0000); // Example address
// led_port.set_pin_high(5); // Turn on LED on pin 5

Notice the unsafe block. Direct hardware access is inherently unsafe—I’m telling the compiler I take responsibility for getting the address right. The structure wraps this unsafe operation in a safer, easier-to-use interface.

Now, my program needs to do more than one thing. It might need to blink a light while also checking a button and reading from a temperature sensor. There’s no operating system to manage these tasks. I have to handle all the concurrency myself.

A common way is to use interrupts. The main program runs a loop, doing its work. When a button is pressed, the hardware immediately pauses the main loop and jumps to a special function I wrote, called an Interrupt Service Routine. This function handles the button press quickly, then jumps back.

The problem is sharing data. If my main loop is updating a counter and the interrupt routine also tries to read it, I could get corrupted data. Rust’s ownership system is brilliant here. It stops me from making this mistake at compile time.

I use a pattern with a Mutex (a mutual exclusion lock) designed for interrupt safety. It ensures only one part of the code can access the data at a time, even between the main loop and an interrupt.

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

// A global, shared counter
static COUNTER: Mutex<RefCell<u32>> = Mutex::new(RefCell::new(0));

#[entry]
fn main() -> ! {
    let mut local_count = 0;
    loop {
        // Update the shared counter safely
        interrupt::free(|critical_section| {
            *COUNTER.borrow(critical_section).borrow_mut() = local_count;
        });
        local_count = local_count.wrapping_add(1);
        // ... do other work
    }
}

// An interrupt can safely read the counter too
#[interrupt]
fn some_hardware_interrupt() {
    interrupt::free(|critical_section| {
        let count_value = *COUNTER.borrow(critical_section).borrow();
        // Now I can use count_value safely inside the interrupt
    });
}

The interrupt::free function creates a critical section. It temporarily disables interrupts, does the data access, then re-enables them. This guarantees no interrupt can happen halfway through my update. Rust’s type system makes me use this pattern; I can’t accidentally access the shared data without going through the mutex.

Before any of my code runs, the chip needs basic setup. It needs a stack pointer, its clock might need to be configured, and the memory initialized. This is the startup code. In Rust, I start by telling the compiler I won’t use the standard library. There’s no “std” here—no files, no threads, no heap allocator by default. I’m on my own.

I use a minimal runtime crate like cortex-m-rt for ARM chips. It provides the absolute basics to get from chip reset to my main function. My program starts with a few directives.

#![no_std]  // No standard library
#![no_main] // We provide our own entry point, not the OS's

use cortex_m_rt::entry;
use panic_halt as _; // If something panics, just halt the processor

#[entry]
fn main() -> ! {
    // My program starts here.
    // First, I often take ownership of the core peripherals.
    let _core_peripherals = cortex_m::Peripherals::take().unwrap();

    // Now I can configure the system clock, setup watchdog timers, etc.
    // ...

    // The return type '!' means this function never returns.
    loop {
        // My main loop lives here forever.
    }
}

The panic_halt is a panic handler. In a desktop program, a panic prints a message and exits. On my embedded device, I might want it to just stop and blink an error LED. This crate does the simplest thing: it stops the processor.

Let’s look closer at interrupts. They are the nervous system of an embedded device. A timer finishes, an interrupt fires. Data arrives on a serial port, an interrupt fires. Speed is crucial. An Interrupt Service Routine should do the minimum necessary: grab the data, set a flag, clear the interrupt signal in the hardware, and get out.

Here’s a more complete example for a timer interrupt on a common STM32 chip.

use cortex_m::peripheral::NVIC;
use stm32f4::stm32f405::{Interrupt, TIM2};

#[interrupt]
fn TIM2() {
    // 1. Immediately clear the interrupt flag in the timer's hardware register.
    // This tells the hardware the interrupt has been serviced.
    unsafe {
        let timer = &*TIM2::ptr();
        timer.sr.modify(|_, w| w.uif().clear_bit());
    }

    // 2. Do the time-critical work. Maybe toggle a pin directly.
    // ...

    // 3. If there's more complex work, set an atomic flag for the main loop to see.
    // The main loop will check this flag later and process the event.
}

fn setup_timer_and_interrupt() {
    // ... (Code to configure the TIM2 timer to count and generate an interrupt)

    // Finally, tell the interrupt controller to enable the TIM2 interrupt.
    unsafe {
        NVIC::unmask(Interrupt::TIM2);
    }
}

The key is separation. The interrupt does the urgent work. Non-urgent work is deferred to the main loop. This keeps my system responsive.

One of the most powerful ideas in Rust for embedded work is abstraction through traits. I can write a driver for a sensor without knowing exactly which microcontroller it will run on. I define what the hardware should do, not how a specific chip does it.

For instance, I can define what it means to be a digital output pin or an SPI communication bus.

pub trait DigitalOutput {
    fn set_high(&mut self);
    fn set_low(&mut self);
}

pub trait SpiBus {
    type Error; // Different chips might have different error types
    fn write(&mut self, data: &[u8]) -> Result<(), Self::Error>;
}

// Now I can write a generic LED driver
struct Led<Pin: DigitalOutput> {
    pin: Pin,
}

impl<Pin: DigitalOutput> Led<Pin> {
    fn new(pin: Pin) -> Self {
        Led { pin }
    }

    fn turn_on(&mut self) {
        self.pin.set_high();
    }

    fn turn_off(&mut self) {
        self.pin.set_low();
    }
}

Later, I (or someone else) can implement the DigitalOutput trait for a specific GPIO pin on a specific chip. My Led code doesn’t change. This makes drivers portable and testable. I can even create a mock implementation for testing on my PC.

Embedded systems often don’t have a heap. You can’t just ask for more memory whenever you want. All memory is accounted for at compile time. But sometimes, you really do need some dynamic memory—maybe for a buffer that holds incoming serial data of variable length.

Rust can support this with a custom global allocator. I can dedicate a chunk of my RAM to be used as a heap. It’s small and fixed in size. A crate like alloc-cortex-m provides a simple allocator for this purpose.

#![feature(alloc_error_handler)] // We need to handle out-of-memory ourselves

extern crate alloc; // Use the core `alloc` crate, which has Vec, String, etc.
use alloc_cortex_m::CortexMHeap;
use core::alloc::Layout;

// Define a 1 kilobyte heap area
const HEAP_SIZE: usize = 1024;

// Declare the global allocator
#[global_allocator]
static ALLOCATOR: CortexMHeap = CortexMHeap::empty();

// This function is called before main to set up the heap
fn init_allocator() {
    // `heap_start` is a symbol defined in our linker script
    let heap_start = cortex_m_rt::heap_start() as usize;
    unsafe {
        ALLOCATOR.init(heap_start, HEAP_SIZE);
    }
}

// If we run out of heap memory, this function is called.
#[alloc_error_handler]
fn out_of_memory(_layout: Layout) -> ! {
    // We can't recover. Maybe blink an LED furiously.
    loop {
        // ... emergency code ...
    }
}

// In my main function, I call init_allocator().
// Now I can use types like `Vec` or `Box`, but only within my 1KB limit.

This is a powerful tool, but I use it sparingly. Every dynamic allocation is a risk of fragmentation or running out of memory. For many cases, static arrays or pool allocators are safer.

Finally, many of my devices run on batteries. Power is precious. A microcontroller can use milliamps when active, but microamps when in a deep sleep mode. My job is to put the chip to sleep as often and for as long as possible.

The pattern is simple: do your work quickly, then put the CPU into a low-power sleep mode. Configure an interrupt—from a timer, a button, or a sensor—to wake it back up.

use cortex_m::asm;

fn go_to_sleep() {
    // 1. Configure peripherals for low-power operation.
    // Maybe turn off certain clocks or put I/O pins in a low-power state.
    unsafe {
        let power_control = &*stm32f4::stm32f405::PWR::ptr();
        power_control.cr.modify(|_, w| {
            w.lpds().set_bit() // Enable low-power deep sleep
             .pdds().set_bit() // Enter deep sleep on WFI instruction
        });
    }

    // 2. Clear any pending interrupts that might wake us up immediately.
    // ...

    // 3. Execute the Wait-For-Interrupt instruction.
    // The CPU halts here. Power consumption drops dramatically.
    asm::wfi();

    // 4. Code execution resumes here after an interrupt wakes the chip.
    // The first thing I do is reconfigure the system clock and
    // peripherals back to their active state.
}

The main loop of an energy-conscious application often looks like this: check if there’s any work to do, do it, then immediately go back to sleep. The device might spend 99% of its life asleep, waking for a few milliseconds every second to take a sensor reading.

These eight techniques form a toolkit. I start by setting up my tools for the right target. I learn to talk directly to hardware registers. I manage concurrency safely with interrupts and shared data. I set up a minimal environment to run in. I handle interrupts efficiently. I build portable drivers with traits. I manage tiny amounts of dynamic memory carefully. And I always keep power consumption in mind.

Writing firmware this way feels different. The compiler becomes a partner, catching whole categories of bugs before the code ever touches the hardware. It lets me be bold in my design, knowing the fundamental memory and concurrency errors are guarded against. I spend less time debugging strange crashes and more time building the logic that makes the device useful. For small computers where every byte and microsecond counts, that’s a significant shift.


// Keep Reading

Similar Articles