feat(lang): inline assembler blocks (asm … end routine bodies)

A routine body may now be written as inline assembly:

    function GetSelf: Pointer; assembler; nostackframe;
    asm
        movq %rdi, %rax
        ret
    end;

The block is opaque GNU/AT&T assembly: the lexer captures the whole asm … end
as one tkAsmBlock token (verbatim text, never tokenised as Pascal), the parser
wraps it in a TAsmStmt, the semantic pass treats it as a black box, and the
native backend emits it verbatim into the assembly stream where the existing
internal/external assembler parses it.  `nostackframe` suppresses the compiler
prologue/epilogue so the asm body owns the whole frame.  asm routines mix
freely with ordinary Pascal routines in a standard .pas unit (no .inc needed).

This is the FPC model (rtl/linux/x86_64/si_c.inc) and the path to retiring the
hand-written runtime/src/main/asm/*.s files (assembled by `cc -c` today) — once
each body moves into an asm routine the RTL builds with no external assembler.

Design follows ports-and-adapters: x86-64 knowledge stays at the backend/
assembler edge, the portable core never interprets the block.  The QBE backend
rejects asm bodies (it emits no assembly text); native is the inline-asm target
and the default.  TAsmStmt round-trips through the .bif unit cache.

Pipeline: lexer (tkAsmBlock + ReadAsmBody raw capture), uAST (TAsmStmt,
TMethodDecl.NoStackFrame), parser (nostackframe directive + asm-body path),
semantic (opaque no-op), native codegen (verbatim emit + nostackframe null-frame
guard), QBE rejection, uUnitInterfaceIO encode/decode, bif-coverage entry.
`asm` becomes a reserved word (one local var named Asm renamed in a test).

Fixes a native sret-Result field-read codegen bug the feature exposed: reading a
field of an sret function's Result at offset 0 (e.g. `Result.Kind` in a record-
returning function) read the Result frame slot DIRECTLY instead of dereferencing
the caller-buffer pointer it holds, so `Result.Field = const` was always false.
The offset-0 fast path in the integer field-read leaf now routes through
EmitLocalRecordBase like the offset>0 path, so the sret indirection happens in
both.  QBE was already correct; this was a native-only divergence.

Tests: lexer raw-capture (3), native verbatim-body/no-prologue IR test (1),
internal-assembler e2e returns-value + adds-two-args (2).  All four fixpoints
and both QBE- and native-built test runners pass (3764 tests).

Design note: docs/inline-asm-design.adoc; grammar + rationale updated.
This commit is contained in:
Graeme Geldenhuys 2026-06-25 01:01:41 +01:00
parent 7ac533ca15
commit 0396ed7b1c
14 changed files with 617 additions and 11 deletions

View file

@ -7322,10 +7322,13 @@ begin
else
Self.Emit(Format(#9'leaq %d(%%rcx), %%rax', [FAE.FieldInfo.Offset]));
end
else if FAE.FieldInfo.Offset = 0 then
Self.EmitLoadVar(Self.VarOperand(FAE.RecordName), FAE.FieldInfo.TypeDesc)
else
begin
{ Route through EmitLocalRecordBase for BOTH offset 0 and > 0: for the
sret-function Result the slot holds the caller-buffer POINTER, so the
field must be read indirectly (movq slot -> %rcx; load N(%rcx)). The
old offset-0 fast path read the slot directly, which for sret Result
read the pointer value as the field (Result.K = const always false). }
Self.EmitLocalRecordBase(FAE.RecordName, '%rcx');
Self.EmitLoadVar(Format('%d(%%rcx)', [FAE.FieldInfo.Offset]),
FAE.FieldInfo.TypeDesc);
@ -10560,6 +10563,13 @@ var
AliasBuf: Integer;
begin
Self.DbgStmtLabel(AStmt);
if AStmt is TAsmStmt then
begin
{ Inline assembler: emit the verbatim block. The internal/external
assembler parses it; the front end never did. }
Self.Emit(TAsmStmt(AStmt).Code);
Exit;
end;
if AStmt is TAssignment then
begin
Asgn := TAssignment(AStmt);
@ -15799,6 +15809,21 @@ begin
if AExported then
Self.Emit('.globl ' + Sym);
Self.Emit(Sym + ':');
{ nostackframe: the body is an inline-asm block that owns the entire frame
(prologue, args-from-registers, ret). Emit no compiler prologue/epilogue,
no param spill, no ARC — just the verbatim block. This is the null-object
frame strategy (see docs/inline-asm-design.adoc). }
if ADecl.NoStackFrame then
begin
if (ADecl.Body <> nil) then
for I := 0 to ADecl.Body.Stmts.Count - 1 do
Self.EmitStmt(TASTStmt(ADecl.Body.Stmts.Items[I]));
Self.DbgEndFunc();
Self.Emit('.type ' + Sym + ', @function');
FExitLabel := '';
FCapturedVars := nil;
Exit;
end;
Self.Emit(#9'pushq %rbp');
Self.Emit(#9'movq %rsp, %rbp');
if FFrameSize > 0 then

View file

@ -2460,6 +2460,10 @@ var
begin
if AStmt = nil then
raise ECodeGenError.Create('EmitStmt called with nil statement');
if AStmt is TAsmStmt then
raise ECodeGenError.Create(
'inline asm blocks require the native backend (--backend native); '
+ 'the QBE backend cannot emit assembly');
if AStmt is TTryFinallyStmt then
EmitTryFinallyStmt(TTryFinallyStmt(AStmt))
else if AStmt is TTryExceptStmt then

View file

@ -247,6 +247,14 @@ type
destructor Destroy; override;
end;
{ An inline-assembler block: the verbatim text of one `asm` ... `end` body.
The front end never interprets Code — it is GNU/AT&T assembly handed to the
backend's assembler verbatim (see docs/inline-asm-design.adoc). }
TAsmStmt = class(TASTStmt)
public
Code: string; { verbatim assembly text between asm and end }
end;
TWhileStmt = class(TASTStmt)
public
Condition: TASTExpr; { owned }
@ -806,6 +814,9 @@ type
consumed by codegen for cross-unit references. }
IsExternal: Boolean; { declared with 'external' directive — no body }
ExternalName: string; { C symbol name from 'external name ''c_foo'''; empty = use Pascal name }
NoStackFrame: Boolean; { declared 'nostackframe' — body is an asm
block that owns the whole frame; codegen
emits no compiler prologue/epilogue }
IsRecordMethod: Boolean; { set by uSemantic — owner type is a record (not a class) }
VTableSlot: Integer; { -1 = static; >=0 = vtable index (set by uSemantic) }
TypeParams: TStringList; { non-nil = generic function template; owns param names }

View file

@ -111,7 +111,12 @@ type
tkDot,
tkDotDot, { .. — array bounds separator and case range }
tkCaret, { ^ — pointer dereference / pointer type prefix }
tkAt { @ — address-of operator }
tkAt, { @ — address-of operator }
tkAsmBlock { whole 'asm' ... 'end' block; Value holds the verbatim
assembly text (the front end never tokenises it).
Appended at the enum's END so adding it shifts no
existing ordinal (set-of-TTokenKind literals elsewhere
depend on stable ordinals). }
);
TToken = record
@ -137,6 +142,7 @@ type
procedure SeedPredefines;
procedure SkipToElseOrEndif;
procedure SkipToEndif;
function ReadAsmBody: string;
public
constructor Create(const ASource: string; const AFilename: string = '');
destructor Destroy; override;
@ -224,6 +230,7 @@ begin
else if AUpper = 'THREADVAR' then Result := tkThreadVar
else if AUpper = 'BEGIN' then Result := tkBegin
else if AUpper = 'END' then Result := tkEnd
else if AUpper = 'ASM' then Result := tkAsmBlock
else if AUpper = 'TYPE' then Result := tkType
else if AUpper = 'RECORD' then Result := tkRecord
else if AUpper = 'PACKED' then Result := tkPacked
@ -524,6 +531,42 @@ begin
end;
end;
{ Called immediately after the 'asm' keyword token was produced (FTok.Token is
that keyword). Drives the tokeniser forward to the matching 'end' keyword and
returns the verbatim source text between the two — assembly is not Pascal, so
nothing in the block is re-interpreted. Only a standalone 'end' KEYWORD token
terminates the block (an 'end' inside an asm '#' line comment is a comment
token, never a keyword, so it is ignored). The terminating 'end' token is
consumed; normal tokenising resumes after it. }
function TLexer.ReadAsmBody: string;
var
raw: TFpgPasToken;
BlockStart: Integer; { 1-based source index of the first char after 'asm' }
EndStart: Integer; { 1-based source index of the terminating 'end' }
begin
{ FTok.Token is the 'asm' keyword; the body begins right after it. }
BlockStart := FTok.Token.TextStart + FTok.Token.Len;
EndStart := -1;
while True do
begin
raw := FTok.NextToken();
if raw.Kind = fptkEOF then
begin
{ Unterminated block: capture to end of source. }
EndStart := Length(FTok.Source) + 1;
Break;
end;
if (raw.Kind = fptkKeyword) and (FTok.TokenTextUpper() = 'END') then
begin
EndStart := FTok.Token.TextStart;
Break;
end;
end;
{ Slice [BlockStart, EndStart) — Copy is 0-based, so subtract 1 from the
1-based start; length is EndStart - BlockStart. }
Result := Copy(FTok.Source, BlockStart - 1, EndStart - BlockStart);
end;
function TLexer.Next: TToken;
var
raw: TFpgPasToken;
@ -583,7 +626,14 @@ begin
begin
text := FTok.TokenTextUpper();
Result.Kind := MapKeyword(text);
Result.Value := FTok.TokenText();
if Result.Kind = tkAsmBlock then
{ Capture the whole 'asm' ... 'end' block as verbatim text — its
content is GNU/AT&T assembly, not Pascal, so it must not be
tokenised. ReadAsmBody drives the tokeniser to the matching
'end' and slices the raw source. }
Result.Value := Self.ReadAsmBody()
else
Result.Value := FTok.TokenText();
end;
fptkIdentifier:
@ -737,6 +787,7 @@ begin
tkOut: Result := 'out';
tkConstructor: Result := 'constructor';
tkDestructor: Result := 'destructor';
tkAsmBlock: Result := 'asm block';
tkIdent: Result := 'identifier';
tkPlus: Result := '+';
tkMinus: Result := '-';

View file

@ -67,6 +67,7 @@ type
procedure ParseUsesList(AList: TStringList);
procedure ParseUses(AProg: TProgram);
function ParseBlock: TBlock;
function ParseAsmBody: TBlock;
procedure ParseTypeSection(ABlock: TBlock);
procedure ParseTypeDecl(ABlock: TBlock);
procedure ParseConstBlock(AList: TObjectList);
@ -2098,6 +2099,7 @@ begin
SameText(FCurrent.Value, 'static') or
SameText(FCurrent.Value, 'final') or
SameText(FCurrent.Value, 'assembler') or
SameText(FCurrent.Value, 'nostackframe') or
SameText(FCurrent.Value, 'forward') or
SameText(FCurrent.Value, 'deprecated') or
SameText(FCurrent.Value, 'platform') or
@ -2105,6 +2107,8 @@ begin
begin
if SameText(FCurrent.Value, 'inline') then
Result.IsInline := True
else if SameText(FCurrent.Value, 'nostackframe') then
Result.NoStackFrame := True
else if SameText(FCurrent.Value, 'forward') then
IsForward := True
else if SameText(FCurrent.Value, 'cdecl') or
@ -2129,7 +2133,16 @@ begin
proc. Without this guard the forward decl swallowed the real
implementation (and everything up to the program's 'end.') as its body
(issue #130 bug2). }
if (not Result.IsExternal) and (not IsForward) and
if (not Result.IsExternal) and (not IsForward) and Check(tkAsmBlock) then
begin
{ Inline-assembler body: the lexer already captured the whole asm ... end
block as one tkAsmBlock token whose Value is the verbatim text. Wrap it
in a TBlock holding a single TAsmStmt; semantic treats it as opaque and
codegen emits the text verbatim. }
Result.Body := Self.ParseAsmBody();
Expect(tkSemicolon);
end
else if (not Result.IsExternal) and (not IsForward) and
(Check(tkBegin) or Check(tkVar) or Check(tkType) or Check(tkConst) or
(ACanHaveNestedProcs and (Check(tkProcedure) or Check(tkFunction)))) then
begin
@ -2142,6 +2155,21 @@ begin
end;
end;
{ Build a TBlock whose sole statement is the TAsmStmt for the current
tkAsmBlock token, then consume that token. }
function TParser.ParseAsmBody: TBlock;
var
Stmt: TAsmStmt;
begin
Result := TBlock.Create();
Stmt := TAsmStmt.Create();
Stmt.Line := FCurrent.Line;
Stmt.Col := FCurrent.Col;
Stmt.Code := FCurrent.Value;
Result.Stmts.Add(Stmt);
Advance(); { consume the tkAsmBlock token }
end;
procedure TParser.ParseParamList(AParams: TObjectList);
var
Par: TMethodParam;

View file

@ -6843,6 +6843,10 @@ var
L: Integer;
DupLocal: Boolean;
begin
{ Inline-assembler block: opaque to the front end — its content is assembly,
not Pascal, so no statement/type/ARC analysis runs over it. }
if AStmt is TAsmStmt then
Exit;
if AStmt is TForStmt then
begin
ForS := TForStmt(AStmt);

View file

@ -903,6 +903,9 @@ begin
Result := EncodeLpstr('inh') +
EncodeLpstr(TInheritedCallStmt(AStmt).Name) +
EncodeExprList(TInheritedCallStmt(AStmt).Args)
else if AStmt is TAsmStmt then
Result := EncodeLpstr('asm') +
EncodeLpstr(TAsmStmt(AStmt).Code)
else
raise EIfaceFormatError.Create(
'EncodeStmt: unhandled statement node ' + AStmt.ClassName);
@ -1535,6 +1538,7 @@ var
PCn: TProcCall;
MCSn: TMethodCallStmt;
ICSn: TInheritedCallStmt;
ASMn: TAsmStmt;
C, I: Integer;
Body: TASTStmt;
begin
@ -1702,6 +1706,12 @@ begin
ReadExprList(AText, APos, ICSn.Args);
Result := ICSn;
end
else if Kind = 'asm' then
begin
ASMn := TAsmStmt.Create();
ASMn.Code := ReadLpstrAt(AText, APos);
Result := ASMn;
end
else
raise EIfaceFormatError.Create(
'ReadStmt: unknown statement kind ''' + Kind + '''');

View file

@ -106,6 +106,10 @@ type
argc/argv forwarding and exit-code propagation through __libc_start_main. }
procedure TestEntry_ExitCodePropagates;
procedure TestEntry_ArgsForwarded;
{ Inline assembler blocks (asm ... end): a nostackframe asm-body function is
assembled verbatim by the internal assembler and called from Pascal. }
procedure TestInlineAsm_ReturnsValue;
procedure TestInlineAsm_AddsTwoArgs;
end;
implementation
@ -1077,6 +1081,59 @@ begin
AssertEquals('2' + LineEnding + 'alpha' + LineEnding + 'beta' + LineEnding, Out_);
end;
procedure TInternalAsmE2ETests.TestInlineAsm_ReturnsValue;
var
Out_: string;
EC: Integer;
begin
{ A nostackframe asm-body function returns 42 in %eax; the internal assembler
assembles the verbatim block, and Pascal calls it normally. }
if not CompileAndRun(
'program test_asmret;' + LineEnding +
'function GetFortyTwo: Integer; assembler; nostackframe;' + LineEnding +
'asm' + LineEnding +
' movl $42, %eax' + LineEnding +
' ret' + LineEnding +
'end;' + LineEnding +
'begin' + LineEnding +
' WriteLn(GetFortyTwo())' + LineEnding +
'end.',
Out_, EC) then
begin
Ignore('<toolchain-missing>');
Exit;
end;
AssertEquals(0, EC);
AssertEquals('42' + LineEnding, Out_);
end;
procedure TInternalAsmE2ETests.TestInlineAsm_AddsTwoArgs;
var
Out_: string;
EC: Integer;
begin
{ Two integer args arrive in %edi/%esi (SysV); the asm body sums them. Proves
a nostackframe asm function reads its parameters from the arg registers. }
if not CompileAndRun(
'program test_asmadd;' + LineEnding +
'function AddTwo(A, B: Integer): Integer; assembler; nostackframe;' + LineEnding +
'asm' + LineEnding +
' movl %edi, %eax' + LineEnding +
' addl %esi, %eax' + LineEnding +
' ret' + LineEnding +
'end;' + LineEnding +
'begin' + LineEnding +
' WriteLn(AddTwo(40, 2))' + LineEnding +
'end.',
Out_, EC) then
begin
Ignore('<toolchain-missing>');
Exit;
end;
AssertEquals(0, EC);
AssertEquals('42' + LineEnding, Out_);
end;
{ ---- Registration ---- }
initialization

View file

@ -44,6 +44,11 @@ type
procedure TestKeywords_CaseInsensitive;
procedure TestIdent_NotKeyword_Prefix;
{ Inline assembler blocks — asm ... end captured as one raw-text token }
procedure TestAsmBlock_CapturesRawText;
procedure TestAsmBlock_EndMentionedInComment_NotTerminator;
procedure TestAsmBlock_FollowedByDecl_StopsAtEnd;
{ Identifiers }
procedure TestIdent_Simple;
procedure TestIdent_WithUnderscore;
@ -313,6 +318,50 @@ begin
AssertEquals('Value', 'beginning', tok.Value);
end;
{ Inline assembler blocks }
procedure TLexerTests.TestAsmBlock_CapturesRawText;
var
tok: TToken;
begin
{ An asm ... end block is returned as ONE tkAsmBlock token whose Value is
the verbatim text between (exclusive) the asm keyword and the end keyword. }
SetLexer('asm' + #10 + ' movl %eax, %ebx' + #10 + ' ret' + #10 + 'end');
tok := FLexer.Next();
AssertEquals('Kind', Ord(tkAsmBlock), Ord(tok.Kind));
AssertTrue('has movl', Pos('movl %eax, %ebx', tok.Value) >= 0);
AssertTrue('has ret', Pos('ret', tok.Value) >= 0);
AssertTrue('no trailing end keyword captured', Pos('end', tok.Value) < 0);
end;
procedure TLexerTests.TestAsmBlock_EndMentionedInComment_NotTerminator;
var
tok: TToken;
begin
{ 'end' appearing inside a # line comment must NOT terminate the block —
only a standalone end keyword does. }
SetLexer('asm' + #10 + ' ret # this ends the block' + #10 + 'end');
tok := FLexer.Next();
AssertEquals('Kind', Ord(tkAsmBlock), Ord(tok.Kind));
AssertTrue('comment text preserved', Pos('this ends the block', tok.Value) >= 0);
AssertTrue('has ret', Pos('ret', tok.Value) >= 0);
end;
procedure TLexerTests.TestAsmBlock_FollowedByDecl_StopsAtEnd;
var
tok: TToken;
begin
{ After the block's end, normal tokenising resumes — the trailing ';' and the
next token are produced as usual. }
SetLexer('asm' + #10 + ' ret' + #10 + 'end; begin');
tok := FLexer.Next();
AssertEquals('asm block', Ord(tkAsmBlock), Ord(tok.Kind));
tok := FLexer.Next();
AssertEquals('semicolon after end', Ord(tkSemicolon), Ord(tok.Kind));
tok := FLexer.Next();
AssertEquals('begin resumes', Ord(tkBegin), Ord(tok.Kind));
end;
{ Identifiers }
procedure TLexerTests.TestIdent_Simple;

View file

@ -65,6 +65,9 @@ type
into an SSE (xmm) argument register per the System V ABI, not an integer
register. Asserts the call site emits movsd into %xmm0. }
procedure TestMethodFloatArg_LoadedIntoXmm;
{ Inline asm: a nostackframe asm-body function emits the verbatim block and
NO compiler prologue (no pushq %rbp / frame subq). }
procedure TestInlineAsm_VerbatimBody_NoPrologue;
end;
implementation
@ -643,14 +646,40 @@ const
end.
''';
var
Asm: string;
AsmText: string;
begin
Asm := GenAsm(Src);
AsmText := GenAsm(Src);
{ The Double argument must reach the SSE arg register, not an integer one. }
AssertTrue('float method arg loaded into %xmm0',
Pos('movsd', Asm) >= 0);
Pos('movsd', AsmText) >= 0);
AssertTrue('float method arg targets the SSE arg register',
Pos('%xmm0', Asm) >= 0);
Pos('%xmm0', AsmText) >= 0);
end;
procedure TNativeConstArgTests.TestInlineAsm_VerbatimBody_NoPrologue;
const
Src = '''
program P;
function GetFortyTwo: Integer; assembler; nostackframe;
asm
movl $42, %eax
ret
end;
begin
WriteLn(GetFortyTwo())
end.
''';
var
AsmText: string;
Region: string;
begin
AsmText := GenAsm(Src);
Region := Self.FuncRegion(AsmText, 'GetFortyTwo');
{ The verbatim asm instructions appear in the function body. }
AssertTrue('verbatim movl $42', Pos('movl $42, %eax', Region) >= 0);
AssertTrue('verbatim ret', Pos('ret', Region) >= 0);
{ nostackframe: no compiler-emitted prologue inside the function. }
AssertTrue('no pushq %rbp prologue', Pos('pushq %rbp', Region) < 0);
end;
initialization

View file

@ -369,7 +369,7 @@ MethodDecl
[ COLON TypeName ] (* return type for functions *)
SEMICOLON
{ MethodDirective SEMICOLON }
[ Block SEMICOLON ] (* body absent for forward, external, or abstract decls *)
[ ( Block | AsmBlock ) SEMICOLON ] (* body absent for forward, external, or abstract decls *)
;
MethodDirective
@ -377,7 +377,17 @@ MethodDirective
| OVERRIDE
| ExternalDirective
| "overload" (* required on every overload of a same-named proc/func/method *)
| IDENT (* inline, stdcall, cdecl, abstract, reintroduce, … *)
| "nostackframe" (* asm body owns the frame; no compiler prologue/epilogue *)
| IDENT (* inline, stdcall, cdecl, abstract, reintroduce, assembler, … *)
;
(* Inline-assembler body. The lexer captures everything between ASM and the
* matching END as one opaque token (verbatim GNU/AT&T assembly text); the front
* end never tokenises the contents. Used for the runtime's startup/setjmp/
* atomic routines. Requires the native backend. See
* docs/inline-asm-design.adoc. *)
AsmBlock
= ASM (* verbatim assembly text *) END
;
(* Calling-convention directives (cdecl, stdcall, register, pascal, safecall)

266
docs/inline-asm-design.adoc Normal file
View file

@ -0,0 +1,266 @@
= Inline Assembler Blocks (`asm … end`)
:toc: macro
:toclevels: 3
toc::[]
== Summary
Add an inline-assembler statement form so a routine body may be written in
assembly:
[source,pascal]
----
procedure BlaiseStart; assembler; nostackframe;
asm
endbr64
xorl %ebp, %ebp
...
call __libc_start_main@PLT
end;
----
This is the FPC model (`rtl/linux/x86_64/si_c.inc`): assembly lives inside an
ordinary Pascal routine in a standard `.pas` unit, freely mixed with normal
Pascal routines in the same unit. Blaise needs no `.inc` include mechanism for
this — `.inc` is only FPC's textual-splice convenience and is orthogonal to
inline assembly.
The motivation is the last gcc dependency in the runtime build: the hand-written
`runtime/src/main/asm/*.s` files (`_start`, setjmp/longjmp, atomics, UTF-8) are
assembled with `cc -c`. Once those bodies can be expressed as `asm` blocks in
Pascal units, the whole RTL builds through Blaise's own pipeline with no external
assembler (the Pascal units already do, since the Makefile uses
`--assembler internal`).
== Scope
In scope:
* A new statement node `TAsmStmt` carrying the verbatim assembly text of one
`asm … end` block.
* Lexer support to capture an `asm` block as raw text (the body is GNU/AT&T
assembly, not Pascal tokens).
* Parser support to attach an `asm` body to a routine and to accept the
`nostackframe` directive.
* Semantic pass: treat an `asm` body as opaque — no statement analysis, but
still register the routine's signature/symbol like any other.
* Both codegen backends:
** *native* — emit the routine label and the verbatim asm lines into the
assembly stream; honour `nostackframe` (no compiler-generated prologue/
epilogue).
** *QBE* — reject `asm` bodies with a clear diagnostic (QBE has no inline-asm
escape; the native backend is the inline-asm target and is the default).
* `.bif` serialisation of `TAsmStmt` so an `asm`-bodied routine in a unit
interface round-trips through the unit cache.
* `bif-coverage` registration for the new node.
Out of scope (explicitly):
* Parsing or validating the assembly instructions. The block is opaque text
handed to the existing internal assembler (or `cc -c`), which already parses
GNU/AT&T syntax. Blaise does not learn an assembler grammar.
* Intel syntax, register-name remapping, or FPC's `@result` / typed local
references inside asm. The block references symbols by their emitted name
exactly as the hand-written `.s` files do today. (A later iteration may add
name substitution; it is not required to retire the `.s` files.)
* Inline asm as an *expression* or mid-statement; only a whole-routine `asm`
body is supported (matches the RTL's needs and FPC's `assembler;` routines).
== Why opaque-text, not a parsed instruction model
The internal assembler (`blaise.assembler.x86_64.AssembleToObject`) already
consumes a full GNU/AT&T assembly *text* and produces a relocatable object; the
native backend already emits its whole output as assembly *text*. An `asm`
block therefore needs only to reach the backend's text stream verbatim — the
existing assembler does the real work. Parsing instructions into the AST would
duplicate the assembler's job, couple the front end to one architecture's
instruction set, and buy nothing: the bytes still end up as text the assembler
re-reads.
The block is thus modelled as an *opaque leaf* in the AST — text in, text out,
inspected by no pass between parser and backend.
== Design patterns applied
The change is small, but two established patterns make the boundaries clean and
keep the architecture honest. They are called out because the user asked, and
because naming them pins the intended seams for future maintainers.
=== Strategy (GoF) — already the backend seam, extended not bent
Code generation is already a Strategy: `ICodeGen` is the strategy interface and
`TCodeGenNative` / `TCodeGenQBE` are concrete strategies selected per `--backend`
(`blaise.codegen.driver`). An `asm` body is a new *body kind* each strategy
decides how to handle:
* native translates it (emit verbatim),
* QBE refuses it (diagnostic).
No new dispatch mechanism is introduced — `TAsmStmt` flows through the same
`EmitStmt` strategy method every other statement uses. This is the litmus test
that the feature fits: the only per-backend code is one new `case`/`is`
arm inside each existing strategy, not a new branch in the pipeline.
=== Hexagonal / Ports-and-Adapters — the assembler is a driven adapter
The compiler core (lexer → parser → AST → semantic) is the *domain*; the
toolchain it drives out to (internal assembler, `cc`, internal linker) sits
behind a *port*. `TAsmStmt` carries an opaque payload that the domain never
interprets; only the *driven adapter* — the assembler behind the port —
understands it. This is exactly the hexagonal intent: the core stays ignorant
of x86-64 instruction encoding (a detail of the outside world), and the
architecture-specific knowledge lives at the adapter edge
(`blaise.assembler.x86_64`, `blaise.codegen.native.x86_64`).
Concretely, this dictates *where the text may and may not be touched*: the
front end and semantic pass must treat the block as a black box. Any future
feature that needs to understand the asm (e.g. `@result` substitution) belongs
in the architecture adapter, behind the same port — never in the parser or
semantic pass. Keeping that rule is what stops inline asm from leaking
x86-64 assumptions into the portable core as more architectures are added.
=== Null Object (GoF) — `nostackframe` as the no-op frame strategy
Frame setup is currently unconditional in `EmitFunctionDef`
(`pushq %rbp` / `movq %rsp,%rbp` / `subq`). `nostackframe` selects a *null*
frame strategy: emit no prologue/epilogue, the asm body owns the entire frame.
Implemented as a guard around the existing prologue/epilogue emission rather
than a parallel code path, so the two cases cannot drift.
== Detailed design
=== Lexing — capturing the raw block
The tokeniser (`uPasTokeniser.TFpgPasToken`) records `TextStart` (1-based source
index) and `Len` for every token, and exposes the whole `Source`. This is
sufficient to slice the verbatim block without a second scanner:
. The lexer recognises `asm` as a keyword token (`tkAsm`).
. On `tkAsm`, a new lexer entry point `ReadAsmBody` drives the tokeniser
forward, tracking the source offset, until it reaches the matching `end`
keyword token. The raw text between the end of the `asm` token and the start
of the `end` token is returned via `Source` + offsets — comments and
whitespace preserved, nothing re-tokenised as Pascal.
. Nesting: assembly has no `asm … end` nesting, so the first `end` keyword
terminates the block. The one caveat is a `#` line comment or a string in
the asm that contains the word `end`; the GNU assembler's own tokens are
line-oriented, so the scan matches `end` only as a standalone keyword token
at statement position (the tokeniser already classifies it as `fptkKeyword`),
which an `end` inside a comment is not. This mirrors how the tokeniser
already distinguishes a keyword from an identifier substring.
Rationale recorded in `language-rationale.adoc`; the `asm` keyword and the
block production added to `grammar.ebnf`.
=== AST — `TAsmStmt`
[source,pascal]
----
TAsmStmt = class(TASTStmt)
public
Code: string; { verbatim assembly text of the block }
end;
----
A routine with an `asm` body holds, in its `Body.Stmts`, a single `TAsmStmt`
(the simplest representation that reuses the existing `TBlock`/`Stmts`
container and the existing body-present/absent logic). `TMethodDecl` gains a
`NoStackFrame: Boolean` flag for the `nostackframe` directive.
=== Parsing
. Directive loop already accepts `assembler`; add `nostackframe` there, setting
`TMethodDecl.NoStackFrame`.
. Body parsing: when the next token is `tkAsm`, call the lexer's raw-capture
path and wrap the returned text in a `TAsmStmt` inside a fresh `TBlock`,
instead of `ParseBlock`. `assembler` is advisory in FPC (an `asm` body
implies it); Blaise keys off the `asm` token, so `assembler;` without an
`asm` body, or an `asm` body without `assembler;`, both behave sensibly.
=== Semantic analysis
`TAsmStmt` is opaque: `AnalyseStmt` has a no-op arm for it. The routine's
parameters, return type, calling convention, and emitted symbol name are
resolved exactly as for any routine, so call sites bind normally. No
liveness, type, or ARC analysis runs over the block.
=== Codegen — native
In `EmitFunctionDef`:
. Emit `.text`, the `.globl`/label as today.
. If `NoStackFrame`: skip prologue, frame `subq`, and the epilogue; emit the
`TAsmStmt.Code` verbatim (the body is responsible for `ret`).
. Else (an `asm` body that still wants the standard frame): emit the normal
prologue, then the verbatim asm, then the normal epilogue — supported, but
the RTL bodies use `nostackframe`.
`EmitStmt` gains a `TAsmStmt` arm that writes the verbatim lines, used when an
`asm` block appears as the sole body statement.
=== Codegen — QBE
`TAsmStmt` raises a clear "inline asm requires the native backend" codegen
error. QBE is not the inline-asm target; the native backend is the default and
the only one that emits assembly text.
=== Unit interface I/O (`.bif`)
`TAsmStmt` gains encode/decode in `uUnitInterfaceIO` (one string field) and a
`bif-coverage.status` entry, so an `asm`-bodied routine exported by a unit
round-trips through the unit cache. `NoStackFrame` joins the `TMethodDecl`
directive flags already serialised (`IsVirtual`, `IsOverride`, …).
== Migration of the `.s` files
Once `asm` blocks land, each `runtime/src/main/asm/*_x86_64.s` body can move
into an `asm` routine in a `.pas` unit (e.g. `blaise_start.pas`,
`blaise_cpu.pas`), the Makefile's `$(CC) -c` asm rule and `$(ASM_DIR)` go away,
and the RTL builds with zero external-assembler invocations. That migration is
a *follow-up* commit per file, gated on this feature; it is not part of the
feature commit, so a regression is bisectable to one body at a time.
The `_start` body is the natural first migration and its e2e guards already
exist (`TInternalAsmE2ETests.TestEntry_*`).
== Testing
Per project policy, both layers:
. *IR/asm unit test* — compile a tiny `asm`-body routine and assert the emitted
native assembly contains the verbatim instructions and, for `nostackframe`,
omits the `pushq %rbp` prologue.
. *E2E (internal assembler + linker)* — a program that calls an `asm` routine
returning a known value in `%rax`, run end-to-end, asserting stdout/exit.
This is the only layer that proves the block assembles and executes; it runs
through `TInternalAsmE2ETests`’ real-compiler harness.
. *QBE rejection* — assert the QBE backend errors on an `asm` body.
. *.bif round-trip* — a unit exporting an `asm` routine, imported via the unit
cache, still links and runs (guards the serialiser).
. *Fixpoints* — once a `.s` body is migrated, the native fixpoints exercise it
(the compiler links its own RTL).
== Risks
* *Block-termination scanning* — a mis-scan that swallows past the real `end`
would corrupt the following declarations. Mitigated by matching `end` only
as a standalone keyword token and by the parser re-synchronising on the
routine's trailing `;`. Covered by a test with an `end`-mentioning comment
inside the block.
* *Symbol naming* — the asm references callees/globals by their emitted symbol
name; a mismatch is a link error, caught by the e2e/fixpoint link step.
* *`nostackframe` misuse* — a body that omits `ret` or corrupts the stack
crashes at run time; the e2e execution test is the guard.
== Alternatives considered
* *Keep the `.s` files, add a `blaise --assemble file.s` mode (Route A).*
Smaller, and reaches "no gcc in the RTL build" too, but leaves assembly
outside the language and keeps a parallel build path. Inline `asm` blocks
(this design, Route B) fold the assembly into ordinary units, matching FPC and
removing the `$(ASM_DIR)` special case entirely. Route A remains a valid
stepping stone but is not pursued.
* *Parse instructions into a structured AST.* Rejected — duplicates the
assembler, couples the front end to x86-64, and provides no benefit for the
text-to-text path (see "Why opaque-text").

View file

@ -5209,3 +5209,62 @@ anything that can be read as a designator can be assigned to.
* *Require an intermediate variable (status quo).* Rejected — it is a
gratuitous restriction with no semantic justification; the read side already
supported the full chain, so write-side parity is the consistent choice.
== Inline Assembler — `asm … end` Routine Bodies
=== Decision
A routine body may be written as an inline-assembler block instead of a Pascal
`begin … end` block:
[source,pascal]
----
function GetSelf: Pointer; assembler; nostackframe;
asm
movq %rdi, %rax
ret
end;
----
The block content is verbatim GNU/AT&T assembly. The front end (lexer, parser,
semantic pass) never interprets it — the lexer captures the whole `asm … end`
as one opaque token, and the native backend emits the text verbatim into the
assembly stream, where the existing internal (or external) assembler parses it.
The `nostackframe` directive suppresses the compiler prologue/epilogue so the
asm body owns the entire frame (it provides its own `ret`). Inline asm requires
the native backend; the QBE backend rejects it with a diagnostic. `asm`
routines mix freely with ordinary Pascal routines in a standard `.pas` unit
(no `.inc` include mechanism is involved).
=== Rationale
The motivation is toolchain independence. The runtime's startup (`_start`),
`setjmp`/`longjmp`, and atomic primitives are hand-written assembly. Shipping
them as standalone `.s` files assembled by `cc -c` reintroduces a build-time gcc
dependency, defeating the internal assembler/linker. Expressing them as `asm`
bodies in Pascal units folds that assembly into Blaise's own pipeline — the same
approach FPC takes (`rtl/linux/x86_64/si_c.inc`).
The block is modelled as opaque text rather than a parsed instruction AST: the
internal assembler already consumes GNU/AT&T text and the native backend already
emits text, so the block needs only to reach the backend's stream verbatim.
Parsing instructions into the AST would duplicate the assembler and couple the
front end to one architecture's instruction set for no benefit. This keeps the
architecture-specific knowledge at the backend/assembler edge (a ports-and-
adapters boundary), so the portable core stays ignorant of x86-64 encoding. See
docs/inline-asm-design.adoc for the full design and the design patterns applied.
=== Alternatives considered
* *A standalone `blaise --assemble file.s -o file.o` mode (Route A).* Reaches
"no gcc in the RTL build" too and is smaller, but leaves assembly outside the
language and keeps a parallel build path. Inline `asm` blocks fold the
assembly into ordinary units, matching FPC and removing the special-case
build directory. Route A remains a valid stepping stone but was not pursued.
* *Parse instructions into a structured AST.* Rejected — duplicates the
assembler, couples the front end to x86-64, and provides nothing for the
text-to-text path.
* *Reserve `asm` only contextually (keep it usable as an identifier).* Rejected
— `asm` is a reserved word in FPC/Delphi and in the grammar; the codebase had
a single local variable named `Asm` (renamed), and contextual keywording would
need parser state the lexer does not have.

View file

@ -112,6 +112,9 @@ TIfStmt.ElseStmt serialise
# TCompoundStmt (uAST.pas:243)
TCompoundStmt.Stmts serialise
# TAsmStmt (uAST.pas:255)
TAsmStmt.Code serialise
# TWhileStmt (uAST.pas:250)
TWhileStmt.Condition serialise
TWhileStmt.Body serialise