feat(freebsd): self-host on FreeBSD — runtime fixes + target-driven defines

Blaise now runs natively on FreeBSD and compiles+links working FreeBSD binaries
(fork/exec RTL sub-compiles, threaded incremental workers, internal assembler +
linker, freestanding runtime), and cross-compiles both directions (Linux<->
FreeBSD). Each of these was a real FreeBSD-only runtime bug exposed by running on
a real FreeBSD 14.3 kernel — invisible to host-side static checks:

  * fork(2) child return: FreeBSD returns the child in %rax=parent-pid with
    %edx=1 (child indicator); the raw leaf must zero %eax when %edx!=0, else the
    forked child runs the parent branch and never execve's its target (exit 127).
  * thread join teardown: the worker set its join word + umtx-WAKE while still on
    its own stack, so pthread_join munmap'd the shared stack/CB region out from
    under the still-live thread -> SIGSEGV. Fixed by passing the join word as
    thr_exit(state): the kernel does the suword+umtx-wake AFTER the thread leaves
    its stack, so the joiner only wakes once the stack is safe to free.
  * argc stack pad word: FreeBSD 16-aligns the entry %rsp and inserts a zero pad
    word below argc when the argv+envc pointer-count parity requires it, so argc
    is at [rsp] or [rsp+8]. _BlaiseStartC now detects and skips the pad word;
    previously an execve'd child with a different environment read the pad (0) as
    argc and reported "--source is required".

Target-driven OS conditional compilation (self-hosting requires HostTarget to be
correct on the produced binary):

  * The OS defines (LINUX/FREEBSD/WINDOWS/UNIX) now follow the resolved --target,
    not the compiler's build host. SeedPredefines seeds the host OS as a default
    (keeps direct-lexer callers/tests working) via compile-time IFDEF; the driver
    injects the target's OS symbol and AddDefinesTo / the unit loader replace the
    host-seeded OS symbols with it on every lexer (program + dep units).
  * HostTarget() reads {$IFDEF FREEBSD} etc., so a blaise built with
    --target freebsd bakes HostTarget=osFreeBSD — a FreeBSD-hosted blaise then
    defaults (no --target) to producing FreeBSD binaries. Verified both
    directions on the VM.

Usability:

  * blaise.cfg now supports rtl-src=<dir> (relative resolved against the config
    dir); a CLI --rtl-src overrides it. So a relocated binary can drop a
    blaise.cfg with unit-path= + rtl-src= and need no path flags. Tests:
    TConfigTests.TestParseRtlSrc_*.
  * --help documents the previously-undocumented --rtl-src flag and the
    blaise.cfg keys, and the --target line now shows the ACTUAL host default
    (TargetName(HostTarget())) instead of a hardcoded linux-x86_64 — correct on
    every host.

Verified: all four fixpoints green; full suite 4032 tests under QBE-built and
native-built runner; Linux self-host and Linux<->FreeBSD cross-compile confirmed;
FreeBSD self-host (blaise compiling+running a program) confirmed on FreeBSD 14.3.
This commit is contained in:
Graeme Geldenhuys 2026-07-02 12:20:18 +01:00
parent db85aba08d
commit 0824ed8a6b
9 changed files with 256 additions and 26 deletions

View file

@ -93,12 +93,17 @@ begin
WriteLn(FormatFlagLine('--output <path>', 'Output binary path'));
WriteLn(FormatFlagLine('--unit-path <dir>',
'Add directory to unit search path (repeatable)'));
WriteLn(FormatFlagLine('--rtl-src <dir>',
'RTL source directory (default: beside the binary; or $BLAISE_RTL_SRC).'));
WriteLn(FormatFlagLine('',
'Needed when the binary is moved away from its source tree.'));
WriteLn(FormatFlagLine('--define <sym> | -d <sym>',
'Define a conditional-compilation symbol (repeatable)'));
WriteLn(FormatFlagLine('--backend <id>', BackendUsageLine()));
WriteLn(FormatFlagLine('--target <os>-<cpu>',
'linux-x86_64 (default), linux-i386, linux-arm64,'));
WriteLn(FormatFlagLine('', 'freebsd-x86_64, windows-x86_64, macos-arm64'));
'Cross-compile target (default: ' + TargetName(HostTarget()) + ', the host).'));
WriteLn(FormatFlagLine('', 'linux-x86_64, linux-i386, linux-arm64, freebsd-x86_64,'));
WriteLn(FormatFlagLine('', 'windows-x86_64, macos-arm64'));
WriteLn(FormatFlagLine('--emit-ir', 'Print QBE IR to stdout and exit'));
WriteLn(FormatFlagLine('--emit-asm',
'Print native assembly to stdout (requires --backend native)'));
@ -134,8 +139,10 @@ begin
'Emit OPDF debug info (.opdf.s companion file)'));
WriteLn('');
WriteLn('Configuration:');
WriteLn(' Unit search paths can also be set in blaise.cfg (one unit-path=<dir>');
WriteLn(' per line). Searched next to the binary, then ~/.blaise.cfg.');
WriteLn(' blaise.cfg (next to the binary, then ~/.blaise.cfg) can set, one');
WriteLn(' per line: unit-path=<dir> (repeatable) and rtl-src=<dir>. A relative');
WriteLn(' path is resolved against the config file''s directory; --rtl-src on');
WriteLn(' the command line overrides rtl-src= in the config.');
end;
{ Populate the two caller-constructed opts objects from the command line.
@ -147,11 +154,29 @@ end;
bad flag; the caller owns and frees both objects regardless. }
{ Apply each -d/--define symbol in ADefines to ALexer's conditional-compilation
table. No-op when ADefines is nil/empty. }
{ True if ASym is one of the OS conditional-compilation symbols. }
function IsOSDefine(const ASym: string): Boolean;
var U: string;
begin
U := UpperCase(ASym);
Result := (U = 'LINUX') or (U = 'FREEBSD') or (U = 'WINDOWS')
or (U = 'DARWIN') or (U = 'UNIX');
end;
procedure AddDefinesTo(ALexer: TLexer; ADefines: TStringList);
var
I: Integer;
HasOS: Boolean;
begin
if ADefines = nil then Exit;
{ If the caller supplies an OS symbol (a cross --target injects the target's),
it REPLACES the host OS symbols the lexer seeded in SeedPredefines: drop
those first so an IFDEF LINUX etc. reflects the target, not the host. }
HasOS := False;
for I := 0 to ADefines.Count - 1 do
if IsOSDefine(ADefines.Strings[I]) then HasOS := True;
if HasOS then
ALexer.ClearOSDefines();
for I := 0 to ADefines.Count - 1 do
ALexer.AddDefine(ADefines.Strings[I]);
end;
@ -444,6 +469,7 @@ var
SourceFile, OutputFile: string;
SearchPaths: TStringList;
ConfigPaths: TStringList;
CfgRtlSrc: string;
EmitIR: Boolean;
EmitAsm: Boolean;
DumpAST: Boolean;
@ -531,6 +557,23 @@ begin
Halt(1);
end;
{ The OS conditional-compilation symbols follow the resolved --target (which
defaults to the host). Inject them into Front.Defines; AddDefinesTo then
replaces the lexer's host-seeded OS symbols with these on every lexer (top
program and, via the unit loader, each dependency unit). So an IFDEF
FREEBSD in compiled source reflects the TARGET, not this compiler's host,
which is what makes cross-compiling blaise itself for FreeBSD bake
HostTarget=FreeBSD into the result. }
case Opts.Target.OS of
osFreeBSD: Front.Defines.Add('FREEBSD');
osWindows: Front.Defines.Add('WINDOWS');
osMacOS: Front.Defines.Add('DARWIN');
else
Front.Defines.Add('LINUX');
end;
if Opts.Target.OS <> osWindows then
Front.Defines.Add('UNIX');
{ Seed the working locals from the opts objects. Keeping the locals lets
the (large) main body read them unchanged; the parser owns the objects.
SearchPaths is the object's owned list (not copied) so config-path
@ -556,7 +599,14 @@ begin
ConfigPaths := TStringList.Create();
try
LoadConfigPaths(ConfigPaths);
{ blaise.cfg supplies extra unit-paths and, optionally, rtl-src. A CLI
--rtl-src (already in Opts.RTLSrcDir) takes precedence: only let the config
set it when the CLI did not. Seed CfgRtlSrc with the CLI value so the
config only overrides an EMPTY one. }
CfgRtlSrc := Opts.RTLSrcDir;
LoadConfigPaths(ConfigPaths, CfgRtlSrc);
if Opts.RTLSrcDir = '' then
Opts.RTLSrcDir := CfgRtlSrc;
for I := ConfigPaths.Count - 1 downto 0 do
SearchPaths.Insert(0, ConfigPaths.Strings[I]);
finally

View file

@ -107,14 +107,22 @@ end;
function HostTarget: TTargetDesc;
begin
{ A '.exe' suffix on our own path means we are running on Windows (incl.
under wine). Unix hosts carry no extension; we can't cheaply tell
linux/macos/freebsd apart here, and only the Windows distinction matters
for tool extensions today, so default the unix case to linux. }
if LowerCase(ExtractFileExt(ParamStr(0))) = '.exe' then
MakeTarget(osWindows, cpuX86_64, Result)
else
MakeTarget(osLinux, cpuX86_64, Result);
{ The host OS is a COMPILE-TIME property of this binary: a blaise built with
`--target freebsd-x86_64` runs on FreeBSD, so its HostTarget must report
FreeBSD. The OS conditional symbols are target-driven (injected by the
driver from the resolved --target; see Blaise.pas and uLexer.SeedPredefines),
the IFDEF FREEBSD below is true exactly when THIS compiler was built for
FreeBSD. This is what lets a FreeBSD-hosted blaise default (no --target) to
producing FreeBSD binaries — the basis of self-hosting on FreeBSD. }
{$IFDEF FREEBSD}
MakeTarget(osFreeBSD, cpuX86_64, Result);
{$ELSE}
{$IFDEF WINDOWS}
MakeTarget(osWindows, cpuX86_64, Result);
{$ELSE}
MakeTarget(osLinux, cpuX86_64, Result);
{$ENDIF}
{$ENDIF}
end;
function PtrSize(const ATarget: TTargetDesc): Integer;

View file

@ -267,7 +267,18 @@ asm
jmp main
end;
{ The C-level entry: SP points at the kernel's initial stack (argc at [SP]). }
{ The C-level entry. The kernel hands us the initial process stack: argc, then
argv[], NULL, envp[], NULL, auxv. On FreeBSD the entry %rsp must be 16-byte
aligned, and the kernel achieves that by inserting a ZERO PAD WORD immediately
below argc when the argument-vector layout would otherwise leave argc at an
8-mod-16 address (this depends on the argc+envc pointer-count parity, so it
varies between the shell-launched top-level and an execve'd child with a
different environment). So argc is at [SP] usually, but at [SP+8] when a pad
word is present. Detect the pad: a real argc is a small non-negative count
and the slot after it (argv[0]) is a valid pointer; a pad word reads 0. If
[SP] is 0 and [SP+8] is a plausible argc, skip the pad word. }
procedure _BlaiseStartC(SP: Pointer);
var
Argc: Int64;
@ -275,6 +286,13 @@ var
Ret: Integer;
begin
Argc := PInt64(SP)^;
{ Skip a kernel alignment pad word below argc, if present. }
if Argc = 0 then
begin
if PInt64(Pointer(PChar(SP) + 8))^ > 0 then
SP := Pointer(PChar(SP) + 8);
Argc := PInt64(SP)^;
end;
Argv := Pointer(PChar(SP) + 8);
{ envp = &argv[argc+1] }
Envp := Pointer(PChar(Argv) + (Argc + 1) * 8);

View file

@ -457,6 +457,14 @@ asm
ret
end;
{ FreeBSD fork(2) has a two-register return the libc wrapper normalises and a
raw syscall must replicate: the kernel returns the child pid in %rax for the
PARENT with %edx = 0, but for the CHILD it returns the PARENT's pid in %rax
with %edx = 1 as the child indicator. Without zeroing the child's %eax when
%edx != 0, the child sees a non-zero "pid" and every `if fork() = 0 then
(child path)` check runs the PARENT branch in the child — so a spawned
subprocess never execve's its target and the parent reaps exit 127. Mirror
libc: if %edx != 0, this is the child -> return 0. }
function fork: Integer;
assembler; nostackframe;
asm
@ -464,7 +472,12 @@ asm
syscall
jae .Lok_fork
negq %rax
ret
.Lok_fork:
testl %edx, %edx { %edx = 1 in the child, 0 in the parent }
jz .Lparent_fork
xorl %eax, %eax { child: return 0 }
.Lparent_fork:
ret
end;

View file

@ -209,10 +209,15 @@ begin
CB := PThreadCB(CBPtr);
Entry := CB^.StartRoutine;
CallEntry(Entry, CB^.Arg);
{ Signal completion to any joiner, then terminate this thread only. }
CB^.JoinWord := 1;
_umtx_op(@CB^.JoinWord, UMTX_OP_WAKE_PRIVATE, 1, nil, nil);
thr_exit(nil);
{ Terminate this thread AND signal the joiner in one kernel step: pass the
join word as thr_exit's `state`. The kernel does `suword(state, 1)` +
umtx-wake on it AFTER the thread has stopped executing on its stack — so the
joiner cannot free the stack while this thread is still running on it. (The
previous code set JoinWord + WAKEd here, on the stack, BEFORE thr_exit, which
let pthread_join munmap the shared stack/CB region out from under this still-
live thread → SIGSEGV during teardown. Never touch the stack after the wake;
let thr_exit's kernel-side wake be the last event on this word.) }
thr_exit(@CB^.JoinWord);
end;
{ Store an 8-byte value at Base + Offset (used to fill struct thr_param). }

View file

@ -15,10 +15,22 @@ uses
function FindConfigFile: string;
{ Parse blaise.cfg lines. Recognised keys (KEY=VALUE, '#' comments, blank lines
ignored): `unit-path=<dir>` appends to APaths; `rtl-src=<dir>` sets ARtlSrc.
A relative VALUE is resolved against ABaseDir (the config file's directory).
ARtlSrc is only written when an rtl-src line is present (left unchanged
otherwise), so a caller can pre-seed it and let the config override only if
set. }
procedure ParseConfigLines(ALines: TStringList; const ABaseDir: string;
APaths: TStringList);
APaths: TStringList; var ARtlSrc: string); overload;
procedure LoadConfigPaths(APaths: TStringList);
{ Convenience overload for callers that only want unit-paths (ignores rtl-src). }
procedure ParseConfigLines(ALines: TStringList; const ABaseDir: string;
APaths: TStringList); overload;
{ Load the resolved blaise.cfg: appends unit-path entries to APaths and returns
any rtl-src via ARtlSrc (unchanged if the file has none / no file). }
procedure LoadConfigPaths(APaths: TStringList; var ARtlSrc: string);
implementation
@ -42,7 +54,7 @@ begin
end;
procedure ParseConfigLines(ALines: TStringList; const ABaseDir: string;
APaths: TStringList);
APaths: TStringList; var ARtlSrc: string);
var
I: Integer;
Line: string;
@ -68,12 +80,27 @@ begin
Value := IncludeTrailingPathDelimiter(ABaseDir) + Value;
APaths.Add(Value);
end
else if SameText(Key, 'rtl-src') then
begin
if (Length(Value) > 0) and (Copy(Value, 0, 1) <> '/') then
Value := IncludeTrailingPathDelimiter(ABaseDir) + Value;
ARtlSrc := Value;
end
else
; { silently skip unrecognised keys }
end;
end;
procedure LoadConfigPaths(APaths: TStringList);
procedure ParseConfigLines(ALines: TStringList; const ABaseDir: string;
APaths: TStringList);
var
IgnoredRtlSrc: string;
begin
IgnoredRtlSrc := '';
ParseConfigLines(ALines, ABaseDir, APaths, IgnoredRtlSrc);
end;
procedure LoadConfigPaths(APaths: TStringList; var ARtlSrc: string);
var
CfgFile: string;
Lines: TStringList;
@ -86,7 +113,7 @@ begin
try
Lines.LoadFromFile(CfgFile);
BaseDir := ExtractFilePath(CfgFile);
ParseConfigLines(Lines, BaseDir, APaths);
ParseConfigLines(Lines, BaseDir, APaths, ARtlSrc);
finally
Lines.Free();
end;

View file

@ -151,6 +151,8 @@ type
{ Define a conditional-compilation symbol before lexing (e.g. from the
-d command-line flag). Case-insensitive. }
procedure AddDefine(const ASym: string);
{ Drop the host OS predefines so a cross-target's OS symbol replaces them. }
procedure ClearOSDefines;
function Next: TToken;
end;
@ -187,15 +189,29 @@ end;
{ Seed the always-on predefined conditional symbols. BLAISE identifies this
compiler (the headline use case: cross-compiler IFDEF BLAISE / ELSE / ENDIF);
the CPU/OS symbols mirror the FPC family so portable source compiles
unchanged. Targets beyond Linux/x86-64 will extend this set. }
the CPU/OS symbols mirror the FPC family so portable source compiles unchanged.
The OS symbols follow the HOST this compiler was built for, via compile-time
conditional compilation, so a FreeBSD-built blaise seeds FREEBSD (matching
HostTarget), which is what makes it default to producing FreeBSD binaries.
A cross-target does not re-seed these; source that switches on the target OS
is rare and the RTL selects per-target units structurally (BuildRTLUnitList),
not via conditional compilation. }
procedure TLexer.SeedPredefines;
begin
Self.DefineSymbol('BLAISE');
Self.DefineSymbol('CPUX86_64');
Self.DefineSymbol('CPUAMD64'); { FPC's alias for the same target }
{$IFDEF FREEBSD}
Self.DefineSymbol('FREEBSD');
Self.DefineSymbol('UNIX');
{$ELSE}
{$IFDEF WINDOWS}
Self.DefineSymbol('WINDOWS');
{$ELSE}
Self.DefineSymbol('LINUX');
Self.DefineSymbol('UNIX');
{$ENDIF}
{$ENDIF}
end;
function TLexer.IsDefined(const ASym: string): Boolean;
@ -218,6 +234,17 @@ begin
FDefines.Delete(Idx);
end;
{ Remove all OS predefines seeded for the host, so a cross --target's OS symbol
can take their place (the driver calls this before applying target defines). }
procedure TLexer.ClearOSDefines;
begin
Self.UndefSymbol('LINUX');
Self.UndefSymbol('FREEBSD');
Self.UndefSymbol('WINDOWS');
Self.UndefSymbol('DARWIN');
Self.UndefSymbol('UNIX');
end;
procedure TLexer.AddDefine(const ASym: string);
begin
Self.DefineSymbol(UpperCase(ASym));

View file

@ -103,6 +103,15 @@ type
implementation
{ True if ASym is one of the OS conditional-compilation symbols (the target's
OS define, injected by the driver, replaces the lexer's host-seeded ones). }
function DefineIsOS(const ASym: string): Boolean;
begin
Result := SameText(ASym, 'LINUX') or SameText(ASym, 'FREEBSD') or
SameText(ASym, 'WINDOWS') or SameText(ASym, 'DARWIN') or
SameText(ASym, 'UNIX');
end;
function TUnitLoader.IsBuiltin(const AName: string): Boolean;
begin
Result :=
@ -240,16 +249,27 @@ var
L: TLexer;
P: TParser;
DI: Integer;
HasOS: Boolean;
begin
SL := TStringList.Create();
try
SL.LoadFromFile(APath);
L := TLexer.Create(SL.Text, APath);
{ Apply the same -d/--define symbols the main program got, so IFDEF
directives resolve consistently across the program and all its units. }
directives resolve consistently across the program and all its units.
If the set carries an OS symbol (the target's, injected by the driver),
drop the lexer's host-seeded OS symbols first so the target wins here too
— mirrors AddDefinesTo in Blaise.pas. }
if FDefines <> nil then
begin
HasOS := False;
for DI := 0 to FDefines.Count - 1 do
if DefineIsOS(FDefines.Strings[DI]) then HasOS := True;
if HasOS then
L.ClearOSDefines();
for DI := 0 to FDefines.Count - 1 do
L.AddDefine(FDefines.Strings[DI]);
end;
try
P := TParser.Create(L);
try

View file

@ -28,6 +28,9 @@ type
procedure TestParseRelativePath_ResolvedAgainstBaseDir;
procedure TestParseAbsolutePath_NotModified;
procedure TestParseUnknownKey_IgnoredSilently;
procedure TestParseRtlSrc_Absolute_SetsValue;
procedure TestParseRtlSrc_Relative_ResolvedAgainstBaseDir;
procedure TestParseRtlSrc_Absent_LeavesSeedUnchanged;
procedure TestFindConfigFile_NextToBinary;
end;
@ -227,6 +230,65 @@ begin
end;
end;
procedure TConfigTests.TestParseRtlSrc_Absolute_SetsValue;
var
Lines, Paths: TStringList;
Rtl: string;
begin
Lines := TStringList.Create();
Paths := TStringList.Create();
try
Rtl := '';
Lines.Add('rtl-src=/opt/blaise/rtl');
ParseConfigLines(Lines, '/base', Paths, Rtl);
AssertEquals('rtl-src set', '/opt/blaise/rtl', Rtl);
AssertEquals('rtl-src is not a unit-path', 0, Paths.Count);
finally
Lines.Free();
Paths.Free();
end;
end;
procedure TConfigTests.TestParseRtlSrc_Relative_ResolvedAgainstBaseDir;
var
Lines, Paths: TStringList;
Rtl: string;
begin
Lines := TStringList.Create();
Paths := TStringList.Create();
try
Rtl := '';
Lines.Add('rtl-src=src/main/pascal');
ParseConfigLines(Lines, '/base/', Paths, Rtl);
AssertEquals('relative rtl-src resolved against base dir',
'/base/src/main/pascal', Rtl);
finally
Lines.Free();
Paths.Free();
end;
end;
procedure TConfigTests.TestParseRtlSrc_Absent_LeavesSeedUnchanged;
var
Lines, Paths: TStringList;
Rtl: string;
begin
Lines := TStringList.Create();
Paths := TStringList.Create();
try
{ Seed a value (as the driver does with a CLI --rtl-src); a config with no
rtl-src line must leave it untouched. }
Rtl := '/cli/override';
Lines.Add('unit-path=/opt/blaise/runtime');
ParseConfigLines(Lines, '/base', Paths, Rtl);
AssertEquals('seed unchanged when config has no rtl-src',
'/cli/override', Rtl);
finally
Lines.Free();
Paths.Free();
end;
end;
procedure TConfigTests.TestFindConfigFile_NextToBinary;
var
CfgPath: string;