Blinky, the async way
Blinky is the embedded hello-world, and it earns the title honestly: getting an LED to blink proves your entire toolchain — compiler target, linker script, flasher, debug logging — end to end. Once it blinks, everything after is just more Rust.
The full crate is crates/01-blinky. Here’s the whole program:
#![no_std]
#![no_main]
use defmt::info;
use embassy_executor::Spawner;
use embassy_nrf::gpio::{Level, Output, OutputDrive};
use embassy_time::Timer;
use {defmt_rtt as _, panic_probe as _};
#[embassy_executor::main]
async fn main(_spawner: Spawner) {
let p = embassy_nrf::init(Default::default());
// Initialize RGB LED pins (active low)
let mut red = Output::new(p.P0_26, Level::High, OutputDrive::Standard);
let mut green = Output::new(p.P0_30, Level::High, OutputDrive::Standard);
let mut blue = Output::new(p.P0_06, Level::High, OutputDrive::Standard);
info!("Starting RGB LED pattern...");
loop {
// Red on
red.set_low();
green.set_high();
blue.set_high();
Timer::after_millis(1000).await;
// Green on
red.set_high();
green.set_low();
blue.set_high();
Timer::after_millis(1000).await;
// Blue on
red.set_high();
green.set_high();
blue.set_low();
Timer::after_millis(1000).await;
}
}A few lines deserve a closer look.
No standard library, no main
#![no_std] opts out of Rust’s standard library — there’s no operating system here to provide files, threads, or heap allocation. #![no_main] opts out of the usual entry point, because there’s no OS to call main either. The #[embassy_executor::main] macro fills that gap: it sets up Embassy’s async executor as the program’s entry point and hands your async fn main to it.
embassy_nrf::init claims the chip’s peripherals and returns them as a struct of singletons. Each pin exists exactly once in p, and moving p.P0_26 into an Output consumes it — the type system makes it impossible for two parts of your program to fight over the same pin.
Active low
The XIAO BLE’s onboard RGB LED is wired active low: the pin sinks current, so set_low() turns the LED on and set_high() turns it off. That’s why all three pins start at Level::High — everything off — and why the code looks inverted if you’re expecting high = on. This wiring is common on dev boards; check the schematic before you trust a pin name.
The await that matters
The interesting line is the one that looks least interesting:
Timer::after_millis(1000).await;A classic bare-metal blinky burns that second in a busy-wait loop, spinning the CPU at full speed doing nothing. Here, .await hands control back to the executor, which programs a hardware timer and puts the core to sleep until it fires. Same behavior, a fraction of the power — and no concurrency involved yet. That’s the quiet pitch of Embassy: the async syntax you already know compiles down to the interrupt-driven, sleep-when-idle code you’d otherwise write by hand.
defmt provides the logging (info!), streamed over the debug probe by defmt-rtt; cargo run inside the crate flashes the board via probe-rs and tails those logs in your terminal.
Next up: input. We’ll read a button — first the obvious way, then the right way.