Add a peripheral block (YAML)
"My firmware talks to a register block the model does not have — how do I add
it?" Renode issue #303 asks exactly that about an EFM32GG
flash controller. On LabWired, a block whose behaviour is a register map plus
timed bit changes is data: one YAML descriptor listing registers, offsets,
access modes and fields, plus timing: entries for state that moves on its
own. No peripheral class, no C# project, no Rust. This page adds a missing
MSC-class flash controller to a chip, end to end, and proves it with
labwired test.
The mechanism is already load-bearing in this repo: the ESP32-C3 register wall
is 26 type: declarative peripherals in configs/chips/esp32c3.yaml, and
the field-level reference is Declarative Registers.
1. Read the datasheet into a register table
The worked example is a reduced EFM32/EFR32-family MSC (Memory System
Controller), the flash-controller block from the issue above. Six registers,
byte offsets from the block base (0x40030000 on EFR32MG26):
| Register | Offset | Access | Bits used here |
|---|---|---|---|
MSC_CMD |
0x000 |
write-only | START[0] — writing 1 starts the addressed operation |
MSC_CTRL |
0x004 |
R/W | — (storage) |
MSC_STATUS |
0x008 |
read-only | BUSY[0] running; DONE[1] finished |
MSC_ADDR |
0x00C |
R/W | — (storage) |
MSC_WDATA |
0x010 |
write-only | — (storage) |
MSC_RDATA |
0x014 |
read-only | — (storage) |
The blocked piece of firmware is the wait loop: it writes MSC_CMD = START,
then polls MSC_STATUS.BUSY/DONE. That transition is modelled with two
timing: entries — no code:
| Trigger | Delay | Action |
|---|---|---|
write MSC_CMD = 1 |
0 peripheral ticks | set_bits MSC_STATUS.BUSY |
write MSC_CMD = 1 |
64 peripheral ticks | set_bits MSC_STATUS.DONE, clear_bits MSC_STATUS.BUSY |
START is visible on the next peripheral tick and clears 64 ticks later. With
the default peripheral_tick_interval: 1 that is 64 CPU cycles — a
tick-quantised delay, not a datasheet figure; changing delay_cycles is the
one knob.
2. Write the block file
msc.yaml in full — copy-paste ready:
# EFR32MG26 MSC (Memory System Controller), reduced to the six registers this
# walkthrough needs. Sources: EFR32xG26 Reference Manual rev 1.0 section 6.7
# (register map) and section 6.8 (fields). Offsets are byte offsets from the
# block base, 0x40030000 on this part.
peripheral: "MSC"
version: "1.0"
registers:
- id: "MSC_CMD"
address_offset: 0x000
size: 32
access: "WRITE_ONLY"
reset_value: 0x00000000
fields:
- name: "START"
bit_range: [0, 0]
description: "Write 1 to start the addressed operation."
- id: "MSC_CTRL"
address_offset: 0x004
size: 32
access: "READ_WRITE"
reset_value: 0x00000000
- id: "MSC_STATUS"
address_offset: 0x008
size: 32
access: "READ_ONLY"
reset_value: 0x00000000
fields:
- name: "BUSY"
bit_range: [0, 0]
description: "Set while a started command is running."
- name: "DONE"
bit_range: [1, 1]
description: "Set when the command has finished."
- id: "MSC_ADDR"
address_offset: 0x00C
size: 32
access: "READ_WRITE"
reset_value: 0x00000000
- id: "MSC_WDATA"
address_offset: 0x010
size: 32
access: "WRITE_ONLY"
reset_value: 0x00000000
- id: "MSC_RDATA"
address_offset: 0x014
size: 32
access: "READ_ONLY"
reset_value: 0x00000000
# The state machine, in data. A START write raises BUSY on the next peripheral
# tick; 64 ticks later BUSY drops and DONE rises.
timing:
- id: "busy"
trigger: !write { register: "MSC_CMD", value: 1 }
delay_cycles: 0
action:
set_bits: { register: "MSC_STATUS", bits: 0x1 }
- id: "done"
trigger: !write { register: "MSC_CMD", value: 1 }
delay_cycles: 64
action:
set_bits: { register: "MSC_STATUS", bits: 0x2 }
- id: "clear_busy"
trigger: !write { register: "MSC_CMD", value: 1 }
delay_cycles: 64
action:
clear_bits: { register: "MSC_STATUS", bits: 0x1 }
Notes that save a round trip:
access:spellings areREAD_WRITE/READ_ONLY/WRITE_ONLY(aliasesR/W,rw,RO,r,WO,w). Access is enforced: a write to a read-only register is dropped, a read of a write-only register returns 0.fields:usesbit_range: [msb, lsb]and is what the inspector decodes; it does not mask the stored word.- The trigger is the tagged form
!write { ... }; serde_yaml 0.9 rejects the plain-map spelling of an externally-tagged enum. - One
timing:entry applies one bit action. A transition that sets one bit and clears another is two entries at the same delay, applied in declaration order.
3. Attach it to the chip
In the copy of the chip descriptor (here: a copy of
configs/chips/efr32mg26.yaml with its native msc entry removed, so
the block is genuinely missing again), add to peripherals::
- id: "msc"
type: "declarative"
clock: { reg: "clken1", bit: 16 }
base_address: 0x40030000
size: "4KB"
config:
path: "./msc.yaml"
type: "declarative"withconfig.pathis the attach arm;pathresolves relative to the chip file, so the descriptor travels with it.clock:is the chip's clock gate, not part of the block: on this part the MSC does not answer the bus untilCMU_CLKEN1bit 16 is set, exactly like silicon. Leave the key out if the block is not gated.
4. Prove it
Everything below ran from a source checkout with /tmp/apc/ holding chip.yaml,
msc.yaml, firmware.elf, system.yaml and script.yaml.
system.yaml:
A tiny firmware drives the block through its normal MMIO registers — clock the
MSC, start a command, accumulate every MSC_STATUS word it reads, publish the
final one. No LabWired-specific API:
/* Tiny Cortex-M firmware for the add-a-peripheral walkthrough: clock the MSC
* block, start a command, and publish what MSC_STATUS says. Pure MMIO — no
* LabWired-specific API. Built with arm-none-eabi-as/ld, linked at the EFR32
* flash base 0x08000000. */
.syntax unified
.cpu cortex-m33
.thumb
.section .vectors, "a"
.word 0x20010000 /* initial SP */
.word reset + 1
.rept 14
.word default_handler + 1
.endr
.text
.global reset
.thumb_func
reset:
/* 1. Clock the MSC block: CMU_CLKEN1 (0x40008068), bit 16. Until this
* store, every MSC access is dropped by the clock gate. */
ldr r0, =0x40008068
ldr r1, =0x00010000
str r1, [r0]
/* 2. MSC_CMD = START. */
ldr r0, =0x40030000
movs r1, #1
str r1, [r0, #0x00]
/* 3. Poll MSC_STATUS, accumulating every word seen. */
ldr r3, =0x20000000
movs r5, #0
ldr r4, =4000
poll:
ldr r2, [r0, #0x08]
orr r5, r5, r2
subs r4, r4, #1
bne poll
/* 4. Publish the accumulator and the final status word. */
str r5, [r3, #0x08] /* OR of every MSC_STATUS read */
ldr r2, [r0, #0x08]
str r2, [r3, #0x04] /* MSC_STATUS at the end */
park:
b park
.thumb_func
default_handler:
b default_handler
.ltorg
arm-none-eabi-as -o firmware.o firmware.S
arm-none-eabi-ld -nostdlib -e reset --build-id=none \
-Ttext=0x08000040 --section-start=.vectors=0x08000000 \
-o firmware.elf firmware.o
arm-none-eabi-strip firmware.elf
script.yaml asserts the two RAM words the firmware published. A
single-machine memory_value assertion reads through the bus, so the same
assertion can also read a register (or any memory-mapped word) directly:
schema_version: "1.0"
inputs:
firmware: "/tmp/apc/firmware.elf"
system: "/tmp/apc/system.yaml"
limits:
max_steps: 100000
assertions:
# OR of every MSC_STATUS word the firmware read while polling: both BUSY
# (bit 0) and DONE (bit 1) must have been observed.
- memory_value:
address: 0x20000008
expected_value: 0x3
mask: 0x3
size: 32
# The last MSC_STATUS read: BUSY clear, DONE set.
- memory_value:
address: 0x20000004
expected_value: 0x2
mask: 0x3
size: 32
- expected_stop_reason: max_steps
Run it (with an installed CLI, labwired test --script /tmp/apc/script.yaml
--no-uart-stdout):
cargo run -p labwired-cli -- test --script /tmp/apc/script.yaml \
--no-uart-stdout --output-dir /tmp/apc/out
Real output (wall-seconds vary between runs):
2026-10-18T14:27:00.743955Z INFO labwired_core::system::builder: Loading chip descriptor: /tmp/apc/chip.yaml
2026-10-18T14:27:00.749375Z INFO labwired_loader: ELF Entry Point: 0x8000041
labwired-cli test: idle_ff=on (event_scheduler=on)
PASS 3/3 checks · script · 100000 steps · 0.03s
Two sanity checks worth doing once, because a declarative block that answers zeros would also "load":
- Remove the CMU enable store from the firmware and rebuild. The same script
fails:
Memory assertion failed at 0x20000008 (size 32): expected 0x3, got 0x0 (mask 0x3)— the clock gate drops the accesses, as documented. out/result.jsonends with the block ininspect.peripherals(kind: "declarative") and decodes the final word:MSC_STATUS.value: 2,DONE = 1,BUSY = 0.
The test script schema (limits, assertions, memory_value details) is in
CI Test Runner.
Where to go for more
| Topic | Link |
|---|---|
| Field-level reference, side effects, timing entries | Declarative Registers |
| Register layout in context of a whole chip | Configuration (YAML) |
| Parts you wire on a bus (I²C/SPI sensors, actuators) — a different object from a chip block | Onboard a part |
| Deep reference for device descriptors and packs | Part packs |
| Test scripts | CI Test Runner |
What this does not do
- It does not write flash.
MSC_WDATAandMSC_ADDRare storage; no page erase or program happens, and noMSC_IF/MSC_IENinterrupt is raised. Add a hook (Rust) or a richer primitive only when firmware must observe the flash array itself. - It does not route interrupts. A descriptor can declare an
interrupts:map and atimingentry can nameinterrupt:; connecting that to the vector table is chip-level work, see Declarative Registers. - It does not connect DMA. Nothing in the descriptor reaches the DMA/LDMA fabric.
- Fields do not mask.
fields:is decode metadata for inspection; write masking is per register viaside_effects.write_action(e.g.write_one_to_clear), not per field. - Delays are tick-quantised.
delay_cyclescounts peripheral ticks, not nanoseconds; the default tick interval makes one tick one CPU cycle.