velxio/docs/wiki/esp32-cache-disable-runtime...

389 lines
15 KiB
Markdown

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