Chapter 18: Driver Architecture
September 17, 2026 · View on GitHub
Introduction
Before writing the blink driver's first constant, we lay down the skeleton every driver in this course shares — and the pattern every future RP2350 driver you write should copy. The design has one goal: put every piece of decidable logic in a library that a desktop test runner can exercise, and leave the binary with only the embedded shell.
The Module Split
A driver project contains five source files, divided by role:
src/
├── lib.rs # library root: exports config and logic modules
├── config.rs # constants, fully documented, all testable
├── led.rs # the LED state machine (pure logic, testable)
└── main.rs # the async entry point (embedded only)
(button drivers add button.rs; UART drivers add uart.rs)
The rule of thumb: if a piece of code has inputs, outputs, and behavior that
could be wrong, it belongs in the library, where you can call it from a
#[test]. If a piece of code touches hardware — pins, timers, interrupts — it
belongs in the binary, which is not tested (its test = false in
Cargo.toml, Chapter 8), but is small.
+------------------+ +------------------+
| lib.rs | | main.rs |
+------------------+ +------------------+
| config.rs | | init() |
| led.rs | test | Output::new |
| (pure logic) | ←────── | loop { ... } |
+------------------+ | Timer.await |
+------------------+
config.rs
The configuration module is a collection of documented constants:
pub const BLINK_DELAY_MS: u64 = 500;
pub const MIN_BLINK_DELAY_MS: u64 = 10;
pub const MAX_BLINK_DELAY_MS: u64 = 10000;
Every constant answers four questions in its docstring: what is it, why does it exist, what os the valid range, and what is the concrete value. Chapter 19 shows the standard in its exact, final form.
lib.rs
Library root is three lines plus the standard header:
#![cfg_attr(not(test), no_std)]
pub mod config;
pub mod led;
no_stdunless we are testing — the host-testing switch of Chapter 25.- The two private source modules become public library modules.
main.rs
The binary main is the thin shell that turns pure logic into hardware behavior:
let p = embassy_rp::init(Default::default());
let mut led = Output::new(p.PIN_16, Level::Low);
let mut controller = LedController::new();
loop {
let state = controller.toggle();
...
Timer::after_millis(controller.delay_ms()).await;
}
Notice what main does not contain: no debounce math, no timing policy, no
state. All of that lives in the library. main wires three things together —
the executor, the pin, and the controller — then lets the loop run forever.
Host Testability
The payoff of the split arrives in one command:
cargo test --lib --target <host> --no-default-features
Because config.rs, led.rs, and their siblings are pure Rust — no
embassy-rp, no cortex-m, no panic-halt — they compile on a desktop with
nothing but core/std, and a full terminal of #[test] functions starts and
ends in milliseconds. Hardware behavior (a pin flipping, a wire resetting) is
not unit-tested; the decisions around it are.
Summary
- Split the driver into a testable library (
lib.rs,config.rs,led.rs) and a thin embedded binary (main.rs). config.rsholds documented constants;lib.rsexports them undercfg_attr(not(test), no_std).main.rswires hardware and executor but owns almost no logic.- The library compiles and tests on the host with
--no-default-features.
Now we write the real files. Chapter 19 opens with config.rs — the standard
headers and the first documented constants.