Embedded Rust has excellent building blocks like HALs, PACs, and async executors. But very little is published about how to structure a serious firmware project on top of them. Most examples stop at blinky with a HAL. What does the architecture look like when you have a real protocol stack, a motor control loop, a dozen interacting peripherals, and the ambition to support more than one chip? And what if you are bare-metal, with no RTOS and no async executor, just interrupt handlers and a main loop?

This article documents the architecture OpenServoCore ’s firmware actually uses, in enough detail to build it for your own project. Overview first, then a zoom into every layer with its internal organization and a code example, all the way down to the discrete-event simulation. The architecture has earned some trust the hard way. The firmware’s entire wire protocol was ripped out and replaced mid-project, and the layering held. The logic layers compile and unit-test on a desktop with no hardware attached. And the simulation caught over a dozen real bugs before they ever reached silicon.

The code examples are teaching-sized but structurally faithful to the shipping code, and the codebase itself is public if you want the full-scale version. If you are new here, the project overview has the background.

TL;DR

Don’t have time for the whole thing? The whole architecture in four bullets:

  • Split the firmware into a chip-agnostic core, thin services, chip-agnostic drivers, chip-specific providers, and a hand-written HAL. Dependencies point strictly downward, and nothing skips a layer.
  • At both seams, the consumer defines the trait and the layer below implements it. Pretty much everything else follows from this inversion.
  • Drivers have private state and exactly three kinds of public methods, which are events, commands, and accessors.
  • Test in three gears, with unit tests over fakes, a discrete-event simulation running the production crates, and a small but strict bench suite.

The rest is the details, and I think with an architecture the details are the whole thing.

When Bare Metal Is The Right Call

The honest answer is that usually it isn’t. Embassy and RTIC are genuinely good. They have real ownership models, real schedulers, and communities that have sanded down the sharp edges. If your firmware reads sensors, drives displays, or glues radios together with normal timing on adequate chips, use a framework. You’ll ship faster and break less.

Bare metal earns its keep when you have hard-realtime requirements on a cheap chip, where the product only works if you take every single advantage the hardware has to offer. A servo on a $0.16 microcontroller is the poster child. A control loop with a fixed rate, bus replies with microsecond hand-offs, and a few KB of RAM to do it all in. Surviving there means doing things no framework anticipated, like commandeering the SPI block’s CRC engine to checksum a UART stream in hardware (the protocol article does exactly this), firing a transmit start from a timer-compare-to-DMA chain so zero CPU sits in the deadline path (the Fast article ), or hand-placing specific functions in RAM because one flash-resident hop eats a timing margin. Every one of those is a peripheral doing something its chapter in the reference manual never advertised, and I only found them because there was nothing standing between me and the silicon.

In that regime, the frameworks’ costs stop being rounding errors. An executor’s wake-and-poll overhead is real money against a turnaround budget measured in tens of microseconds on a 48 MHz core. Async surrenders control of code placement, and hot paths must land in RAM sections deliberately, which futures don’t take kindly to. And the abstraction layers are in charge of exactly the register-write orderings you now need to handle yourself.

So if you’re not constrained by processor or timing, use a framework, happily. But with hard deadlines on cheap silicon, I think bare metal is the only sane choice. The rest of this article is about the discipline that keeps that choice from collapsing into chaos.

The Failure Mode This Prevents

Most firmware starts the same way. Each peripheral gets a struct or a pile of globals, interrupt handlers read and write those globals directly, and coordination happens through shared atomics and ad-hoc flags. It works at three peripherals. Then it collapses, and the collapse has a recognizable shape.

  • A module of statics, dozens of AtomicU32s and unsafe cells, each referenced from three or four places, with ownership implicit and invariants documented nowhere.
  • State machines that exist only as the combination of several boolean flags, with no single place that names the states or the legal transitions.
  • Interrupt handlers that run several screens long, alternating between register polls, state transitions, and global manipulation.
  • And there is a quiet one that hurts the most, the testing ceiling. Every method touches registers, so nothing can be exercised without the bench. A subtle state-machine bug means a hardware repro, a logic analyzer, and a lost afternoon, for something that should have been a one-line unit test.

The architecture below is a set of rules for keeping structure as the project grows past that ceiling. None of it needs a framework. It’s conventions plus the Rust trait system.

The Layer Map

The firmware splits into a chip-agnostic core and four chip-side layers. Each has exactly one reason to change, and dependencies point strictly downward:

  • The core owns the product, meaning the protocol dispatcher, the control table, and the control loops. It defines the traits it needs from the world and knows nothing below them.
  • Services implement the core’s traits over driver method calls. They’re thin, pure type translation.
  • Drivers own hardware state and expose plain methods like on_break(), arm(), window(). They contain the state machines, the scheduling math, and the policy. This is the part of firmware where the real bugs live.
  • Providers implement the traits drivers depend on. Over real HAL calls in production, and over call-recording fakes in tests.
  • HAL talks to the silicon.

A note on that bottom layer, because “HAL” usually means a crate you download. Here it’s typically your own. Small, peripheral-shaped modules written directly over the chip’s PAC (the register-definition crate), containing exactly the register sequences your providers need and nothing else. One reason is precision. The timing tricks this firmware leans on depend on exact register ordering that a general-purpose HAL abstracts away. The hardware transmit start depends on which of two enable bits is written last. The other reason is size. Providers ask for narrow primitives, so the hand-written layer stays a few small files per peripheral. There’s no rule against backing a provider with a community HAL where it serves, since the seam above doesn’t care what’s below it. Just expect the precision corners to push you down to the PAC.

Now take a look at the two dashed edges. They’re the same idea used twice, and basically the whole architecture is built on this one idea. At both seams, the consumer defines the trait and the layer below implements it. The rest of this article is a zoom into each of those five boxes, top to bottom, one code example each.

One more rule before zooming in. Nothing skips a layer. A driver never imports a HAL type, and a service never reaches past its driver. If one change forces edits in two layers at once, the layering has sprung a leak. The usual culprit is a chip-specific type appearing in a driver’s method signature.

The Core

The four layers exist to serve the box at the top. The core is the firmware’s actual behavior, everything that would still be true about the product if you moved it to different silicon.

  • The protocol dispatcher. A verified frame comes in, the dispatcher decodes it into a typed instruction, routes it to a handler, and builds the reply. It’s pure functions over bytes and domain types, with no registers or interrupts, and nothing chip-shaped anywhere in it.
  • The control table. The register map a servo protocol revolves around. A flat, byte-addressed table with typed fields, access rules, and per-field validation, plus staged writes that commit atomically and hooks deciding what persists across power-off.
  • Control loops and domain state. The motor kernel’s math, calibration state, and fault logic, which are the reason the device exists.

The core talks to the world through traits it defines. That’s the top dashed seam. A bus, to the core, is just this:

// Defined in the core. Implemented chip-side by a service.
pub trait Bus {
    fn poll(&mut self) -> Option<Request<'_>>;   // decoded protocol values,
    fn send(&mut self, reply: Reply<'_>);        // never raw bytes or timing
}

The trait doesn’t have baud rates, deadlines, or a “schedule” parameter, it only talks data. The core says what to reply, and wire placement is the driver’s business, derived from the request it already holds. If you find yourself threading a Schedule struct through every send call, driver knowledge is leaking upward.

A dispatcher handler then reads as straight protocol logic:

fn on_write(&mut self, w: Write<'_>) -> Reply<'_> {
    self.table.stage(w.addr, w.bytes)?;              // validated, staged
    if w.touches(BAUD_RATE) {
        self.pending.push(Apply::BaudAfterReply);    // cross-domain orchestration
    }
    Reply::ack(w.id)                                 // applied on frame verdict
}

That handler is also where cross-domain orchestration lives. “This write should persist and also retrigger the baud config” is protocol knowledge, so it’s a core handler making service calls. No driver ever knows another driver exists.

The control table deserves a special mention, because it’s where a naive design bleeds performance. Field rules (ranges, access modes, cross-field constraints) are the classic place to build a little interpreter that walks rule descriptors. I measured that interpreter on a 48 MHz chip. About 10 µs per evaluated rule. Ouch! The shipping design compiles the rules instead. Derive macros expand each field’s constraints into straight-line compare code at build time:

#[table(section = config)]
struct Comms {
    #[field(addr = 0x010, access = RW, range = 1..=249)]
    id: u8,
    #[field(addr = 0x011, access = RW)]
    baud: BaudIdx,          // enum, so validation is "is it a variant". free
}

That runs about 3.5 µs per rule, and dropped a 4-byte position write’s full validation from 45 µs to 17 µs. So in the core, I found that declarative beats interpreted, because a derive macro’s output is exactly the straight-line code you’d have written by hand.

Two consequences of the core’s isolation are worth pointing out. First, it compiles anywhere Rust compiles. Its tests are plain cargo test, and the simulation later in this article runs the same core crates the chip ships. Second, in OpenServoCore the protocol codec and the control table go one step further and live in foundation crates shared by both ends of the bus. The servo firmware and the host-side tools link the same encode/decode and the same table definitions, so the two ends of the wire cannot drift apart.

Drivers

Drivers are where firmware’s real complexity lives, so their internal organization is the most important convention in the whole architecture. Every driver, leaf or composite, has the same anatomy, private state fields and exactly three kinds of public methods.

pub struct TxGate<P: DigitalOut> {
    pin: P,                        // injected provider
    state: State,                  // private FSM, no field is ever pub
    deadline: Option<u32>,
}

enum State { Idle, Armed, Driving }

impl<P: DigitalOut> TxGate<P> {
    pub fn new(pin: P) -> Self { /* fully valid immediately, no init() dance */ }

    /// EVENT. Hardware already did this. Named for the logical event.
    pub fn on_deadline(&mut self) -> Option<Fired> {
        match self.state {
            State::Armed => {
                self.pin.set(Level::High);
                self.state = State::Driving;
                Some(Fired { at: self.deadline.take().unwrap() })
            }
            _ => None,      // spurious wake. record nothing, refuse nothing
        }
    }

    /// COMMAND. Software wants this. May be refused.
    pub fn arm(&mut self, at: u32) -> Result<(), ArmError> {
        if matches!(self.state, State::Driving) {
            return Err(ArmError::Busy);      // a caller can hit this, so Result
        }
        debug_assert!(self.deadline.is_none()); // a caller can't, so assert
        self.deadline = Some(at);
        self.state = State::Armed;
        Ok(())
    }

    /// ACCESSOR. Stable observation, no transition coupling.
    pub fn is_armed(&self) -> bool { matches!(self.state, State::Armed) }
}

The three kinds are distinguished by direction, and keeping them visibly distinct at every call site keeps a driver readable a year later.

Events (on_*) are something the hardware made happen, and they arrive from interrupts. The event is already true by the time the method runs, so an event never returns Result, because you can’t refuse something that already happened. It mutates state, and it may return an outcome value when something downstream must act on the transition. Name events for the logical event instead of the peripheral flag that delivered it, so on_tx_complete rather than on_uart_tc. On the next chip the same event may arrive from a different mechanism, and the driver’s API shouldn’t change when it does.

Commands are something software wants, coming from the main loop or a service. They return Result only for failures a caller can legitimately encounter and handle. Programmer errors like call-order violations are debug_assert!s, not error variants. I treat an error enum full of “can’t happen” cases as a smell.

Accessors are read-only observations that don’t depend on a pending transition. A config scalar, a counter, or a long-lived flag. Here is the test I use. If the value is being polled, an accessor is fine. If it’s being consumed, it’s a transition output, so return it from the event method instead. So “the most recent decoded packet” is not an accessor.

Two structural rules complete the driver type. It is fully valid after new(), with no half-constructed states and no init() two-step. And it carries no statics. Singleton lifecycle belongs to the registry, covered later, which is why tests can just construct one.

Composing Drivers

Drivers compose recursively, and a composite follows the same anatomy. The difference is that its method bodies route between children instead of mutating leaf state:

pub struct Bus<P: Providers> {
    rx: BusRx<P::Ring>,        // sub-drivers are private, like all state
    tx: BusTx<P::Compare, P::TxWire>,
    clock: BusClock<P::Trim>,
}

impl<P: Providers> Bus<P> {
    pub fn on_break(&mut self) {
        if let Some(frame_end) = self.rx.on_break() {
            self.tx.on_wire_end(frame_end);      // rx's outcome routes into tx
        }
    }
    pub fn on_tx_complete(&mut self) {
        if self.tx.on_tx_complete().released() {
            self.clock.apply_pending();          // safe now, wire is quiet
        }
    }
}

Read that impl block again. It is the wiring diagram of the subsystem. Every cross-component interaction in the bus is visible in one file, as plain method calls. That property comes from two rules.

  1. Sub-drivers are only reachable through their parent. Siblings never call each other. Without this rule, two children grow a hidden coupling that appears in neither of their public surfaces, but with it, all coupling is the parent’s explicit routing.
  2. Top-level drivers never communicate. If a motor driver needs the temperature sensor’s reading, they weren’t top-level. Compose them under a coordinator, or move the decision up into the core. In my experience, wanting a driver-to-driver call means the two concerns belong together.

Values crossing driver boundaries travel as primitives (ticks, counts, small enums), not as one driver’s internal types. Flatten at the source and rebuild at the destination. It costs a few lines per boundary, but in return no driver ever imports another driver’s types.

One advanced note for when Rust’s borrow checker objects. A composite whose cached request (borrowed from one half of its state) must coexist with a reply path (mutating another half) can’t hand out both through &mut self methods. The escape is a borrow-splitting sub-composite. A small struct whose reason to exist is one method, fn split_mut(&mut self) -> (&mut HalfA, &mut HalfB), letting the hot path hold both halves at once. Reach for it only when the conflict is real. Most borrow fights dissolve by returning owned data instead.

Interfaces And Providers

Here is the part that does the actual work, and it’s a direction reversal most embedded code gets backwards. The traits sit between drivers and providers, and the driver defines them.

Conventional layered firmware has the HAL expose traits and drivers consume whatever the HAL offers. Here it’s inverted, each driver declares small traits describing what it needs done, and the layer below implements them. In software-architecture vocabulary this is dependency inversion, and the whole arrangement is hexagonal architecture applied inside a firmware binary. Two details make it pay off.

  • Traits are driver-shaped rather than peripheral-shaped. fn set(&mut self, level: Level). The implementor is already bound to one pin, and Level is a domain enum living next to the trait. The peripheral-shaped alternative, fn set_level(pin: Pin, level: Level), forces a chip-specific Pin type into the driver just to compile.
  • Each trait is narrow, only what one driver actually calls. A driver’s trait bounds are its complete hardware dependency list, enforced by the compiler.

A Provider File, In Full

Providers are deliberately boring, and their organization is a naming convention that pays every time you open the directory. Files and types are named for the role instead of the peripheral. A listing of provider/ reads as the catalog of hardware roles the firmware fulfills. Which peripheral plays each role is the file’s content rather than its name.

// provider/monotonic.rs, the "Monotonic" role. SysTick today, and
// that's this file's private business.
pub struct Monotonic;

impl Monotonic {
    /// Claims and configures the peripheral, including its clock-gate
    /// enable. The file that DECIDES "SysTick plays this role" is the
    /// file that turns SysTick on. One decision, one home.
    pub fn init() { /* clock gate + config registers */ }
}

impl crate::drivers::traits::Monotonic for Monotonic {
    const TICKS_PER_US: u32 = 48;
    fn ticks(&self) -> u32 { /* one HAL read */ }
}

#[cfg(test)]
pub struct Fake { pub now: core::cell::Cell<u32> }
#[cfg(test)]
impl crate::drivers::traits::Monotonic for Fake { /* returns self.now */ }

Everything about the layer is visible in that one file. The production type is zero-sized, and monomorphization plus #[inline] collapses the dispatch to a direct register access, so this whole layer costs exactly nothing at runtime. The init-responsibility rule says the provider that claims a peripheral enables and configures it, so swapping chips means swapping provider files and nothing else. And the fake lives right beside the real thing, implementing the same trait. The fake is a second flavor of provider rather than a mocking framework bolted on later, and the seam it slots into is in the architecture on purpose.

Unit Tests Without Hardware

#[test]
fn arming_drives_the_pin_low_then_high() {
    let fake = provider::digital_out::Fake::default();
    let mut gate = TxGate::new(fake);
    gate.arm(1000).unwrap();
    gate.on_deadline();
    assert_eq!(gate.provider().ops(), &[Op::Set(Level::Low), Op::Set(Level::High)]);
}

That runs in milliseconds, on a desktop, in CI. And everything drivers actually do is testable this way. State-machine transitions, command rejection under each disallowed state, drift integrators and schedulers (pure math once time comes from a fake), and composite routing (children record receipt). For composites, one fake host struct hands out per-role fakes sharing a single log, so a test constructs one thing and asserts on one interleaved call sequence.

This layer honestly cannot test real peripheral behavior, electrical timing, or interrupt-priority races. Those belong to the other test gears, covered below. Host tests catch the largest class of firmware bugs, which is logic bugs, and they don’t pretend to catch anything else.

Chip-Agnostic By Construction

Driver<Fake> and Driver<Real> being the same code is more than a test convenience. It’s the proof that drivers are structurally unaware of any chip. Swapping a fake for a real provider is the same operation as swapping chip A’s provider set for chip B’s. The entire drivers tree compiles unchanged for a chip it has never met.

When a codebase actually grows a second chip variant, the provider layer is where the variant lives, and the convention scales without touching anything above it:

provider/
  monotonic.rs        ← cfg-routed re-export (the dispatcher)
  monotonic/
    v00x.rs           ← chip family A: SysTick plays the role
    v30x.rs           ← chip family B: TIM6 plays the role

When a composite’s generic-parameter list grows past three or four, collapse it with a provider-set trait. One type parameter, one associated type per role:

pub trait Providers {
    type Ring: DmaRing;
    type Compare: TickCompare;
    type Trim: ClockTrim;
}
// chip side: one impl naming its concrete types. tests: one FakeProviders.

You can hold all of this together socially, with code review allowing hal:: imports only under provider/. Or you can hold it structurally, by splitting the drivers into their own crate with no HAL dependency, at which point chip-agnosticity becomes a cargo build invariant. The driver crate physically cannot see a register. For long-lived projects, I would choose structural, since rules enforced by the build system don’t erode over time.

None of this is theoretical for me. OpenServoCore’s firmware had its entire wire protocol replaced mid-project. Old transport deleted, a new break-framed protocol dropped in, in about a week. It took a week instead of a quarter because the blast radius was bounded by construction. The protocol layers changed and the provider seams held, so everything outside the seams didn’t even know anything happened.

Services

Services are the smallest layer and the easiest to get wrong by overbuilding. A service implements one core trait over one top-level driver, and it is pure type translation:

// services/bus.rs, the chip-side adapter for the core's Bus trait
pub struct BusAdapter;

impl osc_core::Bus for BusAdapter {
    fn poll(&mut self) -> Option<Request<'_>> {
        unsafe { Drivers::bus() }.poll()          // decoded values out
    }
    fn send(&mut self, reply: Reply<'_>) {
        let _ = unsafe { Drivers::bus() }.send(|buf| serialize(reply, buf));
    }
}

Each method has three lines of substance. It reaches the driver, translates arguments downward, and translates results upward. Two rules keep it that way.

  • 1:1. Each service binds to exactly one top-level driver. Needing two drivers to fulfill one trait is the no-peer-communication rule telling you those drivers want composing.
  • No wire knowledge upward, no protocol knowledge downward. The send above takes data. The driver derives timing and placement from the request it already cached. The service is just a translator sitting between two vocabularies, protocol words above and driver words below. If it stays thin, that’s your sign both sides are doing their jobs.

It’s tempting to skip this layer and have drivers implement core traits directly. But drivers live chip-side (they know their providers) and core traits live in the chip-agnostic crates. Fusing them couples the core to a chip tree and breaks the whole portability story, just to avoid writing a ten-line file.

ISRs, The Registry, And Bringup

Three pieces of orchestration hold the layers together in production, and each is deliberately dumb.

Interrupt handlers are just dispatchers. An ISR reads the status register, classifies it into logical events, and calls driver methods:

pub fn on_usart1() {
    let status = hal::usart::status();
    let bus = unsafe { Drivers::bus() };
    if status.rx_error() { bus.on_rx_error(status.flags()); }
    if status.idle()     { bus.on_idle(); }
    if status.tc()       { bus.on_tx_complete(); }
}

It’s a dozen lines, with no state of its own and no routing decisions. If an ISR body wants to make a decision, that decision belongs in a driver’s on_* method. Single-source interrupts collapse to one line.

The registry quarantines the statics. Driver types carry no globals, but production firmware still needs singletons reachable from ISRs. All of that lives in exactly one place. A facade owning one static cell per driver instance, with typed accessors:

static CELLS: Cells = Cells {
    bus: SyncUnsafeCell::new(None),      // Option<Bus<ChipProviders>>
};

pub struct Drivers;
impl Drivers {
    /// SAFETY: bringup installs before any ISR is unmasked, and all ISRs
    /// touching `bus` share one priority, so no concurrent &mut exists.
    #[inline(always)]
    pub unsafe fn bus() -> &'static mut Bus<ChipProviders> { /* cell access */ }
}

The safety story is stated once, at the accessor, as a contract. The cell is installed before interrupts and mutated only at one priority level. Every unsafe { Drivers::bus() } call site is invoking that contract instead of improvising its own. If your ISRs run at multiple priorities, this contract is only the bare minimum. Mask around cross-priority access, or use a framework that owns the problem. Tests never see any of it. They construct drivers directly, with no install step and no unsafe.

Bringup is three lines with a structural contract:

pub unsafe fn bringup(wiring: &Wiring) {
    Provider::init(wiring);      // hardware powered + configured (IRQs masked)
    Drivers::install(wiring);    // handlers ready
    Provider::enable_irqs();     // IRQs hot. last, always
}

The order is hardware powered, then handlers ready, then interrupts hot. The provider facade runs system providers first (clock tree, pin modes), then each driver provider’s init(), which owns its peripheral’s clock-gate enable per the cohesion rule from the provider section. Every interrupt-enable is deferred to the third phase, so no vector can fire into an empty cell. Board specifics enter as one const WIRING: BoardWiring in the board binary. The bringup sequence itself knows which providers exist and in what order, but never which registers they touch.

Testing In Three Gears

The testing story runs in three gears, and the division of labor is deliberate. Each gear proves something the other two can’t.

gearprovesruns
uniteach piece is individually correct (FSMs, codecs, CRC vectors, table rules)desktop, CI, milliseconds
simulationthe pieces compose correctly under adversarial event sequencing, at every bauddesktop, CI, deterministic
benchthe real silicon meets timing under real interrupts, DMA, and clock drifthardware rig

Gear one is the fakes section above. Gear three closes the loop on real silicon, covered at the end of this section. The middle gear is the one I almost never see in hobby projects, and it pays off more than anything else in this article.

The Discrete-Event Simulation

A discrete-event simulation (DES) replaces the physical world with a virtual clock and an ordered queue of events. Nothing runs in real time. The simulation pops the next event (“byte lands at t=141 µs”, “servo 2’s timer compare fires at t=155 µs”), executes it, possibly scheduling new events, and jumps to the next. Two properties follow immediately. The first is determinism. The same scenario produces the same interleaving, every run, forever, so there are no flaky tests, and every bug it finds comes with a perfect reproduction. The second is speed. A multi-servo exchange sweep across every baud rate runs in seconds of wall time.

The big design decision is what’s real inside the sim, and one more time it comes down to the provider seam. The production driver and core crates run unmodified. The same ServoBus, the same dispatcher, the same control table the chip ships, constructed over a third flavor of provider, behind which lives a model of physics instead of registers.

  • Time is a u64 tick counter at 48 ticks/µs, chosen so a bit-time is integral at every supported baud rate (16 ticks at 3 Mbaud, 96 at 0.5M). No rounding drift anywhere in the model.
  • The wire is a single half-duplex channel delivering events the way the real silicon does, with the rules taken from bench measurements of the real chip. A data byte arrives at its stop-bit sample point. A break’s wake is delivered at the span’s end, where the real detector latches. A byte at the wrong baud rings into the buffer garbled but wakes nobody, because the real error flags don’t interrupt. A break only wakes receivers whose configured rate actually qualifies the span’s length. If the model is wrong about the silicon, the sim validates a fantasy, so every one of these rules cites a measured fact.
  • The peripherals are tiny state machines. A DMA ring that fills as simulated bytes arrive, and a tick-compare whose scheduled deadline carries a generation counter, so a cancelled-and-rearmed deadline can’t fire stale. That’s a real bug class, and it’s modeled because the real firmware has to handle it.
  • The adversary is scriptable. Tests inject mid-frame garble, stray breaks inside another talker’s window, spurious wake-ups with no new bytes (modeling coalesced interrupt service), rescue pulses, and continuous oscillator drift. The drift event changes one servo’s clock rate, re-anchored so its readings never step, exactly like temperature does it.

A test then reads like a bench session that can’t lie to you:

#[test]
fn midframe_garble_costs_one_frame() {
    let mut sim = Sim::new(BaudRate::B1000000);
    let s = sim.add_servo(5);

    // Frame A gets one stray byte injected inside its wire window. A's
    // CRC now covers a shifted span and fails, so A must die by DATA,
    // silently. Frame B follows back-to-back, and the next break
    // re-anchors the framer.
    sim.send_with_garble(write_goal(5, 1000), garble_at_byte(4));
    sim.send(read_goal(5));
    let frames = sim.run();

    assert_eq!(replies(&frames).len(), 1);        // A: no reply, B: answered
    assert_eq!(sim.diag(s).crc_fail_count, 1);    // and the loss was counted
}

Sweep that over the whole baud matrix with a test-parameterization macro and you have, in CI, coverage a bench couldn’t produce in a month. The shipped firmware’s sim suite found over a dozen real bugs pre-silicon, like ordering hazards in back-to-back frame handling, a state-corruption bug when a servo was hot-unplugged mid-exchange, and chain-sequencing edge cases that only occur at specific baud-and-timing alignments. Plus standing regression floods like “100 zero-gap writes while the ring wraps twice, zero loss, in order, at every baud”. The clock-discipline logic is tested the same way, with calibration trains that converge, reject, and time out, a drift tracker following injected thermal drift, and a deliberately lied-to trim loop railing exactly as predicted.

And there is an honest limitation, learned at the cost of two days. The sim is blind to time and to false premises about silicon. It models zero CPU cost, so it cannot see interrupt latency, wall-clock turnaround, or real oscillator physics. That’s the bench gear’s whole job. The subtler blindness is that the peripheral models encode your beliefs about the chip, and a wrong belief passes the sim and fails on hardware. Mine was assuming two timers clocked from the same source share a phase, and they don’t. A simulation only validates your logic against your model of the world, so the model itself has to be validated at the bench.

Building one for your own project is smaller than it sounds. Here is the recipe:

  1. A BinaryHeap of (time, seq, Event). The seq makes ties FIFO, and determinism lives there.
  2. Virtual time as integer ticks, chosen so your bit-times divide evenly.
  3. Your real driver and core crates, instantiated over sim providers.
  4. A wire model at whatever level of detail your protocol’s failure modes live at. For a UART bus that’s bytes and breaks, not edges.
  5. Every scenario choice explicit or seeded, never from a live clock or RNG.
  6. Runaway guards (max events, max sim time), so a wedged scenario fails loudly instead of hanging.

Two hundred lines buys the core, and the value compounds every time a refactor lands and the whole adversarial suite replays in seconds.

The Bench Gear

The last gear asserts on real silicon. There is one trick that makes it trustworthy, which is to measure the actual wire instead of the firmware’s opinion of the wire. The bench adapter’s timer captures every edge on the bus pin, including its own transmissions, and the test suite decodes those edges host-side into byte-and-tick truth. Turnaround budgets per baud, calibration convergence on real de-tuned oscillators, and zero-gap burst gates that assert zero failures, with no tolerance budget.

The strictness paid off for me. Two of the nastiest bugs this project found (a phantom command aliasing data bits at low baud, and a CPU register read silently killing a byte mid-reception) were caught because a burst gate refused to accept 99.6%. My takeaway is that a hardware test that allows occasional failure stops being a test.

The gear split also left the bench suite small. A handful of timing assertions with measured budgets, the strict burst gates, and the calibration probes. Everything logical lives a gear or two down, where it’s free.

Growing Into Crates

Everything above works as module conventions inside one firmware crate, and that is the right starting point. The graduation, when multi-chip support or longevity demands it, maps the layers onto crates. This is also where the last pieces of the system live, the ones that are neither core nor drivers. The chip lib and the board binary.

myproject-core        ← dispatcher, control table, domain. chip-agnostic
myproject-drivers     ← driver state machines + their trait surfaces
mychip-family         ← the CHIP LIB (orchestration crate)
firmware/myboard      ← the binary: board wiring const + main()

Inside The Chip Lib

The chip-family crate is the orchestration crate rather than just “the HAL crate”. It’s the one place where everything concrete composes into a running system. There are five modules in two shapes:

moduleshapecontents
hal/foundationperipheral primitives over the PAC + chip-family type enums (Pin, DmaChannel, TimerChannel)
cfg/declarativethe board-wiring schema. struct definitions typed with hal enums, types only, no values
providers/implsrole-shaped implementations of the driver traits
services/implsimplementations of the core traits
runtime/orchestratorsregistry.rs (the cells + accessors), init.rs (the bringup sequence), isr.rs (vector bindings)

The two shapes don’t mix in one file, and the distinction is worth keeping straight. Providers and services are impls. They fulfill someone else’s trait and are reusable for any consumer. Registry, init, and ISRs are orchestrators. They hold, sequence, or dispatch specific concrete instances. That’s why ISR bodies live in runtime/isr.rs rather than in provider files. A provider file is role-shaped (“something plays Monotonic”), while an ISR binding names a specific peripheral and a specific dispatch target (“DMA1 channel 5’s interrupt dispatches to the bus driver”). That’s a one-off pairing rather than a role.

Board Wiring Configs

Wiring splits across the boundary by what varies where. The schema is chip-family-shaped. Its field types are hal enums, and different chip families support different fields, so it lives in the chip lib’s cfg/:

// mychip-family/src/cfg/board_wiring.rs. types only, no values
pub struct BoardWiring {
    pub bus_rx: Pin,
    pub bus_tx_en: Option<Pin>,  // buffered boards wire it, the direct-wire rev omits it
    pub status_led: Pin,
}

The values are board-shaped, so they live in the binary as one const. Nothing inside the chip lib hardcodes a pin. The runtime entry point takes &'static BoardWiring and threads it to the system providers that set pin modes at bringup.

The Firmware Binary

That makes the board crate almost embarrassingly small, and I take that smallness as proof the layering worked:

// firmware/myboard/src/main.rs. the whole binary
#![no_std]
#![no_main]
use mychip_family as chip;

const WIRING: chip::BoardWiring = chip::BoardWiring {
    bus_rx: chip::Pin::PC1,
    bus_tx_en: Some(chip::Pin::PC2),
    status_led: chip::Pin::PB8,
};

#[entry]
fn main() -> ! {
    unsafe { chip::bringup(&WIRING) };   // the three bringup phases live in the chip lib
    loop { chip::poll(); chip::wfi(); }
}
# firmware/myboard/Cargo.toml. the board pins its exact chip variant
[dependencies]
mychip-family = { path = "../mychip-family", features = ["chip-v006e8"] }

A board is a wiring const, a chip-variant feature flag, and a main loop. A new board revision is a new wiring const, and the same board with a bigger-package chip is a feature change. Everything else is upstream.

Chip Variants

Within a chip family, variants (packages, pin counts, peripheral presence) route through the same cfg pattern at exactly three places. hal/types.rs for which pins and channels exist, cfg/board_wiring.rs for which schema fields exist, and providers/<role>.rs for which register writes fulfill the role:

providers/monotonic.rs      ← cfg-routed re-export (the dispatcher)
providers/monotonic/
  v006.rs                   ← this variant: SysTick plays the role
  v307.rs                   ← that variant: TIM6 plays the role

One mental model covers every variant axis. When variants diverge enough that sharing files stops paying, the next step is a sibling chip-family crate. Core and drivers stay untouched, which is the whole point.

The payoff of the crate split is that the two big properties stop being review conventions and become build invariants. The driver crate has no HAL dependency, so drivers cannot see registers. The core crate has no driver dependency, so product logic cannot couple to a chip tree. The cost is crate ceremony, paid once. For a codebase meant to outlive its first chip, it’s the best trade in this article.

One extension for bus projects. If you ship both ends, device and host, the whole stack repeats per role, as parallel crate columns sharing only the protocol codec and other end-neutral foundations. The columns must never depend on each other, since the two ends only ever meet on the wire. OpenServoCore’s servo and host stacks are exactly this shape.

When To Use This, And When Not To

Let’s do some honest scoping, because the ceremony isn’t free.

Use it when the firmware has three or more non-trivially interacting peripherals, a protocol stack that should outlive the current chip, a multi-year horizon, or contributors to onboard. The pattern is unusually self-documenting, since reading any one file gives a complete picture of one concern.

Skip it when the firmware is a blinker, a thermostat, or a one-peripheral logger. A single struct with methods wins. And if you’re on RTIC or Embassy, use their ownership and scheduling primitives. This architecture is basically the minimum structure you need for framework-less firmware, and a framework already gives you a lot of that structure out of the box. The trait-inversion idea still transfers though, and it composes with async fine.

Here are the costs, so you can accept them with eyes open. A few lines of translation boilerplate at layer boundaries. One extra file per hardware role. Generic parameters that show up in compile errors (production providers are zero-sized, so the runtime cost is exactly nothing). And the composition rules are convention the compiler can’t check. The crate split hardens the big ones, and review carries the rest.

None of this is my invention, and the lineage is worth knowing. It borrows from hexagonal architecture (Cockburn), from domain-driven aggregates, where a composite driver is an aggregate root and “no sibling access” is the aggregate-boundary rule, and from CQRS’s command/query split. And for the embedded-specific ancestry, Miro Samek’s Practical UML Statecharts in C/C++, whose active objects (hierarchical state machines, no peer access, composed under discipline) are this pattern’s closest relative, written down in C twenty years ago. Rust adds the trait system, which makes the seams cheap, and cargo test, which makes the payoff immediate.

What’s Next

This architecture answers “how do I structure firmware that has to last”. It deliberately doesn’t answer “how do I make an exchange fast”. The hot-path engineering happens inside drivers, underneath all these seams. How the current protocol turns a request around in about 30 µs on a $0.16 chip, by processing the stream instead of buffering it, is its own article . Go check it out!