Architecture
Two independent axes, which is the part that confuses people. Layers 1–3 are
only about drivers. arch / soc / board is a separate axis about how
specific a piece of hardware is. A crate has a position on one or the other,
never both.
Word size
Section titled “Word size”32-bit only. Not a limitation waiting to be lifted — an assumption the code is built on:
| Where | What assumes it |
|---|---|
arch/xtensa TaskContext |
#[repr(C)] of u32s, indexed by fixed byte offset from vectors.S, size asserted at 96 |
arch/xtensa/flint32.ld |
Every region origin and length |
hal::bus::PhysicalBus |
Peripheral bases are u32 |
soc/esp32 |
The whole peripheral map, IO_MUX table and signal indices |
tools/size |
Parses ELF32 and rejects anything else |
A 64-bit port would rewrite the trap frame, the context switch and the memory map rather than widen them. No microcontroller in this class needs it, so it is not planned.
Hardware: arch / SoC / board
Section titled “Hardware: arch / SoC / board”Three things that vary independently, so three tiers.
| Tier | Owns | Example |
|---|---|---|
arch/xtensa |
CPU core — traps, context switch, tick, register model | Xtensa LX6 |
soc/esp32 |
Chip infrastructure — address map, IRQ crossbar, pin mux, clock gating | ESP32 |
board/ |
PCB — which pin is wired to what, and what is on it | Atom |
What does not go in arch or soc
Section titled “What does not go in arch or soc”Only what is specific to that CPU or chip and shared. Anything that is one peripheral is a driver, however chip-specific it is.
The test: would a second peripheral driver need this? An address map and a pin router, yes — that is
soc/. A pulse generator, no — that isdrivers/physical/.
RMT, the watchdogs and the RNG were modules of soc-esp32 until this rule was
written down; they are drivers/physical/esp32/* now. The ESP32 interrupt
crossbar was sitting in arch/xtensa, duplicated and dead, and was deleted.
ESP32 and ESP32-S3 share neither peripheral map nor core. A WROVER and an
M5Stack Atom share both and differ only in wiring. Keeping them apart is what
lets a new board be a manifest rather than a cfg tree through the kernel.
A manifest carries every fact an application would otherwise look up in a
datasheet — pins, addresses, IRQs, and the shape of whatever is attached. A
pin without the count of what is on it is half a fact: RGB_LED_GPIO alone let
an application drive one LED of a 25-LED panel and look correct while 24 stayed
dark. That is why the Atom is two boards, -lite and -matrix, declaring
RGB_LED_COUNT and RGB_LED_LAYOUT as well as the pin.
Two architectures today. The seam is parameterised now. Beside the
Xtensa/ESP32 pair sit arch/armv6m and soc/rp2040, and the RP2040 boards
(board-wio-rp2040-mini, board-raspberry-pi-pico) build through the same
kernel. The arch is selected by a Cargo feature (arch-xtensa / arch-armv6m)
that each board pulls in, and hal::TaskContext is a trait each arch implements
— the Xtensa register window and the Cortex-M exception frame are two impls of
it. Adding a third arch is another arch/ + soc/ pair, not a kernel rewrite.
One Xtensa-specific remnant remains: the raw early-boot fault reporter
(kernel/src/debug/fault.rs) writes the ESP32 UART0 FIFO directly, so it prints
only on ESP32 targets. It runs before the console exists; the board-owned
console (board::console) is what everything after bring-up uses.
Why pin routing lives in the SoC tier
Section titled “Why pin routing lives in the SoC tier”It has no portable implementation, only a portable contract:
| SoC | Model |
|---|---|
| ESP32 | GPIO matrix — almost any signal to almost any pad |
| STM32 | Alternate functions — a fixed short list per pad |
| NXP / i.MX | IOMUXC — a third model |
Drivers call PinMux::route(signal, pin, config). Each SoC crate answers in its
own idiom, or returns InvalidConfig where the silicon genuinely can’t comply.
Board manifests declare pins and nothing else.
Drivers: three layers
Section titled “Drivers: three layers” your app ┌─────────────────────────────────────────┐ ────────── │ task task task task │ ← you write these └────────────────┬────────────────────────┘ │ api Layer 3 ┌────────────────┴────────────────────────┐ ┌──────────────┐ logical drivers │ bme280 ssd1306 ws2812 │───────▶│ lib/ │ └────────────────┬────────────────────────┘ │ │ Layer 2 ┌────────────────┴────────────────────────┐───────▶│ led-strip │ bus abstraction │ spi-bus i2c-bus uart-bus │ │ led-matrix │ └────────────────┬────────────────────────┘ │ (contracts + │ Layer 1 ┌────────────────┴────────────────────────┐ │ pure code) │ physical drivers │ esp32-spi esp32-i2c esp32-uart │ └──────────────┘ │ esp32-rmt esp32-wdt esp32-rng │ └────────────────┬────────────────────────┘ ┌────────────────┴────────────────────────┐ │ soc/esp32 — address map, pin mux, IRQ │ └────────────────┬────────────────────────┘ ┌────────────────┴────────────────────────┐ │ hal — traits only, depends on nothing │ └─────────────────────────────────────────┘- Layer 3 knows a part number, not a chip.
- Layer 2 knows a protocol, not registers.
- Layer 1 knows one peripheral’s registers.
lib/is not a layer. See below.
New sensor → write only Layer 3. New MCU → write only Layer 1.
Enforced, not suggested
Section titled “Enforced, not suggested”tools/check-layers.sh is a whitelist per tier, and it runs in CI:
| Tier | May depend on |
|---|---|
hal |
nothing |
arch/* |
hal |
soc/* |
hal |
drivers/physical/* |
hal, soc/* |
drivers/bus/* |
api, lib/* |
drivers/logical/* |
api, lib/* |
lib/* |
other lib/* only |
What a dependency check cannot do: stop a driver writing to 0x3FF44008.
Raw MMIO needs no dependency at all, so the graph never sees it — an
adversarial review demonstrated exactly that against bme280.
#![cfg_attr(not(test), forbid(unsafe_code))] in each logical driver is what
closes it. The two together are the guarantee; the dependency check alone never
was.
lib/ — not drivers at all
Section titled “lib/ — not drivers at all”A driver knows a part number and its output is destined for a pin. ws2812
knows GRB order and 350 ns pulses; bme280 knows a humidity register.
lib/ crates know no chip, no bus and no pin, and return values instead.
led-matrix turns (x, y) into an integer and depends on nothing — not even
api. Filing that under drivers/ would be a false statement about what it
is.
It holds two kinds of thing:
| Kind | Example |
|---|---|
| Device-class contracts | trait LedStrip, trait Dimmable |
| Code generic over them | hue wheel, gradients, panel geometry |
Device classes: where a chip family generalises
Section titled “Device classes: where a chip family generalises”Multiple chips do the same job. The contract is written once, above them:
lib/led-strip trait LedStrip, trait Dimmable, effects ▲ ▲ws2812 apa102 each keeps the promise its hardware can keepThe lib never learns a chip exists — the dependency points upward from the driver. Adding a chip changes nobody else’s code; changing chips is one line in your application.
The promises are deliberately small. WS2812 has no brightness register, so
it implements LedStrip and not Dimmable. Under one fat trait it would have
to fake set_brightness by scaling the colour and losing depth, and a caller
could not tell that from an APA102 doing it properly in hardware.
So a missing impl is a real statement. To keep it from also meaning “nobody
got round to it”, make device-matrix prints who keeps which promise:
CONTRACT FROM IMPLEMENTED BY Dimmable led-strip (nobody yet) LedStrip led-strip ws2812It reports and never fails — a chip that cannot do a thing must not break the build for saying so.
Write the contract when the second chip arrives, not the first. With one
chip you are guessing what is common; with two you can see it. And a contract
written in the abstract goes wrong quietly: PulseEmitter had a single emit
method until a real panel used it, at which point it turned out to send 25
one-pixel frames instead of one, lighting only the first LED.
Repository
Section titled “Repository”apps/ applications — the binaries you flashhal/ traits + types everything depends on (depends on nothing)api/ the API your application usesarch/xtensa/ CPUsoc/esp32/ chipkernel/ scheduler, IPC, timers, IRQ routing, debug — a libraryboard/ PCBdrivers/physical/<soc>/ Layer 1 — one peripheral's registers each, by chipdrivers/bus/ Layer 2drivers/logical/ Layer 3 — one part number eachlib/ portable code — no registers, no part numberstools/ build helper, size report, the three checkersA package name is its directory leaf, unprefixed: hal/ is hal,
drivers/logical/bme280/ is bme280. Nothing here is published — every package
sets publish = false — so there is no global namespace to be unambiguous in,
and a prefix would only make every name longer.
One scheduler, every core
Section titled “One scheduler, every core”There is one scheduler and one ready queue, shared by every core. A task is not owned by a core; where it runs is a scheduling decision, and pinning it is a constraint you add rather than the default.
This holds only for symmetric cores. See Multicore.
The kernel is a library
Section titled “The kernel is a library”The binary is an application in apps/. See
Tutorial: Hello World.
That’s why board and debug features are selected by the app: it’s the only crate that can choose without the choice leaking into everything else that links the kernel.