stm32f4-emu
v1.7.0
Published
STM32F407 emulator: Rust CPU + peripheral model (WASM). Runs real Cortex-M4 firmware in Node or the browser.
Maintainers
Readme
STM32F4 Emulator (stm32f4-emu)
Status: emulation complete. All 41 peripheral modules are Detailed (0 Partial), all five board maps verify green (37/37/72/73/59 presets), and the Ethernet descriptor layer (EDFE, TCH/TER + RCH/RER walks, RDES4, backoff probe) plus the Wireshark-validated pcap capture (
a1b2c3d4, tcpdump-verified DHCP→TCP→HTTP) are in. What remains is product work, not emulation: npm publish, https gateway endpoint, VS Code packaging (see docs/progress-and-future.md).
An STM32F407 microcontroller emulator that runs real Cortex-M4 firmware. It combines a Rust CPU core (a WASM-native Thumb-2 interpreter with exact Cortex-M exception entry/return, including the VFPv4-SP FPU) with a Rust peripheral model (RCC, USART, GPIO, DMA, ETH, TIM, NVIC, ...) also compiled to WASM — so the whole machine runs headless in Node.js or a browser tab, with no SDL, no native deps, no hardware.
It ships three real networking firmwares (eth_http, eth_dhcp, eth_test)
that do DHCP + TCP + HTTP against a simulated (or a real gVisor-backed)
network, a 66-marker Ethernet feature matrix (eth_feat_test: PHY/MDIO,
checksum offload, hash/perfect/SA/DA filtering, VLAN, PTP, WOL, wire
pacing, deferral/collisions, pause frames, MMC, RBUS — see
NETWORKING.md) plus real LwIP 2.2.1 (lwip_demo) and
MII/RMII pin mirrors (eth_pins_test), a browser demo, and a publishable
npm package.
Live demo
The browser demo deploys to GitHub Pages:
https://danish9661.github.io/STM32F4-emulator/
A single console page that starts idle — nothing runs until you pick a
firmware: a preset dropdown with 223 bundled binaries (network demos, a
bare-metal LED blinker, peripheral/crypto/UART/SPI test binaries), custom
firmware upload (.bin, Intel .hex, .elf — with loadable RAM segments
and symbols — plus .map for a symbol table), Run/Stop/Reset, a gateway
URL field to connect a real network stack (openhw-gw + gVisor) with a
scripted network (netsim) as fallback, a live UART terminal (with UART
RX input — type into the console and the firmware reads it; newline
characters excluded per HTML spec, see AGENTS.md §11), GPIO pin readout
for banks A–E, and key peripheral registers. Interrupt-driven firmware
(rx_interrupt_test, rx_crypto_test) gets inline guest-IRQ delivery;
polling firmware (the ETH demos) never uses it. For automation, a
preset can auto-boot via the URL: ?fw=eth_http, ?fw=blinky, ?fw=crypto_test, …
DOOM (in the browser)
site/doom.html runs DOOM 1 shareware
(doomgeneric, ported to the emulated F407) at the full 35 fps in a
headless-Chrome-verified browser page — playable: at ~1M guest instructions
per rendered frame, 35 fps needs ~32 MIPS and the Rust core delivers ~65
(details and the measurements in AGENTS.md §16/§22). Because the guest mixes
one frame of audio
per rendered frame, sound below 35 fps plays slightly slow and pitched-down
rather than breaking up — the worklet rate-matches instead of inserting
gaps, and the stats line reports it (audio 0.72x). The page: 320×200
CMAP256 framebuffer,
WASD + arrows + Ctrl/Space/Shift + F-keys, I2S audio out (mixer →
AudioWorklet at 11025 Hz), and a realtime lock that paces the guest to
wall time. Save/load works: the firmware stages savegames to an
EXTRAM region (2 slots × 256 KB at 0xC0080000) and doom.js mirrors
them to localStorage['doom-save-N'], so F6 (quick-save), F2 (save
menu), F9 (quick-load, 'y' to confirm) and F3 (load menu) survive page
reloads.
- Controls: move W/S/A/D + arrows · strafe Shift · fire Ctrl · use Space · menu Enter/Esc · save F2/F6 · load F3/F9 · F1/F10/F11/F12.
- Boot → menu → gameplay verified end-to-end by
node site/test_doom_wasm.mjs(boot markers, menu navigation to E1M1, palette + framebuffer, W-move + turn, audio, save →SAVE ok slot=0→ load handshake). site/doom1.wadis the 4.2 MB shareware WAD; the firmware never reads it from flash — the driver loads it into 8 MB ofextra_mem(0xB8000000).
Quickstart
# browser demo locally
npm run serve # then open http://127.0.0.1:8123
# node end-to-end flow test (boot -> DHCP -> TCP -> HTTP, 2 rounds)
npm test # == node site/test_flow.mjs
# websocket bridge: headless Node serves the emulator, browser is a thin UI
npm run bridge -- firmware/blinky/blinky.bin --port 8234
# then open http://127.0.0.1:8123/console.html?bridge=ws://127.0.0.1:8234
# gateway-backed run: firmware talks to a REAL network stack (gVisor)
cd stm32-periph-wasm/pkg
node cli.mjs ../../firmware/eth_http/eth_http.bin 10000000 \
--gateway --config=../../firmware/eth_http/config.yaml
# (requires an HTTP server at 127.0.0.1:8092; see AGENTS.md §10)Use it as a library (npm package)
npm pack produces stm32f4-emu — the full emulator as a library,
with all WASM assets, the SVD register map, and the firmware binaries
bundled:
import { createSTM32F407, createNetSim, FIRMWARES } from 'stm32f4-emu';
const netsim = createNetSim(); // canned DHCP/TCP/HTTP peer
const emu = await createSTM32F407({
firmware: FIRMWARES.eth_http.bytes, // any STM32F4 firmware blob
onTx: (frame) => {
for (const reply of netsim.onTx(frame)) emu.injectFrame(reply);
},
});
emu.step(100000); // run up to 100k instructions
const uart = emu.drainUart(); // collect UART output
emu.injectFrame(packetBytes); // inject an Ethernet frameSee site/test_flow.mjs and the exports in index.mjs for the full API
(decodeFirmware, createEmulator, createNetSim re-exports).
Attach virtual hardware (LEDs, buttons, PWM readers, analog sensors,
custom SPI/I2C devices) to pins and buses with the component API —
emu.pin()/emu.watchPin()/emu.setAdcChannel() plus the
LED/Button/Pwm/Potentiometer/I2cRegisterDevice components, or
ext_devices.spiDevices/i2cDevices for your own bus protocol. See
docs/components.md.
For platform integration (Wokwi/OpenHW/Velxio) use the STM32F4 facade —
factories, GPIO/USART/SPI/I2C objects, polled event callbacks, DMA
controller, live displays, SWD/JTAG/power/probe helpers. See
docs/facade.md.
Drive it from an AI agent (MCP)
mcp/server.mjs exposes the emulator over the Model Context Protocol —
boot firmware, step, read UART, poke pins, inject ADC values, and inspect
registers as tools an MCP client (Claude Code, Claude Desktop) can call:
npm install && npm run mcpThe MCP SDK is an optional peer dependency, so installing this package
as a library stays dependency-free; only MCP users need
npm i @modelcontextprotocol/sdk zod. See docs/mcp.md for
the tool reference and client config.
WebSocket bridge (headless Node ↔ browser UI)
A binary WebSocket protocol so the browser console can drive the emulator running headlessly in Node — all WASM execution stays in Node, the browser is a thin UI. Zero impact on the existing local WASM path:
# 1. Start the bridge in Node (serves the emulator over WS on port 8234)
node site/ws-bridge.mjs firmware/eth_http/eth_http.bin --port 8234
# 2. Open the browser console with the bridge URL param
open "http://127.0.0.1:8123/console.html?bridge=ws://127.0.0.1:8234"The RemoteEmu adapter (site/remote-emu.js) is a drop-in replacement
for the local emu object — same step()/drainUart()/read32() API,
all proxied over binary WebSocket. Device stubs (OLED/TFT/etc.) run in
Node and are not visible to browser JS. Without ?fw=, the page boots
whatever firmware the bridge was started with; with ?fw=blinky&bridge=ws://…,
the browser sends the firmware image over the bridge.
See AGENTS.md §20 for the full binary protocol reference.
Firmwares
| Firmware | What it does | Success marker |
|---|---|---|
| eth_http/ | DHCP + TCP client + HTTP GET + prints the response | TCP connected, === HTTP <len>b === |
| eth_dhcp/ | Loops DHCP Discover/Offer/Request/Ack | DHCP SUCCESS |
| eth_test/ | Raw ETH TX/RX self-test | ETH Test: done |
| eth_feat_test/ | 66-marker Ethernet feature matrix (netsim, F407 + F429) | FEAT Test: done (all 66 markers, FAIL/TIMEOUT anti-markers) |
| lwip_demo/ | Real LwIP 2.2.1 (DHCP→DNS→TCP echo→TCP server→UDP echo) | LWIP DEMO DONE |
| eth_pins_test/ | MII/RMII pin-level mirrors (TX_EN/CRS_DV/RXD/COL/MDIO/MDC) | PINS ALL PASS |
| blinky/ | No ethernet — LED blinker on GPIOA PA5 + UART tick counter | tick N LED=ON/OFF |
| doom/ | DOOM 1 shareware (doomgeneric F407 port, browser page site/doom.html) | node site/test_doom_wasm.mjs (boot + menu + gameplay + save/load) |
Plus 17 more test binaries (crypto_test, hal_test, timer_test,
periph_test, echo_test, blink_serial, rx_interrupt_test,
spi_tft_test, …) from the *_test/ directories — all boot headless and
print a banner over UART (probe: node site/probe_firmwares.mjs).
All are bare-metal (no RTOS), built with the Arduino core's arm-none-eabi-gcc, and driven purely through memory-mapped registers — the same firmware binaries run on the emulator and on real silicon. That equivalence is for logic, not timing: timers/ADC/RNG/RTC/watchdogs are instruction-count driven rather than wall-clock driven, so firmware relying on real-time behavior (PWM frequency matching real hardware, watchdog timeouts close to spec) will diverge — see docs/progress-and-future.md#known-limitations.
Architecture
firmware .bin ──► Rust WASM CPU (Thumb-2 + NVIC/exceptions)
│
peripheral MMIO ──► Rust peripheral model (WASM)
RCC USART GPIO DMA ETH TIM NVIC
│
UART out / ETH TX frames ──► driver (cli.mjs / site/emulator.js)
│
RX frames injected (netsim, or real gVisor gateway)- CPU: a pure-Rust Cortex-M4 Thumb-2 interpreter compiled to WASM; peripheral accesses call straight into the Rust model, and guest IRQs (SysTick, ETH, USART, SVC/PendSV) are delivered inline with exact exception stacking — no native deps, no JIT, no hooks.
- Peripherals: a
wasm-bindgencrate (stm32-periph-wasm/); registers and bit fields come from the vendor SVD (site/vendor/stm32f407.svd). - Ethernet: TX is captured from the DMA descriptors (OWN/FS/LS poll
demand); RX frames are delivered head-only with FS+LS status, IPHCE/PCE
checksum status, and PTP snapshots for event messages. The full MAC
register map is cross-checked against CMSIS
stm32f407xx.h(DMASR/ DMAIER/MACFFR/MACPMTCTL positions — see NETWORKING.md §6 for the audit). Optionally, a Go gateway (openhw-local-gateway/) with a gVisor network stack makes the firmware talk to a real network:node cli.mjs <fw.bin> <inst> --gateway. - Browser build (
site/): same modules as ESM;site/emulator.jsis an import-free universal factory;site/netsim.jsis a canned network peer;site/loaders.jsparses Intel HEX / ELF32 / linker-map files. The demo page's gateway mode uses the exact same protocol as the Node CLI: raw Ethernet frames over a WebSocket (/api/network-gateway),RESETcontrol message on reboot.
Repository layout
├── site/ Single-page console + universal emulator factory
│ ├── index.html, app.js Console UI (UART, presets, loaders, gateway, GPIO)
│ ├── doom.html, doom.js DOOM page (gameplay + save/load via localStorage)
│ ├── emulator.js Import-free emulator factory (Node + browser)
│ ├── components.js Virtual components (LED/Button/Pwm/Potentiometer/...)
│ ├── netsim.js Canned DHCP/TCP/HTTP network peer (fallback)
│ ├── loaders.js Intel HEX / ELF32 / linker-map parsers
│ ├── test_flow.mjs Node E2E flow test (npm test)
│ ├── test_blinky.mjs Node blinky GPIO test (npm test)
│ ├── test_rx_interrupt.mjs Node UART-interrupt test (npm test)
│ ├── test_component_*.mjs Component-API tests, one firmware each (npm test)
│ ├── test_doom_wasm.mjs Node DOOM boot/menu/gameplay/save test
│ └── vendor/ Browser WASM build (CPU + peripherals), SVD
├── index.mjs, package.json npm package entry (stm32f4-emu)
├── mcp/ MCP server (drive the emulator from an AI agent)
├── .github/workflows/ CI (Linux/Windows/macOS test matrix) + Pages deploy
├── tools/make_firmware.mjs Regenerates site/firmware.js from firmware/*/.bin
├── stm32-periph-wasm/ Rust peripheral model (WASM build + pkg/)
├── firmware/eth_http/ eth_dhcp/ eth_test/ Sample network firmwares + configs
├── firmware/doom/ DOOM 1 port (doomgeneric f407 target + WAD path)
├── openhw-local-gateway/ Go gateway (gVisor network stack)
├── scripts/verify_ethernet.sh Regression runner for all three firmwares
├── hardware/monox/, hardware/saturn/ Upstream printer firmwares (heritage)
└── AGENTS.md Full architecture, build steps, runbookBuilding from source
# Rust peripheral model (Node target; browser target goes to site/vendor/)
cd stm32-periph-wasm && wasm-pack build --release --target nodejs
# firmware (bare-metal Makefiles; toolchain from the Arduino core)
TOOLCHAIN="$HOME/.arduino15/packages/STMicroelectronics/tools/xpack-arm-none-eabi-gcc/14.2.1-1.1/bin/arm-none-eabi-" \
make -C firmware/eth_http # also eth_dhcp, eth_test (all firmware lives in firmware/)
# (no native SDL emulator in this tree — the headless WASM path above is the only backend)wasm-pack writes site/vendor/.gitignore containing * after a browser
rebuild — delete it so the vendor assets stay tracked/committed.
Testing
npm test— flow test (site/test_flow.mjs) + Ethernet mock harnesses (site/test_eth_mock_model.mjs: 18 checks, fake bindings + real driver;site/test_eth_mock_consumer.mjs: 44 checks, fake firmware + real model)- blinky test
(
site/test_blinky.mjs) + interrupt-UART test (site/test_rx_interrupt.mjs) - component-API tests (
site/test_component_{led,button,pwm,i2cregfile}.mjs, each against real firmware — LED/blinky, Button/exti_test, Pwm/buzzer_test, I2cRegisterDevice/rtc_test), exit 0 = all PASS. Each test file boots exactly one firmware in its ownnodeprocess —createEmulator()instances aren't safe to reuse across different firmware in the same process (see docs/components.md). The same suite runs in CI onubuntu-latest/windows-latest/macos-latest(.github/workflows/ci.yml) on every push.
- blinky test
(
node site/test_doom_wasm.mjs— DOOM boot → menu → E1M1 gameplay + save/load (see the DOOM section above).scripts/verify_ethernet.sh [max_inst]— runs all three firmwares through the gateway, asserts the success markers and 0TCP fail.- Soak-tested: 200M-instruction gateway runs with 1000+ consecutive TCP rounds, 0 failures (details in AGENTS.md §10).
Documentation
- site/about.html — in-repo About page: what it is, architecture, featured firmwares, and how to use it (CLI / browser / Node API / MCP).
- docs/architecture.md — how the emulator is put together (CPU, peripheral model, drivers, ETH flow, interrupts).
- docs/peripherals.md — all 41 peripherals and the level each is implemented to, plus external devices and known gaps.
- docs/usage.md — CLI, browser, and npm-library usage, config files, env vars, building.
- docs/components.md — attach virtual LEDs, buttons, PWM/analog sensors, and custom SPI/I2C devices to pins/buses (rp2040js-style component API).
- docs/mcp.md — the MCP server: drive the emulator as tools from Claude Code / Claude Desktop or any MCP client.
- docs/benchmarks.md — throughput numbers, soak results, tunables.
- docs/progress-and-future.md — status, known limitations, roadmap.
License & Credits
- License: MIT. See LICENSE.
- Heritage: clean-room Rust CPU core + peripheral model, JS driver, and
npm package written for this project (no upstream emulator code remains
in the tree — the legacy native/SDL prototype was removed; see
firmware/doom/NOTICE.mdfor the one GPL-walled demo exception). The networking stack, browser demo, virtual components API, MCP server, and npm package are new work built on that base. - DOOM demo (GPL-walled, not in npm): the
doompreset +site/doom.*page run a doomgeneric port (GPL-2.0-or-later engine,firmware/doom/engine, by id Software / Simon Howard / Ozkan Sezgin — see doomgeneric); only the F407 glue infirmware/doom/f407is original. The doom blob + WAD ship in the repo demo only, never in the npm tarball.
