GPIO nets between machines
A gpio_net interconnect joins GPIO pads of two or more world nodes into one
electrical net: an interrupt line, a ready line, a chip-select handshake, a wake
pin, or a shared open-drain alert line with a pull-up. Each member keeps
running its own firmware; the net decides what level the wire carries and when
each member sees it.
Runnable example: examples/gpio-net-two-boards
(an STM32G0B1 and an ATmega328P; env-rp2040.yaml and env-esp32c6.yaml
swap the ATmega328P for an RP2040 or an ESP32-C6).
Manifest
One gpio_net per wire. nodes lists every node that owns a member, once each.
interconnects:
- type: gpio_net
nodes: [avr, stm]
config:
name: alert # optional label shown in reports
pull: up # none (default) | up | down
latency_ns: 100 # default 100; zero is refused
members:
- { node: stm, peripheral: gpiob, pin: 4 } # open-drain output
- { node: avr, peripheral: portd, pin: 4 } # DDR drives low
| Key | Meaning |
|---|---|
members |
At least two pads on at least two nodes. peripheral is the node's GPIO peripheral id (gpioa, portd, gpio on an ESP32-family chip, sio on an RP2040, ...; see the table below). A pad can be on one net only; merge nets that share a pad. |
pull |
The board's resistor to a rail. A weak level, like a chip's internal pull (see below). |
latency_ns |
Wire delay from a pad edge to every member seeing it. Default 100 ns. Zero is refused, and so is anything below one cycle of the slowest member. |
What the net does
For the drives present at one instant, each member being driving-0 or driving-1 (strong), held by its own internal pull-up or pull-down (weak), or released with no pull:
- any member driving 0 gives 0, else any driving 1 gives 1;
- else the weak sources, which are the members' internal pulls and the net's
pull: all of them up gives 1, all down gives 0; - weak pulls to both rails with nothing driving are a resistor divider. The
net reports
GPIO_NET_PULL_CONFLICT(begin and end times, every member's drive) and reads the net's ownpull(the board resistor is normally much stronger than a chip's 30-50 kOhm internal one). With no netpullit reads 0, as contention does; - else the net floats: it reads 0 and is flagged
GPIO_NET_FLOATING.
A chip's internal pull is therefore part of the net. An input with
PUPDR = 01 on an STM32, or an ATmega pad with DDRx = 0 and PORTx = 1,
holds a wire high with no pull: on the net
(examples/gpio-net-f1-f4). Reports show it as the member's drive:
pull_up / pull_down next to z, low and high. The world watches each
member's own output stage (a LogicSource::Driver channel), on which a pulled
pad reads h / l (the IEEE 1164 weak levels). A probe on the pad
(LogicSource::Pad, the ordinary four-state pin trace) shows the wire as the
pad sees it: z for an undriven input, pulled or not, the level a peer
drives, and x where the pad's own output fights it.
Members driving 0 and 1 together are in contention. The wire resolves to 0
(a low-side driver usually wins) and the net reports GPIO_NET_CONTENTION with
the time it began, the time it ended, and every member's drive and own cycle.
Nothing is dropped silently.
Open-drain falls out of this: pads that only pull low or release, plus
pull: up, make a wired-AND. If one member holds the wire low and a second
joins and then the first lets go, the wire stays low until the second releases:
one low pulse, not two (open_drain_with_pull_up_is_a_wired_and in
crates/core/src/network/gpio_net.rs).
A member's drive is what its own output stage does
(PinPort::driver, decoded from the chip's
registers only). What the net feeds back into the pad (the level firmware
reads from IDR or PIND) is never part of it, so a pad cannot hold a wire up
just because it once saw it high.
Timing
Edges are delivered at t_edge + latency_ns, to every member including the
driver, at an exact cycle of each member (the first instruction boundary at or
after that time), through the same PinPort::set_external path a board button
uses (SystemBus::set_pad_external): EXTI interrupts, AVR INT0/INT1 and
pin-change interrupts, the ESP32-family and RP2040 GPIO interrupts, and timer
captures see a real edge.
Each node keeps its own clock (conservative parallel discrete-event simulation). A node may run as far as its safe horizon: for each net it is on, the earliest time a change it has not been told about can have happened, plus the net's latency. Three things bound that time:
- each other member's known time: the time it has reached, since everything it drove before then is already reported;
- for a member asleep until a scheduled event (a Cortex-M in
WFI, an ATmega328P inSLEEP, with idle fast-forward on), that event instead: it drives nothing before it unless an input wakes it, so the bound is the earliest of the event, a delivery already queued for it, an edge not yet merged into one of its nets (one latency later), and a peer's own known time plus one latency; - the earliest edge not yet merged into the net.
A node's own edges come back to it too, one latency later. Rather than hold a
node to one latency past its own time, the scheduler runs it until its next
own drive change: a push of a net pad ends the CPU batch it happens in
(LogicTap::set_batch_stop_channels), the machine stops at that boundary
(Machine::advance_to_cycle_or_net_drive_change), and the edge bounds the
node's horizon before it runs on. A node that cannot stop there (two cores, or
a pad on the per-cycle poll) keeps its own time in its horizon.
An edge nobody has reported yet happens after its driver's known time, so its delivery is due after the horizon: every delivery a node needs is known before the node gets there, whatever order the nodes run in and however far apart their clocks are. After a node runs, its drive changes are merged into its nets up to the time all their members have reached.
Results do not depend on node order, round length or how the world is driven:
world_multichip.rs (gpio_net_world) runs the example with both node orders,
with rounds from 10 ns to 100 ns, round by round, in one run_until_ps call and
on the old lockstep round driver (set_gpio_lockstep, still the driver for a
world that also has a timed UART network), with idle fast-forward off and on,
and compares every counter, every UART transcript, every applied delivery and
every node's cycle count, part way through and at the end
(the_per_node_scheduler_matches_the_lockstep_rounds); the same for the
ESP32-C6 world (the_esp32c6_world_is_the_same_on_every_driver) and the SPI
and I²C buses over nets (world_gpio_net_buses.rs).
A node skips idle time (a Cortex-M in WFI, an ATmega328P in SLEEP) only
when idle fast-forward is on for it (set_idle_fast_forward(true) on the
world's machine); the results are the same either way
(idle_fast_forward_skips_the_avr_sleep_and_changes_nothing).
step_all still advances the world by one round (the shortest latency) per
call, so a max_steps limit, the browser's step batches and Python's
run_for keep their meaning. World::step_all_into(&mut results) is the same
call with a result buffer (StepResults) kept from round to round instead of a
new map of node ids each time; labwired test and Python step with it.
World::run_until_ps(t) runs to t in one call, and World::step_rounds(n)
does n rounds in one call when that gives the same result (the browser's
step_batch uses it).
Speed
examples/gpio-net-two-boards, 30 ms of simulated time, release build with
the event scheduler, against the same two machines each run alone for 30 ms.
Instruction counts (callgrind, millions, deterministic; the ratio is to the
two machines alone, 1023 M) say more than wall time on a shared machine:
| How it is driven | Before | Now |
|---|---|---|
one run_until_ps / step_rounds call |
1303 M (1.27x) | 1015 M (0.99x) |
| the same, idle fast-forward on | 1395 M | 1104 M |
step_all per round |
2284 M (2.23x) | 1985 M (1.94x) |
step_all_into per round (labwired test, Python) |
n/a | 1556 M (1.52x) |
labwired test --script .../test.yaml (40 ms, per round, whole process) |
3516 M, 0.32 s wall | 2335 M, 0.26 s wall |
Wall time agrees within its noise (median of 6 runs, ±15% on a loaded
4-core machine): run_until_ps 0.124 s before, 0.083 s now, against 0.077 s
for the two machines alone. With idle fast-forward on there is no fair
"alone" figure: alone, the AVR sleeps through the whole run waiting for a peer
that is not there. The ESP32-C6 world (env-esp32c6.yaml, 2 ms, instruction
counts) costs what its C6 costs: run_until_ps is 1.01x the two machines
alone (1.04x before), step_all 1.09x (1.11x).
(cargo test --release -p labwired-core --features event-scheduler --test
world_multichip -- --ignored --nocapture gpio_net_speed prints the wall
times and how many pieces the scheduler cut the run into;
GPIO_NET_BENCH_ENV=env-esp32c6.yaml and GPIO_NET_BENCH_FF=1 pick the
other cases.)
What made the difference:
- A node runs until its own next net pad drive change, not one latency past its own time, and its pad state is read only when it stopped for one. On a net it keeps whatever batch width and idle fast-forward it has alone, and two busy nodes leapfrog by two latencies instead of one.
- A node asleep until its next event, with idle fast-forward on, holds its peers back only as far as that event, or whatever input could wake it first.
- A call that ends within one latency of the slowest node (a
step_allround) skips the horizons altogether, andstep_all_intoreuses its result buffer instead of building a map of node ids per round.
What is still slower than the machines alone, and why:
step_allstops every node at every round end: at 100 ns and 16 MHz that is one or two instructions per node per call, so the per-call cost (and the mapstep_allreturns) dominates. Drive long runs withrun_until_psorstep_rounds;latency_nsmakes rounds longer where a wire does not need to be fast.- Two busy nodes cannot run far apart: neither can know when the other will next drive a pad, so each runs at most about two latencies ahead of the other (the example's 30 ms run is 240 000 pieces, both firmware spinning in their final loops). Here the pieces cost about nothing in instructions and some 10% in wall time; a node whose every stop is expensive pays per piece.
- A sleeping node stretches its peers only with idle fast-forward on, and only until something can wake it: an edge a peer drives brings its horizon back to that edge plus one latency.
- A node with two cores (ESP32, ESP32-S3) or with a net pad on the per-cycle poll cannot stop at its own drive change, so it is still held to one latency past its own time.
- A node with push capture armed (every net node) does not use the CPU fast paths that skip the tap (the RISC-V spin and poll windows, the Cortex-M Thumb RAM chunks); that is the cost of stamping every pad write.
- An ESP32-C3 node booting through its ROM was not measured (the ROM image is not in this tree); its wide batches now end at its own net pad pushes rather than every latency, which is the case the batch stop exists for.
Machines on no net run unchanged and are not held to any net's latency.
Which pads can be on a net
A net member's GPIO model has to implement the pin interface
(docs/architecture/pins.md): report what it
drives and accept an external level. Every GPIO model in tree does:
| GPIO model | Capture | Notes |
|---|---|---|
GpioPort, every register family (STM32 v2 and f1, nRF52/54, Kinetis, EFR32 series 2, SAM, RA, i.MX RT) |
push: the port reports its own edges, idle fast-forward stays on | exercised end to end on STM32 v2 (G0B1, F401) and f1 (F103). The EXTI raises edge interrupts for net edges on G0 and U5 (port from EXTI_EXTICRx), F1 (port from AFIO_EXTICRx), F4 and L0 (port from SYSCFG_EXTICRx). Internal pulls on the net, and read by an input nothing outside drives: STM32 v2 PUPDR, STM32 f1 input-with-pull (CNF = 10, ODR picks the rail), nRF52 PIN_CNF.PULL, EFR32 INPUTPULL, SAM PINCFG.PULLEN, and, from the block that keeps them outside the GPIO port (the port's pad_control: in the chip yaml), Kinetis PORTx_PCRn.PE/PS, RA PmnPFS.PCR and i.MX RT IOMUXC SW_PAD_CTL PKE/PUE/PUS. |
GpioPort pads routed to a peripheral (AF) |
as above | the peripheral says what its output stage does (driving, released, input) and reads the level the net delivers; see Buses over nets. STM32 SPI, modern I²C and legacy (F1/F2/F4) I²C; the RP2040 SIO does the same for SPI and I²C pads (FUNCSEL); the nRF52 PSEL pin selects (SPIM/SPIS/TWIM/TWIS) likewise. |
avr_gpio (ATmega328P portb/portc/portd) |
push: the port reports its own edges, idle fast-forward stays on | INT0/INT1 and PCINT0..2 see external edges; a core parked in SLEEP is skipped until the next edge or Timer0 overflow. Exercised end to end on the Uno (gpio-net-two-boards). An input with its PORTx bit set is a pull-up on the net, unless firmware sets MCUCR.PUD, which takes every pull-up off. |
ESP32 classic gpio (member peripheral: gpio, pins 0..39) |
push | Drive: GPIO_ENABLE and GPIO_OUT (GPIO0..31), GPIO_ENABLE1 and GPIO_OUT1 at bit pin - 32 (GPIO32..39); GPIO_PINn.PAD_DRIVER open drain drives only a 0 and, holding a 1, reads the wire on GPIO_IN / GPIO_IN1. GPIO34..39 are input-only, as on silicon: their ENABLE1 bits store but never drive, and the matrix never routes an output to them; they take input and feed matrix input signals. Interrupts: GPIO_PINn.INT_TYPE edge and level types latch GPIO_STATUS / GPIO_STATUS1 and raise matrix source 22 for the CPU whose INT_ENA bit is set (GPIO_ACPU_INT(1), GPIO_PCPU_INT(1)). The IO_MUX pulls are not modelled on this part. Pads the GPIO matrix routes to VSPI, HSPI, I2C0 or I2C1 (GPIO_FUNCn_OUT_SEL_CFG out, GPIO_FUNCn_IN_SEL_CFG in, both indexed by the pad number in either bank) carry those peripherals' lines; see Buses over nets. |
ESP32-S3 gpio (peripheral: gpio, pins 0..48) |
push | As classic, both banks (GPIO_OUT1 / GPIO_ENABLE1 / GPIO_IN1 / GPIO_STATUS1 at bit pin - 32); matrix source 16 (GPIO_PCPU_INT / GPIO_PCPU_INT1, INT_ENA bit 13). The IO_MUX FUN_WPU pull-up is on the net. GPIO49..53 have registers but no pad, and cannot join a net. Pads the GPIO matrix routes to GP-SPI2, GP-SPI3, I2C0 or I2C1 carry those peripherals' lines. |
ESP32-C3 / ESP32-C6 gpio (peripheral: gpio, pins 0..25) |
push | As classic; matrix source 16 on the C3 and 30 on the C6. The IO_MUX FUN_WPU pull-up is on the net where the IO_MUX is wired. Edge types only (level types are not modelled on this block). Exercised end to end on the C6 (env-esp32c6.yaml). Pads the GPIO matrix routes to GP-SPI2 or I2C0 (FUNCn_OUT_SEL_CFG out, FUNCn_IN_SEL_CFG in, each chip's own signal numbers) carry those peripherals' lines; see Buses over nets. |
Declarative GPIO descriptor (ENABLE / OUT / IN registers) |
poll | Drive from ENABLE and OUT; no pulls. |
RP2040 sio (peripheral: sio, pins 0..29 = GP0..GP29) |
push | Drive: GPIO_OE and GPIO_OUT while IO_BANK0 selects SIO for the pad (or nothing yet); open drain is firmware toggling GPIO_OE with the latch at 0. Interrupts: IO_BANK0 INTRn / PROC0_INTEn edge and level bits raise IO_IRQ_BANK0 (NVIC 13) from any change of GPIO_IN, the pad's own output included. Exercised end to end (env-rp2040.yaml). |
On the ESP32 family and the RP2040 the GPIO interrupt sees the pad as
GPIO_IN reports it. On the classic ESP32 and the RP2040 that includes the
pad's own output; on the C3/C6 and S3 GPIO_IN is the external level only,
so a pad does not interrupt on its own output there.
A peripheral that owns no pins, or a pad whose drive is unknown, is refused when the world is built, naming the pad.
Limits worth knowing:
- On Kinetis, RA and i.MX RT the pull comes from the block the port names
in
pad_control:(mkw41z4gpioc→portc,ra4m1port1/port3→pfs,imxrt1064gpio2→iomuxc). A port declared without it has no pulls. i.MX RT pads must be contiguous in IOMUXC from the port's pin 0 (true for GPIO1, GPIO2 and GPIO4, not GPIO3). - A pad that firmware drives itself does not raise its own EXTI edge for its own transition (the STM32 EXTI model reacts to edges from outside). It does see the release when the wire rises after a peer let go.
- The ATmega328P samples INT0/INT1 and PCINT pads at instruction boundaries, so an edge is seen at the first boundary after it arrives. Waking from power-down, power-save or standby takes four cycles, as from idle: the oscillator start-up time is not modelled.
- A pad routed to a peripheral signal the model does not publish has no known drive and is refused. Pads start as plain GPIO, so this is checked when the world is built; a pad firmware later routes to such a signal keeps its last known drive on the net.
- The RP2040
GPIOn_CTRLoverride fields (OUTOVER,OEOVER,INOVER,IRQOVER) are stored but not applied, and only PROC0's interrupt is raised (the model is single-core).
Adding net support to a GPIO model
There is nothing net-specific to add: a GPIO model that implements
pins::PinPort is on nets, the logic analyzer, board buttons and EXTI at
once. The steps are in
docs/architecture/pins.md;
for a net the parts that matter are:
driver(pin): the pad's own output stage, decoded from the direction, output, open-drain and pull registers. Registers only. An open-drain output holding a 1 isOut::Off; an internal pull ispull, which the net counts as a weak source. A pad handed to a peripheral signal whose drive the model cannot see isNone, and the world refuses it.set_external(pin, External::Level(l)): the level the net settles on. Store it beside the registers (never in them, sodriver()cannot see it), fold it into the input register firmware reads, latch whatever interrupt the block raises from that register, and return the input before and after. The bus fans the change out to EXTI and timer captures by the port'sPortId.- Push capture, so a net does not force the node onto the per-cycle poll:
keep the
PadWatchinstall_watchhands you and bracket every register write andset_externalwithpins::watch_begin(self)/pins::watch_end(self). - If peripheral lines can be routed to the pad, implement
join_wire(mark the line as shared,PadLines::mark_on_net) androutes_changed. - Register the model in
crates/core/src/pins/conformance.rs. The suite checks the driver never echoes the net's level, open drain, pulls, the input round trip, and that push capture equals the poll.
Buses over nets
A hardware SPI or I²C peripheral whose pads are on nets talks to the other
chip bit by bit, at the pads. There is no byte-level shortcut between the
peripherals: the bits are the net's edges, so the net reports, a logic
analyzer on either board and the timing all show the real waveform, and the
bus behaves like wires. Runnable examples:
examples/gpio-net-buses (STM32G071 pairs,
STM32F401, STM32L476 and STM32G474 pairs on the hardware NSS pads,
STM32F103 / STM32F401 / STM32G071 mixed I²C, and an RP2040, an nRF52840
(SPIM0 and SPIM3 with its hardware CSN and D/CX), an ESP32-C3, an ESP32-C6,
an ESP32-S3 and a classic ESP32 against a G071 on SPI and on I²C).
One net per wire, as for any other signal:
- type: gpio_net # I²C: open drain, pull-up on the net
nodes: [a, b]
config:
name: sda
pull: up
members:
- { node: a, peripheral: gpiob, pin: 7 } # I2C1_SDA (AF6)
- { node: b, peripheral: gpiob, pin: 7 }
| Peripheral | Register file | Pads (chip yaml) | Roles |
|---|---|---|---|
| STM32 SPI | classic F1/F4/L0 (CR1.DFF) / FIFO G0/L4/G4/WB/F7 (CR2.DS, FRXTH, FRLVL/FTLVL); DMA requests (TXDMAEN/RXDMAEN, LDMA_TX/LDMA_RX, SPI DMA) |
NSS included on every table: G071 AF0 (pad_map: stm32g0); F4/F7 AF5 (PA4/PA15, PB9/PB12); L476 AF5/AF6 (SPI1 PA4/PA15/PE12, SPI2 PB9/PB12/PD0, SPI3 PA4/PA15 on AF6); G474 (pad_map: stm32g4, SPI1 PA4/PA15/PG5, SPI2 PB12/PF0 and PD15 on AF6, SPI3 PA4/PA15 on AF6); WB55 (pad_map: stm32wb, SPI1 PA4/PA15/PB2, SPI2 PB9/PB12/PD0); L073 (pad_map: stm32l0, SPI1 AF0 PA4/PA15, PE12 AF2; SPI2 PB12 AF0, PB9 AF5, PD0 AF1) |
master, slave |
| STM32 I²C, modern | L4/G0 TIMINGR (ISR/ICR) |
G071 AF6 (pad_map: stm32g0); L4 AF4 |
controller, target |
| STM32 I²C, legacy | F1/F2/F4 (SR1/SR2/CCR/TRISE) |
F103 default mapping (PB6/PB7, PB10/PB11); F401 AF4 | master, slave |
| RP2040 SPI | PL022 (SSPCR0/SSPCR1/SSPDR/SSPSR/SSPCPSR) |
IO_BANK0 FUNCSEL 1: SCK, TX, RX, CSn |
master, slave |
| RP2040 I²C | DW_apb_i2c (IC_CON/IC_TAR/IC_SAR/IC_DATA_CMD/...) |
IO_BANK0 FUNCSEL 3: SDA, SCL |
master, slave |
| nRF52 SPI | SPIM / SPIS, EasyDMA (TXD/RXD PTR/MAXCNT/AMOUNT) |
any pad, by PSEL.SCK/MOSI/MISO (and SPIS PSEL.CSN; SPIM3 PSEL.CSN and PSELDCX) |
master (ENABLE = 7), slave (ENABLE = 2) |
| nRF52 I²C | TWIM / TWIS, EasyDMA | any pad, by PSEL.SCL/SDA |
controller (ENABLE = 6), target (ENABLE = 9) |
| ESP32-C3 / ESP32-C6 GP-SPI2 | Espressif GP-SPI (SPI_CMD/SPI_USER/SPI_MS_DLEN/W0..W15/SPI_SLAVE), or by GDMA (SPI_DMA_CONF, SPI DMA) |
GPIO matrix: FSPICLK, FSPID, FSPIQ, FSPICS0 (63/65/64/68 on both), any pad |
master, slave |
| ESP32-C3 / ESP32-C6 I²C0 | Espressif command list (CTR/COMD0..7/DATA FIFOs/SLAVE_ADDR) |
GPIO matrix: I2CEXT0_SCL/SDA (53/54 on the C3, 45/46 on the C6), any pad, out and back in |
master, slave |
| ESP32-S3 GP-SPI2 / GP-SPI3 | Espressif GP-SPI, as the C3 | GPIO matrix: SPI2 FSPICLK, FSPID, FSPIQ, FSPICS0 (101/103/102/110, spi2), SPI3 SPI3_CLK, SPI3_D, SPI3_Q, SPI3_CS0 (66/68/67/71, spi3), pads 0..48 |
master, slave |
| ESP32-S3 I²C0 / I²C1 | Espressif command list, as the C3 | GPIO matrix: I2CEXT0_SCL/SDA (89/90, i2c0), I2CEXT1_SCL/SDA (91/92, i2c1), pads 0..48, out and back in |
master, slave |
| ESP32 (classic) VSPI / HSPI | classic SPI (SPI_CMD.USR bit 18, SPI_USER, SPI_MOSI_DLEN/MISO_DLEN, SPI_PIN, SPI_SLAVE, W0..W15 at 0x80) |
GPIO matrix: VSPI VSPICLK/VSPID/VSPIQ/VSPICS0 (63/65/64/68, spi3), HSPI HSPICLK/HSPID/HSPIQ/HSPICS0 (8/10/9/11, spi2), outputs on pads 0..33, inputs on pads 0..39 |
master, slave |
| ESP32 (classic) I²C0 / I²C1 | Espressif command list, classic opcodes (COMD0..15) |
GPIO matrix: I2CEXT0_SCL/SDA (29/30, i2c0), I2CEXT1_SCL/SDA (95/96, i2c1 at 0x3FF6_7000), pads 0..33 out and back in (0..39 in) |
master, slave (no stretching) |
SPI (STM32 classic/FIFO SPI): SCK, MOSI, MISO and NSS are push-pull
nets. A master (MSTR=1, SPE=1) drives SCK and MOSI and samples the level
the net delivers to its MISO pad at its own sampling edge; that is what lands
in DR. A slave (MSTR=0) has no clock of its own: it reacts to the SCK,
MOSI and NSS edges the net delivers, shifts MOSI in on its sampling edge and
puts its transmit word out on MISO on its shift edge. Any CPOL/CPHA, MSB or
LSB first; 8 or 16 bits (CR1.DFF) on the classic F1/F4/L0 SPI, 4 to 16
bits (CR2.DS) on the FIFO-type G0/L4/G4/WB/F7 SPI: modes 0-3, LSB-first,
16-bit frames on an F401 pair, and 4-, 8-, 12- and 16-bit frames on a G071
pair (each chip as master) each have an end-to-end test. A slave sets RXNE
with the SPI interrupt when RXNEIE is set; TXEIE interrupts whenever TXE
is set (enabling it with TXE set interrupts at once), so a slave can feed
every answer from its TXE interrupt. A DR write fills the slave's transmit
buffer even while SPE=0. A frame that starts with nothing written to DR
is an underrun: the shift register goes out as it is, which is the previous
frame received (0 after reset), and Spi::net_slave_underruns() counts it
(logged once). It drives MISO only while selected: SSM=1, SSI=0, or SSM=0
with its NSS pad low. A master with SSOE=1 drives NSS low while enabled. A
disabled SPI drives nothing. A word store to DR is one frame (bits 31:16
are not part of the register).
The two register files differ in their buffers, as on silicon. Classic: one
receive buffer, RXNE on every frame, OVR when a frame arrives before DR
was read, one transmit buffer. FIFO-type (RM0444 §35.5.9): 32-bit RX and TX
FIFOs, a frame of up to 8 bits taking one byte and a wider one two; RXNE
when the RX FIFO holds CR2.FRXTH's threshold (one byte with FRXTH=1,
two with the reset FRXTH=0 — so 8-bit firmware that never sets FRXTH
waits for a second frame, as it does on a real G0); TXE while the TX FIFO is
at most half full; SR.FRLVL/FTLVL report both levels; OVR when a frame
arrives to a full RX FIFO. DR accesses pack: an 8-bit access moves one
byte, a 16-bit one two (two frames at DS ≤ 8). A FIFO master with
NSSP=1, SSOE=1 and CPHA=0 pulses NSS high for one SCK period between
back-to-back frames and leaves it high after the last. The tables come from
ST's open pin data (the CubeMX source of each datasheet's AF table); the G4,
WB and L0 have their own (pad_map:) because they disagree with the L4 or
F4 one on some pad (PE12-PE15/AF5 are SPI4 on a G4; the L0's SPI1 is on
AF0). Tested: an L476 pair and a G474 pair, master SSOE=1 / slave SSM=0
on PA4, with and without NSSP (every frame its own NSS low period when the
master polls between frames), and a slave whose NSS net is removed, which is
never selected.
I²C, modern (STM32 L4/G0 TIMINGR register file): SCL and SDA are
open-drain nets; put pull: up on them. The controller generates START, the
address, data, ACK/NACK, repeated START and STOP with SCLL/SCLH/SDADEL
timing, and counts the SCL high period from when it sees SCL high, so a
target holding SCL low stretches the clock. A NACK sets NACKF and sends
STOP; AUTOEND sends STOP after NBYTES, otherwise TC holds SCL low for a
repeated START. With RELOAD, TCR holds SCL low after NBYTES until
firmware writes the next NBYTES (transfers above 255 bytes; the last byte
of a chunk is ACKed). ADD10 sends the 10-bit sequence: write header and low
byte, and for a read a repeated START with the read header (only the read
header with HEAD10R). A released SDA read back low is arbitration lost
(ARLO). A target watches START/STOP and its address on the wire: OAR1
(7-bit, or 10-bit with OA1MODE), OAR2 with OA2MSK, and the general call
with GCEN; it ACKs those only, sets ADDR, DIR and ADDCODE (11110xx
for a 10-bit match), and stretches SCL until firmware clears ADDR, reads
RXDR or writes TXDR. With NOSTRETCH=1 it does not stretch: an empty
TXDR goes out as it is and a byte arriving while RXDR is full is NACKed
and lost, both with OVR. A transfer to an address nobody owns is the
pull-up reading 1 in the ACK slot.
I²C, legacy (STM32 F1/F2/F4 CR1/CR2/OAR1/OAR2/DR/SR1/SR2/
CCR/TRISE): the same open-drain wire, driven by the register sequences of
RM0008/RM0368. Master: START sets SB/MSL/BUSY once START is on the wire;
the DR write after it is the address byte, whose ACK sets ADDR (TRA
from R/W) and whose NACK sets AF. A transmitter takes DR after ADDR is
cleared (SR1 then SR2 read) and holds SCL low with BTF while DR is empty;
a receiver ACKs by CR1.ACK (with POS, the value in force one byte
earlier) and holds SCL low with BTF while a byte waits behind a full DR.
STOP goes out at once from a held bus, otherwise after the byte in flight.
Slave: it ACKs OAR1 (7-bit), OAR2 with ENDUAL and the general call
with ENGC while CR1.ACK is set, stretches SCL while ADDR is up and
while DR is full (receiver) or empty (transmitter), sets AF when the
master NACKs what it sent and STOPF when a write ends with STOP. A byte
loaded into DR that never went out stays there and is the first one the
next read gets. NOSTRETCH skips the stretch after the address. Timing is
CCR's (standard mode, fast mode, DUTY) in PCLK1 periods taken as core
cycles. Event and error interrupts (ITEVTEN/ITBUFEN/ITERREN, separate
error vector where the chip yaml gives irq_error) are raised when a flag
is set and when an enable is written while its flag is up.
SPI, RP2040 (PL022, Motorola frame format): SCK, TX, RX and CSn. A
master (SSPCR1.MS = 0) clocks the words queued in its 8-deep TX FIFO at
CPSDVSR × (1 + SCR) core cycles per bit with SCK idling at SPO and the
edges SPH gives; TX is MOSI and RX (sampled at each sampling edge) is MISO,
and each word lands in the 8-deep RX FIFO. CSn is low for a frame; with
SPH = 0 it goes high for one SCK period between back-to-back frames, with
SPH = 1 it stays low until the FIFO runs dry. A slave (MS = 1) reacts to
the CSn, SCK and RX (MOSI) edges the net delivers, drives TX (MISO) only
while CSn is low and SOD is clear, and answers from its TX FIFO; a frame
with the TX FIFO empty sends zeros and is counted
(Rp2040Spi::net_underruns). SSPSR follows the FIFOs and the shift engine.
Tested against an STM32G071 both ways round (RP2040 master in mode 0 with
hardware CSn, RP2040 slave in mode 3). Not on a net: the PL022 interrupt
registers, DMA, TI/Microwire formats and loopback.
I²C, RP2040 (DW_apb_i2c): the same open-drain wire. A master
(IC_CON.MASTER_MODE) works through its 16-deep command FIFO
(IC_DATA_CMD: a byte to write or a read, with STOP and RESTART):
START and the IC_TAR 7-bit address when the FIFO has a command, a
repeated START with the address again on a change of direction or
RESTART, STOP after a command that asks for it, and SCL held low while the
FIFO is empty without one (the RP2040's IC_EMPTYFIFO_HOLD_MASTER_EN). A
read is ACKed unless it carries STOP. A NACKed address or byte aborts with
TX_ABRT (ABRT_7B_ADDR_NOACK / ABRT_TXDATA_NOACK), flushes the FIFO and
sends STOP. Timing is IC_SS/FS_SCL_LCNT/HCNT with IC_SDA_HOLD. A slave
(MASTER_MODE = 0, IC_SLAVE_DISABLE = 0) ACKs IC_SAR, fills the RX FIFO
(FIRST_DATA_BYTE on the first byte after the address), answers a read from
its TX FIFO and otherwise holds SCL low with RD_REQ until firmware writes
IC_DATA_CMD; the master's NACK ends a read with RX_DONE. STOP_DET,
START_DET, RX_FULL/IC_RX_TL, TX_EMPTY/IC_TX_TL and the IC_CLR_*
registers follow the DW names; the tests poll IC_RAW_INTR_STAT. Tested
against an STM32G071 both ways round, including a slave that answers too
slowly for the clock. Not on a net: 10-bit addressing, the general call,
SMBus, DMA and RX_FIFO_FULL_HLD_CTRL (a full RX FIFO drops the byte with
RX_OVER).
nRF52 (nRF52840 instance 0 at 0x40003000, the window ENABLE turns
into SPIM, SPIS, TWIM or TWIS; also the standalone TWIM1 and SPIM2): the
pads are whichever ones the PSEL registers name, so a peripheral is on a
net when the pad its pin select names is (and it is enabled). A write that
moves a PSEL or ENABLE is bracketed like an RP2040 FUNCSEL write: the
port reports what moved and the net's level reaches the newly routed line.
The window is one register file: what firmware programs before ENABLE
(buffers, CONFIG, SHORTS, INTEN) reaches every personality. EasyDMA
reads a TX buffer when its transfer is armed (TASKS_START, STARTTX,
TASKS_RELEASE, PREPARETX) and writes the RX buffer before the event that
ends it; the events raise the interrupt where INTEN asks for it.
- SPIM:
TASKS_STARTclocksmax(TXD.MAXCNT, RXD.MAXCNT)bytes back to back atFREQUENCYin theCONFIGshape (ORCpast the TX buffer), sampling MISO at the net's level;EVENTS_STARTED, thenENDTX,ENDRX,ENDwith the amounts.TASKS_STOPends after the byte on the wire (STOPPED). On SPIM0..2 chip select is a GPIO. SPIM3 (0x4002F000,spi3on the nRF52833/nRF52840) drives its own: withPSEL.CSNconnected, CSN sits at its inactive level while enabled (CSNPOL0: active low), is asserted once the TX buffer is read, and the first byte startsIFTIMING.CSNDUR64 MHz cycles later (first SCK edgeCSNDUR+ half an SCK period after the CSN edge); after the last SCK edge CSN is heldCSNDURmore, released, and only thenENDfires. The next assertion waits until CSN has been inactive forCSNDUR. WithPSELDCXconnected D/CX idles high and is low for the firstDCXCNTbytes (all of them at 0xF): it falls with CSN, rises at the last SCK edge of the last command byte, and is high again when CSN is released.IFTIMING.RXDELAYis stored, not modelled. - SPIS: the semaphore decides.
TASKS_RELEASEgives the slave the buffers; CSN low then starts a granted transaction (SEMSTAT = 2) that shifts out the TX buffer, thenORC(STATUS.OVERREAD), and fills the RX buffer up toMAXCNT(STATUS.OVERFLOWpast it). With the CPU holding the semaphore it sendsDEF. CSN high:ENDRX,END, the amounts, and the semaphore back to the CPU withACQUIREDwhen it asked (TASKS_ACQUIRE) orSHORTS.END_ACQUIREis set. MISO is driven only while CSN is low. - TWIM:
STARTTX/STARTRXsend START (repeated START from a held bus) andADDRESS;LASTTX/LASTRXfire at the start of the last byte with their shortcuts (LASTTX_STARTRX,_SUSPEND,_STOP,LASTRX_STARTTX,_SUSPEND,_STOP). A received byte is NACKed when STOP or a change of direction follows. A NACK setsERRORSRC.ANACK/DNACKwithERRORand holds the bus untilTASKS_STOP(nrfx'sRESUME+STOP). With nothing left to do the TWIM holds SCL low. Timing:FREQUENCY(K100/K250/K400 of 64 MHz), SCL low and high half a bit each. - TWIS: ACKs
ADDRESS[0]/[1]asCONFIGenables them (MATCH), firesWRITE/READ, and stretches SCL until firmware prepares a buffer (TASKS_PREPARERX/PREPARETX, orRESUMEafterWRITE_SUSPEND/READ_SUSPEND);RXSTARTED/TXSTARTEDfollow. PastRXD.MAXCNTa byte is NACKed withERRORSRC.OVERFLOW | DNACK; past the TX bufferORCgoes out (OVERREAD). Each phase uses up its prepare; STOP firesSTOPPEDwith the amounts.
Tested against an STM32G071 both ways round on each bus (SPIM master /
SPIS slave in mode 0, TWIM controller / TWIS target with the env-i2c.yaml
register-file exchange, the NACK of an absent address, a slow G071 target
holding the TWIM and a slow TWIS holding the G071), and SPIM3 on its
hardware CSN against the G071's hardware NSS (env-spi-nrf52-spim3.yaml:
the CSN-to-SCK and SCK-to-CSN times, D/CX around a one-byte command, and an
active-high CSNPOL that deselects the G071 for the transfer). Not on a
net: SPIM3's CSN/D/CX for an attached device model (off a net the bytes
cross inside one call; the nRF54L map does select such a device), the legacy
SPI/TWI (ENABLE = 1/5), the TWIM ERRORSRC.OVERRUN, the nRF5340 and
nRF54 SPIM/TWIM, and the SPIS/TWIS on a window other than a serial
instance (the nRF52840 chip yaml has them only at 0x40003000).
SPI, ESP32-C3 / ESP32-C6 (GP-SPI2; the C6 runs the same model): each
line reaches its pad through the GPIO matrix, out (FUNCn_OUT_SEL_CFG with
the pad's output stage on) and in (FUNCn_IN_SEL_CFG, SEL set). A master
(SPI_SLAVE.MODE = 0) runs the transaction SPI_CMD.USR starts on the
wire, bit by bit at the SPI_CLOCK rate: CS0 low (unless CS0_DIS) for
CS_SETUP, the command, address and dummy phases when enabled, then the
data, full duplex (DOUTDIN: MS_DLEN + 1 bits out of W0..W15 while
MISO is sampled) or half duplex (a write phase, then a read phase); the bits
read land in W0.., USR clears and SPI_TRANS_DONE latches after
CS_HOLD. CPOL is MISC.CK_IDLE_EDGE, CPHA USER.CK_OUT_EDGE XOR CPOL (how
esp-idf and esp-hal set the four modes), bit order CTRL.WR/RD_BIT_ORDER.
A slave (MODE = 1, CPU-controlled) answers from W0..W15 as they are when
CS0 falls, drives MISO only while selected, and when CS0 rises writes the
bits it sampled to W0.., their count to SLAVE1.DATA_BITLEN, and latches
SPI_TRANS_DONE (CPOL CK_IDLE_EDGE, CPHA SLAVE.CLK_MODE_13, as
spi_ll_slave_set_mode sets them). Tested against an STM32G071 both ways
round on the C3 and the C6 (register-level firmware), and with esp-hal
1.1's blocking master driver (hardware CS0) on the C3. Both roles also run
by GDMA (see SPI DMA). Not on a net: dual/quad modes, segmented
transfers and the master's CS polarity bits.
I²C, ESP32-C3 / ESP32-C6 (I2C0): the same open-drain wire engine as the
legacy STM32 and RP2040 I²C, driven by the command list. A master
(CTR.MS_MODE) runs COMD0.. on TRANS_START: RSTART, WRITE n from
the TX FIFO (the address is its first byte, as esp-idf and esp-hal queue it)
with each ACK slot compared to ack_exp when ack_check_en is set,
READ n into the RX FIFO answered with ack_value, STOP
(TRANS_COMPLETE) and END (END_DETECT, SCL held low until the next
TRANS_START); each command sets command_done, SR.RESP_REC records the
ACKs. A mismatched ACK latches NACK and the master sends STOP (on silicon
the driver resets the controller after a NACK, which also frees the bus).
Timing is CLK_CONF with SCL_LOW/HIGH_PERIOD and SDA_HOLD. A slave
(MS_MODE = 0) ACKs its 7-bit SLAVE_ADDR, fills the RX FIFO
(RXFIFO_WM), answers reads from the TX FIFO and, with it empty and
SLAVE_SCL_STRETCH_EN set, holds SCL low with SLAVE_STRETCH until
firmware writes SLAVE_SCL_STRETCH_CLR; STOP after it was addressed latches
TRANS_COMPLETE. Tested against an STM32G071 both ways round on the C3 and
the C6, including a NACK and a slow target on either side that stretches
SCL, and with esp-hal 1.1's blocking master driver on the C3. Not on a net:
10-bit addressing, the general call, SLAVE_BYTE_ACK_CTL, the FIFO-address
and non-FIFO modes, filters and timeouts.
ESP32-S3 and classic ESP32. The S3 GP-SPI2 and I2C0 are the C3's
register files and run the same two engines, with the S3's matrix numbers
(FUNCn_IN_SEL_CFG: pad [5:0], SEL bit 7) and its I²C timing
(CLK_CONF source clock, 240 MHz core). The classic part's SPI is the older
IP: the same fields at other offsets and bits (SPI_CMD.USR 18, SPI_USER
CS_HOLD 4 / CS_SETUP 5 / CK_OUT_EDGE 7, SPI_CTRL2 setup and hold
times, SPI_MOSI_DLEN / SPI_MISO_DLEN, SPI_PIN CK_IDLE_EDGE /
CS0_DIS / CS_KEEP_ACTIVE, W0 at 0x80); it answers the GP-SPI engine
through a translated register view. A classic slave is SPI_SLAVE.SLAVE_MODE
(bit 30) with CPHA from SPI_USER.CK_I_EDGE as esp-idf's
spi_ll_slave_set_mode sets it (mode 0: CK_I_EDGE = 1); its bit count
lands in SPI_SLV_RD_BIT, and master and slave both report done in
SPI_SLAVE.TRANS_DONE (bit 4). VSPI (spi3, 0x3FF6_5000) and HSPI
(spi2, 0x3FF6_4000, now on the bus) each have their own matrix signals.
The S3's second GP-SPI (spi3, 0x6002_5000) and I2C1 (i2c1, 0x6002_7000)
are the same models as SPI2 / I2C0 with their own matrix signals, and so is
the classic I2C1 (i2c1, 0x3FF6_7000, interrupt source 50, AHB FIFO alias
0x6002_701C), which is now on the classic bus.
The classic I2C0 and I2C1 run the command-list engine with the classic opcodes
(RSTART 0, WRITE 1, READ 2, STOP 3, END 4) and SCL_LOW/HIGH_PERIOD and
SDA_HOLD in APB cycles; it has no SCL_STRETCH_CONF, so a classic target
whose TX FIFO is empty sends 0xFF instead of stretching: firmware keeps the
TX FIFO loaded ahead of a read, as the example does. A TX byte written
through the classic AHB FIFO alias (0x6001_301C) reaches a master WRITE
waiting for it only with the next APB register write. Tested against an
STM32G071 both ways round on each part (env-spi-esp32s3*.yaml,
env-spi-esp32*.yaml, env-i2c-esp32s3*.yaml, env-i2c-esp32*.yaml;
hand-written Xtensa assembly, see the example's build.sh), including an
address NACK, a slow G071 target holding either controller, and a slow S3
target stretching the G071's clock. The second instances run on bank-1
pads (GPIO32 and up) in the same tests: the S3's GP-SPI3 on GPIO34..37 and
I2C1 on GPIO41/42 (env-spi-esp32s3-spi3*.yaml,
env-i2c-esp32s3-i2c1*.yaml), the classic I2C1 on GPIO32/33
(env-i2c-esp32-i2c1*.yaml), and the classic VSPI master / HSPI slave with
MISO, or SCK, MOSI and CS0, on the input-only GPIO34..36
(env-spi-esp32-hi*.yaml). Plain GPIO crosses bank-1 pads between a classic
ESP32 and an ESP32-S3 (env-gpio-esp32-esp32s3.yaml: OUT1 onto the other
part's IN1, each edge latched in GPIO_STATUS1).
The ESP matrix routes follow firmware: a line becomes a net line the first
time firmware routes it (in or out) to a pad on a net. IO_MUX direct
functions (MCU_SEL other than GPIO) and IN_INV_SEL/INV_SEL are not
followed, so choose pads without an IO_MUX function for the signal (esp-hal
then uses the matrix as well).
Clock stretching is tested on every target: an STM32G071 (modern), an
STM32F401 (legacy), an RP2040 and an ESP32-C3 slave whose firmware answers
several SCL low periods late each hold the controller, and the bytes and
clock count come out the same; a slow G071 target holds an ESP32-C3
controller (register level and esp-hal), an ESP32-S3 and a classic ESP32
controller the same way, and a slow ESP32-S3 target holds the G071. A target that ends a stretch with a new bit on SDA releases
SCL a data setup time later (SDADEL-style, one quarter of its own SCL low
period on the legacy and RP2040 engines, 250 ns on the nRF52 TWIS), so the
change is never read as START or STOP.
The legacy and modern controllers meet only on the wire, so an F103 target answers a G071 controller as it answers an F401 one (tested both ways round between F103 and F401, and G071 → F103).
Everything is event-scheduled: a master's bits run at their exact cycles, a
slave or target runs when the net delivers an edge (at the cycle the world
applies it, even on a timed core that publishes its cycle to the bus only at
peripheral ticks). The one poll is a chip
holding SCL low until firmware does something with a register read, which
cannot wake the model: reading RXDR (modern), or clearing ADDR and
reading DR (legacy). It looks every quarter SCL low period, and at every
register write.
Two physical limits come with the wires:
- An answer crosses the wire twice. MISO answers a clock edge only after the
edge reached the slave and the answer came back, so two
latency_nsmust fit in half an SCK period; slow SCK (CR1.BR) or shorten the latency when they do not. The same holds for I²C data and ACKs inside the SCL low period. - Give SCL and SDA (and SCK and MOSI) the same
latency_ns. The receiver tells data from START/STOP by the order the two wires change in.
The results do not depend on node order or on the world's round size; each example's tests run every exchange three ways (as written, nodes swapped, 37 ns rounds) and compare what every pad saw, cycle by cycle.
Not modelled on a net yet: other SPI/I²C families (the H5 SPI v3, whose NSS
pads stay unrouted), legacy-I²C
10-bit addressing, SMBus/PEC, modern-I²C slave byte control (SBC), the I²C
filters and timeouts, SPI CRC, TI mode and the SPI mode fault (MODF). Such
a peripheral on a net keeps its usual behaviour and drives its lines
push-pull as before.
SPI DMA
The data of an SPI transfer can move by DMA on either chip, master or slave,
on a net or off it (byte-level devices on the bus); the bits on the wire are
the same as with the CPU feeding DR or W0..W15.
STM32 (classic F1/F4/L0 and FIFO G0/L4/G4/WB/F7 SPI). CR2.TXDMAEN
raises a request while TXE is set (the classic port's buffer is empty; the
FIFO port's TX FIFO is at or below half full) and the SPI is enabled;
CR2.RXDMAEN while RXNE is set (on the FIFO port it follows FRXTH). Each
request is held until the DR write (TX) or read (RX) that answers it, so
the DMA moves one item per free slot or received frame. The request reaches
the DMA channel the chip's routing registers select: the F1's fixed channels
and the L4's CSELR (type: dma, config.requests: { spi1_tx: 3, spi1_rx:
[2, 1] }), the F4's SxCR.CHSEL (type: stm32f4_dma, config.requests: {
spi1_tx: [[3, 3], [5, 3]] }, stream and channel), and on the G0 the
DMAMUX (type: dmamux, DMAMUX_CxCR.DMAREQ_ID, channel x feeding DMA1
channel x + 1). The G071, F103, F401, F407 and L476 chip files declare their
SPI routes. Each DMA item is one access at its programmed width, so a
half-word item packs two frames at DS ≤ 8 bits; with CR2.LDMA_TX the last
item of a transfer (the DMA tells the SPI, as its end-of-transfer signal
does) carries one frame, and with LDMA_RX and FRXTH = 0 the odd last byte
is requested below the 16-bit threshold. Enable the DMA channel before the
SPI's request bit, as HAL does: a request raised while no channel is enabled
is not seen again until it is answered.
ESP32-C3 / ESP32-C6 (GP-SPI2 by GDMA). With SPI_DMA_CONF.DMA_TX_ENA
the transaction's MOSI bytes (a master's data phase, a slave's answer) come
from what the GDMA OUT link bound to SPI2 (OUT_PERI_SEL = 0) pushed into
the controller when it was started; with DMA_RX_ENA the bytes received go
to the IN link (IN_PERI_SEL = 0), whose descriptors get the data, their
length, suc_eof on the last and the owner bit back to the CPU. The data
phase may then exceed the 64-byte W buffer. The transaction completes
(USR clears, SPI_TRANS_DONE) once the IN link holds the bytes, so
firmware that sees it done finds them in memory; IN_SUC_EOF / IN_DONE
and OUT_EOF / OUT_TOTAL_EOF / OUT_DONE latch in the GDMA. The SPI's
AFIFO reset bits (SPI_DMA_CONF 29..31) empty its DMA FIFOs, as drivers do
before each transaction. The C3 GDMA (esp32c3_gdma) also copies memory to
memory (IN_CONF0.MEM_TRANS_EN).
Tested end to end over nets (examples/gpio-net-buses): a G071 master and a
G071 slave both by DMA through the DMAMUX (env-spi-dma.yaml), and an
ESP32-C3 and an ESP32-C6, master and slave by GDMA, against a G071 by DMA
(env-spi-esp32c3-dma.yaml, env-spi-esp32c3-dma-slave.yaml), each run with
the nodes swapped and with 37 ns rounds.
Not modelled: DMA FIFO thresholds and bursts (SxFCR, one item per request),
DMA priorities, DMAMUX request synchronisation and generators, GDMA
descriptor errors (*_DSCR_ERR, IN_DSCR_EMPTY: a short IN list drops the
excess), the GP-SPI's segmented DMA transfers, and DMA for the other
Espressif peripherals (UHCI0, I2S, crypto, ADC stall visibly on the C3 and
C6 GDMA).
Reports
World::gpio_net_reports() (and result.json gpio_nets from
labwired test, and WasmWorld.gpio_net_report() in the browser) lists each
net: level, edge count, contention and floating counters, each member's drive,
and the diagnostics with times in picoseconds.