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"
Writing the LED Blink Program
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:
- Memory safety without garbage collection makes Rust ideal for embedded systems
- Zero-cost abstractions provide high-level programming without runtime overhead
- Fine-grained control allows direct hardware manipulation when needed
- Strong type system catches many errors at compile time
- 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.