ESP32 real-hardware flash + boot validation (S2/S3, C3)
- Type: feature (validation — requires physical hardware) — Track A
- Status: backlog (blocked on hardware access; un-automatable in-harness)
- Opened: 2026-06-30 (split from feature-esp32-idf-xtensa, whose QEMU scope is done)
Scope
The ESP QEMU + GDB path is verified ([[feature-esp32-idf-xtensa]] done). What remains can only be done with a board on USB:
- Flash a pxx-built ESP32-S2/S3 (Xtensa) + ESP32-C3 (riscv32) image to real silicon.
- Confirm UART boot output matches the QEMU/x86-64 oracle.
- Exercise a live ISR / peripheral (timer/GPIO) on hardware (the QEMU path can't install a real vector).
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:
- Download mode. If esptool cannot open the chip, hold BOOT and tap RESET, then re-run. The script says so on failure.
- Port permissions.
/dev/ttyUSB0needs thedialoutgroup (log out and back in after adding yourself). - Which LED. GPIO2 is the devkit LED on most S3/C3 boards; several S2 boards use GPIO15 or none. A missing LED changes nothing about the diff.
--no-signals. Programs that pull the signal runtime must passESP_PXXFLAGS="--no-signals"; without it app_main panics on anecallin its prologue. The validation program does not need it.
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.