Skip to main content
โšก Calmops

Embedded Systems Programming in Rust

Building Safe and Efficient Firmware with Rust

Embedded systems are everywhere: in your car, your smart home, industrial IoT devices, medical equipment, and satellites. Yet most embedded firmware is still written in C, a language that requires careful manual memory management. A single buffer overflow can compromise an entire deviceโ€”or worse, cause physical harm.

Rust changes this narrative. It brings memory safety, concurrency safety, and expressive abstractions to embedded programming without sacrificing performance or control. This article explores why embedded Rust matters and how to build reliable firmware with it.

Core Concepts & Terminology

What is Embedded Rust?

Embedded Rust is the application of Rust to programming microcontrollers, systems-on-chips (SoCs), and other resource-constrained devices. Unlike application development, embedded systems programming requires understanding hardware details: registers, interrupts, memory layout, and real-time constraints.

Key distinctions from application Rust:

  • No operating system (or minimal OS)
  • No standard library (no heap allocators by default)
  • No_std environments with limited runtime
  • Direct hardware register manipulation
  • Interrupt-driven programming
  • Predictable memory and performance characteristics

Bare Metal vs. Hosted Environments

Bare Metal: Programming directly on hardware without an OS

  • Full control over hardware
  • Minimal overhead
  • Requires understanding of microcontroller architecture
  • Example: ARM Cortex-M microcontrollers

Hosted: Running on an OS (Linux, FreeRTOS, etc.)

  • Easier development (you have standard library access)
  • More abstraction
  • Less direct hardware control
  • Example: Embedded Linux on Raspberry Pi

Target Triples and HALs

In embedded Rust, you compile for a specific target, specified by a target triple: <architecture>-<vendor>-<os>-<environment>

Examples:

  • thumbv7em-none-eabihf - ARM Cortex-M4 (no OS)
  • riscv32imac-unknown-none-elf - RISC-V 32-bit
  • aarch64-unknown-linux-gnu - ARM 64-bit Linux

A Hardware Abstraction Layer (HAL) provides idiomatic Rust bindings to hardware:

  • stm32h7xx-hal - STM32H7 microcontroller
  • nrf52840-hal - nRF52840 Bluetooth microcontroller
  • esp-idf-hal - ESP32 with FreeRTOS

Getting Started: Your First Embedded Project

1. Setup and Project Structure

# Install embedded Rust tools
cargo install cargo-embed      # Flash and debug embedded code
cargo install cargo-expand     # Expand macros to see generated code
cargo install cargo-generate   # Create project from templates

# Create a new project for STM32F4
cargo generate --git https://github.com/stm32-rs/cortex-m-quickstart.git --name my-embedded-app
cd my-embedded-app

2. Cargo Configuration

Your Cargo.toml for a no_std environment:

[package]
name = "embedded-demo"
version = "0.1.0"
edition = "2021"

[dependencies]
cortex-m = "0.7"
cortex-m-rt = "0.7"        # Runtime for ARM Cortex-M
stm32f4xx-hal = "0.14"     # HAL for STM32F4
panic-halt = "0.2"         # Panic handler

# Embedded Rust typically doesn't use std
[profile.release]
opt-level = "z"            # Optimize for size
lto = true                 # Link-time optimization

# src/lib.rs - Tell compiler no_std
#![no_std]
#![no_main]

use cortex_m_rt::entry;
use panic_halt as _;

3. The Simplest Embedded Program

// src/main.rs
#![no_std]
#![no_main]

use cortex_m_rt::entry;
use panic_halt as _;
use stm32f4xx_hal as hal;

use hal::{
    prelude::*,
    stm32,
};

#[entry]
fn main() -> ! {
    let dp = stm32::Peripherals::take().unwrap();
    let cp = cortex_m::Peripherals::take().unwrap();

    // Setup clock and GPIO
    let rcc = dp.RCC.constrain();
    let clocks = rcc.cfgr.freeze();
    let gpioe = dp.GPIOE.split();

    // LED is on PE13
    let mut led = gpioe.pe13.into_push_pull_output();

    loop {
        led.set_high();
        cortex_m::asm::delay(1_000_000);
        led.set_low();
        cortex_m::asm::delay(1_000_000);
    }
}

This toggles an LED indefinitely. Notice:

  • #![no_std] - No standard library
  • #![no_main] - Custom entry point (not main())
  • #[entry] - Marks the real entry point
  • panic_halt - Panic handler (infinite loop on panic)
  • ! return type - Function never returns (infinite loop)

Practical Embedded Patterns

Pattern 1: GPIO and Digital I/O

use stm32f4xx_hal::{
    gpio::{Output, PushPull},
    prelude::*,
};

// Type-safe GPIO abstraction
struct Led {
    pin: stm32f4xx_hal::gpio::PE<Output<PushPull>>,
}

impl Led {
    fn new(pin: stm32f4xx_hal::gpio::PE<stm32f4xx_hal::gpio::Output<stm32f4xx_hal::gpio::PushPull>>) -> Self {
        Led { pin }
    }

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

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

    fn toggle(&mut self) {
        self.pin.toggle();
    }
}

#[entry]
fn main() -> ! {
    let dp = stm32::Peripherals::take().unwrap();
    let gpioe = dp.GPIOE.split();
    
    let mut led = Led::new(gpioe.pe13.into_push_pull_output());

    loop {
        led.toggle();
        cortex_m::asm::delay(1_000_000);
    }
}

Pattern 2: Interrupts and Event Handlers

use cortex_m::interrupt::{Mutex, free};
use core::cell::RefCell;
use stm32f4xx_hal::stm32;

// Shared mutable state protected by interrupt-free critical section
static COUNTER: Mutex<RefCell<u32>> = Mutex::new(RefCell::new(0));

#[entry]
fn main() -> ! {
    let dp = stm32::Peripherals::take().unwrap();
    
    // Enable Timer 2 interrupt
    dp.TIM2.dier.write(|w| w.uie().set_bit());
    dp.TIM2.cr1.write(|w| w.cen().set_bit());

    // Enable NVIC interrupt
    unsafe {
        cortex_m::peripheral::NVIC::unmask(stm32::Interrupt::TIM2);
    }

    loop {
        // Read counter safely
        free(|cs| {
            let count = *COUNTER.borrow(cs).borrow();
            println!("Counter: {}", count);
        });
        cortex_m::asm::wfi(); // Wait for interrupt
    }
}

// Interrupt handler
#[interrupt]
fn TIM2() {
    free(|cs| {
        let mut counter = COUNTER.borrow(cs).borrow_mut();
        *counter += 1;
    });
}

Key insight: Interrupts are one place where Rust’s safety guarantees are relaxed (unsafe code required). The Mutex and free() pattern ensures safe interior mutability.

Pattern 3: Serial Communication (UART)

use stm32f4xx_hal::serial::Serial;
use core::fmt::Write;

#[entry]
fn main() -> ! {
    let dp = stm32::Peripherals::take().unwrap();
    let rcc = dp.RCC.constrain();
    let clocks = rcc.cfgr.freeze();
    
    let gpioa = dp.GPIOA.split();

    // TX on PA9, RX on PA10
    let tx = gpioa.pa9.into_alternate();
    let rx = gpioa.pa10.into_alternate();

    let serial = Serial::usart1(
        dp.USART1,
        (tx, rx),
        serial::Config::default().baudrate(115200.bps()),
        &clocks,
    ).unwrap();

    let (mut tx, _rx) = serial.split();

    loop {
        writeln!(tx, "Hello from STM32F4!\r").ok();
        cortex_m::asm::delay(1_000_000);
    }
}

Pattern 4: Timers and PWM

use stm32f4xx_hal::timer::{Timer, TimerMs};
use stm32f4xx_hal::time::MilliSeconds;

#[entry]
fn main() -> ! {
    let dp = stm32::Peripherals::take().unwrap();
    let rcc = dp.RCC.constrain();
    let clocks = rcc.cfgr.freeze();

    // Create a 1ms timer
    let mut timer = Timer::new(dp.TIM5, &clocks).start_count_down(MilliSeconds::new(1000));

    loop {
        match timer.wait() {
            Ok(()) => {
                // Do something every 1 second
                println!("Tick");
            }
            Err(_) => {}
        }
    }
}

Pattern 5: ADC (Analog-to-Digital Converter)

use stm32f4xx_hal::adc::Adc;

#[entry]
fn main() -> ! {
    let dp = stm32::Peripherals::take().unwrap();
    let rcc = dp.RCC.constrain();
    let clocks = rcc.cfgr.freeze();

    let gpioa = dp.GPIOA.split();
    let mut adc = Adc::adc1(dp.ADC1, true, Default::default());

    // PA0 is ADC channel 0
    let mut ch0 = gpioa.pa0.into_analog();

    loop {
        let sample: u32 = adc.convert(&mut ch0, Default::default());
        println!("ADC value: {}", sample);
        cortex_m::asm::delay(1_000_000);
    }
}

Real-Time Operating Systems (RTOS)

For more complex embedded systems, you might use an RTOS like FreeRTOS, RIOT, or others. Rust has bindings to many:

// Using RTOS-rs for FreeRTOS
use freertos_rs::*;

fn main() {
    Task::new()
        .name("task1")
        .stack_size(512)
        .priority(TaskPriority(1))
        .create(move || {
            loop {
                println!("Task 1 running");
                FreeRtos::delay_ms(1000);
            }
        })
        .unwrap();

    Task::new()
        .name("task2")
        .stack_size(512)
        .priority(TaskPriority(1))
        .create(move || {
            loop {
                println!("Task 2 running");
                FreeRtos::delay_ms(2000);
            }
        })
        .unwrap();

    FreeRtos::start_scheduler();
}

Deployment Architecture

Typical embedded Rust development flow:

โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”
โ”‚          Rust Embedded Project (src/)                   โ”‚
โ”‚  โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”   โ”‚
โ”‚  โ”‚  no_std code + hardware-specific HAL             โ”‚   โ”‚
โ”‚  โ”‚  - Compiled to target architecture               โ”‚   โ”‚
โ”‚  โ”‚  - Minimal binary size (usually < 500KB)         โ”‚   โ”‚
โ”‚  โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜   โ”‚
โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜
                          โ†“ cargo build
โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”
โ”‚          ELF Binary (.elf)                              โ”‚
โ”‚  - Ready for debugging                                  โ”‚
โ”‚  - Contains debug symbols                               โ”‚
โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜
                          โ†“ cargo objcopy
โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”
โ”‚     Binary Image (.bin or .hex)                         โ”‚
โ”‚     - Stripped debug info                               โ”‚
โ”‚     - Ready for flashing                                โ”‚
โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜
                          โ†“ cargo embed / cargo flash
โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”
โ”‚     Microcontroller (STM32, nRF52, ESP32, etc.)        โ”‚
โ”‚  โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”   โ”‚
โ”‚  โ”‚  Bootloader โ†’ Firmware โ†’ Running Program         โ”‚   โ”‚
โ”‚  โ”‚  - Device Memory (Flash/RAM)                     โ”‚   โ”‚
โ”‚  โ”‚  - Peripherals (GPIO, UART, SPI, etc.)           โ”‚   โ”‚
โ”‚  โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜   โ”‚
โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜

Common Pitfalls & Best Practices

1. Stack Overflow in Interrupt Handlers

โŒ Bad: Creating large local variables in interrupts

#[interrupt]
fn TIM2() {
    let large_buffer = [0u8; 4096]; // May overflow stack!
}

โœ… Good: Pre-allocate or use static storage

static mut BUFFER: [u8; 4096] = [0u8; 4096];

#[interrupt]
fn TIM2() {
    // Use pre-allocated buffer
    unsafe { BUFFER[0] = 42; }
}

2. Race Conditions Without Proper Synchronization

โŒ Bad: Accessing shared state without protection

static mut COUNTER: u32 = 0;

#[interrupt]
fn TIM2() {
    unsafe { COUNTER += 1; } // Race condition!
}

โœ… Good: Use critical sections or atomics

use core::sync::atomic::{AtomicU32, Ordering};
static COUNTER: AtomicU32 = AtomicU32::new(0);

#[interrupt]
fn TIM2() {
    COUNTER.fetch_add(1, Ordering::SeqCst);
}

3. Unused Warnings with no_std

The embedded environment generates many warnings. Configure in .cargo/config.toml:

[build]
rustflags = ["-W", "unused-imports", "-W", "dead-code"]

[target.thumbv7em-none-eabihf]
runner = "cargo embed --release"

4. Memory Layout Issues

Always be explicit about section placement:

// Place in flash memory
#[link_section = ".rodata"]
static CONFIG: [u32; 256] = [0; 256];

// Place in RAM at specific address
#[link_section = ".bss"]
static mut SHARED_BUFFER: [u8; 1024] = [0; 1024];

5. Panic Behavior

In embedded systems, panics must be handled carefully:

// src/lib.rs
#![no_std]

// Catch panics
#[panic_handler]
fn panic(info: &core::panic::PanicInfo) -> ! {
    // Log panic info (if you have serial)
    let msg = info.payload();
    
    // Stop execution
    loop {
        cortex_m::asm::wfi();
    }
}

Rust vs. C for Embedded

Aspect Rust C
Memory Safety Compile-time guarantees Manual management
Buffer Overflows Impossible (checked bounds) Common vulnerability
Use-After-Free Caught at compile time Possible runtime error
Data Races Prevented by type system Developer responsible
Binary Size Comparable (with LTO) Typically smaller
Learning Curve Steeper (borrow checker) Easier initially
Tooling Excellent (Cargo) Variable
Debugging Similar (GDB compatible) Similar
Community Growing Mature
HAL Ecosystem Expanding rapidly Vendor-specific

When to Choose Rust for Embedded

โœ… Use Rust when:

  • Safety and correctness are critical (medical, automotive, aerospace)
  • You want compile-time memory safety
  • Your team is familiar with Rust
  • You need modern build tooling (Cargo)

โœ… Use C when:

  • Working with legacy codebases
  • Maximum binary size optimization needed
  • Every cycle matters (extreme real-time constraints)
  • Team expertise is C-only

Testing and Debugging Embedded Rust

Unit Testing

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn test_led_logic() {
        let mut led = Led::new(/* mock pin */);
        led.on();
        assert!(led.is_on());
    }
}

// Run tests: cargo test --lib

Integration Testing with Hardware

# Flash firmware to device and run tests
cargo embed --release

# Debug with GDB
arm-none-eabi-gdb target/thumbv7em-none-eabihf/debug/firmware

Logging and Debugging

use log::{debug, info, warn};

info!("System initialized");
debug!("ADC value: {}", reading);
warn!("Temperature threshold exceeded");

// In Cargo.toml
[dependencies]
log = "0.4"
defmt = "0.3"  # Efficient logging

Resources & Learning Materials

Official Resources

Learning Projects

Tools & Utilities

  • cargo-embed - Flash and debug directly
  • cargo-flash - Flashing without debugging
  • probe-run - Collect defmt logs over probe
  • flip-link - Better stack overflow detection

Alternative Embedded Languages

C (Industry Standard)

  • Pros: Mature, lightweight, every platform supported
  • Cons: Manual memory management, security vulnerabilities
  • Use case: Legacy systems, extreme resource constraints

C++ (Modern C with objects)

  • Pros: Better abstractions than C, static typing
  • Cons: Complex, potential runtime overhead, memory issues remain
  • Use case: Advanced embedded systems with OOP

Python (MicroPython, CircuitPython)

  • Pros: Easy to learn, rapid prototyping
  • Cons: Larger binary, slower execution, garbage collection
  • Use case: Development boards, educational projects

Go (TinyGo)

  • Pros: Modern language, goroutines
  • Cons: Newer, smaller ecosystem, GC adds overhead
  • Use case: IoT devices with more resources

Conclusion

Embedded Rust represents a paradigm shift in systems programming. By bringing memory safety and modern abstractions to firmware development without sacrificing performance or control, Rust enables developers to build more reliable embedded systems.

The learning curve is realโ€”the borrow checker challenges even experienced developers. But this challenge yields systems that are safer, more maintainable, and less prone to the buffer overflows, use-after-free errors, and data races that plague C firmware.

As the embedded Rust ecosystem matures and HALs improve, adoption will accelerate. If you’re building mission-critical embedded systems or want to experience the safety guarantees Rust provides in a resource-constrained environment, embedded Rust is worth serious consideration.

The future of embedded systems is safer, and it’s written in Rust.

Comments