TL;DR:

  • Embassy brings async/await to embedded Rust — you can write non-blocking firmware for microcontrollers using the same patterns as async server code
  • It runs on bare metal with no operating system, using a cooperative task scheduler that fits in a few kilobytes of RAM
  • Supported targets include STM32, nRF52, RP2040 (Raspberry Pi Pico), ESP32, and others — making it practical for real IoT and edge deployments

Writing firmware for microcontrollers has traditionally meant either blocking loops that waste CPU cycles waiting for I/O, or complex interrupt-driven state machines that are difficult to read and even harder to test. Rust’s async/await syntax offers a third option — and Embassy is the framework that makes async programming work on bare-metal embedded hardware, with no operating system, no heap allocator, and resource usage that fits on the smallest practical Cortex-M devices.

Why Async on Embedded?

Consider a typical IoT device: it reads a sensor, waits for the reading to complete, sends data over UART or SPI, waits for the peripheral to be ready, then perhaps sleeps for a period before repeating. In a blocking implementation, “waiting” means spinning in a busy loop or blocking the entire processor — burning power and preventing other work from happening.

Interrupt-driven approaches solve the CPU waste problem but introduce complexity: state machines with flags set in interrupt handlers, shared mutable state between interrupt context and main context, and notoriously difficult-to-debug timing issues.

Embassy’s async executor lets you write this instead:

#[embassy_executor::task]
async fn sensor_task(mut sensor: Sensor, mut uart: UartTx) {
    loop {
        // Non-blocking wait — yields to other tasks while waiting
        let reading = sensor.read().await;
        
        // Non-blocking UART transmission
        let data = format_reading(reading);
        uart.write(data.as_bytes()).await;
        
        // Non-blocking sleep — CPU can sleep or run other tasks
        Timer::after_secs(30).await;
    }
}

The .await points are where the task yields. While this task is waiting for the sensor reading or the UART transfer, Embassy’s executor can run other tasks, or put the CPU into a low-power sleep state — significantly reducing power consumption.

How the Executor Works

Embassy’s executor is a cooperative, single-threaded scheduler with no heap allocation. Tasks are defined at compile time using #[embassy_executor::task] and are represented as state machines that the compiler generates from the async function body — a technique called zero-cost abstraction because there’s no runtime overhead for the async machinery.

The executor runs on bare metal. There’s no OS, no threads, and no dynamic memory allocation required (Embassy supports #[no_std] environments). The total RAM footprint for a basic Embassy executor with a few tasks is typically 2–4 KB — feasible on Cortex-M0 devices with 8 KB of RAM.

For multi-core microcontrollers (like the RP2040 in the Raspberry Pi Pico), Embassy provides an executor per core, with message-passing channels for inter-core communication.

Getting Started: Raspberry Pi Pico

The RP2040 (Raspberry Pi Pico and Pico 2) is one of the best-supported Embassy targets and an accessible starting point.

Setup:

# Install target
rustup target add thumbv6m-none-eabi   # Pico (Cortex-M0+)
rustup target add thumbv8m.main-none-eabihf  # Pico 2 (Cortex-M33)

# Install probe-rs for flashing
cargo install probe-rs-tools --locked

Cargo.toml:

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

Blink + UART example:

#![no_std]
#![no_main]

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

#[embassy_executor::main]
async fn main(spawner: Spawner) {
    let p = embassy_rp::init(Default::default());
    
    // LED blink task
    spawner.spawn(blink(p.PIN_25)).unwrap();
    
    // UART task
    let uart = Uart::new_blocking(p.UART0, p.PIN_0, p.PIN_1, Config::default());
    spawner.spawn(uart_task(uart)).unwrap();
}

#[embassy_executor::task]
async fn blink(pin: embassy_rp::peripherals::PIN_25) {
    let mut led = Output::new(pin, Level::Low);
    loop {
        led.set_high();
        Timer::after_millis(500).await;
        led.set_low();
        Timer::after_millis(500).await;
    }
}

#[embassy_executor::task]
async fn uart_task(mut uart: impl embedded_io_async::Write) {
    let mut counter: u32 = 0;
    loop {
        let msg = format!("Count: {}\r\n", counter);
        uart.write_all(msg.as_bytes()).await.unwrap();
        counter += 1;
        Timer::after_secs(1).await;
    }
}

Both tasks run concurrently — the LED blinks at 1 Hz while UART messages are sent every second. The executor handles the scheduling with no OS required.

Networking on Embassy

For IoT edge devices that need network connectivity, Embassy provides drivers for common hardware:

Wi-Fi (CYW43 on Pico W):

use embassy_rp::pio::Pio;
use cyw43_pio::PioSpi;
use embassy_net::{Config, Stack};

// Connect to Wi-Fi
let fw = include_bytes!("../cyw43-firmware/43439A0.bin");
let clm = include_bytes!("../cyw43-firmware/43439A0_clm.bin");
let (net_device, mut control, runner) = cyw43::new(state, pwr, spi, fw).await;

// HTTP client over Embassy-net
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((server_ip, 80)).await.unwrap();

nRF52840 Bluetooth (common in industrial IoT): Embassy provides BLE support via embassy-nrf with the SoftDevice or nrf-softdevice crate, enabling BLE peripheral and central roles on the nRF52 family.

Power Management

Embassy’s timer integration works with hardware sleep modes. On most Cortex-M devices, when all tasks are waiting, the executor can call WFI (Wait For Interrupt) to put the CPU in a low-power state until the next timer event or peripheral interrupt wakes it up:

// embassy-executor with threading feature will call WFI when idle
// CPU wakes only when a timer fires or peripheral interrupt arrives
Timer::after_secs(300).await;  // Deep sleep for 5 minutes effectively

This makes Embassy practical for battery-powered sensors — a common edge/IoT use case where power consumption is a primary constraint.

Choosing Embassy vs RTOS (FreeRTOS, Zephyr)

EmbassyFreeRTOS / Zephyr
LanguageRustC/C++
SchedulingCooperative asyncPreemptive threads
MemoryNo heap requiredThread stacks required
SafetyMemory-safe by defaultManual memory management
EcosystemGrowing rapidlyMature, wide hardware support
Learning curveRust + async conceptsC + RTOS concepts

Embassy is the better choice if you’re writing new firmware in Rust and want memory safety guarantees. Zephyr or FreeRTOS remain stronger for projects needing maximum hardware support, existing C/C++ codebases, or RTOS-specific features like priority inheritance.

The Embassy project is at embassy.dev with extensive examples for each supported microcontroller family.