How to Use Embassy for Async Embedded Rust

Embassy enables async programming on embedded devices by providing a runtime to execute async functions without an OS.

When one core needs to do everything

You are holding a microcontroller. You want to blink an LED, read a temperature sensor, and stream data over USB. In a desktop app, you would spawn three threads and let the operating system handle the rest. In embedded Rust, you have one core, no operating system, and a stack the size of a postage stamp. If you write a loop that blocks while waiting for the sensor, the LED stops blinking. The USB stream freezes. The entire chip sits idle, wasting power and missing deadlines.

You need concurrency without the overhead of a full OS. You need to switch between tasks efficiently, only when a task is waiting for hardware. That is what Embassy provides. Embassy is an async runtime built for bare-metal Rust. It brings async/await to microcontrollers, giving you the productivity of high-level concurrency patterns while keeping the control and efficiency required for embedded systems.

Async on bare metal

Async programming is cooperative multitasking. The CPU runs one task until that task hits an await point. At that point, the task yields control back to the executor. The executor picks up the next ready task and runs it. This cycle repeats. No context switches. No kernel overhead. Just a tight loop polling a list of futures.

Think of a single chef in a kitchen with one stove. The chef puts a pot of water on to boil. Instead of staring at the pot, the chef switches to chopping vegetables. Every few seconds, the chef glances at the pot. When the water boils, the chef adds pasta. The chef never does two things at once, but the workflow feels concurrent. Embassy is the chef. It tracks which tasks are waiting for timers, interrupts, or I/O, and it schedules them so the CPU is always doing useful work.

The key difference from desktop async is the environment. Desktop runtimes like Tokio rely on the OS for timers, file descriptors, and thread pools. Embassy runs on bare metal. It drives the hardware directly. It manages its own timers using the chip's hardware peripherals. It allocates tasks from a static arena because many embedded targets have no heap, or a heap too small for dynamic allocation.

Minimal setup

To use Embassy, you add embassy-executor to your dependencies. The executor is the core component that runs the async loop. You also need a way to mark your entry point. Embassy provides a macro for this.

[dependencies]
# The executor manages the async loop and task spawning.
# The task-arena-size feature sets the static memory reserved for tasks.
# 65536 bytes is a common starting point for medium-sized projects.
embassy-executor = { version = "0.7", features = ["task-arena-size-65536"] }

# embassy-time provides async delays and clocks.
# The tick-hz feature sets the timer frequency for the hardware clock.
embassy-time = { version = "0.3", features = ["tick-hz-32_768"] }

The task-arena-size feature is critical. Embassy does not allocate tasks on the heap. It uses a static arena, a fixed block of memory defined at compile time. If you spawn more tasks than the arena can hold, the spawn fails. Choosing the right size is a trade-off between memory usage and flexibility. Start with 65536 bytes and adjust based on your task count.

use embassy_executor::Spawner;
use embassy_time::{Duration, Timer};

// The #[main] macro replaces the standard entry point.
// It initializes the executor and spawns the main function as a task.
#[embassy_executor::main]
async fn main(_spawner: Spawner) {
    // The main task runs forever.
    // It can spawn other tasks or perform its own work.
    loop {
        // Toggle an LED or perform periodic work here.
        // Timer::after creates a future that resolves after the duration.
        // The await yields control to the executor while waiting.
        Timer::after(Duration::from_millis(500)).await;
    }
}

Convention aside: Embassy projects often use cortex-m-rt for the reset vector and exception handling. The #[embassy_executor::main] macro integrates with cortex-m-rt automatically. You do not need to add #[cortex_m_rt::entry]. Using both will cause a linker error. Stick to Embassy's main attribute.

How the executor works

The #[embassy_executor::main] macro generates code that sets up the executor and starts the polling loop. It creates a static instance of the executor. It spawns the main function as the first task. Then it enters a loop that polls tasks until the system is idle or a wake-up signal arrives.

When you call await on a future, the future returns Poll::Pending. The executor notes that this task is waiting. It moves to the next task. When the condition is met, such as a timer expiring or an interrupt firing, the executor marks the task as ready. On the next poll cycle, the executor resumes the task.

This model is efficient because the CPU only runs code when there is work to do. If all tasks are waiting, the executor can put the chip into a low-power sleep mode. Embassy integrates with the chip's sleep peripherals to minimize power consumption.

The Spawner argument in main is a handle to the executor. You use it to spawn additional tasks. Tasks are independent async functions that run concurrently with main. You mark a function as a task using the #[embassy_executor::task] attribute.

use embassy_executor::Spawner;
use embassy_time::{Duration, Timer};

// #[task] marks this function as an Embassy task.
// Tasks must be async and take no arguments.
// They run independently in the executor's loop.
#[embassy_executor::task]
async fn blink_task() {
    loop {
        // Perform work, then yield.
        // This keeps the executor responsive.
        Timer::after(Duration::from_millis(200)).await;
    }
}

#[embassy_executor::main]
async fn main(spawner: Spawner) {
    // Spawn the blink task.
    // spawn returns a Result. In embedded, panicking on spawn failure
    // is common because it indicates a configuration error.
    spawner.spawn(blink_task()).ok();

    // Main can also do work.
    loop {
        Timer::after(Duration::from_millis(1000)).await;
    }
}

Treat every await as a contract. If you do not yield, you own the CPU, and the system dies.

Realistic example: Multiple tasks

Real embedded systems have multiple concurrent responsibilities. You might need to read sensors, update displays, and communicate over a bus. Embassy handles this by spawning separate tasks for each concern. Each task runs in its own stack frame. The executor switches between them.

use embassy_executor::Spawner;
use embassy_time::{Duration, Timer};

// Task to read a sensor periodically.
// In a real app, this would use a HAL driver.
#[embassy_executor::task]
async fn sensor_task() {
    loop {
        // Simulate reading a sensor.
        // Real code would await on a driver's read method.
        let value = read_sensor();
        log::info!("Sensor value: {}", value);
        
        // Wait before reading again.
        // This prevents busy-waiting and saves power.
        Timer::after(Duration::from_millis(500)).await;
    }
}

// Helper function to simulate sensor read.
fn read_sensor() -> u16 {
    42
}

// Task to handle communication.
#[embassy_executor::task]
async fn comm_task() {
    loop {
        // Simulate sending data.
        send_data();
        
        // Wait for the next transmission window.
        Timer::after(Duration::from_millis(1000)).await;
    }
}

fn send_data() {
    // Send logic here.
}

#[embassy_executor::main]
async fn main(spawner: Spawner) {
    // Spawn all tasks.
    // Order does not matter. The executor schedules them fairly.
    spawner.spawn(sensor_task()).ok();
    spawner.spawn(comm_task()).ok();

    // Main task can monitor or perform high-level logic.
    loop {
        Timer::after(Duration::from_millis(5000)).await;
        log::info!("System heartbeat");
    }
}

Convention aside: The spawner.spawn(...).ok() pattern is common in examples, but production code often handles the error. If the arena is full, spawn returns an error. You can panic to halt the system, or you can implement a retry strategy. For critical tasks, panicking is safer than silently failing.

Pitfalls and errors

Embassy simplifies concurrency, but it introduces new failure modes. Understanding these helps you debug faster.

Blocking the executor. If a task calls a blocking function, such as a delay from a HAL that does not support async, the entire executor stops. No other tasks run. The system appears frozen. Always use async drivers. If you must call a blocking function, wrap it in a critical section or run it on a separate core if available.

Stack overflow. Each task runs on the stack. If a task uses too much stack space, or if you have deep recursion, you will overflow the stack. Embedded stacks are small. Keep task locals minimal. Move large data to static memory or the heap if your target supports it.

Arena exhaustion. If you spawn more tasks than the arena size allows, spawn returns an error. The default arena size might be too small for complex systems. Increase the task-arena-size feature value. You can also use multiple arenas for different priority levels, though this is advanced.

Trait bounds. Embassy tasks must satisfy certain trait bounds. If you try to spawn a task that captures non-Send data in a multi-core setup, the compiler rejects you with E0277 (trait bound not satisfied). In single-core embedded, tasks are often !Send. Ensure your task signatures match the executor's requirements.

Move errors. If you try to move a value into a task without cloning or moving correctly, the compiler rejects you with E0382 (use of moved value). Tasks are independent. They cannot borrow data from main unless you use interior mutability or static references.

Trust the borrow checker. It usually has a point. If the compiler complains about ownership, you are likely trying to share state unsafely. Reach for static variables or RefCell for single-core state sharing.

Decision: when to use Embassy

Embedded Rust offers several concurrency models. Choosing the right one depends on your system requirements.

Use Embassy when you need async concurrency on bare metal without an operating system. Use Embassy when you want to write clean, readable code with async/await patterns that resemble desktop Rust. Use Embassy when you need low power consumption through efficient sleep management. Use Embassy when you want to share async code between embedded and desktop targets.

Reach for bare-metal polling when your system is tiny and has only one periodic action. A simple loop with delays is sufficient for blinking an LED. Adding an executor adds complexity and memory overhead that you do not need.

Reach for FreeRTOS or Zephyr when you need hard real-time guarantees and priority preemption. Async runtimes are cooperative. A misbehaving task can starve others. Real-time operating systems provide preemption and priority scheduling. Use an RTOS if your system has strict latency requirements that cannot be met with cooperative multitasking.

Reach for tokio or async-std when you are running on a Linux host or a board with an OS. These runtimes rely on OS primitives. They do not work on bare metal.

Counter-intuitive but true: the more you use unsafe to bypass async constraints, the harder your system becomes to debug. Stick to safe async patterns. The executor handles the complexity.

Where to go next