In the previous tutorial, we did a collections deep dive. Now we explore Embedded Rust — how Rust works on microcontrollers, what no_std means, and why Rust is becoming the language of choice for embedded systems.
This is a conceptual tutorial. The code runs on your regular computer but demonstrates the patterns you use in real embedded Rust. You don’t need hardware to follow along.
Why Rust for Embedded?
Embedded systems have strict requirements: no crashes, no memory leaks, no undefined behavior. A bug in a pacemaker or car brakes can be fatal.
C has been the default embedded language for decades. But C gives you no protection against buffer overflows, null pointer dereferences, or data races. You rely on discipline and code reviews.
Rust gives you:
- No buffer overflows — array bounds are checked at runtime and panic instead of silently corrupting memory
- No null pointers —
Option<T>forces you to handle the missing case - No data races — the borrow checker prevents them at compile time
- Zero-cost abstractions — traits and generics compile to the same code as hand-written C
- No runtime — Rust can run without an OS, without an allocator, without anything
What is no_std?
The Rust standard library (std) depends on an operating system. It uses heap allocation, threads, file I/O, and networking. Microcontrollers don’t have an OS.
no_std means “don’t use the standard library.” You still get core — the subset that works everywhere: basic types, iterators, Option, Result, traits, and more.
#![no_std]
#![no_main]
// This program has no std, no main function, no OS.
// It runs directly on hardware.
What you lose in no_std:
| Feature | std | no_std (core) |
|---|---|---|
Vec, String, HashMap | Yes | No (no allocator) |
println! | Yes | No (no stdout) |
| File I/O | Yes | No (no filesystem) |
| Threads | Yes | No (no OS) |
Option, Result | Yes | Yes |
| Iterators | Yes | Yes |
| Traits, generics | Yes | Yes |
const generics | Yes | Yes |
You can add heap allocation back with the alloc crate if your platform has a memory allocator. But many embedded systems avoid heap allocation entirely for predictability.
Fixed-Size Buffers
Without Vec, you use fixed-size arrays. Const generics make this clean:
struct FixedBuffer<const N: usize> {
data: [u8; N],
len: usize,
}
impl<const N: usize> FixedBuffer<N> {
fn new() -> Self {
Self {
data: [0u8; N],
len: 0,
}
}
fn push(&mut self, byte: u8) -> Result<(), BufferError> {
if self.len >= N {
return Err(BufferError::Full);
}
self.data[self.len] = byte;
self.len += 1;
Ok(())
}
fn as_slice(&self) -> &[u8] {
&self.data[..self.len]
}
fn len(&self) -> usize {
self.len
}
fn capacity(&self) -> usize {
N
}
fn is_full(&self) -> bool {
self.len >= N
}
}
#[derive(Debug, PartialEq)]
enum BufferError {
Full,
}
The buffer size N is known at compile time. No heap allocation. The compiler knows exactly how much stack space to reserve.
let mut buf = FixedBuffer::<64>::new(); // 64 bytes on the stack
buf.push(0x48).unwrap(); // 'H'
buf.push(0x69).unwrap(); // 'i'
Ring Buffer
Ring buffers are everywhere in embedded: UART receive buffers, SPI data queues, audio sample buffers. They let you write data from an interrupt and read it from the main loop.
struct RingBuffer<const N: usize> {
data: [u8; N],
read_pos: usize,
write_pos: usize,
count: usize,
}
impl<const N: usize> RingBuffer<N> {
fn new() -> Self {
Self {
data: [0u8; N],
read_pos: 0,
write_pos: 0,
count: 0,
}
}
fn write(&mut self, byte: u8) -> Result<(), BufferError> {
if self.count >= N {
return Err(BufferError::Full);
}
self.data[self.write_pos] = byte;
self.write_pos = (self.write_pos + 1) % N;
self.count += 1;
Ok(())
}
fn read(&mut self) -> Option<u8> {
if self.count == 0 {
return None;
}
let byte = self.data[self.read_pos];
self.read_pos = (self.read_pos + 1) % N;
self.count -= 1;
Some(byte)
}
}
The % N wraps the position around. When you reach the end of the array, it goes back to the start. This is why it’s called a “ring” buffer.
Hardware Abstraction Layer (embedded-hal)
In embedded Rust, the embedded-hal crate defines traits for hardware peripherals. Every chip manufacturer implements these traits for their hardware. Your code works with any chip that implements the traits.
Here’s how the pattern works (simulated):
trait OutputPin {
fn set_high(&mut self);
fn set_low(&mut self);
fn is_high(&self) -> bool;
fn toggle(&mut self);
}
trait InputPin {
fn is_high(&self) -> bool;
fn is_low(&self) -> bool;
}
Now write a LED driver that works with any pin:
struct Led<P: OutputPin> {
pin: P,
}
impl<P: OutputPin> Led<P> {
fn new(pin: P) -> Self {
Self { pin }
}
fn on(&mut self) {
self.pin.set_high();
}
fn off(&mut self) {
self.pin.set_low();
}
fn toggle(&mut self) {
self.pin.toggle();
}
fn is_on(&self) -> bool {
self.pin.is_high()
}
}
This LED driver works on any microcontroller. On an STM32, P would be an STM32 GPIO pin. On an nRF52, it would be an nRF GPIO pin. The code is the same.
This is the power of embedded-hal: write your driver once, run it on any hardware.
State Machines
Embedded systems are often state machines. A device starts in Idle, initializes, runs, handles errors, and shuts down. Rust enums model this perfectly:
#[derive(Debug, Clone, PartialEq)]
enum DeviceState {
Idle,
Initializing,
Running,
Error(&'static str),
Shutdown,
}
#[derive(Debug)]
enum Event {
Start,
Ready,
Tick,
Stop,
Fault(&'static str),
Reset,
}
struct StateMachine {
state: DeviceState,
uptime_seconds: u32,
error_count: u32,
}
impl StateMachine {
fn transition(&mut self, event: Event) -> Result<(), &'static str> {
self.state = match (&self.state, event) {
(DeviceState::Idle, Event::Start) => DeviceState::Initializing,
(DeviceState::Initializing, Event::Ready) => DeviceState::Running,
(DeviceState::Running, Event::Tick) => {
self.uptime_seconds += 1;
DeviceState::Running
}
(DeviceState::Running, Event::Fault(msg)) => {
self.error_count += 1;
DeviceState::Error(msg)
}
(DeviceState::Running, Event::Stop) => DeviceState::Shutdown,
(DeviceState::Error(_), Event::Reset) => DeviceState::Idle,
(_state, _event) => {
return Err("Invalid state transition");
}
};
Ok(())
}
}
The match on (state, event) is exhaustive. If you forget to handle a case, the compiler tells you. In C, a missing case in a switch statement is a silent bug.
Interrupts and Atomics
Microcontrollers use interrupts: when a hardware event happens (button press, timer tick, data received), the CPU stops the main code and runs an interrupt handler.
The main loop and interrupt handler share data. In C, this is done with volatile variables. In Rust, you use atomics:
use core::sync::atomic::{AtomicBool, AtomicU32, Ordering};
struct InterruptCounter {
count: AtomicU32,
enabled: AtomicBool,
}
impl InterruptCounter {
fn new() -> Self {
Self {
count: AtomicU32::new(0),
enabled: AtomicBool::new(false),
}
}
// Called from interrupt handler
fn increment(&self) {
if self.enabled.load(Ordering::Relaxed) {
self.count.fetch_add(1, Ordering::Relaxed);
}
}
// Called from main loop
fn read_and_reset(&self) -> u32 {
self.count.swap(0, Ordering::Relaxed)
}
fn enable(&self) {
self.enabled.store(true, Ordering::Relaxed);
}
}
AtomicU32 and AtomicBool are safe to share between the main loop and interrupts. No mutex needed. No data races possible.
Sensor Patterns
Embedded devices read sensors. Here’s how you model that with Rust traits:
#[derive(Debug, Clone, PartialEq)]
struct SensorReading {
temperature: f32,
humidity: f32,
timestamp: u64,
}
#[derive(Debug, PartialEq)]
enum SensorError {
ReadTimeout,
InvalidData,
OutOfRange,
NotCalibrated,
}
trait Sensor {
fn read(&self) -> Result<SensorReading, SensorError>;
fn calibrate(&mut self) -> Result<(), SensorError>;
fn name(&self) -> &str;
}
Every sensor implements this trait. Your application code doesn’t care if it’s a DHT22, BME280, or a simulated sensor for testing. The trait abstracts it.
The error enum is explicit. You handle each failure mode differently:
match sensor.read() {
Ok(reading) => process(reading),
Err(SensorError::NotCalibrated) => sensor.calibrate()?,
Err(SensorError::ReadTimeout) => retry(),
Err(SensorError::OutOfRange) => log_warning(),
Err(SensorError::InvalidData) => reset_sensor(),
}
Command Parser — Serial Protocol
Embedded devices often communicate over serial (UART). You receive text commands and parse them:
#[derive(Debug, PartialEq)]
enum Command {
SetLed(bool),
ReadSensor(u8),
SetPwm(u8, u16),
Reset,
Status,
Unknown,
}
fn parse_command(input: &str) -> Command {
let mut parts = input.trim().split_whitespace();
match parts.next() {
Some("LED") => match parts.next() {
Some("ON") => Command::SetLed(true),
Some("OFF") => Command::SetLed(false),
_ => Command::Unknown,
},
Some("READ") => {
if let Some(Ok(sensor_id)) = parts.next().map(|id| id.parse::<u8>()) {
Command::ReadSensor(sensor_id)
} else {
Command::Unknown
}
}
Some("PWM") => {
let ch = parts.next().and_then(|s| s.parse::<u8>().ok());
let duty = parts.next().and_then(|s| s.parse::<u16>().ok());
if let (Some(ch), Some(d)) = (ch, duty) {
Command::SetPwm(ch, d)
} else {
Command::Unknown
}
}
Some("RESET") => Command::Reset,
Some("STATUS") => Command::Status,
_ => Command::Unknown,
}
}
Pattern matching makes this clean and safe. Every command variant is typed. You can’t accidentally pass a PWM duty cycle where a sensor ID is expected.
Watchdog Timer
A watchdog timer resets the device if the main loop stops responding. You must “feed” the watchdog regularly. If you forget, the device resets:
struct WatchdogTimer {
timeout_ms: u32,
last_feed_ms: u32,
current_ms: u32,
triggered: bool,
}
impl WatchdogTimer {
fn new(timeout_ms: u32) -> Self {
Self { timeout_ms, last_feed_ms: 0, current_ms: 0, triggered: false }
}
fn feed(&mut self) {
self.last_feed_ms = self.current_ms;
self.triggered = false;
}
fn tick(&mut self, elapsed_ms: u32) {
self.current_ms += elapsed_ms;
if self.current_ms - self.last_feed_ms >= self.timeout_ms {
self.triggered = true;
}
}
fn is_triggered(&self) -> bool {
self.triggered
}
}
In a real system, the watchdog is a hardware peripheral. If triggered, it resets the entire chip.
Embassy Framework
Embassy is the async runtime for embedded Rust. It brings async/await to microcontrollers.
Traditional embedded code uses a main loop with interrupts. Embassy lets you write concurrent code with async tasks:
// Embassy example (conceptual — needs Embassy runtime to compile)
#[embassy_executor::main]
async fn main(spawner: Spawner) {
let led_pin = /* get pin from HAL */;
let button_pin = /* get pin from HAL */;
spawner.spawn(blink_led(led_pin)).unwrap();
spawner.spawn(read_button(button_pin)).unwrap();
}
#[embassy_executor::task]
async fn blink_led(mut pin: AnyPin) {
loop {
pin.set_high();
Timer::after_millis(500).await;
pin.set_low();
Timer::after_millis(500).await;
}
}
#[embassy_executor::task]
async fn read_button(pin: AnyPin) {
loop {
pin.wait_for_rising_edge().await;
// Button was pressed
}
}
Embassy compiles to the same efficient code as manual interrupt handling. But you write it like normal async Rust. No OS needed. No heap allocation.
Embassy supports many chips: STM32, nRF52, RP2040, ESP32, and more.
Getting Started with Real Embedded
To run Rust on actual hardware, you need:
A supported board — popular choices:
- Raspberry Pi Pico (RP2040) — cheapest, great for beginners
- STM32 Nucleo boards — widely used in industry
- nRF52 DK — for Bluetooth projects
- ESP32 — for WiFi projects
The right target — install the compilation target:
rustup target add thumbv7em-none-eabihf # for Cortex-M4A HAL crate — hardware abstraction for your chip:
embassy-stm32for STM32embassy-nrffor Nordic nRFembassy-rpfor RP2040
A probe — to flash the firmware (many boards have one built-in)
probe-rs — the Rust tool for flashing and debugging:
cargo install probe-rs-tools cargo run --release # flashes and runs on the board
Source Code
You can find the complete source code for this tutorial on GitHub:
kemalcodes/rust-tutorial (branch: tutorial-27-embedded)
What’s Next?
In the next tutorial, we explore Rust for AI and ML — Polars for data processing, Burn for machine learning, and PyO3 for Python interop.