feat(codegen): native linker builds the RTL from source, drops blaise_rtl.a

RTL-unification Stage 2 (docs/rtl-unification-plan.adoc): the native internal
linker no longer links a pre-built blaise_rtl.a.  Instead it compiles the
implicit RTL units (runtime.* + rtl.platform.*) from source into an object cache
beside the compiler (compiler/target/rtl/*.o) and links those, recompiling a
unit only when its cached .o is missing or older than the source.

- EnsureRTLObjects (native driver) drives the per-unit compile via self-invoke
  (blaise --no-incremental --assembler internal --source U.pas), the proven
  per-unit path; LinkViaInternalLinker adds the cached .o via AddOwnedObject
  instead of AddArchive.
- uToolchain.RTLSourceDir resolves the RTL source robustly: binary-relative
  (compiler/target -> ../src/main/pascal), then CWD-relative (handles a binary
  copied to /tmp but invoked from the project root, as the fixpoints do).
- New --rtl-src DIR flag (TBackendOpts.RTLSrcDir) + $BLAISE_RTL_SRC for a
  relocated/release binary; the driver fails loudly when the RTL source is
  unreachable rather than emitting a broken binary.

Performance: warm cache adds ~0ms (RTL skipped); a cold full RTL recompile is
~0.4s.  Scope: this changes the NATIVE internal-linker path only — the QBE
backend and cc-link paths still use blaise_rtl.a, so the archive remains a build
artifact (the fixpoint scripts and QBE-based tests still build it).

TestLink_MissingRTL_FailsLoudly rewritten for the new contract (--rtl-src at a
nonexistent dir must fail naming the RTL source).  All four fixpoints OK; full
suite 3829 green; cold-bootstrap from the existing release works.
This commit is contained in:
Graeme Geldenhuys 2026-06-25 17:40:18 +01:00
parent ac9e2fa0b1
commit d85ff170cf
5 changed files with 180 additions and 53 deletions

View file

@ -231,6 +231,11 @@ begin
Inc(I);
AFront.UnitCacheDir := ParamStr(I);
end
else if (Arg = '--rtl-src') and (I < ParamCount()) then
begin
Inc(I);
AOpts.RTLSrcDir := ParamStr(I);
end
else if Arg = '--emit-ir' then
AFront.EmitIR := True
else if Arg = '--emit-asm' then

View file

@ -88,6 +88,10 @@ type
UseInternalLinker: Boolean; { --linker internal (native backend) }
LinkerExplicit: Boolean; { True when user passed --linker }
LinkerChoiceBad: Boolean; { --linker value not 'internal' or 'external' }
RTLSrcDir: string; { --rtl-src DIR: explicit RTL source directory.
Overrides the binary/CWD-relative lookup, for
a relocated/release binary whose RTL source
lives elsewhere (empty = use default lookup) }
end;
TBackendDriver = class

View file

@ -69,6 +69,13 @@ type
private
function LinkViaInternalLinker(const AObjFile, AOutputFile: string;
AOpts: TBackendOpts; AExtraObjects: TStringList): string;
{ Compile the implicit RTL units to a per-compiler object cache and return
their .o paths in AObjPaths (link order). Each unit is (re)compiled only
when its cached .o is missing or older than the source. Replaces the
pre-built blaise_rtl.a: the RTL is now built from source by the compiler
itself (docs/rtl-unification-plan.adoc). '' on success, else an error. }
function EnsureRTLObjects(AOpts: TBackendOpts;
AObjPaths: TStringList): string;
end;
implementation
@ -182,19 +189,90 @@ begin
Result := 'cc -c error (exit ' + IntToStr(ExitCode) + '): ' + Msg;
end;
{ The implicit RTL units, in archive/link order. The compiler emits calls to
their symbols (_SetArgs, _BlaiseGetMem, _start, ARC helpers, ) in every
program, so every program links them. Names are the dotted-flat unit names;
the source files are <name>.pas in the RTL source directory. }
const
RTL_UNITS: array[0..13] of string = (
'runtime.start', 'runtime.atomic', 'runtime.setjmp', 'runtime.utf8',
'runtime.mem', 'runtime.str', 'runtime.set', 'runtime.arc',
'runtime.weak', 'runtime.float', 'runtime.thread', 'runtime.exc',
'rtl.platform.layout.linux', 'rtl.platform.posix');
function TNativeBackendDriver.EnsureRTLObjects(AOpts: TBackendOpts;
AObjPaths: TStringList): string;
var
SrcDir, CacheDir, BlaiseBin: string;
SrcFile, ObjFile: string;
I, ExitCode: Integer;
Args: TStringList;
Msg: string;
begin
Result := '';
{ RTL source lives in the compiler's own source tree. Resolution order:
1. --rtl-src DIR (AOpts.RTLSrcDir) explicit, for a relocated binary;
2. $BLAISE_RTL_SRC;
3. binary/CWD-relative default (RTLSourceDir). }
if AOpts.RTLSrcDir <> '' then
SrcDir := ExpandFileName(AOpts.RTLSrcDir)
else
begin
SrcDir := GetEnvironmentVariable('BLAISE_RTL_SRC');
if SrcDir = '' then
SrcDir := ExpandFileName(RTLSourceDir());
end;
if not DirectoryExists(SrcDir) then
Exit('internal linker: RTL source directory not found (' + SrcDir +
'); pass --rtl-src DIR or set $BLAISE_RTL_SRC to compiler/src/main/pascal');
{ Object cache lives beside the compiler binary so repeated program builds
reuse it (compiler/target/rtl/). }
CacheDir := IncludeTrailingPathDelimiter(CompilerBinDir()) + 'rtl';
ForceDirectories(CacheDir);
BlaiseBin := ParamStr(0);
for I := 0 to High(RTL_UNITS) do
begin
SrcFile := IncludeTrailingPathDelimiter(SrcDir) + RTL_UNITS[I] + '.pas';
ObjFile := IncludeTrailingPathDelimiter(CacheDir) + RTL_UNITS[I] + '.o';
if not FileExists(SrcFile) then
Exit('internal linker: RTL unit source missing: ' + SrcFile);
{ Recompile only when the cached object is missing or stale. }
if (not FileExists(ObjFile)) or (FileAge(ObjFile) < FileAge(SrcFile)) then
begin
Args := TStringList.Create();
try
Args.Add('--no-incremental');
Args.Add('--assembler'); Args.Add('internal');
Args.Add('--source'); Args.Add(SrcFile);
Args.Add('--unit-path'); Args.Add(SrcDir);
Args.Add('--output'); Args.Add(ObjFile);
ExitCode := RunProcess(BlaiseBin, Args, Msg);
finally
Args.Free();
end;
if ExitCode <> 0 then
Exit('internal linker: failed to build RTL unit ' + RTL_UNITS[I] +
' (exit ' + IntToStr(ExitCode) + '): ' + Msg);
end;
AObjPaths.Add(ObjFile);
end;
end;
function TNativeBackendDriver.LinkViaInternalLinker(
const AObjFile, AOutputFile: string;
AOpts: TBackendOpts; AExtraObjects: TStringList): string;
var
Lk: TLinker;
Obj: TElfObjectFile;
TC: TToolchain;
Toolkit: TTargetToolkit;
LinkTarget: TLinkTarget;
RTLObjs: TStringList;
I: Integer;
begin
Result := '';
TC := ResolveToolchain(AOpts.Target);
{ Build the linker with the resolved target's TLinkTarget so the emitted ELF
carries the right EI_OSABI / machine / load base for AOpts.Target without
@ -206,49 +284,53 @@ begin
Exit('internal linker: no toolkit registered for target ' +
TargetName(AOpts.Target));
{ Every Blaise program links blaise_rtl.a the program entry emits
_SetArgs and the runtime supplies ARC, strings, exceptions, the platform
layer, etc. An empty RTLPath means FindRTLArchive could not locate
blaise_rtl.a beside the compiler binary (or via BLAISE_RTL). Linking
anyway is never correct: every RTL symbol becomes an undefined dynamic
import that silently links but dies at run time (e.g. `undefined symbol:
_SetArgs`). Fail loudly instead of emitting a broken executable. }
if TC.RTLPath = '' then
Exit('internal linker: runtime archive blaise_rtl.a not found '
+ '(looked beside the compiler binary and in $BLAISE_RTL); '
+ 'cannot link a Blaise program without the RTL');
{ TLinker.Create(ATarget) borrows the target — we own and free it here. }
LinkTarget := Toolkit.MakeLinkTarget();
Lk := TLinker.Create(LinkTarget);
{ Build the implicit RTL objects from source (cached beside the compiler).
Every Blaise program emits calls to RTL symbols (_SetArgs, _BlaiseGetMem,
_start, ARC/string/exception helpers, ) as undefined externals; these
objects supply them. Replaces the pre-built blaise_rtl.a the RTL is now
compiled by the compiler itself. }
RTLObjs := TStringList.Create();
try
Result := Self.EnsureRTLObjects(AOpts, RTLObjs);
if Result <> '' then Exit;
{ TLinker.Create(ATarget) borrows the target — we own and free it here. }
LinkTarget := Toolkit.MakeLinkTarget();
Lk := TLinker.Create(LinkTarget);
try
Lk.SetDynamic(True);
try
Lk.SetDynamic(True);
Obj := ReadElfObjectFile(AObjFile);
Lk.AddOwnedObject(Obj);
Obj := ReadElfObjectFile(AObjFile);
Lk.AddOwnedObject(Obj);
if AExtraObjects <> nil then
for I := 0 to AExtraObjects.Count - 1 do
if AExtraObjects <> nil then
for I := 0 to AExtraObjects.Count - 1 do
begin
Obj := ReadElfObjectFile(AExtraObjects.Strings[I]);
Lk.AddOwnedObject(Obj);
end;
{ The RTL objects including _start (runtime.start) and the implicit
runtime symbols built from source above. }
for I := 0 to RTLObjs.Count - 1 do
begin
Obj := ReadElfObjectFile(AExtraObjects.Strings[I]);
Obj := ReadElfObjectFile(RTLObjs.Strings[I]);
Lk.AddOwnedObject(Obj);
end;
{ _start now lives in blaise_rtl.a (blaise_start_x86_64.s) no system
Scrt1.o / crtbegin / crtend / crti / crtn needed. AddArchive adds all
members unconditionally, so the entry symbol is always present. }
Lk.AddArchive(TC.RTLPath);
Lk.Link('_start', AOutputFile);
except
on E: Exception do
Result := 'internal linker error [' + Exception(E).ClassName + ']: ' +
Exception(E).Message;
Lk.Link('_start', AOutputFile);
except
on E: Exception do
Result := 'internal linker error [' + Exception(E).ClassName + ']: ' +
Exception(E).Message;
end;
finally
Lk.Free();
LinkTarget.Free();
end;
finally
Lk.Free();
LinkTarget.Free();
RTLObjs.Free();
end;
end;

View file

@ -89,6 +89,14 @@ function ResolveAssembler: TTool;
function ResolveLinker(const ATarget: TTargetDesc): TTool;
function FindRTLArchive(const ATarget: TTargetDesc): string;
{ Directory containing the running compiler binary (ParamStr(0)). }
function CompilerBinDir: string;
{ RTL source directory (compiler/src/main/pascal), relative to the binary.
The driver builds the RTL objects from here when linking; overridable via
$BLAISE_RTL_SRC. }
function RTLSourceDir: string;
{ Walks $PATH for the first file named ABaseName that exists. Returns the
absolute path on hit, '' on miss. On Windows hosts also tries '.exe'. }
function WhichInPath(const ABaseName: string): string;
@ -319,6 +327,42 @@ begin
Result := ExtractFilePath(ParamStr(0));
end;
function RTLDirHasUnits(const ADir: string): Boolean;
begin
Result := (ADir <> '') and
FileExists(IncludeTrailingPathDelimiter(ADir) + 'runtime.arc.pas');
end;
function RTLSourceDir: string;
var
Cand: string;
begin
{ The RTL units (runtime.*, rtl.platform.*) live in the compiler's own source
tree. Resolve the directory robustly across the ways the compiler is run:
1. binary-relative: <bindir>/../src/main/pascal the in-tree
compiler/target/blaise.
2. CWD-relative: compiler/src/main/pascal then src/main/pascal the
binary may be copied elsewhere (e.g. /tmp during a fixpoint) but
invoked from the project root by the build.
The driver consults $BLAISE_RTL_SRC before calling this, for a fully
relocated binary (release) that ships its RTL source elsewhere. }
Cand := IncludeTrailingPathDelimiter(CompilerBinDir()) + '../src/main/pascal';
if RTLDirHasUnits(ExpandFileName(Cand)) then Exit(ExpandFileName(Cand));
Cand := 'compiler/src/main/pascal';
if RTLDirHasUnits(ExpandFileName(Cand)) then Exit(ExpandFileName(Cand));
Cand := 'src/main/pascal';
if RTLDirHasUnits(ExpandFileName(Cand)) then Exit(ExpandFileName(Cand));
{ Fall back to the binary-relative path so callers get a meaningful "not
found" message naming a concrete location. }
Result := ExpandFileName(IncludeTrailingPathDelimiter(CompilerBinDir())
+ '../src/main/pascal');
end;
function FindRTLArchive(const ATarget: TTargetDesc): string;
var
BinDir: string;

View file

@ -1459,17 +1459,7 @@ begin
FCounter := FCounter + 1;
IsoDir := FScratch + 'no_rtl_' + IntToStr(FCounter) + '/';
ForceDirectories(IsoDir);
IsoCompiler := IsoDir + 'blaise';
{ Symlink the real compiler into the isolated dir argv[0]'s dirname is the
isolated dir, which has NO blaise_rtl.a beside it, so FindRTLArchive
returns ''. (A symlink avoids copying the multi-MB binary and the
byte-exactness pitfalls of a string round-trip.) }
if FileExists(IsoCompiler) then DeleteFile(IsoCompiler);
if _test_symlink(PChar(FCompiler), PChar(IsoCompiler)) <> 0 then
begin
Ignore('<symlink-unavailable>');
Exit;
end;
IsoCompiler := FCompiler;
SrcFile := IsoDir + 'prog.pas';
OutFile := IsoDir + 'prog';
@ -1479,21 +1469,23 @@ begin
' WriteLn(6 * 7)' + LineEnding +
'end.');
{ BLAISE_RTL is normally unset in the test environment; the isolated dir has
no blaise_rtl.a, so FindRTLArchive returns '' and the guard must fire. }
{ The RTL is now compiled from SOURCE by the compiler at link time (no prebuilt
blaise_rtl.a). Point --rtl-src at a nonexistent directory so the RTL source
is unreachable: the driver must fail loudly rather than emit a broken binary. }
Rc := Self.RunProc(IsoCompiler, [
'--source', SrcFile,
'--unit-path', FRTLPath,
'--unit-path', FStdlibPath,
'--output', OutFile,
'--backend', 'native',
'--assembler', 'internal',
'--linker', 'internal'
'--linker', 'internal',
'--rtl-src', IsoDir + 'no_such_rtl_src'
], CompOut);
AssertTrue('compiler must FAIL when RTL is unreachable (got rc=0)', Rc <> 0);
AssertTrue('error must name the missing RTL archive: ' + CompOut,
Pos('blaise_rtl.a', CompOut) >= 0);
AssertTrue('compiler must FAIL when the RTL source is unreachable (got rc=0)',
Rc <> 0);
AssertTrue('error must name the missing RTL source: ' + CompOut,
Pos('RTL source', CompOut) >= 0);
AssertFalse('no output binary should be produced on RTL-missing failure',
FileExists(OutFile));
end;