# ESP32 — Arduino runtime crashes on `delay()` / `pinMode()` (issue #129) > **Symptom seen by users**: an LED wired to a normal output GPIO never lights > or blinks; voltage is measurable at the pin but no toggling. The user sees > nothing in the serial monitor and assumes the board model (e.g. ESP32-CAM) > is broken. > > **Real root cause**: the firmware crashes silently inside the lcgamboa QEMU > machine before reaching the first `digitalWrite()`, because Arduino's > runtime functions live in cached flash regions and the machine raises an > illegal-cache-access trap during the cache-disable windows that the > Espressif IDF opens at boot. > > **Reproduces on every ESP32 board model in Velxio** (ESP32 DevKit, ESP32-CAM, > Wemos LOLIN32, etc.) — the issue is not board-specific. --- ## Table of Contents 1. [Background — what cache-disable means on real ESP32](#1-background) 2. [Symptom and how it surfaces in Velxio](#2-symptom) 3. [Why the user thinks ESP32-CAM is broken](#3-why-it-looks-board-specific) 4. [Reproduction in the test suite](#4-reproduction-in-the-test-suite) 5. [Root cause inside lcgamboa QEMU](#5-root-cause-inside-lcgamboa-qemu) 6. [Workaround for end users — cache-safe sketch pattern](#6-workaround-for-end-users) 7. [Fix design — soft-cache mode in `esp32_dport.c`](#7-fix-design) 8. [Building the patched DLL](#8-building-the-patched-dll) 9. [Regression test](#9-regression-test) 10. [Future work](#10-future-work) --- ## 1. Background On real ESP32 silicon the Xtensa cores fetch instructions from a small cached window into the SPI flash chip. The cache is *transparently* invalidated whenever the chip needs to: - write or erase flash sectors, - run WiFi/BT calibration code that re-times the flash bus, - enter critical sections that may corrupt cached lines. During those windows the cache reads return undefined data, and the IDF arms a fault trap (`cache_ill_trap_en`) that raises a panic if any code or data in IROM/DROM is accessed. On hardware those windows are sub-millisecond and the IDF carefully places the relevant code paths in IRAM/DRAM so nothing trips the trap. The lcgamboa QEMU fork models this faithfully: `hw/misc/esp32_dport.c` exposes the cache control registers and raises `cache_ill_irq` whenever the firmware reads from a cache-disabled region while the trap is armed. ## 2. Symptom A user writes the canonical Arduino blink sketch and runs it on **any** ESP32 variant in Velxio: ```cpp #define LED_PIN 13 void setup() { pinMode(LED_PIN, OUTPUT); } void loop() { digitalWrite(LED_PIN, HIGH); delay(1000); digitalWrite(LED_PIN, LOW); delay(1000); } ``` What happens at runtime: 1. The IDF bootloader runs (cache-safe, lives in IRAM/ROM). 2. The user's `setup()` is called. 3. `pinMode(13, OUTPUT)` jumps into Arduino-core code that lives in the `_TEXT` section of cached flash (IROM). 4. WiFi/BT init schedules a calibration tick on core 1 that opens a cache-disable window. 5. While the window is open, the timer ISR for the FreeRTOS scheduler fires and tries to fetch instructions from IROM. Cache disabled → illegal access → `cache_ill_irq` → IDF panic handler → CPU halts. 6. The GPIO direction was never written. The pin sits in default high-impedance INPUT mode — which is why the user can read voltage at it (the LED's pull-up or external supply leaks through) but nothing ever toggles. Crucially, **the panic message does not reach the user**. The serial output handler is also in IROM, so the panic dies before it can print. The user sees a frozen-but-otherwise-intact board. ## 3. Why it looks board-specific The user reports it on the ESP32-CAM because that's the board they were trying. The same crash happens on: - `esp32` (DevKit-C v4) - `esp32-cam` - `wemos-lolin32-lite` - `esp32-s3` - — anywhere the lcgamboa Xtensa machine runs Arduino-runtime sketches. The frontend collapses every Xtensa variant to `board: 'esp32'` before sending `start_esp32` to the backend (`Esp32Bridge.toQemuBoardType()`), so the actual QEMU machine that runs is `esp32-picsimlab` regardless of which board the user picked. There is no ESP32-CAM-specific code path that could be at fault. ## 4. Reproduction in the test suite `test/esp32_cam/test_esp32_cam_blink.py` carries six layers, each designed to fail at a different layer of the stack so a future contributor knows where the regression lives: | Layer | What it checks | Fails when | |------:|----------------|------------| | 1 | The user-faithful sketch source matches issue #129 verbatim | someone "fixes" the sketch instead of the runtime | | 2 | `esp32-cam` is registered in `BoardKind`, has FQBN, exposes pin '13' | board metadata regresses | | 3 | `boardPinToNumber('esp32-cam', '13') === 13` (Python mirror) | frontend pin mapping regresses | | 4 | The simulation WS route forwards `board='esp32-cam'` to the manager unchanged | backend silently drops the variant | | 5 | An IRAM-safe blink on **GPIO13** toggles at least 3 times in real QEMU | GPIO routing through the bridge breaks | | 6 | A live uvicorn backend emits `gpio_change pin=13` over the WebSocket | the WS layer drops events | Layer 5 passing is the smoking gun: GPIO13 routing is healthy. The user's sketch fails not because of pin tracking but because the Arduino runtime crashes the firmware before it ever reaches the toggle. ## 5. Root cause inside lcgamboa QEMU `hw/misc/esp32_dport.c:281` decides whether the cached IROM/DROM regions are mapped: ```c static void esp32_cache_state_update(Esp32CacheState* cs) { bool cache_enabled = FIELD_EX32(cs->cache_ctrl_reg, DPORT_PRO_CACHE_CTRL, CACHE_ENA) != 0; bool drom0_enabled = cache_enabled && FIELD_EX32(cs->cache_ctrl1_reg, DPORT_PRO_CACHE_CTRL1, MASK_DROM0) == 0; /* ... */ memory_region_set_enabled(&cs->drom0.mem, drom0_enabled); /* same for iram0 / dram1 */ } ``` When the firmware writes 0 to `CACHE_ENA`, the memory regions are disabled and any subsequent fetch from them is routed to `esp32_cache_ill_read` (line 320), which raises the trap IRQ when armed: ```c if (crs->illegal_access_trap_en) { crs->illegal_access_status = true; qemu_irq_raise(crs->cache->dport->cache_ill_irq); } ``` In the educational simulator we never have a real flash chip whose state needs protecting during a programming cycle, so the cache-disable window serves no practical purpose — but it kills every Arduino sketch that uses `delay()`, `Serial.print()`, `pinMode()` or `digitalWrite()`. ## 6. Workaround for end users Until the patched DLL is rolled out, ship sketches that stay in IRAM/ROM/DRAM during the WiFi/BT init window: ```cpp // IRAM-safe blink for ESP32 in Velxio (lcgamboa QEMU) #define GPIO_OUT_W1TS (*((volatile uint32_t*)0x3FF44008)) // set HIGH #define GPIO_OUT_W1TC (*((volatile uint32_t*)0x3FF4400C)) // set LOW #define GPIO_ENABLE_W1TS (*((volatile uint32_t*)0x3FF44020)) // enable output #define LED_BIT (1u << 13) // GPIO13 extern "C" { void ets_delay_us(uint32_t us); int esp_rom_printf(const char* fmt, ...); } void IRAM_ATTR setup() { GPIO_ENABLE_W1TS = LED_BIT; for (int i = 0; i < 1000; i++) { GPIO_OUT_W1TS = LED_BIT; ets_delay_us(500000); GPIO_OUT_W1TC = LED_BIT; ets_delay_us(500000); } } void IRAM_ATTR loop() { ets_delay_us(1000000); } ``` Rules: - Tag every function with `IRAM_ATTR`. - Tag every string literal with `DRAM_ATTR`. - Use `esp_rom_printf` instead of `Serial`. - Use `ets_delay_us` instead of `delay`/`vTaskDelay`. - Touch GPIOs via the `0x3FF44xxx` register window, not Arduino helpers. A reference implementation lives at `test/esp32_cam/sketches/blink_pin13_iram/blink_pin13_iram.ino`. ## 7. Fix design The patch lives behind a compile-time flag so upstream lcgamboa stays strictly faithful to silicon and only the Velxio build relaxes the rule. **File**: `third-party/qemu-lcgamboa/hw/misc/esp32_dport.c` **Change A** — keep the cache regions mapped even when firmware writes 0 to `CACHE_ENA`. Existing `esp32_cache_data_sync` keeps them coherent with the underlying flash block on every transition, so this does not introduce stale-cache reads: ```c static void esp32_cache_state_update(Esp32CacheState* cs) { #ifdef ESP32_PICSIMLAB_SOFT_CACHE /* velxio: there is no real SPI flash chip whose state needs * protecting during a cache-disable window, so we keep the * cached regions mapped and let esp32_cache_data_sync (called on * every CACHE_ENA transition by esp32_dport_write) keep them * coherent. This eliminates the panic users see when Arduino * runtime functions (delay/pinMode/digitalWrite/Serial) get * touched while CACHE_ENA briefly drops to 0 during WiFi/BT init. * See docs/wiki/esp32-cache-disable-runtime-crash.md. */ bool cache_enabled = true; #else bool cache_enabled = FIELD_EX32(cs->cache_ctrl_reg, DPORT_PRO_CACHE_CTRL, CACHE_ENA) != 0; #endif /* ...rest unchanged */ } ``` **Change B** — suppress the illegal-access IRQ. With Change A in place the firmware should never legitimately hit a cache-disabled region, but some IDF builds arm the trap aggressively and we want to be robust: ```c static uint64_t esp32_cache_ill_read(void *opaque, hwaddr addr, unsigned int size) { Esp32CacheRegionState *crs = (Esp32CacheRegionState*) opaque; uint32_t ill_data[] = { crs->illegal_access_retval, crs->illegal_access_retval }; uint32_t result; memcpy(&result, ((uint8_t*) ill_data) + (addr % 4), size); #ifndef ESP32_PICSIMLAB_SOFT_CACHE if (crs->illegal_access_trap_en) { crs->illegal_access_status = true; qemu_irq_raise(crs->cache->dport->cache_ill_irq); } #endif return result; } ``` **Build flag**: add `-DESP32_PICSIMLAB_SOFT_CACHE=1` to the cflags in `build_libqemu-esp32-win.sh` / `build_libqemu-esp32.sh`. **Why not patch the cache writes themselves**: the IDF reads back the register to confirm the toggle. Faking the bit would diverge from `cache_ctrl_reg` and could confuse `esp32_cache_data_sync`'s "transition" detection on the next write. **Why not just suppress the trap IRQ alone**: when the memory region is disabled, instruction fetches from IROM return the fill value (`0xCECECECE`) which decodes as an illegal Xtensa instruction → exception → panic via a different path. We have to keep the regions mapped. ## 8. Building the patched libraries The patch is already in the source tree (`third-party/qemu-lcgamboa/hw/misc/esp32_dport.c`) and the `-DESP32_PICSIMLAB_SOFT_CACHE=1` flag is wired into both `build_libqemu-esp32.sh` (Linux/macOS) and `build_libqemu-esp32-win.sh` (Windows MSYS2 MINGW64). ### 8.1 Recommended — let the CI publish the release `third-party/qemu-lcgamboa/.github/workflows/build-libqemu.yml` builds the patched libraries for **every host Velxio supports natively** and uploads them to the `qemu-prebuilt` release of the velxio repo: | Asset name in release | Host | |---|---| | `libqemu-xtensa-amd64.so` / `libqemu-riscv32-amd64.so` | Linux x86_64 | | `libqemu-xtensa-arm64.so` / `libqemu-riscv32-arm64.so` | Linux ARM64 | | `libqemu-xtensa-windows-amd64.dll` / `libqemu-riscv32-windows-amd64.dll` | Windows x86_64 | | `libqemu-xtensa-macos-arm64.dylib` / `libqemu-riscv32-macos-arm64.dylib` | macOS Apple Silicon (Intel Macs not supported) | | `esp32-v3-rom.bin`, `esp32-v3-rom-app.bin`, `esp32c3-rom.bin` | All hosts (arch-independent) | Trigger by pushing to the `picsimlab-esp32` branch of `davidmonterocrespo24/qemu-lcgamboa`, or run the workflow manually from the GitHub UI (`workflow_dispatch`). Total CI time ≈30 min. Once the release is updated, `Dockerfile.standalone` picks the arch-specific `.so` automatically (it already uses `${TARGETARCH}`), and the native installers below pull the right binary. ### 8.2 Native install — pick the right asset per host Drop the renamed file into `backend/app/services/`: | Host | Asset to download | Rename to | |---|---|---| | Linux x86_64 | `libqemu-xtensa-amd64.so` | `libqemu-xtensa.so` | | Linux ARM64 | `libqemu-xtensa-arm64.so` | `libqemu-xtensa.so` | | Windows x86_64 | `libqemu-xtensa-windows-amd64.dll`| `libqemu-xtensa.dll` | | macOS Apple Silicon| `libqemu-xtensa-macos-arm64.dylib`| `libqemu-xtensa.dylib` | Repeat for the matching `libqemu-riscv32-*` file. The backend's loader (`backend/app/services/esp32_lib_manager.py`) picks the right file extension automatically based on `sys.platform`. ### 8.3 Building locally (when you can't wait for CI) **Linux / macOS:** ```bash cd third-party/qemu-lcgamboa mkdir build-out && cd build-out bash ../build_libqemu-esp32.sh xtensa-softmmu,riscv32-softmmu .. # Linux: cp libqemu-xtensa.so ../../../backend/app/services/ # macOS: cp libqemu-xtensa.so ../../../backend/app/services/libqemu-xtensa.dylib ``` Required Debian/Ubuntu packages: ``` build-essential ninja-build pkg-config python3 python3-venv git libglib2.0-dev libpixman-1-dev libslirp-dev libgcrypt20-dev libfdt-dev ``` Required macOS Homebrew packages: ``` gnu-sed coreutils ninja pkg-config meson glib pixman libgcrypt libslirp ``` (Make sure `gnu-sed`'s `gnubin` is on `PATH` before `/usr/bin` so the script's `sed -i …` calls resolve to GNU sed, not BSD sed.) **Windows (MSYS2 MINGW64):** ```bash cd /e/Hardware/velxio\ release/third-party/qemu-lcgamboa bash build_libqemu-esp32-win.sh cp build/libqemu-xtensa.dll ../../backend/app/services/ cp build/libqemu-riscv32.dll ../../backend/app/services/ ``` Required `pacman` packages are listed in the build script's header. ## 9. Regression test After deploying the new DLL, the user-faithful firmware should toggle GPIO13. Drive the verification through the pytest harness: ```bash # Compile the user sketch verbatim (already tracked in the repo) arduino-cli compile \ --fqbn esp32:esp32:esp32cam:FlashMode=dio \ --output-dir test/esp32_cam/out_blink_pin13 \ test/esp32_cam/sketches/blink_pin13 python -m esptool --chip esp32 merge-bin --pad-to-size 4MB \ -o test/esp32_cam/binaries/blink_pin13.merged.bin \ --flash-mode dio --flash-size 4MB \ 0x1000 test/esp32_cam/out_blink_pin13/blink_pin13.ino.bootloader.bin \ 0x8000 test/esp32_cam/out_blink_pin13/blink_pin13.ino.partitions.bin \ 0x10000 test/esp32_cam/out_blink_pin13/blink_pin13.ino.bin ``` Then add a once-per-process test that boots the user firmware and asserts ≥3 GPIO13 transitions; the existing IRAM control class needs to remain the only QEMU instance per pytest run because lcgamboa keeps singleton aio_context state. ## 10. Future work - Apply the same `ESP32_PICSIMLAB_SOFT_CACHE` flag to `hw/misc/esp32c3_cache.c` for the RISC-V build. - Detect at runtime whether the firmware ever writes `esp_wifi_init` and only relax the cache when WiFi is not initialised (partial fix that preserves WiFi-flow fidelity). - Upstream the patch as a `--enable-soft-cache` configure option to lcgamboa so the Velxio fork can rebase cleanly.