← board

ESP32 real-hardware flash + boot validation (S2/S3, C3)

Scope

The ESP QEMU + GDB path is verified ([[feature-esp32-idf-xtensa]] done). What remains can only be done with a board on USB:

Acceptance

A pxx ESP image boots on a physical board and its UART output matches the oracle; a basic peripheral/ISR fires on hardware. Requires the user's board + USB access.

Everything except the board is now in place (2026-08-02)

The three things that made this "un-automatable" are done; what is left is literally plugging a board in.

1. One command flashes and checks

tools/esp_flash.sh [--chip esp32s2|esp32s3|esp32c3] [--port /dev/ttyUSB0] <prog.pas>

tools/esp_flash.sh is the silicon twin of tools/esp_run.sh — same projects, same compiler flags, same output filter — so a program verified under qemu is re-checked on hardware with one word changed. It compiles the program for the chip, links it into the matching IDF project, writes flash with esptool, reads the serial console for N seconds, and (by default) diffs what the board said against the same program run on x86-64. It finds the port itself when exactly one is present and refuses to guess between several.

2. A program worth running first

test/test_esp_hw_validation.pas — everything it prints is pure computation, so board output must equal the x86-64 run byte for byte. It covers 64-bit arithmetic, a by-value record result, a 9-word argument list (the two xtensa ABI gaps closed this session), and managed strings; it toggles GPIO2 between lines so an LED or a scope shows it is really executing.

Verified under qemu against the oracle on esp32s3 (Xtensa/windowed) and esp32c3 (riscv32). The S2 has no qemu machine, so its first run IS the hardware run.

3. The S2 exists as a target at all

examples/esp32/hello-s2 is new. The S2 was never built for before — every project here was S3 or C3 — and it is half the user's hardware. It builds and links a pxx app_main with idf.py set-target esp32s2; esp_flash.sh --chip esp32s2 uses it as the harness.

Procedure for the board session

. ~/esp/esp-idf/export.sh
make compiler/pascal26

# per board, one line each:
tools/esp_flash.sh --chip esp32s3 test/test_esp_hw_validation.pas
tools/esp_flash.sh --chip esp32s2 test/test_esp_hw_validation.pas
tools/esp_flash.sh --chip esp32c3 test/test_esp_hw_validation.pas

Expected: the seven lines below, then esp_flash: OK — board output matches the x86-64 oracle, and an LED on GPIO2 that changed state a few times.

pxx esp hw validation
pow3^20 3486784401
int64min+1 -9223372036854775807
divmod -9223344366821 -675344
vec 7000011 4199 120
string ABCDEFGH 8
ok

Things that are expected to bite, so they do not read as failures:

The peripheral half is unblocked too (2026-08-02, later)

[[bug-esp-timer-callback-never-dispatched]] is FIXED — it was a 64-bit argument to a C function being passed with only its low word, so esp_timer's period arrived with a stale pointer in its high half. Both chips now run the periodic callback correctly, xtensa included. So the board session gets a second step:

ESP_PXXFLAGS="--no-signals -Fu$PWD/lib/rtl -Fu$PWD/lib/rtl/platform/esp" \
  tools/esp_flash.sh --chip esp32s3 --no-verify --seconds 15 \
  examples/esp32/timer-c3/main/main.pas

Expected:

PXX timer: started
PXX timer: tick=1 ... tick=5
PXX timer: done ticks=5 status=0

(--no-verify because the demo has no meaningful x86-64 run: it is all SDK calls.) That satisfies the acceptance's "a basic peripheral/ISR fires" — the callback is dispatched by the SDK's timer interrupt, which is the real thing on silicon and only emulated in qemu. make test-esp-idf guards the qemu side.

Still worth watching on hardware: qemu's systimer is not the S2/S3 silicon's, so a timer that works in emulation and not on the board would be new information.

2026-09-20 — two open claims attached here rather than filed separately (frankS)

Both belong to this ticket's existing acceptance rows and neither needs a page of its own. Attaching the evidence to the ticket that already called it.

1. The UART verdict now has an instrument, and the instrument is unverified

tools/esp_flash.sh gained --project, --no-flash, chip inference, a main.expected oracle and a --project-scoped IDF-log strip (7d4f7ea33), so the four NilPy demos have a route to a board with a real pass/hang verdict rather than a human reading scrollback. Established before hardware: all four are OK in qemu against pin v413.

WHAT IS NOT ESTABLISHED, AND IT IS THE WHOLE POINT OF THIS TICKET'S FIRST ACCEPTANCE ROW: whether the log filter matches what real silicon emits. The filter was written against qemu output and the ROM/bootloader preamble differs on a physical part. A filter tuned to qemu can strip a line the board prints and report a clean pass, which is the failure this ticket exists to catch, so do not read the qemu greens as evidence about it. The board is the only instrument; say which one, and which chip, beside the first green.

2. "A basic peripheral/ISR fires" IS A ROW THAT PASSES WHILE THE CONTRACT IS VIOLATED

The acceptance above asks that an ISR fire. Firing is necessary and is not sufficient, and the gap is not cosmetic. The callback-ABI work has three consumers; frankb-8e took two and deliberately left ESP interrupts, because the contract there genuinely differs: boxing ALLOCATES, and an allocation inside an interrupt handler is a latent crash with good latency numbers. It does not fault on the tick that allocates. It faults later, somewhere else, on a heap another context was using — and every timing number collected in between looks correct, because the timing IS correct.

So a hardware run that prints tick=1 … tick=5 and done ticks=5 status=0 satisfies the row as written and says nothing about the contract. This is the expected-value collision in this ticket's own acceptance: if the machinery did nothing about allocation at all, would this row still pass? Yes, every time.

The condition that springs it is any callback path reaching an ISR context through a boxed value — not any particular demo, which is why this is stated as a mechanism rather than as a row that fires today. What would settle it is an assertion that observes the allocator rather than the output: a run with the ESP heap instrumented across the handler, asserting the allocation count is UNCHANGED across N ticks. That is a different assertion class from expect_same and cannot be reached by strengthening the tick comparison, in the same way a leak cannot fail a value check.

Ownership: frankS holds this question and cannot close it — there is no board on this box, so it is the same honest kind of open as row 1. Not going near the ESP consumer of the callback ABI until there is silicon; no collision with 8e in either direction, confirmed through the coordinator.