= Internal Linker Design :author: Graeme Geldenhuys :revdate: 2026-06-12 :toc: :sectnums: == Motivation The Blaise compiler currently depends on an external C compiler driver (`cc` / `gcc` / `clang`) to link executables. This is the last remaining external toolchain dependency — the internal assembler and ELF object writer (`--assembler internal`) already eliminated the dependency on an external assembler. Replacing the linker completes the toolchain-independence goal: a single `blaise` binary that compiles Pascal source to a runnable executable with no external tools. Benefits: * **Simpler bootstrapping.** A self-contained compiler binary avoids the chicken-and-egg problem of needing an external linker to build the linker. Cold bootstrap reduces to: previous release binary → new binary. Done. * **Portable builds.** Users on minimal systems (containers, embedded, CI images) do not need to install a C toolchain. * **Deterministic output.** The compiler controls every byte of the output binary. No variation from different `cc` versions, CRT object versions, or default flag differences across distributions. * **Faster builds.** Eliminating the fork+exec of `cc` (which itself forks `collect2` → `ld`) removes ~50ms of process overhead per link. == Scope and Non-Goals === In scope * Link `.o` files (from the internal assembler) and `.a` archives (the RTL) into a dynamically-linked ELF executable on Linux x86-64. * Resolve relocations: `R_X86_64_PC32`, `R_X86_64_PLT32`, `R_X86_64_64`, `R_X86_64_32S`, `R_X86_64_GOTPCREL`, `R_X86_64_TPOFF32`, and their relaxable variants (`R_X86_64_GOTPCRELX`, `R_X86_64_REX_GOTPCRELX`). * Produce PIE executables (ET_DYN with PT_INTERP) — the same format `cc` produces by default on modern Linux. * Emit a GOT for external data symbols and a PLT with eager binding (`DT_FLAGS BIND_NOW`) for external function calls. * Emit `.rela.dyn` with `R_X86_64_RELATIVE` entries for absolute 64-bit relocations in writable sections (vtable pointers, typeinfo, etc.) — required for PIE because the load address is not known at link time. * Emit a `.dynamic` section with `DT_NEEDED` entries for `libc.so.6` (and conditionally `libm.so.6` / `libpthread.so.0` for glibc < 2.34). * Handle TLS variables (`.tbss` and `.tdata` sections, `R_X86_64_TPOFF32`, `PT_TLS` segment). * Merge `.init_array` / `.fini_array` sections and emit corresponding `DT_INIT_ARRAY` / `DT_FINI_ARRAY` dynamic tags. * Merge CRT startup objects (`Scrt1.o`, `crti.o`, `crtn.o`) — these are system-provided and supply `_start`, `.init`, and `.fini`. * Define linker-synthesised symbols: `_GLOBAL_OFFSET_TABLE_`, `__bss_start`, `_edata`, `_end`, `__TMC_END__`. * Resolve weak undefined symbols (e.g. `__gmon_start__`, `_ITM_registerTMCloneTable` from CRT objects) to address 0 without error. * Pass through OPDF debug sections (`.opdf.*`) from input objects into the final executable, preserving the debug workflow for pdr. === Not in scope (future work) * **Static linking** — linking libc statically (`-static`) requires pulling in hundreds of libc `.o` files and resolving IFUNC dispatchers. The internal linker links dynamically against `libc.so.6` (same as today). * **macOS / Mach-O linking** — macOS uses Mach-O, not ELF. The linker will be structured to allow a future Mach-O backend, but this design covers ELF only. * **Windows / PE-COFF linking** — Windows uses PE format with import libraries. Deferred. * **Shared library output** — producing `.so` files. The compiler only produces executables. * **Link-time optimisation (LTO)** — no IR-level linking. * **Linker scripts** — not needed for simple executables. * **`.eh_frame` merging** — CRT objects carry `.eh_frame` sections. The internal linker will pass them through unmerged (or drop them) rather than implementing the `.eh_frame_hdr` binary-search table. The compiler's own exception handling uses `setjmp`/`longjmp`, not DWARF unwinding. == PIE Across Target Platforms The design targets PIE (Position-Independent Executable) output because it is the modern default and required for ASLR security. This section records the PIE landscape for all target platforms in the Blaise roadmap. === Linux PIE is the default output format of `gcc` and `clang` on all modern distributions (Ubuntu 17.10+, Fedora 23+, Debian 10+, Arch). GCC is configured with `--enable-default-pie`. The ELF type is `ET_DYN` with a `PT_INTERP` segment pointing to `/lib64/ld-linux-x86-64.so.2`. Our internal linker produces this format. Non-PIE (`ET_EXEC`) would be simpler but is increasingly treated as a second-class citizen by toolchains and some hardened kernels refuse to run non-PIE executables. === FreeBSD FreeBSD 13+ uses LLVM/Clang as the system compiler. PIE executables are fully supported. FreeBSD's `cc` (clang) defaults to PIE on amd64. The ELF structure is the same as Linux with the following differences: * **Dynamic linker path:** `/libexec/ld-elf.so.1` (not `/lib64/ld-linux-x86-64.so.2`). * **ELF OS/ABI brand:** FreeBSD's image activator checks `EI_OSABI = ELFOSABI_FREEBSD` (9) or an `NT_FREEBSD_ABI_TAG` note. The existing ELF writer hardcodes `ELFOSABI_NONE` — the executable writer must parameterise this per target. * **CRT objects:** `Scrt1.o`, `crti.o`, `crtbeginS.o`, `crtendS.o`, `crtn.o` from `/usr/lib/`. * **No libgcc:** FreeBSD uses compiler-rt instead of libgcc. * **libc version:** `libc.so.7` instead of `libc.so.6`. * **Symbol versioning:** FreeBSD libc uses `FBSD_1.x` symbol versions. Unversioned symbol references work (the dynamic linker binds to the default version), so the internal linker does not need to emit `.gnu.version` / `.gnu.version_r` sections initially. The relocation types, GOT/PLT layout, and section merging are identical to Linux. === macOS macOS uses Mach-O, not ELF. PIE is the default and effectively mandatory since macOS 10.7 (Lion). The Mach-O header includes an `MH_PIE` flag. Apple's linker (`ld64` / `ld-prime`) produces PIE by default. The internal linker's ELF code does not apply to macOS. A future Mach-O linker backend would be a separate implementation behind the `ILinker` interface. The segment/section model (segments containing sections, with `__TEXT` and `__DATA` segments) differs from ELF's flat section model. === Windows Windows uses PE (Portable Executable) format. The equivalent of PIE is ASLR, enabled by the `/DYNAMICBASE` linker flag (default since MSVC 2012). PE executables include a `.reloc` section with base relocations. The OS kernel relocates the entire image at load time. Like macOS, a Windows PE linker would be a separate implementation. The PE format has import tables (`.idata`), export tables, and a fundamentally different relocation model from ELF. == Architecture === Interface Design The linker integrates into the compiler via a strategy interface, mirroring the existing `--assembler internal|external` pattern: [source,pascal] ---- type ILinker = interface procedure Link(const AInputFiles: array of string; const AOutputFile: string; const ATarget: TTargetDesc); end; TInternalLinker = class(TObject, ILinker) procedure Link(const AInputFiles: array of string; const AOutputFile: string; const ATarget: TTargetDesc); end; TExternalLinker = class(TObject, ILinker) procedure Link(const AInputFiles: array of string; const AOutputFile: string; const ATarget: TTargetDesc); end; ---- `TExternalLinker` wraps the current `cc` invocation (extracting the existing code from `LinkObjectFile` / `CompileToNativeDirect` in `Blaise.pas`). `TInternalLinker` implements the ELF linking pipeline described below. The compiler selects the implementation via a `--linker internal|external` CLI flag, defaulting to `external` initially (switched to `internal` once validated). === Source Layout [cols="1,3"] |=== | Unit | Purpose | `blaise.linker.pas` | `ILinker` interface, `TExternalLinker`, factory function | `blaise.linker.elf.pas` | `TInternalLinker` — ELF executable writer, relocation resolver, GOT/PLT builder, section merger | `blaise.elfreader.pas` | ELF `.o` and `.a` reader — parses ELF headers, section headers, symbol tables, relocation entries from input files | `blaise.elfwriter.pas` | _Existing_ — ELF relocatable object writer (used by the assembler). Shared constants and low-level helpers reused by the linker. |=== === Data Flow [source] ---- Input .o files ──┐ ├──→ ELF Reader ──→ Section merge ──→ Symbol resolution RTL .a archive ──┘ │ ▼ Relocation resolution │ ┌──────────────────────────────┘ ▼ GOT/PLT generation (for external symbols) │ ▼ Layout: assign virtual addresses to sections │ ▼ Emit ELF executable: ELF header, program headers, sections (.text, .rodata, .data, .bss, .tdata, .tbss, .got, .plt, .dynamic, .interp, .dynsym, .dynstr, .hash, .rela.dyn, .rela.plt, .init_array, .fini_array, .opdf.*), section headers ---- == ELF Linking Pipeline === Phase 1: Read Input Objects Parse each input `.o` file and each member of the `.a` archive: * Read ELF header — validate `ELFCLASS64`, `ELFDATA2LSB`, `ET_REL`, `EM_X86_64`. * Read section headers — collect `.text`, `.data`, `.rodata`, `.bss`, `.tdata`, `.tbss`, `.init_array`, `.fini_array`, `.init`, `.fini`, `.symtab`, `.strtab`, `.rela.*`, and `.opdf.*` sections. * Read symbol table — collect all `STB_GLOBAL` and `STB_LOCAL` symbols with their section index, value (offset within section), size, and type. * Read relocation sections — collect all `RELA` entries (type, symbol index, offset, addend). Archive processing: read the `!\n` magic, parse the GNU extended name table (`//` member) for long filenames (required — `blaise_rtl.a` contains `blaise_setjmp_x86_64.o` which exceeds the 15-character ar name limit), then iterate members and parse each as an ELF object. For the initial implementation, all archive members are included unconditionally. The RTL archive contains ~11 curated object files; full inclusion is acceptable and avoids the complexity of iterative symbol- driven member selection. Selective inclusion (maintain an unresolved symbol set, pull members that define needed symbols, iterate until no new members are pulled) should be added in Phase D — the infrastructure is nearly free since the unresolved-symbol set is already needed for "undefined reference" diagnostics. === Phase 2: Merge Sections Concatenate like-named sections from all input objects: * All `.text` sections → merged `.text` * All `.rodata` sections → merged `.rodata` * All `.data` sections → merged `.data` * All `.bss` sections → merged `.bss` (sizes summed, no data) * All `.tdata` sections → merged `.tdata` (initialised TLS data; `SHF_TLS | SHF_WRITE`, `SHT_PROGBITS`) * All `.tbss` sections → merged `.tbss` (zero-initialised TLS; `SHF_TLS | SHF_WRITE`, `SHT_NOBITS`) * All `.init_array` sections → merged `.init_array` (constructor pointers) * All `.fini_array` sections → merged `.fini_array` (destructor pointers) * All `.opdf.*` sections → passed through into the output (OPDF debug info for the pdr debugger) Sections from CRT objects (`.init`, `.fini`, `.eh_frame`) are merged or dropped as appropriate — `.init`/`.fini` are merged (the CRT prologue/ epilogue objects bracket them), `.eh_frame` is dropped (the Blaise runtime uses `setjmp`/`longjmp`, not DWARF unwinding). For each input section, record its start offset within the merged section. This offset is added to all symbol values and relocation offsets from that input section. Section alignment: each input section has an `sh_addralign` value. The merged section's alignment is the maximum of all input sections' alignments. When concatenating, insert padding to satisfy alignment. === Phase 3: Resolve Symbols Build a global symbol table from all input objects: * For each `STB_GLOBAL` or `STB_WEAK` symbol with a definition (`SHN_UNDEF` excluded): if the name is already in the table, a new `STB_GLOBAL` wins over `STB_WEAK`; two `STB_GLOBAL` definitions is a "duplicate symbol" error. * `STB_LOCAL` symbols are not merged — they remain scoped to their input object. * Undefined symbols (`SHN_UNDEF`): add to the unresolved set. **Linker-defined symbols.** The linker itself must define the following symbols, which are referenced by CRT objects and the RTL: [cols="1,3"] |=== | Symbol | Value | `_GLOBAL_OFFSET_TABLE_` | Address of the `.got` section (referenced by RTL objects `blaise_exc.o`, `blaise_mem.o`) | `__bss_start` | Start of `.bss` | `_edata` | End of initialised data (end of `.data`) | `_end` | End of `.bss` (program break) | `__TMC_END__` | Referenced by `crtbeginS.o`; set to end of `.tm_clone_table` or 0 |=== **Weak undefined symbols.** CRT objects reference symbols like `__gmon_start__`, `_ITM_registerTMCloneTable`, `_ITM_deregisterTMCloneTable`, and `__cxa_finalize` as weak undefined. These must resolve to address 0 without error when no definition exists. After processing all inputs, classify remaining unresolved symbols: * **Weak undefined** → resolve to 0 (no error). * **Strong undefined, name matches a libc function** → generate GOT/PLT entries and `.dynsym` records. * **Strong undefined, not found anywhere** → link error ("undefined reference to ..."). === Phase 4: Build GOT, PLT, and Dynamic Relocations External symbols (those resolved to shared library functions) need indirection. The design uses **eager binding** (`DT_FLAGS BIND_NOW`, `DT_FLAGS_1 NOW`) rather than lazy PLT resolution. This is simpler to implement (no PLT[0] resolver stub, no `_dl_runtime_resolve` protocol) and matches what `cc` produces by default (`-z now`). Eager binding also enables full RELRO (`PT_GNU_RELRO` covering the GOT), which hardened distributions require. **GOT (Global Offset Table):** * Three reserved entries at the start: GOT[0] = address of `.dynamic`, GOT[1] = 0, GOT[2] = 0 (no lazy resolver). * One 8-byte entry per external symbol. * Located in a writable data segment (becomes read-only after RELRO). * At load time, the dynamic linker fills each entry with the symbol's actual address (eager, not lazy). **PLT (Procedure Linkage Table):** With eager binding, PLT stubs are simplified — each is a single indirect jump through the pre-resolved GOT entry: [source,asm] ---- jmpq *GOT_ENTRY(%rip) ; indirect jump through GOT (6 bytes) nop ; padding to 16-byte alignment (10 bytes) ---- No PLT[0] resolver stub is needed. The dynamic linker resolves all `R_X86_64_JUMP_SLOT` entries at load time before `_start` runs. **`.rela.plt` — PLT relocations:** * One `R_X86_64_JUMP_SLOT` relocation per PLT entry, pointing at the corresponding GOT slot. * Processed eagerly at load time due to `DT_FLAGS BIND_NOW`. **`.rela.dyn` — data relocations (critical for PIE):** In a PIE executable (`ET_DYN`), absolute 64-bit addresses in writable sections cannot be resolved at link time because the load base is unknown. Every `R_X86_64_64` relocation against an internally-defined symbol in `.data` (vtable pointers, typeinfo records, class-name `.quad` entries) must be converted to an `R_X86_64_RELATIVE` dynamic relocation: * The linker patches the data section with the link-time value `S + A`. * An `R_X86_64_RELATIVE` entry is emitted in `.rela.dyn` with the offset and addend = `S + A`. * At load time, the dynamic linker adds the load base to the addend and writes the result. The `.dynamic` section must include `DT_RELA`, `DT_RELASZ`, `DT_RELAENT`, and `DT_RELACOUNT` tags for `.rela.dyn`. NOTE: The RTL archive contains ~131 `R_X86_64_64` relocations. Every vtable/typeinfo `.quad` the native backend emits is one. Without `.rela.dyn`, no program using classes can link under PIE. NOTE: `R_X86_64_32S` against a symbol address is a **link error** under PIE (it would be a text relocation, which the loader refuses). The linker must reject this case with a diagnostic rather than silently emitting a wrong value. **GOTPCREL relaxation:** * `R_X86_64_GOTPCRELX` and `R_X86_64_REX_GOTPCRELX` can be relaxed to direct `leaq` when the symbol is defined locally. The internal linker will handle this for internally-defined symbols. === Phase 5: Layout Assign virtual addresses to sections. The layout follows the standard Linux convention: [source] ---- Address space layout: 0x0000 ELF header + program headers .interp .dynsym, .dynstr, .hash .rela.dyn, .rela.plt ─── R segment (read-only, non-executable) ─── 0x0000 + page .text (merged) .init, .fini .plt ─── R+X segment (read + execute) ─── next page .rodata (merged) ─── R segment (read-only) ─── next page .init_array, .fini_array .dynamic .got ─── RELRO boundary (PT_GNU_RELRO covers above) ─── .data (merged) ─── R+W segment (read + write) ─── after .data .bss (merged, no file content) TLS block: .tdata (initialised TLS data, file content) .tbss (zero-initialised TLS, no file content) ─── PT_TLS segment ─── .opdf.* (debug sections, non-loadable) ---- Each `PT_LOAD` segment starts on a page boundary (4096 bytes). **RELRO.** The `.got`, `.dynamic`, `.init_array`, and `.fini_array` sections are placed in the RELRO region — a `PT_GNU_RELRO` program header marks them as read-only after relocation. With eager binding (`DT_FLAGS BIND_NOW`), the dynamic linker mprotects the GOT to read-only after resolving all symbols, preventing GOT-overwrite attacks. **TLS layout.** The `.tdata` and `.tbss` sections form the TLS block, described by a `PT_TLS` program header. The TLS offset formula for x86-64 variant II (used by Linux and FreeBSD) is: `TP_offset = -(align_up(tls_size, tls_align))`, where `tls_size` = `sizeof(.tdata) + sizeof(.tbss)` and `tls_align` = max alignment of the TLS sections. `R_X86_64_TPOFF32` relocations use this negative offset. The entry point is set to the symbol `_start` (provided by `Scrt1.o` from the system CRT). `_start` calls `__libc_start_main`, which calls `main`. === Phase 6: Apply Relocations Walk all relocation entries from the input objects. For each relocation: * Look up the symbol — now has a resolved virtual address. * Compute the final value based on relocation type: [cols="2,4"] |=== | Type | Computation | `R_X86_64_PC32` | `S + A - P` (32-bit, PC-relative) | `R_X86_64_PLT32` | `L + A - P` where L = PLT entry address (32-bit) | `R_X86_64_64` | In writable section: patch `S + A`, emit `R_X86_64_RELATIVE` in `.rela.dyn`. In read-only section: link error (PIE cannot have text relocations). | `R_X86_64_32S` | Link error under PIE — absolute 32-bit address is a text relocation that the loader refuses. The native backend should not emit this for symbol references (only for stack-frame offsets, which are PC-relative). | `R_X86_64_GOTPCREL` | `G + GOT + A - P` (32-bit, GOT-relative) | `R_X86_64_TPOFF32` | `S + A - align_up(TLS_SIZE, TLS_ALIGN)` (negative offset from TP, x86-64 variant II) | `R_X86_64_GOTPCRELX` | Relax to `leaq` if local, else `G + GOT + A - P` | `R_X86_64_REX_GOTPCRELX` | Same as above with REX prefix |=== Where: S = symbol value, A = addend, P = relocation offset (virtual address of the byte being patched), L = PLT entry address, G = GOT entry offset, GOT = GOT base address, TLS_SIZE = total TLS block size. * Patch the bytes at the relocation offset with the computed value. === Phase 7: Emit Executable Write the final ELF executable file: * **ELF header** — `ET_DYN` (PIE), `EM_X86_64`, entry point = `_start`. * **Program headers** — `PT_PHDR`, `PT_INTERP`, `PT_LOAD` × N, `PT_DYNAMIC`, `PT_TLS`, `PT_GNU_STACK` (NX stack). * **Section contents** — merged and relocated `.text`, `.rodata`, `.data`, `.tdata`, `.init_array`, `.fini_array`, `.init`, `.fini`, plus generated `.interp`, `.plt`, `.got`, `.dynamic`, `.dynsym`, `.dynstr`, `.hash`, `.rela.dyn`, `.rela.plt`, and pass-through `.opdf.*`. * **Section headers** — for tooling (objdump, readelf, strip). The `.dynamic` section contains: [cols="1,2"] |=== | Tag | Value | `DT_NEEDED` | `libc.so.6` (and conditionally `libpthread.so.0`, `libm.so.6` for glibc < 2.34 — see Platform Parameterisation) | `DT_SYMTAB` | Address of `.dynsym` | `DT_STRTAB` | Address of `.dynstr` | `DT_STRSZ` | Size of `.dynstr` | `DT_SYMENT` | Size of one `.dynsym` entry (24) | `DT_PLTGOT` | Address of `.got` | `DT_PLTRELSZ` | Size of `.rela.plt` | `DT_PLTREL` | `DT_RELA` (7) | `DT_JMPREL` | Address of `.rela.plt` | `DT_RELA` | Address of `.rela.dyn` | `DT_RELASZ` | Size of `.rela.dyn` | `DT_RELAENT` | Size of one RELA entry (24) | `DT_RELACOUNT` | Number of `R_X86_64_RELATIVE` entries in `.rela.dyn` | `DT_INIT_ARRAY` | Address of `.init_array` | `DT_INIT_ARRAYSZ` | Size of `.init_array` | `DT_FINI_ARRAY` | Address of `.fini_array` | `DT_FINI_ARRAYSZ` | Size of `.fini_array` | `DT_INIT` | Address of `.init` (from CRT) | `DT_FINI` | Address of `.fini` (from CRT) | `DT_FLAGS` | `DF_BIND_NOW` (eager binding) | `DT_FLAGS_1` | `DF_1_NOW \| DF_1_PIE` | `DT_HASH` | Address of SysV symbol hash table (start with SysV `.hash`; add `.gnu.hash` as polish) | `DT_DEBUG` | 0 (filled by the dynamic linker; enables `r_debug` rendezvous for GDB/pdr shared-library awareness) | `DT_NULL` | Terminator |=== == CRT Objects The C runtime startup objects provide the `_start` entry point and initialisation/finalisation scaffolding. On Linux x86-64 with PIE: [cols="1,3"] |=== | Object | Purpose | `Scrt1.o` | Defines `_start`, calls `__libc_start_main(main, argc, argv, ...)` | `crti.o` | `.init` and `.fini` section prologue | `crtn.o` | `.init` and `.fini` section epilogue | `crtbeginS.o` | GCC internal — `.ctors`/`.dtors` registration (PIE variant) | `crtendS.o` | GCC internal — `.ctors`/`.dtors` termination (PIE variant) |=== These are small relocatable objects (< 1 KB each) installed by the C toolchain. The internal linker must locate them at link time. Resolution strategy: 1. Check `$BLAISE_CRT_DIR` environment variable. 2. Probe well-known paths: - `/usr/lib/x86_64-linux-gnu/` (Debian/Ubuntu multiarch) - `/usr/lib64/` (Fedora/RHEL) - `/usr/lib/` (general) 3. For `crtbeginS.o` / `crtendS.o`, probe the GCC install directory: - Run `cc -print-file-name=crtbeginS.o` at configure time, or - Search `/usr/lib/gcc/x86_64-linux-gnu/*/` (Debian) - Search `/usr/lib/gcc/x86_64-linux-gnu-*/` (Debian trixie) If no CRT objects are found, fall back to `TExternalLinker` with a diagnostic message. This avoids a hard failure on systems where the CRT layout is non-standard. **IMPORTANT:** CRT discovery succeeding does not guarantee correctness. On musl-based systems (Alpine Linux), CRT objects exist under `/usr/lib/` but require a different dynamic linker path (`/lib/ld-musl-x86_64.so.1`) and libc name (`libc.musl-x86_64.so.1`). The linker must detect the libc flavour — e.g. by checking for the existence of `/lib/ld-musl-x86_64.so.1` — and parameterise accordingly, or fall back to `TExternalLinker`. A future `--libc glibc|musl` flag can provide explicit control. === Eliminating CRT Dependency (Future) Long-term, the compiler can provide its own `_start` implementation in assembly, eliminating the need for system CRT objects entirely. The minimal `_start` for Linux x86-64 is: [source,asm] ---- .text .globl _start _start: xorl %ebp, %ebp ; mark deepest stack frame movq %rsp, %rdi ; argc+argv on stack andq $-16, %rsp ; align stack to 16 bytes callq _blaise_entry ; Blaise program entry point movl %eax, %edi ; exit code movl $60, %eax ; SYS_exit syscall ---- This removes the libc dependency for `__libc_start_main` but also loses `atexit` handling, stdio buffer flushing, TLS setup, and `.init`/`.fini`/`.ctors`/`.dtors` processing. Worth pursuing after the runtime's libc dependency list is minimised (many RTL functions currently call libc — `mmap`, `write`, `memcpy`, etc.). == Platform Parameterisation The linker's platform-dependent values are collected in a record, keyed by the target triple: [cols="2,2,2,2"] |=== | Parameter | Linux x86-64 | FreeBSD x86-64 | Notes | Dynamic linker | `/lib64/ld-linux-x86-64.so.2` | `/libexec/ld-elf.so.1` | Written into `PT_INTERP` | `EI_OSABI` | `ELFOSABI_NONE` (0) | `ELFOSABI_FREEBSD` (9) | FreeBSD image activator checks this | libc | `libc.so.6` | `libc.so.7` | `DT_NEEDED` entry | Extra `DT_NEEDED` | `libpthread.so.0`, `libm.so.6` (glibc < 2.34 only) | none (FreeBSD libc bundles these) | See note below | Page size | 4096 | 4096 | Segment alignment | CRT objects | `Scrt1.o`, `crti.o`, `crtn.o` | `Scrt1.o`, `crti.o`, `crtn.o` | Same names, different paths | Default base | 0 (PIE) | 0 (PIE) | ET_DYN, loaded by kernel |=== **glibc version note.** glibc 2.34+ merged libpthread and libm into `libc.so.6`, making separate `DT_NEEDED` entries unnecessary. However, older distributions (Debian 10 with glibc 2.28, Ubuntu 18.04 with glibc 2.27) still ship separate `libpthread.so.0` and `libm.so.6`. The internal linker should detect the glibc version at link time (check whether `libpthread.so.0` exists in the library search path) and emit conditional `DT_NEEDED` entries. Alternatively, always emit all three `DT_NEEDED` entries — redundant on glibc 2.34+ but harmless (the loader ignores `DT_NEEDED` for already-loaded libraries). **musl / Alpine Linux.** On musl-based systems, the dynamic linker is `/lib/ld-musl-x86_64.so.1` and libc is `libc.musl-x86_64.so.1`. CRT objects exist under `/usr/lib/` but require the correct interp path. The CRT discovery strategy must detect musl (e.g. by checking for `/lib/ld-musl-x86_64.so.1`) and parameterise accordingly, or fall back to `TExternalLinker`. A `--libc` flag (e.g. `--libc musl`) is a future option for explicit selection. == Implementation Phases === Phase A: ELF Reader + Archive Reader + Section Merge [IMPLEMENTED] * Implement `blaise.elfreader.pas` — parse ELF headers, section headers, symbol tables, relocation entries from `.o` files. * Implement `.a` archive reader — parse the `!\n` header, GNU long-name table (`//`), iterate members. * Merge like-named sections with alignment padding. * Test: read the internal assembler's `.o` output and verify section contents match. Read `blaise_rtl.a` and verify all 11 members parse correctly (validates the long-name table handling). === Phase B: Symbol Resolution + Static Relocations [IMPLEMENTED] * Build global symbol table from merged sections. * Define linker-synthesised symbols (`_GLOBAL_OFFSET_TABLE_`, `__bss_start`, `_edata`, `_end`, `__TMC_END__`). * Resolve weak undefined symbols to 0. * Resolve `R_X86_64_PC32` and `R_X86_64_PLT32` relocations between internally-defined symbols (intra-program calls/references). * Produce a non-PIE `ET_EXEC` executable with a fixed base address and no GOT/PLT — skipping dynamic linking entirely. * Validate by structural comparison: link a hand-crafted `.o` fixture, then compare section contents and relocated bytes against `ld -r` output or the `cc`-linked binary. (Every Blaise program reaches libc through the RTL, so a truly standalone test needs a hand-written fixture that uses `SYS_exit` directly.) Implemented in `TLinker` (`blaise.linker.elf.pas`). Layout assigns virtual addresses at a fixed base (`0x400000`) in two permission runs — an executable run (`.text` + `.rodata`, sharing the first page with the ELF header and program headers) and a writable run (`.data` + `.bss`) starting on a fresh page. `BuildSymbols` enters every `STB_GLOBAL`/`STB_WEAK` definition (global-over-weak, two-globals = duplicate error); `DefineSynthSymbols` adds the five linker symbols; `ResolveSymbolAddr` handles local section-relative, absolute, resolved global, weak-undefined→0, and strong-undefined→error. `ApplyRelocations` patches `R_X86_64_PC32`/`PLT32` as `S + A − P` and rejects the dynamic-only forms (`R_X86_64_64`, GOT/TLS) and absolute 32-bit symbol references with a diagnostic, deferring them to Phase C. `EmitExecutable` writes the `ET_EXEC` image (ELF header, two `PT_LOAD` program headers, relocated section payloads, and a section-header table for tooling) and marks the file executable. Platform/architecture values that differ across the roadmap targets (`Is64`, `OSABI`, `EMachine`, `BaseAddr`, `PageSize`) are collected in `TLinkTarget` rather than hard-coded, so FreeBSD x86-64 (different `EI_OSABI`) and a future i386 ELF target are a new target record, and a PE/Mach-O container would be a sibling writer behind the same symbol/ relocation core. Tests: `TLinkerTests` (symbol resolution — global/weak/duplicate/strong- undefined, synthesised symbols, `PC32` cross-object displacement, `R_X86_64_64` rejection, ELF-header/entry-point structure) and `TLinkerE2ETests` (`TestRun_SyscallHelloWorld` — a hand-written `write`+`exit` syscall object linked internally to an `ET_EXEC`, executed, and asserted on stdout and exit code). `readelf -a` reports the output as a clean statically-linked x86-64 executable. === Phase C: Dynamic Linking + RTL + TLS [IMPLEMENTED] This phase produces the first real runnable Blaise program. It must include the RTL archive and TLS support because the RTL is required for any program that calls `WriteLn` (or any runtime function), and the RTL itself uses TLS (`blaise_mem.o` contains `.tdata` and `.tbss` with 26 `R_X86_64_TPOFF32` relocations). * Read and link CRT startup objects (`Scrt1.o`, `crti.o`, `crtn.o`, `crtbeginS.o`, `crtendS.o`). * Link the RTL archive (`blaise_rtl.a` — all members). * Classify unresolved symbols as external (libc) or weak-undefined. * Generate GOT entries with eager binding, PLT stubs, `.rela.plt`. * Generate `.rela.dyn` with `R_X86_64_RELATIVE` entries for all `R_X86_64_64` relocations in writable sections. * Generate `.dynamic`, `.dynsym`, `.dynstr`, `.hash` sections (start with SysV `.hash`; `.gnu.hash` is polish for Phase E). * Handle TLS: merge `.tdata` and `.tbss`, compute TLS block size with variant-II alignment, emit `PT_TLS`, resolve `R_X86_64_TPOFF32`. * Merge `.init_array`/`.fini_array` from CRT objects; emit `DT_INIT_ARRAY` / `DT_FINI_ARRAY` tags. * Merge `.init`/`.fini` sections from CRT; emit `DT_INIT`/`DT_FINI`. * Add `PT_INTERP`, `PT_DYNAMIC`, `PT_GNU_RELRO`, `PT_GNU_STACK` program headers. * Switch from `ET_EXEC` to `ET_DYN` (PIE). * Test: link a "Hello, world" program that calls `WriteLn`. Link a program using classes (exercises `.rela.dyn` for vtable pointers). Link a program using threadvars (exercises TLS). **Implementation notes:** * `SHF_TLS` was initially defined as `$200` (incorrect); fixed to the correct ELF spec value `$400` in both `blaise.elfreader.pas` and `blaise.elfwriter.pas`. The wrong value caused `.tbss` sections to be placed twice in the layout (once in the normal NOBITS loop that did not recognise them as TLS, once in the explicit TLS placement), producing doubled negative TPOFF32 offsets and segfaults at runtime. * `FTlsAddr` must be read from the post-alignment address (`MergedAddr(M)` after `PlaceSection`), not from the pre-alignment cursor, to avoid off-by-padding TPOFF values. * Duplicate global symbols use first-wins semantics (matching ld behaviour for archive members), since the main program object and RTL archive members may both define system runtime functions. * CRT object discovery (`FindCrtObjects` in `uToolchain.pas`) probes Debian and RedHat layout paths with GCC versions 11–14. * `PhdrCount` is 11 in both `LayoutDynamic` and `EmitDynExecutable`: PT_PHDR, PT_INTERP, PT_LOAD x4, PT_DYNAMIC, PT_TLS, PT_GNU_STACK, PT_GNU_RELRO. PT_TLS is always emitted (zero-sized when no TLS sections) to keep header count stable. === Phase D: Full Integration [IMPLEMENTED] * Wire `TLinker` into the compiler driver behind `--linker internal` (`TNativeBackendDriver.LinkViaInternalLinker`). * `--linker internal` works with both `--assembler internal` and `--assembler external`. * CRT startup objects are discovered automatically via `FindCrtObjects`. * OPDF debug sections (`.opdf.*`) pass through from input objects into the final executable. * Duplicate global symbols handled with first-wins semantics (matching ld). * Full test suite (3536 tests) passes with `--linker internal`. * Self-compilation: the compiler can compile itself and produce a working binary using `--backend native --assembler internal --linker internal` — no external tools in the pipeline. * E2E tests in `TInternalLinkerE2ETests`: HelloWorld, StringOps, ExceptionHandling, ClassAndVirtual. **Remaining Phase D items (not yet implemented):** * Selective archive member inclusion (pull only members that define symbols in the unresolved set, iterate until stable). * Fixpoint verification with `--linker internal` (the binary is structurally correct but the fixpoint scripts do not yet exercise this path). * Differential validation: compile the existing e2e test corpus with both `--linker external` and `--linker internal`, compare stdout and exit codes. === Phase E: Default and Polish * Switch default from `external` to `internal`. * Add FreeBSD x86-64 support (different `PT_INTERP`, `EI_OSABI`, CRT locations, `libc.so.7`). * Detect musl-based systems and parameterise interp/libc accordingly (or fall back to `TExternalLinker`). * Replace SysV `.hash` with `.gnu.hash` (bloom filter, sorted `.dynsym`). * Emit conditional `DT_NEEDED` for `libpthread.so.0` / `libm.so.6` on glibc < 2.34 systems. * Add `--linker external` escape hatch documentation. == Testing Strategy [cols="1,2,3"] |=== | Layer | Tests | What It Catches | Unit tests | `TElfReaderTests` — parse known `.o` files, verify headers, symbols, relocations. Parse `blaise_rtl.a` archive members. | ELF/archive parsing bugs | Unit tests | `TLinkerTests` — merge sections, resolve symbols, apply relocations on synthetic inputs. Must cover every relocation type with manually-verified expected bytes. Include relocation serialisation (`.rela.*` `r_info` packing, symbol remapping after local/global sort). | Relocation arithmetic, symbol resolution logic | E2E tests | `TInternalLinkerE2ETests` — compile small programs with `--linker internal`, run, assert stdout. Must include: class with virtual method, `Double` arithmetic, threadvar, global nil-assignment, exception handling, dynamic array, string operations. | Full pipeline integration | Differential | Compile the existing e2e test corpus with both `--linker external` and `--linker internal`; assert identical stdout and exit codes. Structural comparison of `readelf -l` / `readelf -d` output between the two binaries. | Subtle divergences in section layout, relocation, or symbol resolution | Negative tests | Undefined reference (strong), duplicate symbol (two globals), wrong-class object file (32-bit ELF, non-x86_64) | Error reporting quality and robustness | Native TestRunner | Run the full native TestRunner under `--linker internal` | Catches issues the fixpoint misses (documented in project memory) | Fixpoint | Run `fixpoint.sh` and `fixpoint-native.sh` with `--linker internal` | Self-hosting correctness | Container matrix | Run the e2e suite on Alpine (musl), Debian oldstable (glibc 2.31), Ubuntu 24.04 (glibc 2.39) — at minimum as a manual validation step | CRT discovery and glibc version edge cases |=== == Risk Assessment **Highest risk:** Dynamic relocations for PIE. The `.rela.dyn` / `R_X86_64_RELATIVE` mechanism is conceptually simple but touches every vtable and typeinfo pointer in `.data`. A single missing entry produces a binary that loads but segfaults on the first virtual method call — the symptom is indistinguishable from a codegen bug, making diagnosis difficult. The differential test harness (compare against `cc`-linked binary) is the primary mitigation. **High risk:** TLS layout. The x86-64 variant-II TLS offset formula requires `align_up(tls_size, tls_align)` — a classic off-by-alignment bug. The RTL's `blaise_mem.o` contains both `.tdata` (initialised) and `.tbss` (zero-initialised) TLS sections; a wrong TLS offset corrupts the memory allocator's thread-local state, causing non-deterministic crashes anywhere in the program. **High risk:** Relocation arithmetic. A single off-by-one in `S + A - P` produces a binary that crashes at runtime with no obvious diagnostic. The unit tests for Phase B must cover every relocation type with manually-verified expected values. **Medium risk:** CRT object location. Systems have varying layouts (multiarch, cross-compilation sysroots, musl). The fallback to `TExternalLinker` mitigates this — if CRT objects cannot be found, the compiler degrades gracefully. The musl case is particularly tricky: CRT objects exist but require a different interp path. **Medium risk:** `.init_array` / `.fini_array` ordering. CRT objects bracket these sections (`crtbeginS.o` before, `crtendS.o` after). If the linker merges them in the wrong order, constructors run in the wrong sequence or not at all — a load-time crash with no useful backtrace. **Low risk:** GOT/PLT generation. With eager binding, PLT stubs are trivial (one indirect jump each). No lazy resolver protocol to implement. == Current Linker Dependency Analysis For reference, the current `cc` link line for a simple Blaise program pulls in: * **CRT objects:** `Scrt1.o`, `crti.o`, `crtbeginS.o`, `crtendS.o`, `crtn.o` * **Dynamic libraries:** `libc.so.6` (provides all ~47 undefined symbols in a typical program: `write`, `mmap`, `memcpy`, `exit`, `fork`, `pthread_*`, `clock_gettime`, etc.) * **Implicit flags:** `-pie`, `-z now`, `-z relro`, `--build-id`, `--eh-frame-hdr`, `--hash-style=gnu` The RTL archive (`blaise_rtl.a`) contains 11 object files and ~131 `R_X86_64_64` relocations (vtable/typeinfo pointers). All undefined symbols in the RTL are libc functions — there are no dependencies on libgcc or libstdc++. The `-lm` and `-lpthread` flags on the current link line are unnecessary on glibc 2.34+ (which merged libm and libpthread into `libc.so.6`), but are required on older glibc versions still in use on Debian 10, Ubuntu 18.04, etc.