DWARF debug info (-g) — phased, x86-64 first
- Type: feature
- Status: rainy-day
- Owner: —
- Opened: 2026-06-20 (feasibility discussion — debugger support)
- Priority: high value, but after the current correctness/breadth arc. Pairs
with feature-optimization-levels:
-O0's 1:1 source↔asm contract is what makes debug info accurate.
Motivation
PXX emits no debug info of any kind today — no .debug_*, no stabs. A PXX
binary is opaque to gdb/lldb: no line numbers, no function names in backtraces,
no variable inspection. As programs (and the compiler itself) grow, the absence
of a real debugger is the main productivity gap. Target DWARF (debug
DWARF3 as the nominal version; gdb consumes 2/3/4/5 and the line program barely
differs across them).
Key feasibility fact: PXX is its own linker
PXX writes the final ET_EXEC directly (own program headers, own dynamic
tables, picks the ld.so interpreter itself — elfwriter.inc). There is no GNU
as/ld in the final path. Therefore nothing downstream can emit DWARF for
us — PXX must write every .debug_* section byte-by-byte. Full burden, but
full control and zero new toolchain dependency. The section-emission machinery
(shstrtab, section headers, symtab) already exists in elfwriter.inc to extend.
Current state (examined 2026-06-20)
- Tokens carry
.Line(CurTok.Line,Tokens[].Line). That is the only source-location data that survives lexing. - AST nodes are parallel arrays (
ASTKind/ASTLeft/ASTIVal/ASTTk/…) — noASTLine. IR instructions carry no line either. So source→address mapping requires new plumbing lexer→AST→IR→emit. Procs[].BodyAddralready records each function's code offset — the anchor for per-function PC ranges and the line program.
Plan — three burden tiers, ship as separate sub-tickets
Tier 1 — .debug_line (address → file:line). Highest value/effort ratio.
Delivers gdb breakpoints, single-step, Ctrl-C-shows-location, and line numbers
in backtraces. This tier alone = a usable debugger.
- Add
ASTLine[]parallel array; set fromCurTok.Lineat every AST-node alloc. (Parallel array, not a TSymbol field — dodges the MAX_UFIELD overflow landmine; but every node-alloc site must set it, same discipline as the symtabAlloc*parallel-array landmine.) - Add
IRLine[]parallel array; propagate AST line → IR at build. - At emit, collect
(code offset, line, file)rows. - Emit the
.debug_linestate-machine program (version-agnostic enough across DWARF 2..5).
Tier 2 — .debug_info + .debug_abbrev + .debug_str: subprograms.
DIEs for the compile unit + one DW_TAG_subprogram per function (name,
low_pc/high_pc from Procs[].BodyAddr, frame-base). → function names in
bt, proper frames. No type system needed yet.
Tier 3 — locals / params / types. The long tail, incremental.
DW_TAG_variable / DW_TAG_formal_parameter with location expressions
(frame-base + slot offset), and the type DIE graph mapping the tyXxx model +
records/classes/dynarrays/managed strings/variants to
DW_TAG_base_type/pointer_type/structure_type/array_type. Enables
print x and struct inspection. Largest piece; land type kinds one at a time.
Cross-target (×5, deferred until x86-64 proven)
DWARF content is target-independent except: PC/address values, the frame-base register (rbp / x29 / r11 / …) via the per-ABI DWARF register-number map, and pointer width (DWARF32 form on the ELF32 targets i386/arm32/riscv32/ xtensa). One emitter + a small per-arch register/width table. Do x86-64 end to end first; generalise after.
Gates / interactions
-gis opt-in, OFF by default. Self-host andmake bootstrap/cross-bootstrapemit no debug sections → byte-identical path unaffected.- Determinism. DWARF bytes derive only from stable source/line/type data, no
addresses-as-keys or map iteration order → emission is deterministic. (A
-gself-host could even stay byte-identical if ever wanted.) - Optimizer interaction.
-O0keeps the 1:1 source↔asm contract → clean stepping.-g -O2degrades line/var fidelity (same as GCC); acceptable, do not promise accurate optimized-build debug in v1. See feature-optimization-levels.
Build-out order (split when work starts; do not pre-flood the board)
- Tier 1
.debug_line, x86-64 only — prove gdb steps a PXX binary, breakpoints hit,btshows lines. - Tier 2 subprograms (function names / frames).
- Tier 3 locals + base types, then aggregate types incrementally.
- Cross-target generalisation.
Acceptance
- Tier 1:
pxx -g hello.pas→ gdb sets a line breakpoint that hits,step/nexttrack source lines,btshows file:line.make testgreen; default (no-g) output andmake bootstrapbyte-identical unchanged. - Tier 2:
btshows PXX function names with correct frames. - Tier 3:
print <local>yields correct values for base types; struct/record fields inspectable. - Cross-target: same gdb behaviour on i386 + aarch64 + arm32 (under QEMU);
make cross-bootstrapbyte-identical unaffected (debug off on the self-host path).
Related
- feature-optimization-levels —
-O01:1 contract underpins accurate debug; the two arcs are composable. - feature-compiler-warnings — adjacent developer-experience work.
Re-validation 2026-06-24 (all claims still hold) + plan refinements
Code re-checked against current tree. Findings that change the estimate:
- Line capture is ~3 sites, not 540.
AllocNode(parser.inc:13) andIRAppend(ir.inc:21) are each a single definition. CaptureCurTok.LineinsideAllocNode→ASTLine[]; set aCurLowerLineglobal at the top ofIRLowerAST(=ASTLine[node]); read it inIRAppend→IRLine[]. No need to touch the 540 call sites. - Emit hook is clean.
IREmitNode(ir_codegen.inc:1391, x64) —CodeLenat entry is the address where that node's code begins → push a(CodeLen, IRLine[node])row when the line changes. One backend for Tier 1. writeELFhas NO section-header table (elfwriter.inc:646; e_shoff=0). gdb reads.debug_*via section headers, so the-gexe path must ADD a section header table +.shstrtab. The byte-writers exist (writeShdrA/B,writeStrZ) — used today only in the ET_REL (--emit-obj) and ESP paths — so it is wiring, not new machinery. ~80 lines, gated on-g.- Tier 1 also needs a minimal
.debug_infoCU stub. A.debug_linetable is only usable when a CU DIE references it viaDW_AT_stmt_list. So Tier 1 ships with a one-DIE.debug_info+.debug_abbrev+.debug_str(CU name / comp_dir / stmt_list, ~40 bytes). (Ticket had this under Tier 2; it rides with Tier 1 in practice.)
Graceful-degradation decision (kills the Tier-3 bulk)
Anything with runtime/heap layout is emitted as a labeled
DW_TAG_pointer_type, NOT a byte-exact ABI struct DIE:
- dynarray
array of T→ pointer named"array of <T>"(gdb shows handle;print p^if pointee set). - managed string / AnsiString → pointer named
"string"(gdbx/sworks — data is NUL-terminated — no struct DIE needed). - variant → opaque pointer. class instance → pointer to the struct DIE.
What stays "real" (all cheap, RTTI-backed): base types (DW_TAG_base_type),
records/classes (DW_TAG_structure_type + members from the EXISTING RTTI:
__rttireg / UClsRTTIOff / typinfo — do NOT rebuild field metadata), fixed
arrays (DW_TAG_array_type). The expensive part — describing dynarray/string/
variant runtime layout byte-exact — is DELETED. Inspection works for ints,
records, class fields, fixed arrays; the hard 10% shows a labeled pointer
instead of nothing.
Refined effort: Tier 1 (line + CU stub) ≈ 1 session; Tier 2 (subprograms/frames) small; Tier 3-lite (base types + RTTI structs + pointer-cheat) ≈ 1 session.
Log
-
2026-06-20 — ticket opened from DWARF feasibility examination. Findings: no debug info exists; PXX is its own linker so must emit all
.debug_*itself; line info dies at the lexer (tokens have.Line, AST/IR do not). Plan: three tiers (.debug_line→ subprograms → locals/types), x86-64 first,-gopt-in so self-host byte-identical is untouched. -
2026-06-24 — re-validated against current tree (claims hold); added line-capture / emit-hook / section-header findings + the pointer-cheat degradation decision (see section above). Tier 1 confirmed ~1-session feasible.
-
2026-06-24 — Tier 1 LANDED (x86-64,
-gopt-in).pxx -g hello.pasnow emits.debug_line+ a minimal CU stub; gdb resolves+hits a line breakpoint,step/nexttrack source lines, frame 0 ofbtshowsfile:line. (btfunction NAMES are still??— that is Tier 2 subprograms, as designed.) Implementation as planned:ASTLine[]set inAllocNode(parser.inc) fromCurTok.Line, but ONLY for main-file nodes (TokPos <= DbgMainTokEnd) — appended builtin/RTL units lex with their SrcLine reset to 1 per unit (LexAppend), so without this guard their rows collide with the user file's lines under the single shared file entry.DbgMainTokEndis set toTokCountright afterLexAll.CurLowerLineglobal tracked at the top ofIRLowerAST;IRLine[]stamped inIRAppend.- Row collection in the x64
IREmitMachineCodeloop (CodeLenat the start of each statement whose line changed).DwarfLastLinereset per proc. - All
.debug_*bytes built into oneDbgBuf(BuildDwarfSections in elfwriter.inc):.debug_line(DWARF3 state machine),.debug_abbrev,.debug_info(one compile_unit DIE, inlineDW_FORM_string— NO.debug_strneeded),.shstrtab. BothwriteELFvariants (FPC seed + self-host) gained a-g-gated section-header table (writeShdr64, Elf64_Shdr) +e_shoff/e_shnum=5/e_shstrndx=4. Debug sections live pastp_filesz(unmapped). -gflag in compiler.pas;DebugInfoglobal, default False.- Gates:
make testgreen; self-host + cross-bootstrap byte-identical (the row-recording is behindif DebugInfo, so-g-off codegen bytes are unchanged → no reseed). Newmake test-debug-g(tools/dwarf_smoke.sh): readelf decodedline rows + gdb break/run/bt. LANDMINES recorded: (1) SLEB128 needs a real arithmetic shift — Pascaldivtruncates toward zero andshris logical; bias(v-127) div 128for negatives. (2) per-unit SrcLine reset means a one-file line table MUST filter to main-file tokens or every unit's "line N" aliases the user's "line N". Next: Tier 2 (subprograms → realbtnames/frames), then Tier 3-lite.
-
2026-06-24 — Tier 2 + Tier 3-lite LANDED (x86-64,
-g).btnow shows PXX function names + file:line frames;print/info argsread params, locals, and globals; records inspect field-by-field (print p→{x = 3, y = 4}); strings read viax/s. All behindif DebugInfo→ self-host + cross-bootstrap stay byte-identical (verified). Newmake test-debug-gnow asserts the Tier 2/3 behaviour too. Implementation:- Side tables, not live symtab. Symbol slots recycle across procs (SymCount
restored at each proc end), so params/locals are GONE by writeELF time. Capture
them during compile:
DbgCaptureProcLocals(parser.inc, before scope teardown) snapshotsSyms[ScopeBase..SymCount-1]into theDbgVar*arrays; program globals captured at ParseProgram end (skGlobal pastDbgGlobalScopeBase).ProcDbgMain[]flags main-file procs;DbgMainBodyStart/End+DbgProgNamebound the program body. - .debug_info DIEs (BuildDwarfSections, elfwriter.inc): CU(children) →
type DIEs first (so param/var
ref4s point backward) → oneDW_TAG_subprogramper main-file proc + one for the program body (name, low/high_pc fromProcs[].BodyAddr/ProcBodyEnd,frame_base = DW_OP_reg6), each with its capturedformal_parameter/variablechildren. Locations: globalsDW_OP_addr(bssBase+off); locals/paramsDW_OP_fbreg(off), +DW_OP_dereffor by-ref params. - Types (graceful degradation): base types →
DW_TAG_base_type; records →DW_TAG_structure_typewith members from the EXISTING UFld* tables (no field metadata rebuild); fixed arrays →array_type+subrange; class/string/ pointer/variant/dynarray → labeledDW_TAG_pointer_type(the pointer-cheat). Deduped via the(cat,recId,aux)map. - Allocated sections required. Two new LANDMINES that cost real debugging:
(a) gdb segfaults when a
DW_TAG_subprogram'slow_pclands in NO allocated section — the-gELF MUST carry real.text/.data/.bsssection headers (sh_addr set), not only.debug_*.find_pc_sectionreturns null otherwise. (b)break <func>only skips the prologue cleanly when a line row exists AT the function'slow_pc; without it gdb's arch analyzer stops mid-prologue (before params are stored → args read as 0). Fix: emit an entry row atProcs[].BodyAddrplusDW_LNS_set_prologue_endon the first body row. - Helpers split to ≤6 params (
writeShdr64+ per-callDbgShFlags/Addr/Alignglobals) to dodge the many-param-call-corruption backend landmine.
- Side tables, not live symtab. Symbol slots recycle across procs (SymCount
restored at each proc end), so params/locals are GONE by writeELF time. Capture
them during compile:
-
2026-06-24 — CFI (
.debug_frame) + cheat-label polish LANDED (x86-64). Native x86-64 debug is now feature-complete; only cross-target remains..debug_frame(DbgEmitFrame): one CIE (standard rbp frame:def_cfa rsp+8,RA@cfa-8, data_align -8, RA reg 16) + one FDE per main-file proc (advance_loc 1; def_cfa_offset 16; offset rbp@cfa-16; advance_loc 3; def_cfa_register rbp— byte offsets MUST match the emittedpush rbp(1)/mov rbp,rsp(3)) + a program-body FDE withDW_CFA_undefined RAso the unwinder terminates. Result: robust unwinding everywhere (incl. a clean SIGSEGV backtrace —btshows the faulting frame, args likep=0x0, the whole call chain, and stops at the program body) and the junk outermost frame is GONE. Chose.debug_frameover.eh_frame(absolute addresses, rides the existing unmapped-debug-section machinery; CIE_id = 0xFFFFFFFF).- Cheat labels: runtime-layout types now emit a
DW_TAG_typedefover a shared anonymous void pointer, so gdb shows the real Pascal name (whatis s→string,whatis arr→array of Integer) instead of^pointer, whilex/s/handle inspection still work. - New
make test-debug-gassertions: no junk?? ()frame, and a SIGSEGV sample unwinds to the named faulting frame atfile:line. All behindif DebugInfo→ self-host + cross-bootstrap byte-identical (verified). LANDMINE added:.debug_frameFDE prologue offsets are hard-coded to the standardpush rbp; mov rbp,rspbyte lengths — asm-body procs and any future prologue change would desync the CFI (generators are fine, they still go through EmitProcPrologue).
-
2026-06-24 — CROSS-TARGET LANDED —
-gcomplete on all four Linux targets (x86-64, i386, aarch64, arm32). Verified end-to-end under qemu-user gdbstub + gdb-multiarch:btshows function names/args/file:line, CFI unwinds caller frames,printreads params/locals, globals, and record fields on every arch. (esp xtensa/riscv32 stay excluded — windowed/no-FP ABI, no gdb path.)DbgSetArchParamscentralises the per-arch table: ptr width (8 x86-64/ aarch64, 4 i386/arm32), frame-base reg (rbp6/ebp5/x29 29/r11 11), RA col (16/8/30/14), SP reg, link-reg-saved-in-prologue flag (aarch64/arm32 store x30/lr; x86 relies on thecall-pushed RA), and the two prologue step byte lengths for the FDEadvance_loc.DbgPutAddremits ptr-width addresses everywhere (line set_address, CU/subprogram low/high_pc, DW_OP_addr, FDE).- Row recording moved to a shared
DbgRecordRow(i)(in ir.inc, before the per-arch ir_codegen_* files) called at the top of each backend's statement emit loop — previously only the x86-64 loop recorded rows, so cross builds had an empty line table. Per-body reset moved to the IREmitMachineCode dispatcher so it runs before any backend. writeELF32gained the section-header table (writeShdr32/DbgWriteShdrTable32, Elf32_Shdr 40B) + e_shoff/shnum/shstrndx, mirroring the 64-bit path; gated to i386/arm32 (not esp). aarch64 already flows through the 64-bitwriteELF, so just enabling it in the doDebug condition sufficed.- Globals → CU-level
DW_TAG_variableDIEs (was: nested under the main-body subprogram, which scoped them tomainonly →print <global>failed inside other procs). Now true globals, visible from every frame. LANDMINES: (1) row recording is per-backend — a new backend needs theDbgRecordRowcall or its line table is silently empty. (2) host gdb prints cross frame-base regs with x86 names ($mm0/$st0) when not arch-configured — cosmetic; the DWARF reg numbers are correct (confirmed under gdb-multiarch withset architecture). (3) program globals must be CU children, not subprogram children, or they aren't global to gdb.
DWARF
-gis now DONE for all Linux targets. Remaining (deferred, optional): pointer-cheat element inspection (print arr[i]/ managed-string struct — the byte-exact ABI struct the ticket deliberately deleted); class instances show as a labeled pointer rather than pointer-to-structure_type. esp targets out of scope. Ticket can move to done once these optional tails are explicitly waived.