STM32 Bluepill Emulator

About this project ← Live Demo Docs GitHub
Project notes · started on real hardware, finished in the browser

Why emulate a Blue Pill?

What is this?

The STM32 Bluepill Emulator is a full-system emulator for the STM32F103C8 “Blue Pill” microcontroller. It executes real, unmodified firmware — compiled Arduino sketches (STM32duino), libopencm3 and STM32Cube HAL programs — in your browser or in Node.js, without any hardware.

Instead of a simplified instruction interpreter, it runs a native Rust Cortex-M3 CPU interpreter plus a Rust peripheral emulator that implements the chip’s registers — GPIO, USART, SPI, I2C, TIM, ADC, DAC, DMA, CAN, RTC, CRC, NVIC, EXTI, FSMC and more — with realistic timing, interrupts and electrical behaviour, all in one WebAssembly module.

WASM / WebAssembly ARM Cortex-M3 Rust + JavaScript Browser + Node.js MIT License npm: stm32f1-emu

Why build an STM32 emulator?

At a glance

~70 MIPSheadless (200M/~2.9s)
764/764unit test asserts
39/39integration checks
8chip variants
43browser demo presets
41Full-coverage peripherals

How it works

STM32 Bluepill Emulator running the peripheral showcase in a browser: live UART terminal at 72.9M IPS, glowing Blue Pill target rig, instruction and program-counter readouts
The live demo: real Arduino firmware executing in the browser — UART terminal, LCD instrument tiles, and the target rig with pin-activity glow.

The emulator uses a dual-engine architecture:

  1. Rust CPU — a native Thumb-2 interpreter executes Cortex-M3 instructions in adaptive 20K/50K batches (20K when IRQ/DMA pending, 50K idle; batch_size overrides), with exact instruction accounting.
  2. Rust Peripheral WASM — the same WebAssembly module models every chip register with cycle-accurate timing, electrical behaviour (GPIO pull-ups, open-drain, slew), interrupt priorities and DMA transfers.
  3. Module Worker + OffscreenCanvas — browser emulation runs off-main-thread (site/worker.js:1, site/index.html:449); canvas is transferred via OffscreenCanvas (site/worker.js:117); frame stats ride the per-frame message (a SharedArrayBuffer channel was removed — it had no readers and its scheduler starved worker controls).

System map

Emulator system map Arduino firmware loads into one WebAssembly module holding the Rust CPU, peripherals and RAM. The page and the Node CLI observe it through UART, GPIO, canvas and event bridges, on any F1 chip map. Arduino .elfreal firmware one WASM moduleRust CPU + periphs + RAM chip mapsF103 · F105 · GD32 Node CLIheadless browserworker UART terminalTX out · RX in GPIO + canvaspins · OLED · LCD event queuedrainEvents · 19 types firmware in · signals out · no hardware
One WASM module holds the whole machine; the page and the CLI only observe it through narrow bridges.

Batch loop

Emulation batch loop flowchart Each batch pumps UART input, runs DMA, executes up to twenty thousand instructions, ticks peripherals, dispatches up to sixty-four interrupts, then repeats. Watchdog reset stops the loop. pump stdinUART RX DMA pumpzero JS hops CPU run 20Kexact count tick periphsstep_batch dispatch IRQs≤64 / batch next batch · ~70 MIPS watchdog?stop
One emulation batch: pump input, move DMA, run the core, advance peripherals, deliver interrupts — then repeat. Interrupt latency ≈1.1 ms at 20K batches.

Each emulation step follows this cycle (adaptive batch):

  1. Pump stdin — UART RX bytes from the host
  2. Process DMA transfers queued by peripherals
  3. Execute an adaptive batch in the Rust interpreter (20K busy / 50K idle)
  4. Tick all peripherals by the elapsed instruction count (step_batch)
  5. Deliver pending interrupts (up to 64 per batch, NVIC-priority ordered)
  6. Check watchdog reset flags
  7. Worker posts frame → main renders (OffscreenCanvas if transferred, site/worker.js:168); UI_THROTTLE 10 (site/index.html:441) — step 60fps, paint 6fps, never starves throughput

Interrupt path

Interrupt delivery flowchart Peripheral flags sort by NVIC priority, dispatch up to sixty-four per batch onto a stacked frame, run the handler, restore registers on return. Pending bits coalesce to one delivery per batch; SVC hooks fire synchronously mid-batch. flag setsUIF · RXNE · CCxIF NVIC sorts4-bit priority dispatch ≤64intr_next budget stack + handleR0–R3 · xPSR frame restore + returnxPSR back, resume pending coalesces → 1 delivery / batch · SVC hooks sync mid-batch
Flags become NVIC pendings, the top ≤64 run per batch on real stacked frames — including xPSR, so flags never leak across handlers.

Performance techniques

Emulated peripherals

“Full” means register-accurate with timing, interrupts and DMA support where applicable.

PeripheralInstancesDetailsStatus
GPIOA – D Electrical model: pull-up/down, floating, open-drain, push-pull output, slew transitions. BSRR/BRR atomics. EXTI edge generation. Full
USART1 – 5 TXE/TC/RXNE flags, baud-rate byte-time pacing, overrun, TX/RX DMA. External probe for bidirectional data. Full
SPI1 – 3 Master mode, 8/16-bit, CPOL/CPHA, NSS software control. External device bus (flash, touchscreen, LCD). Full
I2C1, 2 Full state machine: START, address ACK, DR read/write, STOP. 7/10-bit addressing, stretching. Slave mode via host inject (OAR match, ADDR/STOPF, RXNE/TXE). SMBus PEC + ALERT. Bus recovery. Full
TIM1, 2 – 14 Up/down/center-aligned counting, PWM output, input capture, output compare, TRGO triggers (ADC, DMA), ARR/CCR registers, prescaler. Full
ADC1 – 3 Real conversion state machine (Tconv = SMP + 12.5 cycles), per-sequence channels (SQ1-16), EOC/EOCS, AWD, CONT, SWSTART. External triggers from TIM/EXTI. DAC loopback. Full
DAC1, 2 12-bit right-aligned output, DOR register. Analog wire to ADC (DAC1→PA4/ch4, DAC2→PA5/ch5). Software triggers. Full
DMA1 (7 ch), 2 (5 ch) MEM2MEM, periph→mem, mem→periph. TC/HT/error flags, IRQ requests, configurable priority. Linked to USART/SPI/I2C/ADC/TIM. Full
CAN1, 2 TX/RX mailboxes, acceptance filters (list/bank modes), RX injection for testing. Extended 29-bit IDs. Full
RTC1 Second counter, alarm IRQ, prescaler. Full
NVIC— Priority-based dispatch (4 bits), set/clear pending, enable/disable. Up to 64 IRQs per batch. SysTick debt drain. Full
SysTick— 24-bit decrementing counter, TICKINT, clock source. Drives millis() / micros() via debt accounting. Full
SCB— CPUID, ICSR (PendSV/SysTick pending), AIRCR (reset), SHPR1-3 (fault priorities), SHCSR, CFSR, HFSR, BFAR. SLEEPDEEP control. Full
EXTI0 – 18 Rising/falling edge config, software trigger, IMR mask. Maps to NVIC IRQ 6–40. EXTI 11/15 trigger ADC external conversion. Full
AFIO— Pin remapping (SWJCFG, SPI1, I2C1, USART, TIM, CAN). Full
FSMC— 7 memory banks: NE1-4 NOR, NAND2/3, PC-Card. BCR/BTR/BWTR registers. External NOR backing via JS Uint8Array image. Full
SPI Flashext External device: JEDEC ID, read (0x03), fast read (0x0B), page program (0x02), sector erase, WREN/WRSR. Up to 2 buses. Full
I2C EEPROMext External device: byte read/write, sequential. Up to 2 buses. Full
I2C OLEDext External device: SSD1306 framebuffer, I2C commands/data. Full
Touchscreenext External device: SPI XPT2046, deferred-reply model. Full
LCDext External device: SPI 128×64 framebuffer. Full
BKP— Backup registers (10×16-bit), TAMPALARM flag. Full
PWR— Power control: PDDS, LPDS, DBP. Deep-sleep gating of peripherals. Full
FLASH— Access control, prefetch. FLASH_ACR register emulation. Full
CRC— 32-bit CRC (polynomial 0x04C11DB7), DR register. Full
IWDG— Independent watchdog: prescaler, reload, KEY register, reset on timeout. Full
WWDG— Window watchdog: window size, counter, early interrupt. Full
USBFS device EP0–7 toggle semantics, 1024 B PMA, RESET, SETUP/OUT injection, IN completion events, SOF engine + suspend/resume, double-buffered bulk, isochronous, detach + ESOF. CDC-ACM + DFU bootloader demos + page USB host. Full
OTG_FShost + device Synopsys core: 8 host channels, device EP0–3, GRXSTSP queue, shared DFIFOs, SOF engine. Bare-metal HCD + CDC demos with scripted virtual peers. Full
ITMstimulus port 0 Firmware printf channel draining as host-side events. Full

Exceptions and interrupts

CLI usage

The command-line interface runs firmware headlessly in Node.js. Install globally or use npx:

# Run a bare ELF
node pkg/cli.mjs firmware.elf

# Feed UART input + run 200M instructions
echo -n "AB" | node pkg/cli.mjs --max=200000000 firmware.elf

# Use a config YAML with external devices
node pkg/cli.mjs --config=config.yaml firmware.elf

# Show help
node pkg/cli.mjs -h

# Trace all peripheral access
node pkg/cli.mjs --verbose firmware.elf

# Dump registers on exit
node pkg/cli.mjs --regs firmware.elf

Flags

Library API

Use the emulator programmatically from Node.js or the browser:

import { createEmulator } from 'stm32f1-emu';

const emu = createEmulator({
  firmware: elfBytes,          // Uint8Array of .elf / .hex / .bin
  map: mapText,              // optional linker map string
  ext_devices: [
    { type: 'spi_flash', bus: 'SPI1', file: flashBin },
    { type: 'i2c_eeprom', bus: 'I2C1', addr: 0x50, size: 65536 },
    { type: 'i2c_oled', bus: 'I2C1', addr: 0x3C },
  ],
});

emu.run(200_000_000);            // execute 200M instructions

const uart = emu.getUartOutput();   // string of all UART TX bytes
emu.uartRxBytes(Buffer.from('AB')); // inject bytes to UART RX

const regs = emu.getRegisters();    // { r0, r1, ..., r15, xpsr }
emu.close();                        // free WASM memory

Browser demo features

The live demo (index.html) runs entirely in the browser with no server needed — just open the page or serve it with python3 -m http.server -d site. Worker off-thread + OffscreenCanvas + adaptive batch + batch_size option (site/worker.js:1, pkg/emulator.js) — see docs/USAGE.md.

43 built-in presets

Starter ten below — plus TIM DMA-burst PWM, servo, RTC clock, DAC sine, I2C scanner/slave, CAN chat, dual-CAN, USB CDC/serial, OTG CDC/host, DFU bootloader, Mini-RTOS, SD logger and per-board builds (Blue Pill, Maple Mini, Nucleo, F103RC).

PresetDescription
BlinkLED on PC13 toggling at 1 Hz — the “hello world” of embedded
UART EchoType in the terminal, bytes echo back via USART1
TIM2 PWM FadePA0 LED fading via TIM2 CH1 PWM output
ADC on UARTReads ADC1 ch0 every second, prints adc=1023
SPI Flash + I2CReads JEDEC ID from SPI flash, reads/writes I2C EEPROM, drives OLED
Comprehensive Test21-peripheral register-level validation
Peripheral Test39-check integration suite (GPIO, USART, SPI, I2C, TIM, ADC, DAC, DMA, CAN, RTC, CRC, IWDG, WWDG, BKP, PWR, SVC, PendSV, etc.)
ShowcaseLive OLED, SPI LCD, 7-segment (74HC595), RGB LED (TIM2 PWM), buzzer, button (EXTI13)
WS2812 Strip8-LED rainbow animation over SPI1 + DMA1 CH3
Custom UploadLoad your own .bin, .hex or .elf file

Interactive features

Multi-chip support

Beyond the built-in STM32F103C8 map, the emulator supports any STM32F1-series chip via CMSIS System View Description (SVD) files:

Custom peripherals

Add your own memory-mapped devices via JavaScript callbacks. The API matches the rp2040js bus model — register a base address, size, and read/write handlers:

Browser / library API

emu.addJsPeripheral({
  base:  0x40018000,       // absolute peripheral base address
  size:  0x400,
  read:  (addr, size) => { /* return 0/1/2/4-byte value */ },
  write: (addr, val, size) => { /* handle the write */ },
});

CLI plugin

// my_periph.mjs  (default export: array of {base,size,read,write})
export default [{
  base: 0x40018000, size: 0x400,
  read:  (addr, sz) => 0xFF,
  write: (addr, v, sz) => {},
}];
node pkg/cli.mjs --periph-plugin=./my_periph.mjs firmware.elf

Callbacks receive the absolute address (not offset from base). The bus uses last-wins overlap, so a plugin can override a built-in peripheral.

Testing

The emulator has a three-tier test suite that runs in CI on every push:

Unit tests — node tests/test_all.mjs

764 asserts covering every peripheral in isolation: GPIO electrical model, pin events, USART TX/RX (incl. HDSEL loopback, LIN, IrDA/smartcard registers), ADC conversion timing, RC sample-and-hold, DAC→ADC loopback, external triggers (TIM/EXTI), RCC, SysTick, all timer modes, NVIC priority dispatch, CRC, SPI (incl. TI frame format), I2C state machine (incl. slave mode, 10-bit, PEC, SMBus ALERT), RTC alarm, PWR/FLASH, CAN mailboxes + filters, DMA pump, AFIO, EXTI, BKP, DAC, TIM6, FSMC, SDIO, USB, deep-sleep gating and fault escalation, plus DMA circular mode, GPIO LCKR, AFIO SWJ pin reservation, UID, MCO/RCC clock tree, PVD levels, WKUP/standby gating, SPI NSS output, CAN silent modes, TIM dead-time, ADC discontinuous/JAUTO, SDSC cards, ITM stimulus, ESOF and the HD/CL superset (UART4/5, TIM5, ADC3, CAN2, OTG) on the builtin map.

Firmware integration — node tests/canary.mjs

Runs the 24-peripheral Arduino sketch (tests/arduino_periph_test/) for 100M instructions with UART input “AB”. Asserts 39/39 checks pass (GPIO, USART TX, UART loopback, RCC, FLASH, PWR, BKP, IWDG, WWDG, RTC, CRC, DAC, ADC, AFIO, EXTI, CAN, SPI flash, I2C EEPROM, OLED, touchscreen, LCD, I2C2, SPI2, USART2, SVC, PendSV, DMA, UART RX, TIM2, EXTI0, EXTI1, EXTI13, CAN RX, SysTick, TIM3 PWM, TIM4, RTC Alarm). Completes in a couple of seconds; the full 200M run takes ~3s.

Browser tests — Playwright

24 browser checks running live firmware in Chromium via Playwright: every demo preset boots and prints (blink through dual-CAN, USB enumeration, I2C slave), every static page loads with zero console errors, and the docs viewer, nav and screenshots are asserted. Runs headlessly in CI on every push.

CI pipeline

Project links

FAQ

What firmware can I run?
Any firmware that targets the STM32F103C8 and fits in 128 KB of flash. This includes Arduino sketches compiled with STM32duino, programs built with libopencm3, and STM32Cube HAL projects. Load .elf, .hex or raw .bin files. The UART TX output works out of the box; external devices (SPI flash, I2C EEPROM, OLED) are configured via the config YAML or the library API.
How fast is it?
Headless (Node CLI): ~70 MIPS (200M in ~2.9s); the emulator.js browser path matches it in Node. Interactive page loop is frame-budgeted (step 60fps, paint 6fps) so the UI stays live. The Rust interpreter is ~99% of wall time; the JS/Rust layer ~1%. See docs/ARCHITECTURE.md.
Does it support all STM32F103 peripherals?
Everything except USB. GPIO (with electrical model), USART, SPI, I2C, timers (1–14), ADC, DAC, DMA, CAN, RTC, NVIC, SysTick, SCB, EXTI, AFIO, FSMC, BKP, PWR, FLASH, CRC, IWDG, WWDG and the Cortex-M exception model (SVC, PendSV, faults) are all implemented with register-level accuracy and realistic timing.
Can I use it in my own project?
Yes. The emulator is MIT-licensed and available on npm: npm install stm32f1-emu. Use createEmulator() from the library API section above, or load the WASM module directly in the browser. You can also write custom peripheral plugins in JavaScript.

Acknowledgements