← board

ESP32 peripheral callback API (timer / GPIO / ADC) — the user-facing "interrupt"

Why

From a user's perspective a timer / ADC / GPIO callback is "an interrupt", even though at the ISA level it is a plain C callback dispatched by FreeRTOS (which already did the window spill / context save before calling it). Users want:

Timer1.OnElapsed := @MyHandler;        // or  Gpio.OnEdge := @h;  /  Adc.OnReady := @h;

They do not want to write esp_intr_alloc(source, flags, @h, arg, @handle) + cdecl; iram; boilerplate, and they must not reach for the interrupt; keyword — that is bare-metal raw-vector plumbing (see feature-esp32-isr-iram; on IDF it actively breaks, because registering a raw-vector proc with esp_intr_alloc double-saves context and rfes instead of rets → crash).

This ticket builds the high-level layer that hides the SDK call and the iram; detail behind an event-style surface.

Layering (keep the dark CPU magic hidden)

Layer Who Surface
App user "run my code when the peripheral fires" Timer1.OnElapsed := @h
This library wraps the SDK esp_intr_alloc / esp_timer + callback trampoline marked iram;
Compiler (Track A) plumbing iram;, @isr, extern C — all done

Scope

Non-goals

Open questions

Acceptance (slice 1 — timer)

Log

Moved to blocked/ (2026-07-20, Track B sweep)

Slice 1 (esptimer + demo) is written but unverified, and the acceptance for every remaining slice is "it runs on the device". Neither ESP32 hardware nor a qemu/IDF runner is available to this lane, so nothing here can be honestly completed or honestly closed — it would be code nobody has ever executed.

Blocked on an external constraint, not on another ticket, which is what blocked/ is for (same category as [[feature-port-macos]]). What would unblock it: a C3/S3 board or a working qemu-esp32 harness. A host-side compile-only smoke (riscv32 .o + readelf -sW import assertions) is possible without hardware and would be worth doing, but it proves linkage, not behaviour — do not mistake it for acceptance.

Back to blocked/ 2026-07-31 (Track B sweep) — still no way to RUN it

Re-checked rather than assumed: this box has no qemu-system-riscv32 and no qemu-system-xtensa, IDF_PATH is unset, and there is no board. An ESP-IDF checkout exists at ~/esp/esp-idf, which is enough to LINK and nothing more.

Every remaining item here — the qemu acceptance run, dropping the interim iram;, and the GPIO and ADC slices — has "it runs on the target" as its acceptance. Writing more of it would add code nobody has ever executed, which is the one thing this ticket already says not to do.

Tagged for later testing. When a C3/S3 board or a working qemu-IDF harness appears, the first action is the slice-1 acceptance run that was never done, not more slices. The existing compile-only smoke in lib-test (riscv32 .o + readelf -sW import assertions) stays where it is — it proves linkage, and it must not be read as acceptance. Nothing further from this ticket goes into the regression suite until something can execute it.

2026-08-30 (pxx-b) — THE BLOCK WAS FALSE. There is a working ESP QEMU on this box.

Re-checked before taking the ticket, and the answer is the opposite of what the last two sweeps recorded. Both Espressif QEMU forks are installed, with the toolchains, and they run:

~/.espressif/tools/qemu-riscv32/esp_develop_9.2.2_20250817/qemu/bin/qemu-system-riscv32
    QEMU emulator version 9.2.2 (esp_develop_9.2.2_20250817)
    machines: esp32c3   Espressif ESP32-C3 machine
~/.espressif/tools/qemu-xtensa/esp_develop_9.2.2_20250817/qemu/bin/qemu-system-xtensa
    machines: esp32     Espressif ESP32 machine
              esp32s3   Espressif ESP32S3 machine

Plus riscv32-esp-elf, xtensa-esp-elf, both gdbs, openocd-esp32, and an ESP-IDF v6.0.1 checkout at ~/esp/esp-idf with export.sh.

Why two sweeps missed it, and this is the reusable part. Both checks asked command -v qemu-system-riscv32 (the 2026-07-31 entry above says so in as many words: "this box has no qemu-system-riscv32"). That is true and it is not the question. IDF-managed tools are installed off PATH by design, under ~/.espressif/tools/, and only reach PATH when . $IDF_PATH/export.sh is sourced. IDF_PATH is unset in a fresh shell, so the probe was structurally incapable of seeing an installed runner — it tested the shell's environment and reported it as a property of the machine.

Both sweeps wrote "re-checked rather than assumed", and both were honest: the command was really run and really returned nothing. Running a check is not the same as running a check that could have come back positive. The instrument had no true arm — the same failure this repo keeps meeting from other directions (a guard that passes on the state it rejects; a probe whose two arms are indistinguishable). The cost was a ticket family parked ~5 weeks on a fact that was never true.

For the next reader: probe for a tool by looking where its installer puts it, not by asking whether the current shell happens to have it. For IDF that is ls ~/.espressif/tools/, or sourcing export.sh first.

Also stale in this file: the README's "KNOWN BROKEN under qemu right now" points at bug-esp-idf-heap-linux-mmap-ecall, which the 2026-07-16 entry above already records as DONE.

Same claim appears in feature-dns-esp-backend, feature-pal-esp-posix-fd-semantics, and feature-esp-hardware-flash-validation — none re-verified by me beyond noting they cite the same premise. The hardware-flash one is genuinely blocked (no board: no /dev/ttyUSB* or /dev/ttyACM*); QEMU does not unblock flashing to real silicon.

Next action per this ticket's own instruction is the slice-1 acceptance run that was never done — NOT more slices.

2026-08-30 (pxx-b) — SLICE 1 ACCEPTANCE PASSED. First execution since 2026-07-11.

examples/esp32/timer-c3 built under ESP-IDF v6.0.1 and booted on the emulated C3. Serial:

I (414) main_task: Calling app_main()
PXX timer: started
PXX timer: tick=1
PXX timer: tick=2
PXX timer: tick=3
PXX timer: tick=4
PXX timer: tick=5
PXX timer: done ticks=5 status=0

That satisfies this ticket's stated acceptance for slice 1: a periodic timer configured through the library's event surface, the callback firing and counting, matching the expected sequence under qemu IDF. The app-code check holds too -- main.pas names only TimerInit / OnElapsed / TimerStartPeriodicMs / TimerStop / TimerDone; no esp_timer_create, no esp_intr_alloc, no iram;. The interim iram; is already gone (both Track A blockers landed).

status is a bitmask the app builds itself (1 = start failed, 2 = fewer than five ticks, 4 = stop failed), so status=0 is not reachable by a dead timer. That is the assertion; the exit code is not.

Made repeatable: ./build.sh qemu-assert — drives qemu directly with serial to a file, asserts the exact seven-line sequence, exits 77 with an explanation naming the off-PATH trap when the emulator is absent. NOT wired into lib-test: it needs export.sh sourced and takes minutes. Opt-in, documented in the README.

Both arms verified (pass on a real boot; skip-with-reason on a bogus QEMU_BIN). Worth recording that the first two cuts of the script FAILED, and both failed as an EMPTY CAPTURE rather than an error:

  1. no flash image — idf.py set-target wipes build/, and my regeneration line was a stub that did nothing. qemu booted with no image and wrote an empty log.
  2. an all-zero efuse block — reports chip revision v0.0 against an image requiring >= v0.3, so the bootloader rejected it and rebooted forever. 306KB of boot attempts, zero app lines.

Both are indistinguishable from "the program ran and printed nothing" if you only look at the assertion result. The efuse defaults now come from IDF's own QEMU_TARGETS table rather than a pasted constant. set-target is now guarded so a re-run does not trigger a from-scratch 988-target rebuild.

Boundary, stated precisely and written into build.sh and the README as well as here: a green run witnesses a genuine esp_timer callback dispatched by FreeRTOS through the library's event surface on EMULATED silicon. It does not witness real silicon, timing, the physical peripheral, anything analog, or anything xtensa. Flashing to hardware remains genuinely blocked — no /dev/ttyUSB* or /dev/ttyACM* — so feature-esp-hardware-flash-validation is NOT unblocked by this.

Remaining in this ticket: GPIO (slice 2) and ADC (slice 3), both now genuinely attemptable rather than blocked. Slice 1 is done and, for the first time, executed.

2026-08-30 (pxx-b) — SLICE 2 (GPIO) CANNOT BE WITNESSED HERE. Measured, with a control.

Probed before writing any of it, because the ticket forbids adding code nobody has executed and I wanted to know whether an acceptance was reachable at all. examples/esp32/gpio-c3 is that probe, landed with its result:

PROBE: gpio_config rc=0
PROBE: install_isr_service rc=0
PROBE: isr_handler_add rc=0
PROBE: set 1 -> read 0        (x5 toggles)
PROBE: pullup-input pin4 cfg rc=0 reads 0 (1 on real silicon)
PROBE: edges=0
PROBE: VERDICT qemu-delivers-NO-gpio-edges

All three SDK calls return rc=0 and nothing happens. A probe that checked only return codes concludes GPIO works and writes a library against it.

The control arm is what makes this a diagnosis rather than an observation. The first cut only toggled an INPUT_OUTPUT pin and counted ISR entries, and edges=0 is equally consistent with two different worlds: "GPIO works, the interrupt model is missing" and "GPIO is inert". So the probe also configures a SECOND pin as INPUT with a pull-up, which on real silicon reads 1 with nothing attached. It reads 0.

So QEMU's esp32c3 does not model the GPIO input path at all. The missing edges follow from that; they are not a separate gap. No choice of edge type, ISR flag or pin works around it — which is the day this control arm saves.

Disposition: slice 2 needs a board. Writing the GPIO unit now would produce exactly the unexecuted code this ticket has twice refused to accumulate. Note the difference from the block this same ticket carried for five weeks: THAT one was a probe with no true arm and was never true; this one is measured and has a control. Both say "blocked"; only one of them earned it.

./build.sh qemu-assert in gpio-c3 asserts the CURRENT behaviour, so it fails the day QEMU gains a GPIO model or the day someone runs it on hardware — where edges>0 is the right answer and slice 2 becomes acceptable. The failure text says so, to stop the next reader "fixing" it by updating the expectation.

Slice 3 (ADC) is NOT measured. Do not infer it from this — probe it the same way. Reasoning from the GPIO result is the move this ticket keeps punishing.

State of the ticket: slice 1 done and executed; slices 2 and 3 blocked on hardware, one measured and one unknown.