Two things at once: Embassy tasks
Part three left us with a program that sleeps beautifully but can only wait for one thing. The fix is to give each independent job its own task: one watches the button, one blinks a heartbeat LED, and the executor interleaves them. The code is crates/03-digital-input-async-multitask:
#![no_std]
#![no_main]
use defmt::{info, unwrap};
use embassy_nrf::gpio::{Input, Level, Output, OutputDrive, Pull};
use embassy_time::Timer;
use {defmt_rtt as _, panic_probe as _};
#[embassy_executor::task]
async fn button_task(mut button: Input<'static>, mut red_led: Output<'static>) {
loop {
button.wait_for_low().await;
red_led.set_low(); // Red LED ON
info!("Button pressed!");
button.wait_for_high().await;
red_led.set_high(); // Red LED OFF
info!("Button released!");
}
}
#[embassy_executor::main]
async fn main(spawner: embassy_executor::Spawner) {
let p = embassy_nrf::init(Default::default());
info!("Initializing the onboard Red LED...");
let onboard_red = Output::new(p.P0_26, Level::High, OutputDrive::Standard);
info!("Initializing the heartbeat LED...");
let mut heartbeat = Output::new(p.P0_02, Level::Low, OutputDrive::Standard);
info!("Initializing the button...");
let button = Input::new(p.P0_03, Pull::Up);
// Move ownership of the button and red LED into another task.
spawner.spawn(unwrap!(button_task(button, onboard_red)));
loop {
heartbeat.toggle();
info!("Heartbeat: {}", heartbeat.get_output_level());
Timer::after_millis(1000).await;
}
}The button loop is the same code as part three, lifted verbatim into its own function. What’s new is everything around it.
Anatomy of a task
#[embassy_executor::task] turns an async function into something the executor can run independently. Tasks are statically allocated — no heap, remember — so the macro reserves their memory at compile time. That’s also why the arguments are Input<'static> and Output<'static>: a task can outlive main’s stack frame, so it can only hold resources that live for the whole program.
The hand-off is ordinary Rust ownership:
spawner.spawn(unwrap!(button_task(button, onboard_red)));button and onboard_red move into the task. After this line, main can’t touch them — try, and the compiler stops you. The same rule that made greet(name) a one-shot in a desktop program here guarantees that exactly one task drives each pin. No mutex, no “who owns the LED” bug class at all.
Meanwhile main — which is itself just the first task — keeps the heartbeat:
loop {
heartbeat.toggle();
Timer::after_millis(1000).await;
}Cooperative, not preemptive
Both loops are infinite, and both run. The trick is that neither ever blocks: each runs for a few microseconds, hits an .await, and yields. The executor wakes whichever task’s event arrived — timer tick or pin change — runs it to its next await, and sleeps again. Press the button mid-heartbeat and both LEDs do their jobs, on time, with the CPU asleep for the overwhelming majority of every second.
That’s the payoff of the whole arc. In four small programs we went from a busy-waiting blinky to genuinely concurrent, interrupt-driven, sleep-when-idle firmware — and the concurrency cost us no threads, no RTOS config, and not one unsafe block. From here the same pattern extends to the board’s headline feature: tasks that talk BLE. Watch the repo and the playlist for what comes next.