feat: update ESP32-C3 simulator and add emulation tests

- Updated components-metadata.json with new generated timestamp.
- Refactored Esp32C3Simulator.ts to remove unnecessary debug variables and logging, and added support for additional ROM functions.
- Modified useSimulatorStore.ts to clarify bridge usage for ESP32 boards.
- Updated submodules for QEMU and other libraries to indicate dirty state.
- Added test_esp32c3_emulation.py for end-to-end testing of ESP32-C3 emulation, including compilation, flash image merging, and GPIO event checking.
pull/47/head
David Montero Crespo 2026-03-18 23:30:45 -03:00
parent 372e0f1d00
commit 7053b6f2c8
12 changed files with 868 additions and 403 deletions

5
.gitignore vendored
View File

@ -82,12 +82,15 @@ data/*
.publicar_discord/*
img/*
# ESP32 QEMU runtime binaries (too large for git — build/download separately)
# ESP32 QEMU runtime binaries (too large for git — build/copy from MSYS2/qemu-lcgamboa)
backend/app/services/libqemu-xtensa.dll
backend/app/services/libqemu-riscv32.dll
backend/app/services/esp32-v3-rom.bin
backend/app/services/esp32-v3-rom-app.bin
backend/app/services/esp32c3-rom.bin
# ESP32 build artifacts (ELF/MAP debug symbols — large, not needed for tests)
test/esp32-emulator/**/*.elf
test/esp32-emulator/**/*.map
test/esp32-emulator/out_*/
.claude/settings.json

View File

@ -75,13 +75,18 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
RUN curl -fsSL https://raw.githubusercontent.com/arduino/arduino-cli/master/install.sh \
| BINDIR=/usr/local/bin sh
# Initialize arduino-cli config, add RP2040 board manager URL, then install cores
# Initialize arduino-cli config, add board manager URLs, then install cores
# - RP2040: earlephilhower fork for Raspberry Pi Pico
# - ESP32: Espressif official (covers ESP32, ESP32-S3, ESP32-C3, etc.)
RUN arduino-cli config init \
&& arduino-cli config add board_manager.additional_urls \
https://github.com/earlephilhower/arduino-pico/releases/download/global/package_rp2040_index.json \
&& arduino-cli config add board_manager.additional_urls \
https://raw.githubusercontent.com/espressif/arduino-esp32/gh-pages/package_esp32_index.json \
&& arduino-cli core update-index \
&& arduino-cli core install arduino:avr \
&& arduino-cli core install rp2040:rp2040
&& arduino-cli core install rp2040:rp2040 \
&& arduino-cli core install esp32:esp32
WORKDIR /app
@ -107,16 +112,23 @@ RUN chmod +x /app/entrypoint.sh
# ── ESP32 emulation: lcgamboa QEMU .so + ROM binaries ────────────────────────
# libqemu-xtensa.so → ESP32 / ESP32-S3 (Xtensa LX6/LX7)
# libqemu-riscv32.so → ESP32-C3 (RISC-V)
# libqemu-riscv32.so → ESP32-C3 (RISC-V RV32IMC)
# esp32-v3-rom*.bin → boot/app ROM images required by esp32-picsimlab machine
# esp32c3-rom.bin → ROM image required by esp32c3-picsimlab machine
# NOTE: ROM files must live in the same directory as the .so (worker passes -L
# to QEMU pointing at os.path.dirname(lib_path))
RUN mkdir -p /app/lib
COPY --from=qemu-builder /qemu-lcgamboa/build/libqemu-xtensa.so /app/lib/
COPY --from=qemu-builder /qemu-lcgamboa/build/libqemu-riscv32.so /app/lib/
COPY --from=qemu-builder /qemu-lcgamboa/pc-bios/esp32-v3-rom.bin /app/lib/
COPY --from=qemu-builder /qemu-lcgamboa/pc-bios/esp32-v3-rom-app.bin /app/lib/
COPY --from=qemu-builder /qemu-lcgamboa/pc-bios/esp32c3-rom.bin /app/lib/
# Activate full ESP32 emulation (GPIO + ADC + UART + PWM + NeoPixel + WiFi)
# Activate ESP32 emulation
# QEMU_ESP32_LIB → Xtensa library (ESP32, ESP32-S3)
# QEMU_RISCV32_LIB → RISC-V library (ESP32-C3 and variants)
ENV QEMU_ESP32_LIB=/app/lib/libqemu-xtensa.so
ENV QEMU_RISCV32_LIB=/app/lib/libqemu-riscv32.so
EXPOSE 80

View File

@ -35,14 +35,14 @@ async def simulation_websocket(websocket: WebSocket, client_id: str):
async def qemu_callback(event_type: str, data: dict) -> None:
if event_type == 'gpio_change':
logger.info('[%s] gpio_change pin=%s state=%s', client_id, data.get('pin'), data.get('state'))
logger.debug('[%s] gpio_change pin=%s state=%s', client_id, data.get('pin'), data.get('state'))
elif event_type == 'system':
logger.info('[%s] system event: %s', client_id, data.get('event'))
logger.debug('[%s] system event: %s', client_id, data.get('event'))
elif event_type == 'error':
logger.error('[%s] error: %s', client_id, data.get('message'))
elif event_type == 'serial_output':
text = data.get('data', '')
logger.info('[%s] serial_output uart=%s len=%d: %r', client_id, data.get('uart', 0), len(text), text[:80])
logger.debug('[%s] serial_output uart=%s len=%d: %r', client_id, data.get('uart', 0), len(text), text[:80])
payload = json.dumps({'type': event_type, 'data': data})
try:
await manager.send(client_id, payload)

View File

@ -209,6 +209,14 @@ class ArduinoCLIService:
"""Return True if the FQBN targets an ESP32 family board."""
return fqbn.startswith("esp32:")
def _is_esp32c3_board(self, fqbn: str) -> bool:
"""Return True if the FQBN targets an ESP32-C3 (RISC-V) board.
ESP32-C3 places the bootloader at flash offset 0x0000, unlike Xtensa
boards (ESP32, ESP32-S3) which use 0x1000.
"""
return "esp32c3" in fqbn or "xiao-esp32-c3" in fqbn or "aitewinrobot-esp32c3-supermini" in fqbn
async def compile(self, files: list[dict], board_fqbn: str = "arduino:avr:uno") -> dict:
"""
Compile Arduino sketch using arduino-cli.
@ -345,22 +353,25 @@ class ArduinoCLIService:
print(f"[ESP32] Build dir contents: {[f.name for f in build_dir.iterdir()]}")
# Merge individual .bin files into a single 4MB flash image in pure Python.
# ESP32 default flash layout: 0x1000 bootloader | 0x8000 partitions | 0x10000 app
# Flash layout differs by chip:
# ESP32 / ESP32-S3 (Xtensa): 0x1000 bootloader | 0x8000 partitions | 0x10000 app
# ESP32-C3 (RISC-V): 0x0000 bootloader | 0x8000 partitions | 0x10000 app
# QEMU lcgamboa requires exactly 2/4/8/16 MB flash — raw app binary won't boot.
if not merged_file.exists() and bin_file.exists() and bootloader_file.exists() and partitions_file.exists():
print("[ESP32] Merging binaries into 4MB flash image (pure Python)...")
try:
FLASH_SIZE = 4 * 1024 * 1024 # 4 MB
flash = bytearray(b'\xff' * FLASH_SIZE)
bootloader_offset = 0x0000 if self._is_esp32c3_board(board_fqbn) else 0x1000
for offset, path in [
(0x1000, bootloader_file),
(bootloader_offset, bootloader_file),
(0x8000, partitions_file),
(0x10000, bin_file),
]:
data = path.read_bytes()
flash[offset:offset + len(data)] = data
merged_file.write_bytes(bytes(flash))
print(f"[ESP32] Merged image: {merged_file.stat().st_size} bytes")
print(f"[ESP32] Merged image: {merged_file.stat().st_size} bytes (bootloader @ 0x{bootloader_offset:04X})")
except Exception as e:
print(f"[ESP32] Merge failed: {e} — falling back to raw app binary")

View File

@ -47,24 +47,38 @@ from typing import Callable, Awaitable
logger = logging.getLogger(__name__)
# ── Library path detection ────────────────────────────────────────────────────
_LIB_NAME = 'libqemu-xtensa.dll' if sys.platform == 'win32' else 'libqemu-xtensa.so'
_DEFAULT_LIB = str(pathlib.Path(__file__).parent / _LIB_NAME)
_SERVICES_DIR = pathlib.Path(__file__).parent
# Xtensa library (ESP32, ESP32-S3)
_LIB_XTENSA_NAME = 'libqemu-xtensa.dll' if sys.platform == 'win32' else 'libqemu-xtensa.so'
_DEFAULT_LIB_XTENSA = str(_SERVICES_DIR / _LIB_XTENSA_NAME)
LIB_PATH: str = os.environ.get('QEMU_ESP32_LIB', '') or (
_DEFAULT_LIB if os.path.isfile(_DEFAULT_LIB) else ''
_DEFAULT_LIB_XTENSA if os.path.isfile(_DEFAULT_LIB_XTENSA) else ''
)
_WORKER_SCRIPT = pathlib.Path(__file__).parent / 'esp32_worker.py'
# RISC-V library (ESP32-C3)
_LIB_RISCV_NAME = 'libqemu-riscv32.dll' if sys.platform == 'win32' else 'libqemu-riscv32.so'
_DEFAULT_LIB_RISCV = str(_SERVICES_DIR / _LIB_RISCV_NAME)
LIB_RISCV_PATH: str = os.environ.get('QEMU_RISCV32_LIB', '') or (
_DEFAULT_LIB_RISCV if os.path.isfile(_DEFAULT_LIB_RISCV) else ''
)
_WORKER_SCRIPT = _SERVICES_DIR / 'esp32_worker.py'
EventCallback = Callable[[str, dict], Awaitable[None]]
# lcgamboa machine names
# lcgamboa machine names and which DLL each board requires
_MACHINE: dict[str, str] = {
'esp32': 'esp32-picsimlab',
'esp32-s3': 'esp32s3-picsimlab',
'esp32-c3': 'esp32c3-picsimlab',
'xiao-esp32-c3': 'esp32c3-picsimlab',
'aitewinrobot-esp32c3-supermini': 'esp32c3-picsimlab',
}
# Board types that require the RISC-V library instead of the Xtensa one
_RISCV_BOARDS = {'esp32-c3', 'xiao-esp32-c3', 'aitewinrobot-esp32c3-supermini'}
# ── UART buffer ───────────────────────────────────────────────────────────────
@ -127,12 +141,18 @@ class EspLibManager:
@staticmethod
def is_available() -> bool:
"""Returns True if the Xtensa DLL is present (minimum for ESP32/ESP32-S3)."""
return (
bool(LIB_PATH)
and os.path.isfile(LIB_PATH)
and _WORKER_SCRIPT.exists()
)
@staticmethod
def is_riscv_available() -> bool:
"""Returns True if the RISC-V DLL is present (required for ESP32-C3)."""
return bool(LIB_RISCV_PATH) and os.path.isfile(LIB_RISCV_PATH)
# ── Public API ────────────────────────────────────────────────────────────
async def start_instance(
@ -152,8 +172,9 @@ class EspLibManager:
return
machine = _MACHINE.get(board_type, 'esp32-picsimlab')
lib_path = LIB_RISCV_PATH if board_type in _RISCV_BOARDS else LIB_PATH
config = json.dumps({
'lib_path': LIB_PATH,
'lib_path': lib_path,
'firmware_b64': firmware_b64,
'machine': machine,
})

View File

@ -60,6 +60,8 @@ def _log(msg: str) -> None:
# ─── GPIO pinmap (identity: slot i → GPIO i-1) ──────────────────────────────
# ESP32 has 40 GPIOs (0-39), ESP32-C3 only has 22 (0-21).
# The pinmap is rebuilt after reading config (see main()), defaulting to ESP32.
_GPIO_COUNT = 40
_PINMAP = (ctypes.c_int16 * (_GPIO_COUNT + 1))(
@ -67,6 +69,16 @@ _PINMAP = (ctypes.c_int16 * (_GPIO_COUNT + 1))(
*range(_GPIO_COUNT),
)
def _build_pinmap(gpio_count: int):
"""Build a pinmap array for the given GPIO count."""
global _GPIO_COUNT, _PINMAP
_GPIO_COUNT = gpio_count
_PINMAP = (ctypes.c_int16 * (gpio_count + 1))(
gpio_count,
*range(gpio_count),
)
# ─── ctypes callback types ───────────────────────────────────────────────────
_WRITE_PIN = ctypes.CFUNCTYPE(None, ctypes.c_int, ctypes.c_int)
@ -165,6 +177,10 @@ def main() -> None: # noqa: C901 (complexity OK for inline worker)
firmware_b64 = cfg['firmware_b64']
machine = cfg.get('machine', 'esp32-picsimlab')
# Adjust GPIO pinmap based on chip: ESP32-C3 has only 22 GPIOs
if 'c3' in machine:
_build_pinmap(22)
# ── 2. Load DLL ───────────────────────────────────────────────────────────
_MINGW64_BIN = r'C:\msys64\mingw64\bin'
if os.name == 'nt' and os.path.isdir(_MINGW64_BIN):

View File

@ -0,0 +1,253 @@
#!/usr/bin/env python3
"""
test_esp32c3_emulation.py Direct test of ESP32-C3 emulation via libqemu-riscv32.
Steps:
1. Compile a minimal blink sketch for ESP32-C3 via arduino-cli
2. Merge .bin files into a 4 MB flash image
3. Launch esp32_worker.py with machine=esp32c3-picsimlab
4. Wait for gpio_change events on GPIO8 (blink period ~500ms)
5. PASS if at least 2 toggles seen within 30 s, FAIL otherwise
Run from the backend directory:
python test_esp32c3_emulation.py
"""
import base64
import json
import os
import subprocess
import sys
import tempfile
import time
from pathlib import Path
# ── paths ────────────────────────────────────────────────────────────────────
BACKEND_DIR = Path(__file__).parent
SERVICES_DIR = BACKEND_DIR / 'app' / 'services'
WORKER = SERVICES_DIR / 'esp32_worker.py'
LIB_PATH = SERVICES_DIR / 'libqemu-riscv32.dll'
BLINK_SKETCH = """\
#define LED_PIN 8
void setup() {
Serial.begin(115200);
pinMode(LED_PIN, OUTPUT);
Serial.println("ESP32-C3 blink test started");
}
void loop() {
digitalWrite(LED_PIN, HIGH);
Serial.println("LED ON");
delay(500);
digitalWrite(LED_PIN, LOW);
Serial.println("LED OFF");
delay(500);
}
"""
FQBN = 'esp32:esp32:esp32c3:FlashMode=dio'
TIMEOUT_S = 45 # seconds to wait for GPIO toggles
MIN_TOGGLES = 2 # minimum gpio_change events expected
def _fail(msg: str) -> None:
print(f'\nFAIL: {msg}')
sys.exit(1)
def _ok(msg: str) -> None:
print(f'OK {msg}')
# ── Step 1: compile ───────────────────────────────────────────────────────────
def compile_sketch(sketch_dir: Path, build_dir: Path) -> Path:
print('\n=== Step 1: Compiling ESP32-C3 blink sketch ===')
sketch_file = sketch_dir / 'sketch.ino'
sketch_file.write_text(BLINK_SKETCH)
cmd = [
'arduino-cli', 'compile',
'--fqbn', FQBN,
'--build-property', 'build.extra_flags=-DARDUINO_ESP32_LCGAMBOA=1',
'--output-dir', str(build_dir),
str(sketch_dir),
]
result = subprocess.run(cmd, capture_output=True, text=True)
if result.returncode != 0:
print('STDOUT:', result.stdout)
print('STDERR:', result.stderr)
_fail('arduino-cli compilation failed')
_ok(f'Compilation successful (rc={result.returncode})')
return build_dir / 'sketch.ino.bin'
# ── Step 2: merge flash image ─────────────────────────────────────────────────
def merge_flash_image(build_dir: Path) -> bytes:
"""Merge bootloader + partition table + app into a 4 MB flash image."""
print('\n=== Step 2: Merging flash image ===')
FLASH_SIZE = 4 * 1024 * 1024
image = bytearray(b'\xFF' * FLASH_SIZE)
parts = [
(0x00000, build_dir / 'sketch.ino.bootloader.bin'),
(0x08000, build_dir / 'sketch.ino.partitions.bin'),
(0x10000, build_dir / 'sketch.ino.bin'),
]
for offset, path in parts:
if not path.exists():
_fail(f'Missing file: {path}')
data = path.read_bytes()
image[offset:offset + len(data)] = data
_ok(f'{path.name} -> 0x{offset:05X} ({len(data)} bytes)')
_ok(f'Merged image: {len(image)} bytes')
return bytes(image)
# ── Step 3+4: run worker and watch for GPIO events ────────────────────────────
def run_worker_and_check_gpio(firmware_bytes: bytes) -> None:
print('\n=== Step 3: Launching esp32_worker.py ===')
if not WORKER.exists():
_fail(f'Worker not found: {WORKER}')
if not LIB_PATH.exists():
_fail(f'DLL not found: {LIB_PATH}')
firmware_b64 = base64.b64encode(firmware_bytes).decode()
cfg = {
'lib_path': str(LIB_PATH),
'firmware_b64': firmware_b64,
'machine': 'esp32c3-picsimlab',
}
proc = subprocess.Popen(
[sys.executable, str(WORKER)],
stdin=subprocess.PIPE,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
text=False,
)
# Send config on stdin
cfg_line = (json.dumps(cfg) + '\n').encode()
proc.stdin.write(cfg_line)
proc.stdin.flush()
print(f'Worker PID {proc.pid} started, waiting up to {TIMEOUT_S}s for GPIO toggles on GPIO8...')
print('(All worker stderr will be shown below)\n')
# ── Read stdout in a non-blocking fashion ────────────────────────────────
import threading
gpio8_states: list[int] = []
uart_output: list[str] = []
errors: list[str] = []
all_events: list[dict] = []
def _read_stdout() -> None:
for raw_line in proc.stdout:
line = raw_line.decode('utf-8', errors='replace').strip()
if not line:
continue
try:
evt = json.loads(line)
except Exception:
print(f' [worker non-JSON] {line}')
continue
all_events.append(evt)
t = evt.get('type')
if t == 'gpio_change':
pin = evt.get('pin')
state = evt.get('state')
if pin == 8:
gpio8_states.append(state)
print(f' [GPIO8] -> {"HIGH" if state else "LOW"} (toggle #{len(gpio8_states)})')
elif t == 'uart_tx':
ch = chr(evt.get('byte', 0))
uart_output.append(ch)
line_so_far = ''.join(uart_output)
if line_so_far.endswith('\n'):
print(f' [UART0] {"".join(uart_output).rstrip()}')
uart_output.clear()
elif t == 'system':
print(f' [system] {evt.get("event")}')
elif t == 'error':
msg = evt.get('message', '')
errors.append(msg)
print(f' [ERROR] {msg}')
def _read_stderr() -> None:
for raw_line in proc.stderr:
print(f' [stderr] {raw_line.decode("utf-8", errors="replace").rstrip()}')
t_out = threading.Thread(target=_read_stdout, daemon=True)
t_err = threading.Thread(target=_read_stderr, daemon=True)
t_out.start()
t_err.start()
# Wait up to TIMEOUT_S seconds or until we have MIN_TOGGLES
deadline = time.monotonic() + TIMEOUT_S
while time.monotonic() < deadline:
if len(gpio8_states) >= MIN_TOGGLES:
break
if proc.poll() is not None:
print(f' Worker exited early with rc={proc.returncode}')
break
time.sleep(0.2)
# Terminate worker
try:
proc.stdin.write((json.dumps({'cmd': 'stop'}) + '\n').encode())
proc.stdin.flush()
except Exception:
pass
time.sleep(0.5)
if proc.poll() is None:
proc.terminate()
proc.wait(timeout=5)
print(f'\n=== Step 4: Results ===')
print(f' GPIO8 toggles observed : {len(gpio8_states)}')
print(f' UART output chars : {len("".join(uart_output))}')
print(f' Total events : {len(all_events)}')
print(f' Errors : {errors}')
if errors and not gpio8_states:
_fail(f'Worker reported errors and no GPIO activity: {errors}')
if len(gpio8_states) < MIN_TOGGLES:
_fail(
f'Expected at least {MIN_TOGGLES} GPIO8 toggles within {TIMEOUT_S}s, '
f'got {len(gpio8_states)}.\n'
f' First 10 events: {all_events[:10]}'
)
_ok(f'GPIO8 toggled {len(gpio8_states)} times — ESP32-C3 emulation is working!')
# ── main ──────────────────────────────────────────────────────────────────────
def main() -> None:
print('=' * 60)
print('ESP32-C3 EMULATION END-TO-END TEST')
print('=' * 60)
with tempfile.TemporaryDirectory() as tmpdir:
sketch_dir = Path(tmpdir) / 'sketch'
build_dir = Path(tmpdir) / 'build'
sketch_dir.mkdir()
build_dir.mkdir()
compile_sketch(sketch_dir, build_dir)
firmware_bytes = merge_flash_image(build_dir)
run_worker_and_check_gpio(firmware_bytes)
print('\nPASS: ESP32-C3 emulation test PASSED')
if __name__ == '__main__':
main()

View File

@ -5,7 +5,7 @@
> Available on: **Windows** (`.dll`) · **Linux / Docker** (`.so`, included in the official image)
> Applies to: **ESP32, ESP32-S3** (Xtensa LX6/LX7 architecture)
> **Note on ESP32-C3:** The ESP32-C3, XIAO ESP32-C3, and ESP32-C3 SuperMini boards use the **RISC-V RV32IMC** architecture and have their own in-browser emulator. See → [RISCV_EMULATION.md](./RISCV_EMULATION.md)
> **Note on ESP32-C3:** The ESP32-C3, XIAO ESP32-C3, and ESP32-C3 SuperMini boards use the **RISC-V RV32IMC** architecture and are emulated via `libqemu-riscv32` (same backend pattern as Xtensa, different library and machine). See → [RISCV_EMULATION.md](./RISCV_EMULATION.md)
## Supported Boards
@ -376,15 +376,18 @@ libglib2.0-0, libgcrypt20, libslirp0, libpixman-1-0
**Required ROM binaries** (in the same folder as the lib):
```
# Windows (backend/app/services/):
libqemu-xtensa.dll ← main engine (not in git — 43 MB)
libqemu-xtensa.dll ← Xtensa engine for ESP32/S3 (not in git — 43 MB)
libqemu-riscv32.dll ← RISC-V engine for ESP32-C3 (not in git — 58 MB)
esp32-v3-rom.bin ← ESP32 boot ROM (not in git — 446 KB)
esp32-v3-rom-app.bin ← application ROM (not in git — 446 KB)
esp32c3-rom.bin ← ESP32-C3 boot ROM (not in git — 384 KB)
# Docker (/app/lib/):
libqemu-xtensa.so ← compiled in Stage 0 of the Dockerfile
libqemu-riscv32.so ← ESP32-C3 (RISC-V)
libqemu-riscv32.so ← ESP32-C3 (RISC-V) — same build stage
esp32-v3-rom.bin ← copied from the lcgamboa repo's pc-bios/
esp32-v3-rom-app.bin
esp32c3-rom.bin ← ESP32-C3 ROM
```
> On Windows these files are in `.gitignore` due to their size. Each developer generates them locally.
@ -791,13 +794,15 @@ The original problem was that `ledc_update {channel: N}` arrived at the frontend
### 10.1 Windows (MSYS2 MINGW64)
#### Xtensa (ESP32 / ESP32-S3)
The `build_libqemu-esp32-win.sh` script in `wokwi-libs/qemu-lcgamboa/` automates the process:
```bash
# In MSYS2 MINGW64:
cd wokwi-libs/qemu-lcgamboa
bash build_libqemu-esp32-win.sh
# Produces: build/libqemu-xtensa.dll and build/libqemu-riscv32.dll
# Produces: build/libqemu-xtensa.dll
```
The script configures QEMU with `--extra-cflags=-fPIC` (required for Windows/PE with ASLR), compiles the full binary, and then relinks removing `softmmu_main.c.obj` (which contains `main()`):
@ -810,6 +815,41 @@ cc -m64 -mcx16 -shared \
@dll_link.rsp # all .obj files except softmmu_main
```
#### RISC-V (ESP32-C3)
Building `libqemu-riscv32.dll` requires a **separate build directory** because the configure flags differ from Xtensa (notably `--disable-slirp`, which is required because GCC 15.x rejects incompatible pointer types in `net/slirp.c` for the riscv32 target):
```bash
# In MSYS2 MINGW64:
cd wokwi-libs/qemu-lcgamboa
mkdir build-riscv && cd build-riscv
../configure \
--target-list=riscv32-softmmu \
--disable-werror \
--enable-gcrypt \
--disable-slirp \
--without-default-features \
--disable-docs
ninja # ~15-30 min first time
# Once built, create the DLL using the keeprsp technique:
# 1. Build the full executable first
ninja qemu-system-riscv32.exe
# 2. Edit build/riscv32-softmmu/dll_link.rsp:
# - Change -o qemu-system-riscv32.exe → -o libqemu-riscv32.dll
# - Add -shared flag
# - Remove softmmu_main.c.obj from the object list
gcc -shared -o libqemu-riscv32.dll @dll_link.rsp
# Deploy to backend:
cp libqemu-riscv32.dll /e/Hardware/wokwi_clon/backend/app/services/
cp ../pc-bios/esp32c3-rom.bin /e/Hardware/wokwi_clon/backend/app/services/
```
See [RISCV_EMULATION.md §4](./RISCV_EMULATION.md) for full step-by-step instructions.
### 10.2 Linux
The `build_libqemu-esp32.sh` script produces a `.so`:
@ -1050,7 +1090,8 @@ The connection logic lives in `SimulatorCanvas.tsx`: it detects the tag of the w
| Variable | Example Value | Effect |
|----------|--------------|--------|
| `QEMU_ESP32_LIB` | `/app/lib/libqemu-xtensa.so` | Force lib path (overrides auto-detect) |
| `QEMU_ESP32_LIB` | `/app/lib/libqemu-xtensa.so` | Force Xtensa lib path (ESP32/S3) |
| `QEMU_RISCV32_LIB` | `/app/lib/libqemu-riscv32.so` | Force RISC-V lib path (ESP32-C3) |
| `QEMU_ESP32_BINARY` | `/usr/bin/qemu-system-xtensa` | Subprocess fallback (without lib) |
| `SKIP_LIB_INTEGRATION` | `1` | Skip QEMU integration tests in pytest |
@ -1058,9 +1099,10 @@ The connection logic lives in `SimulatorCanvas.tsx`: it detects the tag of the w
| Platform | Library auto-searched |
|----------|-----------------------|
| Docker / Linux | `/app/lib/libqemu-xtensa.so` (via `QEMU_ESP32_LIB`) |
| Windows | `backend/app/services/libqemu-xtensa.dll` |
| Custom | `$QEMU_ESP32_LIB` (if set, takes priority) |
| Docker / Linux | `/app/lib/libqemu-xtensa.so` (Xtensa) + `/app/lib/libqemu-riscv32.so` (RISC-V) |
| Windows | `backend/app/services/libqemu-xtensa.dll` + `backend/app/services/libqemu-riscv32.dll` |
| Custom Xtensa | `$QEMU_ESP32_LIB` (if set, takes priority) |
| Custom RISC-V | `$QEMU_RISCV32_LIB` (if set, takes priority) |
**Startup examples:**

View File

@ -1,8 +1,11 @@
# RISC-V Emulation (ESP32-C3 / XIAO-C3 / C3 SuperMini)
> Status: **Functional** · In-browser emulation · No backend dependencies
> Engine: **RiscVCore (RV32IMC)** — implemented in TypeScript
> Platform: **ESP32-C3 @ 160 MHz** — 32-bit RISC-V architecture
> Status: **Functional** · Backend QEMU (`libqemu-riscv32`) — same pattern as ESP32/ESP32-S3
> Engine: **QEMU lcgamboa — riscv32-softmmu** compiled with `esp32c3-picsimlab` machine
> Platform: **ESP32-C3 @ 160 MHz** — 32-bit RISC-V RV32IMC architecture
> **Unit-test / ISA layer:** `RiscVCore.ts` and `Esp32C3Simulator.ts` are TypeScript implementations
> used exclusively for Vitest unit tests; they are **not** the production emulation path.
---
@ -10,39 +13,45 @@
1. [Overview](#1-overview)
2. [Supported Boards](#2-supported-boards)
3. [Emulator Architecture](#3-emulator-architecture)
4. [Emulated Memory and Peripherals](#4-emulated-memory-and-peripherals)
5. [Full Flow: Compile and Run](#5-full-flow-compile-and-run)
6. [ESP32 Image Format](#6-esp32-image-format)
7. [Supported ISA — RV32IMC](#7-supported-isa--rv32imc)
8. [GPIO](#8-gpio)
9. [UART0 — Serial Monitor](#9-uart0--serial-monitor)
10. [Limitations](#10-limitations)
11. [Tests](#11-tests)
12. [Differences vs Xtensa Emulation (ESP32 / ESP32-S3)](#12-differences-vs-xtensa-emulation-esp32--esp32-s3)
13. [Key Files](#13-key-files)
3. [Architecture — QEMU Backend Path](#3-architecture--qemu-backend-path)
4. [Setup on Windows — Building `libqemu-riscv32.dll`](#4-setup-on-windows--building-libqemu-riscv32dll)
5. [Setup on Linux / Docker](#5-setup-on-linux--docker)
6. [GPIO Pinmap — 22 GPIOs](#6-gpio-pinmap--22-gpios)
7. [Full Flow: Compile and Run](#7-full-flow-compile-and-run)
8. [ESP32 Image Format](#8-esp32-image-format)
9. [Supported ISA — RV32IMC](#9-supported-isa--rv32imc)
10. [GPIO — MMIO Registers](#10-gpio--mmio-registers)
11. [UART0 — Serial Monitor](#11-uart0--serial-monitor)
12. [Limitations of the riscv32 QEMU machine](#12-limitations-of-the-riscv32-qemu-machine)
13. [Tests](#13-tests)
14. [Differences vs Xtensa Emulation (ESP32 / ESP32-S3)](#14-differences-vs-xtensa-emulation-esp32--esp32-s3)
15. [Key Files](#15-key-files)
---
## 1. Overview
Boards based on **ESP32-C3** use Espressif's **ESP32-C3** processor, which implements the **RISC-V RV32IMC** architecture (32-bit, Multiply, Compressed instructions). Unlike the ESP32 and ESP32-S3 (Xtensa LX6/LX7), the C3 **does not require QEMU or a backend** to be emulated.
Boards based on **ESP32-C3** use Espressif's **ESP32-C3** processor, implementing the **RISC-V RV32IMC** architecture. In production the system uses the same QEMU backend pattern as ESP32/ESP32-S3, but with a different library (`libqemu-riscv32`) and a different machine target (`esp32c3-picsimlab`).
> The browser-side TypeScript emulator (`RiscVCore.ts` + `Esp32C3Simulator.ts`) cannot handle the 150+ ROM functions that ESP-IDF needs during initialization. All production runs go through the QEMU backend. The TypeScript layer is kept as unit-test infrastructure for the RV32IMC ISA.
### Emulation Engine Comparison
| Board | CPU | Engine |
| ----- | --- | ------ |
| ESP32, ESP32-S3 | Xtensa LX6/LX7 | QEMU lcgamboa (backend WebSocket) |
| **ESP32-C3, XIAO-C3, C3 SuperMini** | **RV32IMC @ 160 MHz** | **RiscVCore.ts (browser, no backend)** |
| Arduino Uno/Nano/Mega | AVR ATmega | avr8js (browser) |
| Raspberry Pi Pico | RP2040 | rp2040js (browser) |
| Board | CPU | Production Engine | Unit-Test Engine |
| ----- | --- | ----------------- | ---------------- |
| ESP32, ESP32-S3 | Xtensa LX6/LX7 | QEMU lcgamboa `libqemu-xtensa` | — |
| **ESP32-C3, XIAO-C3, C3 SuperMini** | **RV32IMC @ 160 MHz** | **QEMU lcgamboa `libqemu-riscv32`** | RiscVCore.ts (Vitest) |
| Arduino Uno/Nano/Mega | AVR ATmega | avr8js (browser) | — |
| Raspberry Pi Pico | RP2040 | rp2040js (browser) | — |
### Advantages of the JS Emulator
### Key differences vs Xtensa (ESP32)
- **No network dependencies** — works offline, no WebSocket connection to the backend
- **Instant startup** — no QEMU process to launch (0 ms latency)
- **Testable with Vitest** — the same TypeScript code that runs in production can be tested in CI
- **Cross-platform** — works the same on Windows, macOS, Linux, and Docker
- Different library: `libqemu-riscv32.dll/.so` instead of `libqemu-xtensa`
- Different machine: `esp32c3-picsimlab` instead of `esp32-picsimlab`
- 22 GPIOs (GPIO 021) instead of 40; worker auto-adjusts pinmap
- ROM file: `esp32c3-rom.bin` (384 KB) instead of `esp32-v3-rom.bin`
- WiFi, LEDC/PWM, RMT/NeoPixel: **not yet emulated** in the riscv32 machine
- Build flag: `--disable-slirp` required (riscv32 target has incompatible pointer types in `net/slirp.c`)
---
@ -64,93 +73,243 @@ Boards based on **ESP32-C3** use Espressif's **ESP32-C3** processor, which imple
---
## 3. Emulator Architecture
## 3. Architecture — QEMU Backend Path
```text
Arduino Sketch (.ino)
User (browser)
└── WebSocket (/ws/{client_id})
└── simulation.py (FastAPI router)
└── EspLibManager
▼ arduino-cli (backend)
sketch.ino.bin ← ESP32 image format (IROM/DRAM/IRAM segments)
board_type in _RISCV_BOARDS?
├── YES → lib_path = LIB_RISCV_PATH (libqemu-riscv32.dll/.so)
│ machine = 'esp32c3-picsimlab'
│ _build_pinmap(22) ← 22 GPIOs, not 40
└── NO → lib_path = LIB_PATH (libqemu-xtensa.dll/.so)
machine = 'esp32-picsimlab' or 'esp32s3-picsimlab'
pinmap = 40 GPIOs
▼ base64 → frontend
compileBoardProgram(boardId, base64)
esp32_worker.py (subprocess)
▼ Esp32C3Simulator.loadFlashImage(base64)
parseMergedFlashImage() ← reads segments from the 4MB image
ctypes.CDLL(libqemu-riscv32.dll)
├── IROM segment → flash buffer (0x42000000)
├── DROM segment → flash buffer (0x3C000000, alias)
├── DRAM segment → dram buffer (0x3FC80000)
└── IRAM segment → iram buffer (0x4037C000)
Machine: esp32c3-picsimlab
CPU: RISC-V RV32IMC @ 160 MHz
▼ core.reset(entryPoint)
RiscVCore.step() ← requestAnimationFrame @ 60 FPS
│ 2,666,667 cycles/frame (160 MHz ÷ 60)
├── MMIO GPIO_W1TS/W1TC → onPinChangeWithTime → visual components
└── MMIO UART0 FIFO → onSerialData → Serial Monitor
┌──────────┴──────────┐
ESP32-C3 core emulated peripherals
(single core) GPIO (22 pins) · UART0 · SPI Flash
```
### Main Classes
**Required files (same directory as the lib):**
| Class | File | Responsibility |
| ----- | ---- | -------------- |
| `RiscVCore` | `simulation/RiscVCore.ts` | RV32IMC decoder/executor, generic MMIO |
| `Esp32C3Simulator` | `simulation/Esp32C3Simulator.ts` | ESP32-C3 memory map, GPIO, UART0, RAF loop |
| `parseMergedFlashImage` | `utils/esp32ImageParser.ts` | ESP32 image format parsing (segments, entry point) |
| File | Size | Source |
|------|------|--------|
| `libqemu-riscv32.dll` | ~58 MB | Compiled from `wokwi-libs/qemu-lcgamboa` (see §4) |
| `esp32c3-rom.bin` | 384 KB | `wokwi-libs/qemu-lcgamboa/pc-bios/esp32c3-rom.bin` |
### TypeScript / Browser layer (unit tests only)
The `RiscVCore.ts` + `Esp32C3Simulator.ts` classes exist for **Vitest unit tests only**. They provide a fast, offline RV32IMC interpreter that can run bare-metal binaries. They cannot handle the full ESP-IDF initialization sequence needed by real Arduino sketches.
| Class | File | Used in |
| ----- | ---- | ------- |
| `RiscVCore` | `simulation/RiscVCore.ts` | Vitest ISA unit tests |
| `Esp32C3Simulator` | `simulation/Esp32C3Simulator.ts` | Vitest end-to-end tests |
| `parseMergedFlashImage` | `utils/esp32ImageParser.ts` | Vitest + compile flow |
---
## 4. Emulated Memory and Peripherals
## 4. Setup on Windows — Building `libqemu-riscv32.dll`
### Memory Map
This section covers building the RISC-V QEMU library from source on Windows with MSYS2.
| Region | Base Address | Size | Description |
| ------ | ------------ | ---- | ----------- |
| Flash IROM | `0x42000000` | 4 MB | Executable code (core's main buffer) |
| Flash DROM | `0x3C000000` | 4 MB | Read-only data (alias of the same buffer) |
| DRAM | `0x3FC80000` | 384 KB | Data RAM (stack, global variables) |
| IRAM | `0x4037C000` | 384 KB | Instruction RAM (ISR, time-critical code) |
| UART0 | `0x60000000` | 1 KB | Serial port 0 |
| GPIO | `0x60004000` | 512 B | GPIO registers |
### 4.1 Prerequisites
### GPIO — Implemented Registers
Same as the Xtensa build (see [ESP32_EMULATION.md §1.11.4](./ESP32_EMULATION.md)), except:
- `--disable-slirp` is **required**`net/slirp.c` has incompatible pointer types in the riscv32-softmmu target that cause a compile error with GCC 15.x.
- `--enable-gcrypt` is **required** — matches the working Xtensa DLL linking pattern and avoids GCC emutls/pthread crash on Windows.
| Register | Offset | Function |
| -------- | ------ | -------- |
| `GPIO_OUT_REG` | `+0x04` | Read/write output state of all pins |
| `GPIO_OUT_W1TS_REG` | `+0x08` | **Set bits** — drive pins HIGH (write-only) |
| `GPIO_OUT_W1TC_REG` | `+0x0C` | **Clear bits** — drive pins LOW (write-only) |
| `GPIO_IN_REG` | `+0x3C` | Read input state of pins |
| `GPIO_ENABLE_REG` | `+0x20` | Pin direction (always returns `0xFF`) |
Install MSYS2 dependencies (same as Xtensa, **without** `libslirp`):
Covers **GPIO 021** (all available on ESP32-C3).
```bash
pacman -S \
mingw-w64-x86_64-gcc \
mingw-w64-x86_64-glib2 \
mingw-w64-x86_64-libgcrypt \
mingw-w64-x86_64-pixman \
mingw-w64-x86_64-ninja \
mingw-w64-x86_64-meson \
mingw-w64-x86_64-python \
mingw-w64-x86_64-pkg-config \
git diffutils
```
### UART0 — Implemented Registers
### 4.2 Configure and Build
| Register | Offset | Function |
| -------- | ------ | -------- |
| `UART_FIFO_REG` | `+0x00` | Write TX byte / read RX byte |
| `UART_STATUS_REG` | `+0x1C` | FIFO status (always returns `0` = ready) |
```bash
# In MSYS2 MINGW64:
cd /e/Hardware/wokwi_clon/wokwi-libs/qemu-lcgamboa
RX byte reading is available to simulate input from the Serial Monitor.
mkdir build-riscv && cd build-riscv
### Peripherals NOT Emulated (return 0 on read)
../configure \
--target-list=riscv32-softmmu \
--disable-werror \
--disable-alsa \
--enable-tcg \
--enable-system \
--enable-gcrypt \
--disable-slirp \
--enable-iconv \
--without-default-features \
--disable-docs
- Interrupt Matrix (`0x600C2000`)
- System / Clock (`0x600C0000`, `0x60008000`)
- Cache controller (`0x600C4000`)
- Timer Group 0/1
- SPI flash controller
- BLE / WiFi MAC
- ADC / DAC
ninja # compiles ~1430 objects, takes 15-30 min first time
```
> These peripherals return `0` by default. Code that depends on them may not function correctly (see [Limitations](#10-limitations)).
> **Why `--disable-slirp`?** GCC 15.x rejects the `net/slirp.c` code in the riscv32 machine due to incompatible pointer types in `slirp_smb_init`. Adding `-fpermissive` or `-Wno-incompatible-pointer-types` is also an option, but `--disable-slirp` is cleaner since this machine does not need network emulation.
### 4.3 Create the DLL (keeprsp trick)
QEMU's build system produces an executable (`qemu-system-riscv32.exe`) rather than a shared library. To get a DLL, relink all objects and exclude `softmmu_main.c.obj` (which contains `main()`):
```bash
# From build-riscv/:
# 1. Build the full exe first to generate the link response file:
ninja qemu-system-riscv32.exe
# 2. Capture the link command from ninja's database:
ninja -t commands qemu-system-riscv32.exe | tail -1 > dll_link.rsp
# 3. Edit dll_link.rsp:
# - Replace -o qemu-system-riscv32.exe with: -shared -o libqemu-riscv32.dll
# - Remove the softmmu_main.c.obj entry
# - Add: -Wl,--export-all-symbols -Wl,--allow-multiple-definition
# 4. Relink:
gcc @dll_link.rsp
# 5. Verify:
ls -lh libqemu-riscv32.dll # should be ~58 MB
objdump -p libqemu-riscv32.dll | grep qemu_picsimlab
```
### 4.4 Copy to Backend
```bash
cp libqemu-riscv32.dll /e/Hardware/wokwi_clon/backend/app/services/
cp ../pc-bios/esp32c3-rom.bin /e/Hardware/wokwi_clon/backend/app/services/
```
**Verify:**
```bash
ls -lh /e/Hardware/wokwi_clon/backend/app/services/libqemu-riscv32.dll
ls -lh /e/Hardware/wokwi_clon/backend/app/services/esp32c3-rom.bin
# libqemu-riscv32.dll ~58 MB
# esp32c3-rom.bin 384 KB
```
### 4.5 Verify DLL Loads
```python
# Run from backend/ with venv active:
python -c "
import ctypes, os
os.add_dll_directory(r'C:\msys64\mingw64\bin')
lib = ctypes.CDLL(r'app/services/libqemu-riscv32.dll')
print('qemu_init:', lib.qemu_init)
print('qemu_picsimlab_register_callbacks:', lib.qemu_picsimlab_register_callbacks)
print('OK — DLL loaded successfully')
"
```
### 4.6 Verify Emulation End-to-End
```bash
cd backend
python test_esp32c3_emulation.py
# Expected: PASS — GPIO8 toggled 2 times — ESP32-C3 emulation is working!
```
---
## 5. Full Flow: Compile and Run
## 5. Setup on Linux / Docker
### 5.1 Compile the Sketch
### 5.1 Docker (automatic)
The Dockerfile should compile `libqemu-riscv32.so` alongside `libqemu-xtensa.so`. Add the riscv32 target to the configure step:
```dockerfile
RUN cd /tmp/qemu-lcgamboa && ../configure \
--target-list=xtensa-softmmu,riscv32-softmmu \
--enable-gcrypt \
--disable-slirp \
... \
&& ninja \
&& cp build/libqemu-xtensa.so /app/lib/ \
&& cp build/libqemu-riscv32.so /app/lib/ \
&& cp pc-bios/esp32c3-rom.bin /app/lib/
```
### 5.2 Linux (manual)
```bash
sudo apt-get install -y libglib2.0-dev libgcrypt20-dev libpixman-1-dev libfdt-dev
cd wokwi-libs/qemu-lcgamboa
mkdir build-riscv && cd build-riscv
../configure \
--target-list=riscv32-softmmu \
--enable-gcrypt \
--disable-slirp \
--without-default-features \
--disable-docs
ninja
cp libqemu-riscv32.so ../../backend/app/services/
cp ../pc-bios/esp32c3-rom.bin ../../backend/app/services/
```
---
## 6. GPIO Pinmap — 22 GPIOs
The ESP32-C3 has **22 GPIOs (GPIO 021)**, fewer than the 40 on the ESP32. The QEMU machine (`esp32c3-picsimlab`) registers only 22 GPIO output lines in the QOM model. Sending a pinmap with more entries causes a QEMU property lookup error:
```
qemu: Property 'esp32c3.gpio.esp32_gpios[22]' not found
```
The worker (`esp32_worker.py`) detects the machine and rebuilds the pinmap before registering callbacks:
```python
# In main(), right after reading config:
if 'c3' in machine: # 'esp32c3-picsimlab'
_build_pinmap(22) # GPIO 0..21 only
# else: default pinmap (40 GPIOs for ESP32/ESP32-S3)
```
`_build_pinmap(n)` creates a `ctypes.c_int16` array of `n+1` elements:
```python
def _build_pinmap(gpio_count: int):
global _GPIO_COUNT, _PINMAP
_GPIO_COUNT = gpio_count
_PINMAP = (ctypes.c_int16 * (gpio_count + 1))(
gpio_count, # [0] = count
*range(gpio_count) # [1..n] = GPIO numbers 0..n-1
)
```
This is passed to QEMU via `_cbs_ref.pinmap`. When GPIO N changes, QEMU calls `picsimlab_write_pin(slot=N+1, value)` and the worker translates `slot → N` before emitting `{type: gpio_change, pin: N, state: value}`.
---
## 7. Full Flow: Compile and Run
### 7.1 Compile the Sketch
```bash
# arduino-cli compiles for ESP32-C3:
@ -168,7 +327,7 @@ arduino-cli compile \
The Velxio backend produces this image automatically and sends it to the frontend as base64.
### 5.2 Minimal Sketch for ESP32-C3
### 7.2 Minimal Sketch for ESP32-C3
```cpp
// LED on GPIO 8 (ESP32-C3 DevKit)
@ -190,7 +349,7 @@ void loop() {
}
```
### 5.3 Bare-Metal Sketch (for direct emulation tests)
### 7.3 Bare-Metal Sketch (for unit-test runner only)
To verify the emulation without the Arduino framework, you can compile directly with the RISC-V toolchain:
@ -229,7 +388,7 @@ See full script: `frontend/src/__tests__/fixtures/esp32c3-blink/build.sh`
---
## 6. ESP32 Image Format
## 8. ESP32 Image Format
The backend produces a merged **4 MB** image:
@ -263,7 +422,7 @@ The `parseMergedFlashImage()` parser in `utils/esp32ImageParser.ts` extracts all
---
## 7. Supported ISA — RV32IMC
## 9. Supported ISA — RV32IMC
`RiscVCore.ts` implements the three extensions required to run code compiled for ESP32-C3:
@ -290,9 +449,11 @@ All 16-bit instructions from the standard C extension are supported. They are de
---
## 8. GPIO
## 10. GPIO — MMIO Registers
GPIO handling follows the W1TS/W1TC register model of the ESP32-C3:
> Note: these registers are used by the TypeScript unit-test layer (`RiscVCore.ts`). The QEMU backend handles GPIO via the `picsimlab_write_pin` callback, not MMIO polling.
GPIO MMIO layout on the ESP32-C3:
```typescript
// Arduino sketch:
@ -309,7 +470,7 @@ The callback `onPinChangeWithTime(pin, state, timeMs)` is the integration point
---
## 9. UART0 — Serial Monitor
## 11. UART0 — Serial Monitor
Any byte written to `UART0_FIFO_REG` (0x60000000) calls the `onSerialData(char)` callback:
@ -331,48 +492,40 @@ sim.serialWrite("COMMAND\n");
---
## 10. Limitations
## 12. Limitations of the riscv32 QEMU machine
### ESP-IDF / Arduino Framework
The `esp32c3-picsimlab` QEMU machine in the lcgamboa fork emulates the core CPU and GPIO. The following are **not yet implemented** in the QEMU machine (as of 2026-03):
The Arduino framework for ESP32-C3 (based on ESP-IDF 4.4.x) has a complex initialization sequence that accesses non-emulated peripherals:
| Feature | Status | Notes |
| ------- | ------ | ----- |
| WiFi / BLE | Not emulated | No slirp networking in riscv32 machine |
| LEDC / PWM | Not emulated | No rmt/ledc callbacks yet |
| RMT / NeoPixel | Not emulated | No RMT peripheral in esp32c3-picsimlab |
| ADC | Not emulated | GPIO 0-5 ADC channels not wired |
| Hardware I2C / SPI | Not emulated | Callbacks not registered |
| Interrupts (`attachInterrupt`) | Not emulated | GPIO interrupt lines not connected |
| NVS / SPIFFS | Not emulated | Flash writes not persisted |
| arduino-esp32 3.x (IDF 5.x) | **Unsupported** | Use 2.0.17 (IDF 4.4.x) — same as Xtensa |
| Peripheral | Why ESP-IDF accesses it | Effect in emulator |
| ---------- | ----------------------- | ------------------ |
| Cache controller | Configures MMU for flash/DRAM mapping | Reads 0, may not loop |
| Interrupt Matrix | Registers ISR vectors | No effect (silenced) |
| System registers | Configures PLLs and clocks | Reads 0 (assumes default speed) |
| FreeRTOS tick timer | Timer 0 → periodic interrupt | No interrupt = tasks not scheduled |
As a result, an Arduino sketch compiled with the full framework may execute partially — code prior to FreeRTOS initialization may work, but `setup()` and `loop()` depend on FreeRTOS running.
**Scenarios that DO work:**
- Bare-metal code (no framework, direct GPIO MMIO access)
- Code fragments that do not use FreeRTOS (`delay()`, `millis()`, `digitalWrite()` require FreeRTOS)
- ISA test programs (arithmetic operations, branches, loads/stores to DRAM)
**Roadmap for full support:**
1. Cache controller stub (return values indicating "cache already configured")
2. Interrupt matrix stub (accept writes, ignore)
3. Basic timer peripheral (generate FreeRTOS tick periodically)
4. Once FreeRTOS is active: normal Arduino sketches should work
### Other Limitations
| Limitation | Detail |
| ---------- | ------ |
| No WiFi | ESP32-C3 has BLE/WiFi radio; not emulated |
| No ADC | GPIO 0-5 as ADC not implemented |
| No hardware SPI/I2C | Hardware SPI/I2C peripherals return 0 |
| No interrupts | `attachInterrupt()` does not work |
| No RTC | `esp_sleep_*`, `rtc_*` not implemented |
| No NVS/Flash writes | `Preferences`, `SPIFFS` not implemented |
> The QEMU machine boots the ESP-IDF bootloader and transitions to the Arduino `setup()`/`loop()` correctly for GPIO and UART sketches. Sketches using WiFi, LEDC, or RMT will boot but those peripherals will silently do nothing.
---
## 11. Tests
## 13. Tests
### 13.1 End-to-End Test (Backend QEMU)
File: `backend/test_esp32c3_emulation.py`
```bash
cd backend
python test_esp32c3_emulation.py
# Expected: PASS — GPIO8 toggled 2 times — ESP32-C3 emulation is working!
```
Compiles a blink sketch via arduino-cli, merges a 4 MB flash image, launches the worker, and checks that GPIO8 toggles at least twice within 45 s.
### 13.2 RiscVCore ISA Unit Tests (TypeScript)
RISC-V emulation tests are in `frontend/src/__tests__/`:
@ -410,6 +563,8 @@ Compiles `blink.c` with `riscv32-esp-elf-gcc` (the arduino-cli toolchain) and ve
**Expected result:**
> Note: these TypeScript tests run against `RiscVCore.ts` (the in-browser interpreter), not against the QEMU backend.
```text
✓ esp32c3-simulation.test.ts (30 tests) ~500ms
✓ esp32c3-blink.test.ts (8 tests) ~300ms
@ -429,31 +584,54 @@ frontend/src/__tests__/fixtures/esp32c3-blink/
---
## 12. Differences vs Xtensa Emulation (ESP32 / ESP32-S3)
## 14. Differences vs Xtensa Emulation (ESP32 / ESP32-S3)
| Aspect | ESP32-C3 (RISC-V) | ESP32 / ESP32-S3 (Xtensa) |
| ------ | ----------------- | ------------------------- |
| Engine | `Esp32C3Simulator` (TypeScript, browser) | `Esp32Bridge` + backend QEMU |
| Backend dependency | **No** — 100% in the browser | Yes — WebSocket to QEMU process |
| Startup | Instant | ~1-2 seconds |
| GPIO | Via MMIO W1TS/W1TC | Via QEMU callbacks → WebSocket |
| WiFi | Not emulated | Emulated (hardcoded SSIDs) |
| Hardware I2C/SPI | Not emulated | Emulated (synchronous callbacks) |
| LEDC/PWM | Not emulated | Emulated (periodic polling) |
| NeoPixel/RMT | Not emulated | Emulated (RMT decoder) |
| Arduino framework | Partial (FreeRTOS not active) | Full |
| CI tests | Yes (Vitest) | No (requires native lib) |
| QEMU library | `libqemu-riscv32.dll/.so` | `libqemu-xtensa.dll/.so` |
| QEMU machine | `esp32c3-picsimlab` | `esp32-picsimlab` / `esp32s3-picsimlab` |
| ROM file | `esp32c3-rom.bin` (384 KB) | `esp32-v3-rom.bin` + `esp32-v3-rom-app.bin` |
| GPIO count | **22** (GPIO 021) | **40** (GPIO 039) |
| Build target | `--target-list=riscv32-softmmu` | `--target-list=xtensa-softmmu` |
| `--disable-slirp` required | **Yes** (slirp.c incompatible pointer types with riscv32) | No (slirp works with xtensa) |
| WiFi | Not emulated | Emulated (hardcoded SSIDs via SLIRP) |
| LEDC/PWM | Not emulated | Emulated (periodic polling via internals) |
| NeoPixel/RMT | Not emulated | Emulated (RMT decoder in worker) |
| I2C / SPI | Not emulated | Emulated (synchronous QEMU callbacks) |
| GPIO3239 fix | N/A (only 22 GPIOs) | Required (bank 1 register) |
| Arduino framework | Full (IDF 4.4.x) | Full (IDF 4.4.x) |
| ISA unit tests | Yes (Vitest — RiscVCore.ts) | No (requires native lib) |
---
## 13. Key Files
## 15. Key Files
### Backend
| File | Description |
| ---- | ----------- |
| `frontend/src/simulation/RiscVCore.ts` | RV32IMC emulator core (I + M + C extensions) |
| `frontend/src/simulation/Esp32C3Simulator.ts` | ESP32-C3 memory map, GPIO, UART0, RAF loop |
| `backend/app/services/libqemu-riscv32.dll` | QEMU riscv32 shared library (**not in git** — build locally) |
| `backend/app/services/esp32c3-rom.bin` | ESP32-C3 boot ROM (**not in git** — copy from pc-bios/) |
| `backend/app/services/esp32_worker.py` | QEMU subprocess worker; calls `_build_pinmap(22)` for C3 |
| `backend/app/services/esp32_lib_manager.py` | `_RISCV_BOARDS`, `_MACHINE`, `LIB_RISCV_PATH`; selects right DLL |
| `backend/test_esp32c3_emulation.py` | End-to-end test: compile → flash → QEMU worker → GPIO toggle |
### Frontend
| File | Description |
| ---- | ----------- |
| `frontend/src/store/useSimulatorStore.ts` | `ESP32_RISCV_KINDS` set; `isRiscVEsp32Kind()` routes to QEMU bridge |
| `frontend/src/simulation/RiscVCore.ts` | RV32IMC interpreter (unit-test layer only) |
| `frontend/src/simulation/Esp32C3Simulator.ts` | ESP32-C3 SoC wrapper (unit-test layer only) |
| `frontend/src/utils/esp32ImageParser.ts` | ESP32 image format parser (merged flash → segments) |
| `frontend/src/store/useSimulatorStore.ts` | `ESP32_RISCV_KINDS`, `createSimulator()`, `compileBoardProgram()` |
| `frontend/src/__tests__/esp32c3-simulation.test.ts` | ISA unit tests (30 tests) |
| `frontend/src/__tests__/esp32c3-blink.test.ts` | End-to-end integration test (8 tests) |
| `frontend/src/__tests__/esp32c3-simulation.test.ts` | RV32IMC ISA unit tests (30 tests — TypeScript only) |
| `frontend/src/__tests__/esp32c3-blink.test.ts` | End-to-end bare-metal test (8 tests — TypeScript only) |
| `frontend/src/__tests__/fixtures/esp32c3-blink/` | Bare-metal test firmware + toolchain script |
### QEMU Source
| File | Description |
| ---- | ----------- |
| `wokwi-libs/qemu-lcgamboa/hw/riscv/esp32c3_picsimlab.c` | ESP32-C3 PICSimLab machine definition |
| `wokwi-libs/qemu-lcgamboa/hw/gpio/esp32c3_gpio.c` | ESP32-C3 GPIO model (inherits esp32_gpio, 22 outputs) |
| `wokwi-libs/qemu-lcgamboa/pc-bios/esp32c3-rom.bin` | ROM binary to copy to backend/app/services/ |

View File

@ -1,6 +1,6 @@
{
"version": "1.0.0",
"generatedAt": "2026-03-16T21:03:46.561Z",
"generatedAt": "2026-03-19T00:06:03.160Z",
"components": [
{
"id": "arduino-mega",

View File

@ -152,21 +152,8 @@ export class Esp32C3Simulator {
*/
private _periRegs = new Map<number, number>();
// ── Diagnostic state ─────────────────────────────────────────────────────
private _dbgFrameCount = 0;
private _dbgTickCount = 0;
private _dbgLastMtvec = 0;
private _dbgMieEnabled = false;
/** Track PC at the start of each tick for stuck-loop detection. */
private _dbgPrevTickPc = -1;
private _dbgSamePcCount = 0;
private _dbgStuckDumped = false;
/** Ring buffer of sampled PCs — dumped when stuck detector fires. */
private _pcTrace = new Uint32Array(128);
private _pcTraceIdx = 0;
private _pcTraceStep = 0;
/** Count of ROM function calls (for logging first N). */
private _romCallCount = 0;
/** CPU ticks per µs — updated by ets_update_cpu_frequency(). */
private _ticksPerUs = 160;
public pinManager: PinManager;
public onSerialData: ((ch: string) => void) | null = null;
@ -382,7 +369,6 @@ export class Esp32C3Simulator {
private _registerIntMatrix(): void {
const peri = this._periRegs;
const BASE = INTMATRIX_BASE;
let logCount = 0;
this.core.addMmio(BASE, INTMATRIX_SIZE,
(addr) => {
@ -428,28 +414,15 @@ export class Esp32C3Simulator {
if (off <= 0x0F8) {
const src = off >> 2;
if (src < 62) {
const oldLine = this._intSrcMap[src];
this._intSrcMap[src] = newWord & 0x1F;
if ((newWord & 0x1F) !== oldLine && logCount < 30) {
logCount++;
console.log(`[INTMATRIX] src ${src} → CPU line ${newWord & 0x1F}`);
}
}
} else if (off === 0x104) {
this._intLineEnable = newWord;
if (logCount < 30) {
logCount++;
console.log(`[INTMATRIX] ENABLE = 0x${newWord.toString(16)}`);
}
} else if (off >= 0x114 && off <= 0x190) {
const line = (off - 0x114) >> 2;
if (line < 32) this._intLinePrio[line] = newWord & 0xF;
} else if (off === 0x194) {
this._intThreshold = newWord & 0xF;
if (logCount < 30) {
logCount++;
console.log(`[INTMATRIX] THRESH = ${newWord & 0xF}`);
}
}
},
);
@ -615,15 +588,6 @@ export class Esp32C3Simulator {
const r = this.core.regs;
const c = this.core;
if (++this._romCallCount <= 50) {
console.log(
`[ROM] #${this._romCallCount} 0x${addr.toString(16)}` +
` ra=0x${(r[1]>>>0).toString(16)}` +
` a0=0x${(r[10]>>>0).toString(16)} a1=0x${(r[11]>>>0).toString(16)}` +
` a2=0x${(r[12]>>>0).toString(16)} a3=0x${(r[13]>>>0).toString(16)}`
);
}
switch (addr) {
// ── C library functions ──────────────────────────────────────────
case 0x40000354: { // memset(dest, val, n) → dest
@ -805,6 +769,116 @@ export class Esp32C3Simulator {
break;
}
// ── libgcc shift / bit operations ────────────────────────────────
case 0x4000077c: { // __ashldi3(a, shift) → a << shift (64-bit)
const aLo = r[10] >>> 0, aHi = r[11] >>> 0;
const shift = r[12] & 63;
const val = BigInt(aLo) | (BigInt(aHi) << 32n);
const res = BigInt.asUintN(64, val << BigInt(shift));
r[10] = Number(res & 0xFFFFFFFFn) | 0;
r[11] = Number((res >> 32n) & 0xFFFFFFFFn) | 0;
break;
}
case 0x40000780: { // __ashrdi3(a, shift) → a >> shift (signed/arithmetic 64-bit)
const aLo = r[10] >>> 0, aHi = r[11] >>> 0;
const shift = r[12] & 63;
const val = BigInt.asIntN(64, BigInt(aLo) | (BigInt(aHi) << 32n));
const res = BigInt.asUintN(64, val >> BigInt(shift));
r[10] = Number(res & 0xFFFFFFFFn) | 0;
r[11] = Number((res >> 32n) & 0xFFFFFFFFn) | 0;
break;
}
case 0x40000830: { // __lshrdi3(a, shift) → a >>> shift (unsigned/logical 64-bit)
const aLo = r[10] >>> 0, aHi = r[11] >>> 0;
const shift = r[12] & 63;
const val = BigInt(aLo) | (BigInt(aHi) << 32n);
const res = val >> BigInt(shift); // positive BigInt → logical shift
r[10] = Number(res & 0xFFFFFFFFn) | 0;
r[11] = Number((res >> 32n) & 0xFFFFFFFFn) | 0;
break;
}
case 0x4000079c: { // __clzsi2(a) → count leading zeros (32-bit)
r[10] = Math.clz32(r[10] >>> 0);
break;
}
case 0x40000798: { // __clzdi2(a) → count leading zeros (64-bit)
const lo = r[10] >>> 0, hi = r[11] >>> 0;
r[10] = hi !== 0 ? Math.clz32(hi) : 32 + Math.clz32(lo);
break;
}
case 0x400007a8: { // __ctzsi2(a) → count trailing zeros (32-bit)
const v = r[10] >>> 0;
r[10] = v === 0 ? 32 : 31 - Math.clz32(v & -v);
break;
}
case 0x400007a4: { // __ctzdi2(a) → count trailing zeros (64-bit)
const lo = r[10] >>> 0, hi = r[11] >>> 0;
if (lo !== 0) {
r[10] = 31 - Math.clz32(lo & -lo);
} else if (hi !== 0) {
r[10] = 32 + (31 - Math.clz32(hi & -hi));
} else {
r[10] = 64;
}
break;
}
case 0x4000086c: { // __negdi2(a) → -a (64-bit)
const lo = r[10] >>> 0, hi = r[11] >>> 0;
const val = BigInt(lo) | (BigInt(hi) << 32n);
const neg = BigInt.asUintN(64, -val);
r[10] = Number(neg & 0xFFFFFFFFn) | 0;
r[11] = Number((neg >> 32n) & 0xFFFFFFFFn) | 0;
break;
}
case 0x400007d0: { // __ffsdi2(a) → find first set bit (64-bit), 0 if none
const lo = r[10] >>> 0, hi = r[11] >>> 0;
if (lo !== 0) {
r[10] = (31 - Math.clz32(lo & -lo)) + 1;
} else if (hi !== 0) {
r[10] = 32 + (31 - Math.clz32(hi & -hi)) + 1;
} else {
r[10] = 0;
}
break;
}
case 0x400007d4: { // __ffssi2(a) → find first set bit (32-bit), 0 if none
const v = r[10] >>> 0;
r[10] = v === 0 ? 0 : (31 - Math.clz32(v & -v)) + 1;
break;
}
case 0x40000784: { // __bswapdi2(a) → byte-swap 64-bit
const lo = r[10] >>> 0, hi = r[11] >>> 0;
const swapLo = ((lo >>> 24) | ((lo >>> 8) & 0xFF00) | ((lo << 8) & 0xFF0000) | (lo << 24)) >>> 0;
const swapHi = ((hi >>> 24) | ((hi >>> 8) & 0xFF00) | ((hi << 8) & 0xFF0000) | (hi << 24)) >>> 0;
r[10] = swapHi | 0; // swapped hi becomes new lo
r[11] = swapLo | 0; // swapped lo becomes new hi
break;
}
case 0x40000788: { // __bswapsi2(a) → byte-swap 32-bit
const v = r[10] >>> 0;
r[10] = ((v >>> 24) | ((v >>> 8) & 0xFF00) | ((v << 8) & 0xFF0000) | (v << 24)) | 0;
break;
}
case 0x400007a0: { // __cmpdi2(a, b) → 0 if a<b, 1 if a==b, 2 if a>b (signed 64-bit)
const a = BigInt.asIntN(64, BigInt(r[10] >>> 0) | (BigInt(r[11]) << 32n));
const b = BigInt.asIntN(64, BigInt(r[12] >>> 0) | (BigInt(r[13]) << 32n));
r[10] = a < b ? 0 : a === b ? 1 : 2;
break;
}
case 0x400008a8: { // __ucmpdi2(a, b) → 0 if a<b, 1 if a==b, 2 if a>b (unsigned 64-bit)
const a = BigInt(r[10] >>> 0) | (BigInt(r[11] >>> 0) << 32n);
const b = BigInt(r[12] >>> 0) | (BigInt(r[13] >>> 0) << 32n);
r[10] = a < b ? 0 : a === b ? 1 : 2;
break;
}
case 0x40000764: { // __absvdi2(a) → |a| (signed 64-bit, aborts on overflow)
const val = BigInt.asIntN(64, BigInt(r[10] >>> 0) | (BigInt(r[11]) << 32n));
const abs = val < 0n ? BigInt.asUintN(64, -val) : BigInt.asUintN(64, val);
r[10] = Number(abs & 0xFFFFFFFFn) | 0;
r[11] = Number((abs >> 32n) & 0xFFFFFFFFn) | 0;
break;
}
// ── ESP-IDF ROM helpers ──────────────────────────────────────────
case 0x40000018: { // rtc_get_reset_reason() → 1 (POWERON_RESET)
r[10] = 1;
@ -813,7 +887,7 @@ export class Esp32C3Simulator {
case 0x40000050: { // ets_delay_us(us) → void
// Burn the equivalent number of CPU cycles so timers advance
const us = r[10] >>> 0;
const burnCycles = Math.min(us * (CPU_HZ / 1_000_000), 1_000_000);
const burnCycles = Math.min(us * this._ticksPerUs, 1_000_000);
this.core.cycles += burnCycles;
r[10] = 0;
break;
@ -826,6 +900,33 @@ export class Esp32C3Simulator {
r[10] = 0;
break;
}
case 0x4000195c: { // rom_i2c_writeReg(...) → 0 (success)
r[10] = 0;
break;
}
case 0x40000588: { // ets_update_cpu_frequency(ticks_per_us)
// Store value so our ets_delay_us can use the right multiplier.
// Firmware calls this with e.g. 40 or 160.
this._ticksPerUs = r[10] >>> 0;
r[10] = 0;
break;
}
case 0x40000084: { // uart_tx_wait_idle(uart_num) — no-op
r[10] = 0;
break;
}
case 0x400005f4: { // intr_matrix_set(cpu_no, model_num, intr_num)
// Maps peripheral interrupt source a1 to CPU interrupt line a2.
// This directly programs the interrupt matrix hardware.
const source = r[11] >>> 0;
const line = r[12] & 0x1F;
if (source < 62) {
this._intSrcMap[source] = line;
// Also store in the MMIO echo-back register so firmware reads work
this._periRegs.set(INTMATRIX_BASE + source * 4, line);
}
break;
}
default: {
// Unknown ROM function — return a0=0 (ESP_OK for esp_err_t functions)
@ -848,16 +949,11 @@ export class Esp32C3Simulator {
* Called for all known TIMG0/TIMG1 base addresses across ESP-IDF versions.
*/
private _registerTimerGroup(base: number): void {
const seen = new Set<number>();
const peri = this._periRegs;
this.core.addMmio(base, 0x100,
(addr) => {
const off = addr - base;
const wOff = off & ~3;
if (!seen.has(wOff)) {
seen.add(wOff);
console.log(`[TIMG@0x${base.toString(16)}] 1st read wOff=0x${wOff.toString(16)} pc=0x${this.core.pc.toString(16)}`);
}
if (wOff === 0x68) {
// TIMG_RTCCALICFG: bit15=TIMG_RTC_CALI_RDY=1 — calibration instantly done
// Also set bit31 (start bit echo) which some versions check
@ -1153,17 +1249,7 @@ export class Esp32C3Simulator {
start(): void {
if (this.running) return;
this._dbgFrameCount = 0;
this._dbgTickCount = 0;
this._dbgLastMtvec = 0;
this._dbgMieEnabled = false;
this._dbgPrevTickPc = -1;
this._dbgSamePcCount = 0;
this._dbgStuckDumped = false;
this._pcTrace.fill(0);
this._pcTraceIdx = 0;
this._pcTraceStep = 0;
this._romCallCount = 0;
this._ticksPerUs = 160;
console.log(`[ESP32-C3] Simulation started, entry=0x${this.core.pc.toString(16)}`);
this.running = true;
this._loop();
@ -1182,17 +1268,7 @@ export class Esp32C3Simulator {
this._stIntEna = 0;
this._stIntRaw = 0;
this._periRegs.clear();
this._dbgFrameCount = 0;
this._dbgTickCount = 0;
this._dbgLastMtvec = 0;
this._dbgMieEnabled = false;
this._dbgPrevTickPc = -1;
this._dbgSamePcCount = 0;
this._dbgStuckDumped = false;
this._pcTrace.fill(0);
this._pcTraceIdx = 0;
this._pcTraceStep = 0;
this._romCallCount = 0;
this._ticksPerUs = 160;
this.dram.fill(0);
this.iram.fill(0);
this.core.reset(IROM_BASE);
@ -1219,163 +1295,15 @@ export class Esp32C3Simulator {
private _loop(): void {
if (!this.running) return;
this._dbgFrameCount++;
// ── Per-frame diagnostics (check once, before heavy execution) ─────────
// Detect mtvec being set — FreeRTOS writes this during startup.
const mtvec = this.core.mtvecVal;
if (mtvec !== this._dbgLastMtvec) {
if (mtvec !== 0) {
console.log(
`[ESP32-C3] mtvec set → 0x${mtvec.toString(16)}` +
` (mode=${mtvec & 3}) @ frame ${this._dbgFrameCount}`
);
}
this._dbgLastMtvec = mtvec;
}
// Detect MIE 0→1 transition — FreeRTOS enables this when scheduler starts.
const mie = (this.core.mstatusVal & 0x8) !== 0;
if (mie && !this._dbgMieEnabled) {
console.log(
`[ESP32-C3] MIE enabled (interrupts ON) @ frame ${this._dbgFrameCount}` +
`, pc=0x${this.core.pc.toString(16)}`
);
this._dbgMieEnabled = true;
}
// Log PC + key state every ~1 second (60 frames).
if (this._dbgFrameCount % 60 === 0) {
console.log(
`[ESP32-C3] frame=${this._dbgFrameCount}` +
` pc=0x${this.core.pc.toString(16)}` +
` cycles=${this.core.cycles}` +
` ticks=${this._dbgTickCount}` +
` mtvec=0x${mtvec.toString(16)}` +
` MIE=${mie}` +
` GPIO=0x${this.gpioOut.toString(16)}`
);
}
// Execute in 1 ms chunks so FreeRTOS tick interrupts fire at ~1 kHz.
let rem = CYCLES_PER_FRAME;
while (rem > 0) {
const n = rem < CYCLES_PER_TICK ? rem : CYCLES_PER_TICK;
for (let i = 0; i < n; i++) {
this.core.step();
// Sample PC every 500 steps into a ring buffer for post-mortem analysis
if (++this._pcTraceStep >= 500) {
this._pcTraceStep = 0;
this._pcTrace[this._pcTraceIdx] = this.core.pc >>> 0;
this._pcTraceIdx = (this._pcTraceIdx + 1) & 127;
}
}
rem -= n;
this._dbgTickCount++;
// Log frequently early in boot (every 10 ticks for first 50, then every 100)
const shouldLog = this._dbgTickCount <= 50
? this._dbgTickCount % 10 === 0
: this._dbgTickCount <= 1000 && this._dbgTickCount % 100 === 0;
if (shouldLog) {
const spc = this.core.pc;
let instrInfo = '';
const iramOff = spc - IRAM_BASE;
const flashOff = spc - IROM_BASE;
const romOff = spc - ROM_BASE;
let ib0 = 0, ib1 = 0, ib2 = 0, ib3 = 0;
if (iramOff >= 0 && iramOff + 4 <= this.iram.length) {
[ib0, ib1, ib2, ib3] = [this.iram[iramOff], this.iram[iramOff+1], this.iram[iramOff+2], this.iram[iramOff+3]];
} else if (flashOff >= 0 && flashOff + 4 <= this.flash.length) {
[ib0, ib1, ib2, ib3] = [this.flash[flashOff], this.flash[flashOff+1], this.flash[flashOff+2], this.flash[flashOff+3]];
} else if (romOff >= 0 && romOff < ROM_SIZE) {
// PC is in ROM stub region — show 0x8082 (C.RET)
ib0 = 0x82; ib1 = 0x80;
}
const instr16 = ib0 | (ib1 << 8);
const instr32 = ((ib0 | (ib1<<8) | (ib2<<16) | (ib3<<24)) >>> 0);
const isC = (instr16 & 3) !== 3;
const hex = isC ? instr16.toString(16).padStart(4,'0') : instr32.toString(16).padStart(8,'0');
if (!isC) {
const op = instr32 & 0x7F;
const f3 = (instr32 >> 12) & 7;
const rs1 = (instr32 >> 15) & 31;
if (op === 0x73) {
const csr = (instr32 >> 20) & 0xFFF;
instrInfo = ` [SYSTEM csr=0x${csr.toString(16)} f3=${f3}]`;
} else if (op === 0x03) {
const imm = (instr32 >> 20) << 0 >> 0;
instrInfo = ` [LOAD x${rs1}+${imm} f3=${f3}]`;
} else if (op === 0x63) {
instrInfo = ` [BRANCH f3=${f3}]`;
} else if (op === 0x23) {
instrInfo = ` [STORE f3=${f3}]`;
}
}
console.log(
`[ESP32-C3] tick #${this._dbgTickCount}` +
` pc=0x${spc.toString(16)} instr=0x${hex}${instrInfo}` +
` MIE=${(this.core.mstatusVal & 0x8) !== 0}`
);
}
// ── Stuck-loop detector ────────────────────────────────────────────
// If the PC hasn't changed across consecutive ticks (160 000 cycles),
// the CPU is stuck in a tight spin. Dump all registers once for
// post-mortem analysis so we can identify which peripheral or stub
// needs attention.
{
const curPc = this.core.pc;
if (curPc === this._dbgPrevTickPc) {
this._dbgSamePcCount++;
if (this._dbgSamePcCount >= 3 && !this._dbgStuckDumped) {
this._dbgStuckDumped = true;
console.warn(
`[ESP32-C3] ⚠ CPU stuck at pc=0x${curPc.toString(16)} for ${this._dbgSamePcCount} ticks — register dump:`
);
const regNames = [
'zero','ra','sp','gp','tp','t0','t1','t2',
's0','s1','a0','a1','a2','a3','a4','a5',
'a6','a7','s2','s3','s4','s5','s6','s7',
's8','s9','s10','s11','t3','t4','t5','t6',
];
for (let i = 0; i < 32; i++) {
console.warn(` x${i.toString().padStart(2)}(${regNames[i].padEnd(4)}) = 0x${(this.core.regs[i] >>> 0).toString(16).padStart(8, '0')}`);
}
console.warn(` mstatus=0x${(this.core.mstatusVal >>> 0).toString(16)} mtvec=0x${(this.core.mtvecVal >>> 0).toString(16)}`);
// Dump sampled PC trace (oldest → newest)
const traceEntries: string[] = [];
for (let j = 0; j < 128; j++) {
const idx = (this._pcTraceIdx + j) & 127;
const tpc = this._pcTrace[idx];
if (tpc !== 0) traceEntries.push(`0x${tpc.toString(16).padStart(8,'0')}`);
}
if (traceEntries.length > 0) {
// Deduplicate consecutive entries for readability
const deduped: string[] = [];
let prev = '';
let count = 0;
for (const e of traceEntries) {
if (e === prev) { count++; }
else {
if (count > 1) deduped.push(` (×${count})`);
deduped.push(e);
prev = e;
count = 1;
}
}
if (count > 1) deduped.push(` (×${count})`);
console.warn(` PC trace (sampled every 500 steps, ${deduped.length} entries):`);
console.warn(' ' + deduped.join(', '));
}
}
} else {
this._dbgSamePcCount = 0;
this._dbgStuckDumped = false;
}
this._dbgPrevTickPc = curPc;
}
// Raise SYSTIMER TARGET0 alarm → routed through interrupt matrix.
this._stIntRaw |= 1;
if (this._stIntEna & 1) {

View File

@ -46,19 +46,20 @@ export const getBoardPinManager = (id: string) => pinManagerMap.get(id);
export const getBoardBridge = (id: string) => bridgeMap.get(id);
export const getEsp32Bridge = (id: string) => esp32BridgeMap.get(id);
// Xtensa-based ESP32 boards — still use QEMU bridge (backend)
// Xtensa-based ESP32 boards — use QEMU bridge (backend)
const ESP32_KINDS = new Set<BoardKind>([
'esp32', 'esp32-devkit-c-v4', 'esp32-cam', 'wemos-lolin32-lite',
'esp32-s3', 'xiao-esp32-s3', 'arduino-nano-esp32',
]);
// RISC-V ESP32 boards — use the browser-side Esp32C3Simulator (no backend needed)
// RISC-V ESP32 boards — also use QEMU bridge (qemu-system-riscv32 -M esp32c3)
// The browser-side Esp32C3Simulator cannot handle the 150+ ROM functions ESP-IDF needs.
const ESP32_RISCV_KINDS = new Set<BoardKind>([
'esp32-c3', 'xiao-esp32-c3', 'aitewinrobot-esp32c3-supermini',
]);
function isEsp32Kind(kind: BoardKind): boolean {
return ESP32_KINDS.has(kind);
return ESP32_KINDS.has(kind) || ESP32_RISCV_KINDS.has(kind);
}
function isRiscVEsp32Kind(kind: BoardKind): boolean {