A beginner-friendly guide to understanding the unique aspects of Rust for embedded systems development.
- Why Embedded Rust is Different
- Essential Attributes and Macros
- Memory Management in Embedded Systems
- Hardware Abstraction Layers (HAL)
- Common Patterns
- Troubleshooting Tips
Embedded Rust differs from regular Rust in several key ways:
- No operating system - Your code runs directly on bare metal
- Limited resources - Tight constraints on memory, processing power, and energy
- Real-time requirements - Predictable timing and response to hardware events
- Direct hardware control - Managing peripherals, interrupts, and memory-mapped registers
- Different runtime model - Programs run forever until power off or reset
These constraints require special language features and programming patterns.
Every embedded Rust program uses several special attributes that look unusual to newcomers:
#![no_std] // ← Tells Rust: "Don't include the standard library"What this means:
- No
std::vec::Vec,std::collections::HashMap,std::fs::File, etc. - No heap allocation - embedded systems often have very limited RAM
- No operating system dependencies - your code runs directly on bare metal
- Much smaller binary size - only includes what you actually use
What you get instead:
corelibrary: Basic types likeOption,Result, iterators, math operations- Embedded-specific crates: Hardware abstraction layers and embedded collections
- Stack-only memory: All variables live on the stack or in static memory
Common no_std alternatives:
// Instead of std::vec::Vec:
use heapless::Vec; // Fixed-capacity vector
// Instead of std::collections::HashMap:
use heapless::FnvIndexMap; // Fixed-capacity map
// Instead of std::string::String:
use heapless::String; // Fixed-capacity string#![no_main] // ← Tells Rust: "I'll provide my own program entry point"Why this is needed:
- Standard
main()assumes an operating system that can launch your program - Embedded systems boot directly from reset vector - no OS to call
main() - Different signature: Embedded main never returns (runs forever), so it's
fn main() -> !
use cortex_m_rt::entry;
#[entry] // ← Macro that marks this as the program entry point
fn main() -> ! { // ← Never returns (! = "never type")
// Your code here
loop {
// Embedded programs typically run forever
}
}What #[entry] does behind the scenes:
- Creates reset handler: Generates the ARM reset vector function
- Sets up stack: Configures the processor stack pointer
- Initializes memory: Copies data from flash to RAM, zeros uninitialized memory
- Calls your function: Jumps to your
main()after hardware initialization
The -> ! return type:
- "Never type": Function never returns normally
- Embedded reality: Microcontrollers run forever until power off or reset
- Compiler optimization: Rust knows this function never exits, optimizes accordingly
use panic_halt as _; // ← Links in a panic handler (what happens when code panics)Why this is required:
- No operating system to handle crashes for you
- Must choose panic behavior: halt, restart, print debug info, etc.
panic_halt: Simply stops the processor - useful for development
Other panic handler options:
// Different panic behaviors:
use panic_halt as _; // Stop processor (development)
use panic_reset as _; // Restart microcontroller
use panic_rtt_target as _; // Print panic info over debug probe
use panic_abort as _; // Just abort (minimal code size)#![no_std] // ← Use minimal core library only
#![no_main] // ← I'll provide my own entry point
use cortex_m_rt::entry; // ← The #[entry] macro comes from here
use panic_halt as _; // ← Choose panic behavior
#[entry] // ← This becomes the reset vector handler
fn main() -> ! { // ← Runs forever on bare metal
// Your embedded code here
loop {
// Typical embedded pattern: infinite loop
}
}Stack Memory (Preferred):
fn example() {
let array = [0u8; 64]; // ← 64 bytes on stack, automatically freed
// Stack is fast, predictable, and automatically managed
}Static Memory (Global, Lives Forever):
static GLOBAL_COUNTER: AtomicU32 = AtomicU32::new(0); // ← Lives for entire program
static mut BUFFER: [u8; 256] = [0; 256]; // ← Mutable global (requires unsafe)Heap Memory (Avoided in Embedded):
// This WON'T work with #![no_std]:
// let vec = Vec::new(); // ← Requires heap allocation
// Use this instead:
use heapless::Vec;
let mut vec: Vec<u8, 32> = Vec::new(); // ← Fixed capacity, stack-allocated- Predictability: Heap allocation can fail or fragment
- Real-time: malloc/free have unpredictable timing
- Memory safety: Easier to reason about memory usage
- Resource constraints: Limited RAM (often 32KB-256KB)
Embedded Rust uses a layered approach to hardware access:
// Raw register access (auto-generated from chip specification):
use nrf52833_pac as pac;
let peripherals = pac::Peripherals::take().unwrap();
peripherals.GPIOTE.config[0].write(|w| unsafe {
w.mode().event()
.psel().bits(21)
.polarity().lo_to_hi()
});// Safe, type-checked hardware access:
use nrf52833_hal as hal;
let gpio = hal::gpio::p0::Parts::new(peripherals.P0);
let pin = gpio.p0_21.into_push_pull_output(Level::Low);// Board-specific pin mappings and configuration:
use microbit::Board;
let board = Board::take().unwrap();
let led = board.display_pins.row1.into_push_pull_output(Level::Low);// Generic traits that work across different chips:
use embedded_hal::digital::OutputPin;
fn blink_led<P: OutputPin>(mut led: P) {
led.set_high().ok();
// This function works with ANY microcontroller!
}// Hardware can only be accessed once:
let peripherals = pac::Peripherals::take().unwrap(); // ← First call succeeds
let more_peripherals = pac::Peripherals::take(); // ← Returns None!
// This prevents multiple parts of code from conflicting over the same hardwareuse cortex_m::interrupt;
// Disable interrupts for atomic operations:
let result = interrupt::free(|_cs| {
// Code here runs with interrupts disabled
// Safe to access shared data
GLOBAL_COUNTER.load(Ordering::Relaxed)
});// Embedded code often uses unwrap() for simplicity:
let pin = gpio.p0_21.into_push_pull_output(Level::Low);
pin.set_high().unwrap(); // ← Panic if this fails
// Or handle errors explicitly:
match pin.set_high() {
Ok(()) => { /* success */ },
Err(e) => { /* handle error */ },
}// Use Rust's type system to prevent invalid states:
struct Led<STATE> {
pin: P0_21<Output<PushPull>>,
_state: PhantomData<STATE>,
}
struct On;
struct Off;
impl Led<Off> {
fn turn_on(self) -> Led<On> { /* ... */ }
}
impl Led<On> {
fn turn_off(self) -> Led<Off> { /* ... */ }
}
// Compiler prevents calling turn_off() on an already-off LED!Cargo uses semantic versioning for dependency management:
"0.7": Compatible with 0.7.x (allows 0.7.0, 0.7.1, etc., but not 0.8.0)"0.13.0": Exact version specification"^0.7": Explicit caret requirement (same as "0.7")"~0.7.1": Tilde requirement (allows 0.7.1, 0.7.2, but not 0.7.0 or 0.8.0)
Once you're comfortable with these concepts:
- Try the examples in this repository
- Read chip reference manuals to understand your hardware
- Explore the embedded-hal ecosystem for sensors and peripherals
- Join the community - #rust-embedded on Matrix/Discord
- Build something cool! 🦀
- The Embedded Rust Book - Comprehensive guide
- embedded-hal Documentation - Standard traits
- awesome-embedded-rust - Curated list of crates
- Rust Embedded Working Group - Official organization