Skip to content
Published on

Embedded Rust 2026 Deep Dive — no_std, Embassy, esp-rs, RP2350, probe-rs, and Rust on Microcontrollers in Practice

Authors

Prologue — The "Is Embedded Rust Usable Yet?" Question is Settled

Five years ago this post would have opened with "embedded Rust is still experimental, but…" Embassy in 2021 was alpha, the RP2040 HAL hadn't settled, ESP32 needed a forked LLVM, and probe-rs was a niche companion to OpenOCD.

The landscape in May 2026 is different.

  • Embassy 0.6 is the de facto default async runtime on microcontrollers. New projects rarely default to the main-loop-plus-interrupt-handlers pattern anymore.
  • RP2350 (Raspberry Pi Pico 2, dual Cortex-M33 plus dual RISC-V Hazard3) has stable HAL coverage under rp235x-hal / embassy-rp.
  • esp-rs has been elevated to Espressif's official top-tier SDK. Xtensa (ESP32, ESP32-S3) still needs a forked LLVM, but the RISC-V line (ESP32-C3, C6, H2, P4) builds with upstream Rust.
  • probe-rs has effectively replaced OpenOCD. One cargo run and you get flash plus RTT logs plus backtraces in a single terminal.
  • defmt logging is faster than printf, and the formatter runs on the host — the firmware binary does not carry the format strings.
  • Non-async embedded Rust is still alive. Skipping Embassy is not a sin. It just isn't what most new projects pick.

In short, embedded Rust is production-ready in 2026. Apollo3, Arduino R4, conference badges, home automation, art installations — all real domains shipping real code. This post walks from the basics of no_std all the way to your first Embassy blinky on an RP2040, in one pass.


1. The Landscape — Why Rust is Reaching the MCU Now

1.1 Five Pressures on Embedded Firmware

Anyone writing microcontroller firmware deals with five pressures simultaneously.

  1. Memory — environments like 32 KB RAM, 256 KB flash, no room for the standard library.
  2. Determinism — you do not allocate inside an ISR. It wrecks real-time guarantees.
  3. Hardware abstraction — you want to treat GPIO, UART, SPI, I2C, and timers without chip-specific code.
  4. Concurrency — sensor polling, UART RX, log transmission, display refresh, all running together with no OS.
  5. Debugging — JTAG/SWD, RTT, semihosting. Printing is hard. Endless blink-the-LED debugging.

1.2 Why C/C++ Was the Answer for 60 Years

C/C++ actually has a decent answer to all five. static variables plus interrupt handlers plus either an RTOS or a superloop, plus vendor SDKs (ST's HAL, Espressif's ESP-IDF, Nordic's nRF Connect SDK). The whole industry has run on this for six decades.

Two problems remain.

  • Memory safety — null pointers, bad casts, buffer overruns. One bad line of firmware turns into infinite LED-blink debugging.
  • Impoverished concurrency model — RTOS tasks plus mutexes plus queues are standard, but priority inversion, deadlocks, and priority inheritance traps never end.

1.3 How Rust Answers

Rust has a language-level answer to each of these pressures.

PressureRust's answer
Memory#![no_std] — build without the standard library, use core and alloc only
DeterminismOwnership and borrowing express alloc-free state machines naturally
Hardware abstractionThe embedded-hal trait ecosystem
Concurrencyasync/await plus the Embassy runtime (or RTIC)
Debuggingdefmt plus probe-rs — RTT/SWD setup in one line

What is decisively different from five years ago is that each answer now has working code and real chip support. The rest of this post unpacks those five answers one by one.


2. The Essence of no_std — How Far Does core Alone Get You?

2.1 Environments Where std Cannot Follow

On desktop and server, Rust's std runs on top of an OS. std::fs is the filesystem, std::net is the TCP stack, std::thread is OS threads, std::sync::Mutex is a pthread mutex. None of that exists on a microcontroller.

The #![no_std] attribute tells the compiler "this crate does not use std." Instead, it uses core, a smaller standard library.

AreaIn stdIn core
Primitive types (u8, f32, …)yesyes
Option, Resultyesyes
Iterator traityesyes
Vec, String, HashMapyesno (separate alloc)
println!yesno
std::fs, std::netyesno
Box, Rc, Arcyesno (separate alloc)
Threads, sync primitivesyesno

Even with only core you keep the richness of slices, iterators, traits, and macros. Only types that need dynamic allocation drop off.

2.2 The Decision to Pull in alloc

If your microcontroller has enough RAM — RP2040 has 264 KB SRAM, ESP32-S3 has 512 KB — you can add the alloc crate and bring Box, Vec, and String back. You must designate a global allocator.

#![no_std]
#![no_main]

extern crate alloc;

use alloc::vec::Vec;
use embedded_alloc::Heap;

#[global_allocator]
static HEAP: Heap = Heap::empty();

#[entry]
fn main() -> ! {
    // Reserve an 8 KB heap statically.
    {
        use core::mem::MaybeUninit;
        const HEAP_SIZE: usize = 8 * 1024;
        static mut HEAP_MEM: [MaybeUninit<u8>; HEAP_SIZE] = [MaybeUninit::uninit(); HEAP_SIZE];
        unsafe { HEAP.init(HEAP_MEM.as_ptr() as usize, HEAP_SIZE) }
    }

    let mut v: Vec<u32> = Vec::new();
    v.push(42);
    loop {}
}

The community-wide recommendation is "never allocate inside an ISR." Allocation in the main loop is fine; interrupts only touch pre-allocated buffers.

2.3 panic_handler Is Mandatory

A no_std binary must specify what to do on panic. In std you get automatic abort plus backtrace, but no such mechanism exists on a microcontroller.

use panic_halt as _;        // panic -> infinite loop
// or
use panic_probe as _;       // panic -> report via RTT, then halt (with probe-rs)
// or
use panic_reset as _;       // panic -> watchdog reset

Production firmware usually picks panic_reset plus a "save last panic info to non-volatile memory and report after reboot" pattern.

2.4 no_main and the Entry Point

A no_std crate is usually no_main as well. Instead of the standard fn main entry, the chip-specific boot sequence has to take control first.

#![no_std]
#![no_main]

use cortex_m_rt::entry;
use panic_halt as _;

#[entry]
fn main() -> ! {
    // `!` return means it never returns. Infinite loop or `wfi`.
    loop {}
}

The cortex_m_rt::entry macro creates the reset handler and plugs in the right boot sequence (stack pointer, zeroing BSS, copying initialized RAM data). The RP2040 has rp2040-hal, the ESP32 has esp-hal, each doing the equivalent.


3. PAC, HAL, BSP — Three Layers of Abstraction

The embedded Rust ecosystem is made of three layers of crates. Higher layers are more abstract; lower layers are more chip-specific.

3.1 PAC (Peripheral Access Crate)

The lowest layer. Auto-generated from the chip vendor's SVD (System View Description) XML by svd2rust. It exposes register bit-fields as types.

// Using rp2040-pac
use rp2040_pac as pac;

let peripherals = pac::Peripherals::take().unwrap();
let sio = peripherals.SIO;

// Set GPIO25 as output and drive it high.
sio.gpio_oe_set.write(|w| unsafe { w.bits(1 << 25) });
sio.gpio_out_set.write(|w| unsafe { w.bits(1 << 25) });

PAC-only code reads like the datasheet itself. Fast but unreadable and not portable across chips.

3.2 HAL (Hardware Abstraction Layer)

A safe abstraction on top of the PAC. rp2040-hal, embassy-rp, stm32f4xx-hal, esp-hal, nrf52840-hal, and so on.

// Using rp2040-hal
use rp2040_hal::gpio::Pins;
use embedded_hal::digital::OutputPin;

let pins = Pins::new(pac.IO_BANK0, pac.PADS_BANK0, sio.gpio_bank0, &mut pac.RESETS);
let mut led = pins.gpio25.into_push_pull_output();

led.set_high().unwrap();

Thanks to the OutputPin trait, the same code keeps working when the chip changes.

3.3 embedded-hal — The Abstraction Contract

The reason every HAL exposes the same shape of API is the embedded-hal trait crate. As of 2026, embedded-hal 1.0 is stable and embedded-hal-async 1.0 lands alongside it.

// Core traits from embedded-hal 1.0
pub trait OutputPin {
    type Error;
    fn set_low(&mut self) -> Result<(), Self::Error>;
    fn set_high(&mut self) -> Result<(), Self::Error>;
}

pub trait SpiDevice<Word = u8>: ErrorType {
    fn transaction(&mut self, operations: &mut [Operation<'_, Word>]) -> Result<(), Self::Error>;
}

pub trait I2c<A = SevenBitAddress, Word = u8>: ErrorType {
    fn transaction(&mut self, address: A, operations: &mut [Operation<'_, Word>]) -> Result<(), Self::Error>;
}

Sensor driver crates (such as bme280, mpu6050, ssd1306, embedded-graphics) are written generically over these traits. A single BME280 driver works on RP2040, STM32, ESP32, and nRF52.

3.4 BSP (Board Support Package)

A crate that pins down the pin mapping, power, and LED macros for a specific board (Raspberry Pi Pico, Adafruit Feather, STM32 Nucleo, etc.). Examples: rp-pico, feather_rp2040, stm32f4-discovery.

// Using rp-pico BSP — the LED pin already has a name.
use rp_pico::hal::Clock;
use rp_pico::hal::pac;
use rp_pico::hal::gpio::Pins;

let pac = pac::Peripherals::take().unwrap();
let sio = rp_pico::hal::Sio::new(pac.SIO);
let pins = rp_pico::Pins::new(pac.IO_BANK0, pac.PADS_BANK0, sio.gpio_bank0, &mut pac.RESETS);

let mut led = pins.led.into_push_pull_output();
led.set_high().unwrap();

The naming pins.led matters — the BSP knows the Pico wires its LED to GPIO25.


4. Embassy — Async by Default in Embedded Rust

4.1 Why Async on a Microcontroller?

Concurrency on a microcontroller traditionally took one of three forms.

  • Superloop — write loop { check_a(); check_b(); check_c(); }. Simple, but one long task starves the others.
  • Interrupt handler plus main loop — ISR starts work, main loop processes results. Shared state is tricky.
  • RTOS tasks — FreeRTOS, ChibiOS, Zephyr. Context-switch cost, N times the stack memory, learning curve for primitives.

Embassy is a fourth answer. Cooperative concurrency expressed in async/await, with a runtime that wakes on top of interrupts.

4.2 The Core Idea of Embassy

// Two tasks running concurrently. Not threads — async tasks.
#[embassy_executor::task]
async fn blink(mut led: Output<'static>) {
    loop {
        led.set_high();
        Timer::after_millis(500).await;   // Other tasks run during these 500 ms.
        led.set_low();
        Timer::after_millis(500).await;
    }
}

#[embassy_executor::task]
async fn echo(mut uart: BufferedUart<'static, UART0>) {
    let mut buf = [0u8; 64];
    loop {
        let n = uart.read(&mut buf).await.unwrap();
        uart.write_all(&buf[..n]).await.unwrap();
    }
}

The interesting part is Timer::after_millis(500).await. At an await point, the task yields and another task can run. When the timer interrupt fires 500 ms later, the Embassy runtime wakes the task back up.

uart.read(&mut buf).await works the same way — the task sleeps until a UART RX interrupt arrives. In the meantime the runtime runs other tasks, and once idle it sleeps the core with wfi (Wait For Interrupt).

4.3 Stack Memory — The Decisive Difference vs RTOS

RTOS tasks each have their own stack. Five tasks at 4 KB each gives you 20 KB. That hurts on small microcontrollers.

Embassy's async tasks share a single stack. The state of each task is compressed into a compiler-generated state-machine struct. As a result, ten tasks frequently fit in 1-2 KB of stack.

RTOS:    [Stack T1: 4KB] [Stack T2: 4KB] [Stack T3: 4KB] [Stack T4: 4KB]
Embassy: [Shared Stack: 4KB] + [Task1 state: 64B] [Task2 state: 96B] [Task3 state: 32B]

4.4 Spawner and main

Standard Embassy main-function boilerplate.

#![no_std]
#![no_main]

use embassy_executor::Spawner;
use embassy_rp::gpio::{Level, Output};
use embassy_time::Timer;
use {defmt_rtt as _, panic_probe as _};

#[embassy_executor::main]
async fn main(spawner: Spawner) {
    let p = embassy_rp::init(Default::default());
    let led = Output::new(p.PIN_25, Level::Low);
    spawner.spawn(blink(led)).unwrap();
}

#[embassy_executor::task]
async fn blink(mut led: Output<'static>) {
    loop {
        led.toggle();
        Timer::after_millis(500).await;
    }
}

The embassy_executor::main macro makes main an async function and injects a Spawner argument. Tasks register through spawner.spawn(...).

4.5 RTIC vs Embassy — The Two Main Choices

RTIC (Real-Time Interrupt-driven Concurrency) is another embedded concurrency framework that predates Embassy. It schedules priority-based interrupt-driven tasks, with resource locking statically verified at compile time.

AspectEmbassyRTIC
Modelasync/await plus executorPriority-based interrupt scheduling
Learning curveIf you know async, immediateMacros and resource model to learn
Priority guaranteesCooperative — all tasks equalPriority-based, compile-time verified
MemorySmaller (shared stack)Per-priority stack
PopularityThe default for new projects, 2024-2026When safety-critical or hard real-time guarantees are required

Pick Embassy first for new projects. Pick RTIC for hard real-time (automotive, avionics) where priority verification is mandatory.


5. Per-Target Status as of May 2026

5.1 RP2040 / RP2350 — Raspberry Pi Pico Family

  • RP2040 — Dual Cortex-M0+, 264 KB SRAM. embassy-rp is stable. Covers Pico 1 and Pico W.
  • RP2350 — Dual Cortex-M33 plus dual RISC-V Hazard3 (choose at boot), 520 KB SRAM, secure boot, ARM TrustZone. rp235x-hal 1.0 and embassy-rp rp235x support are stable.
  • Bluetooth and Wi-Fi (Pico W, Pico 2 W) — cyw43 crate plus embassy-net (smoltcp-based TCP/IP). Even BLE works over PIO plus SPI.
  • PIO — the signature feature of the RP series. A lightweight stateful I/O machine. Write PIO assembly in Rust with pio-proc plus embassy-rp::pio.
// Snippet: Wi-Fi on Pico W (abridged)
use cyw43_pio::PioSpi;
use embassy_net::Stack;

let mut control = cyw43::new(state, pwr, spi, fw).await;
control.init(clm).await;
control.join_wpa2("SSID", "PASSWORD").await.unwrap();

5.2 ESP32 (esp-rs)

Espressif elevated Rust to its official top-tier SDK in 2023. As of 2026, esp-hal 1.0 is stable.

  • RISC-V chips (ESP32-C3, C6, H2, P4) — built with upstream Rust toolchain. No espup needed.
  • Xtensa chips (ESP32, ESP32-S2, ESP32-S3) — still need a forked LLVM. Auto-configured by espup install.
  • embassy-executor plus esp-hal-embassy — run Embassy on ESP32 as-is.
  • Wi-Fi / BLEesp-wifi crate. Wi-Fi 6 and BLE 5.3. Integrates with embassy-net.
# Cargo.toml — for ESP32-C6
[dependencies]
esp-hal = { version = "1.0", features = ["esp32c6"] }
esp-hal-embassy = { version = "0.6", features = ["esp32c6"] }
embassy-executor = { version = "0.6", features = ["task-arena-size-20480"] }
esp-wifi = { version = "0.10", features = ["esp32c6","wifi","ble"] }

The ESP32-C6 — RISC-V plus Wi-Fi 6 plus a Thread/Zigbee radio — is the hot spot of embedded Rust in 2026. It is the easiest path to a Matter device written in Rust.

5.3 STM32 (stm32-rs)

STMicroelectronics's STM32 family. The default chipset of the embedded industry.

  • stm32-rs — a joint project that auto-generates PACs for every STM32 chip from SVD.
  • stm32f4xx-hal, stm32h7xx-hal, stm32l4xx-hal — per-family HALs.
  • embassy-stm32 — the unified Embassy HAL. Covers every family, pick a chip with features = ["stm32f407vg"]. The default for new STM32 projects in 2026.

The compelling reason to pick Embassy on STM32 is that the interrupt-to-async-waker plumbing exists for nearly every peripheral. UART, SPI, I2C, ADC, DMA, USB — all async.

5.4 nRF52 / nRF53 / nRF54 — Nordic Semiconductor

The de facto standard for BLE devices.

  • embassy-nrf — stable for nRF52832, 52840, 5340, and 54L.
  • nrf-softdevice — wrapper that calls Nordic's SoftDevice (BLE stack) from Rust async. Both peripheral and central role.
  • 2026 updatetrouble (pure Rust BLE host) has matured into a SoftDevice-free alternative. Runs on nRF52, ESP32, and CYW43.

5.5 Others — Briefly

  • Ambiq Apollo3 / 4ambiq-hal. Ultra-low-power (uA) MCUs. Popular for wearables and BLE.
  • Arduino UNO R4 (Renesas RA4M1)ra4m1-hal (community). Cortex-M4. Arduino-compatible pinout.
  • CH32V — WCH's ultra-cheap RISC-V. ch32-hal. Price tag around 20 cents.
  • SAMD (Microchip / Atmel)atsamd-hal. Covers SAMD21 (Adafruit Feather M0) and SAMD51.
  • Generic RISC-Vriscv-rt for boot, chip-specific HAL separately.

6. Tooling — probe-rs, cargo-embed, defmt

6.1 probe-rs — The Successor to OpenOCD

The era where OpenOCD plus GDB was the standard for embedded debugging is over. probe-rs has effectively replaced it.

probe-rs is a Rust-written driver for debug probes (J-Link, ST-Link, CMSIS-DAP, Raspberry Pi Debug Probe, FTDI, etc.), plus flash algorithms, plus the RTT/SWO host.

# Install
cargo install probe-rs-tools

# List connections
probe-rs list

# Flash, run, and stream RTT logs in one command.
cargo run --release  # with runner = "probe-rs run" set in .cargo/config.toml

6.2 Standard .cargo/config.toml

A standard setup for an RP2040 project.

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

[build]
target = "thumbv6m-none-eabi"   # RP2040 (Cortex-M0+)
# target = "thumbv8m.main-none-eabihf"  # RP2350 (Cortex-M33)

[env]
DEFMT_LOG = "trace"

Type cargo run --release and you get build, flash, run, RTT logs, and backtraces in one command.

6.3 defmt — Logging Faster Than printf

defmt is a macro like println!, but with a key trick — the format string never lives in the firmware binary, only an index does. The host (probe-rs) reads the .defmt section of the ELF to reconstruct strings.

use defmt::{info, warn, error};

fn read_sensor(i: u32) {
    info!("sensor read iteration={}", i);

    let value = sensor.read();
    if value > 100 {
        warn!("sensor value high: {=u32}", value);
    } else if value == 0 {
        error!("sensor not responding");
    }
}

Type hints like {=u32} exist for serialization efficiency. A single log typically serializes to 4-8 bytes. A 1 MHz UART can sustain 100k logs per second.

Comparisonprintf (semihosting)RTT printfdefmt over RTT
Per-log costTens of µs (blocking)1-2 µs100-500 ns
Firmware sizeFormat strings includedIncludedIndex only
Compile-time format checknonoyes

6.4 cargo-embed — An Alternative Runner

Besides probe-rs run there is cargo-embed. It provides a TUI with both RTT logs and a GDB debugger in one screen.

cargo install cargo-embed
cargo embed --release

The 2025-2026 trend favors probe-rs run since it is lighter and is now the default for new projects. Use cargo-embed when you want the dashboard.

6.5 GDB Is Still Alive

For situations where GDB is stronger (intricate memory corruption, for example), launch probe-rs gdb as a server and attach with arm-none-eabi-gdb.

probe-rs gdb --chip RP2040
# In another terminal
arm-none-eabi-gdb target/thumbv6m-none-eabi/release/myfirmware
(gdb) target remote :1337

7. Your First Blinky with Embassy — RP2040 Walkthrough

Time for real code. The onboard LED of a Raspberry Pi Pico (GPIO25) blinking every 0.5 seconds.

7.1 Create the Project

cargo new --bin blinky
cd blinky
rustup target add thumbv6m-none-eabi

7.2 Cargo.toml

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

[dependencies]
embassy-executor = { version = "0.6", features = ["arch-cortex-m","executor-thread","integrated-timers"] }
embassy-time = { version = "0.4" }
embassy-rp = { version = "0.4", features = ["rp2040","time-driver"] }
defmt = "0.3"
defmt-rtt = "0.4"
panic-probe = { version = "0.3", features = ["print-defmt"] }
cortex-m-rt = "0.7"

[profile.release]
debug = 2          # keep debug symbols for defmt
lto = true
codegen-units = 1
opt-level = "s"

7.3 .cargo/config.toml

[target.'cfg(all(target_arch = "arm", target_os = "none"))']
runner = "probe-rs run --chip RP2040 --protocol swd"
rustflags = [
  "-C", "link-arg=--nmagic",
  "-C", "link-arg=-Tlink.x",
  "-C", "link-arg=-Tdefmt.x",
]

[build]
target = "thumbv6m-none-eabi"

[env]
DEFMT_LOG = "trace"

7.4 src/main.rs — First Blinky

#![no_std]
#![no_main]

use defmt::info;
use embassy_executor::Spawner;
use embassy_rp::gpio::{Level, Output};
use embassy_time::Timer;
use {defmt_rtt as _, panic_probe as _};

#[embassy_executor::main]
async fn main(_spawner: Spawner) {
    let p = embassy_rp::init(Default::default());
    let mut led = Output::new(p.PIN_25, Level::Low);

    info!("blinky started");

    loop {
        led.set_high();
        Timer::after_millis(500).await;
        led.set_low();
        Timer::after_millis(500).await;
    }
}

7.5 Build and Flash

Hold BOOTSEL on the Pico while plugging in USB and the board enumerates as a mass storage device. If you also have a probe-rs-compatible debug probe attached (or a Pico Probe firmware on a second Pico), one command does the rest.

cargo run --release
# Compiling blinky ...
# Finished release [optimized + debuginfo] target(s)
# Running `probe-rs run --chip RP2040 --protocol swd target/thumbv6m-none-eabi/release/blinky`
# 0.000000 INFO  blinky started

The LED blinks every second. A single log line streams to the host terminal over RTT.

7.6 One Step Up — UART Echo Plus Two Concurrent Tasks

#![no_std]
#![no_main]

use defmt::info;
use embassy_executor::Spawner;
use embassy_rp::gpio::{Level, Output};
use embassy_rp::peripherals::{PIN_25, UART0};
use embassy_rp::uart::{Async, BufferedUart, Config as UartConfig, InterruptHandler};
use embassy_rp::{bind_interrupts, uart};
use embassy_time::Timer;
use embedded_io_async::{Read, Write};
use {defmt_rtt as _, panic_probe as _};

bind_interrupts!(struct Irqs {
    UART0_IRQ => InterruptHandler<UART0>;
});

#[embassy_executor::main]
async fn main(spawner: Spawner) {
    let p = embassy_rp::init(Default::default());

    // LED task
    let led = Output::new(p.PIN_25, Level::Low);
    spawner.spawn(blink(led)).unwrap();

    // UART echo task — GPIO0 TX, GPIO1 RX, 115200 baud
    let mut cfg = UartConfig::default();
    cfg.baudrate = 115_200;
    let uart = BufferedUart::new(
        p.UART0, Irqs, p.PIN_0, p.PIN_1,
        cfg, &mut [0u8; 64], &mut [0u8; 64],
    );
    spawner.spawn(echo(uart)).unwrap();
}

#[embassy_executor::task]
async fn blink(mut led: Output<'static>) {
    loop {
        led.toggle();
        Timer::after_millis(500).await;
    }
}

#[embassy_executor::task]
async fn echo(mut uart: BufferedUart<'static, UART0>) {
    let mut buf = [0u8; 32];
    info!("echo task ready");
    loop {
        match uart.read(&mut buf).await {
            Ok(n) => {
                info!("rx {} bytes", n);
                let _ = uart.write_all(&buf[..n]).await;
            }
            Err(_) => Timer::after_millis(10).await,
        }
    }
}

The point — blink and echo both stay alive. While one is asleep on Timer::after_millis(500).await, the other can wake on a UART RX interrupt. One core, one stack, two tasks.

7.7 One More defmt Line

info!("rx {} bytes, first={=u8:#04x}", n, buf[0]);

Host terminal output:

0.001234 INFO  blinky started
0.001500 INFO  echo task ready
0.012345 INFO  rx 5 bytes, first=0x48

The 0x48 is ASCII 'H'. That was the entire first embedded Rust project.


8. Real Projects — What People Are Building

8.1 Home Automation / Matter Devices

ESP32-C6 plus esp-hal plus the rs-matter crate. A Rust implementation of Matter (the IoT standard over Thread or Wi-Fi). Since 2024, rs-matter has reached v0.5, and you can write the full path — BLE commissioning, Wi-Fi or Thread, exposing Matter clusters — in Rust.

[BLE commissioning] -> [Wi-Fi/Thread] -> [Matter cluster: OnOff, LevelControl, ...]
                       embassy-net          rs-matter

Apple Home, Google Home, and Amazon Alexa all see the same device.

8.2 Conference Badges

Hackaday Supercon, Defcon, EMF Camp, and other conference badges have been shifting toward Rust. The EMF Camp 2024 badge — RP2040 plus ePaper plus LoRa — was Embassy-based from day one.

The reason: refreshing ePaper, RX on the radio, user input, and battery monitoring all concurrently — async expresses this much more cleanly than the alternatives.

8.3 Interactive Art Installations

Media-art work that ties together thousands of LED matrices, sensor arrays, and Wi-Fi/Art-Net. Rust firmware is becoming standard at Burning Man, teamLab-style projects in Japan, and European festivals. The decisive selling point is escaping the C boilerplate and the memory-corruption debugging.

8.4 Industrial IoT — Vibration / Temperature Monitors

STM32H7 plus LoRa plus a MEMS vibration sensor — FFT the vibration data, transmit wirelessly when a threshold is exceeded. On chips like the STM32H7, you can call DSP instructions through wrappers like cortex-m::asm::sev, or use cmsis-dsp-sys bindings to CMSIS-DSP.

8.5 Non-Volatile Key/Value Storage (sequential-storage)

Persist key/value data in flash so the firmware can keep config and calibration across reboots. Built on the embedded-storage trait, with sequential-storage providing wear leveling. The last 4 KB sector of an RP2040, the EEPROM region of an STM32, and the NVS partition of an ESP32 are all driven by the same API.


9. Compared to C/C++, MicroPython, Zig Embedded, and Ada/SPARK

9.1 C/C++ — Still the Industry Standard

AspectC/C++Rust
CompilerAvailable on every chipOnly chips with upstream LLVM support (almost all)
Memory safetyYou guarantee itThe compiler verifies it
Vendor SDKFirst-class (ST HAL, ESP-IDF, nRF Connect)Catching up over 2-3 years (esp-rs is first-class)
ConcurrencyRTOS plus interruptsEmbassy / RTIC
DebuggingOpenOCD plus GDBprobe-rs plus defmt
Talent poolMassiveGrowing fast
Auto / aerospace certificationMISRA C standardFerrocene (Ferrous Systems's certified Rust compiler)

In short — pick C/C++ if legacy chips, talent pool, or a certified compiler are decisive. Pick Rust if a new chip, a new team, and the value of memory safety plus a concurrency model matter more.

9.2 MicroPython — Entry Barrier and RAM

MicroPython runs an interpreter on ESP32, RP2040, and STM32. The entry barrier is unbeatably low — first LED blink in five minutes.

AspectMicroPythonRust
Entry barrierVery lowMedium to high
Execution speedInterpreted (1/100 to 1/50 of C)At C performance
RAM useInterpreter and GC overhead, ~64 KB minimumNear zero overhead
Compile-time checksAlmost noneStrong
Best fitPrototyping, education, light logicProduction, real-time, tight resources

MicroPython will not die — it is too strong in education and prototyping. But to turn a prototype into a product, the rewrite is usually into Rust or C/C++.

9.3 Zig Embedded

Zig treats embedded as a first-class citizen from day one. comptime is more powerful than macros, the memory model is explicit, and the C ABI interop is natural.

But as of May 2026, the ecosystem depth is far behind Rust. There is no unified async runtime like Embassy, no static verification like RTIC, no library of more than a hundred sensor driver crates. Zig is appealing on small firmware; for big projects Rust is ahead.

9.4 Ada / SPARK

The standard for decades in automotive, aerospace, and nuclear. Static verification through the SPARK subset is powerful.

Versus Rust — memory safety, both. Concurrency safety, Rust ahead. Industry certification depth, Ada/SPARK ahead. Talent pool growth, Rust faster. With Ferrocene certifying the Rust compiler to ISO 26262 / IEC 61508 (ASIL D in 2023), Rust is moving into safety-critical territory too.


10. The Hard Parts — Honest Traps

10.1 The Pressure of the 'static Lifetime

Embassy tasks must be 'static — that is, you cannot pass a stack-borrowed reference to a task. The common pattern is to initialize once in static memory via the static_cell crate.

use static_cell::StaticCell;

static UART_BUF: StaticCell<[u8; 256]> = StaticCell::new();

#[embassy_executor::main]
async fn main(spawner: Spawner) {
    let p = embassy_rp::init(Default::default());
    let buf: &'static mut [u8; 256] = UART_BUF.init([0; 256]);
    // Now buf is 'static and can be passed to spawn.
}

The pattern feels awkward for the first month. By month two it is natural.

10.2 The Interrupt Binding Macro

The macro that wires peripheral interrupt handlers into the Embassy runtime, bind_interrupts!, has an odd-looking shape that differs slightly per chip.

bind_interrupts!(struct Irqs {
    UART0_IRQ => embassy_rp::uart::InterruptHandler<UART0>;
    USBCTRL_IRQ => embassy_rp::usb::InterruptHandler<USB>;
});

At first this looks "why does it look like this," but unpacked it is just the safe registration "when the USB interrupt fires, call Embassy's USB handler."

10.3 Xtensa Rust Still Needs a Forked LLVM

The original ESP32 and ESP32-S2/S3 are Xtensa cores. Xtensa is not upstream LLVM; Espressif maintains a fork. espup install handles the setup, but CI like GitHub Actions has one extra step.

The RISC-V chips (C3, C6, H2, P4) build with upstream Rust and do not have this issue. Prefer the RISC-V line for new ESP projects is the conventional wisdom in 2026.

10.4 Debug Build Size

cargo build (debug) has no LTO and opt-level 0, so the code can balloon 4 to 10 times and may not fit in flash. Embedded Rust is almost always written against cargo build --release. Keep debug symbols on with debug = 2 in release so that defmt and backtraces still work.

10.5 PACs for Unknown Chips

The ecosystem is active but does not cover everything. SiPEED MAIX (Kendryte K210), Bouffalo BL602, and similar boards may have a PAC but only a partial HAL. Outside the major lines (RP2xxx, ESP32, STM32, nRF5x, SAMD) you may have to write a HAL on top of the PAC yourself.


11. A Learning Path — Where to Start

11.1 Week Zero — Buy a Pico

Fastest start: Raspberry Pi Pico 2 W (~7)plusRaspberryPiDebugProbe( 7)** plus **Raspberry Pi Debug Probe (~12) plus two USB-C cables. Around $25 all in. Buying the debug probe separately is the key — it is what unlocks flash plus RTT debugging from one command.

For the ESP32 line, ESP32-C6 DevKit (~$15) is a great choice. C6 has Wi-Fi 6 plus BLE 5 plus Thread.

11.2 Weeks 1-2 — embedded-hal Traits

embedded-hal::digital::OutputPin, embedded-hal::spi::SpiDevice, embedded-hal::i2c::I2c, embedded-hal-async::*. The first one to two weeks should focus on the shape of these traits. After that, the BME280 driver, the SSD1306 OLED driver, and the MPU6050 IMU driver all look the same.

11.3 Weeks 3-4 — Embassy's Async

Embassy's official examples folder is the fastest learning material. Open examples/rp, examples/esp32c6, or examples/stm32f4 — whichever matches your chip — and read end to end. Each example is 100-200 lines, short, with repeated patterns.

11.4 Months 1-2 — First Project

One small meaningful project. Recommended — ePaper clock plus Wi-Fi NTP sync, BME280 environment sensor over BLE or Wi-Fi, or rotary encoder plus OLED menu UI. Projects between 100 and 500 lines yield the best learning efficiency.

11.5 Months 3-6 — Depth

At this stage pick one direction for depth.

  • Matter device — using rs-matter. Actually register with Apple or Google Home.
  • Own PCB plus own firmware — KiCad for the board, Rust for the firmware.
  • Low power — uA-level sleep plus interrupt wake-up plus battery life math.
  • DMA plus high-speed peripherals — SPI DMA pushing close to 1 Gbps.
  • PIO (Pico) or RMT (ESP32) — custom protocols (WS2812, IR, 1-wire).

Epilogue — Rust Made Embedded More Honest

The one-line summary of embedded Rust — the compiler asks "whose memory is this?" with you. Code where an interrupt and the main loop touch the same buffer fails to compile. An async task that borrowed a peripheral keeps another task from using it concurrently. Static RAM is verified to be initialized in exactly one place.

This sounds small, but if you have ever spent half your firmware-debugging time on "why won't the LED blink" only to discover it was memory corruption, it is decisive.

C/C++ are not going away. Sixty years of code, talent, vendor SDKs, and automotive certification live there. What is changing is that Rust is becoming the default on new chips. And once it becomes the default — the next generation of developers will not go back to C.

Embassy's async was an "interesting experiment" five years ago. In 2026 it is "why would you write it any other way?" RTIC, superloops, and RTOSes are still alive — but the default choice for new projects is Embassy.

probe-rs and defmt have dragged embedded debugging from the 1990s into the 2020s. Once you taste the workflow of one cargo run --release doing build, flash, and logging, you cannot go back to OpenOCD.

The big picture — embedded Rust in 2026 has passed the stage of "is it usable?" and reached "why aren't you using it?" The next five years will be Ferrocene penetrating certification-heavy areas like automotive, medical, and aerospace.

14-Item Checklist

  1. Are #![no_std] and #![no_main] enabled?
  2. Is a panic_handler declared (panic_halt / panic_probe / panic_reset)?
  3. Are debug symbols kept in Cargo.toml's [profile.release] with debug = 2?
  4. Does .cargo/config.toml's runner use probe-rs run --chip ...?
  5. Are you using HAL/BSP first instead of code that touches the PAC directly?
  6. Are sensor and display drivers used generically against embedded-hal traits?
  7. When an Embassy task needs 'static, do you handle it with static_cell / StaticCell?
  8. Are you avoiding dynamic allocation inside interrupt handlers?
  9. Are defmt::info!, warn, and error levels filtered appropriately for production?
  10. If using Wi-Fi or BLE, have you decided between embassy-net, trouble, or esp-wifi?
  11. For non-volatile storage, are you abstracting via sequential-storage / embedded-storage?
  12. Where possible, have you preferred RISC-V (C3, C6, H2) over Xtensa (ESP32, S2, S3) to reduce toolchain burden?
  13. Does CI run cargo build --release plus cargo clippy --target ...?
  14. Do you have a separate debug probe (Pico Probe / J-Link / ST-Link)? It is the heart of RTT debugging.

Ten Anti-Patterns

  1. Driving GPIO with bit-shifts on the PAC only — loses both readability and portability.
  2. Calling a large synchronous blocking function from inside an Embassy task — starves other tasks.
  3. Dynamic allocation like Vec::push inside an ISR — wrecks real-time guarantees.
  4. Shipping production firmware with panic_halt — should reset via watchdog instead.
  5. Flashing the cargo build (debug) firmware as-is — both size and speed suffer.
  6. Spraying defmt::info! calls without an RTT host attached — the RTT buffer can fill up and stall the firmware.
  7. Cramming every feature into a single Xtensa chip (ESP32) — RISC-V has far less toolchain burden.
  8. Sharing the same mutable variable between an interrupt and the main loop without a mutex — Rust will block you, but code that bypassed safety with unsafe is dangerous.
  9. Forgetting to expose debug probe pins on a prototype PCB — one bad flash and USB DFU may not be enough to recover.
  10. 100 percent Rust purism — if a vendor BLE stack (SoftDevice) or ESP-IDF Wi-Fi is more stable, wrap it and use it.

Next Post Preview

Candidates for the next post: Matter device in Rust — ESP32-C6 plus rs-matter full stack, Embassy plus LoRa (SX126x) for a long-range sensor network, probe-rs deep dive — writing a debug probe of your own.

"Embedded Rust is the act of making the compiler your ally. Whose memory it is, what an interrupt wakes, where a resource is released — the code says it out loud."

— Embedded Rust 2026 deep dive, end.


References