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-bitaarch64-unknown-linux-gnu- ARM 64-bit Linux
A Hardware Abstraction Layer (HAL) provides idiomatic Rust bindings to hardware:
stm32h7xx-hal- STM32H7 microcontrollernrf52840-hal- nRF52840 Bluetooth microcontrolleresp-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 pointpanic_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