← board

esp_timer callbacks are not dispatched — and one extra statement fixes it

Repro — two files that differ by ONE line

devdocs/progress/fixtures/bug-esp-timer-callback-never-dispatched-A.pas and ...-C.pas. The whole diff is a statement in app_main that only prints the callback's address:

  h := MakeTimer;
+ esp_rom_printf('cb=%x'#10, Integer(@OnTick));      { C only }
  rc := esp_timer_start_periodic(h, 100000);

Run either with:

ESP_RUN_TIMEOUT=25 ESP_PXXFLAGS="--no-signals" \
  tools/esp_run.sh --chip esp32c3 devdocs/progress/fixtures/bug-...-A.pas
A (as written) C (one extra printf)
esp_timer_create rc=0 rc=0
esp_timer_start_periodic rc=0 rc=0
ticks after ~3 s at 100 ms 0 30

Deterministic — two runs each, same numbers. Same on esp32s3 (xtensa, windowed): the stock examples/esp32/timer-c3 demo reports done ticks=0 status=2 on both chips.

What has been ruled out, by measurement

Two more hypotheses killed (2026-08-02, same session)

Sharper: the SDK is armed CORRECTLY, and only code BEFORE the start helps

Two more measurements narrow this a lot.

That combination — correct period, timer active, dispatch dead unless something happens before the start — is what the debugger should be pointed at.

What that leaves

The behaviour flips on an unrelated statement, so it is layout-sensitive: image layout, not logic. Something in the image that MOVES when the object's code size changes and that the SDK depends on — the alarm interrupt's delivery being the visible casualty. Candidates not yet excluded: a symbol our object defines that the linker prefers over the SDK's; a cache/IRAM boundary the 16-byte shift crosses; memory written by our startup that belongs to IDF.

Next measurement: attach a debugger rather than perturb the source further — esp_run.sh's qemu with the IDF gdbstub, breakpoint on the esp_timer dispatch path, and see whether the alarm interrupt arrives at all in the failing build. Perturbation experiments have gone as far as they can: every source-level hypothesis above died, and the remaining ones (something the image's layout moves under the SDK) need to be watched, not guessed.

Note for the reader who assumes this is a regression

Unverified either way. The demo's own log claims tick=1..5 on esp32c3 when it landed (2026-07), and ESP-IDF has been upgraded to v6.0.1 since. Whether the compiler regressed or the SDK moved is an open question — and note that a bisect would be treacherous here: with the outcome flipping on 16 bytes of unrelated code, an old commit that "works" may only be lucky.

Acceptance

FIXED 2026-08-02 — a 64-bit argument to a C function was passed HALF

The perturbation experiments had gone as far as they could and every one of them pointed away from the truth. gdb on the qemu gdbstub found it in three breakpoints.

Breakpoint 1, esp_timer_start_periodic (timer=0x3fc94d14,
                                        period_us=4596191047733839520)

period_us arrives as garbage whose high 32 bits are 0x3FC8A2AA — a DRAM address. The low half is the 100000 that was asked for. pxx pushed ONE word for the 64-bit argument and never wrote the high one, so the callee read whatever the previous call had left in that register. IDF then computed alarm = now + period some 145,000 years out and the ISR, correctly, never fired.

That explains every observation at once, including the ones that looked supernatural: the outcome flipped on unrelated code before the call because that code decided what was left in the stale register; code after the call could not help; an uncalled procedure could not help.

Root cause in one line

Both ir_codegen_riscv32.inc and ir_codegen_xtensa.inc gated their Int64/UInt64 and float argument marshalling on not ProcExternal[procIdx] — so for an EXTERNAL callee, the two-word push was skipped and the argument fell through to the generic one-word case. Every external C function taking an Int64, QWord or Double was affected on both ESP backends.

The two ABIs differ, and both were measured, not assumed

An earlier A/B experiment had "disproved" the alignment theory by inserting a dummy word by hand; the gdb register dump shows why that was misleading — on riscv32 the aligned layout is the WRONG one, so the padded variant failed for a second, different reason.

Verified

Left open, deliberately

A pxx routine called from C with a 64-bit parameter still uses the internal packed convention on its callee side, so a C caller and a pxx callee would disagree on xtensa. Nothing does that today (app_main and the esp_timer callback take no 64-bit arguments), and the honest fix is to drive the callee spill from an explicit cdecl marker rather than guess. Filed as [[bug-a-pxx-callee-uses-internal-abi-for-64bit-params-called-from-c]].

Log