Technology16 min read

Show HN: Esp32sim – Run ESP32 firmware in the browser

By · Published by Everything Blog

In short

Esp32sim emulates four ESP32 SoCs, including the ESP32-S3, ESP32-C3, and ESP32-C6, in the browser, booting the real mask ROM, 2nd-stage bootloader, and application image without any patches or stubs. It includes full-listing decoder checks and hardware comparisons, running on Linux, Windows, and macOS. The project is MIT licensed and requires no installation.

Key points

  • Esp32sim is a browser-based tool for running ESP32 firmware.: Esp32sim is a browser-based tool for running ESP32 firmware.
  • Emulates four ESP32 SoCs without any patches or stubs.: Emulates four ESP32 SoCs without any patches or stubs.
  • No cloud or accounts required, making it accessible.: No cloud or accounts required, making it accessible.

Sources checked by our analysis pipeline: 1

Instruction-level emulation of four ESP32 SoCs, across both of Espressif's CPU architectures. Each boots the real mask ROM, the real 2nd-stage bootloader and an unmodified application image — no patched firmware, no stubs in the way — with enough of the SoC modelled to run real projects end to end. No cloud, no accounts. MIT.

The classic ESP32 also boots its ECO3 ROM on dual LX6 cores, with GPIO, UART and timers; --board ttgo-tdisplay

adds the LilyGO TTGO T-Display's ST7789 and buttons.

ESP32-S3 | ESP32-C3 | ESP32-C6 | |

---|---|---|---|

CPU | 2 × Xtensa LX7 @ 240 MHz | 1 × RISC-V RV32IMC @ 160 MHz | 1 × RISC-V RV32IMAC @ 160 MHz |

Command | esp32sim |

esp32sim-c3 |

esp32sim-c6 |

Core crate | xtensa-lx7 |

riscv-rv32 |

riscv-rv32 (+ the A extension) |

Reported full-listing decoder checks | 977 k instructions, 0 mismatches | 161 k instructions, 0 mismatches | 126 k instructions, 0 mismatches |

Boots | ROM → bootloader → FreeRTOS → app | ROM → bootloader → FreeRTOS → app | ROM → bootloader → FreeRTOS → app |

Boards / displays | ST7735, ST7701S 480×480 + touch, WS2812, camera, audio | none — console only | ST7789 172×320 over SPI+DMA, WS2812, an 802.15.4 energy-detect stand-in |

WiFi | virtual AP, WPA2, DHCP/DNS/NTP, NAT to the real network, HTTPS | the same virtual AP and network: scan, WPA2, DHCP, NAT (one station) | the same virtual AP and network: scan, WPA2, DHCP, NAT (one station, legacy rates) |

Speed | block interpreter + AArch64 JIT, ~150–240 Minsn/s | plain interpreter (no block cache or JIT yet) — still well above real time on hello_world | same interpreter |

In the browser | yes (WebAssembly) | yes (WebAssembly) | yes (WebAssembly) |

Reported hardware comparisons | JTAG trace, 8000 steps after timing-loop resynchronization | console diff, 205/208 lines identical | console diff, 203/204 lines identical |

Status | mature | draft — see docs/esp32c3.md | draft — see docs/esp32c6.md |

These full-listing and hardware counts describe earlier manual runs. CI runs the checked-in sampled decoder corpora and emulator golden outputs; it excludes the external full-listing tests. The Xtensa objdump test excludes PIE operand comparisons. Hardware trace comparison normalizes window exceptions and resynchronizes delay loops, so it is not uninterrupted cycle-by-cycle agreement. See the test scope, CI workflow, Xtensa decoder test and hardware comparator.

Most of the SoC is shared: the C3 reuses the S3's UART, USB-Serial/JTAG, systimer, timer groups, GPIO, SPI flash controller, GDMA and SHA/AES/RSA models unchanged, and adds its own memory map, cache controller and interrupt matrix; the C6 reuses the same models again, with its own memory map, PLIC front-end, L1 cache, PCR and always-on LP blocks. Only the CPU crates are genuinely separate.

Try it in a browser, no install: https://mikroverk.github.io/esp32sim/ — Linux on the S3, the Touch-LCD-4B panel with its SID player, the pocket-tank LLM aquarium, and the C3 and C6 demos.

esp32sim/

emu-core/ the Bus and Core traits, Trap, ClockTree, AArch64 encoder — shared by both cores

esp-soc/ Machine<S: Soc>: scheduler, device time, console, scripts, web UI, loaders, boards —

one machine for every chip; a chip plugs in its cores, bus and interrupt routing;

the virtual WiFi access point, the network behind it and the NAT, for every chip

esp-periph/ the peripheral IP the chips share (UART, systimer, TIMG, GPIO, SPI_MEM, GDMA, crypto,

I2S, RMT, I2C, …), the Device trait, and the device_set! table that mounts them

── ESP32-S3 (Xtensa LX7, dual core) ──

xtensa-lx7/ decoder (sampled objdump checks plus optional full listings), interpreter

(windowed regs, loops, XEA2 exceptions/interrupts, FPU, MAC16, booleans,

PIE SIMD), basic-block interpreter, AArch64 JIT, objdump-compatible disassembler

esp32s3/ SoC + boards: memory map, cache MMU, SPI flash/PSRAM, SHA/AES/RSA, RNG,

systimer, timer groups, interrupt matrix (per core), GPIO, USB-CDC,

UARTs, I2C, GDMA + I2S/LCD_CAM, RMT TX, regi2c, RTC WDT, WiFi MAC;

board/: atech14 / waveshare-cam / waveshare-lcd4b / waveshare-amoled18-v2 / none

cli/ the `esp32sim` command line, every chip (`--chip`); `esp32sim-c3` / `esp32sim-c6` are alias binaries

── ESP32-C3 and ESP32-C6 (RISC-V, single core) ──

riscv-rv32/ RV32IMAC decoder (sampled objdump checks), interpreter, disassembler

esp32c3/ the C3 SoC: memory map, interrupt matrix, cache controller, RNG;

peripheral models shared with esp32s3 where the IP is identical

esp32c6/ the C6 SoC: unified memory map and MMU, interrupt matrix + PLIC/INTPRI, L1 cache,

PCR, the LP blocks (reset cause, software reset, RTC timer), ASSIST_DEBUG,

the 802.15.4 and WiFi MACs

tests/ golden-output regression tests and their fixtures (tests/README.md)

── shared ──

wasm/ C-ABI crate wrapping all three Machines for the browser in one module

web/ browser UI (board drawing, console, audio, camera) + emu.js/worker.js for wasm

hw/ JTAG differential-test scripts against a real board, captured C3 and C6 consoles

examples/ hello_world (IDF, S3, C3 and C6), waveshare-cam (autopling run script + photo),

wifi-station (S3), c6-wifi-station (builds for the S3, C3 and C6; the WiFi goldens'

firmware), c6-radio-dashboard (WiFi + 802.15.4 on the C6 board)

tools/ PIE table generator (TRM-derived); bench.py (interleaved A/B benchmark);

wasm-build.sh (the WebAssembly module)

The SoC model emits pin-level events (GPIO edges, RMT symbol streams, I2S samples); a

BoardModel

(esp-soc/src/board.rs

) interprets them. --board atech14

(default) is the Atech

14‑port board with its ST7735, WS2812 ring, encoder and buttons; --board none

is a bare

module — any ESP32‑S3 firmware, console only; --board waveshare-lcd4b

is the Waveshare

ESP32‑S3‑Touch‑LCD‑4B (ST7701S 480×480 over the LCD_CAM RGB bus, GT911 touch, TCA9554, codecs)

running the esp32-screen LVGL panel with touch; --board waveshare-amoled18-v2

is the Waveshare

ESP32-S3-Touch-AMOLED-1.8 V2 (CO5300 368x448 over QSPI, CST820 touch); --board waveshare-cam

is the Waveshare

ESP32‑S3‑CAM‑OV5640 (CH32V003 IO expander, OV5640 over SCCB, ES8311/ES7210 codecs on I2C0,

speaker on I2S1, OV5640 on the LCD_CAM DVP port) — runs the waveshare-autopling

firmware

(IDF 5.5, 16 MB flash, 8 MB octal PSRAM: --flash-mb 16 --psram-mb 8

) end to end: camera frames

from --cam-image

or the browser (picture upload / webcam) → esp‑dl pedestrian detector on the

emulated PIE SIMD unit → pling on the ES8311. See examples/waveshare-cam/

. Adding a board =

implementing the trait (gpio_changes

, rmt_frame

, i2c_devices

, camera_frame

, …).

The Pocket Synth firmware the atech14

demos run is its own project:

atech-firmware — a PlatformIO build of the SID synth and a cRSID

player for real .sid

tunes. This repo carries the built images it produces, in

web/wasm/fw/public/

.

cargo build --release

FW=web/wasm/fw/public # the demo images the goldens and the web page run

./target/release/esp32sim --boot rom \

--bootloader $FW/atech-bootloader.bin --ptable $FW/atech-ptable.bin --app $FW/atech-firmware.bin \

--script $FW/atech-script1.txt --wav out.wav --tft-png tft.png --max-seconds 5

The mask ROM ELF is picked up from ~/.espressif/tools/esp-rom-elfs/*/esp32s3_rev0_rom.elf

(shipped with ESP‑IDF). Without ESP-IDF, tools/fetch-demo-assets.sh --no-linux

puts the S3, C3 and

C6 ROMs in web/wasm/fw/

: pass --rom web/wasm/fw/esp32s3_rev0_rom.elf

, or set

ESP32SIM_ROM_DIR=web/wasm/fw

for the tests. --boot app

skips ROM+bootloader and loads the app

image directly.

A plain ESP‑IDF project, e.g. examples/hello_world

(the IDF 5.5 get-started example built with

idf.py set-target esp32s3 && idf.py build

):

H=examples/hello_world/build

./target/release/esp32sim --board none --boot rom --console uart0 \

--bootloader $H/bootloader/bootloader.bin --ptable $H/partition_table/partition-table.bin \

--app $H/hello_world.bin --elf $H/hello_world.elf --elf $H/bootloader/bootloader.elf --max-seconds 26

prints the ROM banner, the bootloader and app logs on UART0, Hello world!

, the countdown, and

then reboots through the RTC watchdog exactly as silicon does (rst:0xc (RTC_SW_CPU_RST)

),

~30× faster than real time. Chip resets (software, RTC watchdog) restart the machine from the

ROM with the right reset cause; --no-reboot

stops at the first reset instead.

esp32sim-c3

runs unmodified ESP-IDF firmware on the emulated RISC-V core, from the real mask

ROM through the real bootloader into FreeRTOS. Same flags as esp32sim

(--trace

, --break

,

--watch

, --peek

, --disasm

, --log-periph

), printing RISC-V mnemonics with symbols.

H=examples/hello_world-c3/build

./target/release/esp32sim-c3 --boot rom --flash-mb 4 \

--bootloader $H/bootloader/bootloader.bin --ptable $H/partition_table/partition-table.bin \

--app $H/hello_world.bin --elf $H/hello_world.elf --max-seconds 26

prints the ROM banner, the bootloader log, Hello world!

and the reboot, three times over.

Checked line-for-line against a physical C3 module: 205 of 208 console lines identical over

three boot cycles — the difference is the ROM's Saved PC:

line. --mac

, --reset-cause

and

--strap

let a run adopt a board's identity so the comparison is meaningful. WiFi station mode

supports open and WPA2 virtual APs with DHCP and NAT. No boards; --boot app

unsupported. See docs/esp32c3.md for what works,

what does not, and the five emulator bugs the hardware found.

esp32sim-c6

is the same front end on the ESP32-C6: RV32IMAC, the PLIC, a unified memory map.

H=examples/hello_world-c6/build

./target/release/esp32sim-c6 --boot rom --flash-mb 4 \

--bootloader $H/bootloader/bootloader.bin --ptable $H/partition_table/partition-table.bin \

--app $H/hello_world.bin --elf $H/hello_world.elf --max-seconds 27

Checked against a Waveshare ESP32-C6-LCD-1.47: 203 of 204 console lines identical over three

boot cycles, including the ROM's Saved PC:

after esp_restart()

; the one difference is that

line on the first boot. --board waveshare-c6-lcd147

adds that board — the ST7789 over SPI2 and

the GDMA, the WS2812, the BOOT button — and the C6's 802.15.4 MAC answers energy scans, so the

board's LVGL spectrum-scanner firmware runs end to end (examples/waveshare-c6-lcd147/

). The MAC

also sends and receives frames with the timing of the air, and --cooja

makes the C6 an external

mote of Cooja-NG, driven in exact lock-step over NDJSON (an unmodified Contiki-NG-on-IDF image

exchanging broadcasts with emulated MSP430 nodes). WiFi works too: --wifi

attaches the same

virtual access point as on the S3 (see WiFi below). See

docs/esp32c6.md.

1.5 press btn1 150 # GPIO17 low for 150 ms

2.2 knob cw 2 # two quadrature detents on CLK5/DT4

3.4 press btn2 120

4.0 serial {"action":"set_note","value":"9"} # into USB-CDC RX

5.0 stop

--wifi ssid=NAME[,psk=PASS,chan=N,bssid=..]

attaches a virtual access point that the unmodified

Espressif WiFi blob associates with — scan, authentication, association and, with a passphrase, the

WPA2-PSK four-way handshake — and a virtual network behind it (DHCP, ARP, ICMP, DNS, SNTP off the

host clock). --net nat

(the default) relays the guest's TCP and UDP over ordinary host sockets, so

firmware reaches the real network; --net none

refuses it. No firmware changes, no root, no tun

device.

./target/release/esp32sim --board waveshare-lcd4b --boot rom --flash-mb 16 --psram-mb 8 \

--bootloader $P/bootloader/bootloader.bin --ptable $P/partition_table/partition-table.bin \

--app $P/energy_panel.bin --console usb --wifi "ssid=home,psk=secret" --max-seconds 45

runs the esp32-screen energy panel: it joins, takes a lease, syncs its clock, fetches two days of electricity prices over HTTPS and polls a real Home Assistant on the LAN. docs/networking-howto.md is the how-to (flags, debugging, limits); docs/wifi-plan.md and docs/networking-plan.md describe how the MAC model and the packet path work, and the Networking section of docs/architecture.md the layers every chip shares.

The ESP32-C6 joins the same access point and the same network, through its own MAC model

(docs/wifi-c6-plan.md): one station, legacy rates, no power save.

examples/c6-wifi-station

is the specimen — scan, join, lease, five pings, and the state on the

board's screen — and every C6 radio run needs --stub bb_init=0

(the PHY's baseband calibration

wants analog hardware):

B=examples/c6-wifi-station/build-emu

./target/release/esp32sim-c6 --boot rom --flash-mb 4 --board waveshare-c6-lcd147 --console usb \

--bootloader $B/bootloader/bootloader.bin --ptable $B/partition_table/partition-table.bin \

--app $B/c6_wifi_station.bin --elf $B/c6_wifi_station.elf --stub bb_init=0 \

--wifi ssid=esp32sim,psk=esp32sim-pass --max-seconds 14

The ESP32-C3 joins the same access point too, through a MAC model derived from the S3's. The

same station example, built for each chip without the display, is committed under

web/wasm/fw/public/

as the input of the WiFi goldens (wifi_station_{s3,c3,c6}

in

cli/tests/goldens.rs

), so it runs without a local build:

P=web/wasm/fw/public

./target/release/esp32sim-c3 --boot rom --flash-mb 4 --console usb \

--bootloader $P/c3-wifi-bootloader.bin --ptable $P/c3-wifi-ptable.bin --app $P/c3-wifi_station.bin \

--wifi ssid=esp32sim,psk=esp32sim-pass --max-seconds 14

examples/c6-radio-dashboard

runs WiFi and 802.15.4 energy scans in one firmware, a page each.

tools/wasm-build.sh # the module -> web/wasm/esp32sim.wasm

tools/fetch-demo-assets.sh # mask ROMs, xterm.js and the Linux image (--no-linux skips its 16 MB)

python3 -m http.server -d web 8790 # then open http://127.0.0.1:8790/?wasm&fw=hello

The demo firmware is committed; the mask ROMs, xterm.js and the Linux image are not, and every demo boots from the ROM, so run the fetch once. It gets the same files the Pages site serves.

The same emulator compiled to WebAssembly, running inside the page in a Web Worker: pick a board,

load the ROM ELF and firmware from disk (or ?wasm&fw=<name>

for a hosted manifest), press Boot.

hello_world, the Touch-LCD-4B panel with its SID player, the Atech board, the ESP32-C3 and the

ESP32-C6 all run at real time in Chrome; there is no NAT (the browser has no sockets). The S3 has

a WebAssembly JIT of its own (hot blocks and regions, PIE on WASM SIMD). See docs/wasm.md.

Live: https://mikroverk.github.io/esp32sim/ — Linux 6.11 on the ESP32-S3

(svermigo/Linux-on-esp32-S3, log in and type on the

console), the Touch-LCD-4B panel with its SID player, the Atech board,

pocket-tank (a 4-bit transformer steering the fish on

the Touch-AMOLED-1.8), and the ESP32-C3 and C6

booting hello_world; or load your own firmware for any chip from disk (pick the board, or

esp32c3

/ esp32c6

).

--trace [--trace-from N]

, --break ADDR

, --watch ADDR

(stop when a word changes),

--peek ADDR[,N]

, --profile

(pc histogram), --log-periph

(first touch of every

unknown register), --stop-after-exceptions N

, --gram-png

(raw ST7735 GRAM), --no-jit

(interpret instead of running native code — must give identical results).

Env: ESP_EMU_DEBUG

, ESP_EMU_DEBUG_SPI

, ESP_EMU_DEBUG_USB

, ESP_EMU_DEBUG_WIFI[_FRAMES]

,

ESP_EMU_DEBUG_NET

, ESP_EMU_DEBUG_AES

, ESP_EMU_DEBUG_SHA

, ESP_EMU_DEBUG_RSA

.

- docs/adding.md — adding a peripheral, a board with devices, a CPU/chip, an observer

docs/

— architecture, peripheral coverage,

boards, CLI reference, web UI protocol,

design decisions & gotchas, roadmap,

networking how-to, the WiFi and

networking design notes, and the testing plan.

Written from the ESP‑IDF register headers, the Xtensa core config shipped with ESP‑IDF, the RISC-V specification and the C3 and C6 technical reference manuals, and observed firmware behaviour. QEMU was consulted only to confirm instruction semantics (no code copied). MIT.

Every chip is checked against hardware, by different methods: the S3 in JTAG lock-step

(below), the C3 and C6 by diffing a captured console against the emulator running the same

binaries (hw/c3-hello-world-real.txt

, 205/208 lines identical — see

docs/esp32c3.md; hw/c6-hello-world-real.txt

, 203/204 — see

docs/esp32c6.md). Those comparisons found five emulator bugs each, including

a device-command race and a cycle counter that did not restart at reset — neither of which a

board-free test would have caught.

DIFF_DIR=hw/<board> FLASH_MB=8 hw/difftest.sh 3000

— the scripts read efuses/strap from the

attached chip over JTAG, then step it and the emulator in lock-step on the same flash dump

(flash-8M.bin

if present). Atech board, 2026-08-25: 3000 steps from reset, 0 divergences.

Any ESP32‑S3 board on USB works (its built‑in USB‑Serial/JTAG carries both the console and JTAG). The flow, all scripted:

# one-time: dump the board's bootloader/partition table/app start (esptool) into hw/flash-0-1M.bin

hw/difftest.sh 8000 # reset → single-step 8000 instructions on the chip and in the emulator, diff

hw/difftest-at.sh 403c8948 6000 # run both to a PC (here the 2nd-stage bootloader entry), then step 6000 and diff

difftest*.sh

read the chip's efuses (hw/efuse.txt

), strapping pins and the peripheral

reset state (hw/reset-regs.txt

, dumped over JTAG at reset halt

) and start the emulator

from the same image and state. hw/compare.py

diffs pc a0..a15 ps windowbase

per

instruction, masks PS.INTLEVEL

(forced during single‑step), hides window‑exception

handlers (the debugger steps over them atomically) and resynchronises across CCOUNT‑timed

delay loops, which iterate a different number of times when each step takes milliseconds.

Result so far: the ROM reset path (8000 steps) and 3000+ steps of the IDF 5.5 bootloader run with zero PC divergence; remaining register differences are RTC‑domain values that depend on the previous boot.

./target/release/esp32sim --boot rom --bootloader … --ptable … --app … --web 8766

# open http://127.0.0.1:8766/

--web PORT

runs the emulator in real time and serves web/run.html

: the 14‑port board

(knob + LED ring, buttons, speaker VU, the ST7735 in its physical orientation plus a readable

copy), USB‑CDC and UART0 consoles, an action box for the SDK JSON protocol, and audio through

WebAudio. Inputs: click the buttons, wheel/drag/←→ on the knob, click the cap to push it.

Blocks of instructions are decoded once and executed from a cache, then compiled to native

AArch64 code (--no-jit

falls back to the interpreter and must give identical results); idle

cores (waiti

) are skipped, device time is delivered lazily against computed timer deadlines,

and interrupt lines are recomputed only when a source changes. See

docs/speed-plan.md for the measurements behind each step.

The Pocket Synth firmware runs at real time with margin while idle or playing notes; the one

place it cannot keep up is a full display redraw, where core 1 runs 100 % busy bit‑banging SPI

(4.8 M instructions per 20 ms — 240 Minsn/s needed, ~70 Minsn/s achieved on an M‑series

MacBook). Each redraw therefore costs ~0.3 s of lag, which the UI's adaptive audio buffer

absorbs (it grows on underrun, up to 400 ms) and the pacer recovers afterwards; if the

emulator ever falls more than 0.5 s behind it resynchronises instead of bursting. The header

shows real time

, ⚠ N s behind

and the resync count.

ESP_EMU_RT_LOG=1

prints every 20 ms window that took > 40 ms wall with both cores'

instruction counts and PCs. hw/wsdrive.py [port] [seconds]

drives the UI protocol without a

browser (button presses + knob turns) and reports push gaps, lag and audio delivered — use it

to measure changes to the scheduler.

Contributions are welcome. CONTRIBUTING.md lists the checks a pull request needs (the same ones CI runs) and the conventions for tests, experiments and evidence.

esp32sim is released under the MIT License, copyright Joakim Eriksson (@joakimeriksson), Alice (@aliceisjustplaying), and (@mfiumara) Mattia Fiumara.

Third-party code, data and demo files keep their own licenses:

- pocket-tank (mediacutlet/pocket-tank, MIT). Its

bootloader, partition table and app are committed under

web/wasm/fw/public/

withpocket-tank-LICENSE.txt

; its model is fetched. - MicroPython (micropython.org, MIT): the

official ESP32_GENERIC v1.29.0 image of the T-Display demo, fetched by

tools/fetch-micropython.sh

, not committed; the demo's app and filesystem are inexamples/ttgo-tdisplay

with Russ Hughes's st7789py driver and fonts (MIT), notice inweb/wasm/fw/public/tdisplay-NOTICE.txt

. - Mask ROM ELFs (espressif/esp-rom-elfs, Apache-2.0), fetched, not committed.

- xterm.js (xtermjs/xterm.js, MIT), fetched, not committed.

- Linux image (svermigo/Linux-on-esp32-S3, GPL-3.0), fetched, not committed.

- ESP-IDF ADC calibration data (Espressif, Apache-2.0): the curve-fitting coefficients

and S3 ADC1 calibration from #165, now together in

esp-periph/src/sar_adc.rs

; seeesp-periph/LICENSE-ADC

.

Original source: github.com

Technology