Add a board (chip + system manifest)
"What does it take to get my own board running?" On LabWired the answer is data: two YAML documents and a test script. There is no platform class, no board file in C#, nothing to compile into the simulator:
- the chip descriptor says what the silicon is — core, clock, flash and RAM regions, the peripheral windows the bus maps;
- the system manifest says what the board is — which chip it carries,
which external devices sit on which buses, and
board_io: which pin a user LED or button is soldered to.
Add a peripheral block (YAML) adds a register block inside a
chip and Add an I²C sensor (data + Python) wires a part to a
bus. This page goes one level up: a whole minimal Cortex-M4 board, built and
proved with labwired test — and then the feature that makes flash: and
ram: first-class memory regions: cpu_writes, the per-region CPU-write
policy whose fault mode turns a store into read-only flash into a precise
BusFault that names the region.
Everything below ran from a source checkout; the board lives in
/tmp/add-a-board/. Nothing on the page is invented output.
1. The chip descriptor — what the silicon is
chip.yaml, minimal but complete, every key annotated. Structure follows the
in-tree fixture configs/chips/ci-fixture-stm32l476-readonly.yaml
(and the C8 in configs/chips/stm32f103.yaml) — cut down to one GPIO
port and one UART:
# /tmp/add-a-board/chip.yaml — the SILICON as data. No board facts here.
name: "my-board" # free-form label for the chip
arch: "arm" # instruction-set family: arm | riscv | xtensa | avr
core: "cortex-m4" # the exact core; gates core-specific bus behavior
# (e.g. bit-band aliasing exists only on M3/M4)
cpu_hz: 80_000_000 # the clock THIS chip runs at, Hz — declared, not
# derived from a clock tree (see section 5)
flash:
base: 0x08000000 # start of the flash window; the boot alias mirrors it
# at address 0x0 (which is where reset fetches vectors)
size: "512KB" # 512 x 1024 — KB is binary; MB is 10^6, so spell
# binary-megabyte sizes in KB ("1024KB" = 1 MiB)
ram:
base: 0x20000000
size: "128KB"
peripherals:
- id: "gpioa" # the name board_io and firmware configs cite
type: "gpio" # a model the engine already has, by name
base_address: 0x48000000 # window base the bus maps
size: "1KB" # window length
config:
profile: "stm32v2" # a bare `gpio` MUST name its register layout;
# the engine never guesses one
- id: "uart1"
type: "uart"
base_address: 0x4000C000
size: "1KB"
# No `irq:` — a polled console needs no vector. A real board would wire
# the UART's interrupt line; `irq:` is an optional key on the peripheral.
Notes that save a round trip:
arch:andcore:are two fields because they answer different questions:archcollapses a family (armcovers the whole Cortex-M range), whilecorenames the exact part and gates behavior that is per-core rather than per-family (bit-band aliasing on M3/M4,core: "cortex-m33"elsewhere in the tree).- The
peripherals:entries are windows, not models:type:selects a register map the engine implements,base_address/sizesay where in the address space it answers.config:carries per-instance data — for a baretype: "gpio", the mandatoryprofile. - A chip file has no
board_io:. Which pin an LED is soldered to is a board fact, and board facts live in the manifest. size:strings take a bare byte count,0x…, or a unit:KBandKiBare 1024 bytes,MiBis 2^20, butMBis 10^6 — it is the long-standing reason 1 MiB of L476 flash is written"1024KB"rather than"1MB".
2. The system manifest — what the board is
system.yaml instantiates the chip and records the board wiring. The LED
binding is the same schema as the NUCLEO-L476RG's LD2 in
configs/systems/nucleo-l476rg.yaml:
# /tmp/add-a-board/system.yaml — the BOARD as data.
name: "my-board"
chip: "chip.yaml" # path resolved relative to THIS file (below)
external_devices: [] # parts on I²C/SPI/UART buses; none on this board
board_io: # board-level pin bindings
- id: "led_ld2" # a user LED ...
kind: "led"
peripheral: "gpioa" # ... on a peripheral id from the chip file ...
pin: 5 # ... at PA5
signal: "output"
active_high: true # lit when the pin reads high
How the chip: path resolves — the rule, not a guess:
- A bare word (
stm32f103) is a built-in chip name, resolved from the engine's embedded descriptor registry (or a loaded plugin), never a file. A spec counts as a path if it contains/or\, or ends in.yaml/.yml/.json. - A path like
chip.yamlor../chips/stm32l476.yamlis joined to the directory of the manifest file, not your shell's cwd (ResolvedSystem::from_manifest_file→ChipDescriptor::resolve_with). So a manifest is relocatable together with everything it names.
board_io: is what turns a bare SoC into a board: the browser's board view
reads the bound GPIO pin's output level through led_ld2 to light the LED; a
button binding with signal: input is additionally materialised as a
drivable stimulus. A plain labwired test run does not need the bindings —
but the LED line is the difference between "a chip" and "a board".
3. Run it
The script pins the board to a firmware image and states what must be true at
the end. Paths inside a script (inputs.firmware, inputs.system) resolve
relative to the script file, so the test directory is self-contained:
# /tmp/add-a-board/script.yaml
schema_version: "1.0"
inputs:
firmware: "firmware.elf" # relative to this script
system: "system.yaml"
limits:
max_steps: 100000
assertions:
- uart_contains: "OK"
- expected_stop_reason: max_steps
For the firmware, use a committed fixture for this core — the same
tests/fixtures/uart-ok-thumbv7m.elf the CI scripts use
(examples/ci/dummy-max-uart-bytes.yaml). It is a Thumb v7-M (Cortex-M3)
image, but the v7-M instruction subset executes on the M4 core, and the loader
maps its segments through the chip's boot alias (vectors at 0x0 mirror into
flash at 0x08000000), so it loads and runs unchanged (run from the checkout
root):
cp tests/fixtures/uart-ok-thumbv7m.elf /tmp/add-a-board/firmware.elf
cargo run -p labwired-cli -- test --script /tmp/add-a-board/script.yaml \
--output-dir /tmp/add-a-board/out
Real output:
2026-10-18T15:14:34.326220Z INFO labwired_core::system::builder: Loading chip descriptor: chip.yaml
2026-10-18T15:14:34.327492Z INFO labwired_loader: ELF Entry Point: 0x401
labwired-cli test: idle_ff=on (event_scheduler=on)
labwired-cli test: hosted fast path (exact windows up to 1024 cycles)
OK
PASS 2/2 checks · script · 100000 steps · 0.00s
The OK line is the firmware itself: it stores 'O', 'K', '\n' to the
UART's data register at 0x4000C000, and the test's uart_contains assertion
reads the captured console. The board boots, prints, and passes — no firmware
build in this walkthrough, just the committed fixture. The test-script schema
(limits, assertions, max_uart_bytes and friends) is in the
CI Test Runner.
4. Memory-region policy: cpu_writes
A firmware bug that scribbles over its own flash is one of the nastiest things
to debug on hardware: on many parts the store silently commits and the crash
lands somewhere else entirely. LabWired makes the region's policy explicit
data. Both flash: and ram: accept:
flash:
base: 0x08000000
size: "512KB"
cpu_writes: allow # allow (default) | warn | fault
ram:
base: 0x20000000
size: "128KB"
cpu_writes: allow
| Mode | A CPU store into the region… | Recorded in result.json |
|---|---|---|
allow (default) |
commits silently | this region records nothing; the whole block is absent only when both regions are allow |
warn |
commits; one deduplicated log line | every violation counted, first kept |
fault |
does not commit; the core takes a data fault | every violation counted, first kept |
The gate applies to CPU stores only (never loader writes, snapshot restores,
or the generic DMA path). fault refuses the store and, on ARMv7-M with
BusFault enabled, surfaces as a precise data BusFault with BFAR valid; warn
commits but logs one deduplicated WARN. Every non-allow region records each
violation and the first {addr, cycle, pc}, and result.json carries the
block only when at least one region is non-allow — the board built above is
all-default and has no such key. The authoritative rule set, including the
boot-alias corner and the image-linked-at-zero caveat, is
Configuration → cpu_writes.
The receipt: a store into cpu_writes: fault flash
The in-tree example examples/readonly-flash/ runs a tiny
firmware that stores to 0x0800_1000, inside flash, against the L476 fixture
chip whose flash declares cpu_writes: fault. The command, run in this
checkout, and its real output:
cargo run -p labwired-cli -- test --system examples/readonly-flash/system.yaml \
--script examples/readonly-flash/script.yaml \
--output-dir /tmp/labwired-readonly-flash
2026-10-18T15:14:34.606073Z INFO labwired_core::system::builder: Loading chip descriptor: ../../configs/chips/ci-fixture-stm32l476-readonly.yaml
2026-10-18T15:14:34.646398Z INFO labwired_loader: ELF Entry Point: 0x8000040
labwired-cli test: idle_ff=on (event_scheduler=on)
labwired-cli test: hosted fast path (exact windows up to 1024 cycles)
PASS 1/1 checks · script · 5000 steps · 0.00s
FAULT Precise BusFault: data access at 0x0800_1000 (BFAR valid; store to read-only flash), at PC 0x0800_006C in `sensor_read` (fault_fixture.c:61), called from `main`.
HFSR=0x00000000 CFSR=0x00008200 [PRECISERR] BFAR=0x08001000
The verdict names the region, the address, and the source line — the store
never committed. /tmp/labwired-readonly-flash/result.json carries the block
(a verbatim excerpt):
"memory_policy": {
"flash": {
"mode": "fault",
"violations": 1,
"first": {
"addr": 134221824,
"cycle": 7,
"pc": 134217870
}
},
"ram": {
"mode": "allow",
"violations": 0
}
}
violations: 1 is the firmware's own store at cycle 7 — the load of the same
image wrote flash directly and was not counted, which is the "CPU stores only"
rule in one number.
Flip cpu_writes to warn on the walkthrough board and re-run the same
store: make chip-warn.yaml = chip.yaml with cpu_writes: warn on its
flash:, and system-warn.yaml = system.yaml with
chip: "chip-warn.yaml". The new script — identical to script.yaml except
for the firmware and manifest — is:
# /tmp/add-a-board/script-warn.yaml
schema_version: "1.0"
inputs:
firmware: "fault-write.elf" # relative to this script
system: "system-warn.yaml"
limits:
max_steps: 5000
assertions:
- expected_stop_reason: max_steps
The store-to-flash ELF is the receipt's own firmware,
tests/fixtures/fault-verdict/fault-readonly-flash.elf (linked at
0x08000000), copied next to the script:
cp tests/fixtures/fault-verdict/fault-readonly-flash.elf \
/tmp/add-a-board/fault-write.elf
cargo run -p labwired-cli -- test --script /tmp/add-a-board/script-warn.yaml \
--output-dir /tmp/add-a-board/out-warn
Real output (the WARN line's timestamp is RFC 3339 wall-clock; wall-seconds in the PASS line vary between runs):
2026-10-18T18:41:56.727911Z WARN labwired_core::bus::memory_policy: CPU store into flash with cpu_writes: warn — store committed addr=0x08001000 pc=0x0800008E
PASS 1/1 checks · script-warn · 5000 steps · 0.00s
and the recorded first violation is unchanged ("mode": "warn", "violations":
1, addr 0x08001000, cycle 7) — committing is not forgetting.
5. What this board does not include
- Device models.
type: "gpio"andtype: "uart"are models the engine ships; a part like a sensor is a device you declare inexternal_devices:and model as data — see Add an I²C sensor (data + Python). - Peripheral register blocks. A register block the engine does not know is
its own YAML attached to the chip with
type: "declarative"— see Add a peripheral block (YAML). - Clock trees.
cpu_hz:is declared, not derived: nothing walks RCC/PLL registers to compute a clock. The chip file states the rate (and a board that runs the part slower overrides it withcpu_hz:in the manifest — the NUCLEO-L476RG declares 4 MHz because its firmware never leaves the MSI reset clock). Self-timed device models scale by it, so the number must agree with what the firmware was built against. - Firmware builds. This walkthrough reuses a committed ELF; for a real board bring-up — smoke firmware, validation trail, board docs — follow the board onboarding playbook.
Where to go for more
| Topic | Link |
|---|---|
| Full schema reference (chip, manifest, peripherals) | Configuration (YAML) |
| End-to-end onboarding process for a real board | Board onboarding playbook |
| The gold-reference board, page by page | NUCLEO-L476RG |
| The read-only-flash example and its README | examples/readonly-flash |
| Test scripts | CI Test Runner |