Embedded Systems Programming with Rust: Safety and Performance for Resource-Constrained Devices

12 min read 2595 words

Table of Contents

Embedded systems programming has traditionally been dominated by C and C++, languages that offer the low-level control and performance necessary for resource-constrained environments. However, these languages also come with significant drawbacks, particularly in terms of memory safety and modern language features. Rust offers a compelling alternative, providing the same level of control and performance while eliminating entire classes of bugs through its ownership system and zero-cost abstractions.

In this comprehensive guide, we’ll explore how Rust is changing the landscape of embedded systems development. From bare-metal programming on microcontrollers to higher-level abstractions for IoT devices, you’ll learn how Rust’s unique features make it an excellent choice for embedded applications. By the end, you’ll understand how to leverage Rust’s safety and performance for your own embedded projects, whether you’re building a simple sensor node or a complex industrial control system.


Why Rust for Embedded Systems?

Before diving into the technical details, let’s understand why Rust is gaining traction in the embedded space:

Memory Safety Without Garbage Collection

Rust’s ownership system ensures memory safety without the need for garbage collection, which is crucial for embedded systems where memory is limited and predictable performance is essential:

fn main() {
    // Stack-allocated array - no heap allocation needed
    let buffer = [0u8; 1024];
    
    // Ownership ensures this buffer is properly managed
    process_data(&buffer);
    
    // Buffer is automatically freed when it goes out of scope
}

fn process_data(data: &[u8]) {
    // Work with the data safely
}

Zero-Cost Abstractions

Rust’s abstractions compile down to efficient machine code, often as good as hand-written C:

// High-level abstraction
fn sum_even_numbers(numbers: &[u32]) -> u32 {
    numbers.iter()
        .filter(|&&n| n % 2 == 0)
        .sum()
}

// Compiles to efficient machine code similar to:
// fn sum_even_numbers(numbers: &[u32]) -> u32 {
//     let mut sum = 0;
//     for i in 0..numbers.len() {
//         if numbers[i] % 2 == 0 {
//             sum += numbers[i];
//         }
//     }
//     sum
// }

Fine-Grained Control

Rust provides the low-level control needed for embedded systems:

use core::ptr;

// Direct memory manipulation when needed
unsafe fn set_register(address: usize, value: u32) {
    ptr::write_volatile(address as *mut u32, value);
}

// Memory-mapped I/O
const GPIO_BASE: usize = 0x40020000;
const GPIO_ODR_OFFSET: usize = 0x14;

fn set_pin_high(pin: u8) {
    unsafe {
        let gpio_odr = (GPIO_BASE + GPIO_ODR_OFFSET) as *mut u32;
        let current = ptr::read_volatile(gpio_odr);
        ptr::write_volatile(gpio_odr, current | (1 << pin));
    }
}

Strong Type System and Compile-Time Guarantees

Rust’s type system can prevent many common embedded programming errors at compile time:

// Define register types with specific bit patterns
#[repr(u8)]
enum PowerMode {
    Sleep = 0b00,
    LowPower = 0b01,
    Normal = 0b10,
    Performance = 0b11,
}

// Function that only accepts valid power modes
fn set_power_mode(mode: PowerMode) {
    let value = mode as u8;
    // Write to power management register
}

fn main() {
    set_power_mode(PowerMode::LowPower); // OK
    
    // This would not compile:
    // set_power_mode(0b100); // Error: expected PowerMode, found integer
}

Getting Started with Embedded Rust

Let’s explore how to set up and start developing embedded applications with Rust:

Setting Up the Development Environment

First, you’ll need to install the necessary tools:

# Install Rust
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh

# Add cross-compilation support for your target
rustup target add thumbv7em-none-eabihf  # For ARM Cortex-M4F with hardware floating point

# Install cargo-binutils for working with binary files
cargo install cargo-binutils
rustup component add llvm-tools-preview

# Install probe-run for flashing and debugging
cargo install probe-run

Creating a New Project

Let’s create a simple LED blinking project for an STM32F4 microcontroller:

# Create a new project
cargo new --bin led_blink
cd led_blink

Edit the Cargo.toml file:

[package]
name = "led_blink"
version = "0.1.0"
edition = "2021"

[dependencies]
cortex-m = "0.7.7"
cortex-m-rt = "0.7.3"
panic-halt = "0.2.0"
stm32f4xx-hal = { version = "0.15.0", features = ["stm32f411"] }

[profile.release]
opt-level = "s"  # Optimize for size
lto = true       # Link-time optimization
codegen-units = 1
debug = true

Create a .cargo/config.toml file:

[target.'cfg(all(target_arch = "arm", target_os = "none"))']
runner = "probe-run --chip STM32F411CEUx"

rustflags = [
  "-C", "link-arg=-Tlink.x",
]

[build]
target = "thumbv7em-none-eabihf"

Now, let’s write the code to blink an LED:

#![no_std]
#![no_main]

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

use crate::hal::{pac, prelude::*};

#[entry]
fn main() -> ! {
    // Get access to device peripherals
    let dp = pac::Peripherals::take().unwrap();
    
    // Set up the system clock
    let rcc = dp.RCC.constrain();
    let clocks = rcc.cfgr.sysclk(48.MHz()).freeze();
    
    // Set up the LED pin (PC13 on many STM32F4 boards)
    let gpioc = dp.GPIOC.split();
    let mut led = gpioc.pc13.into_push_pull_output();
    
    // Create a delay abstraction based on SysTick
    let mut delay = dp.TIM2.delay_ms(&clocks);
    
    loop {
        // Turn LED on
        led.set_low();
        delay.delay_ms(500_u32);
        
        // Turn LED off
        led.set_high();
        delay.delay_ms(500_u32);
    }
}

Building and Flashing

To build and flash the program to your device:

cargo run --release

This will compile the program, flash it to the connected device, and start a debug session.


Working with Hardware Peripherals

Embedded systems interact with the world through peripherals like GPIO, UART, SPI, and I2C. Let’s see how to use these in Rust:

GPIO: Digital Input and Output

use stm32f4xx_hal::{pac, prelude::*};

fn configure_gpio(dp: pac::Peripherals) {
    // Configure GPIO ports
    let gpioa = dp.GPIOA.split();
    let gpioc = dp.GPIOC.split();
    
    // Configure outputs
    let mut led = gpioc.pc13.into_push_pull_output();
    
    // Configure inputs
    let button = gpioa.pa0.into_pull_up_input();
    
    // Read input state
    if button.is_high() {
        led.set_high();
    } else {
        led.set_low();
    }
}

UART: Serial Communication

use stm32f4xx_hal::{pac, prelude::*, serial::{config::Config, Serial}};

fn setup_uart(dp: pac::Peripherals) {
    // Set up clocks
    let rcc = dp.RCC.constrain();
    let clocks = rcc.cfgr.sysclk(48.MHz()).freeze();
    
    // Set up GPIOA
    let gpioa = dp.GPIOA.split();
    
    // Set up UART pins
    let tx_pin = gpioa.pa2.into_alternate();
    let rx_pin = gpioa.pa3.into_alternate();
    
    // Configure UART
    let serial_config = Config::default().baudrate(115_200.bps());
    let serial = Serial::new(
        dp.USART2,
        (tx_pin, rx_pin),
        serial_config,
        &clocks,
    ).unwrap();
    
    // Split into TX and RX parts
    let (mut tx, mut rx) = serial.split();
    
    // Send data
    for byte in b"Hello, World!\r\n" {
        nb::block!(tx.write(*byte)).unwrap();
    }
    
    // Receive data (blocking)
    let received = nb::block!(rx.read()).unwrap();
    
    // Echo back
    nb::block!(tx.write(received)).unwrap();
}

SPI: Serial Peripheral Interface

use stm32f4xx_hal::{pac, prelude::*, spi::{Mode, Phase, Polarity, Spi}};

fn setup_spi(dp: pac::Peripherals) {
    // Set up clocks
    let rcc = dp.RCC.constrain();
    let clocks = rcc.cfgr.sysclk(48.MHz()).freeze();
    
    // Set up GPIOA
    let gpioa = dp.GPIOA.split();
    
    // Set up SPI pins
    let sck = gpioa.pa5.into_alternate();
    let miso = gpioa.pa6.into_alternate();
    let mosi = gpioa.pa7.into_alternate();
    
    // Configure SPI
    let mode = Mode {
        polarity: Polarity::IdleLow,
        phase: Phase::CaptureOnFirstTransition,
    };
    
    let mut spi = Spi::new(
        dp.SPI1,
        (sck, miso, mosi),
        mode,
        1.MHz(),
        &clocks,
    );
    
    // Chip select pin
    let mut cs = gpioa.pa4.into_push_pull_output();
    cs.set_high();
    
    // Send data
    cs.set_low();
    spi.write(&[0x90, 0x00, 0x00, 0x00, 0x00]).unwrap();
    let mut buffer = [0u8; 2];
    spi.transfer(&mut buffer).unwrap();
    cs.set_high();
    
    // buffer now contains the received data
}

I2C: Inter-Integrated Circuit

use stm32f4xx_hal::{pac, prelude::*, i2c::I2c};

fn setup_i2c(dp: pac::Peripherals) {
    // Set up clocks
    let rcc = dp.RCC.constrain();
    let clocks = rcc.cfgr.sysclk(48.MHz()).freeze();
    
    // Set up GPIOB
    let gpiob = dp.GPIOB.split();
    
    // Set up I2C pins
    let scl = gpiob.pb8.into_alternate().set_open_drain();
    let sda = gpiob.pb9.into_alternate().set_open_drain();
    
    // Configure I2C
    let mut i2c = I2c::new(
        dp.I2C1,
        (scl, sda),
        100.kHz(),
        &clocks,
    );
    
    // Device address (7-bit)
    let device_addr = 0x68;
    
    // Write data
    let register = 0x6B;  // PWR_MGMT_1 register for MPU6050
    let data = 0x00;      // Wake up the device
    i2c.write(device_addr, &[register, data]).unwrap();
    
    // Read data
    let accel_x_h = 0x3B;  // ACCEL_XOUT_H register
    i2c.write(device_addr, &[accel_x_h]).unwrap();
    let mut buffer = [0u8; 6];  // Read 6 bytes (X, Y, Z accelerometer data)
    i2c.read(device_addr, &mut buffer).unwrap();
    
    // Process accelerometer data
    let accel_x = (buffer[0] as i16) << 8 | buffer[1] as i16;
    let accel_y = (buffer[2] as i16) << 8 | buffer[3] as i16;
    let accel_z = (buffer[4] as i16) << 8 | buffer[5] as i16;
}

Interrupts and Real-Time Programming

Embedded systems often need to respond to events in real-time. Rust provides safe abstractions for interrupt handling:

Setting Up Interrupts

#![no_std]
#![no_main]

use cortex_m::peripheral::NVIC;
use cortex_m_rt::{entry, exception, interrupt};
use panic_halt as _;
use stm32f4xx_hal::{pac, prelude::*};

static mut LED: Option<stm32f4xx_hal::gpio::Pin<'C', 13, stm32f4xx_hal::gpio::Output>> = None;

#[entry]
fn main() -> ! {
    // Get access to device peripherals
    let dp = pac::Peripherals::take().unwrap();
    let cp = cortex_m::Peripherals::take().unwrap();
    
    // Set up the system clock
    let rcc = dp.RCC.constrain();
    let clocks = rcc.cfgr.sysclk(48.MHz()).freeze();
    
    // Set up the LED pin
    let gpioc = dp.GPIOC.split();
    let led = gpioc.pc13.into_push_pull_output();
    
    // Store LED in global variable for the interrupt handler
    unsafe {
        LED = Some(led);
    }
    
    // Set up SysTick for a 1Hz interrupt
    let mut systick = cp.SYST;
    systick.set_clock_source(cortex_m::peripheral::syst::SystickClockSource::Core);
    systick.set_reload(clocks.sysclk().0 / 1 - 1);  // 1 Hz
    systick.clear_current();
    systick.enable_counter();
    systick.enable_interrupt();
    
    // Enable EXTI0 interrupt for button press
    let gpioa = dp.GPIOA.split();
    let _button = gpioa.pa0.into_pull_up_input();
    
    // Configure EXTI line 0
    dp.SYSCFG.exticr1.modify(|_, w| w.exti0().pa0());
    
    // Configure interrupt for rising edge
    dp.EXTI.rtsr.modify(|_, w| w.tr0().set_bit());
    
    // Unmask the interrupt
    dp.EXTI.imr.modify(|_, w| w.mr0().set_bit());
    
    // Enable the interrupt in NVIC
    unsafe {
        NVIC::unmask(pac::Interrupt::EXTI0);
    }
    
    loop {
        // Main loop can do other work or sleep
        cortex_m::asm::wfi();  // Wait for interrupt
    }
}

#[exception]
fn SysTick() {
    // Toggle LED on each SysTick interrupt
    unsafe {
        if let Some(led) = &mut LED {
            led.toggle();
        }
    }
}

#[interrupt]
fn EXTI0() {
    // Clear the interrupt pending bit
    let exti = unsafe { &(*pac::EXTI::ptr()) };
    exti.pr.modify(|_, w| w.pr0().set_bit());
    
    // Do something when button is pressed
    unsafe {
        if let Some(led) = &mut LED {
            led.toggle();
        }
    }
}

Critical Sections and Shared Resources

For safe access to shared resources from different contexts (main thread, interrupts), Rust provides the cortex_m::interrupt module:

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

// Shared resources
static COUNTER: Mutex<RefCell<u32>> = Mutex::new(RefCell::new(0));

#[entry]
fn main() -> ! {
    // Initialize peripherals...
    
    loop {
        // Access shared resource safely
        free(|cs| {
            let mut counter = COUNTER.borrow(cs).borrow_mut();
            *counter += 1;
            
            if *counter % 1000 == 0 {
                // Do something every 1000 counts
            }
        });
    }
}

#[exception]
fn SysTick() {
    // Access shared resource safely from interrupt
    free(|cs| {
        let counter = COUNTER.borrow(cs).borrow();
        
        if *counter > 10000 {
            // Do something when counter exceeds threshold
        }
    });
}

Memory Management in Embedded Rust

Embedded systems often have limited memory, making efficient memory management crucial:

Static Allocation

Rust’s #![no_std] attribute indicates that the standard library (which depends on an operating system and heap allocation) is not available. Instead, we use static allocation:

#![no_std]
#![no_main]

// Statically allocated buffer
static mut BUFFER: [u8; 1024] = [0; 1024];

#[entry]
fn main() -> ! {
    // Use the buffer safely
    let buffer = unsafe { &mut BUFFER };
    
    // Fill with a pattern
    for (i, byte) in buffer.iter_mut().enumerate() {
        *byte = (i % 256) as u8;
    }
    
    // Process the data
    process_data(buffer);
    
    loop {
        // Main loop
    }
}

fn process_data(data: &mut [u8]) {
    // Process the data
}

Heap Allocation with alloc

For more complex applications, you might want to use heap allocation. This requires implementing an allocator:

#![no_std]
#![no_main]
#![feature(alloc_error_handler)]

extern crate alloc;

use alloc::vec::Vec;
use core::alloc::{GlobalAlloc, Layout};
use core::ptr::null_mut;
use cortex_m_rt::entry;
use linked_list_allocator::LockedHeap;

// Define a heap area
#[global_allocator]
static ALLOCATOR: LockedHeap = LockedHeap::empty();

// Define a heap size
const HEAP_SIZE: usize = 1024;
static mut HEAP: [u8; HEAP_SIZE] = [0; HEAP_SIZE];

#[alloc_error_handler]
fn alloc_error(_layout: Layout) -> ! {
    loop {}
}

#[entry]
fn main() -> ! {
    // Initialize the heap
    unsafe {
        ALLOCATOR.lock().init(
            HEAP.as_mut_ptr(),
            HEAP_SIZE,
        );
    }
    
    // Now we can use heap allocation
    let mut vec = Vec::new();
    vec.push(1);
    vec.push(2);
    vec.push(3);
    
    // Use the vector
    for item in &vec {
        // Do something with item
    }
    
    loop {
        // Main loop
    }
}

Real-World Example: Temperature Sensor with Display

Let’s put everything together in a real-world example: a temperature sensor that displays readings on an OLED display:

#![no_std]
#![no_main]

use cortex_m_rt::entry;
use panic_halt as _;
use stm32f4xx_hal::{pac, prelude::*, i2c::I2c};
use ssd1306::{prelude::*, I2CDisplayInterface, Ssd1306};
use embedded_graphics::{
    mono_font::{ascii::FONT_6X10, MonoTextStyleBuilder},
    pixelcolor::BinaryColor,
    prelude::*,
    text::{Baseline, Text},
};

#[entry]
fn main() -> ! {
    // Get access to device peripherals
    let dp = pac::Peripherals::take().unwrap();
    
    // Set up the system clock
    let rcc = dp.RCC.constrain();
    let clocks = rcc.cfgr.sysclk(48.MHz()).freeze();
    
    // Set up GPIOB for I2C
    let gpiob = dp.GPIOB.split();
    let scl = gpiob.pb8.into_alternate().set_open_drain();
    let sda = gpiob.pb9.into_alternate().set_open_drain();
    
    // Configure I2C
    let i2c = I2c::new(
        dp.I2C1,
        (scl, sda),
        100.kHz(),
        &clocks,
    );
    
    // Set up the display
    let interface = I2CDisplayInterface::new(i2c);
    let mut display = Ssd1306::new(interface, DisplaySize128x64, DisplayRotation::Rotate0)
        .into_buffered_graphics_mode();
    display.init().unwrap();
    display.clear();
    
    // Set up text style
    let text_style = MonoTextStyleBuilder::new()
        .font(&FONT_6X10)
        .text_color(BinaryColor::On)
        .build();
    
    // Create a delay abstraction
    let mut delay = dp.TIM2.delay_ms(&clocks);
    
    // Main loop
    let mut counter = 0;
    loop {
        // Clear the display
        display.clear();
        
        // Simulate temperature reading (replace with actual sensor reading)
        let temperature = 25.5 + (counter as f32 / 10.0).sin();
        counter = (counter + 1) % 100;
        
        // Format temperature string
        let mut temp_str = [0u8; 20];
        let _ = write_to_slice!(&mut temp_str, "Temp: {:.1}°C", temperature);
        let temp_str = core::str::from_utf8(&temp_str).unwrap_or("Error");
        
        // Draw text
        Text::with_baseline(
            "Temperature Sensor",
            Point::new(0, 16),
            text_style,
            Baseline::Top,
        )
        .draw(&mut display)
        .unwrap();
        
        Text::with_baseline(
            temp_str,
            Point::new(0, 32),
            text_style,
            Baseline::Top,
        )
        .draw(&mut display)
        .unwrap();
        
        // Update the display
        display.flush().unwrap();
        
        // Wait before next update
        delay.delay_ms(1000_u32);
    }
}

// Helper macro for formatting without allocations
macro_rules! write_to_slice {
    ($dst:expr, $($arg:tt)*) => {
        {
            use core::fmt::Write;
            struct SliceWriter<'a>(&'a mut [u8], usize);
            impl<'a> Write for SliceWriter<'a> {
                fn write_str(&mut self, s: &str) -> core::fmt::Result {
                    let bytes = s.as_bytes();
                    let len = bytes.len().min(self.0.len().saturating_sub(self.1));
                    self.0[self.1..self.1 + len].copy_from_slice(&bytes[..len]);
                    self.1 += len;
                    Ok(())
                }
            }
            let mut writer = SliceWriter($dst, 0);
            let _ = core::write!(writer, $($arg)*);
            writer.1
        }
    };
}

Best Practices for Embedded Rust

Based on experience from real embedded Rust projects, here are some best practices:

1. Use Hardware Abstraction Layers (HALs)

Instead of directly manipulating registers, use HALs that provide safe abstractions:

// Avoid direct register manipulation when possible
// unsafe {
//     (*pac::GPIOC::ptr()).odr.modify(|r, w| w.odr13().bit(!r.odr13().bit()));
// }

// Instead, use the HAL
let gpioc = dp.GPIOC.split();
let mut led = gpioc.pc13.into_push_pull_output();
led.toggle();

2. Leverage Type Safety for Hardware Configuration

Use Rust’s type system to prevent configuration errors:

// Define pin types that encode their configuration
type LedPin = stm32f4xx_hal::gpio::Pin<'C', 13, stm32f4xx_hal::gpio::Output>;
type ButtonPin = stm32f4xx_hal::gpio::Pin<'A', 0, stm32f4xx_hal::gpio::Input>;

// Function that only accepts correctly configured pins
fn configure_button_interrupt(button: &ButtonPin) {
    // Configuration code...
}

3. Use const Generics for Fixed-Size Buffers

// Generic function that works with any fixed-size buffer
fn process_buffer<const N: usize>(buffer: &mut [u8; N]) {
    for byte in buffer.iter_mut() {
        *byte = byte.wrapping_add(1);
    }
}

// Use with different buffer sizes
let mut small_buffer = [0u8; 64];
let mut large_buffer = [0u8; 1024];

process_buffer(&mut small_buffer);
process_buffer(&mut large_buffer);

4. Minimize Unsafe Code

While unsafe is sometimes necessary in embedded programming, minimize its scope:

// Bad: Large unsafe block
unsafe fn process_data() {
    // Lots of code that doesn't all need to be unsafe
}

// Good: Minimal unsafe blocks
fn process_data() {
    // Safe code
    
    // Only the specific operations that need to be unsafe
    unsafe {
        // Minimal unsafe operations
    }
    
    // More safe code
}

Conclusion

Rust is revolutionizing embedded systems development by bringing memory safety, modern language features, and zero-cost abstractions to resource-constrained environments. Its ownership system eliminates entire classes of bugs while maintaining the performance and control necessary for embedded applications.

The key takeaways from this exploration of embedded Rust are:

  1. Memory safety without garbage collection makes Rust ideal for embedded systems
  2. Zero-cost abstractions provide high-level programming without runtime overhead
  3. Fine-grained control allows direct hardware manipulation when needed
  4. Strong type system catches many errors at compile time
  5. Rich ecosystem of crates and tools supports embedded development

Whether you’re building a simple sensor node or a complex industrial control system, Rust provides the tools and abstractions you need to create reliable, efficient embedded applications. By leveraging Rust’s unique features, you can write embedded code that is both safer and more maintainable than traditional approaches, without sacrificing performance or control.

Andrew
Andrew

Andrew is a visionary software engineer and DevOps expert with a proven track record of delivering cutting-edge solutions that drive innovation at Ataiva.com. As a leader on numerous high-profile projects, Andrew brings his exceptional technical expertise and collaborative leadership skills to the table, fostering a culture of agility and excellence within the team. With a passion for architecting scalable systems, automating workflows, and empowering teams, Andrew is a sought-after authority in the field of software development and DevOps.

Tags

Recent Posts