← board

xtensa: support calls/definitions with more than 6 parameter words

Problem

The xtensa backend caps both function definitions and call sites at 6 parameter words:

riscv32 already allows 8 (parser.inc:10523); x86-64/aarch64/arm32 spill to the stack. xtensa needs the same: arguments beyond the in-register set (a2..a7 for Call0, the rotated window for windowed) go on the outgoing stack frame per the Xtensa ABI; the callee reads them from its incoming frame.

Impact (why it surfaced)

The esp32s3 (xtensa) PAL object build is blocked. PalBackendVforkAndExec (lib/rtl/platform/{posix,esp}/platform_backend.pas) takes 7 parameter words (path, argv, envp, stdinReadFd, stdinWriteFd, stdoutReadFd, stdoutWriteFd), so --target=xtensa --xtensa-abi=windowed -Fulib/rtl/platform/esp test/lib_platform_esp.pas fails at pascal26:647. This predates the PAL datagram/introspection work — it landed with the process-spawning feature. The esp32c3 (riscv32) build is fine (8-word cap) and imports all expected lwip_*/process symbols.

No workaround applied: the 7-word PalBackendVforkAndExec signature is the honest one (mirrors the POSIX fork+exec plumbing). It stays clean; the fix is in the compiler.

Acceptance

Log

Reference implementation, measured from xtensa gcc (2026-08-02)

Asked how other toolchains handle this (Arduino, MicroPython, ESP-IDF — all gcc). They do not work around it. No pointer-struct packing: they implement the ABI, first N words in registers and the rest in the caller's outgoing stack frame. Taken from the xtensa-esp32s3-elf-gcc that ships with the installed IDF, -O2, 9 int args:

Caller (windowed) — args 1..6 in a10..a15, args 7,8,9 written to the outgoing area at sp+0/4/8 before the call:

caller:
    entry   sp, 48
    movi.n  a8, 9
    s32i.n  a8, sp, 8        ; arg 9
    movi.n  a8, 8
    s32i.n  a8, sp, 4        ; arg 8
    movi.n  a8, 7
    s32i.n  a8, sp, 0        ; arg 7
    movi.n  a15, 6 ... movi.n a10, 1
    call8   callee

Callee (windowed)entry sp,32 lowers sp by the frame size, so the caller's sp+0/4/8 is read back at sp+32/36/40:

callee:
    entry   sp, 32
    l32i.n  a8, sp, 32       ; arg 7
    l32i.n  a9, sp, 36       ; arg 8
    l32i.n  a2, sp, 40       ; arg 9

Callee (Call0, -mabi=call0) — no window rotation and no entry, so the same slots are read directly at sp+0/4/8:

callee:
    l32i.n  a9, sp, 0
    l32i.n  a10, sp, 4
    l32i.n  a2, sp, 8

So the two ABIs differ only in whether the incoming offset is biased by the entry frame size — the caller side is identical. That is the whole fix.

riscv32 is NOT working by luck — checked

The Problem section above says "riscv32 already allows 8", which reads as a higher cap of the same kind. It is not: riscv32 spills to the stack properly. Measured with a 12-integer-argument function compiled --target=riscv32 and run under qemu-riscv32:

expect:  1 2 3 4 5 6 7 8 9 10 11 12   sum=78
riscv32: 1 2 3 4 5 6 7 8 9 10 11 12   sum=78     (identical to x86-64)

9, 10 and 12 parameter words all compile and pass correctly. So this is an xtensa-only implementation gap, not a shared limit that riscv32 happens to sit under — and there is a working in-tree implementation of the same idea to copy from.

Line numbers refreshed

The Problem section's citations have drifted. Current sites:

Priority: xtensa is the PRIMARY ESP target (user, 2026-08-02)

Raised 45 -> 65. The user's ESP32 devices are mostly S2 and S3, both xtensa; older models are out of scope. So this ticket is not a nice-to-have behind riscv32 — it is the gate on the ESP target that actually matters to the person using it.

Confirmed the same day that this cap is the only thing stopping the xtensa ESP PAL build:

--target=xtensa --xtensa-abi=windowed --platform=esp \
  -Fulib/rtl -Fulib/rtl/platform/esp test/lib_platform_esp.pas
  -> pascal26:961: error: target xtensa: more than 6 parameter words not yet supported

That is the first and only error; nothing else in the ESP PAL is refused.

Matching gcc is required, not merely convenient

An ABI is a contract with the other compiler, and on ESP both directions are crossed constantly:

So the alternative sometimes suggested — packing the overflow into a caller-allocated struct and passing a pointer — is not an option here: it is ABI-incompatible the moment either boundary is crossed with >6 words, and it would leave two conventions to keep straight. Doing exactly what gcc does is both simpler and the only interoperable answer. The measured reference codegen is in the section above.

(Aside, since it came up: a Pascal var parameter costs one word — it passes an address — so it needs no heap and does not itself push a signature over the cap. The overflow area is plain outgoing stack, never heap.)

QEMU covers S3 but not S2

qemu-system-xtensa in the installed IDF offers machines esp32 and esp32s3 only — there is no esp32s2. So this work is verifiable headless for S3, and S2 needs real silicon ([[feature-esp-hardware-flash-validation]]).

Do WINDOWED first — it is the profile that matters (user, 2026-08-02)

The acceptance above asks for "both Call0 and windowed" without ranking them. They are not equally valuable:

ABI what it gets you
ESP-IDF profile windowed (--xtensa-abi=windowed, as examples/esp32/hello-s3 uses) lwIP, Wi-Fi/BT, esp_netif, FreeRTOS, the whole driver set
bare (--esp-profile=bare) Call0 — required, windowed needs window-overflow handlers and a vecbase bare-metal never installs (compiler.pas:634) MMIO only

Bare-metal is not a smaller version of the IDF profile; it is a different, much weaker device. lib/rtl/platform/esp/platform_backend.pas carries 39 PXX_PAL_ESP_IDF_TARGET guards, and every one of them returns PAL_ERR_UNSUPPORTED without IDF — no files, no sockets, no networking. As the user put it, bare would downgrade an ESP32 to microcontroller level, which throws away the reason to choose a Wi-Fi SoC in the first place.

So the ordering is:

  1. Windowed caller + callee — unblocks the xtensa ESP PAL build, and with it everything real: networking, peripherals, [[feature-dns-esp-backend]]. Remember the callee offset is biased by the entry frame size (the measured gcc reference above reads args 7/8/9 at sp+32/36/40 after entry sp,32).
  2. Call0 callee — same caller-side spill, offsets read directly at sp+0/4/8. Needed for the bare profile, which is the niche one.

Both are small once the caller-side spill exists — the caller side is identical between the two ABIs — so this is a sequencing note, not a scope cut. But if only one lands first, windowed is the one that turns the S2/S3 hardware the user actually owns into a usable target.

DONE 2026-08-02 — both ABIs, verified on hardware-accurate qemu

Implemented exactly as the measured gcc reference above prescribes; no workaround, no second convention.

Windowed. XT_OUTARG_REGION (64 bytes = 16 overflow words, so 22 argument words in total) is now reserved at the BOTTOM of the constant-sp frame and the expression stack starts just above it (XtSpillDepth is seeded to XT_OUTARG_REGION instead of 0; the frame reservation and the entry-stub entry grew by the same amount). The caller copies word k>=6 down to sp+(k-6)*4 before the call; the callee reads it at a15 + 32 + (k-6)*4, the 32 being its own entry frame size — the bias the gcc listing shows as sp+32/36/40.

Call0. No reservation needed (moving sp). The pushes run arg0-deepest, so the overflow words already occupy the LOWEST slots but in DESCENDING order; the low nArgs-6 slots are reversed in place, and the block is freed AFTER the call instead of before, because those words are the callee's incoming arguments. The six register words stay allocated across the call (24 bytes). Callee bias is 16, the a0/a15 save area.

Both sides live in three helpers (XtensaLoadArgRegsWindowed, XtensaOrderOverflowArgsCall0, XtensaLoadArgRegsCall0) used by all four call sites — direct, indirect, virtual, constructor — so the four separate "more than 6" errors are gone together. An Int64 pair may straddle the boundary (lo in the last register, hi in the first stack slot) and is handled. interrupt; handlers still refuse stack arguments: their prologue saves 48 extra bytes first, so the bias would silently be wrong, and they take no parameters anyway.

Verificationtest/test_esp_stack_args.pas (7, 9 and 12 words; a var parameter landing in an overflow slot; a straddling Int64; a nested call that itself spills, so the outgoing area is reused):

Commit: 21c963bc6.

Acceptance bullet 3 moves to a follow-up (the PAL has a SECOND blocker)

The claim above that the 6-word cap was "the first and only error" was an artifact of the compiler stopping at the first one. With the cap lifted, the xtensa windowed PAL object build now reaches:

--target=xtensa --xtensa-abi=windowed --platform=esp -Fulib/rtl \
  -Fulib/rtl/platform/esp test/lib_platform_esp.pas
  -> pascal26:504: error: target xtensa: record function results require Call0
                          (windowed not yet supported)   [near PalIn6Any]

That is an orthogonal, already-known gap (Call0 record results landed 2026-06-23; windowed was deferred because the call-window rotation has no obvious caller->callee hidden-dest register). Filed as [[feature-xtensa-windowed-record-results]], which now carries the PAL object-smoke acceptance.