Arduino uses C++. It’s fast to write, but on a microcontroller with only a few dozen KB of RAM, the two scariest things are array overruns and dangling pointers — and C++ won’t stop you at compile time. When it blows up, you get a hard reset in the field. Rust’s bare-metal mode (no_std) brings ownership and borrow checking to the microcontroller, and Embassy uses async/await to solve the age-old problem of “how do I write bare-metal multitasking.”
This guide uses a $2 Raspberry Pi Pico (RP2040) to get your first Embassy program running: blinking the on-board LED once per second.
Why Embassy, instead of poking registers by hand
Traditional bare-metal code either busy-waits with blocking delay or pulls in an RTOS. Embassy takes a different approach:
- Cooperative async executor: each task is an
async fnthat yields on I/O with.await, and the scheduler runs something else. Switching is almost free and no per-task kernel stack is needed. - Safe hardware abstraction layer: GPIO, UART, I2C, SPI, and USB all have safe Rust APIs, so you don’t have to hand-tremble your way into registers.
- DMA is a natural fit for async: waiting on a peripheral like a serial transceiver is far cleaner with async than with blocking calls.
In one line: lighter than an RTOS, safer than hand-written registers.
Set up the toolchain
# 1. Install the Rust target triple (RP2040 is Cortex-M0+)
rustup target add thumbv6m-none-eabi
# 2. Install probe-rs (flashes firmware and monitors serial, replacing OpenOCD)
cargo install probe-rs --locked
STM32 and other Cortex-M4/M7 boards use
thumbv7em-none-eabihf— don’t install the wrong one.
Connect the Pico over USB, hold the BOOTSEL button while plugging in, and it shows up as a USB drive; probe-rs also needs this state to flash (some boards enter the bootloader automatically).
Project layout
pico-blinky/
├── Cargo.toml
├── .cargo/
│ └── config.toml
├── memory.x
├── build.rs
└── src/
└── main.rs
Cargo.toml
[package]
name = "pico-blinky"
version = "0.1.0"
edition = "2021"
[dependencies]
embassy-rp = { version = "0.1", features = ["rp2040"] }
embassy-executor = { version = "0.1", features = ["arch-cortex-m", "executor-thread", "integrated-timers"] }
embassy-time = "0.1"
static-cell = "0.2"
cortex-m = { version = "0.7", features = ["inline-asm"] }
cortex-m-rt = "0.7"
defmt = "0.3"
defmt-rtt = "0.4"
panic-probe = { version = "0.3", features = ["print-defmt"] }
[profile.release]
opt-level = "s"
debug = 0
lto = true
Use the latest versions on crates.io; the
featuresnames may shift between versions — just follow the compiler’s hints.
.cargo/config.toml
[build]
target = "thumbv6m-none-eabi"
[target.thumbv6m-none-eabi]
runner = "probe-rs run --chip RP2040"
memory.x
MEMORY
{
BOOT2 : ORIGIN = 0x10000000, LENGTH = 0x100
FLASH : ORIGIN = 0x10000100, LENGTH = 2048K - 0x100
RAM : ORIGIN = 0x20000000, LENGTH = 264K
}
build.rs
fn main() {
// Tell the linker to use Embassy's provided linker script
println!("cargo:rustc-link-arg-bins=-Tlink.x");
}
src/main.rs — the core
#![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) {
// Initialize RP2040 peripherals
let p = embassy_rp::init(Default::default());
// On-board LED is on GPIO 25
let mut led = Output::new(p.PIN_25, Level::Low);
loop {
info!("led on");
led.set_high();
Timer::after_secs(1).await; // async wait — scheduler can run other tasks meanwhile
info!("led off");
led.set_low();
Timer::after_secs(1).await;
}
}
Note the #[embassy_executor::main] macro — it wraps your async fn main into Embassy’s executor so you don’t write the executor bootstrap yourself. The line Timer::after_secs(1).await is the key: it’s an async wait, so the CPU is free to do other work during that second instead of spinning.
Flash and run
cargo run --release
On the first flash, make sure the Pico is in BOOTSEL mode. On success the on-board LED blinks once per second and the terminal prints:
0.001 INFO led on
1.002 INFO led off
2.002 INFO led on
5 beginner pitfalls
- Wrong target triple: RP2040 is
thumbv6m-none-eabi, STM32F4 isthumbv7em-none-eabihf. Get it wrong and compilation fails outright. - No logs: you must have
defmt+defmt-rtt+panic-probe, and the runner in.cargo/config.tomlmust point at the right chip (--chip RP2040). - Forgot BOOTSEL: when
probe-rs runcan’t connect the first time, nine times out of ten you didn’t enter the bootloader. - A loop without
.awaitstarves tasks: Embassy is cooperative — if one task hogs the CPU, the others never get a turn. - Assuming
println!/Vecwork: underno_stdthere is no standard library; for dynamic allocation pull inheaplessorembedded-allocexplicitly.
Next steps
- Add WiFi to a Pico W with embassy-rp’s
cyw43crate and report temperature/humidity over the network. - Turn the Pico into a USB keyboard or serial device with
embassy-usb. - Run multiple
async fns concurrently withspawner.spawn(...)to feel cooperative multitasking.
Rust moves “memory safety” from runtime to compile time, and Embassy brings it to microcontrollers that don’t even have an OS — a combination every IoT builder should try at least once.