Maker.io main logo

Intro to Embedded Rust Part 12: Asynchronous Programming with Embassy

2026-04-09 | By ShawnHymel

Microcontrollers Raspberry Pi MCU

Embassy is a modern async/await framework for embedded Rust that enables concurrent programming on microcontrollers without requiring a traditional real-time operating system (RTOS) like FreeRTOS. Unlike the bare-metal approach we've been using with rp235x-hal, Embassy provides higher-level abstractions for common embedded tasks: asynchronous I/O, timers, USB, and networking.

In this tutorial, we'll create a very simple button-controlled blinking LED program using Embassy's async functions and task spawning, demonstrating how async/await syntax allows you to write concurrent code that appears sequential while the executor efficiently switches between tasks, yielding the processor when waiting for events like button presses or timer expirations.

Note that all code for this series can be found in this GitHub repository.

Embassy

Embassy is a collection of frameworks and libraries for embedded Rust that provides an async/await runtime and hardware abstraction layers designed specifically for microcontrollers. At its core, Embassy uses Rust's native async/await syntax to enable cooperative multitasking, which allows you to write code that handles multiple concurrent operations (like monitoring buttons, blinking LEDs, and managing USB) without the memory overhead and complexity of a traditional RTOS or manual interrupt handling.

Embassy abstracts away many low-level details we've been managing manually (e.g., interrupt setup, peripheral configuration, and resource sharing), making it easier to build complex embedded applications while maintaining the zero-cost abstractions and memory safety that make Rust attractive for embedded development. The async approach means tasks can "await" events like GPIO pin changes or timer expirations, automatically yielding the processor to other tasks instead of blocking, resulting in more efficient resource usage and more readable code compared to traditional callback-based or polling architectures.

The framework consists of modular components you can mix and match. Let’s look at some of the most popular ones.

Platform-Specific Hardware Abstraction Layers:

  • embassy-nrf - HAL for Nordic nRF52/nRF53 series microcontrollers
  • embassy-rp - HAL for Raspberry Pi RP2040 and RP2350 chips (what we'll use for Pico 2)
  • embassy-stm32 - HAL for STM32 family microcontrollers

These replace the bare-metal HALs we've been using (like rp235x-hal) with async-first abstractions that work seamlessly with Embassy's executor.

Async Runtime and Task Management:

  • embassy-executor - The core async runtime that schedules and runs tasks, providing the #[embassy_executor::main] and #[embassy_executor::task] macros
  • embassy-futures - Utilities for working with futures, including join for running multiple async operations concurrently

Timing and Delays:

  • embassy-time - Provides async timers and delays with Timer::after_millis() and duration types, allowing tasks to sleep without blocking

Synchronization Primitives:

  • embassy-sync - Thread-safe communication between tasks, including Signal for notification, Channel for message passing, and Mutex for shared state

Protocol Stacks and Libraries:

  • embassy-usb - Complete USB device stack for implementing USB functionality
  • embassy-net - TCP/IP networking stack for Ethernet, WiFi, and other network interfaces

Hardware Connections

For this series, you will need the following components:

Connect the hardware as follows on your breadboard:

Image of Intro to Embedded Rust Part 12: Asynchronous Programming with Embassy

We will be using the LED and button for this tutorial. You will not need to use the TMP102 breakout board.

Initialize the Project

Start by copying the blinky project to use as a template. Navigate to your workspace directory and copy the entire usb-serial project:

Copy Code
cd workspace/apps
cp -r blinky timer-interrupt
cd timer-interrupt 

If a target/ directory exists from previous builds, you can delete it to start fresh (though Cargo will handle rebuilding automatically):

Copy Code
rm -rf target

Your project directory structure should be as follows:

Copy Code
apps/embassy-demo/
    ├── .cargo/
    │   └── config.toml
    ├── src/
    │   └── main.rs
    ├── Cargo.toml
    └── memory.x 

This follows the same basic structure as our previous embedded applications. We’ll keep .cargo/config.toml and memory.x the same (as the ones used in blinky), as they configure the embedded project for us and offer a memory map for the RP2350. However, we'll be making significant changes to the dependencies in Cargo.toml and completely rewriting main.rs to use Embassy's async/await patterns instead of bare-metal peripheral access.

Cargo.toml

Update Cargo.toml as follows. We replaced the bare-metal HAL with Embassy's ecosystem of crates.

Copy Code
[package]
name = "embassy-demo"
version = "0.1.0"
edition = "2024"

[dependencies]
embassy-executor = { version = "0.9.0", features = ["arch-cortex-m", "executor-thread"] }
embassy-futures = "0.1.2"
embassy-time = "0.5.0"
embassy-rp = { version = "0.8.0", features = ["time-driver", "critical-section-impl", "rp235xa"] }
embassy-usb = "0.5.1"
embassy-usb-logger = "0.5.1"
embassy-sync = "0.7.2"

log = "0.4"

cortex-m = "0.7.7"
cortex-m-rt = "0.7.5"

panic-probe = { version = "1.0.0" }

[profile.dev]

[profile.release]
opt-level = "s"
lto = true
codegen-units = 1
strip = true

We remove rp235x-hal and embedded-hal entirely, replacing them with embassy-rp (version 0.8.0) configured with features time-driver for timer support, critical-section-impl for interrupt-safe synchronization, and rp235xa to target the RP2350A variant on the Pico 2.

We add embassy-executor with arch-cortex-m for Cortex-M processor optimizations and executor-thread to run cooperative tasks, embassy-futures for async utilities like joining multiple futures, and embassy-time for async delays and timers.

For USB logging, we include embassy-usb and embassy-usb-logger along with the standard log crate that provides the logging interface. We also add embassy-sync for inter-task communication primitives like signals and mutexes.

The cortex-m and cortex-m-rt crates remain for low-level processor support, and we keep panic-probe for panic handling. This collection of Embassy crates provides a complete async runtime with hardware abstraction, replacing the manual peripheral management we've been doing throughout the series with higher-level, async-first abstractions.

src/main.rs

Our code looks much different, as we’re now relying on Embassy to handle the low-level details for us.

Copy Code
#![no_std]
#![no_main]

// Embassy: HAL imports
use embassy_rp::bind_interrupts;
use embassy_rp::gpio;
use embassy_rp::peripherals::USB;
use embassy_rp::usb::{Driver, InterruptHandler};

// Embassy: main executor
use embassy_executor::Spawner;

// Embassy: futures
use embassy_futures::join::join_array;

// Embassy: timer
use embassy_time::Timer;

// Embassy: sync
use embassy_sync::blocking_mutex::raw::CriticalSectionRawMutex;
use embassy_sync::signal::Signal;

// Let panic_probe handle our panic routine
use panic_probe as _;

// Create signal handle
static SIGNAL_BLINK: Signal<CriticalSectionRawMutex, bool> = Signal::new();

// Macro to bind USB interrupt handler
bind_interrupts!(struct Irqs {
    USBCTRL_IRQ => InterruptHandler<USB>;
});

// Task: handle USB logging
#[embassy_executor::task]
async fn logger_task(driver: Driver<'static, USB>) {
    embassy_usb_logger::run!(1024, log::LevelFilter::Debug, driver);
}

// Handle button presses in main task
async fn monitor_button(mut pin: gpio::Input<'_>, id: &str) {
    let mut state = false;
    loop {
        // Yield waiting for button press
        pin.wait_for_low().await;
        log::info!("Button {} pressed", id);

        // Toggle and send signal to blinky thread
        state = !state;
        SIGNAL_BLINK.signal(state);

        // Simple debounce
        Timer::after_millis(200).await;
        pin.wait_for_high().await;
    }
}

// Task: blink the LED if the button is pressed
#[embassy_executor::task]
async fn blink_led_task(mut pin: gpio::Output<'static>) {
    let mut enabled = false;
    loop {
        // See if signal has anything, otherwise use previous `enabled` value
        enabled = SIGNAL_BLINK.try_take().unwrap_or(enabled);

        // Only blink if `enabled` is `true`
        if enabled {
            pin.set_high();
            Timer::after_millis(250).await;
        }
        pin.set_low();
        Timer::after_millis(250).await;
    }
}

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

    // Initialize USB driver and task
    let usb_driver = Driver::new(p.USB, Irqs);
    let _ = spawner.spawn(logger_task(usb_driver));

    // Create a new output pin
    let led_pin = gpio::Output::new(p.PIN_15, gpio::Level::Low);

    // Spawn blink task
    spawner.spawn(blink_led_task(led_pin)).unwrap();

    // Create a new input pin with an internal pulldown
    let btn_pin = gpio::Input::new(p.PIN_14, gpio::Pull::Up);

    // Create button monitor
    let btn_fut = monitor_button(btn_pin, "Pin 14");

    // Wait for the button monitor future to complete (it never will)
    join_array([btn_fut]).await;
}

The structure of our Embassy program differs significantly from bare-metal code, starting with the imports and interrupt binding. At the top, we import Embassy-specific modules: embassy_rp for hardware access, embassy_executor::Spawner for task management, embassy_futures::join::join_array for running multiple async operations concurrently, embassy_time::Timer for delays, and synchronization primitives from embassy_sync.

The bind_interrupts! macro is unique to Embassy. It uses a special syntax to automatically connect hardware interrupt sources (like USBCTRL_IRQ) to their handlers (InterruptHandler)<USB>, generating the necessary boilerplate code that we previously wrote manually.

We also declare a global Signal<criticalsectionrawmutex,bool> named SIGNAL_BLINK for communication between tasks. Signals are Embassy's async-aware notification mechanism that allows one task to wake another when an event occurs, more efficient than the Mutex<refcell<option<T>>> pattern we used with bare-metal interrupts.

The program defines three functions that demonstrate Embassy's async programming model. The logger_task function is marked with #[embassy_executor::task], transforming it into a spawnable task that runs concurrently with other code. This task simply runs the USB logger using the embassy_usb_logger::run! macro.

The monitor_button function is an async function (not a full task) that lives within the main task's lifecycle, demonstrating how Embassy's HAL provides async methods like pin.wait_for_low().await that yield execution until a GPIO event occurs, completely eliminating the need for manual interrupt handlers or polling loops.

When the button is pressed, we use SIGNAL_BLINK.signal(state) to notify the LED task, then Timer::after_millis(200).await for debouncing (without blocking other tasks). The blink_led_task demonstrates checking a signal with SIGNAL_BLINK.try_take().unwrap_or(enabled) to see if the button has been pressed, enabling or disabling LED blinking based on that state.

The main function uses the #[embassy_executor::main] macro, which initializes the Embassy runtime and provides a Spawner for launching tasks. We initialize the Embassy HAL with embassy_rp::init(Default::default()), spawn the USB logger task with spawner.spawn(logger_task(usb_driver)), and spawn the LED blink task similarly.

The button monitoring happens in the main task itself using join_array([btn_fut]).await, which waits for the future to complete (it never does, so our program runs forever). A "future" is an object that represents an asynchronous operation that may not have completed yet. Think of it like a "placeholder" for a value that will be available later. When you call an async function or use .await, you're working with futures that the executor manages, pausing and resuming them as needed until the operation completes and the placeholder gets filled with the actual result.

This structure demonstrates Embassy's power: what would normally require complex interrupt handlers, global state management, and careful synchronization is expressed as simple, sequential-looking async code. The executor handles all the complexity of switching between tasks, waking them when events occur, and ensuring efficient CPU usage. When all tasks are waiting, the processor enters low-power sleep mode automatically, just like our manual wifi() calls, but managed transparently by the framework.

Build and Flash

Build your program:

Copy Code
cargo build

You are welcome to check the binary size of the debug and release variants:

 
Copy Code
cargo size
cargo size --release

Convert the ELF file to a UF2 file:

Copy Code
picotool uf2 convert target/thumbv8m.main-none-eabihf/debug/embassy-demo -t elf firmware.uf2 -t uf2 

Press and hold the BOOTSEL button on the Pico 2, plug in the USB cable to the Pico 2, and then release the BOOTSEL button. That should put your RP2350 into bootloader mode, and it should enumerate as a mass storage device on the computer.

On your host computer, navigate to workspace/apps/i2c-tmp102/, copy firmware.uf2, and paste it into the root of the RP2350 drive (should be named “RP2350” on your computer).

Once it copies, the board should automatically reboot. Try pressing the button to toggle the blinking LED on and off.

Image of Intro to Embedded Rust Part 12: Asynchronous Programming with Embassy

Recommended Reading

This concludes the Introduction to Embedded Rust series! Hopefully, at this point, you have a solid foundation for creating your own embedded Rust projects. If you would like some additional resources, I recommend checking out the following:

Simplified Embedded Rust book - I recommend the ESP Core Library edition for getting started with Rust on the ESP32.

Find the full Intro to Embedded Rust series here.

Référence fabricant SC1631
RASPBERRY PI PICO 2 RP2350
Raspberry Pi
Référence fabricant SC1632
RASPBERRY PI PICO 2 H RP2350
Raspberry Pi
Référence fabricant SC1633
RASPBERRY PI PICO 2 W RP2350
Raspberry Pi
Référence fabricant SC1634
RASPBERRY PI PICO 2 WH RP2350
Raspberry Pi
Add all DigiKey Parts to Cart
Have questions or comments? Continue the conversation on TechForum, DigiKey's online community and technical resource.