feat(rtl,linker): static TLS + mutex stubs — a libc-free static binary now RUNS

Completes the runnable-static-binary milestone: `blaise --static` now produces a
fully static, libc-free ELF (statically linked, no NEEDED libc.so.6, no
PT_INTERP) that EXECUTES correctly — `hello` prints, arithmetic/WriteLn work.

Static TLS (the last blocker — threadvar access via %fs faulted because %fs was
unset and static mode emitted no PT_TLS):
- linker LayoutSections now lays out .tdata/.tbss as a TLS block and sets
  FTlsAddr/FTlsSize/FTlsFileSize/FTlsAlign (previously only LayoutDynamic did,
  so static-mode TPOFF relocs resolved against 0).
- EmitExecutable emits a PT_TLS program header when the program has TLS, and
  LayoutSections reserves a 3rd phdr slot so the first section does not overlap
  and overwrite it.
- runtime.start.static.linux: rewritten with a Pascal core (_BlaiseStartC) that
  parses argc/argv/envp + the auxv, reads PT_TLS via AT_PHDR, mmaps + inits the
  TLS block, sets the thread pointer with arch_prctl(ARCH_SET_FS) (variant II:
  TP past the block, TCB self-pointer at %fs:0), then calls main.

runtime.thread.static.linux: no-op single-threaded pthread_mutex_* stubs
(correct for a single-threaded process).  pthread_create is intentionally
unprovided, so a thread-using static program (e.g. `uses classes`) fails to
link — the honest signal that the threads leaf (clone/futex) is the one
remaining piece.

Also fixed: runtime.start.static.linux had its `uses` in the implementation
section (Blaise only honours interface-section uses) which made syscall symbols
invisible; and em-dashes in comments tripped the lexer (known UTF-8 issue).

Full suite green (3829); dynamic native/QBE (incl. TLS + classes) unaffected.
This commit is contained in:
Graeme Geldenhuys 2026-06-26 08:53:48 +01:00
parent 7b6e275053
commit 3e4619a507
6 changed files with 253 additions and 57 deletions

View file

@ -817,12 +817,19 @@ var
M: TMergedSection;
Addr: Int64;
HdrBytes: Int64;
IsAlloc, IsExec, IsWrite: Boolean;
IsAlloc, IsExec, IsWrite, HasTlsSec: Boolean;
begin
{ Program-header count is known: PT_LOAD x2 (exec run, write run).
Reserve header space so the first section's file offset — which
equals (addr - base) — clears the headers. Elf64_Phdr = 56. }
HdrBytes := ELF64_EHDR_SIZE + 2 * 56;
{ Program headers: PT_LOAD x2 (exec run, write run), plus PT_TLS when the
program has any TLS section (.tdata/.tbss). Reserve header space so the
first section's file offset (= addr - base) clears the headers; otherwise
the first section would overlap and overwrite the PT_TLS phdr. Phdr = 56. }
HasTlsSec := False;
for I := 0 to FMerger.Merged.Count - 1 do
if (FMerger.Merged.Get(I).Flags and SHF_TLS) <> 0 then HasTlsSec := True;
if HasTlsSec then
HdrBytes := ELF64_EHDR_SIZE + 3 * 56
else
HdrBytes := ELF64_EHDR_SIZE + 2 * 56;
{ Executable run: header bytes share its first page. }
Addr := FTarget.BaseAddr + HdrBytes;
@ -844,14 +851,16 @@ begin
Self.PlaceSection(M, Addr);
end;
{ Writable run starts on a fresh page. }
{ Writable run starts on a fresh page. TLS sections (.tdata/.tbss, SHF_TLS)
are placed separately below and must be skipped here. }
Addr := LkAlignUp(Addr, FTarget.PageSize);
for I := 0 to FMerger.Merged.Count - 1 do
begin
M := FMerger.Merged.Get(I);
IsAlloc := (M.Flags and SHF_ALLOC) <> 0;
IsWrite := (M.Flags and SHF_WRITE) <> 0;
if IsAlloc and IsWrite and (M.ShType <> SHT_NOBITS) then
if IsAlloc and IsWrite and (M.ShType <> SHT_NOBITS)
and ((M.Flags and SHF_TLS) = 0) then
Self.PlaceSection(M, Addr);
end;
for I := 0 to FMerger.Merged.Count - 1 do
@ -859,9 +868,36 @@ begin
M := FMerger.Merged.Get(I);
IsAlloc := (M.Flags and SHF_ALLOC) <> 0;
IsWrite := (M.Flags and SHF_WRITE) <> 0;
if IsAlloc and IsWrite and (M.ShType = SHT_NOBITS) then
if IsAlloc and IsWrite and (M.ShType = SHT_NOBITS)
and ((M.Flags and SHF_TLS) = 0) then
Self.PlaceSection(M, Addr);
end;
{ TLS sections (.tdata then .tbss). Mirror LayoutDynamic so a static ET_EXEC
also gets a correct TLS block + FTlsAddr/FTlsSize for TPOFF relocations and
the PT_TLS program header (which the freestanding _start reads to set up the
thread pointer). }
FTlsAddr := 0;
FTlsSize := 0;
FTlsAlign := 1;
FTlsFileSize := 0;
M := FMerger.FindMerged('.tdata');
if M <> nil then
begin
Self.PlaceSection(M, Addr);
FTlsAddr := Self.MergedAddr(M);
FTlsFileSize := M.Size;
FTlsSize := M.Size;
if M.Align > FTlsAlign then FTlsAlign := M.Align;
end;
M := FMerger.FindMerged('.tbss');
if M <> nil then
begin
Self.PlaceSection(M, Addr);
if FTlsAddr = 0 then FTlsAddr := Self.MergedAddr(M);
FTlsSize := FTlsSize + M.Size;
if M.Align > FTlsAlign then FTlsAlign := M.Align;
end;
end;
function TLinker.FindSymbol(const AName: string): TLinkSymbol;
@ -1804,7 +1840,12 @@ begin
Buf := Buf + LkLE(0, 4); { e_flags }
Buf := Buf + LkLE(ELF64_EHDR_SIZE, 2); { e_ehsize }
Buf := Buf + LkLE(56, 2); { e_phentsize }
Buf := Buf + LkLE(2, 2); { e_phnum (2 PT_LOAD) }
{ 2 PT_LOAD, plus PT_TLS when the program has threadvars (so the static
_start can set up the thread pointer from the PT_TLS init image). }
if FTlsSize > 0 then
Buf := Buf + LkLE(3, 2) { e_phnum }
else
Buf := Buf + LkLE(2, 2);
Buf := Buf + LkLE(ELF64_SHDR_SIZE, 2); { e_shentsize }
Buf := Buf + LkLE(0, 2); { e_shnum (patched later) }
Buf := Buf + LkLE(0, 2); { e_shstrndx (patched later) }
@ -1827,6 +1868,20 @@ begin
Buf := Buf + LkLE(WriteMemHi - WriteLo, 8); { p_memsz }
Buf := Buf + LkLE(PageSz, 8); { p_align }
{ PT_TLS — the initial TLS image (.tdata file bytes + .tbss zero size). The
static _start reads this segment to build the per-thread block and set %fs.
Only emitted when the program has threadvars (e_phnum reflects this). }
if FTlsSize > 0 then
begin
Buf := Buf + LkLE(PT_TLS, 4) + LkLE(PF_R, 4);
Buf := Buf + LkLE(Self.FileOffset(FTlsAddr), 8); { p_offset }
Buf := Buf + LkLE(FTlsAddr, 8); { p_vaddr }
Buf := Buf + LkLE(FTlsAddr, 8); { p_paddr }
Buf := Buf + LkLE(FTlsFileSize, 8); { p_filesz (.tdata) }
Buf := Buf + LkLE(FTlsSize, 8); { p_memsz (.tdata + .tbss) }
Buf := Buf + LkLE(FTlsAlign, 8); { p_align }
end;
{ Pad headers out to the first section's file offset (the exec run's
first byte sits at Self.FileOffset(ExecLo)). }
if Length(Buf) < Self.FileOffset(ExecLo) then

View file

@ -19,8 +19,8 @@ unit runtime.cstub;
// (the inline-asm assembler already encodes the vector ops).
//
// Signatures match the C ABI the codegen assumes:
// void* memcpy(void* dst, const void* src, size_t n) — returns dst
// void* memset(void* dst, int c, size_t n) — returns dst
// void* memcpy(void* dst, const void* src, size_t n) - returns dst
// void* memset(void* dst, int c, size_t n) - returns dst
// int memcmp(const void* a, const void* b, size_t n)
// size_t strlen(const char* s)

View file

@ -8,31 +8,24 @@
unit runtime.libc.linux;
// libc-shaped leaves that are MORE than a raw syscall — they need logic the
// libc-shaped leaves that are MORE than a raw syscall - they need logic the
// kernel does not provide: PATH/env lookup, the libc-style return shapes, etc.
// Built on the raw syscalls in runtime.syscall.linux. Linked only in a
// --static (libc-free) build (docs/linux-syscall-migration.adoc).
//
// These DEFINE the bare POSIX names (getcwd, getenv, …) that rtl.platform.posix
// These DEFINE the bare POSIX names (getcwd, getenv, ) that rtl.platform.posix
// imports via `external name`, so they resolve here instead of libc.so.6.
interface
uses
runtime.syscall.linux;
{ The process environment vector (NULL-terminated array of "KEY=VALUE" PChars).
Captured by the freestanding _start (runtime.start.static.linux) from the
initial process stack; libc would otherwise own this symbol. getenv/execvp
read it. }
var
environ: Pointer;
runtime.syscall.linux; { provides `environ`, captured by _start }
{ libc-shaped getcwd: returns Buf on success, nil on error (the raw syscall
returns a length / -errno). }
function getcwd(Buf: PChar; Size: Int64): PChar;
{ getenv: linear scan of `environ` for "Name=" → returns a pointer to the value
{ getenv: linear scan of `environ` for "Name=" returns a pointer to the value
(just past '='), or nil if not present. }
function getenv(Name: PChar): PChar;
@ -47,7 +40,7 @@ function waitpid(Pid: Integer; Status: Pointer; Options: Integer): Integer;
NULL-terminated argument vector; the current `environ` is passed as envp. }
function execvp(File_: PChar; Argv: Pointer): Integer;
{ sysconf for the few names the RTL queries — currently only the online CPU
{ sysconf for the few names the RTL queries - currently only the online CPU
count (_SC_NPROCESSORS_ONLN = 84), via sched_getaffinity + popcount. }
function sysconf(Name: Integer): Int64;
@ -103,7 +96,7 @@ begin
if environ = nil then Exit;
NLen := CStrLen(Name);
{ environ is a NULL-terminated array of PChar; read the I-th slot as a
Pointer at environ + I*8 (PPointer deref, the RTL idiom — no [] on a typed
Pointer at environ + I*8 (PPointer deref, the RTL idiom - no [] on a typed
pointer). }
Off := 0;
Entry := PChar(PPointer(Pointer(PChar(environ) + Off))^);
@ -148,7 +141,7 @@ begin
if HasSlash then
Result := execve(File_, Argv, environ)
else
{ No PATH search yet — try as-is so the failure is the kernel's ENOENT
{ No PATH search yet - try as-is so the failure is the kernel's ENOENT
rather than a silent wrong result. }
Result := execve(File_, Argv, environ);
end;

View file

@ -199,7 +199,7 @@ end;
function localtime_r(T: Pointer; Tm: Pointer): Pointer;
begin
{ No timezone database in the freestanding RTL — treat local as UTC. }
{ No timezone database in the freestanding RTL - treat local as UTC. }
Result := gmtime_r(T, Tm);
end;

View file

@ -8,43 +8,184 @@
unit runtime.start.static.linux;
// Freestanding `_start` for a static, libc-free Linux ET_EXEC (the --static
// Freestanding _start for a static, libc-free Linux ET_EXEC (the --static
// kernel-leaf swap; docs/linux-syscall-migration.adoc).
//
// The libc-backed runtime.start calls __libc_start_main, which only exists when
// linking libc. This variant is the drop-in replacement linked instead when a
// program is built --static: it sets up argc/argv from the raw process stack and
// jumps straight to `main` (which the backend emits to call _SetArgs/_BlaiseInit,
// run the body, and `exit`).
// A tiny asm trampoline captures the initial stack pointer and tail-calls the
// Pascal _BlaiseStartC, which:
// 1. parses argc / argv / envp / the auxv (the kernel's initial stack);
// 2. sets up the static TLS block and the thread pointer (%fs) - required
// before any threadvar (%fs-relative) access, which a static binary's
// kernel does NOT do for us;
// 3. captures `environ`;
// 4. calls main(argc, argv);
// 5. exit_group with main's return (main itself calls exit, so this is a
// guard).
//
// On entry the kernel hands us the initial process stack (System V AMD64,
// "Initial Process Stack"):
// (%rsp) = argc
// 8(%rsp) = argv[0] … argv[argc-1], then a NULL,
// 8+(argc+1)*8(%rsp) = envp[0] … then a NULL, then auxv.
// So envp = &argv[argc+1] = %rsp + (argc+2)*8. We capture it into the global
// `environ` (which getenv/execvp read) before calling main.
//
// main expects C-`main(argc, argv)` register layout: argc in %edi, argv in %rsi.
// x86-64 TLS (variant II): the thread pointer points at the TCB, which sits
// ABOVE the TLS block; threadvars are at negative offsets from the TP. %fs:0
// must hold the TP value itself (the TCB self-pointer). Layout we build:
// [ TLS block: memsz bytes ][ TCB: 8-byte self-pointer ]
// tp = block + memsz_aligned ; %fs = tp ; *(void**)tp = tp.
interface
uses
runtime.syscall.linux; { syscalls + the `environ` global }
procedure _start;
implementation
type
PPointer = ^Pointer;
PInt64 = ^Int64;
const
ARCH_SET_FS = $1002;
PROT_RW = 3; { PROT_READ or PROT_WRITE }
MAP_PRIVANON = $22; { MAP_PRIVATE or MAP_ANONYMOUS }
AT_NULL = 0;
AT_PHDR = 3;
AT_PHENT = 4;
AT_PHNUM = 5;
PT_TLS = 7;
{ ELF64 Phdr field offsets. }
PH_TYPE = 0; { p_type (u32) }
PH_OFFSET = 8; { p_offset (u64) - file offset; == vaddr-base in our images }
PH_VADDR = 16; { p_vaddr (u64) }
PH_FILESZ = 32; { p_filesz (u64) }
PH_MEMSZ = 40; { p_memsz (u64) }
PH_ALIGN = 48; { p_align (u64) }
{ Round X up to the next multiple of A (A a power of two, >= 1). }
function AlignUp(X, A: Int64): Int64;
begin
if A < 1 then A := 1;
Result := (X + A - 1) and (not (A - 1));
end;
{ Walk the auxv for PT_TLS via AT_PHDR/AT_PHENT/AT_PHNUM, then build the static
TLS block and set the thread pointer. Auxv is an array of (Int64 tag, Int64
val) pairs terminated by AT_NULL. No-op when the program has no TLS. }
procedure SetupTLS(Auxv: Pointer);
var
P: PInt64;
PhdrAddr, PhEnt, PhNum: Int64;
I, Tag, Val: Int64;
Ph: PChar;
TlsVaddr, TlsFileSz, TlsMemSz, TlsAlign: Int64;
PType: ^Integer;
Block, Tp: Pointer;
BlockSize: Int64;
J: Int64;
Src, Dst: PChar;
TpSlot: PPointer;
begin
PhdrAddr := 0; PhEnt := 56; PhNum := 0;
P := PInt64(Auxv);
while True do
begin
Tag := P^;
P := PInt64(Pointer(PChar(P) + 8));
Val := P^;
P := PInt64(Pointer(PChar(P) + 8));
if Tag = AT_NULL then Break;
if Tag = AT_PHDR then PhdrAddr := Val;
if Tag = AT_PHENT then PhEnt := Val;
if Tag = AT_PHNUM then PhNum := Val;
end;
if PhdrAddr = 0 then Exit;
{ Find PT_TLS among the program headers. }
TlsVaddr := 0; TlsFileSz := 0; TlsMemSz := 0; TlsAlign := 8;
I := 0;
while I < PhNum do
begin
Ph := PChar(Pointer(PhdrAddr + I * PhEnt));
PType := Pointer(Ph + PH_TYPE);
if PType^ = PT_TLS then
begin
TlsVaddr := PInt64(Pointer(Ph + PH_VADDR))^;
TlsFileSz := PInt64(Pointer(Ph + PH_FILESZ))^;
TlsMemSz := PInt64(Pointer(Ph + PH_MEMSZ))^;
TlsAlign := PInt64(Pointer(Ph + PH_ALIGN))^;
Break;
end;
I := I + 1;
end;
if TlsMemSz = 0 then Exit;
{ Allocate block = aligned(memsz) + 8 (TCB self-pointer). mmap zero-fills. }
BlockSize := AlignUp(TlsMemSz, TlsAlign) + 16;
Block := mmap(nil, BlockSize, PROT_RW, MAP_PRIVANON, -1, 0);
if Int64(Block) < 0 then Exit;
{ Copy the .tdata init image to the start of the block; .tbss stays zero. }
Src := PChar(Pointer(TlsVaddr));
Dst := PChar(Block);
J := 0;
while J < TlsFileSz do
begin
Dst[J] := Src[J];
J := J + 1;
end;
{ Thread pointer sits just past the TLS data (variant II); store the TCB
self-pointer at *tp and set %fs. }
Tp := Pointer(PChar(Block) + AlignUp(TlsMemSz, TlsAlign));
TpSlot := PPointer(Tp);
TpSlot^ := Tp;
arch_prctl(ARCH_SET_FS, Tp);
end;
{ Call the program's `main(argc, argv)` (emitted by the backend) and return its
result. The asm thunk tail-jumps to the bare `main` symbol; argc/argv are
already in %edi/%rsi (SysV), exactly what main expects. }
function MainTrampoline(Argc: Integer; Argv: Pointer): Integer;
assembler; nostackframe;
asm
jmp main
end;
{ The C-level entry: SP points at the kernel's initial stack (argc at [SP]). }
procedure _BlaiseStartC(SP: Pointer);
var
Argc: Int64;
Argv, Envp, Auxv: Pointer;
I: Int64;
Ret: Integer;
begin
Argc := PInt64(SP)^;
Argv := Pointer(PChar(SP) + 8);
{ envp = &argv[argc+1] }
Envp := Pointer(PChar(Argv) + (Argc + 1) * 8);
environ := Envp;
{ auxv follows envp's NULL terminator. }
Auxv := Envp;
I := 0;
while PPointer(Pointer(PChar(Auxv) + I * 8))^ <> nil do
I := I + 1;
Auxv := Pointer(PChar(Auxv) + (I + 1) * 8);
SetupTLS(Auxv);
Ret := MainTrampoline(Integer(Argc), Argv);
_exit(Ret);
end;
{ The kernel entry. Capture %rsp (points at argc), align, and call the Pascal
core. Never returns. }
procedure _start; assembler; nostackframe;
asm
endbr64
xor %ebp, %ebp { clear frame pointer — outermost frame }
movq (%rsp), %rdi { %rdi = argc }
leaq 8(%rsp), %rsi { %rsi = &argv[0] }
leaq 16(%rsp,%rdi,8), %rax { environ = &argv[argc+1] = %rsp+16+argc*8 }
movq %rax, environ(%rip)
andq $0xfffffffffffffff0, %rsp { 16-byte align before the call }
call main
xorl %edi, %edi { main terminates via exit; guard with exit_group(0) }
movq $231, %rax { SYS_exit_group }
xor %ebp, %ebp
movq %rsp, %rdi { SP -> first arg }
andq $0xfffffffffffffff0, %rsp
call _BlaiseStartC
xorl %edi, %edi
movq $231, %rax { exit_group(0) guard }
syscall
hlt
end;

View file

@ -8,13 +8,13 @@
unit runtime.syscall.linux;
// Linux x86_64 direct-syscall kernel leaf — the libc replacement for the thin
// Linux x86_64 direct-syscall kernel leaf - the libc replacement for the thin
// 1:1 syscall wrappers (docs/linux-syscall-migration.adoc).
//
// This unit DEFINES the bare POSIX names (open, read, write, …) that
// This unit DEFINES the bare POSIX names (open, read, write, ) that
// rtl.platform.posix.pas references via `external name 'open'` etc. When it is
// linked into a program (the --static link-time swap), those symbols resolve
// here as raw `syscall` stubs instead of being imported from libc.so.6 — so the
// here as raw `syscall` stubs instead of being imported from libc.so.6 - so the
// resulting binary is a freestanding static ET_EXEC with no libc, no PT_INTERP.
//
// System V AMD64 -> Linux syscall ABI:
@ -64,7 +64,7 @@ function pipe(Fds: Pointer): Integer;
function mmap(Addr: Pointer; Length: Int64; Prot, Flags, Fd: Integer;
Offset: Int64): Pointer;
function munmap(Addr: Pointer; Length: Int64): Integer;
{ mremap(old, oldsz, newsz, flags, newaddr) — 5 args; raw syscall. }
{ mremap(old, oldsz, newsz, flags, newaddr) - 5 args; raw syscall. }
function mremap(OldAddr: Pointer; OldSize, NewSize: Int64; Flags: Integer;
NewAddr: Pointer): Pointer;
@ -74,7 +74,7 @@ function clock_gettime(ClockId: Integer; Ts: Pointer): Integer;
{ Process control + raw primitives the higher-level wrappers (runtime.libc.linux)
build on. These are the bare syscalls; the libc-shaped versions (execvp with
PATH search, waitpid, system, …) live in the wrapper unit. }
PATH search, waitpid, system, ) live in the wrapper unit. }
function fork: Integer;
function execve(Path: PChar; Argv, Envp: Pointer): Integer;
function wait4(Pid: Integer; Status: Pointer; Options: Integer;
@ -88,15 +88,22 @@ function kill(Pid, Sig: Integer): Integer;
written there. The kernel syscall handles the out-param itself. }
function sys_time(T: Pointer): Int64;
{ Raw getcwd(2): fills Buf (up to Size), returns the length INCLUDING the NUL on
success or -errno. (libc's getcwd returns the buffer pointer — the wrapper in
success or -errno. (libc's getcwd returns the buffer pointer - the wrapper in
runtime.libc.linux adapts that.) }
function sys_getcwd(Buf: PChar; Size: Int64): Int64;
{ _exit(2) — exit_group, terminates all threads, runs NO atexit handlers.
{ _exit(2) - exit_group, terminates all threads, runs NO atexit handlers.
The bare `exit` (which DOES run atexit handlers, called by main's epilogue) is
defined by runtime.libc2.linux alongside the registry, not here. }
procedure _exit(Code: Integer);
{ The process environment vector (NULL-terminated array of "KEY=VALUE" PChars),
captured by the freestanding _start from the initial stack. Lives in this
base unit (not the higher libc wrapper) so _start can use ONLY this unit and
avoid a diamond import; runtime.libc.linux's getenv/execvp read it. }
var
environ: Pointer;
implementation
{ ---- Linux x86_64 syscall numbers (arch/x86/entry/syscalls). ---- }