Text-assembler codegen helpers (EmitAsm386 / EmitAsmX64 …)
- Type: feature
- Status: done (2026-06-19) — text assemblers complete for all five targets
(x64/386/rv32/a64/arm32;
make testasm-emit checks all green); no feature gaps left. The only residue is opportunistic (EmitB-block retargeting) and low-priority (inline-asm unification) — not blocking, left as notes. - Owner: —
- Blocked-by: feature-array-of-const
- Opened: 2026-06-14 (design discussion: readable asm emission)
Scope split (this is now the shared-core + x86-64 + inline-asm-unify ticket)
The shared asmtext.inc front-end (parser, %/@data/@glob/.label
markers, hole binding, rel8/rel32 resolution) and EmitAsmX64 are done here.
Each remaining target is its own incremental ticket, all built on this core,
recommended order (cheapest-first — targets with a typed encoder layer already
done cost least):
- feature-i386-asm-emitter —
EmitAsm386(shares the x86 ModRM core). next. - feature-rv32-asm-emitter —
EmitAsmRv32(typedrv32enc.incexists → xtensa-shape, cheap). - feature-aarch64-asm-emitter —
EmitAsmA64(needs a new thina64enc.inc). - feature-arm32-asm-emitter —
EmitAsmArm32(newarm32enc.inc; 4-byte align). - feature-xtensa-asm-emitter —
EmitAsmXtensa(typedxtensaenc.inc). DONE.
This ticket retains: further EmitAsmX64 block conversions + scope item 6
(retarget the user asm … end path in asmenc.inc onto the shared engine).
Done so far (2026-06-14)
compiler/asmtext.inc:EmitAsmX64(const items: array of const)text assembler over the interleaved string/%-hole form. Parses one instruction per string, binds%holes from the following ints, and encodes through the typedx64_*encoders (so ModRM/REX are computed once). Supported this slice: zero-op (ret/leave/syscall/nop/cqo/cdq), push/pop reg, mov (reg,reg | reg,imm | reg,[base±disp] | [base±disp],reg), lea reg,[base±disp], and add/sub/and/or/ xor/cmp (reg,imm | reg,reg).%holes for immediates and displacements. Operand/register parsing reusesAsmRegNumfromasmenc.inc.- Labels + relative jumps:
name:defines a label;jmp/jcc nameresolve back (rel8 when in range, else rel32) and forward (rel32, patched at call end) — the mechanism that will delete the manual CodeLen/Patch32 jump sites. Plusinc/dec, explicit memory sizes (cmp byte [rax], 0), and ALU r/m,imm. EmitWriteCStris now expressed entirely inEmitAsmX64, including the strlen scan loop (back+forward label jumps,cmp byte [mem],inc), exercised bytest/test_array_of_const.pasand the newtest/test_asm_emit.pas(varied PChar lengths, FPC-parity).- Bootstrap stays byte-identical; full
make testgreen.
Self-host landmine hit (fixed)
PXX evaluates and/or fully (no short-circuit), and indexing an EMPTY
AnsiString derefs a nil data pointer. So (Length(s) > 0) and (s[i] = ..) still
touches s[i] and crashes on an empty string (FPC short-circuits, hence the
divergence: FPC-built compiler fine, PXX-built segfaults). All conditional char
reads in asmtext.inc go through AsmTextCharAt (range-checked, returns #0).
Also: a var AnsiString parameter reassigned in the callee is a frozen-inline
landmine — AsmTextSizeKeyword returns the size instead.
Remaining: @data/@glob reloc holes; single-line overload; EmitAsmX64 for
more codegen blocks; then EmitAsm386 and the inline-asm unification.
Motivation
Codegen currently emits raw bytes — EmitB($19); EmitB($D3); { sbb ebx,edx } —
with the mnemonic in a comment. ~970 EmitB lines in ir_codegen386.inc alone.
It is unreadable and error-prone: this exact pattern produced the session's
19 DB vs 19 D3 ModRM bug (sbb ebx,ebx instead of sbb ebx,edx), which the
assembler-computes-ModRM approach cannot make.
Goal: write emit blocks as assembly text, parsed and encoded by a per-target
text-assembler, with runtime values bound inline. Readable, fewer bugs (encode
ModRM/SIB/REX once, correctly), and it doubles as the backend for built-in
asm … end inline assembly (today a separate, rudimentary x86-64-only parser
in asmenc.inc) — one engine, two consumers, every mnemonic added helps both.
Target shape (depends on array of const)
One interleaved array of const: each string is one instruction (one
instruction per line — house rule), and the integers right after a string are
that line's hole values, in order.
EmitAsm386([
'push ebp',
'mov ebp, esp',
'sub esp, %', frameSize,
'mov eax, [ebp+%]', Syms[si].Offset,
'mov edi, @data', INTBUF_OFFSET + INTBUF_SIZE,
'.loop:',
'mov eax, esi',
'div ecx',
'dec edi',
'jnz .loop',
'int 0x80'
]);
Provide a single-line overload so trivial cases skip the brackets:
EmitAsm386('ret'); / EmitAsm386('mov eax, %', v);.
Binding / marker rules
%— value hole. The nextvtIntegerelement is the operand value; width is inferred from the instruction (add dl, %→ imm8;mov ecx, %→ imm32;[ebp+%]→ disp32). No type tag on the hole. Add an explicit%d8escape only for the rare disp8 form.@data/@glob— relocation hole. Emits the 4-byte placeholder and registers the fixup via the existingEmitDataRef/EmitGlobRef(data vs bss can't be inferred, so the kind is explicit). Consumes the next int (the offset)..name:defines a label;jXX .name/jmp .namereference it. Assembler picks rel8/rel32, back or forward — this deletes the 57 manualCodeLen/Patch32jump sites inir_codegen386.inc.- Plain line, no marker → no args consumed.
- The assembler counts markers per line and validates the count and types of the following elements (mismatch = hard error → catches the ordering mistakes the flat-array form invites).
Why this and not alternatives (settled in design)
- Bare
f('asm', 1, 2)varargs = compiler magic (writeln); using it would make the source FPC-incompatible → breaksmake bootstrap. Rejected. - Multiline backtick strings = FPC 3.3.1 only → breaks bootstrap. Not needed; the line array reads fine. (Easy to add later as pure sugar once we cut the FPC cord; not on the path.)
%name-with-parallel-value-array / records = the "variable mess" the interleavedarray of constavoids. Rejected.
Scope
- x86 text-assembler first — covers i386 and x86-64 from a shared ModRM/SIB/(REX) encoder core (the two hottest backends; also the encoding layer where the ModRM bug lived). Mnemonic table grown on demand — start with exactly the instructions the converted blocks use.
- Operand parsing: registers (8/16/32/64),
[base+disp], immediates, the%/@data/@glob/ label markers above. - Emit through the existing byte sink + fixup tables (
EmitB,EmitDataRef,EmitGlobRef,Code[]/Patch32) — no new relocation or ELF machinery; the assembler is a front-end over what's already there. EmitAsm386+EmitAsmX64entry points (+ single-line overloads). LaterEmitAsmA64/EmitAsmArm32/EmitAsmRv32/EmitAsmXtensaas those ISAs get an assembler (separate, incremental).- Incremental adoption — mix freely. Convert fixed / label-heavy / lightly
bound blocks; leave heavily-dynamic blocks (
[ebp+Syms[..].Offset]with many holes) onEmitB/typed encoders. No big-bang rewrite. - Unify inline asm: once the x86 assembler is solid, retarget the user
asm … endpath (asmenc.inc) onto it so inline asm and codegen share one engine (own follow-up slice; the encoder is the shared asset).
Acceptance
EmitAsm386exists with the marker/label/binding rules above and a single-line overload.EmitwriteUInt64_386(fixed + a backward-jump loop + two@data) and at least one bound site (e.g. theIR_LOAD_SYM[ebp+disp]path) are converted toEmitAsm386.make test+test-i386green; the i386 self-fixedpoint stays byte-identical (native and self use the same assembler, so output may shift vs the old hand-bytes but must agree between native↔self — re-verify thecmp).- A focused
test/test_asm_emit.pasexercising imm/disp/label/reloc encodings against known-good bytes.
Notes / landmines
- Byte-identity discipline: replacing a hand block changes the emitted
compiler's bytes; that is fine as long as the fixedpoint
cmpis clean (both sides run the new assembler). Re-run the i386 fixedpoint after each conversion batch. - Keep one instruction per line (house rule) — makes the eventual
EmitB→EmitAsmconversions mechanical and diffs legible. - The assembler runs at compiler runtime; for a hot/pervasive path, parse once into a cached byte template with binding holes. Not needed until profiling says so.
- Existing per-target encoders (
x64enc.inc,rv32enc.inc,xtensaenc.inc,asmenc.inc) and the donefeature-typed-instruction-encodersare the seed / lower layer the text-assembler sits on top of.
Log
- 2026-06-14 — opened from the readable-asm-emission design thread. Settled:
interleaved
array of constbinding,%/@data/@glob/.labelmarkers, width-inferred holes, single-line overload, x86-first shared ModRM core, incremental mix-with-EmitB, inline-asm unification as the double-win. Gated on feature-array-of-const (the binding vehicle). - 2026-06-17 — x86 instruction surface completed so new codegen can write
assembly text instead of raw
EmitBruns.EmitAsmX64(asmtext.inc) andEmitAsm386(asmtext_386.inc) now both cover: integer ALU in every operand direction (reg,imm | reg,reg | reg,[mem] | [mem],reg | [mem],imm) incl. adc/sbb; test; the unary F6/F7 group (not/neg/mul/imul/idiv); imul reg,reg; shifts (imm/1/cl); movzx/movsx (reg + mem, incl. x64 movsxd); setcc; call reg; cdq/cqo; string ops + rep prefix;@data/@globrelocation holes; movabs (x64); and the full SSE/SSE2 scalar-float family — movsd/movss, add/sub/mul/div sd+ss, comisd/ucomisd, cvtsi2sd, cvttsd2si, cvtsd2ss, cvtss2sd, xorps/xorpd, pxor, and x64movqin all five operand shapes (xmm flagged size 16 in AsmRegNum). Single-lineEmitAsmX64('...')overload added (386 already had it). Fixed a latent REX bug: the spl/bpl/sil/dil byte-register REX-forcing must not fire for a memory base register — newEncPrefixAndREXMem(x64enc.inc). Addedtest/test_asm_emit_x64.pas(x64 had no standalone encoder unit test) and extendedtest_asm_emit_386.pas; both assert every form againstllvm-mc-18, wired intomake test-asm-emit. The four RISC targets (a64, arm32, rv32, xtensa) already emit zero raw bytes via their text assemblers. All five commits keep the per-target self-fixedpoint byte-identical (no codegen call sites converted yet — pure capability growth). - 2026-06-17 — policy on what's left (decided):
- Retargeting existing
EmitBblocks onto the assembler is deferred, not a campaign. It is mechanically trivial but each conversion shifts emitted bytes and so carries reseed/regression risk for no behavioural gain. Do it only opportunistically — when writing genuinely new codegen, or when already editing + retesting a given block. No bulk sweep. - Inline-asm
asm…endunification onto the shared engine: low priority. Nice-to-have, not blocking. The only remaining feature gap is the mutexed— DONE (see next entry).lock xchg [@glob], regatomic
- Retargeting existing
- 2026-06-17 — mutexed xchg / absolute-mem operand landed. EmitAsmX64 now
parses
[@glob]/[@data]as an absolute-address r/m (operand kinds 3/4 → ModRM mod00 rm100 + SIB $25 + 4-byte reloc), plus thelockprefix,xchg(reg,reg | reg,[mem] | [mem],reg | [@abs],reg), andmovto/from[@abs]. The two heap-lock sites (EmitAcquireHeapLockspin loop,EmitReleaseHeapLockzero-store) are converted onto it. Regular +--threadsafeself-fixedpoint byte-identical;test_multithreadinggreen. The remaining items are now only the (opportunistic) EmitB-block retargeting and the (low-priority) inline-asm unification — no feature gaps left in the x86 assemblers.