~/Blog
8 min read
I built a Blue Pill emulator that runs real firmware. It is done.
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 orderPeripherals 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.
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.yamlThe 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).
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.