Chapter 30: The Complete Integration
September 17, 2026 · View on GitHub
Introduction
Every driver in this course was built to the same blueprint: a pure, testable controller; a documented config module; and a tiny async shell that reads hardware, feeds the controller, and writes hardware back. This final chapter assembles the three drivers into one integrated firmware — and, more importantly, shows how to design such a system. It ends with the full architecture and the habits that will serve any future driver.
The Shared Architecture
Lay the three drivers side by side and the pattern is unmistakable:
BLINK BUTTON UART
logic file led.rs button.rs uart.rs
hardware main.rs main.rs main.rs
config config.rs config.rs config.rs
sensor timer GPIO15 in UART0_RX
actuator GPIO16 out GPIO16 out UART0_TX
cadence Timer 500 ms Timer 5 ms interrupt + DMA
Every driver is three files with four habits:
config.rsowns tunables as documented constants.- The controller file owns logic as pure methods and hosts the test suite.
main.rsis the only file that imports Embassy peripherals.- The loop is read → controller → write → await.
Because the controller never sees a pin, the whole logic layer is host-testable;
because main is thin, the hardware layer is trivially auditable.
The Integration Project
A practical capstone: one firmware where the button chooses the blink speed, and UART reports the state. Its skeleton — applying every rule this course taught — looks like this:
src/
config.rs delay + baud + debounce constants
led.rs LedController (unchanged)
button.rs ButtonController (unchanged)
uart.rs UartController (unchanged)
main.rs executor: button -> led, uart logs state
Cargo.toml / memory.x / build.rs build system (unchanged)
Makefile test/build/flash host testing pipeline
The three controllers were designed to compose: each is Clone + Copy, each
starts via new(), and none of them owns hardware. Integration is then only a
policy decision in main:
#[embassy_executor::main]
async fn main(_spawner: Spawner) {
let p = embassy_rp::init(Default::default());
let mut led = Output::new(p.PIN_16, Level::Low);
let mut button = ButtonController::new();
let mut controller = LedController::new();
// ... uart setup (Ch 28/29) ...
loop {
button.update(true); // sampled from GPIO15 each cycle
let interval = if button.is_pressed() { 100 } else { 500 };
let state = controller.toggle();
led.set_level(Level::from(!led_state_to_level(state)));
// ... uart.write(state_log).await;
Timer::after_millis(interval).await;
}
}
That is the course reduced to one loop: sample, decide, act, wait — and every decision is a named constant or a tested method.
Split the Work With Async Tasks
The shared-single-loop version is simple, but Embassy rewards a different shape: independent async tasks that run cooperatively. The core insight is that spawnable tasks divide the system along responsibility lines, letting the executor interleave them:
+---------------+
Spawner -------> | executor |
| |
| button_task : samples GPIO15, flips speed via channel
| blink_task : toggles LED at selected speed
| uart_task : echoes bytes + logs state changes
+---------------+
_spawner — reserved since Chapter 14 — is exactly what this needs:
#[embassy_executor::main]
async fn main(spawner: Spawner) {
spawner.spawn(blink_task()).unwrap();
spawner.spawn(button_task()).unwrap();
spawner.spawn(uart_task()).unwrap();
}
Each task is a loop like the ones in every driver, and Embassy's time and
signal primitives coordinate them. The debug attribute #[embassy_executor::task]
turns each into a runtime entity; the compiler guarantees spawnable tasks are
Send and correctly sized. This is the destination the course has been
walking toward: three small loops, one executor, zero polling.
Rules for the Next Driver
Before you wire a new peripheral, apply the five habits this course made second nature:
- Config first. Every tunable is a documented constant in
config.rswith a test. - Controller pure. The logic layer takes primitives and returns echoed results — host-testable, no HAL imports.
- Docs with the standard. C-block header,
//!FILE block, and/// # Details/# Arguments/# Returnsper item. - Tests per banner. Construction, behavior, traits — twenty tests are a normal morning.
- Async shell last. The
mainfile binds peripherals, spawns tasks, and loops; it is the only embedded file.
The Complete Picture
Everything from Chapter 1 to here resolves into one system:
+----------------------------------------------------------------------+
| RP2350 firmware (Rust + Embassy) |
| |
| pure logic -> host-tested controllers (std) |
| embedded -> thin main.rs shells (no_std) |
| build -> config.toml / memory.x / build.rs / Makefile |
| async -> executor, tasks, Timer, interrupt + DMA UART |
+----------------------------------------------------------------------+
The three drivers are the proof, the tests are the guarantee, and the architecture is the reusable lesson. You now hold the full developer's path from a bare chip to an integrated, interrupt-driven, host-tested Rust system.
Summary
- All three drivers share one blueprint: config constants, pure controllers with test suites, and thin async shells.
- Integration composes unchanged controllers; only
main.rsdefines policy. - Embassy tasks split the system by responsibility under one executor; the
reserved
_spawnerfinally earns its name. - The five habits — config first, pure controller, standard docs, tests per banner, async shell last — apply verbatim to your next driver.
- The course's whole journey collapses to: sample, decide, act, wait — cooperatively, safely, and testably.
Congratulations. You built a foundation that will carry every driver you write next.