Xtensa text-assembler (EmitAsmXtensa) for ESP32
- Type: feature
- Status: done (first slice)
- Owner: —
- Depends-on: feature-array-of-const (DONE), feature-asm-text-emitter (x86-64 precedent)
- Opened: 2026-06-14
Motivation
ESP32 codegen (ir_codegen_xtensa.inc) emits Xtensa through hand calls to the
xtensa_* encoders, and the ISA's sharp edges — L32R's always-negative literal
offsets, CALL0 align4(PC)+4, J's imm18 at bits [23:6], RET = 80 00 00 — make
that error-prone and a session-eater to debug (see [[project_esp32_stage1]]).
Same idea as the x86-64 emitter ([[project_array_of_const_and_asm_emitter]],
feature-asm-text-emitter): write emit blocks as assembly text parsed and
encoded by a per-target text-assembler, with runtime % holes bound inline.
Readable, fewer bugs, and it doubles as the backend for eventual Xtensa inline
asm … end. Xtensa is the primary target here — it makes ESP32 work cleaner.
This is mostly new code on top of xtensaenc.inc, not a rewrite; ESP32
progress is chaotic, so land it incrementally.
Precedent to copy
compiler/asmtext.inc (EmitAsmX64) is the template: interleaved
array of const of instruction strings + %-hole ints, one instruction per
string, encoded through the typed x64_* layer. EmitAsmXtensa is the same
front-end over the typed xtensa_* layer (compiler/xtensaenc.inc). Reuse the
shared helpers' shape (AsmTextCharAt, AsmTextSlice, AsmTextParseInt,
hole-binding loop) — ideally factor the target-agnostic bits so both emitters
share them rather than copy.
Operand model (simpler than x86 — no ModRM/brackets)
Xtensa is mostly 24-bit (some 16-bit narrow) with a flat register file a0..a15
(sp = a1). Instructions are mnem dst, src, … comma-separated; loads/stores
take a base register + immediate offset, not bracketed memory:
l32i a3, a2, 8 ; a3 := [a2 + 8]
addi a4, a4, % ; immediate hole
beq a3, a5, .loop ; branch to label
j .done
- registers
a0..a15+spalias. %value hole → nextvtIntegerelement (immediate / offset / branch is size/range-checked per instruction)..name:label;j .name,bXX as, at, .namepick the encoding and compute the (target-specific) relative offset, back and forward.
Scope (incremental)
- Cover the instructions
ir_codegen_xtensa.incalready uses first:add sub and or xor mull mov movi addi,l32i l16ui l16si l8ui s32i s16i s8i,nop ret, branchesbeq bne blt bge+j. Grow on demand. - Labels + branch/J relative-offset resolution (back + forward), honouring the J imm18-at-bits[23:6] and branch range/encoding rules.
- Emit through the existing
xtensa_*encoders + byte sink — no new relocation machinery. - Convert one real
ir_codegen_xtensa.incblock toEmitAsmXtensa(a fixed/branchy one), leave heavily-dynamic blocks on the typed encoders. Mix freely, like the x86 emitter does. - Defer (call out clearly): L32R literal-pool sugar (the jump-over-island +
always-negative
l32r rd, $FFFFscheme — the gnarliest piece), 16-bit narrow encodings, windowed-ABIentry/call8sugar,--target=esp32IDF specifics.
Landmines (PXX self-host — the emitter runs in the compiler)
- No short-circuit
and/orand EMPTY-AnsiString index = nil deref → segfault. Route every conditional char read through a range-checked accessor (AsmTextCharAt). See [[project_pxx_and_not_shortcircuit]] / feature-short-circuit-eval. - No
Copy— use the slice helper. Never reassign avar AnsiStringparam (frozen-inline overflow) — return the value. - Local
array of AnsiStringis a self-host landmine — keep scratch tables module-global (asEmitAsmX64does for its label tables). - Xtensa encoding facts that already cost a session: L32R one-extended (negative)
offsets; J imm18 at [23:6] (not
<<4); RET LE80 00 00; CALL0 targetalign4(PC)+4+imm*4(proc entries NOP-padded to 4). All in [[project_esp32_stage1]] andxtensaenc.inc.
Acceptance
EmitAsmXtensa(const items: array of const)with the register/%/label rules, one instruction per string.- At least one
ir_codegen_xtensa.incblock converted (a branch/label-bearing one), output still correct under the Xtensa run path (make test-*/tools/run_target.sh xtensa …/ QEMU as applicable). - A focused
test/test_asm_emit_xtensa.*-style check exercising imm/offset/ branch/label encodings against known-good bytes (llvm-mc is the encoding oracle used previously). - Bootstrap byte-identical; the Xtensa target fixedpoint (where one exists) stays consistent.
Log
- 2026-06-14 — opened. Mirrors the x86-64
EmitAsmX64work; primary target Xtensa to clean up ESP32 codegen. New code overxtensaenc.inc; literal-pool sugar explicitly deferred. - 2026-06-14 — first slice landed.
compiler/asmtext_xtensa.incaddsEmitAsmXtensa(registers a0..a15/sp,%holes,.name:labels,j+beq/bne/blt/bgewith back/forward offset resolution, the load/store/ALU set from the scope list) over the typedxtensa_*/EncodeXtensa*layer, sharing theAsmText*helpers inasmtext.inc. The six IR_BINOP comparison blocks inir_codegen_xtensa.incare converted (one proc each:XtensaCmpEq/Neq/Lt/Gt/ Le/Ge). Output is byte-identical to the prior encoder calls; verified against the ESPobjdumporacle (forward.donebranch targets decode correctly).make bootstrapbyte-identical,make test+make test-emit-obj(xtensa call0/windowed .o links) pass. - Self-host bug found + FIXED (root cause). The conversion first made the
self-hosted compiler segfault on EVERY xtensa compile. It looked like "several
array of constliterals in one function miscompile" (threshold ~5), but the real cause: anarray of constliteral lowers to a managed dyn-array temp (TVarRec) that the function's exit cleanup finalizes. The temp is synthesised during IR lowering, after the parser's prologue zero-init pass, and was only zeroed inline right before use. When the[...]sits in a branch that is not taken (the comparison cases for a non-comparison program;IREmitNodeXtensa), the handle slot kept stale stack bytes and cleanup freed a garbage pointer → segfault. Fix: flag the tempSymIsHiddenArgTempso codegen's existing prologue nil-init pass covers it (ir.inc,defs.inccomment broadened). Regression testtest/test_varrec_branch.pas(aoc in not-taken branches). After the fix the comparison blocks use plain inlineEmitAsmXtensaliterals — no per-operator workaround procs. See [[project_pxx_array_of_const_selfhost]]. - Second bug FIXED: a non-literal
AnsiStringelement inside anarray of constread garbage under PXX — the vtAnsiString lowering didvalue + 8to skip a frozen string's length prefix, wrong for a runtime AnsiString whose value is already a char pointer (matchesPChar(s), which only adjuststyString). Fix: apply the+8skip only when the element node istyString(frozen literal); storetyAnsiStringas-is (ir.inc). Regressiontest/test_varrec_string.pas(literal/int/var/param/concat elements, matches FPC). A parameterised emit helper passing the branch line as a string param is now viable, but the inline-literal form is kept (clearest). - Still deferred: L32R literal-pool sugar, 16-bit narrow encodings,
windowed-ABI
entry/call8sugar, dynamic blocks left on the typed encoders. - Commits: e4ae2e0 (feat: EmitAsmXtensa), 029301c + 0bbbed9 (refactor: cmp blocks via EmitAsmXtensa, drop workaround procs), 48cf2bf (fix: nil-init aoc managed temp in prologue), 8bce6e2 (fix: aoc AnsiString element as char ptr).