Skip to content
← All posts

~/Blog

8 min read

I built a Blue Pill emulator that runs real firmware. It is done.

EmulatorsSTM32WASMRust

The Blue Pill emulator is finished. Not “v1 with a roadmap” finished — the peripherals are modeled, the firmware proofs pass, the package is on npm, and there is a browser demo. I am calling it done and writing down what that took.

What it is

A full-system emulator for the STM32F1 family — the STM32F103C8 Blue Pill, plus the F105, GD32F103, Maple Mini, and Nucleo-F103RB. It runs real, unmodified Arduino and STM32Cube firmware in Node.js or a browser tab. One WASM module holds the whole machine: a native Rust Cortex-M3 core plus every peripheral model. No server, no native dependencies, no hardware on your desk.

The numbers, since those are the part I had to earn: 772/772 unit asserts, 39/39 real-firmware checks, around 70M instructions/sec headless with the MPU live on every access, and 8 chip variants behind one API. Try it here: the browser demo, or npm install stm32f1-emu.

Why I built it

Debugging on physical microcontrollers is slow in a specific, demoralizing way. Flash, watch it fail, guess which register you misprogrammed, reflash, repeat. Students hit this hardest — half their lab time goes to drivers and cables before they touch a register.

I wanted firmware you could test the way you test anything else: run it, watch the UART, poke a pin, read a register, repeat in seconds. Zero driver installation, zero boards, and — this mattered to me — the same binary that runs on silicon. If the emulator only ran special demo firmware, it would be a toy. It runs whatever you compiled.

How it works

The shape is simple once you stop fighting it. CPU and peripherals both live in Rust, compiled to a single WASM module. JavaScript never touches an instruction — it drives batches:

// every iteration is one batch — no per-instruction JS
1. pump stdin       -> uart_rx_byte()
2. rustcpu_dma_pump()  // plan build + exec, in Rust RAM
3. rustcpu_run(20000)  // native Thumb-2, exact count back
4. step_batch(count)   // tick every peripheral once
5. rustcpu_dma_pump()
6. rustcpu_dispatch()  // up to 64 IRQs, NVIC order

Peripherals tick once per batch on an instruction delta, not once per instruction. That one decision is worth roughly a hundred thousand times fewer tick calls, and it is the reason the thing is fast. The peripheral bus itself is a runtime registry in the rp2040js style, so custom JS peripherals can shadow built-ins without touching Rust.

One batch through the machine. JS only pumps I/O at the edges; everything hot stays in Rust.
DriverCPUBusDMANVIC
DriverCPU
rustcpu_run(20K): native Thumb-2, exact count back
CPUBusfast
MMIO lands in Rust RAM, zero JS crossings
BusDMA
rustcpu_dma_pump: plan build + exec in Rust
DMANVIC
rustcpu_dispatch: up to 64 IRQs, priority order
NVICDriver
step_batch: tick all peripherals once on the delta

The part I am proudest of is the part nobody sees: the coverage audit. I censused all 67 peripheral blocks in ST’s SVD against the models. 41 full, 1 partial (the RCC clock tree — enables and resets are real, wall-clock conversion stays on the instruction budget by decision), 0 stubs. Two entries are intentionally skipped because the silicon genuinely lacks them. That table is what “done” means here — not a feeling, a census.

The Unicorn chapter

For most of the project the CPU was Unicorn’s TCG JIT. It worked. Then I replaced it with a vendored pure-Rust ARMv7-M interpreter and the whole thing got about 3.5x faster headless with exact instruction accounting and no memory hooks. The TCG-parity risk I was scared of never materialized — Unicorn here was running TCI, and the native core beats it.

Deleting ~1.6MB of Unicorn binaries and every --cpu flag felt better than any optimization I have ever shipped. One backend, one backend’s bugs.

The bugs that taught me the most were shared off-by-ones: the model, the unit test, and the firmware all agreeing on the wrong bit. A USB BTABLE stride, a DMA completion nibble, an RTC register order. Three witnesses, one lie. Only real firmware caught them.

What done looks like

The scoreboard, all committed and green:

tests/test_all.mjs   772/772 asserts
canary               39/39 real-firmware checks
headless             ~70M IPS (200M in ~2.8s, MPU live)
browser              multi-MIPS, frame-budgeted loop
chips                8 variants (F103C8/CB, F105,
                     GD32F103x, Maple Mini, Nucleo-F103RB)

Plus the things that don’t fit in a number: GDB remote stub with watchpoints proven against real arm-none-eabi-gdb, a transaction-level SWD/JTAG slice, USB FS plus F105 OTG device and host modes, a Maple-style DFU bootloader demo, I2C slave mode, dual CAN, SDIO with SDHC/MMC, and a WebSocket bridge so a headless Node process can drive a browser viewer.

Using it

Library first — ELF, HEX, or raw binary, pick a chip, run:

import { createEmulator } from "stm32f1-emu";
import { readFileSync } from "fs";

const emu = await createEmulator({
  firmware: readFileSync("firmware.elf"),
  chip: "stm32f103c8",
});

const out = emu.run(10_000_000);
console.log(out.instCount, "instructions executed");
console.log(emu.getUartOutput());

Or the CLI, if you just want to run a binary:

npm install stm32f1-emu

# raw binary, Intel HEX, or ELF — vector table honored
npx stm32f1-emu firmware.bin --max=200000000

# with config (devices, symbols, UART gate)
npx stm32f1-emu --config=config.yaml

The browser demo deploys to GitHub Pages with presets per board (Blue Pill, GD32, Maple Mini, Nucleo), a live UART terminal you can type into, GPIO readout, and key registers. Pick a firmware, press run, watch it behave like hardware.

Demo

A screen recording beats a thousand words here. Video first, then stills — arrows or ←/→ keys move between them. The full register reference lives in the technical manual (PDF).

Screen recording: the browser demo running real firmware.1 / 5

What I would do differently

I would have written the coverage census first. For months “done” was a vibe — peripherals felt modeled because demos ran. The census turned it into a list with 41 rows, and lists can be finished. Vibes cannot.

I would also have killed Unicorn sooner. I kept it as a safety net for weeks after the native core passed parity, maintaining two backends “just in case.” The in-case never came. The maintenance cost did.

Next: this emulator becomes one more card in the fleet — the F4, the ESP32s, the RP2040s all got the same treatment. But this one was first, and it taught me the loop I keep reusing: figure out what actually matters, ship the smallest real thing, put it in front of real firmware, and tighten from there. So far, so good.

Thoughts, corrections, or want to talk about this? I read everything.

Download PDF saves the full technical manual. Print page gives you this article as a clean single-column PDF.