32esp32-emu
WASM dual-core Xtensa

about the emulator

A whole ESP32, simulated honestly.

esp32-emu boots the real ESP32 boot ROM and runs real compiled Arduino / ESP-IDF firmware on a cycle-counted dual-core Xtensa LX6 model. There is no instruction-set paraphrase and no UART spoofing: every byte on the demo terminal was transmitted by emulated silicon.

How it works

Four layers, each doing the job it is best at.

1 · Core engine (Rust to WASM)

Both LX6 cores execute inside a single WebAssembly module compiled from Rust. A batched core_run export advances up to 512 interleaved iterations per host call, with CCOUNT sync per instruction. The core is self-timed: it tracks its own cycle count and fast-forwards idle stretches straight to the next timer alarm.

2 · Native peripherals (same module)

UART, GPIO, SPI, I2C, timers, RMT, TWAI, ADC, touch, SDMMC, EMAC, WiFi MAC and more live in Rust behind a page-table dispatch. A full firmware run issues zero fallback calls into JavaScript for peripheral traffic.

3 · Host bridges (JavaScript)

What cannot live in WASM stays out: the worker-thread host, the SharedArrayBuffer UART ring, the simulation clock source, the WiFi gateway socket, and the compile service that turns Arduino sketches into flash images.

4 · This page

The demo tab spawns the same worker the Node test-suite uses, feeds it a prebuilt 4 MB flash image, and renders UART0 into the terminal. The ESP32-CAM toggle applies the real module preset: 4 MB PSRAM and the AI-Thinker pinout.

Speed, measured not marketed

Throughput is reported in true MIPS: retired instructions per wall-clock second, counted in the WASM cores themselves — not the simulator's step budget. Two numbers matter.

~18 MIPS
retired, GPIO firmware, whole run to pass mark
9–26
MIPS range across all 53 demos (real firmware)
live
every demo run prints its own MIPS line
0
JS fallback calls on the peripheral hot path
Why not 3000? An older build divided the JS step budget (chip.cycles: +512 per batched step plus idle fast-forward) by wall-clock and printed ~3000 MIPS. That counter is virtual time, not executed instructions — one step can retire far fewer than 512, and idle fast-forward adds cycles with zero instructions. The honest numerator is Rust inst_count (both cores, via the debug SAB): ~11M retired in ~0.6s on GPIO ≈ 18 MIPS. The per-demo table in Docs lists all 53.
# reproduce a true-MIPS number (Node, repo root; needs :5525 for images)
node /tmp/fwex5/measure-inst.mjs gpio
# per-demo pass/fail (needs the compile server on :5525)
node tests/test-worker-gpio.mjs   # … one file per peripheral

Key facts

Chip
ESP32 (Xtensa LX6, dual core, 240 MHz model)
Boards
ESP32 dev module · ESP32-CAM (AI-Thinker, 4 MB PSRAM)
Firmware
Arduino / ESP-IDF 3.3.10, compiled with arduino-cli
Boot
Real boot ROM (esp32-v3-rom.bin) + real bootloader
Engine
Rust → wasm32-unknown-unknown, batched core_run
Host
Node worker_threads · browser Web Worker + SharedArrayBuffer
UART
Ring buffer over SAB, 115200 8-N-1 model
Honest limits
BLE advertising needs the baseband (not emulated; ADV/GATT marks only via callback injection); WiFi RF needs the gateway