

Wasp node board
WebAssembly Swarm Processor - a WAMR network compute distribution for MCUs
- Size
- 60.4 × 51.4 mm
- Copper layers
- 4
- Thickness
- 1.56 mm
- Parts
- 116
- Nets
- 202
- Pads
- 579
- Tracks, vias
- 1,334, 182
- Pours
- 6
- Routed
- Yes
- KiCad
- KiCad 9.0 (file 20241229)
- Created
- 11 Jul 2026
- Updated
- 16 Oct 2026
Overview
Wasp node board is an open-source printed circuit board by oskarvh, from the oskarvh/wasp repository on GitHub, published under the Apache-2.0 licence. It is a 4-layer board of 60.4 × 51.4 mm with 116 parts on 202 nets, routed with 1,334 tracks and 182 vias.
It is built around the KSZ8794CNXCC, LP5907MFX-1.2 and TPSM863253RDXR, with the USBLC6-2SC6, SS34 and ACM2012-361-2P-T002 among its other parts. Its parts include 60 capacitors, 28 resistors, 8 connectors, 6 ICs and 4 diodes. It belongs with the STM32 boards of this library.
Main components
8KSZ8794CNXCC
QFN-64-EP(8x8)0℃~+70℃ 1.8V、2.5V、3.3V 10Mbit/s、100Mbit/s、1Gbit/s 4 MII、RMII、RGMII QFN-64-EP(8x8) Ethernet Transceivers ROHSMicrochip TechnologyEthernet TransceiversLP5907MFX-1.2
SOT-23-5-40℃~+125℃ 1 1.2V 250mA 250mV@(250mA) 25uA 5.5V 6.5uVrms 60dB@(100kHz)、90dB@(100Hz) Fixed Positive Under Voltage Lockout、Over Temperature Protection、Enable、Short Circuit Protection SOT-23-5 Voltage Regulators - Linear, Low Drop Out (LDO) Regulators ROHSTexas InstrumentsVoltage Regulators - Linear, Low Drop Out (LDO) RegulatorsTPSM863253RDXR
QFN-FCMOD-7(3.3x4)UncategorisedTexas InstrumentsUSBLC6-2SC6
SOT-23-60.35pF、0.8pF 100nA 150W@8/20us 15V 2 5V 6A@8/20us 6V ESD SOT-23-6 ESD and Surge Protection (TVS/ESD) ROHSUMWESD And Surge Protection (TVS/ESD)SS34
SMA(DO-214AC)-55℃~+125℃ 3A 40V 500uA@40V 550mV@3A Independent SMA(DO-214AC) Schottky Diodes ROHSMDDSchottky DiodesACM2012-361-2P-T002
SMD-4P,2x1.2mm-40℃~+85℃ 2 220mA 360Ω@100MHz 500mΩ 50V 50V SMD-4P,2x1.2mm Common Mode Filters ROHSTDKCommon Mode FiltersLTST-C191KGKT
0603-55℃~+85℃ 130° 20mA 2V 35mcd 567.5nm~576.5nm 574nm 75mW Discrete Diode Water Clear Yellow Green 0603 LED Indication - Discrete ROHSLite-OnLED Indication - DiscreteLTST-C191KSKT
0603-55℃~+85℃ 104mcd 130° 2.1V 20mA 584.5nm~597nm 588nm 75mW Discrete Diode Water Clear Yellow 0603 LED Indication - Discrete ROHSLite-OnLED Indication - Discrete
README
README.mdwasp
WebAssembly Swarm Processor — a WAMR network compute distribution for MCUs.
A wasp node is a deliberately dumb MCU: it boots Zephyr, joins the network, and waits. A coordinator (future work, not in this repo yet) pushes WebAssembly modules and execution commands to nodes over a TCP socket. The node loads the module into the WebAssembly Micro Runtime (WAMR), executes it, and reports results back. All application intelligence lives in the WASM modules and the coordinator — the firmware is a thin, portable execution shell.
┌─────────────┐ TCP / framed protocol ┌──────────────────────┐
│ Coordinator │ ───────────────────────────────────▶ │ wasp node (MCU) │
│ (future) │ ◀─────────────────────────────────── │ Zephyr + WAMR │
└─────────────┘ results / logs / status └──────────────────────┘
… × N nodes (swarm)Firmware architecture
The firmware is a Zephyr application with three long-lived threads connected by message queues. Ownership is strict: only the network thread touches the socket, only the executor thread calls into a running WASM module.
wasp node firmware
┌─────────────────────────────────────────────────────────────────────┐
│ │
│ ┌───────────────┐ rx msgq ┌──────────────┐ exec q ┌─────────┐ │
│ │ network │ ─────────▶ │ agent │ ───────▶ │ wasm │ │
│ │ handler │ │ │ │ executor│ │
│ │ thread │ ◀───────── │ • protocol │ └───┬─────┘ │
│ └───────┬───────┘ tx msgq │ dispatch │ │ │
│ │ ▲ │ • routing │ WAMR runtime │
│ Zephyr network stack │ └──────────────┘ (interpreter, │
│ (sockets, DHCP) └───────────────────────────── static heap │
│ │ responses from executor pool) + │
│ Ethernet driver (per-board) module │
│ lifecycle │
└─────────────────────────────────────────────────────────────────────┘Threads
| Thread | Responsibility |
|---|---|
| network handler | Brings up the network interface (DHCP by default), runs the TCP server socket, frames/deframes protocol messages, forwards complete commands to the agent via the rx queue, and drains the tx queue back to the coordinator. Owns the socket exclusively. Framing-level errors (bad magic, oversize, no memory) are handled here. |
| agent | Protocol dispatcher. Answers protocol-level commands (HELLO, PING) itself and routes WASM lifecycle commands (LOAD_MODULE / UNLOAD_MODULE / CALL) to the executor via the exec queue. Never blocks on WASM execution. |
| wasm executor | The only thread that touches the WAMR runtime. Consumes lifecycle and call requests from the exec queue — serialized, so a module can never be unloaded mid-call — and posts responses directly to the tx queue. Isolated in its own thread so a long-running or misbehaving module cannot wedge the agent or the network handler. |
Zephyr's own threads (net stack rx/tx, system workqueue, logging) run alongside these.
Message flow
- Coordinator connects and sends a framed command (e.g. load module, call function).
- Network handler validates the frame and posts it to the rx msgq.
- Agent answers protocol commands directly on the tx msgq; lifecycle and call commands are routed (payload ownership included) to the exec queue.
- Executor performs the runtime operation and posts the RESULT/ERROR response directly to the tx msgq.
- Network handler drains the tx msgq onto the socket.
Wire protocol
Length-prefixed binary frames over a single TCP connection (default port
4242, configurable via Kconfig). Canonical definition:
app/src/protocol.h; reference implementation of the coordinator side:
tools/wasp_client.py. Sequence diagrams for every command and failure
path: docs/diagrams/.
┌────────┬────────┬───────────┬─────────────┬───────────┐
│ "WA" │ type │ seq │ payload len │ payload │
│ 2 B │ 1 B │ 1 B │ 4 B LE │ n B │
└────────┴────────┴───────────┴─────────────┴───────────┘Responses echo the request's seq. Payloads are capped at
CONFIG_WASP_MAX_MODULE_SIZE, advertised in HELLO_ACK; oversized frames
are drained and answered with ERROR(TOO_LARGE) without losing stream
sync. A bad magic means the stream is desynchronized and the node drops
the connection.
Command set:
HELLO/HELLO_ACK— version exchange; ack carries the payload cap and feature bits (0x01remote memory,0x02atomic primitives)LOAD_MODULE— payload is a.wasmbinary; node loads + instantiates it (one module at a time; ack is an emptyRESULT)UNLOAD_MODULE— tears the module down and frees its memoryCALL—[name_len u8][name][argc u8][arg u32 LE × argc]; invokes an exported function. v1 calling convention: i32 args/results only, signature-checked on the node via WAMR introspectionRESULT—[nresults u8][value u32 LE × n](empty for lifecycle acks)ERROR—[code u8][optional utf8 detail], e.g.TRAP(wasm operand stack overflow); codes inapp/src/protocol.hPING/PONG— liveness;PONGechoes thePINGpayloadIDENTIFY— strobes the status LED (default 10 s) so a human can find the physical board:wasp_client.py <ip> identify [seconds]REBOOT— remote reboot; mode 1 enters the USB bootloader (RP2040 only), so Pico Ws can be reflashed without touching BOOTSEL:wasp_client.py <ip> reboot [--bootsel]MEM_READ/MEM_WRITE/LOCK/UNLOCK/REGION_INFO/MEM_ADD/MEM_CAS(0x4x) — node-initiated remote-memory requests, sent mid-CALLin the node's own seq space and answered by the coordinator; see Remote memory
A module that traps (e.g. unreachable, out-of-bounds access) returns
ERROR(TRAP, exception-text) and the node carries on — the WASM sandbox
holds, and the next CALL works.
Discovery & status LED
Nodes don't need to be known in advance: once online, every node
broadcasts an ANNOUNCE datagram to UDP port 4243 every 5 seconds
(both Kconfig-tunable) carrying its protocol version, feature bits, TCP
port, busy flag (coordinator currently connected) and board name.
python3 tools/wasp_client.py discover # human-readable list
python3 tools/wasp_client.py discover --ips # for scripting
python3 tools/swarm_test.py tools/remote_module.wasm discoverBoards with an led0 devicetree alias (the Nucleo's green LED, the Pico
W's WiFi-chip LED — wired up in the app overlay) show their state at a
glance:
| Pattern | Meaning |
|---|---|
| fast blink (5 Hz) | no network yet (WiFi joining / DHCP) |
| heartbeat (short blip every second) | online, waiting for a coordinator |
| solid on | coordinator connected |
| frantic strobe (10 Hz) | IDENTIFY received — "here I am" |
Broadcast datagrams do not cross routers, so ANNOUNCE discovery is
LAN-only by nature. Reaching a coordinator on another network is a
planned expansion (unicast registration to a DNS-resolved coordinator
hostname — see the roadmap); DNS-based tricks cannot make an actual
broadcast leave the LAN.
Memory strategy
WAMR runs from a static heap pool (Alloc_With_Pool) so WASM memory is
bounded and isolated from the Zephyr kernel heap. Pool size, thread stack
sizes, and the maximum module size are Kconfig options tuned per board.
The pool must live in main SRAM and comfortably exceed 64 KiB: a WASM
page is fixed at 64 KiB, so instantiating any standard clang-built module
demands one contiguous 64 KiB+ allocation for its linear memory. The
payload heap (protocol buffers + the raw .wasm of the loaded module)
takes the board's tightly-coupled memory instead when available — on the
F439: 88 KiB SRAM pool for WAMR, 40 KiB payload heap in CCM.
Repository layout
wasp/
├── app/ # the Zephyr application (manifest repo for west)
│ ├── west.yml # pins Zephyr, HALs, and WAMR
│ ├── CMakeLists.txt
│ ├── Kconfig # wasp-specific options (port, pool sizes, …)
│ ├── prj.conf # board-agnostic configuration
│ ├── boards/ # per-board config & devicetree overlays
│ │ └── nucleo_f439zi.conf
│ └── src/
│ ├── main.c # boot, queue setup, thread start
│ ├── led.c # status LED thread (patterns + IDENTIFY strobe)
│ ├── rpc.c # node->coordinator RPC slot (remote memory)
│ ├── net/ # net bring-up (netup.c) + worker socket/framing + ANNOUNCE
│ ├── agent/ # agent thread + protocol dispatch
│ ├── wamr/ # WAMR wrapper + executor + wasp.* host functions
│ └── peer/ # peer role: host-function API (peer_api.h), conductor
│ # demo, boot smoke test — libwasp in firmware
├── deps/ # west-managed dependencies (NOT committed)
│ ├── zephyr/
│ └── modules/ # hal_stm32, cmsis, wasm-micro-runtime, …
├── docs/
│ ├── writing-modules.md # C function -> node -> result, step by step
│ ├── performance.md # measured remote-memory costs + optimization guide
│ ├── demo.md # the distributed Mandelbrot demo, explainedExternal code (Zephyr, STM32 HAL, WAMR) is pulled by west from the
manifest in app/west.yml, pinned to exact release tags — same
reproducibility as git submodules, but west also resolves Zephyr's internal
module graph, which plain submodules cannot. deps/ is gitignored.
Portability
Nothing in src/ may include board- or SoC-specific headers. Portability is
handled by the Zephyr layers:
- Board selection is a build-time switch:
west build -b <board> app. - Per-board tuning (heap pool size, network driver options, devicetree
overlays) lives in
app/boards/<board>.conf/.overlay. - New MCU support = add a board conf/overlay (and a HAL module to the manifest allowlist if it's a new vendor). No source changes.
Supported boards:
- wasp node board (
wasp_node) — the purpose-built board: STM32H723ZG Cortex-M7 @ 550 MHz, 320 KiB AXI SRAM, a KSZ8794 3-port switch so nodes daisy-chain without external switches, and three instance slots. Board definition lives in-tree atboards/wasp/wasp_node, so noBOARD_ROOTneeds setting —app/CMakeLists.txtpoints at it. Design rationale: docs/hardware.md. - ST Nucleo-F439ZI (
nucleo_f439zi) — Cortex-M4F @ 180 MHz, 192 KiB SRAM + 64 KiB CCM, on-board Ethernet (LAN8742 PHY). - Raspberry Pi Pico W (
rpi_pico/rp2040/w) — Cortex-M0+ @ 133 MHz, 264 KiB SRAM, CYW43439 WiFi (Infineon AIROC driver; needs a one-timewest blobs fetch hal_infineonand WiFi credentials, see Building). Console is USB CDC-ACM. WAMR runs the generic soft-floatTHUMBtarget on ARMv6-M.
Building
Dependencies: CMake ≥ 3.20, Python ≥ 3.10, a toolchain (Zephyr SDK, or GNU
Arm Embedded with ZEPHYR_TOOLCHAIN_VARIANT=gnuarmemb).
# one-time workspace setup (from the repo root)
python3 -m venv .venv && source .venv/bin/activate
pip install west ninja
west init -l app
west update # clones Zephyr + WAMR into deps/
pip install -r deps/zephyr/scripts/requirements-base.txt
# if using GNU Arm Embedded instead of the Zephyr SDK:
export ZEPHYR_TOOLCHAIN_VARIANT=gnuarmemb
export GNUARMEMB_TOOLCHAIN_PATH=/usr # prefix of arm-none-eabi-gcc
# build & flash — wasp node board (Ethernet via its own switch)
west build -b wasp_node app -d build/node
west flash -d build/node # openocd over an ST-Link V2 on the SWD header (J6)
# build & flash — Nucleo-F439ZI (Ethernet)
west build -b nucleo_f439zi app
west flash # needs stlink (openocd or STM32CubeProgrammer)
# build & flash — Raspberry Pi Pico W (WiFi)
west blobs fetch hal_infineon # one-time: CYW43439 firmware
cp app/wifi_credentials.conf.example app/wifi_credentials.conf
# ...then edit app/wifi_credentials.conf with your SSID/PSK. The file is
# gitignored and merged into the build automatically whenever it exists.
west build -b rpi_pico/rp2040/w app -d build/pico
west flash -d build/pico -r uf2 # board must be in BOOTSEL modeThe BOOTSEL-button dance (hold it while plugging in) is only needed the first time a Pico is flashed with wasp. From then on, reflash over the network:
python3 tools/wasp_client.py <node-ip> reboot --bootsel # RP2040 only
# ...the RPI-RP2 drive enumerates over USB; mount it and copy the image:
cp build/pico/zephyr/zephyr.uf2 /media/$USER/RPI-RP2/
# node reboots into the new firmware and rejoins the network in ~20 swasp node board notes
west flash uses openocd with boards/wasp/wasp_node/support/openocd.cfg,
which drives an ST-Link V2 (interface/stlink.cfg plus
dapdirect_swd). That is deliberately not the Nucleo's configuration — the
in-tree Nucleo boards use stlink-dap.cfg, the V3 path, which a V2 will
not answer. For an older V2 or a clone, swap the transport line for
hla_swd; if your SWD pigtail omits NRST, use reset_config none separate. Both are noted in the file.
Reflashing over USB, no debugger. The H723 has a DFU bootloader in system memory, so the board can be flashed with nothing but a USB-C cable. Two ways in:
# 1. the button: hold SW2 (BOOT0) and tap reset, then
dfu-util -a 0 -s 0x08000000:leave -D build/node/zephyr/zephyr.bin
# 2. over the network, no hands on the board (same flag the Picos use)
python3 tools/wasp_client.py <node-ip> reboot --bootselThe second route leaves a magic word in uninitialised RAM, resets, and
jumps to the ROM bootloader from PRE_KERNEL_1 priority 0 — ahead of the
clock driver, so the chip is still on its reset defaults rather than at
550 MHz on a PLL with caches and Ethernet DMA live. See the comment at the
top of app/src/boot_dfu.c.
Either way the board must be on USB-C as a data link to the PC: per
AN2606 the bootloader samples its interfaces and leaves for the
application when no host answers, and a charger or a power-only cable
looks exactly like no host. If the node boots normally instead of
appearing as 0483:df11, the console says which of the two failures it
was.
Pass -p to west build after changing Kconfig or devicetree — Kconfig
results are cached and a plain rebuild will silently keep the old values.
The board has no USB-serial bridge of its own (a bare ST-Link V2 has no VCP), so the console comes out on the expansion header: J9.3 = PD8 (board TX), J9.4 = PD9 (board RX), plus a header ground, at 115200 8N1. Any 3V3 USB-TTL adapter will do. To use the board's own USB-C as a CDC-ACM console instead:
west build -b wasp_node app -d build/node -- \
-DEXTRA_CONF_FILE=boards/wasp_node_cdc.conf \
-DEXTRA_DTC_OVERLAY_FILE=boards/wasp_node_cdc.overlayA healthy boot looks like this — the switch line is the one to check first, because port 4 comes out of reset in RGMII mode and nothing moves until the firmware tells it RMII:
wasp_ksz: KSZ8794 found, rev 0
wasp_ksz: port 4: RMII, clock mode (0x56 = 0xed)
wasp_ksz: port 1 (J2): link up, autoneg done
wasp: sysclk 550 MHz from PLL1 (HSE ready, PLL1 locked), HCLK 275 MHz
wasp_wamr: WAMR initialized (pool: 249992 bytes, all spare RAM, ~3 instance(s))
wasp_netup: IPv4 address: 10.0.0.189Ethernet goes to any of the three jacks — J2 is switch port 1, J4 is
port 2, J5 is port 3, and the driver logs which ones have a link three
seconds after boot. The external PSRAM is mapped but not used; see
docs/hardware.md for why, and
CONFIG_WASP_PSRAM_ALLOW.
Talking to a node — no serial cable needed, nodes announce themselves over UDP broadcast (see Discovery & status LED):
python3 tools/wasp_client.py discover # list nodes on the LAN
python3 tools/wasp_client.py <node-ip> identify # strobe LED: which board is this?
python3 tools/wasp_client.py <node-ip> check # protocol self-test
tools/build_test_module.sh # needs clang + wasm-ld
python3 tools/wasp_client.py <node-ip> lifecycle tools/test_module.wasm
python3 tools/wasp_client.py <node-ip> load tools/test_module.wasm
python3 tools/wasp_client.py <node-ip> call fib 20
python3 tools/wasp_client.py <node-ip> unload
python3 tools/wasp_client.py <node-ip> reboot # remote reboot (any board)
python3 tools/wasp_client.py <node-ip> reset # release every module/instanceWhere the time goes
Nodes count, per instance, how long they spent inside the interpreter against how long they spent waiting for the coordinator to answer a remote-memory round trip. Host-side wall clock cannot tell those apart, which is the whole reason the counters exist on the node.
python3 tools/wasp_client.py <node-ip> stats --reset # zero
# ...run whatever you want to measure...
python3 tools/wasp_client.py <node-ip> stats # read
python3 tools/mandelbrot_demo.py <ips...> --stats # or let the demo do it inst calls exec compute waiting wait% RPCs traffic
16 1 585.3ms 180.6ms 404.7ms 69.1% 34 8.3K
1 1 585.3ms 184.8ms 400.4ms 68.4% 34 8.3K
2 1 587.7ms 185.0ms 402.7ms 68.5% 34 8.3K
all 3 1.76s 550.4ms 1.21s 68.7% 102 24.9K
per round trip: 11.8ms waiting, 249B movedA round trip's wait is accrued inside the call that issued it, so
compute = exec - waiting and the split needs no second measurement.
Instance gone is the total for instances since dropped — releasing a
module would otherwise take its own measurements with it, which makes
"run a workload, then ask what happened" report nothing.
The per-round-trip line is the one to watch. Rendering the same frame with one, two and three instances per node across two boards:
| inst/node | workers | compute/node | wait% | ms per round trip |
|---|---|---|---|---|
| 1 | 2 | 540 ms | 27.1% | 2.05 |
| 2 | 4 | 554 ms | 52.9% | 6.24 |
| 3 | 6 | 555 ms | 68.4% | 11.8 |
Compute per node is conserved, as it must be — same work, same core. But the wait per round trip grows roughly linearly with the number of concurrent workers, which is the signature of a serialized server rather than of the wire: the coordinator services all remote memory behind one mutex. That is the current ceiling, not the nodes — wall time is flat at 0.8 s whether a node runs one instance or three, and the core still sits around a third idle at three.
WASM modules for wasp are plain clang output — no SDK required:
clang --target=wasm32 -O2 -nostdlib -Wl,--no-entry -Wl,--export-dynamic \
-z stack-size=1024 -o module.wasm module.cFor the full developer walk-through — writing a C function, compiling it, pushing it to a node, and reading the result back — see docs/writing-modules.md.
To see the whole stack do something at once, run the distributed Mandelbrot demo: the swarm renders an image into coordinator RAM, live in your terminal, self-balancing via an atomic work counter and surviving node loss mid-render (docs/demo.md):
python3 tools/mandelbrot_demo.py discoverNote on WAMR integration: WAMR's zephyr/module.yml declares external
CMake/Kconfig glue but does not ship it; app/modules/wamr/ provides that
glue and builds WAMR's vmlib as a Zephyr library, enabled by
CONFIG_WAMR=y. Memory placement is described under Memory
strategy above.
Remote memory — design plan
This is software distributed shared memory: when a module needs data that lives in the coordinator's RAM, the node fetches (or writes back) that memory over the wire on the module's behalf — so C code can work with pass-by-reference data whose backing store is on another machine. Phases 1 and 2 (explicit host functions, then transparent remote pointers) plus most of phase 3 (queued locks, atomic primitives) are implemented and tested on hardware; the checklists below track the details, and phase 4 is research.
Can we overload the dereference?
A hard constraint first, because it shapes everything below. Inside WASM a C pointer is just an i32 offset into the module's own linear memory — there is no MMU, no page fault, and an out-of-bounds access traps non-resumably in WAMR. So a raw C pointer to coordinator RAM can never be transparently dereferenced without modifying the interpreter itself. What we can do, in increasing order of transparency:
- Explicit API (phase 1) — host functions the module imports:
wasp_mem_read(region, offset, dst, len)/wasp_mem_write(...)pluswasp_lock/wasp_unlock. Ugly but honest; everything else builds on it. - Compile-time typed transparency for C (phase 2) — clang's named
address spaces put "this pointer is remote" into the type system:
#define wasp_remote __attribute__((address_space(100))), thenwasp_remote int *arrreads like a normal pointer (arr[i],p->field, pointer arithmetic) but is a distinct type — assigning it to a local pointer without an explicit cast is a compile error, so local/remote confusion is caught at build time and propagated automatically through every assignment, argument, and dereference. A small LLVM pass plugin (-fpass-plugin=) rewrites loads/stores through that address space into__wasp_remote_load/storecalls, which are ordinary WASM imports: the emitted module is plain, valid wasm32, and code using only local pointers compiles exactly as before, zero overhead. C++ gets the same ergonomics with no compiler plugin at all, viaremote_ptr<T>overloadingoperator*/operator->/operator[]with a write-through proxy. - Fully transparent raw pointers (phase 4, research) — for code
that cannot be annotated (third-party sources,
void *plumbing,memcpy): either an ASan-style pass that instruments every load/store with a reserved-window check (fully transparent, but a compare on each access), or patching the interpreter's bounds check so a reserved address window above linear memory faults into a fetch hook instead of trapping (WAMR's shared heap feature already routes such addresses to a second host-managed heap, so the plumbing half-exists — but it means carrying a WAMR patch). Explicitly out of scope until phases 1–3 are proven.
Why not infer remoteness with no annotation at all? Pointer
provenance in C is statically undecidable — pointers flow through
integers, unions, memcpy, and data structures no compiler can trace —
and link time is too late (wasm-ld sees opaque function bodies, not
types). One qualifier at the declaration site, type-checked from there
on, is the practical ceiling for compile-time detection.
Remote references are capabilities, not addresses
A module must never hold a raw coordinator RAM address — only memory the
coordinator has explicitly exported is reachable, and non-shared
coordinator memory simply has no name a node could use. The coordinator
registers a buffer as a region — (region_id, base, length) on its
side — and hands modules remote references as ordinary i32 CALL
arguments, packed region_id:8 | offset:24 (up to 256 live regions of
16 MiB each). The packing keeps the v1 i32-only calling convention
intact and, crucially, lets plain C pointer arithmetic (ref + i) work
on the packed value. Every access carries an explicit length and is
bounds-checked twice: on the node against a cached region descriptor
(fast local failure), and again by the coordinator, which is
authoritative — even a compromised node cannot reach outside a region.
The role reversal: node-initiated requests
Everything so far is strictly coordinator-asks, node-answers. Remote
memory reverses this mid-call: while the executor is inside
wasm_runtime_call_wasm, a host function must send a request to the
coordinator and block until the reply arrives. That needs:
- Protocol v2: a node-initiated message range (
MEM_READ,MEM_WRITE,LOCK,UNLOCK+ their acks) with its own seq space, so both directions can be in flight on the one TCP connection without ambiguity. Advertised as feature bits inHELLO_ACK. - An RPC slot in the firmware: executor posts the request to the tx
queue and blocks on a semaphore; the agent routes the coordinator's
reply back to the slot. Executor is serialized, so exactly one
outstanding remote op — no table needed. A timeout or connection drop
fails the RPC and the host function raises a WASM exception, surfacing
to the coordinator as
ERROR(TRAP, "remote memory timeout")— a hung coordinator must not wedge the node.
Races: who protects the coordinator's RAM?
The coordinator's copy is the single authoritative copy, and all lock state lives in the coordinator — a node never decides whether a region is locked; it only sends requests. Concretely:
- Every read and write RPC is mutex-protected, unconditionally. The
coordinator wraps each
MEM_READ/MEM_WRITEin the target region's mutex, so a single access is always atomic and a torn read or write can never be observed. A module cannot opt out: the serialization happens on the coordinator, after the request has left the node. - Explicit
LOCK= holding that same region mutex across multiple RPCs, for compound operations (read-modify-write, multi-field updates). Locks are granted as leases with an expiry, so a node that dies mid-critical-section cannot deadlock the swarm — the lease expires and the coordinator revokes it. - Enforced, not advisory. While node A holds a region's lock, a
read or write from anyone else fails fast with
ERROR(LOCKED)— it neither interleaves silently nor blocks. (Blocking a plain access would stall the other node's executor invisibly mid-WASM-call; a fast error lets that module retry or take the lock properly.) A contendedLOCKrequest is the exception: the coordinator may park it in a per-region FIFO and grant it when the holder unlocks — see the phase-3 notes below. - Single-word read-modify-writes don't need a lock at all: the
MEM_ADD/MEM_CASprimitives do the whole update inside the coordinator's serialization window, one round-trip, race-free against every other node.
Consistency model: today there is no caching — every access is a round-trip, so it is sequentially consistent and slow (~ms per access vs ns locally; the API shape pushes users toward bulk transfers). Phase 3's remaining item is a node-side page cache with release consistency: acquire a lock → invalidate cached pages of that region; release → write back dirty pages. That is the classic DSM trade (IVY/TreadMarks lineage) and it is where the real performance lives.
Implementation todo list
Phase 1 — explicit remote memory, end to end (implemented):
- Protocol v2 in
protocol.h: node-initiated types (MEM_READ,MEM_DATA,MEM_WRITE,MEM_ACK,LOCK,LOCK_GRANT,UNLOCK,REGION_INFOfor descriptor fetch),ERROR(LOCKED)code, separate node-side seq space,HELLOfeature flag. - Firmware RPC core (
app/src/rpc.c): blocking executor→coordinator request/response slot (semaphore + timeout), reply routing in the agent; a silent coordinator traps the module (ERROR(TRAP)) afterCONFIG_WASP_RPC_TIMEOUT_MSand the node carries on. - Host function module (
app/src/wamr/native_remote.c):wasp.*natives registered with WAMR; module buffer pointers validated and translated by WAMR's*~signature markers; imports from thewaspnamespace now resolve at instantiation. - Coordinator side (
tools/wasp_client.py): region registry (register_region(id, bytearray)), servesMEM_READ/MEM_WRITEinline while requests are in flight, lease-based locks with expiry-revocation, fail-fastLOCKEDrejection of non-holders, authoritative bounds checks. - Tests (
wasp_client.py <ip> remote tools/remote_module.wasm+ deaf-coordinator timeout test): remote sum (element-wise, bulk, via reference arithmetic), remote write-back, locked read-modify-write, fail-fastLOCKEDfor reads/writes/locks while a rival holds the lease, lease expiry revocation, out-of-bounds rejection, RPC timeout →TRAPwith node survival. - Swarm test (
tools/swarm_test.py <module> <ip> <ip> ..., or... discoverto use ANNOUNCE broadcasts): drives N nodes concurrently against one shared coordinator memory — distributed sum sliced across the swarm (exact), unlocked read-modify-write from all nodes loses updates (~55–65% lost with 5–6 real nodes racing), the same workload underwasp_lockis exact. The concurrency story is proven on hardware.
Phase 2 — ergonomics (implemented, verified on Nucleo + Pico W):
-
wasp/remote.hfor C:wasp_remoteaddress-space qualifier,WASP_REMOTE_PTR()helper,wasp_locked()scoped lock guard — usable stand-alone (explicit calls) without the pass plugin. - LLVM pass plugin (
tools/wasp-remote-pass/,-fpass-plugin=): lowers loads/stores through address space 100 (AS 1/10/20 are reserved by the wasm backend) to__wasp_remote_load/store_*shims (tools/lib/wasp_remote_rt.c— plain phase-1 imports, no firmware change), and remote↔localmemcpyto single bulk RPCs, so whole-struct assignment is one round-trip. Pointer arithmetic, indexing, i8→f64 widths verified on hardware; unsupported constructs (remote↔local casts, remote memset, remote-to-remote copies, atomics) fail the compile — never a silent miscompile. -
wasp/remote.hppfor C++:remote_ptr<T>withoperator*/operator->/operator[]and a write-through proxy,remote_lockRAII guard,try_read/try_writebulk path — no compiler plugin needed. clang wasm32 C++ (-fno-exceptions -fno-rtti -nostdlib) output: ~1 KiB module. - Failed transparent dereferences trap the module (the "remote
segfault"), reported as
ERROR(TRAP); expected-failure paths (contended locks) keep using explicit error codes. -
docs/writing-modules.md: chapter with worked C and C++ examples and performance guidance (batch, don't peek); self-test:wasp_client.py <ip> remote2 …(23 checks).
Phase 3 — performance (queueing + atomics implemented; measured on the 6-node swarm, 150 contended increments):
- Coordinator-side lock queueing: a
LOCKon a held region parks in a per-region FIFO (bounded wait, thenERROR(LOCKED)after all) instead of failing fast; the grant is sent the moment the holder unlocks. Coordinator policy — no node/protocol change. Kills the retry storm: ~1000 wastedELOCKEDround-trips → zero, with FIFO fairness. Lease clock starts at grant; the parked wait is bounded (4 s) to stay under the node's RPC timeout. - Atomic RPC primitives
MEM_ADD/MEM_CAS: the coordinator already serializes each request, so doing the read-modify-write inside that window is race-free with no lock and no lease — one round-trip per increment (~10 ms measured). Module API:wasp_add/wasp_cas(C),fetch_add/compare_exchangeonremote_ptr<T>(C++); advertised as feature bit 0x02. - RPC latency (found while measuring the above): node-initiated
requests waited up to 50 ms in the TX queue for the serve loop's
poll tick, and header+payload as two
send()s stalled on Nagle + delayed ACK.SERVE_POLL_MS50→2 plusTCP_NODELAYon both ends ≈ 4× across every remote-memory workload. - Node-side page cache (DTCM candidate) with dirty tracking, write-back on unlock/flush, invalidate on lock acquire; cache statistics queryable over the protocol.
Measured end to end (tools/swarm_test.py, 2026-07-13): 150 increments
of one shared counter, contended by all 6 nodes (1× Nucleo-F439ZI +
5× Pico W), each strategy run back to back on the same hardware:
| strategy | time | ELOCKED retries |
|---|---|---|
| fail-fast locks, before latency fixes | 60.8 s | 1038 |
| fail-fast locks, after latency fixes | 13.6 s | 783 |
| queued locks | 10.2 s | 0 |
atomic MEM_ADD | 1.5 s | 0 |
40× total on the contended counter; the latency fixes alone also cut the uncontended distributed-sum phase from 0.70 s to 0.22 s. Two readings worth keeping: measuring in stages is what exposed the TX-poll bottleneck at all (queueing looked like a modest 1.3× until the real cost was traced to the serve loop, not the locks); and queued locks' wall-clock gain is deliberately modest — a lock-serialized workload is bounded by hold time regardless — its real value is deleting ~800 garbage round-trips, FIFO fairness, and traffic that no longer grows with contention.
A full performance characterization — per-RPC cost on both transports,
batching, alignment, layout pitfalls, lock partitioning, MEM_ADD vs
CAS scaling — lives in docs/performance.md
(tools/perf_test.py + tools/perf_module.c).
Phase 4 — research:
- Un-annotated code: ASan-style instrument-every-access pass, or a transparent raw-pointer window via interpreter bounds-check hook / shared-heap adaptation; upstream-ability of such a WAMR patch.
Status / roadmap
- Architecture (this document)
- Workspace scaffolding: west manifest, Zephyr app skeleton, WAMR integration
- Thread + queue skeleton (network handler, agent, executor)
- Wire protocol implementation (framing, HELLO/PING/ERROR)
- WASM module lifecycle (load / call / unload) end to end — i32-only v1 calling convention
- Remote memory phase 1: modules read/write/lock coordinator RAM via
wasp.*host functions (see Remote memory — design plan) - Remote memory phase 2: transparent dereferencing —
wasp_remotepointers in C (LLVM pass plugin) andremote_ptr<T>in C++ (header-only) - Remote memory phase 3 (partial): queued locks, atomic
MEM_ADD/MEM_CAS, RPC latency fixes — contended counter 60 s → 1.5 s on the 6-node swarm; node-side caching still open - Second board: Raspberry Pi Pico W (RP2040 + CYW43439 WiFi) — full suite passing on Ethernet and WiFi nodes simultaneously
- 6-node swarm (1× Nucleo + 5× Pico W): distributed sum, cross-node lost-update race demonstrated, lock exactness under contention
- Fleet ergonomics: status LED, UDP
ANNOUNCEdiscovery,IDENTIFYLED strobe, remoteREBOOT(incl. RP2040 USB-bootloader entry for cable-free reflashing) - Distributed Mandelbrot demo (docs/demo.md):
work-stealing via
MEM_ADD, live tile-by-tile terminal view, per-node provenance image, node-loss recovery with work re-issue — 256×192 in 4 s on the 6-node swarm - WAN discovery — announce to a coordinator outside the LAN.
Feasibility checked (2026-07): broadcasts can't leave the LAN, but
the same
ANNOUNCEdatagram can be unicast to a configured coordinator hostname (CONFIG_WASP_COORDINATOR_HOST), resolved on-node with Zephyr's DNS client (CONFIG_DNS_RESOLVER;CONFIG_NET_DHCPV4_OPTION_DNS_ADDRESSfeeds it the DHCP-supplied DNS server automatically — measured cost on the Pico W: ~5 KiB RAM + ~9 KiB flash, fits today). Caveat: a coordinator behind the internet usually can't TCP-connect back through the node's NAT, so the follow-on step is a node-initiated "phone home" TCP connection — the wire protocol already runs traffic in both directions over one socket (the 0x4x range), so only who dials changes. mDNS/DNS-SD was considered and rejected: it is link-local, same as broadcast. - Purpose-built node board, built and running — STM32H723ZG at
550 MHz + KSZ8794 3-port switch,
boards/wasp/wasp_node. Two of them daisy-chained through their own switch ports, no external switch. Answered the open question that gated it: the switch's 25 MHz REFCLKO is clean enough to run the MCU's HSE, and PLL1 locks on it at 550 MHz. Two faults found in the process, both recorded in docs/hardware.md for a rev 2: the external quad PSRAM cannot be written through memory-mapped OCTOSPI on this silicon (so the WAMR pool stays in SRAM, and v2 goes octal), and the USB data pair never reaches the MCU (U2 bridges nets that should be distinct, FL1 crosses D+/D−). - Multiple WAMR instances per node —
MODULE_UPLOADwith an instance count,INSTANTIATE,CALL_INST,DROP,INVENTORY, behindWASP_FEAT_MULTI_INSTANCE; handles carry a generation so a stale one is refused rather than hitting whatever took its slot. An executor pool replaces the single executor and the node→coordinator RPC slot became a table, so instances of one node run concurrently — proved deterministically by withholding aMEM_DATAand watching another instance finish anyway. The v1LOAD_MODULE/CALL/UNLOAD_MODULEtrio is kept as a default-instance shim, so older clients and the Pico fleet are untouched. Three instances fit on the H723's 320 KiB; the pool sizes itself at boot from whatever RAM is spare, so one image suits every board. - Per-instance metrics — nodes count time inside the interpreter
against time waiting on a remote-memory round trip, which host
wall-clock cannot separate.
wasp_client.py <ip> statsandmandelbrot_demo.py --stats. What they showed: at 256×192 the coordinator is the ceiling (wait per round trip grows linearly with concurrent workers, 2.05 → 6.24 → 11.8 ms for 2, 4 and 6), while at 1920×1080 the nodes are compute-bound at ~98% core utilisation. The bottleneck moves with the size of the work. - Teach the conductor the instance API —
app/src/peer/conductor.candswarm_test.py/perf_test.pystill treat each node as one worker, so they see two workers where the fleet offers six.libwaspalready speaks it (wasp_upload,wasp_call_inst,wasp_call_wait_any), so this is wiring, not design. - Lift the coordinator ceiling — it services all remote memory
behind one mutex, which the metrics above identify as the limit
for small work. Either per-region locks, or batch the per-tile
MEM_ADD/MEM_WRITEpair so there are simply fewer round trips (two per tile today, and the claim could ride along with the previous tile's write). - Multiple WAMR instances per node — on RAM-rich MCUs (RP2350:
520 KiB, STM32H7: ~1 MiB, ESP32-S3: up to 8 MiB PSRAM) one
physical node could host N module instances and present as N
virtual workers. Feasible: WAMR already supports concurrent
modules/instances in one runtime, and each instance is
independent, so the cost is per-instance RAM (its ≥64 KiB linear
memory + stack) plus bounded firmware work — instance handles in
LOAD_MODULE/CALL/UNLOAD_MODULE, one executor thread (and exec_env) per running call instead of the single executor, and the node→coordinator RPC slot generalized to a small table keyed by instance. Superseded by the two entries above — kept for the reasoning. The Pico W (~93% RAM) and the F439 (pool fits exactly one 64 KiB page) do stay single-instance, which is now expressed asCONFIG_WASP_MAX_INSTANCES=1in their board configs rather than as a property of the firmware. - Coordinator —
libwaspC core with a Python binding on top, also embeddable in firmware so nodes can delegate pooled worker functions to idle peers with or without a dedicated coordinator (design sketch: docs/coordinator.md). Step 1 landed 2026-07:libwasp/implements the full coordinator role in C (framing, blocking RPC with inline remote-memory serving, region/lock/atomics engine, queued locks, discovery, write-observation events) with a wasp_client-compatible ctypes binding — the complete wasp_client self-test suite and the Mandelbrot demo (incl.--chaosrecovery) pass through it on the 6-node fleet. Step 2 landed 2026-07: libwasp compiles into the firmware itself (CONFIG_WASP_PEER, port layer + static allocation) — a Nucleo booted, dialed a stock Pico, pushed a module from its own flash, and served that module's remote-memory RPCs from its own RAM (CONFIG_WASP_PEER_SMOKE). Nodes can now be coordinators. Step 3 landed 2026-07: the firmware-driven conductor demo (CONFIG_WASP_CONDUCTOR) — a conductor-only Nucleo discovers the Picos, pushes the Mandelbrot module from its own flash, hosts the framebuffer in its own RAM, and renders 256×192 in 4.0 s across 5 workers, fractal on the serial console, no PC in the loop. Async call slots + on-node discovery landed in libwasp; the brain is C against the peer host-function API (app/src/peer/peer_api.h). Next: the same brain as a WASM module (peer API as WAMR natives). - More boards
Parts
116| Qty | Value | Footprint | References |
|---|---|---|---|
| 36 | 100nF | Capacitor_SMD:C_0402_1005Metric | C1, C2, C3, C10, C14, C16, C17, C18, C19, C21, C23, C26, C27, C28, C29, C30 +20 |
| 7 | 0R | Resistor_SMD:R_0402_1005Metric | R10, R12, R13, R18, R19, R22, R23 |
| 6 | 10k | Resistor_SMD:R_0402_1005Metric | R5, R6, R9, R21, R24, R27 |
| 6 | 1uF | Capacitor_SMD:C_0603_1608Metric | C6, C7, C8, C9, C43, C45 |
| 6 | 4.7uF | Capacitor_SMD:C_0603_1608Metric | C5, C11, C15, C33, C34, C36 |
| 6 | 470R | Resistor_SMD:R_0402_1005Metric | R7, R8, R14, R15, R16, R17 |
| 4 | BLM18PG221SN1D | Inductor_SMD:L_0603_1608Metric | FB1, FB3, FB4, FB5 |
| 3 | 10uF | Capacitor_SMD:C_0805_2012Metric | C12, C25, C48 |
| 3 | 1k | Resistor_SMD:R_0402_1005Metric | R3, R20, R28 |
| 3 | 22uF | Capacitor_SMD:C_0805_2012Metric | C13, C39, C40 |
| 3 | RJ45_Hanrun_HR911105A_Horizontal | Connector_RJ:RJ45_Hanrun_HR911105A_Horizontal | J2, J4, J5 |
| 2 | 2.2uF | Capacitor_SMD:C_0603_1608Metric | C38, C42 |
| 2 | 33R | Resistor_SMD:R_0402_1005Metric | R25, R26 |
| 2 | 33pF | Capacitor_SMD:C_0402_1005Metric | C20, C24 |
| 2 | 5.1k | Resistor_SMD:R_0402_1005Metric | R1, R2 |
| 2 | Conn_02x05_Odd_Even | Connector_PinHeader_2.54mm:PinHeader_2x05_P2.54mm_Vertical_SMD | J8, J9 |
| 2 | SS34 | Diode_SMD:D_SMA | D1, D2 |
| 1 | 10nF | Capacitor_SMD:C_0402_1005Metric | C22 |
| 1 | 10uF | Capacitor_SMD:C_0603_1608Metric | C32 |
| 1 | 12.4k 1% | Resistor_SMD:R_0402_1005Metric | R4 |
| 1 | ACM2012-361-2P-T002 | ACM2012-361-2P-T002:ACM2012_TDK | FL1 |
| 1 | APS6404L-3SQRx-SN | Package_SO:SOIC-8_3.9x4.9mm_P1.27mm | U3 |
| 1 | ASE-25.000MHZ-L-K-T | Oscillator:Oscillator_SMD_Abracon_ASE-4Pin_3.2x2.5mm | Y2 |
| 1 | BLM21PG300SN1D | Resistor_SMD:R_0805_2012Metric | FB2 |
| 1 | C&K JS102011SAQN | Button_Switch_SMD:SW_SPDT_CK_JS102011SAQN | SW1 |
| 1 | C&K KMR231NG LFS | Button_Switch_SMD:SW_Push_1P1T_NO_CK_KMR2 | SW2 |
| 1 | Conn_01x02 | Connector_AMASS:AMASS_XT30U-F_1x02_P5.0mm_Vertical | J1 |
| 1 | DNM | Resistor_SMD:R_0402_1005Metric | R11 |
| 1 | KSZ8794CNXCC | KSZ8794CNXCC:IC_KSZ8794CNXCC | U5 |
| 1 | LP5907MFX-1.2 | Package_TO_SOT_SMD:SOT-23-5 | U6 |
| 1 | LTST-C191KGKT | LED_SMD:LED_0603_1608Metric | D3 |
| 1 | LTST-C191KSKT | LED_SMD:LED_0603_1608Metric | D4 |
| 1 | STM32H723ZGTx | Package_QFP:LQFP-144_20x20mm_P0.5mm | U1 |
| 1 | TC2030 | Connector:Tag-Connect_TC2030-IDC-NL_2x03_P1.27mm_Vertical | J6 |
| 1 | TPSM863253RDXR | TPSM863253RDXR:RDX0007A-MFG | U4 |
| 1 | TSX-3225 25.00M-N0YYYYG30RGB | Crystal:Crystal_SMD_3225-4Pin_3.2x2.5mm_HandSoldering | Y1 |
| 1 | USBLC6-2SC6 | Package_TO_SOT_SMD:SOT-23-6 | U2 |
| 1 | USB_C_Receptacle_USB2.0_16P | Connector_USB:USB_C_Receptacle_GCT_USB4110 | J7 |
Similar boards
- 4 layers96 × 61 mm

- 2 layers184 × 94 mm

FIC Board
DanielRHoefer/satoshi-emblem
Toyota backlit grill emblem with sequential LEDs for 3rd gen 4runner
193 parts1 - 2 layers20 × 20 mm

B IrDA Transceiver BRK R1.0
Boardoza/Boardoza_IrDA_Transceiver_Infrared_Wireless_Communication_Breakout_Board
Compact infrared transceiver breakout board for short-range asynchronous wireless serial communication.
18 partsCC-BY-SA-4.0 - 4 layers24 × 27.3 mm

Aaronjamt Framework IR Module
hackclub/OnBoard
💡 Join 1,000 teenagers and make your first PCB with a $100 grant!
39 parts915 - 4 layers89.5 × 62.6 mm

- 6 layers104.8 × 77 mm

- 4 layers56 × 88 mm

Pico peltier
syauqi-alias/pico-peltier
A TEC Controller using Pico RP2350 with Analog Devices LT8722 full bridge driver rated at 15V 4A
99 parts1MIT - 2 layers69 × 56.8 mm

Questions
How big is the Wasp node board PCB?
It measures 60.4 × 51.4 mm and has 4 copper layers, 1.56 mm thick.
What parts does the Wasp node board use?
116 parts, built around the KSZ8794CNXCC, LP5907MFX-1.2, TPSM863253RDXR and USBLC6-2SC6. The full list of its parts is on this page, with links to each part's symbol and footprint where PCBKit's library has them.
Where can I download the Wasp node board KiCad files?
From the oskarvh/wasp repository on GitHub, at the commit this page was made from: the board is hardware/wasp_node_board/wasp_node_board.kicad_pcb and its schematic hardware/wasp_node_board/eth.kicad_sch. PCBKit opens the board in the browser, with nothing to install.
Can I use the Wasp node board design in my own project?
Its repository publishes it under the Apache-2.0 licence: you may reuse it on that licence's terms (keep the author's notice, and for a copyleft licence share your changes alike).