Relocation-layer genericization (planned work)
Status — 2026-06-05 — WS-B (descriptor table) + WS-C (byte-patcher partition) + WS-E.2/E.3 (residual gates) landed; only the optional WS-A enum collapse remains
This roadmap makes the canonical-RelocKind half of the relocation subsystem
as modular as the wire half already is. The goal is the project's standing
contract (see ../INTERFACES.md): code that depends on a
pluggable item — here, the target arch — must never switch on its identity,
and adding or changing an arch's relocations must touch exactly one place.
The "modularity wave" commits (9d905b3c..769d6ae1) already closed the two
identity switches in the reloc path and moved the reloc-name table onto a
per-arch hook, all via the incremental capability-hook style (narrow fields/hooks
on the existing LinkArchDesc / ObjElfArchOps vtables). What remains is the
structural denormalization: the per-kind static facts (width, GOT/TLS class) are
still re-enumerated in generic switches, and the byte-patcher's ISA encoders still
live in the format-neutral obj layer. This revision marks the landed items as
baseline and rescopes the open work accordingly.
Design docs this work feeds back into once shipped: ../OBJ.md ("Relocation model and the shared byte-patcher"), ../LINK.md (the reloc passes), ../INTERFACES.md (the backend contract).
Landed since this plan was first written (9d905b3c..769d6ae1)
- The one arch-identity switch is gone (was finding #25). The
(target.arch == KIT_ARCH_X86_64) ? R_X64_TPOFF64 : R_AARCH64_TPOFF64ternary inlink_emit_internal_tpoff64is nowlink_arch_desc_for(l->c)->tpoff64_reloc, a new per-archLinkArchDescfield (src/link/link_arch.h, populated insrc/arch/{aa64,x64,riscv}/link.c). This is WS-A's functional fix via the field route rather than the value-class collapse — the collapse remains an optional cleanup (now WS-A below, downgraded). - The FreeBSD static-IFUNC OS gate is gone (was finding #18).
use_rela_ipltnow callsobj_format_static_ifunc_via_rela_iplt(c)(src/obj/obj.h:819, implsrc/obj/obj_secnames.c:371) instead ofos == KIT_OS_FREEBSD && obj == KIT_OBJ_ELF. WS-E item 1 is done. - The reloc-name table moved to a per-arch hook (was finding #24, partially).
kit_obj_reloc_kind_nameno longer inlines an x86_64 table; it lowers the canonical kind viareloc_toand calls the newObjElfArchOps.reloc_name(src/obj/format.h:65; implself_{x86_64,aarch64,riscv}_reloc_name). But the dispatch is still gatedif (fmt != KIT_OBJ_ELF || arch != KIT_ARCH_X86_64) return NULL;(src/api/object_file.c:384): the aarch64/riscvreloc_namefunctions exist but are deliberately not consulted, because the rv64/aa64 objdump golden corpus expects the arch-neutral spelling ("RV_CALL", not "R_RISCV_CALL"). So the name table is now per-arch data, but a residual two-axis identity gate remains, coupled to the test corpus. See WS-E item 3.
Net: the reloc path now contains no arch-identity branch, but still denormalizes per-kind facts across generic switches (the structural work below).
The thesis (what still stands)
A relocation kind is a single logical entity. Its static attributes still live in parallel tables the compiler cannot keep in sync:
| Attribute | Lives in | Status |
|---|---|---|
| how to patch the bytes | per-arch src/arch/<arch>/reloc.c (*_reloc_apply_insn) + neutral reloc_apply_neutral() src/obj/reloc_apply.c; dispatched by link_reloc_apply() src/link/link_reloc_apply.c |
landed — WS-C |
| byte width | RelocDesc.width (per-arch src/arch/<arch>/reloc.c + neutral src/obj/reloc.c) |
landed — WS-B |
| uses GOT / is TLS-GOT | RelocDesc.flags RELOC_USES_GOT/RELOC_IS_TLS_GOT |
landed — WS-B |
| branch / got-load / tlvp / direct-page | RelocDesc.flags RELOC_IS_BRANCH/USES_GOT/IS_TLVP/DIRECT_PAGE |
landed — WS-B |
| display name | ObjElfArchOps.reloc_name src/obj/format.h:65 (per-arch hook) |
landed (with a residual gate — WS-E.3) |
Two generic switches (reloc_width, reloc_uses_got/is_tls_got) still enumerate
every arch's kinds, so adding an arch's relocation edits generic link code; and
the GOT/branch classification is answered twice — once by those generic switches
(consumed by the ELF/static GOT pass) and once by the per-arch LinkArchDesc.is_*
hooks (consumed by the Mach-O linker). The byte-patcher's per-kind encoders — pure
ISA knowledge — still sit in the format-neutral src/obj/reloc_apply.c.
Baseline — already clean (context, not work)
- Per-(arch,format) wire translators (
reloc_to/reloc_from/reloc_pcrel/reloc_length, and nowreloc_name) insrc/obj/{elf,macho,coff}/reloc_<arch>.c, reached only through the format sub-ops (src/obj/format.h:55-81). Adding a format or an arch's wire encoding is a one-table change. These do not move; the per-arch reloc name legitimately belongs here, not in the descriptor below. - The single-entry byte-patcher boundary.
link_reloc_apply(c, kind, P, S, A, P)is reused verbatim by the static linker, JIT linker, assembler, and emulator guest loader (../OBJ.md: "one encoder, three loaders"). That one-entry, one-encoder invariant is load-bearing and WS-C preserves it: only the implementation behind the entry is partitioned, never the entry. LinkArchDescalready carries per-arch PLT/IPLT geometry, stub emitters, theis_*classifiers, and nowtpoff64_reloc. It is the proven home for per-arch link facts; WS-B extends it (or a descriptor it points to), it does not replace it.- The canonical
RelocKindenum (src/obj/obj.h:108) — one global enum, backends emit canonical kinds — is correct and stays.
The end state (ownership)
src/obj/reloc_apply.c neutral core: reloc_apply_neutral() — byte encoders
for the arch-independent data-word kinds (R_ABS*,
R_REL*, R_PC*, R_TPOFF*, the x64 GOT/dynamic data
slots, the RISC-V data ADD/SUB/SET arithmetic) + the
ULEB128 codec. Pure obj-core, no link/arch dep.
src/link/link_reloc_apply.c (NEW) the single public link_reloc_apply() dispatcher:
neutral-then-arch. Housed in link (not obj-core)
because resolving the per-arch slice needs
link_arch_desc_for() — same boundary call as WS-B's
reloc_desc() dispatcher.
src/arch/<arch>/reloc.c that arch's RelocDesc rows (width + class flags, WS-B)
AND its instruction-immediate byte encoders
(*_reloc_apply_insn, WS-C), reached via
LinkArchDesc.reloc_apply_insn. (R_PLT32's apply is the
RISC-V AUIPC+JALR pair, so it lives in the rv hook with
R_RV_CALL — not neutral, despite its neutral name.)
src/obj/<fmt>/reloc_<arch>.c UNCHANGED — the per-(arch,fmt) wire translators,
incl. the reloc_name spellings (already landed).
src/obj/coff/reloc.c COFF-specific kinds' RelocDesc rows (format, not arch).
After this, adding an arch's relocation is one row (width + flags) in that
arch's reloc.c, one byte encoder beside it, and one wire-translator entry — all
arch-local. No generic file in src/link or src/api enumerates relocation kinds.
WS-A — Value-class kind collapse (addresses A) — #25 done; collapse optional
Status. The identity switch (#25) is fixed via LinkArchDesc.tpoff64_reloc.
What remains is the underlying naming smell, now optional and lower-value: the
canonical enum still carries two byte-identical 64-bit-tpoff kinds, and RISC-V
reuses the AArch64-named one cross-arch (src/arch/riscv/link.c:131,149: .tpoff64_reloc = R_AARCH64_TPOFF64).
Optional cleanup. Collapse R_X64_TPOFF64 + R_AARCH64_TPOFF64 → a neutral
R_TPOFF64 (apply arm is shared already, reloc_apply.c:98-99). This additionally
retires the tpoff64_reloc field — once all three arches name the same kind,
link_emit_internal_tpoff64 just writes R_TPOFF64 and the per-arch field has no
remaining variation. Touch-sites: obj.h:198,284 (enum), reloc_apply.c:98-99 +
reloc_width (fold arms), obj/elf/reloc_x86_64.c (R_TPOFF64 ↔ ELF_R_X86_64_TPOFF64; aa64 stays wire-less), obj/elf/link.c:352,388 (the two
arch-specific tpoff-classification helpers — verify the variant-I/II coordinate
selection there keys on the ABI/arch context, not on the kind name, before
merging), and arch/{aa64,x64,riscv}/link.c (drop .tpoff64_reloc).
Defer unless doing WS-B/C anyway — it is pure tidiness now and best folded into that pass (the descriptor work touches the same enum + apply arms). No urgency: there is no remaining identity switch here.
Oracle. make test-link test-elf test-smoke-x64 test-smoke-rv64 test-aa64-inline + a TLS test-toy slice + make bootstrap (IE-model TLS).
WS-B — One per-arch RelocDesc {width, flags} table (addresses B + C) — LANDED
Status (landed). RelocDesc {u8 width; u8 flags} resolved arch-aware by
reloc_desc(c, k):
- neutral data-word kinds →
src/obj/reloc.c(reloc_desc_neutral, pure obj-core); - per-arch slices →
src/arch/{aa64,x64,riscv}/reloc.c, reached through a newLinkArchDesc.reloc_deschook that replaces the fiveis_*hooks; - dispatcher +
reloc_kind_*predicates →src/link/link_reloc_desc.{h,c}.
Placement note: the dispatcher lives in src/link, not the plan's
src/obj/reloc.c, because resolving the per-arch slice needs link_arch_desc_for()
— housing it in obj-core would invert the obj→link boundary (CLAUDE.md). The neutral
descriptor data is still pure obj-core (src/obj/reloc.c). The arch slice wins over
neutral so R_PLT32 can be a branch on x86-64/RISC-V but flag-free on AArch64 while
sharing the neutral width.
Deleted: reloc_width / reloc_uses_got / reloc_is_tls_got (link_reloc_layout) and
jit_reloc_width_local (link_jit). Migrated consumers (GOT/stub/width passes,
link_jit, and the Mach-O is_* call sites) read reloc_kind_*. Migration guard:
test/link/reloc_desc_test.c — frozen-oracle parity over every kind × every backend
arch (3016 checks). rg "case R_(AARCH64|X64|RV)_" src/link is now empty; full
link/elf/macho/ar/isa/aa64-inline suites + make bootstrap (debug+release,
byte-identical) pass. WS-A's enum collapse stays deferred — tpoff64_reloc remains a
per-arch field.
Problem (original). reloc_width() and reloc_uses_got()/reloc_is_tls_got() are generic
switches re-enumerating every arch's kinds, and the GOT/branch classification is
answered twice (those switches vs the per-arch LinkArchDesc.is_* hooks). Adding
an arch's reloc edits generic link_reloc_layout.c; the two classification
mechanisms can silently disagree.
Change. One descriptor, owned per-arch, as the single source of a kind's static
structural facts. Name is excluded — it already landed on the per-arch wire
ops (ObjElfArchOps.reloc_name), which is its correct home; the descriptor carries
only width + classification.
/* src/obj/reloc.h (new) */
typedef enum RelocDescFlag {
RELOC_PCREL = 1u << 0,
RELOC_USES_GOT = 1u << 1,
RELOC_IS_TLS_GOT = 1u << 2,
RELOC_IS_BRANCH = 1u << 3, /* needs a JIT/range veneer (== needs_jit_call_stub) */
RELOC_IS_TLVP = 1u << 4, /* Mach-O TLV page/pageoff */
RELOC_DIRECT_PAGE = 1u << 5, /* Mach-O ADRP-direct */
RELOC_MARKER = 1u << 6, /* RELAX/ALIGN/TPREL_ADD — no bytes */
RELOC_WIDTH_DYN = 1u << 7, /* ULEB128 — width read from bytes at apply */
} RelocDescFlag;
typedef struct RelocDesc { u8 width; u8 flags; } RelocDesc;
const RelocDesc* reloc_desc(const Compiler* c, RelocKind k); /* caller holds target arch */
Ownership / assembly. reloc_desc() resolves neutral-core kinds from a table in
src/obj/reloc.c; arch-family kinds dispatch to link_arch_desc_for(c)->reloc_desc(k)
(a new LinkArchDesc hook returning that arch's slice, the same shape as the
existing is_*/tpoff64_reloc entries); COFF-family kinds resolve from a COFF slice.
Adding an arch is one slice in src/arch/<arch>/reloc.c — no generic edit.
Migrate consumers, then delete the generic switches:
reloc_width()(link_reloc_layout.c:256) → delete; callers readreloc_desc(c,k)->width. Keep theRELOC_WIDTH_DYNsentinel + the ULEB128 offset-bounds guard (link_reloc_layout.c:1117-1126).reloc_uses_got()/reloc_is_tls_got()(link_reloc_layout.c:392,380) → delete; the GOT pass readsreloc_desc(c,k)->flags & RELOC_USES_GOT / RELOC_IS_TLS_GOT.- The four
LinkArchDesc.is_*hooks (link_arch.h:79-82) + their impls insrc/arch/{aa64,x64,riscv}/link.c→ delete; the Mach-O linker callers (src/obj/macho/link.c:420,492,566,1483,1496,1505,1514,1563) read descriptor flags.needs_jit_call_stub(link_reloc_layout.c:594,1095) →RELOC_IS_BRANCH(it aliasesis_branch_relocon every arch today).
End state: no generic file classifies or sizes relocations by enumerating arch kinds, and each fact has exactly one source — width/flags in the descriptor, name on the wire ops.
Exhaustiveness test (the red-green anchor). Add test/obj/reloc_desc iterating
every RelocKind for each enabled arch, asserting reloc_desc() returns a row
(width != 0 unless MARKER/WIDTH_DYN). Cross-check that, for every kind the old
reloc_width() covered, the descriptor returns the same width (a migration guard).
This makes "forgot a row" a failing test instead of a silent default. Write it red
first.
Oracle. The exhaustiveness/migration test, then make test-link test-elf test-macho test-ar test-smoke-x64 test-smoke-rv64, then make bootstrap
(macOS/aa64 bootstrap drives the Mach-O GOT/TLVP/branch classifiers that the is_*
deletion touches; byte-identity catches any width drift).
WS-C — Partition the byte-patcher per-arch behind the single entry (addresses D) — LANDED
Status (landed). The instruction-immediate byte encoders moved into each
backend as *_reloc_apply_insn (src/arch/{aa64,x64,riscv}/reloc.c), reached
through a new LinkArchDesc.reloc_apply_insn hook (src/link/link_arch.h,
wired in each arch's link.c). The format-neutral data-word arms (R_ABS/REL/PC/
TPOFF writes, x64 GOT/dynamic slots, the RISC-V data ADD/SUB/SET arithmetic, and
the ULEB128 codec) stay in obj-core as reloc_apply_neutral()
(src/obj/reloc_apply.c), which has no link/arch dependency. The single public
entry link_reloc_apply() moved to src/link/link_reloc_apply.c (neutral-then-
arch dispatch) — not obj-core, because resolving the per-arch slice needs
link_arch_desc_for(), the same boundary reason WS-B placed reloc_desc() in
src/link. The dispatcher enumerates no kinds (rg "case R_(AARCH64|X64|RV)_" src/link is empty). x64 owns only R_X64_PC8; the wider x64 GOT/PLT/TPOFF data
slots remained neutral. R_PLT32 is applied as the RISC-V AUIPC+JALR pair so it
lives in the rv hook beside R_RV_CALL (x64 never emits canonical R_PLT32 — it
emits R_X64_PLT32 via reloc_from). Migration guard:
test/link/reloc_apply_test.c (test-link-reloc-apply) — frozen pre-WS-C
patched bytes for every instruction-immediate kind across aa64/x64/rv (50
checks). The reloc_uleb128 c=NULL path still works (neutral never touches the
compiler). Full link/elf/macho/ar/asm/isa/opt/coff/smoke matrix + bootstrap pass.
Problem (original). src/obj/reloc_apply.c lives in the format-neutral obj layer but
encodes pure ISA knowledge — AArch64 imm19/imm26/ADRP page math, RISC-V U/I/S/B/J
immediate scatter and the 0x800 HI20 bias, x64 field writes. Adding an arch edits
this shared file; the encoders belong in the backends, beside that arch's MC emitter
and (post-WS-B) its reloc.c descriptor slice.
Constraint (must not break). link_reloc_apply(c, kind, ...) stays the one
public entry, called unchanged by all four loaders (src/asm/asm.c:1296,
src/emu/dl.c:15, src/link/link_jit.c, src/obj/{elf,macho,coff}/link.c). The
"one encoder, three loaders" invariant (../OBJ.md) is preserved — there
is still exactly one encoder per kind; it moves to the owning backend.
Change.
- Keep
link_reloc_applyinsrc/obj/reloc.cas the dispatcher; it handles the arch-neutral data-word arms inline (R_ABS32/64,R_REL*/PC*,R_TPOFF*,R_GOT32,R_PLT32, the ULEB128 codec) — plainwr_uN_le, no ISA knowledge. - Instruction-embedded kinds dispatch to a new
LinkArchDesc.reloc_apply_insn(c, k, P, S, A, P)hook. Move the AArch64 arms tosrc/arch/aa64/reloc.c, RISC-V tosrc/arch/riscv/reloc.c, x64 instruction arms (R_X64_PC8) tosrc/arch/x64/reloc.c.c(hencetarget.arch) is available at every call site (verified: alllink_reloc_applycallers pass aCompiler*). - COFF-specific kinds route to a COFF encoder slice.
Each backend's reloc.c then owns {desc rows (WS-B), class flags (WS-B), byte
encoders (WS-C)} for its kinds — one file per arch.
Oracle. Highest blast radius; lean on the WS-B exhaustiveness test + the full
matrix: make test-link test-elf test-macho test-isa test-asm test-smoke-x64 test-smoke-rv64 test-aa64-inline, the JIT/emu reloc paths (test-cg-api, a
run/emu smoke), then both bootstrap chains (make bootstrap-debug bootstrap-release) — byte-identity over the compiler's own object output is the
definitive proof no encoding shifted. Do this last, one arch at a time (neutral-core
extraction first, then aa64, x64, rv), keeping old switch arms live until each arch's
hook is proven, so every step bisects to one arch.
WS-E — Residual format gates (addresses E) — all items LANDED
- FreeBSD static-IFUNC mechanism (#18). Done — now
obj_format_static_ifunc_via_rela_iplt(c)(src/obj/obj_secnames.c:371). - IRELATIVE wire type via hardcoded
KIT_OBJ_ELF. Done. The genericlink_elf_irelative_typeis deleted; the iplt pass now callsobj_format_static_ifunc_irelative_type(l->c)(sibling of the WS-E.1 predicate insrc/obj/obj_secnames.c), which resolves the resolver reloc through the target object format (c->target.obj) rather than the literalKIT_OBJ_ELF. The generic link pass names no format constant. reloc_namedispatch gate (#24 residual). Done.kit_obj_reloc_kind_name(src/api/object_file.c) now guards onlyif (fmt != KIT_OBJ_ELF) return NULL;— thearch != KIT_ARCH_X86_64axis is gone, so aarch64/riscv ELF relocs print via theirObjElfArchOps.reloc_nametables (matching binutils objdump:R_AARCH64_CALL26,R_RISCV_CALL). One golden refreshed (test/objdump/rv64/cases/03-reloc-annotations:RV_CALL→R_RISCV_CALLin the-rrecords; the-ddisasm annotation keeps the arch-neutral[RV_CALL], which comes from the disassembler'sreloc_kind_name, a separate path). Mach-O/COFF have noreloc_nametable yet, so they still fall back to the neutral spelling.
Oracle. make test-link test-elf test-driver-objdump — all pass (item 3's golden
churn was the single rv64 reloc-annotations case, purely the reloc spelling). Item 2's
FreeBSD static-IFUNC path is unexercised on the macOS host but the change is a
behaviour-preserving refactor (same per-arch r_irelative, resolved format == ELF
wherever use_rela_iplt is true); deeper coverage is the FreeBSD VM lane
(scripts/freebsd_vm.sh / test-toy-freebsd-vm, see FREEBSD.md).
Sequencing & risk
- WS-B — the central remaining change: the
RelocDesc {width, flags}table + exhaustiveness test, deleting both generic switches and the duplicatingis_*hooks. This is now the highest-value open item (the identity switches are already gone). Fold WS-A's value-class collapse in here since it touches the same enum/arms. - WS-C — DONE. Encoder partition behind the single entry, gated by the new
test/link/reloc_apply_test.cfrozen-bytes guard + bootstrap byte-identity. - WS-E.2 / WS-E.3 — DONE. (WS-E.3's binutils-spelling switch also required
refreshing
test/smoke/rv64_tls_link.sh's reloc grep —RV_TPREL_HI20→R_RISCV_TPREL_HI20— a stale expectation it had missed.)
Risk controls. Every WS is red-green: WS-B's exhaustiveness + width-migration test
is written first and fails until each arch's slice is complete. The bootstrap is
the load-bearing oracle — it patches every relocation kind the compiler emits for its
own source, so a byte-identical stage2/stage3 proves the encoding path is unchanged.
Per CLAUDE.md, prefer targeted suites during iteration (redirect output to a file);
reserve make bootstrap for end-of-WS gates. Keep old paths live beside new within a
WS (especially WS-C, per-arch) so any regression bisects to one arch's hook.
Done criteria
All met by WS-B + WS-C below except the optional WS-A enum collapse (still deferred).
- ✓ No file under
src/link/enumeratesRelocKindarms:reloc_width,reloc_uses_got,reloc_is_tls_got, theLinkArchDesc.is_*hooks, and the byte-patcher's instruction arms are gone; consumers read the per-archRelocDesc/ call the per-archreloc_apply_insn. (rg "case R_(AARCH64|X64|RV)_" src/linkreturns nothing — the WS-C dispatcher is case-free.) - ✓ Every relocation static fact has exactly one source: width + class flags in the
per-arch
RelocDescslice, wire encoding + name insrc/obj/<fmt>/reloc_<arch>.c, and the instruction byte encoder in that arch'sreloc.c*_reloc_apply_insn. - ✓
link_reloc_applyremains the single public byte-patcher entry (now insrc/link/link_reloc_apply.c); its instruction-encoding arms live insrc/arch/<arch>/reloc.c, the obj layer keeps only the arch-neutral data-word arms (reloc_apply_neutral). - ✓ Adding a hypothetical new arch's relocation touches only that arch's
src/arch/<arch>/reloc.c(oneRelocDescrow + onereloc_apply_insnarm) and itssrc/obj/<fmt>/reloc_<arch>.c— guarded bytest/link/reloc_desc_test.c(rows) andtest/link/reloc_apply_test.c(bytes); no generic file needs edits. - (Optional/low-pri, still open — WS-A) the
tpoff64_relocfield is retired by theR_TPOFF64collapse. (Theobject_file.creloc_namegate removal + objdump golden refresh andlink_elf_irelative_typealready landed under WS-E.) - ✓
make bootstrap-debugreaches the byte-identical fixed point; the full link/elf/macho/coff/isa/asm/opt/smoke matrix passes. (Release bootstrap carries a PRE-EXISTING.Lkit_jt.0break unrelated to this work — gate onbootstrap-debug.)