Chapter 12: Real-Time and Concurrency
September 17, 2026 · View on GitHub
Introduction
A microcontroller must do many things at once: blink an LED, sample a button, echo serial data, and maybe steer servo outputs — all on one core, interleaved in microseconds. This chapter defines the concurrency problem and argues for the solution the rest of the course uses: cooperative async scheduling, as provided by Embassy.
One Core, Many Tasks
The RP2350 has two cores, but each core runs exactly one instruction stream at a time. "Many tasks" really means: interleave them quickly enough that the hardware world sees each one making progress when it needs to.
Consider three jobs:
+----------------+ +----------------+ +----------------+
| Blink LED | | Sample Button | | Echo UART |
| every 500 ms | | every 5 ms | | continuously |
+----------------+ +----------------+ +----------------+
None of them is doing useful work most of the time. A blink is a 500 ms wait with a 1 us toggle. A button sample is a 5 ms wait around a 1 us read. The core is idle for 99.99% of the time; the challenge is where the waits live and who orchestrates the interleaving.
Cooperative Scheduling
There are two ways to share one core. Preemptive scheduling lets the
kernel/RTOS force a task to pause at any instruction (SysTick interrupt +
context switch). With cooperative scheduling, a task runs until it
voluntarily yields, then another task runs.
Cooperative: A runs | A yields | B runs | B waits | A runs ...
^
tasks must grant the CPU themselves
Async Rust and Embassy are cooperative: an async fn yields at each .await.
Nobody can seize the CPU mid-operation; code runs atomically from one .await
to the next. This makes shared data dramatically safer — no two tasks ever touch
the same variable concurrently at the instruction level within one core.
Busy-Waiting
The naive delay is a busy-wait: the core does nothing but count, a few million times, until enough clock cycles pass:
fn delay_ms(ms: u64) {
for _ in 0..(ms * 150_000) { } // waste 150k cycles per ms
}
Busy-waiting has two costs:
- The core is useless during the wait. While one task spins, the button is never sampled and incoming UART bytes overflow the FIFO.
- Timing is fragile. The loop's cycle count depends on compiler optimization and features.
Firmware uses busy-waits only where strict, short, loop-accurate timing is required — and even then carefully. For ordinary delays we use the hardware timer instead.
Events, Interrupts, and the Async Model
The hardware itself solves the waiting problem. Peripherals raise interrupts when an event happens: a byte arrived, a timer expired, a DMA transfer finished. The CPU then runs a short interrupt handler and returns.
Async Rust is the elegant generalization. Think of each interrupt as a signal that an event occurred; the executor sleeps until some event fires, then wakes the specific future that was waiting on it:
+-------------+ +--------------+ +--------------+
| Task A | | Executor | | Peripheral |
| await x | ------->| (sleeps on |<-------| event (IRQ) |
| | | interrupt) | | x happened |
+-------------+ +--------------+ +--------------+
The CPU is genuinely asleep (wfi — wait for interrupt) between events,
consuming ~no power and no cycles. When a byte arrives, the UART interrupt wakes
the executor, which resumes the exact task that was waiting for that byte. This
is the model Timer::after_millis(...).await, uart.read(...).await, and
every other Embassy call uses.
The Embassy Approach
Embassy packages all of this into the primitives we program with:
+------------------+
| embassy-executor | the cooperative scheduler
+------------------+
| embassy-time | timers and delays on the time driver
+------------------+
| embassy-rp | the RP2350 HAL: GPIO, UART, DMA, ...
+------------------+
Application code writes ordinary-looking async functions; the executor
interleaves them, the time driver covers delays, and the HAL bridges async
futures to hardware events. In the next four chapters we meet each component:
futures and async/await (Chapter 13), the executor (Chapter 14), time
(Chapter 15), and the HAL (Chapters 16–17).
Summary
- One core means one instruction stream; concurrency is fast interleaving.
- Embassy uses cooperative scheduling — tasks yield at
.await. - Busy-waiting wastes the only core and is used sparingly.
- Peripherals signal completion via interrupts; async Rust turns those signals into resumable waits.
- Embassy = executor (scheduling) + time driver (delays) + HAL (hardware), which is exactly the stack all three drivers are built on.
Next we open the box on Rust's async machinery: futures and poll.