Skip to content
rusthacks.dev
Systems

Rust on the Raspberry Pi Pico With Embassy Step by Step

Blink an LED on a Raspberry Pi Pico in async Rust with Embassy: toolchain target, memory.x, probe-rs runner, the main task and a second task with a button.

15 min read
Summarize in AI
Flat illustration for the post “Rust on the Raspberry Pi Pico With Embassy Step by Step”, composed around async.

A Raspberry Pi Pico is a two-dollar board with an RP2040 chip, 264 KB of RAM and 2 MB of flash, and it is one of the friendliest places to learn embedded Rust. This guide takes you from an empty directory to a program that runs two cooperative tasks on the chip: one blinks the on-board LED, the other watches a button and changes the blink speed. It uses Embassy, an async framework for microcontrollers that needs no heap and no real-time operating system. You will set up the toolchain, write the five project files that every Embassy RP2040 project needs and read each one, build, flash two different ways, add the second task, and read the failures people hit most. Plan on about 40 minutes the first time, most of it downloads. Every listing here was built for the thumbv6m-none-eabi target with Rust 1.99 and the crate versions below, and the program sizes quoted come from that build.

What you need before you start

Hardware: a Pico (or Pico W, which has the same chip), a USB cable, and ideally a second Pico to use as a debug probe, or any CMSIS-DAP probe. A button between GPIO 15 and ground is needed only for the second program. Software: Rust installed with rustup (stable is fine; see installing Rust with rustup) and the following one-time setup:

Shell
rustup target add thumbv6m-none-eabi
cargo install probe-rs-tools --locked
cargo install elf2uf2-rs --locked

The RP2040 has two ARM Cortex-M0+ cores, which implement the ARMv6-M architecture, and that is why the Rust target is thumbv6m-none-eabi: thumb is the instruction set, v6m the architecture profile, none means no operating system, and eabi is the calling convention. The core has no floating point unit, so there is no hf suffix. probe-rs is the tool that talks to a debug probe to flash the chip and stream log messages; elf2uf2-rs converts the compiled program into a UF2 file for the second flashing method that needs no probe.

Pin the crate versions. Embassy's crates are moving quickly, and an example copied from last year's blog post often fails to compile because a feature name or a function signature changed. These are the versions this guide was built with:

CrateVersionRole
embassy-executor0.10.0The async executor and the #[embassy_executor::main] and task macros
embassy-rp0.10.0The RP2040 hardware abstraction: GPIO, timers, clocks, interrupts
embassy-time0.5.1Timer, Duration and the time driver
embassy-sync0.8.0Channels, signals and mutexes safe for async embedded use
embassy-futures0.1.2select and join without an allocator
defmt, defmt-rtt1.2, 1.3Compact logging over the debug probe
panic-probe1.0.0Prints panics through defmt
cortex-m-rt0.7.7Reset vector, startup code and the linker script link.x

The project, file by file

An embedded project has more files than a hello world, and each one answers a question the operating system answers for you on a computer: where does the program live in memory, how is it started, and what do you do when it crashes. Create a directory called pico-blink and add these five files.

Cargo.toml

Cargo.tomlTOML
[package]
name = "pico-blink"
version = "0.1.0"
edition = "2024"

[dependencies]
embassy-executor = { version = "0.10", features = ["platform-cortex-m", "executor-thread", "defmt"] }
embassy-rp = { version = "0.10", features = ["rp2040", "defmt", "time-driver", "critical-section-impl"] }
embassy-time = { version = "0.5", features = ["defmt"] }
embassy-sync = { version = "0.8", features = ["defmt"] }
embassy-futures = "0.1"
cortex-m-rt = "0.7"
defmt = "1"
defmt-rtt = "1"
panic-probe = { version = "1", features = ["print-defmt"] }

[profile.release]
debug = 2
lto = true
opt-level = "s"

The features do real work. platform-cortex-m selects the executor implementation for ARM M-profile cores (older articles call it arch-cortex-m, and the compiler will tell you the feature does not exist if you copy one). executor-thread adds the thread-mode executor that #[embassy_executor::main] uses. rp2040 selects the chip variant, time-driver wires the RP2040's hardware timer to embassy-time so Timer::after works, and critical-section-impl provides the interrupt-disabling lock that several crates share. The release profile keeps debug information (debug = 2) because debug info does not go to the chip, it only lets the tools show source lines, and it enables link-time optimisation and size optimisation because the flash is small.

.cargo/config.toml

.cargo/config.tomlTOML
[build]
target = "thumbv6m-none-eabi"

[target.thumbv6m-none-eabi]
runner = "probe-rs run --chip RP2040"
rustflags = ["-C", "link-arg=-Tlink.x", "-C", "link-arg=-Tdefmt.x"]

[env]
DEFMT_LOG = "info"

This file makes cargo run do the right thing. target means every cargo build cross-compiles; without it, Cargo would build for your laptop and the linker would fail in a confusing way. The runner is the command Cargo invokes with the path of the built program, so cargo run --release becomes probe-rs run --chip RP2040 target/.../pico-blink: it flashes the chip, resets it, and streams the log output to your terminal. The two link arguments pass linker scripts: link.x comes from cortex-m-rt and lays out the vector table and sections, and defmt.x reserves the section where defmt keeps its log strings. DEFMT_LOG sets the compile-time log level; messages below it are not compiled in at all, which is how defmt stays small.

memory.x and build.rs

memory.xText
MEMORY {
    BOOT2 : ORIGIN = 0x10000000, LENGTH = 0x100
    FLASH : ORIGIN = 0x10000100, LENGTH = 2048K - 0x100
    RAM   : ORIGIN = 0x20000000, LENGTH = 264K
}

EXTERN(BOOT2_FIRMWARE)

SECTIONS {
    .boot2 ORIGIN(BOOT2) :
    {
        KEEP(*(.boot2));
    } > BOOT2
} INSERT BEFORE .text;
build.rsrust
use std::{env, fs::File, io::Write, path::PathBuf};

fn main() {
    let out = PathBuf::from(env::var_os("OUT_DIR").unwrap());
    File::create(out.join("memory.x")).unwrap().write_all(include_bytes!("memory.x")).unwrap();
    println!("cargo:rustc-link-search={}", out.display());
    println!("cargo:rerun-if-changed=memory.x");
}

memory.x is the map of the chip. The RP2040 runs your code in place from external flash (execute-in-place), mapped at 0x10000000. The first 256 bytes are reserved for the second stage bootloader, a tiny routine that the chip's mask ROM copies and runs to configure the flash interface; embassy-rp supplies it as the BOOT2_FIRMWARE symbol, and the SECTIONS block places it there. The program proper starts after it. RAM is 264 KB at 0x20000000. build.rs copies memory.x into the build directory and tells the linker where to look, because link.x expects to find a file named memory.x and a Cargo project cannot otherwise put one on the linker's search path.

src/main.rs: the smallest program

src/main.rsrust
#![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);

    loop {
        info!("led on");
        led.set_high();
        Timer::after_millis(500).await;
        info!("led off");
        led.set_low();
        Timer::after_millis(500).await;
    }
}

Two attributes at the top say there is no standard library and no ordinary main: #![no_std] removes std (no heap, no threads, no files), and #![no_main] tells the compiler not to generate the usual entry point, because the reset handler from cortex-m-rt is the real entry. The use {defmt_rtt as _, panic_probe as _}; line looks like it does nothing, and it is what links the logging transport and the panic handler into the binary; without it, linking fails with a missing _defmt_write or panic_impl symbol. The embassy_rp::init call configures the clocks and returns a struct holding every peripheral exactly once: p.PIN_25 is the on-board LED pin on a Pico (on a Pico W the LED hangs off the wireless chip and needs a different driver). Passing the pin into Output::new moves it, so the borrow checker guarantees that nobody else can drive the same pin at the same time. This ownership of hardware is the feature that makes embedded Rust different from embedded C.

Build and look at what you got

Shell
cargo build --release

The first build downloads and compiles a few dozen crates. A rebuild after a change takes a few seconds. The result is an ELF file, and the program really is tiny; here are the sections for the blink above, as reported by llvm-size (install with rustup component add llvm-tools):

SectionBytesLives inWhat it is
.vector_table192FlashInterrupt vectors
.boot2256FlashSecond stage bootloader
.text6,936FlashYour code and the runtime
.rodata1,292FlashConstants and defmt strings table
.data56Flash, copied to RAM at startInitialised statics
.bss336RAMZero-initialised statics
.uninit1,024RAMUninitialised buffers (the executor and RTT)

That is 8,732 bytes of flash (out of two million) and 1,416 bytes of RAM (out of 264,000) for a program with a real executor, a hardware timer driver and formatted logging. If you have written C for microcontrollers, the surprise is that Rust async did not make the image bigger than the equivalent hand-written interrupt code would be; the state machine for the blink task is a few dozen bytes of RAM.

Flash it: two ways

With a debug probe (best)

Connect the probe's SWCLK, SWDIO and ground to the Pico's three debug pads, power the Pico over USB, and run:

Shell
cargo run --release

The runner flashes the chip, resets it and keeps the connection open. You should see something like INFO led on then INFO led off twice a second in the terminal, and the LED blinking. The logs arrive through RTT (real-time transfer), which has the program write into a buffer in RAM that the probe reads over the debug connection without stopping the core. Ctrl-C ends the session. A panic prints its message and location through the same channel before the chip halts, a large improvement on the silent hang of a bare-metal C program. The tooling around this is expanded in debugging embedded Rust with defmt.

Without a probe: the UF2 route

Hold the BOOTSEL button while plugging in the Pico. It appears as a USB drive named RPI-RP2. Convert and copy:

Shell
elf2uf2-rs target/thumbv6m-none-eabi/release/pico-blink pico-blink.uf2
cp pico-blink.uf2 /Volumes/RPI-RP2/

The board reboots into your program. You lose logging and breakpoints, because without the probe nothing reads RTT. This route is also how a finished product is updated in the field. To get logs without a probe, use the embassy-usb-logger crate, which makes the board appear as a USB serial port.

Two tasks and a button

The single loop above is the part of Embassy that looks like ordinary blocking code. The part that earns its name appears when you have two things to do at once. We will blink the LED at a period that a button press cycles through 500, 200 and 80 ms. Replace src/main.rs with:

src/main.rsrust
#![no_std]
#![no_main]

use defmt::info;
use embassy_executor::Spawner;
use embassy_futures::select::{select, Either};
use embassy_rp::gpio::{Input, Level, Output, Pull};
use embassy_sync::blocking_mutex::raw::CriticalSectionRawMutex;
use embassy_sync::signal::Signal;
use embassy_time::{Duration, Timer};
use {defmt_rtt as _, panic_probe as _};

static BLINK_PERIOD_MS: Signal<CriticalSectionRawMutex, u64> = Signal::new();

#[embassy_executor::task]
async fn blinker(mut led: Output<'static>) {
    let mut period = 500u64;
    loop {
        led.toggle();
        match select(Timer::after(Duration::from_millis(period)), BLINK_PERIOD_MS.wait()).await {
            Either::First(()) => {}
            Either::Second(new_period) => {
                info!("period changed to {} ms", new_period);
                period = new_period;
            }
        }
    }
}

#[embassy_executor::task]
async fn button(mut pin: Input<'static>) {
    let periods = [500u64, 200, 80];
    let mut i = 0;
    loop {
        pin.wait_for_falling_edge().await;
        i = (i + 1) % periods.len();
        BLINK_PERIOD_MS.signal(periods[i]);
        Timer::after_millis(30).await;
    }
}

#[embassy_executor::main]
async fn main(spawner: Spawner) {
    let p = embassy_rp::init(Default::default());
    spawner.spawn(blinker(Output::new(p.PIN_25, Level::Low)).unwrap());
    spawner.spawn(button(Input::new(p.PIN_15, Pull::Up)).unwrap());
    info!("tasks running");
}

Walk through it. A #[embassy_executor::task] is an async function that the executor can run; its arguments must be 'static because the task lives as long as the program, and the macro allocates the task's state statically (this is why there is no heap). In 0.10 the generated function returns a Result holding a spawn token, so you call spawner.spawn(blinker(...).unwrap()); examples from earlier versions write spawner.spawn(blinker(...)).unwrap() and will not compile. The Signal is a one-slot mailbox shared by the two tasks: signal(value) stores the newest value and wakes the waiter, and wait().await returns it. A signal keeps only the latest value, which is what you want for "the current setting"; use a Channel when every message matters.

The blinker uses select to wait for whichever happens first, the timer expiring or a new period arriving, and handles each. The button task awaits a falling edge on GPIO 15 (the pin has a pull-up, so pressing the button to ground makes a falling edge). wait_for_falling_edge does not poll: it arms a GPIO interrupt and the task sleeps until it fires. The 30 ms delay afterwards is a crude debounce. Both tasks run on the one core; whenever neither has anything to do, the executor puts the core to sleep with the WFE instruction, so a program like this draws little current. This build came to 10,504 bytes of flash and 1,584 bytes of RAM.

The cooperative scheduling model is the only real rule: a task runs until it awaits. A long computation in one task, with no await, delays the other until it finishes. The blink would stutter if the button task did 50 ms of filtering without yielding. The cure is to split the work with embassy_futures::yield_now().await, or to move heavy work into its own interrupt-priority executor, the structure compared with the RTIC framework in RTIC versus Embassy.

What the executor does while nothing is happening

It helps to know what the chip is doing between blinks, because it is the reason this style suits battery-powered devices. After main spawns the two tasks and each reaches its first await, the executor finds no task that is ready to run. It then executes the Cortex-M WFE (wait for event) instruction, which halts the core until an interrupt or event arrives. The core stops fetching instructions and the chip drops to a low-power state; the timer peripheral keeps counting in hardware. When the 500 ms timer expires, its interrupt wakes the core, the interrupt handler marks the blinker task ready by calling its waker, and the executor runs it for a few microseconds, toggles the pin, and goes back to sleep. A GPIO edge does the same for the button task. Nothing polls in a loop. This is the whole mechanism of the wakers described in futures as state machines, running on a chip with 264 KB of RAM.

That model has a consequence you can measure. In an Embassy program the CPU is idle for most of its life, so the interesting numbers are latency and energy rather than throughput. The latency from a button edge to the blinker changing period is the interrupt entry time plus two task polls, a handful of instructions plus a context switch into the task, far below anything a human can perceive (measure it with a logic analyser on a spare pin if it matters to your design). The energy depends on how often the core wakes: at two toggles a second it is asleep almost all of the time. If you replaced the timers with a busy loop that polls the pin, the core would run at full speed continuously and the same program would draw many times the current.

Growing the program without growing the mess

The two-task program shares one value through a Signal. As the project gets bigger, three habits keep it readable. Give each peripheral to exactly one task, and have other tasks ask it to do things through a Channel or Signal rather than sharing the pin; the move-only ownership of Output already pushes you this way. Keep each task a loop with a single select or a single await at the top, so the control flow of a task reads like a small state machine. And size the shared structures at compile time: Channel<CriticalSectionRawMutex, Message, 8> holds exactly eight messages with no allocation, which means a producer that outruns its consumer waits on send().await instead of exhausting memory, the same backpressure idea as in bounded channels, here in a few hundred bytes.

When you outgrow one core, remember that the RP2040 has two. Embassy can run a second executor on core 1 (embassy_rp::multicore) with its own tasks, and data moves between cores through the same sync primitives, using the hardware spinlocks for the critical sections. Start with one core: most firmware is limited by what the peripherals do, not by CPU time, and a second core doubles the places a data race can hide.

Testing without the board

Everything above needs the hardware to run, which makes the edit-flash-observe loop slow. Two techniques recover most of the lost speed. First, keep logic out of the tasks: a function that maps a button count to a period (fn next_period(i: usize) -> u64) is plain Rust that can be unit-tested on your laptop if you put it in a #![no_std] library crate with the hardware code in a thin binary crate; the approach is in testing embedded Rust on the host. Second, write drivers against the embedded-hal traits, so that a fake pin in a test stands in for the real one. Reserve the board for what only it can tell you: timing, interrupts and electrical behaviour.

What goes wrong, and what to check

SymptomCauseFix
linking with rust-lld failed, undefined memory.x or link.xbuild.rs missing, or memory.x not at the crate rootAdd build.rs as above; check that -Tlink.x is in the rustflags
Undefined symbol _defmt_timestamp or panic_implThe use {defmt_rtt as _, panic_probe as _} line is missingAdd it to main.rs
"package depends on embassy-executor with feature arch-cortex-m but it does not have that feature"Following older instructionsUse platform-cortex-m in 0.10
Program builds, board does nothingBoot2 not placed, or the wrong chip featuresCheck EXTERN(BOOT2_FIRMWARE) in memory.x and the rp2040 feature
probe-rs says no probe foundWiring, a missing udev rule on Linux, or the probe firmwareRun probe-rs list; on Linux install the udev rules from the probe-rs docs
LED never lights on a Pico WThe LED is on the wireless chipUse the cyw43 driver, or blink an external LED on a GPIO
Logs show nothingDEFMT_LOG set above your levelSet it to debug or trace in [env]
Hard fault right after startStack overflow into statics, or a wrong memory.x RAM sizeCompare the RAM LENGTH with 264K; shrink big arrays

How to tell it worked

The LED toggles twice a second. With the probe, the terminal prints tasks running once, then period changed to 200 ms each time you press the button, cycling to 80 and back to 500. The first release build of this project took about 20 seconds on a laptop once the crates were downloaded; later builds are incremental. If any step fails, the table above covers the common causes in the order people hit them.

Where this does not fit

If you need hard real-time guarantees on interrupt latency measured in microseconds, a framework with priority-based preemption such as RTIC is a better structure than a cooperative executor, though Embassy supports multiple executors at different interrupt priorities for the same effect. If the program is a one-shot, stateless script that blinks and sleeps, the async machinery is more than you need; blocking embedded-hal delays work and the code is shorter. If you target a chip other than the RP2040 you change the HAL and features (embassy-stm32 for STM32 chips, covered in STM32 with probe-rs) but the executor, signals and the shape of the project stay the same. And if you want drivers that run on any chip, write them against the embedded-hal traits, as in portable drivers. The no-standard-library rules this program lives under are explained in no_std Rust explained.

Taggedrp2040
Share

Keep reading

Get new posts by email

We send one email when a new post is published. No spam.

Unsubscribe in one click. No spam, ever.