◈ STM32 Bluepill Emulator

About this project ← Live Demo GitHub

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

~23 MIPSheadless (50M/~2.1s, pure 26.5)
~21 MIPSbrowser direct run (200M/9.3s)
~96 MIPSbrowser direct run, native CPU
236/236peripheral unit tests
39/39integration checks
30+emulated peripherals

How it works

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).

Batch loop

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

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 – 3 TXE/TC/RXNE flags, baud-rate byte-time pacing, overrun, TX/RX DMA. External probe for bidirectional data. Full
SPI1, 2 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. 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, 2 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
USB Stub — register reads return 0. Stub

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.

10 built-in presets

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

236 tests covering every peripheral in isolation: GPIO electrical model, pin events, USART TX/RX, ADC conversion timing, RC sample-and-hold, DAC→ADC loopback, external triggers (TIM/EXTI), RCC, SysTick, all timer modes, NVIC priority dispatch, CRC, SPI, I2C state machine, RTC alarm, PWR/FLASH, CAN mailboxes + filters, DMA pump, AFIO, EXTI, BKP, DAC, TIM6, FSMC, deep-sleep gating and fault escalation.

Firmware integration — node tests/canary.mjs

Runs the 24-peripheral Arduino sketch (tests/arduino_periph_test/) for 200M 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 ~9 seconds.

Browser tests — Playwright

3 browser tests running the full firmware in Chromium via Playwright: the periph37 200M run (39/39, ~11s), GPIO grid rendering, and F105 chip selector. 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): ~23 MIPS (50M in ~2.1s, pure compute 26.5 MIPS); emulator.js path 200M in 8.2s. Browser direct run (headless Chromium): 200M in 9.3s (~21.5 MIPS) — parity with Node since the CAN-autopilot timing fix (a stale flag address used to cost 3M-iteration spin storms). Interactive page loop: 8.6 MIPS headed (frame-budgeted). The Rust interpreter is ~99% of wall time; 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