From 7053b6f2c8f14d9883cd683367aa7f4803484dee Mon Sep 17 00:00:00 2001 From: David Montero Crespo Date: Wed, 18 Mar 2026 23:30:45 -0300 Subject: [PATCH] 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. --- .gitignore | 5 +- Dockerfile.standalone | 24 +- backend/app/api/routes/simulation.py | 6 +- backend/app/services/arduino_cli.py | 21 +- backend/app/services/esp32_lib_manager.py | 43 +- backend/app/services/esp32_worker.py | 16 + backend/test_esp32c3_emulation.py | 253 +++++++++++ docs/ESP32_EMULATION.md | 60 ++- docs/RISCV_EMULATION.md | 478 ++++++++++++++------ frontend/public/components-metadata.json | 2 +- frontend/src/simulation/Esp32C3Simulator.ts | 356 ++++++--------- frontend/src/store/useSimulatorStore.ts | 7 +- 12 files changed, 868 insertions(+), 403 deletions(-) create mode 100644 backend/test_esp32c3_emulation.py diff --git a/.gitignore b/.gitignore index 63c57ea..bec3335 100644 --- a/.gitignore +++ b/.gitignore @@ -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 diff --git a/Dockerfile.standalone b/Dockerfile.standalone index a69c25e..924eea8 100644 --- a/Dockerfile.standalone +++ b/Dockerfile.standalone @@ -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 @@ -106,17 +111,24 @@ COPY deploy/entrypoint.sh /app/entrypoint.sh 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) -# esp32-v3-rom*.bin → boot/app ROM images required by esp32-picsimlab machine +# libqemu-xtensa.so → ESP32 / ESP32-S3 (Xtensa LX6/LX7) +# 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 diff --git a/backend/app/api/routes/simulation.py b/backend/app/api/routes/simulation.py index 46f1a97..07d8eb3 100644 --- a/backend/app/api/routes/simulation.py +++ b/backend/app/api/routes/simulation.py @@ -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) diff --git a/backend/app/services/arduino_cli.py b/backend/app/services/arduino_cli.py index c09c11d..e571e7a 100644 --- a/backend/app/services/arduino_cli.py +++ b/backend/app/services/arduino_cli.py @@ -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), - (0x8000, partitions_file), - (0x10000, bin_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") diff --git a/backend/app/services/esp32_lib_manager.py b/backend/app/services/esp32_lib_manager.py index dd29165..d47c65b 100644 --- a/backend/app/services/esp32_lib_manager.py +++ b/backend/app/services/esp32_lib_manager.py @@ -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', + '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( @@ -151,9 +171,10 @@ class EspLibManager: logger.info('start_instance %s: no firmware — skipping worker launch', client_id) return - machine = _MACHINE.get(board_type, 'esp32-picsimlab') - config = json.dumps({ - 'lib_path': LIB_PATH, + 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, 'firmware_b64': firmware_b64, 'machine': machine, }) diff --git a/backend/app/services/esp32_worker.py b/backend/app/services/esp32_worker.py index d286c09..a465efb 100644 --- a/backend/app/services/esp32_worker.py +++ b/backend/app/services/esp32_worker.py @@ -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): diff --git a/backend/test_esp32c3_emulation.py b/backend/test_esp32c3_emulation.py new file mode 100644 index 0000000..c6d63ef --- /dev/null +++ b/backend/test_esp32c3_emulation.py @@ -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() diff --git a/docs/ESP32_EMULATION.md b/docs/ESP32_EMULATION.md index b65cb28..fd35807 100644 --- a/docs/ESP32_EMULATION.md +++ b/docs/ESP32_EMULATION.md @@ -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) + 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:** diff --git a/docs/RISCV_EMULATION.md b/docs/RISCV_EMULATION.md index 7d4f506..101ae48 100644 --- a/docs/RISCV_EMULATION.md +++ b/docs/RISCV_EMULATION.md @@ -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 0–21) 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) - │ - ▼ arduino-cli (backend) - sketch.ino.bin ← ESP32 image format (IROM/DRAM/IRAM segments) - │ - ▼ base64 → frontend - compileBoardProgram(boardId, base64) - │ - ▼ Esp32C3Simulator.loadFlashImage(base64) - parseMergedFlashImage() ← reads segments from the 4MB image - │ - ├── IROM segment → flash buffer (0x42000000) - ├── DROM segment → flash buffer (0x3C000000, alias) - ├── DRAM segment → dram buffer (0x3FC80000) - └── IRAM segment → iram buffer (0x4037C000) - │ - ▼ 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 +User (browser) + └── WebSocket (/ws/{client_id}) + └── simulation.py (FastAPI router) + └── EspLibManager + │ + 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 + │ + esp32_worker.py (subprocess) + │ + ctypes.CDLL(libqemu-riscv32.dll) + │ + Machine: esp32c3-picsimlab + CPU: RISC-V RV32IMC @ 160 MHz + │ + ┌──────────┴──────────┐ + 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.1–1.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 0–21** (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 0–21)**, 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 0–21) | **40** (GPIO 0–39) | +| 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) | +| GPIO32–39 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/ | diff --git a/frontend/public/components-metadata.json b/frontend/public/components-metadata.json index ffdd945..13eb40a 100644 --- a/frontend/public/components-metadata.json +++ b/frontend/public/components-metadata.json @@ -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", diff --git a/frontend/src/simulation/Esp32C3Simulator.ts b/frontend/src/simulation/Esp32C3Simulator.ts index 0a32fcc..2a26691 100644 --- a/frontend/src/simulation/Esp32C3Simulator.ts +++ b/frontend/src/simulation/Esp32C3Simulator.ts @@ -152,21 +152,8 @@ export class Esp32C3Simulator { */ private _periRegs = new Map(); - // ── 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 ab (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 ab (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(); 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) { diff --git a/frontend/src/store/useSimulatorStore.ts b/frontend/src/store/useSimulatorStore.ts index c732dda..cd5dec0 100644 --- a/frontend/src/store/useSimulatorStore.ts +++ b/frontend/src/store/useSimulatorStore.ts @@ -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([ '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([ '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 {