stm32f4-emu
v1.0.0
Published
STM32F407 emulator: Unicorn (WASM) CPU + Rust peripheral model. Runs real Cortex-M4 firmware in Node or the browser.
Downloads
189
Maintainers
Readme
STM32F4 Emulator (stm32f4-emu)
An STM32F407 microcontroller emulator that runs real Cortex-M4 firmware. It combines a Unicorn CPU core (QEMU-derived, compiled to WASM) 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 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 31 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) is serviced by an opt-in guest-IRQ
pump; 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 ~25 FPS in a
headless-Chrome-verified browser page — playable, but below DOOM's native
35 fps: at ~918k guest instructions per rendered frame, 35 fps would need
~32 MIPS and the Unicorn WASM core tops out near 20-24 (details and the
measurements in AGENTS.md §16). 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.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 -- blinky/blinky.bin --port 8234
# then open http://127.0.0.1:8123?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 ../../eth_http/eth_http.bin 10000000 \
--gateway --config=../../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.
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 eth_http/eth_http.bin --port 8234
# 2. Open the browser console with the bridge URL param
open "http://127.0.0.1:8123/?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 |
| 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.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 ──► Unicorn WASM CPU ──► memory hooks
│
periph_read/write ──► 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: Unicorn 2.1.4 compiled to WASM executes Thumb-2 code; every read/write to a hooked MMIO range is routed into the Rust model, which answers by writing the modeled register value back into guest memory.
- Peripherals: a
wasm-bindgencrate (stm32-periph-wasm/); registers and bit fields come from the vendor SVD (monox/stm32f407.svd). - Ethernet: TX is captured from the DMA descriptors; RX frames are
injected into the RX ring and the firmware's
eth_irq_flag(SRAM) drives polling — no interrupts required. 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.mjs Node DOOM boot/menu/gameplay/save test
│ └── vendor/ Browser WASM build, SVD, Unicorn
├── 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 eth_*/.bin
├── stm32-periph-wasm/ Rust peripheral model (WASM build + pkg/)
├── eth_http/ eth_dhcp/ eth_test/ Sample network firmwares + configs
├── 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
├── src/, monox/, saturn/ Native SDL emulator (upstream 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 eth_http # also eth_dhcp, eth_test
# native SDL emulator (upstream heritage, not the headless path)
cd stm32-emulator-main && cargo build --releasewasm-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) + 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.
- component-API tests (
node site/test_doom.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 33 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: GPL-3.0-only. See LICENSE.
- Unicorn CPU Core: Powered by Unicorn.js by Alex Altea (WASM/JS port of the Unicorn Engine CPU emulator, derived from QEMU, licensed under GPLv2).
- Heritage: Fork and continuation of nviennot/stm32-emulator (native SDL 3D printer emulator by Nicolas Viennot). The headless WASM peripheral model, networking stack, browser demo, virtual components API, MCP server, and npm package are new work built on that base.
- DOOM: Ported using doomgeneric by Ozkan Sezgin.
