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. On the pad's own
four-state trace a pulled net pad reads h / l (the IEEE 1164 weak levels).
Pads that are not on a net keep reporting z for an undriven input, pulled or
not.
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. What the net feeds back
into the pad (the level firmware reads from IDR or PIND) is never counted
as the pad's own drive, 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 set_gpio_input path a board button uses:
EXTI interrupts, AVR INT0/INT1 and pin-change 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 time the slowest member of that net has reached, plus the net's latency. An edge nobody has reported yet happens after its driver's current time, so its delivery is due after that 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), and compares every counter, every
UART transcript, every applied delivery and every node's cycle count, part way
through and at the end.
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::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
At the default 100 ns latency a net no longer costs a multiple of the
machines' own time. examples/gpio-net-two-boards, 30 ms of simulated time,
release build, against the same two machines each run alone for 30 ms (median
of repeated runs on a shared machine, so treat the figures as rough):
| How it is driven | Before | Now |
|---|---|---|
one run_until_ps / step_rounds call |
n/a | 1.16x the two machines alone |
step_all per round |
2.05x | 1.55x |
labwired test --script .../test.yaml (40 ms, per round, wall) |
0.54 s | 0.36 s |
(cargo test --release -p labwired-core --test world_multichip -- --ignored
--nocapture gpio_net_speed prints the first two.)
What is left:
- A net node still runs in pieces no longer than the shortest latency of its nets, because its own edges come back to it after one latency. The world nodes built today (Cortex-M, AVR) tick their peripherals every cycle anyway, so the pieces cost almost nothing extra. A node that would otherwise run wide batches or fast-forward idle time (an ESP32-C3 ROM-boot node) loses that while it is on a net, so it runs slower than alone at 100 ns.
- A sleeping node does not stretch the horizon yet: a node idle in
WFIstill advances one latency at a time while its peers do. step_allpays a fixed cost per call (the results map), about half the machines' own time at 100 ns rounds. Drive long runs withrun_until_psorstep_rounds;latency_nsstill makes rounds longer where a wire does not need to be fast.
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 report what it drives and accept an external level. Supported today:
| 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) and F4 (port from SYSCFG_EXTICRx). Internal pulls reported: STM32 v2 PUPDR, STM32 f1 input-with-pull (CNF = 10, ODR picks the rail), nRF52 PIN_CNF.PULL, EFR32 INPUTPULL, SAM PINCFG.PULLEN. Kinetis, RA and i.MX RT keep their pull outside the GPIO block, so their pulls are not on the net yet. |
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 and modern I²C only. |
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. |
ESP32 classic gpio (member peripheral: gpio, pins 0..31) |
push | Drive: GPIO_ENABLE and GPIO_OUT; GPIO_PINn.PAD_DRIVER open drain drives only a 0 and, holding a 1, reads the wire on GPIO_IN. Interrupts: GPIO_PINn.INT_TYPE edge and level types latch GPIO_STATUS and raise matrix source 22 for the CPU whose INT_ENA bit is set. GPIO32..39 cannot join a net. |
ESP32-S3 gpio (peripheral: gpio, pins 0..31) |
push | As classic; matrix source 16 (GPIO_PCPU_INT, INT_ENA bit 13). GPIO32..48 cannot join a net. |
ESP32-C3 / ESP32-C6 gpio (peripheral: gpio, pins 0..25) |
push | As classic; matrix source 16 on the C3 and 30 on the C6. Edge types only (level types are not modelled on this block). Exercised end to end on the C6 (env-esp32c6.yaml). |
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.
Any other GPIO model is refused when the world is built, naming the pad.
Limits worth knowing:
- The ATmega328P port model does not see
MCUCR.PUD(it is in the CPU's IO space), so a pad withPORTx = 1counts as pulled up even when firmware has setPUD. - 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).
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 example:
examples/gpio-net-buses (two STM32G071s).
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 }
SPI (STM32 classic/FIFO SPI, pad_map: stm32g0 routing on the G071):
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, puts its DR word out on MISO on its
shift edge (any CPOL/CPHA, MSB or LSB first, 8 or 16 bits; the example and
tests exercise mode 0, MSB first, 8 bits), sets
RXNE with the SPI interrupt when RXNEIE is set, and OVR when a frame arrives
before DR was read. 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.
I²C (STM32 modern I²C, the L4/G0 TIMINGR register file, pad_map:
stm32g0 on the G071): SCL and SDA are open-drain nets; put pull: up on
them. The controller generates START, the 7-bit 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. A released SDA
read back low is arbitration lost (ARLO). A target (OAR1.OA1EN) watches
START/STOP and its address on the wire, ACKs its own address only (ADDR,
DIR, ADDCODE), and stretches SCL until firmware clears ADDR, reads
RXDR or writes TXDR. A transfer to an address nobody owns is the pull-up
reading 1 in the ACK slot.
Everything is event-scheduled: a master's bits run at their exact cycles, a
slave or target runs when the net delivers an edge. The one poll is a target
or controller stretching SCL until firmware reads RXDR (a register read
cannot wake the model), at a quarter of the SCL low period.
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.
Not modelled on a net yet: other SPI/I²C families (nRF, ESP32, RP2040, the
STM32 F1/F4 legacy I²C, the H5 SPI v3), I²C 10-bit addressing, OAR2, general
call, RELOAD (more than 255 bytes), SMBus/PEC, NOSTRETCH=1, the I²C
filters and timeouts, SPI CRC, TI mode and DMA on a slave. Such a peripheral
on a net keeps its usual behaviour and drives its lines push-pull as before.
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.