ESP32: Compiler-Directed ISR and IRAM Support
- Type: feature
- Status: DONE 2026-06-21 (
iram;done 2026-06-18;interrupt;Call0 done both ISAs 2026-06-21; windowedinterrupt;ruled out-of-scope by design with a sharpened compile-error — see Decision below). User-facing peripheral callbacks split tofeature-esp-peripheral-callback-api(Track B). - Owner: —
- Opened: 2026-06-14 (from ESP32 ISR analysis)
Decision 2026-06-21 — windowed interrupt; is out of scope BY DESIGN (not a gap)
interrupt; is a raw hardware-vector directive: the proc is entered directly
by the CPU as a vector, saves its own context, and returns via the
exception-return insn (riscv mret, xtensa rfe). Two independent axes decide
where that is valid:
| Call0 | Windowed | |
|---|---|---|
| Bare-metal (you install the vector) | interrupt; → DONE (rfe/mret) |
does not boot — no vecbase / window-overflow handlers (compiler.pas already errors --esp-profile=bare + windowed) |
| IDF + FreeRTOS (OS owns the vector + dispatcher) | use iram;+esp_intr_alloc |
use iram;+esp_intr_alloc; FreeRTOS _xt_context_save spills windows once, then calls your fn normally |
The windowed + raw-vector cell has no valid configuration: bare-metal
xtensa can't boot windowed, and under IDF you never install a raw vector — you
register a normal callback via esp_intr_alloc, and FreeRTOS does the window
spill for you. A windowed raw interrupt; would have to emit a Tensilica-style
window-spill loop inside the ISR — duplicating the FreeRTOS dispatcher,
untestable here (bare windowed doesn't boot; IDF wouldn't use it), and a
loop-in-ISR hazard. Rejected.
Resolution:
- Windowed
interrupt;stays a compile error; sharpen the message to route to the two real paths (bare → Call0interrupt;; IDF →iram;+esp_intr_alloc). (This is the only remaining code change; then this ticket → done.) - The user-facing "interrupt" (a timer/ADC/GPIO callback, which most users
mean when they say "interrupt") is a library concern, not this keyword →
filed as
feature-esp-peripheral-callback-api(Track B). Theinterrupt;keyword serves only the bare-metal raw-vector author. interrupt;(Call0) on riscv32 + xtensa is DONE and is the complete, correct scope for this keyword.
Status 2026-06-18 — iram; DONE (both ISAs)
procedure foo; iram; places the routine's machine code in a new ELF
.iram1.text section (the IDF linker script routes it to internal IRAM).
Validated: readelf -S shows .iram1.text PROGBITS / AX (ALLOC|EXECINSTR)
on esp32s3 (xtensa) + esp32c3 (riscv32); test_esp_iram runs under qemu ==
x86-64 oracle (S/ABC/ABCDE/E). make test + cross-bootstrap byte-identical.
Design (single Code[] buffer, low-risk): .text = the full Code[] verbatim
(each iram proc keeps a dead twin there so flash PC-relative call offsets
computed at emit time stay valid); .iram1.text = a live duplicate of each
iram proc's contiguous byte-range (internal relative branches + xtensa l32r
literal pools survive because the whole proc moves as a unit). Cross-section
calls (flash↔iram) can't stay PC-relative, so EmitCallProc lowers them to an
indirect literal-slot call (same shape as an external call) recorded as an
IramCallFix and relocated R_*_32 against the callee proc's own local
symbol. The extended writer (writeELF32RelIram, gated on any iram proc so the
proven non-iram path is byte-identical) partitions relocations by which text
section their CodePos lands in and emits proc symbols with st_shndx =
.iram1.text for iram procs. On non-ESP targets iram; is an accepted no-op
(same source builds as the x86-64 oracle).
Status 2026-06-21 — interrupt; DONE on riscv32 (esp32c3); structurally verified
procedure foo; interrupt; now compiles a raw hardware trap handler on riscv32
(9ab4304). Prologue saves the interrupted caller-saved context (t0-t6, a0-a7;
ra/s0 via the normal frame, 64-byte save area above the frame), the body runs,
the epilogue restores that context and returns via mret (added rv32_mret).
interrupt implies iram, so the handler lands in .iram1.text (existing
placement + cross-section call lowering). Gated on ProcIsInterrupt →
non-interrupt riscv codegen byte-identical, self-host unchanged.
Verified structurally (test/test_esp_interrupt.pas + --emit-obj,
riscv32-esp-elf-{readelf,objdump}): MyIsr sits in .iram1.text; the disasm
shows the full t0-t6/a0-a7 save, the normal ra/s0 frame, a working cross-section
indirect call into flash (esp_rom_printf), the mirrored restore, and mret.
Remaining:
- Live trap validation. Installing the handler in
mtvec+ triggering a real trap isn't expressible in PXX yet — the rv32 inline-asm dialect has no CSR ops (csrw mtvec,csrrs mstatus) and@isr/ProcAddrFix(below) still errors, so nothing in PXX can point the vector at the handler. Options: add CSR ops +mret/wfitoasmtext_rv32.incfor a self-contained bare trap test; or fixProcAddrFixso the handler address can be handed to a setup routine; or run on real esp32c3 hardware. (This is the "not qemu-validatable without a vector table" the deferral noted.) - xtensa
interrupt;(Call0) — DONE 2026-06-21; structurally verified.procedure foo; interrupt;now compiles a raw level-1 exception handler on xtensa Call0. Prologue saves the interrupted caller-saved a-regs the body clobbers (a2-a8, a10-a13) in a 48-byte area above the normal Call0 frame (a0/a15 via that frame; a9/a14 never touched by codegen); the body runs; the epilogue restores them and returns viarfe(general level-1, PC<-EPC1, PS.EXCM cleared) — addedxtensa_rfe($003000, oracle-verified) toxtensaenc.inc. Gated onProcIsInterrupt(inline check; symtab can't call backend helpers) → non-interrupt xtensa codegen byte-identical, self-host fixedpoint unchanged. Verified structurally (test/test_esp_interrupt.pasxtensa arm,--emit-obj,xtensa-esp32s3-elf-{readelf,objdump}):MyIsrsits in.iram1.textwith the full a2-a13 save, the normal a0/a15 frame, a working cross-section indirect call into flash (callx0 a8via a relocated literal →esp_rom_printf), the mirrored restore, andrfe. Windowed ABI still errors with a clear message (--xtensa-abi=windowed): mid-window register-file spill is the deferred hard part. Live-fire still needs a vector-table install (no Xtensa CSR/inline-asm from PXX) — same QEMU-unvalidatable gap as riscv below. Historical plan kept below: - xtensa
interrupt;— original plan (now implemented above). Was erroring (parser.inc, theisInterrupt and TargetArch <> TARGET_RISCV32guard). Mirror the riscv shape but for the Xtensa exception/interrupt model:- Where:
EmitProcPrologue/EmitProcEpilogxtensa branches (symtab.inc, sameProcIsInterrupt[CurProc]gate as riscv).interruptalready impliesiramso.iram1.textplacement is free. - Save/restore: preserve the interrupted caller-saved context. On the bare
Call0 profile (start here, simplest —
--esp-profile=bareforces Call0) save the a-regs the body clobbers (a2-a15 less the frame regs; codegen uses a2-a8, a10-a13) to a fixed save area above the frame, mirror on exit. The windowed ABI is the hard part — an interrupt can hit mid-window; needs the window spilled (or a dedicated interrupt stack) — defer windowed until Call0 works. - Return: not a normal
ret. Level-1 interrupt/exception returns viarfe(return-from-exception, PS.EXCM) for the general exception, orrfi 1for a high-priority level-1 interrupt; EPC1/EPS1 hold the restart PC/state (set by HW). Pickrfefor the general-exception vector path; document the level assumption. - Encoders: add
xtensa_rfe/xtensa_rfi/wsr/rsrtoxtensaenc.inc(encoding oracle:xtensa-esp32s3-elf-as— same one used for the FP insns;llvm-mc-18lacks Xtensa FP/CSR).rfe=0x003000,rfi n=0x003010|(n<<4) (verify with the oracle). - Validate: structural only, same as riscv (
--emit-obj+xtensa-esp32s3-elf-objdump: handler in.iram1.text, save/restore +rfe). Live-fire needs a vector-table install (no Xtensa inline-asm/CSR from PXX yet), so it's QEMU-unvalidatable without that scaffolding — same gap as riscv below. - Reference: the riscv implementation (9ab4304) is the template; arm32's register-pair save shapes are unrelated here.
- Where:
@isrproc-address fixups in the.o— DONE 2026-06-21 (fef87c2) for the iram-writer path (writeELF32RelIram): eachIR_PROCADDRliteral gets an absoluteR_*_32reloc against the target proc symbol, so@MyIsrhanded toesp_intr_allocresolves to the linked handler. ISR/@isr programs always have an iram routine -> that writer. (Non-iram--emit-obj@procstill guards.) Verified:test_esp_isr_register --emit-objshowsR_RISCV_32vs MyIsr + vs esp_intr_alloc. KNOWN MINOR wart:IR_PROCADDRlowers a single source@twice (idempotent literals, one dead) — pre-existing, target-independent, separate cleanup.
@proc on bare riscv32 — DONE 2026-06-21 (38242eb)
@Routine now yields the absolute code address on the bare ET_EXEC image:
IR_PROCADDR emits a PC-relative inline-literal load (auipc/jal/literal/lw)
recorded as a ProcAddrFix, patched to entry+BodyAddr by writeELF32 (the
generic patch loop already existed; the esp32 guard was relaxed for riscv32).
test_esp_procaddr matches the x86-64 oracle on esp32c3. This is the first half
of the @isr path; the .o relocatable writers still need it (above), as does a
self-contained live trap test (still also blocked on CSR setup: no rv32 inline
asm / CSR ops to write mtvec).
Motivation
To support safe execution of Interrupt Service Routines (ISRs) under both bare-metal (esp32-bare) and SDK-hosted (esp32-idf) profiles. Code executing in an interrupt context must reside in internal Instruction RAM (IRAM) to prevent cache fetch exceptions when the flash cache is disabled. The compiler needs language-level keywords to route specific procedure code to IRAM and wrap raw bare-metal hardware interrupt registers.
Scope
- Language Directives:
iram;: Compiles the procedure normally (matching the target's default ABI calling convention) but places its machine instructions in the.iram1.textsection. Suitable for ESP-IDF registered ISRs and helper functions.interrupt;: Compiles the procedure as a raw hardware interrupt vector handler. Emits an assembly prologue/epilogue to save and restore all CPU registers, returns via the target's hardware interrupt return instruction (e.g.,mreton RISC-V), and places the code in the.iram1.textsection. Suitable for bare-metal vector tables.
- Pascal Standards Alignment:
- Free Pascal (FPC) and Delphi define the
interrupt;directive to denote a hardware interrupt routine that handles its own register saving/restoring and interrupt return sequence. We adopt this standard behavior. - We introduce
iram;as a custom compiler attribute/directive to designate RAM-resident functions without modifying the standard calling conventions.
- Free Pascal (FPC) and Delphi define the
- Calling Conventions:
- For procedures decorated with
iram;, the compiler automatically matches the active target's standard C calling convention (e.g., RISC-V 32-bitilp32/cdeclor Xtensawindowed/call0ABI as configured by target flags). The programmer does not need to specify manual calling conventions in the procedure signature.
- For procedures decorated with
- ELF Relocatable Object Writer:
- Update
writeELF32Relin [elfwriter.inc](file:///home/rene/frankonpiler/compiler/elfwriter.inc) to define and output the.iram1.textsection. - Partition the compiler's unified
Codebuffer by section, adjusting internal symbol offsets and relocation targets dynamically.
- Update
Non-goals
- Enforcing FreeRTOS/ISR-safe API calling limits at compile time.
- Generating automated vector table routing from the compiler (handled by developer code or SDK APIs).
Acceptance
- Compiler accepts procedure declarations with the new directives:
procedure my_gpio_isr(arg: pointer); cdecl; iram; procedure my_raw_hw_isr; interrupt;
- Executing
./pascal26 --target=riscv32 --emit-objproduces an ELF object containing both.text(Flash) and.iram1.text(IRAM) sections. readelf -Sverified correct section headers, flags (SHF_ALLOC | SHF_EXECINSTR), and offsets.- Linking with ESP-IDF puts
.iram1.textin IRAM, and the application runs safely in cache-disable states.