kit

kit
git clone https://git.ryansepassi.com/git/kit.git
Log | Files | Refs | README

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)

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)

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):

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:

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.

  1. Keep link_reloc_apply in src/obj/reloc.c as 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) — plain wr_uN_le, no ISA knowledge.
  2. Instruction-embedded kinds dispatch to a new LinkArchDesc.reloc_apply_insn(c, k, P, S, A, P) hook. Move the AArch64 arms to src/arch/aa64/reloc.c, RISC-V to src/arch/riscv/reloc.c, x64 instruction arms (R_X64_PC8) to src/arch/x64/reloc.c. c (hence target.arch) is available at every call site (verified: all link_reloc_apply callers pass a Compiler*).
  3. 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

  1. FreeBSD static-IFUNC mechanism (#18). Done — now obj_format_static_ifunc_via_rela_iplt(c) (src/obj/obj_secnames.c:371).
  2. IRELATIVE wire type via hardcoded KIT_OBJ_ELF. Done. The generic link_elf_irelative_type is deleted; the iplt pass now calls obj_format_static_ifunc_irelative_type(l->c) (sibling of the WS-E.1 predicate in src/obj/obj_secnames.c), which resolves the resolver reloc through the target object format (c->target.obj) rather than the literal KIT_OBJ_ELF. The generic link pass names no format constant.
  3. reloc_name dispatch gate (#24 residual). Done. kit_obj_reloc_kind_name (src/api/object_file.c) now guards only if (fmt != KIT_OBJ_ELF) return NULL; — the arch != KIT_ARCH_X86_64 axis is gone, so aarch64/riscv ELF relocs print via their ObjElfArchOps.reloc_name tables (matching binutils objdump: R_AARCH64_CALL26, R_RISCV_CALL). One golden refreshed (test/objdump/rv64/cases/03-reloc-annotations: RV_CALLR_RISCV_CALL in the -r records; the -d disasm annotation keeps the arch-neutral [RV_CALL], which comes from the disassembler's reloc_kind_name, a separate path). Mach-O/COFF have no reloc_name table 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

  1. WS-B — the central remaining change: the RelocDesc {width, flags} table + exhaustiveness test, deleting both generic switches and the duplicating is_* 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.
  2. WS-CDONE. Encoder partition behind the single entry, gated by the new test/link/reloc_apply_test.c frozen-bytes guard + bootstrap byte-identity.
  3. WS-E.2 / WS-E.3DONE. (WS-E.3's binutils-spelling switch also required refreshing test/smoke/rv64_tls_link.sh's reloc grep — RV_TPREL_HI20R_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).