Quick Takeaways
What you'll learn in this article
- 1
Data structures: heapless provides fixed-capacity Vec, String, HashMap, and queues that live entirely on the stack or in static memory
- 2
Serialization: serde works in nostd with default-features = false, and postcard is the go-to compact binary format for embedded
- 3
Logging: defmt (deferred formatting) is the standard for efficient embedded logging, sending format strings to the host at compile time
- 4
Error handling: thiserror has a nostd compatible fork, and defmt provides its own error formatting
- 5
Async runtime: embassy-executor provides the cooperative async runtime
Keep reading for detailed implementation, code examples, and real-world results
Updated (March 2026): Complete rewrite expanding the original overview into a comprehensive technical development guide. Covers Embassy 0.4, RTIC v2, embedded-hal 1.0 stable traits, probe-rs 0.24 debugging, defmt structured logging, heapless 0.8, no_std ecosystem maturity, RP2350 and ESP32-C6 RISC-V target support, async driver patterns, and memory management strategies for bare-metal Rust. All framework versions, API examples, and toolchain details reflect the ecosystem as of early 2026.
Rust Embedded Development in 2026: The Complete Technical Guide
The Rust embedded ecosystem has matured dramatically since its early days of experimental HAL crates and hand-rolled linker scripts. In 2026, writing firmware in Rust is no longer an exercise in pioneering spirit -- it is a practical, well-supported development experience backed by stable toolchains, production-grade frameworks, and a vibrant open-source community. The language's compile-time safety guarantees, zero-cost abstractions, and modern tooling have converged to create an embedded development experience that rivals and often surpasses what C and C++ offer.
This guide focuses on the technical development experience of writing embedded Rust in 2026. Rather than surveying industry adoption trends or corporate case studies, we will dig into the frameworks, toolchains, debugging workflows, memory management patterns, and real-time strategies that working embedded Rust developers use every day. Whether you are porting a production firmware from C, evaluating Embassy for a new product, or building your first bare-metal project on an RP2040, this is the reference you need.
Crates on crates.io tagged embedded
4,800+
Up from ~2,100 in early 2024
The no_std Ecosystem in 2026
Understanding the no_std ecosystem is the foundational knowledge every embedded Rust developer needs. Unlike desktop or server Rust where the full standard library is available, embedded targets typically cannot use std because it depends on an operating system for heap allocation, file I/O, networking, and threading primitives. Rust solves this with a layered library design that gives embedded developers precise control over what capabilities they pull in.
Core, Alloc, and Std: When to Use Each
The Rust standard library is split into three layers, and knowing when to use each is critical for embedded work:
core is the foundation. It provides the language primitives that require zero OS support: primitive types, slices, iterators, Option, Result, core::fmt for formatting, core::sync::atomic for atomics, and mathematical operations. Every no_std crate builds on core. When you write #![no_std] at the top of your crate, you are saying "I only need core by default." This is where the vast majority of embedded Rust code lives. The core library is entirely platform-agnostic and compiles for any target Rust supports.
alloc is the middle layer. It provides heap-allocated collections -- Vec, String, Box, BTreeMap, Arc -- but requires you to supply a global allocator. In embedded contexts, you can use alloc by configuring a fixed-size heap allocator like embedded-alloc (formerly alloc-cortex-m). This is useful when you genuinely need dynamic allocation, such as building variable-length protocol buffers or managing a dynamic set of network connections. However, most embedded developers avoid alloc unless absolutely necessary because heap allocation introduces fragmentation risks and makes worst-case memory usage harder to reason about.
std is the full standard library. It includes everything in core and alloc plus OS-dependent features: file systems, networking, threads, time, and process management. On embedded targets, std is generally not available unless you are running on a platform with an OS layer. The notable exception is ESP-IDF development on ESP32 chips, where Espressif provides a full std environment running on top of FreeRTOS through the esp-idf-hal and esp-idf-svc crates. This lets you write ESP32 firmware using familiar std patterns, including std::net for TCP/IP networking.
// A typical no_std embedded crate root #![no_std] #![no_main] use core::fmt::Write; use panic_probe as _; // panic handler for probe-rs // With alloc (when heap is needed): // extern crate alloc; // use alloc::vec::Vec;
The practical guidance in 2026 is straightforward: start with core only, reach for heapless collections when you need data structures, and only pull in alloc when you have a genuine use case that heapless cannot serve. Reserve std for ESP-IDF projects or Linux-based embedded systems like those running on Raspberry Pi.
The no_std Crate Ecosystem
The no_std crate ecosystem has expanded significantly. Key categories include:
- Data structures: heapless provides fixed-capacity Vec, String, HashMap, and queues that live entirely on the stack or in static memory
- Serialization: serde works in no_std with default-features = false, and postcard is the go-to compact binary format for embedded
- Logging: defmt (deferred formatting) is the standard for efficient embedded logging, sending format strings to the host at compile time
- Error handling: thiserror has a no_std compatible fork, and defmt provides its own error formatting
- Async runtime: embassy-executor provides the cooperative async runtime
- Cryptography: RustCrypto crates (aes, sha2, ecdsa, etc.) all support no_std
- Protocol parsing: nom and winnow parsers work in no_std for parsing binary protocols
- USB: usb-device and embassy-usb for USB device implementations
Embassy: Async Embedded Rust
Embassy is the most significant development in the embedded Rust ecosystem in recent years. It brings Rust's async/await syntax to bare-metal microcontrollers, enabling developers to write concurrent firmware that looks like sequential code while compiling down to an efficient state machine with no heap allocation.
Why Async Matters for Embedded
Traditional embedded C firmware uses one of two concurrency models: a superloop that polls peripherals in sequence, or an RTOS that provides preemptive multitasking with threads and context switching. Both have drawbacks. Superloops waste CPU cycles polling inactive peripherals and become unwieldy as complexity grows. RTOS threads consume RAM for per-thread stacks (typically 256 bytes to 4 KB each) and introduce context-switching overhead.
Embassy's async model offers a third path. Each concurrent task is an async fn that yields when waiting for an event (a timer expiring, a UART byte arriving, a DMA transfer completing). The executor only resumes a task when its waker fires, meaning zero CPU time is wasted on polling. And because Rust's async transforms compile each task into a state machine, there are no per-task stacks -- the memory footprint is just the size of each task's state, which the compiler calculates at compile time.
Comparison
RTOS Thread Model
Embassy Async Model
Embassy Architecture
Embassy is not a single crate but a family of crates that work together:
embassy-executor is the async task executor. It runs on bare metal with no OS dependency. Tasks are spawned statically (allocated at compile time in static memory) using the #[embassy_executor::task] attribute. The executor is interrupt-driven: when a task awaits a future, the executor puts the CPU to sleep (WFI/WFE on ARM) until an interrupt fires and wakes a task. This means your microcontroller draws minimal power when idle.
embassy-time provides async timers and delays. Instead of busy-waiting with a loop, you write Timer::after_millis(100).await and the executor sleeps the CPU for exactly 100 milliseconds. It uses a hardware timer peripheral (configured per-chip) and maintains a timer queue for scheduling multiple concurrent delays efficiently.
embassy-sync provides async-aware synchronization primitives: Mutex, Signal, Channel, PubSubChannel, and Pipe. These are designed for the cooperative async model -- when a task tries to acquire a locked mutex, it yields to the executor rather than spinning.
embassy-net is a full async TCP/IP networking stack built on smoltcp. It provides TcpSocket, UdpSocket, DhcpClient, and DNS resolution. For WiFi chips like the CYW43 (used on Raspberry Pi Pico W), Embassy includes a complete driver that integrates with embassy-net. For Ethernet, it supports various PHY chips through the embassy-net-driver trait.
embassy-usb provides async USB device support, including CDC-ACM (serial), HID (keyboard/mouse), and mass storage class implementations.
HAL crates are where Embassy meets specific hardware. embassy-stm32 covers the entire STM32 family, embassy-nrf covers Nordic nRF52 and nRF53 series, embassy-rp covers the RP2040 and RP2350, and community-maintained HALs extend support to other chips. These HALs implement embedded-hal and embedded-hal-async traits, so portable drivers work seamlessly.
Embassy in Practice
Here is what a typical Embassy application looks like on an STM32:
#![no_std]
#![no_main]
use defmt::*;
use embassy_executor::Spawner;
use embassy_stm32::gpio::{Level, Output, Speed};
use embassy_time::Timer;
use panic_probe as _;
#[embassy_executor::task]
async fn blink(mut led: Output<'static>) {
loop {
led.set_high();
Timer::after_millis(500).await;
led.set_low();
Timer::after_millis(500).await;
}
}
#[embassy_executor::task]
async fn sensor_reader() {
loop {
// Read sensor via async SPI/I2C
Timer::after_secs(1).await;
info!("Sensor reading complete");
}
}
#[embassy_executor::main]
async fn main(spawner: Spawner) {
let p = embassy_stm32::init(Default::default());
let led = Output::new(p.PB7, Level::Low, Speed::Low);
spawner.spawn(blink(led)).unwrap();
spawner.spawn(sensor_reader()).unwrap();
}
Notice several things: there is no main loop -- the executor manages scheduling. Each task is an async fn that runs concurrently. The LED blink and sensor reader operate independently, and the CPU sleeps whenever both tasks are awaiting. The defmt::info! macro logs efficiently using deferred formatting. No heap allocation occurs anywhere.
Embassy DMA Integration
One of Embassy's most powerful features is its DMA (Direct Memory Access) integration. In traditional embedded C, DMA transfers require setting up callbacks, managing buffer lifetimes manually, and carefully coordinating between interrupt handlers and main code. Embassy wraps this in clean async APIs:
use embassy_stm32::usart::{Config, Uart};
#[embassy_executor::task]
async fn uart_echo(mut usart: Uart<'static, embassy_stm32::mode::Async>) {
let mut buf = [0u8; 256];
loop {
// This await yields while DMA fills the buffer
let n = usart.read_until_idle(&mut buf).await.unwrap();
// Echo back using DMA transmit
usart.write(&buf[..n]).await.unwrap();
}
}
The read_until_idle call sets up a DMA transfer and puts the task to sleep. When the UART receives data and goes idle, the DMA interrupt fires, completes the transfer, and wakes the task. Zero CPU cycles are spent waiting. This pattern extends to SPI, I2C, ADC, and any peripheral with DMA support.
RTIC: Real-Time Interrupt-Driven Concurrency
While Embassy is the dominant async framework, RTIC (Real-Time Interrupt-driven Concurrency) remains an important choice for developers who need priority-based preemptive scheduling with formal real-time guarantees. RTIC v2, the current stable version, maps directly to hardware interrupt priorities and uses the Stack Resource Policy (SRP) for deadlock-free resource sharing.
RTIC's Model
RTIC is fundamentally different from Embassy. Where Embassy uses cooperative scheduling (tasks voluntarily yield at .await points), RTIC uses preemptive scheduling based on hardware interrupt priorities. Higher-priority tasks can interrupt lower-priority ones at any point, giving you hard real-time guarantees about response latency.
The framework uses a procedural macro (#[app]) that analyzes your entire application at compile time. It verifies that resource sharing is safe, calculates the minimum stack usage, and generates the interrupt vector table and dispatcher code. The result is zero-overhead compared to hand-written interrupt handlers, but with compile-time safety guarantees.
#[rtic::app(device = stm32f4xx_hal::pac, dispatchers = [SPI1])]
mod app {
use stm32f4xx_hal::prelude::*;
#[shared]
struct Shared {
sensor_value: u16,
}
#[local]
struct Local {
led: gpio::Pin<'B', 7, Output>,
}
#[init]
fn init(cx: init::Context) -> (Shared, Local) {
// Hardware initialization
let dp = cx.device;
// Configure clocks, peripherals...
(
Shared { sensor_value: 0 },
Local { led: led_pin },
)
}
#[task(binds = TIM2, shared = [sensor_value], priority = 2)]
fn sensor_read(mut cx: sensor_read::Context) {
// High priority: read sensor on timer interrupt
cx.shared.sensor_value.lock(|val| {
*val = read_adc();
});
}
#[task(shared = [sensor_value], local = [led], priority = 1)]
async fn process(cx: process::Context) {
// Lower priority: process data
let val = cx.shared.sensor_value.lock(|v| *v);
if val > 1000 {
cx.local.led.set_high();
}
}
}
When to Choose RTIC vs Embassy
The choice between RTIC and Embassy depends on your requirements:
Choose RTIC when you need hard real-time guarantees with bounded interrupt latency, your application maps naturally to interrupt-driven event handling, you want formal analysis of priority inversion and deadlock freedom, or you are porting from a traditional interrupt-driven C firmware design.
Choose Embassy when you have many concurrent I/O-bound tasks (networking, sensors, displays), you want the ergonomic benefits of async/await, your tasks spend most of their time waiting for external events, or you need the Embassy networking and USB stacks.
In practice, many projects in 2026 choose Embassy because the ergonomic benefits of async outweigh the theoretical advantages of priority-based scheduling for most applications. But RTIC remains the right choice for motor control, power conversion, and other timing-critical domains.
Hardware Abstraction Layer: embedded-hal 1.0
The embedded-hal crate defines a set of traits that abstract over common hardware interfaces: SPI, I2C, UART, GPIO, PWM, ADC, and delays. Version 1.0, which stabilized in early 2024, represents years of iteration and community feedback. In 2026, the embedded-hal 1.0 ecosystem is mature and widely adopted.
The Trait Architecture
The key insight of embedded-hal is that peripheral drivers (for sensors, displays, radio modules, etc.) should be written against abstract traits rather than concrete hardware. A BMP280 pressure sensor driver written against embedded_hal::i2c::I2c works on any microcontroller that implements the trait -- STM32, nRF52, ESP32, RP2040, or RISC-V chips.
The 1.0 API is cleaner than the pre-1.0 versions. Traits use associated error types and return Result for fallible operations:
use embedded_hal::i2c::I2c;
pub struct Bmp280<I2C> {
i2c: I2C,
address: u8,
}
impl<I2C: I2c> Bmp280<I2C> {
pub fn new(i2c: I2C, address: u8) -> Self {
Self { i2c, address }
}
pub fn read_temperature(&mut self) -> Result<f32, I2C::Error> {
let mut buf = [0u8; 3];
self.i2c.write_read(
self.address,
&[0xFA], // temperature register
&mut buf,
)?;
// Convert raw bytes to temperature
Ok(Self::compensate_temperature(buf))
}
}
This driver compiles for any platform. The HAL implementation for each chip provides the concrete I2c impl, and the driver never needs to know which chip it is running on.
embedded-hal-async
Alongside the blocking embedded-hal 1.0, the embedded-hal-async crate provides async versions of the same traits. This is what Embassy HAL crates implement, and it enables fully async driver development:
use embedded_hal_async::i2c::I2c;
impl<I2C: I2c> Bmp280<I2C> {
pub async fn read_temperature_async(&mut self) -> Result<f32, I2C::Error> {
let mut buf = [0u8; 3];
self.i2c.write_read(
self.address,
&[0xFA],
&mut buf,
).await?;
Ok(Self::compensate_temperature(buf))
}
}
The only difference is the .await on the I2C operation. Under the hood, on a chip with DMA-backed I2C, this releases the CPU while the I2C transaction completes via DMA. The driver code is almost identical, but the runtime behavior is radically more efficient.
embedded-hal-bus
A common challenge with embedded-hal is sharing a bus (like SPI) between multiple device drivers. The embedded-hal-bus crate solves this by providing bus-sharing adapters:
use embedded_hal_bus::spi::ExclusiveDevice; // spi_bus is the SPI peripheral // cs_display and cs_flash are chip-select pins let display = ExclusiveDevice::new(spi_bus, cs_display, delay); let flash = ExclusiveDevice::new(spi_bus, cs_flash, delay); // Now both drivers can use the SPI bus through their ExclusiveDevice wrapper
For async Embassy code, embassy-embedded-hal provides the SpiDevice trait and shared bus implementations that handle concurrent access safely.
embedded-hal 0.2
First widely-adopted version. Unproven trait design led to friction, but established the concept of portable drivers.
embedded-hal 1.0-alpha
Major redesign with associated error types, I2c trait unification, and SpiDevice/SpiBus split. Community migration begins.
embedded-hal 1.0 stable
Stable release. embedded-hal-async, embedded-hal-bus, and embedded-io reach 1.0. Driver ecosystem migrates to the new traits.
Ecosystem convergence
Major HALs (embassy-stm32, embassy-nrf, embassy-rp, esp-hal) fully implement 1.0 traits. Legacy 0.2 compatibility layers phased out.
Mature ecosystem
Over 500 portable driver crates on crates.io target embedded-hal 1.0. Async drivers become the default for new development.
Target Support: ARM, RISC-V, and Beyond
One of embedded Rust's strengths in 2026 is its broad target support. The Rust compiler, through LLVM, supports a wide range of embedded architectures, and the ecosystem provides HAL crates that make each target practical for real development.
ARM Cortex-M
ARM Cortex-M remains the most mature target for embedded Rust. The cortex-m crate provides low-level access to ARM-specific features (NVIC, SysTick, MPU), and cortex-m-rt provides the runtime (reset handler, vector table, memory initialization).
STM32 has the best coverage of any chip family. The embassy-stm32 crate supports essentially every STM32 variant -- from the tiny STM32G0 to the powerful STM32H7 -- with peripheral support including GPIO, UART, SPI, I2C, USB, CAN, Ethernet, DMA, ADC, DAC, timers, and RTC. The stm32-metapac provides auto-generated register definitions from STMicro's SVD files, ensuring completeness.
Nordic nRF series (nRF52832, nRF52840, nRF5340) are supported through embassy-nrf. The nRF52840 is particularly popular for Rust projects because it has generous RAM (256 KB), USB, and BLE. The nrf-softdevice crate provides Bluetooth Low Energy support by interfacing with Nordic's precompiled SoftDevice binary, giving you production-grade BLE while writing application logic in Rust.
RP2040 and RP2350 from Raspberry Pi are the darlings of the hobbyist embedded Rust community. The embassy-rp crate provides full support for both chips. The RP2040 (dual Cortex-M0+) is popular because of the $4 Raspberry Pi Pico board, and the RP2350 (dual Cortex-M33 or dual RISC-V Hazard3 cores, with ARM TrustZone) adds security features and more processing power. The RP2350's dual-architecture design is particularly interesting -- you can run Rust on either the ARM or RISC-V cores.
RISC-V
RISC-V support in embedded Rust has matured significantly. The riscv and riscv-rt crates provide the foundation (similar to cortex-m and cortex-m-rt for ARM), and several chip families now have solid Rust support:
ESP32-C3, ESP32-C6, and ESP32-H2 from Espressif use RISC-V cores and are supported through the esp-hal crate. The ESP32-C6 is particularly noteworthy because it includes WiFi 6, Bluetooth 5.3, and 802.15.4 (Thread/Zigbee) in a single chip. Espressif has invested heavily in Rust support, maintaining both esp-hal (bare-metal no_std) and esp-idf-hal (full std on FreeRTOS).
GD32VF103 is a RISC-V alternative to the STM32F103, with community HAL support.
BL602 and BL616 from Bouffalo Lab are low-cost WiFi/BLE RISC-V chips with growing Rust support.
ESP32 (Xtensa)
The original ESP32 and ESP32-S2/S3 use Xtensa cores, which required a custom Rust compiler fork (esp-rs/rust) because upstream LLVM did not support Xtensa. In 2026, the situation has improved substantially -- Espressif's Xtensa LLVM backend has matured, and the espup tool makes installing the ESP Rust toolchain straightforward. The esp-hal crate provides a unified HAL across both Xtensa and RISC-V ESP32 variants.
| target | driverCrates |
|---|---|
| STM32 (Cortex-M) | 185 |
| nRF (Cortex-M) | 95 |
| RP2040/2350 | 72 |
| ESP32 (all) | 68 |
| RISC-V (other) | 35 |
Build System and Toolchain
The Rust embedded build system in 2026 is mature and well-documented, but it does require understanding several concepts that desktop Rust developers may not encounter.
Cross-Compilation Setup
Embedded Rust uses Cargo's built-in cross-compilation support. You install the target triple for your chip and Cargo handles the rest:
# Add target for ARM Cortex-M4F (STM32F4, nRF52, etc.) rustup target add thumbv7em-none-eabihf # Add target for ARM Cortex-M0+ (RP2040, STM32G0) rustup target add thumbv6m-none-eabi # Add target for ARM Cortex-M33 (RP2350, STM32U5) rustup target add thumbv8m.main-none-eabihf # Add target for RISC-V 32-bit (ESP32-C3, GD32VF103) rustup target add riscv32imc-unknown-none-elf
A .cargo/config.toml file in your project sets the default target and linker flags:
[build] target = "thumbv7em-none-eabihf" [target.thumbv7em-none-eabihf] runner = "probe-rs run --chip STM32F411CEUx" rustflags = ["-C", "link-arg=-Tlink.x"]
The runner line tells Cargo to flash the binary to the chip when you run cargo run, making the development loop as simple as cargo run --release.
Linker Scripts and memory.x
Every embedded Rust project needs a memory.x file that describes the chip's memory layout. The linker uses this to place code in flash and data in RAM:
MEMORY
{
FLASH : ORIGIN = 0x08000000, LENGTH = 512K
RAM : ORIGIN = 0x20000000, LENGTH = 128K
}
The cortex-m-rt (or riscv-rt) crate provides a base linker script (link.x) that uses the regions defined in memory.x to set up the correct memory sections. For most projects, this is the only chip-specific configuration you need.
defmt: Deferred Formatting
defmt is the de facto logging framework for embedded Rust, and understanding it is essential. Unlike log or println!, defmt does not format strings on the target. Instead, it sends format string indices and raw argument bytes to the host, where the formatting happens. This is dramatically more efficient in both code size and execution time.
use defmt::*;
fn process_reading(value: u16) {
// This compiles to sending ~3 bytes, not a formatted string
info!("Sensor value: {}", value);
if value > 4000 {
warn!("Sensor value {} exceeds threshold", value);
}
// defmt supports complex formatting
debug!("Buffer contents: {:x}", &buffer[..16]);
// Structured data
#[derive(defmt::Format)]
struct SensorData {
temperature: i16,
humidity: u16,
}
let data = SensorData { temperature: 225, humidity: 650 };
info!("Reading: {:?}", data);
}
A typical info! log statement adds only 2-4 bytes of code size compared to hundreds of bytes for core::fmt-based formatting. On a Cortex-M0 with 64 KB flash, this difference is critical. The defmt-rtt crate transports log data over RTT (Real-Time Transfer), which uses a shared-memory ring buffer accessible through the debug probe.
Cargo Features and Conditional Compilation
Embedded Rust projects make heavy use of Cargo features for conditional compilation. HAL crates use features to select the specific chip variant:
[dependencies]
embassy-stm32 = { version = "0.2", features = [
"stm32f411ce", # specific chip
"time-driver-any", # embassy time driver
"memory-x", # auto-generate memory.x
"exti", # external interrupts
] }
This fine-grained feature system ensures that only the code and register definitions for your specific chip are compiled, keeping binary size minimal.
probe-rs: The Debugging Ecosystem
Debugging embedded systems has traditionally been one of the most painful parts of firmware development. The probe-rs project has transformed this experience for Rust developers, providing a unified, Rust-native debugging and flashing toolchain.
What probe-rs Provides
probe-rs is a debugging toolkit that replaces the traditional OpenOCD + GDB combination with a modern, integrated solution. It supports all major debug probes (ST-Link, J-Link, CMSIS-DAP, Raspberry Pi Debug Probe) and all major chip families out of the box.
The key tools in the probe-rs ecosystem:
probe-rs run flashes your firmware and starts execution with RTT logging. When configured as the Cargo runner, cargo run compiles, flashes, and shows logs in one command.
cargo-embed is a more feature-rich runner that provides a configuration file (Embed.toml) for controlling flash algorithms, RTT channels, GDB server settings, and reset behavior.
probe-rs VS Code extension provides full graphical debugging with breakpoints, variable inspection, call stacks, peripheral register views, and integrated RTT log output. This is the recommended development environment for most teams.
RTT (Real-Time Transfer) Logging
RTT is the preferred logging transport for embedded Rust. It works by sharing a ring buffer in the target's RAM that the debug probe reads through the debug interface (SWD or JTAG). Unlike UART-based logging, RTT requires no additional pins, works at millions of bytes per second, and has minimal impact on real-time behavior because writes are non-blocking memory operations.
Combined with defmt, RTT provides a logging experience that approaches what desktop developers expect from println! debugging, but with negligible performance overhead on the target.
# Flash and run with RTT log output
$ cargo run --release
Compiling my-firmware v0.1.0
Flashing [=============================] 100%
Finished in 1.2s
0.000000 INFO Boot complete
0.001234 INFO Sensor value: 2048
0.501234 INFO Sensor value: 2051
1.001234 WARN Sensor value 4200 exceeds threshold
VS Code Integration
The probe-rs VS Code extension provides a launch.json configuration that connects all the pieces:
{
"type": "probe-rs-debug",
"request": "launch",
"name": "Debug Firmware",
"chip": "STM32F411CEUx",
"coreConfigs": [
{
"programBinary": "target/thumbv7em-none-eabihf/debug/my-firmware",
"rttEnabled": true,
"svdFile": "STM32F411.svd"
}
]
}
With this setup, pressing F5 compiles, flashes, starts the debugger, and opens the RTT log panel. You can set breakpoints, step through code, inspect variables, and view peripheral registers -- all from VS Code. The SVD file provides human-readable names for all peripheral registers, so you can inspect the UART baud rate register as USART1.BRR rather than a raw address.
Testing Embedded Rust
Testing firmware is one of the most challenging aspects of embedded development. The embedded Rust ecosystem in 2026 provides several strategies, from pure unit tests to on-hardware integration tests.
Host-Based Unit Testing
The simplest testing strategy is to structure your code so that business logic can be tested on the host machine using standard cargo test. This means separating hardware-dependent code from pure logic:
// src/protocol.rs -- pure logic, testable on host
#![cfg_attr(not(test), no_std)]
pub fn parse_sensor_frame(data: &[u8]) -> Option<SensorReading> {
if data.len() < 6 { return None; }
let temp = i16::from_be_bytes([data[0], data[1]]);
let humidity = u16::from_be_bytes([data[2], data[3]]);
let crc = u16::from_be_bytes([data[4], data[5]]);
if crc != calculate_crc(&data[..4]) { return None; }
Some(SensorReading { temp, humidity })
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_parse_valid_frame() {
let data = [0x00, 0xE1, 0x02, 0x8A, 0xAB, 0xCD];
let reading = parse_sensor_frame(&data).unwrap();
assert_eq!(reading.temp, 225);
assert_eq!(reading.humidity, 650);
}
#[test]
fn test_parse_short_frame() {
assert!(parse_sensor_frame(&[0x00, 0x01]).is_none());
}
}
This runs instantly with cargo test on your development machine. No hardware needed.
defmt-test: On-Target Testing
For tests that need to run on actual hardware, defmt-test provides a test harness that runs on the microcontroller and reports results over RTT:
#![no_std]
#![no_main]
use defmt_test as _;
#[defmt_test::tests]
mod tests {
use defmt::assert_eq;
#[test]
fn test_gpio_output() {
// This runs on the actual microcontroller
let p = embassy_stm32::init(Default::default());
let mut pin = Output::new(p.PA5, Level::Low, Speed::Low);
pin.set_high();
assert!(pin.is_set_high());
}
#[test]
fn test_adc_reading() {
// Read actual ADC value from hardware
let reading = adc.read(&mut channel);
assert!(reading > 0);
assert!(reading < 4096);
}
}
Running cargo test --target thumbv7em-none-eabihf compiles the test binary, flashes it to the chip, and reports pass/fail results through RTT. This is invaluable for testing peripheral configurations, timing, and hardware-specific behavior.
Hardware-in-the-Loop (HIL) Testing
For production firmware, Hardware-in-the-Loop testing automates the process of flashing firmware to real hardware and verifying behavior. A typical HIL setup includes:
- A CI runner connected to target hardware via debug probes
- Test fixtures that can stimulate inputs (voltage, signals, network traffic)
- probe-rs for flashing and monitoring
- A host-side test harness that validates RTT output and GPIO states
The Rust Embedded Working Group has published guidelines for setting up HIL testing, and several companies use GitHub Actions self-hosted runners with custom hardware fixtures for their CI pipelines.
Driver Development Patterns
Writing portable peripheral drivers is one of the most common tasks in embedded Rust. The community has established patterns that make drivers reusable, testable, and efficient.
The Generic Driver Pattern
A well-structured driver is generic over the bus trait and uses the builder pattern for configuration:
use embedded_hal::i2c::I2c;
pub struct Lis3dh<I2C> {
i2c: I2C,
address: u8,
scale: AccelScale,
}
pub enum AccelScale {
G2,
G4,
G8,
G16,
}
impl<I2C: I2c> Lis3dh<I2C> {
pub fn new(i2c: I2C, address: u8) -> Self {
Self {
i2c,
address,
scale: AccelScale::G2,
}
}
pub fn set_scale(&mut self, scale: AccelScale) -> Result<(), I2C::Error> {
let reg_val = match scale {
AccelScale::G2 => 0x00,
AccelScale::G4 => 0x10,
AccelScale::G8 => 0x20,
AccelScale::G16 => 0x30,
};
self.i2c.write(self.address, &[0x23, reg_val])?;
self.scale = scale;
Ok(())
}
pub fn read_accel(&mut self) -> Result<(f32, f32, f32), I2C::Error> {
let mut buf = [0u8; 6];
// Read 6 bytes starting from OUT_X_L with auto-increment
self.i2c.write_read(
self.address,
&[0x28 | 0x80],
&mut buf,
)?;
let raw_x = i16::from_le_bytes([buf[0], buf[1]]);
let raw_y = i16::from_le_bytes([buf[2], buf[3]]);
let raw_z = i16::from_le_bytes([buf[4], buf[5]]);
let scale_factor = match self.scale {
AccelScale::G2 => 0.001,
AccelScale::G4 => 0.002,
AccelScale::G8 => 0.004,
AccelScale::G16 => 0.012,
};
Ok((
raw_x as f32 * scale_factor,
raw_y as f32 * scale_factor,
raw_z as f32 * scale_factor,
))
}
}
Async Driver Pattern
The same driver can support both blocking and async by using a feature flag or a separate async implementation:
#[cfg(feature = "async")]
use embedded_hal_async::i2c::I2c as AsyncI2c;
#[cfg(feature = "async")]
impl<I2C: AsyncI2c> Lis3dh<I2C> {
pub async fn read_accel_async(&mut self) -> Result<(f32, f32, f32), I2C::Error> {
let mut buf = [0u8; 6];
self.i2c.write_read(
self.address,
&[0x28 | 0x80],
&mut buf,
).await?;
// ... same conversion logic
}
}
Many driver crates in 2026 use the maybe-async or embedded-hal-compat crates to reduce duplication between blocking and async implementations, though the community has not settled on a single approach.
DMA-Aware Drivers
For high-throughput peripherals (display controllers, ADC streaming, audio codecs), drivers need to leverage DMA. The pattern is to accept a DMA-capable transfer type and use it for bulk operations:
use embassy_stm32::spi::Spi;
pub struct Display<SPI> {
spi: SPI,
// ...
}
impl<SPI: embedded_hal_async::spi::SpiDevice> Display<SPI> {
pub async fn write_framebuffer(&mut self, data: &[u8]) -> Result<(), SPI::Error> {
self.set_data_mode().await?;
// This transfers the entire framebuffer via DMA
// CPU is free while the transfer happens
self.spi.write(data).await?;
Ok(())
}
}
Memory Management Without an Allocator
One of the most significant differences between embedded Rust and desktop Rust is the absence of a heap allocator. Most bare-metal firmware avoids heap allocation entirely because dynamic allocation introduces fragmentation, makes worst-case memory analysis impossible, and can fail at runtime. The embedded Rust ecosystem provides powerful tools for working without a heap.
Heapless Collections
The heapless crate is the backbone of no-heap embedded Rust. It provides fixed-capacity versions of standard library collections that live on the stack or in static memory:
use heapless::{Vec, String, FnvIndexMap};
// Fixed-capacity Vec -- max 32 elements, no heap
let mut readings: Vec<u16, 32> = Vec::new();
readings.push(1024).unwrap(); // Returns Err if full
readings.push(2048).unwrap();
// Fixed-capacity String -- max 64 bytes
let mut msg: String<64> = String::new();
core::fmt::write(&mut msg, format_args!("Temp: {}C", 22)).unwrap();
// Fixed-capacity HashMap -- max 16 entries
let mut config: FnvIndexMap<&str, u32, 16> = FnvIndexMap::new();
config.insert("baud_rate", 115200).unwrap();
config.insert("timeout_ms", 1000).unwrap();
heapless also provides lock-free data structures for sharing between interrupt handlers and main code:
use heapless::spsc::Queue;
// Single-producer, single-consumer lock-free queue
static mut Q: Queue<u16, 32> = Queue::new();
// In interrupt handler (producer):
fn adc_interrupt() {
let (mut producer, _) = unsafe { Q.split() };
producer.enqueue(adc_value).ok();
}
// In main loop (consumer):
fn process_readings() {
let (_, mut consumer) = unsafe { Q.split() };
while let Some(value) = consumer.dequeue() {
// process value
}
}
Static Allocation Patterns
For data that needs to live for the entire program lifetime, static allocation is the standard approach. The static_cell crate provides safe one-time initialization of static variables:
use static_cell::StaticCell;
static UART_BUF: StaticCell<[u8; 1024]> = StaticCell::new();
fn main() {
let buf = UART_BUF.init([0u8; 1024]);
// buf is &'static mut [u8; 1024], guaranteed initialized exactly once
}
Embassy uses this pattern extensively. Task state, peripheral instances, and DMA buffers are all statically allocated, ensuring that memory usage is determined at compile time.
Stack-Based Buffers
For temporary data, stack allocation is the simplest approach:
fn process_packet(uart: &mut Uart) -> Result<(), Error> {
// 512 bytes on the stack -- fine for most embedded targets
let mut buf = [0u8; 512];
let n = uart.read(&mut buf)?;
// Process in-place, no allocation needed
let parsed = parse_packet(&buf[..n])?;
handle_command(parsed);
Ok(())
// buf is dropped here, stack space is reclaimed
}
The key discipline is knowing your stack size. The flip-link tool (developed by Knurling, the team behind defmt and probe-rs) inverts the memory layout so that stack overflows cause a hard fault instead of silently corrupting data. This is essential for catching stack overflow bugs during development.
Embedded Rust vs C: A Technical Comparison
The Rust-vs-C debate in embedded development is nuanced. Both languages compile to efficient native code, and both can produce firmware that meets hard real-time requirements. The differences lie in the development experience, safety guarantees, and ecosystem.
Code Size
A common concern is that Rust produces larger binaries than C. In practice, the difference depends heavily on what you are doing:
Minimal blink program: A LED blinker on Cortex-M4 compiles to approximately 1-2 KB in both Rust and C when optimized. Rust's core library adds minimal overhead because unused code is eliminated by the linker.
Medium complexity firmware (UART, SPI, I2C, timers): Rust binaries are typically 5-15% larger than equivalent C code, primarily because of monomorphization (generic code is compiled separately for each concrete type). Using opt-level = "z" (optimize for size) and lto = true (link-time optimization) closes much of this gap.
Complex firmware with heavy use of Rust's type system: The overhead can grow if you use many generic types, closures, or formatting. But defmt specifically addresses the formatting size problem, and experienced embedded Rust developers learn which patterns to avoid in size-constrained environments.
Performance Benchmarks
In terms of raw execution speed, Rust and C produce comparable results. The LLVM backend used by both rustc and clang generates similar machine code for equivalent algorithms. Where Rust can actually outperform C is in high-level optimizations:
- Aliasing information: Rust's ownership model gives LLVM more aliasing information than C, enabling optimizations that are unsafe to perform in C (because the compiler cannot prove pointers do not alias)
- Bounds check elimination: While Rust inserts bounds checks on array access, LLVM eliminates most of them when it can prove they are redundant. Iterator patterns (for x in slice.iter()) generate zero bounds checks
- Zero-cost abstractions: Higher-level Rust patterns (iterators, closures, trait objects with devirtualization) compile to the same machine code as hand-written C loops
Compile-Time Checks
This is where Rust's value proposition is strongest. The following classes of bugs are impossible in safe Rust code:
- Buffer overflows: Array bounds are checked; slices carry their length
- Use-after-free: The ownership system prevents accessing freed memory
- Data races: Mutable references are exclusive; shared data requires synchronization
- Null pointer dereference: Rust has no null; Option<T> is used instead
- Uninitialized memory: Variables must be initialized before use
- Integer overflow: Checked in debug mode, configurable in release
In embedded C, these bugs are the primary source of security vulnerabilities and field failures. The cost of finding and fixing a buffer overflow in deployed firmware -- through recalls, OTA updates, or safety incidents -- dwarfs any productivity cost of Rust's stricter compiler.
Comparison
Embedded C
Embedded Rust
Real-Time Constraints
Embedded systems often have hard real-time requirements where missing a deadline causes a system failure. Rust's suitability for real-time work depends on understanding what the language does and does not guarantee.
Interrupt Handling
ARM Cortex-M interrupt handling in Rust is straightforward and has the same latency characteristics as C. The cortex-m-rt crate generates the vector table, and interrupt handlers are regular Rust functions:
use cortex_m_rt::exception;
use stm32f4xx_hal::pac::interrupt;
#[interrupt]
fn TIM2() {
// Hardware timer interrupt handler
// Latency is identical to a C ISR -- the compiler generates
// the same prologue/epilogue
static mut COUNTER: u32 = 0;
*COUNTER += 1;
}
RTIC's interrupt-based task dispatch adds zero overhead compared to hand-written interrupt handlers because the framework generates the dispatch code at compile time.
Embassy's interrupt handling is slightly different. When an interrupt fires, the Embassy HAL's interrupt handler wakes the appropriate async task. The actual processing happens when the executor runs the task, which adds a small (typically under 1 microsecond on Cortex-M4 at 168 MHz) latency compared to processing directly in the ISR. For most applications, this is negligible, but for sub-microsecond timing requirements, direct interrupt handlers or RTIC are better choices.
Timing Guarantees
Rust's lack of a garbage collector is a significant advantage for real-time systems. There are no unpredictable GC pauses. The language guarantees deterministic execution timing for any given code path, with the same caveats as C (cache effects, pipeline stalls, flash wait states).
The main area where Rust requires caution is panic handling. By default, a panic in Rust unwinds the stack, which is non-deterministic in timing. Embedded Rust projects universally set panic = "abort" in their Cargo.toml to eliminate unwinding. With panic-probe, a panic immediately halts the CPU and reports the panic location over the debug probe, which is the correct behavior for development. In production, panic-halt simply halts the CPU, and a watchdog timer handles recovery.
[profile.release] panic = "abort" opt-level = "s" # optimize for size lto = true # link-time optimization codegen-units = 1 # better optimization debug = true # keep debug info for defmt
Bare Metal vs RTOS
Embedded Rust in 2026 offers three main runtime models:
Bare metal with Embassy is the most common choice. The async executor provides cooperative multitasking without an OS. This gives you deterministic behavior (tasks only switch at .await points), minimal overhead, and the ergonomics of concurrent programming.
Bare metal with RTIC provides preemptive priority-based scheduling without an OS. Use this when you need hard real-time guarantees and your tasks have clearly defined priorities.
RTOS integration is available through several paths. The RIOT-rs project builds on Embassy to provide a Rust-native RTOS. For existing RTOS integration, FreeRTOS-rust provides Rust bindings to FreeRTOS, and ESP-IDF projects run on FreeRTOS natively. Zephyr RTOS has experimental Rust module support.
Connectivity
Modern embedded devices are increasingly connected, and the Rust ecosystem provides networking stacks that range from bare TCP/IP to full BLE implementations.
Embassy-net: Async TCP/IP
embassy-net provides a full TCP/IP stack built on smoltcp, with async socket APIs that integrate naturally with Embassy tasks:
use embassy_net::{tcp::TcpSocket, Stack, StackResources};
#[embassy_executor::task]
async fn net_task(stack: &'static Stack<WifiDevice>) {
let mut rx_buffer = [0; 4096];
let mut tx_buffer = [0; 4096];
let mut socket = TcpSocket::new(stack, &mut rx_buffer, &mut tx_buffer);
socket.connect(("192.168.1.100", 8080)).await.unwrap();
socket.write_all(b"Hello from Rust embedded!").await.unwrap();
let mut buf = [0; 1024];
let n = socket.read(&mut buf).await.unwrap();
defmt::info!("Received: {:a}", &buf[..n]);
}
embassy-net supports Ethernet (STM32 ETH, ENC28J60, W5500), WiFi (ESP32, CYW43 on Pico W, ESP-hosted for other chips), and 6LoWPAN for IEEE 802.15.4 radios. DHCP and DNS are built in.
embedded-nal: Network Abstraction Layer
The embedded-nal (Network Abstraction Layer) crate provides traits for TCP and UDP sockets, similar to how embedded-hal abstracts hardware peripherals. Drivers written against embedded-nal traits work with any networking stack:
use embedded_nal_async::{TcpConnect, SocketAddr};
async fn send_data<T: TcpConnect>(stack: &T, data: &[u8]) -> Result<(), T::Error> {
let addr = SocketAddr::new("192.168.1.100".parse().unwrap(), 8080);
let mut conn = stack.connect(addr).await?;
conn.write_all(data).await?;
Ok(())
}
Bluetooth Low Energy
BLE support in embedded Rust varies by chip:
nRF52/nRF53: The nrf-softdevice crate provides comprehensive BLE support through Nordic's SoftDevice binary. It supports GAP, GATT server and client, advertising, scanning, and connections. The async API integrates cleanly with Embassy:
#[embassy_executor::task]
async fn ble_task(sd: &'static Softdevice) {
let server = Server::new(sd).unwrap();
loop {
let conn = peripheral::advertise_connectable(
sd,
peripheral::ConnectableAdvertisement::ScannableUndirected {
adv_data: &adv_data,
scan_data: &scan_data,
},
&Default::default(),
).await.unwrap();
// Handle connection
gatt_server::run(&conn, &server, |event| {
// Process GATT events
}).await;
}
}
ESP32: BLE is available through esp-idf-svc in std mode, or through the esp-wifi crate for no_std bare-metal use.
Generic BLE: The trouble crate is an emerging pure-Rust BLE host stack that aims to work on any chip with an HCI-compatible BLE controller. It is still maturing but represents the future direction of chip-agnostic BLE in Rust.
Other Connectivity
- LoRa: The lora-phy crate provides drivers for SX126x and SX127x LoRa transceivers, with the lorawan-device crate implementing the LoRaWAN protocol stack
- CAN bus: Embassy includes CAN (FDCAN) support for STM32, and the socketcan crate works on Linux-based embedded systems
- USB: embassy-usb provides device-side USB with CDC-ACM, HID, and mass storage class support
- MQTT: The rust-mqtt crate provides an async no_std MQTT client that works with embassy-net
Practical Development Workflow
Bringing all these pieces together, here is what a typical embedded Rust development workflow looks like in 2026.
Project Setup
Start a new project with cargo init --name my-firmware, add a .cargo/config.toml for your target and runner, create a memory.x for your chip, and add your dependencies. For Embassy projects, the Embassy project provides template repositories for each supported chip family that include all the boilerplate.
Development Cycle
The inner development loop is:
- Write code in your editor (VS Code with rust-analyzer is the most popular choice)
- Run cargo run --release to compile, flash, and see RTT logs
- If something crashes, set a breakpoint in VS Code and run the debugger
- For complex issues, use probe-rs to inspect peripheral registers
- Run cargo test for host-based unit tests, cargo test --target ... for on-target tests
The turnaround time from code change to running on hardware is typically 5-15 seconds (compile + flash), which is comparable to or faster than C development with vendor IDEs.
Release Builds
For production firmware, the Cargo.toml profile settings matter:
[profile.release] opt-level = "s" # optimize for size (or "z" for minimum size) lto = true # link-time optimization across all crates codegen-units = 1 # slower compile, better optimization debug = 2 # keep debug info (stripped from binary) overflow-checks = false # disable integer overflow checks panic = "abort" # no unwinding strip = "symbols" # strip symbols from final binary [profile.release.package."*"] opt-level = "s" # also optimize dependencies for size
Use cargo size -- -A (from cargo-binutils) to analyze your binary's memory usage by section. This shows exactly how much flash and RAM your firmware uses, broken down by .text (code), .rodata (constants), .data (initialized globals), and .bss (zero-initialized globals).
The Road Ahead
The embedded Rust ecosystem continues to evolve rapidly. Several developments are worth watching in 2026 and beyond:
Stable async in traits has landed in Rust stable, which enables embedded-hal-async traits without requiring nightly. This was one of the last major blockers for production adoption.
Formal verification tools like Kani and Creusot are being applied to embedded Rust code, allowing mathematical proofs of correctness for safety-critical firmware. This is particularly relevant for automotive and medical device firmware.
The esp-hal unification by Espressif has brought all ESP32 variants (Xtensa and RISC-V) under a single HAL crate, simplifying the development experience and making it easier to port between ESP32 variants.
RISC-V growth continues as more RISC-V microcontrollers reach the market. The riscv and riscv-rt crates are stable, and the ecosystem is approaching ARM Cortex-M parity for developer experience.
Improved compile times through incremental compilation improvements and the parallel frontend work in rustc are addressing one of the most common complaints from developers coming from C.
Companies like Ferrous Systems continue to advance the commercial embedded Rust ecosystem through training, consulting, and their Ferrocene qualified Rust compiler for safety-critical systems (ISO 26262, IEC 61508). The Rust Embedded Working Group coordinates open-source development across the ecosystem, maintaining core crates and publishing best-practice guidelines.
Getting Started Today
If you are new to embedded Rust, here is the recommended path in 2026:
-
Get hardware: A Raspberry Pi Pico ($4) or STM32 Nucleo board ($12-15) with a built-in ST-Link debugger is the easiest starting point. The Pico has excellent Embassy support, and STM32 Nucleo boards have the most comprehensive documentation.
-
Install tooling: rustup for the compiler, probe-rs for debugging (cargo install probe-rs-tools), and the probe-rs VS Code extension for graphical debugging.
-
Start with Embassy examples: Clone the Embassy repository and run the examples for your chip. Each example is self-contained and demonstrates a specific peripheral or pattern.
-
Read "The Embedded Rust Book": Published by the Rust Embedded Working Group, it covers the fundamentals of no_std development, memory-mapped I/O, and the build system.
-
Join the community: The Embedded Rust Matrix chat room and the Rust Embedded Working Group forums are active and welcoming to newcomers.
Conclusion
Embedded Rust in 2026 is not a promise -- it is a practical reality. The no_std ecosystem provides everything you need for bare-metal development without a heap allocator. Embassy brings async concurrency to microcontrollers with zero overhead. RTIC delivers hard real-time guarantees with compile-time verification. The embedded-hal 1.0 traits enable a portable driver ecosystem that works across chip families. And probe-rs provides a debugging experience that rivals or exceeds what proprietary vendor tools offer.
The learning curve is real -- understanding ownership, lifetimes, and the no_std constraints takes effort. But the payoff is firmware that is safer by construction, where entire classes of memory bugs are eliminated at compile time rather than discovered in the field. For new embedded projects in 2026, Rust is not just a viable choice -- it is increasingly the right one.

