diff --git a/compiler/src/main/pascal/blaise.codegen.native.x86_64.pas b/compiler/src/main/pascal/blaise.codegen.native.x86_64.pas index ed58a41..ce61515 100644 --- a/compiler/src/main/pascal/blaise.codegen.native.x86_64.pas +++ b/compiler/src/main/pascal/blaise.codegen.native.x86_64.pas @@ -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 diff --git a/compiler/src/main/pascal/blaise.codegen.qbe.pas b/compiler/src/main/pascal/blaise.codegen.qbe.pas index e7a2f2d..1d4f56f 100644 --- a/compiler/src/main/pascal/blaise.codegen.qbe.pas +++ b/compiler/src/main/pascal/blaise.codegen.qbe.pas @@ -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 diff --git a/compiler/src/main/pascal/uAST.pas b/compiler/src/main/pascal/uAST.pas index 5ddc261..bf8369e 100644 --- a/compiler/src/main/pascal/uAST.pas +++ b/compiler/src/main/pascal/uAST.pas @@ -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 } diff --git a/compiler/src/main/pascal/uLexer.pas b/compiler/src/main/pascal/uLexer.pas index 239eebd..c82ee8c 100644 --- a/compiler/src/main/pascal/uLexer.pas +++ b/compiler/src/main/pascal/uLexer.pas @@ -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 := '-'; diff --git a/compiler/src/main/pascal/uParser.pas b/compiler/src/main/pascal/uParser.pas index f0ba1a5..193b2e5 100644 --- a/compiler/src/main/pascal/uParser.pas +++ b/compiler/src/main/pascal/uParser.pas @@ -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; diff --git a/compiler/src/main/pascal/uSemantic.pas b/compiler/src/main/pascal/uSemantic.pas index 3955558..e1b75e1 100644 --- a/compiler/src/main/pascal/uSemantic.pas +++ b/compiler/src/main/pascal/uSemantic.pas @@ -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); diff --git a/compiler/src/main/pascal/uUnitInterfaceIO.pas b/compiler/src/main/pascal/uUnitInterfaceIO.pas index f8e3926..32c7468 100644 --- a/compiler/src/main/pascal/uUnitInterfaceIO.pas +++ b/compiler/src/main/pascal/uUnitInterfaceIO.pas @@ -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 + ''''); diff --git a/compiler/src/test/pascal/cp.test.assembler.pas b/compiler/src/test/pascal/cp.test.assembler.pas index 8af9cb3..abaac8e 100644 --- a/compiler/src/test/pascal/cp.test.assembler.pas +++ b/compiler/src/test/pascal/cp.test.assembler.pas @@ -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(''); + 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(''); + Exit; + end; + AssertEquals(0, EC); + AssertEquals('42' + LineEnding, Out_); +end; + { ---- Registration ---- } initialization diff --git a/compiler/src/test/pascal/cp.test.lexer.pas b/compiler/src/test/pascal/cp.test.lexer.pas index 608792f..e95b633 100644 --- a/compiler/src/test/pascal/cp.test.lexer.pas +++ b/compiler/src/test/pascal/cp.test.lexer.pas @@ -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; diff --git a/compiler/src/test/pascal/cp.test.nativeconstarg.pas b/compiler/src/test/pascal/cp.test.nativeconstarg.pas index 1ddd924..6e59c00 100644 --- a/compiler/src/test/pascal/cp.test.nativeconstarg.pas +++ b/compiler/src/test/pascal/cp.test.nativeconstarg.pas @@ -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 diff --git a/docs/grammar.ebnf b/docs/grammar.ebnf index d1a29a1..92a1e26 100644 --- a/docs/grammar.ebnf +++ b/docs/grammar.ebnf @@ -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) diff --git a/docs/inline-asm-design.adoc b/docs/inline-asm-design.adoc new file mode 100644 index 0000000..69ca65b --- /dev/null +++ b/docs/inline-asm-design.adoc @@ -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"). diff --git a/docs/language-rationale.adoc b/docs/language-rationale.adoc index 2e4cffc..271bf53 100644 --- a/docs/language-rationale.adoc +++ b/docs/language-rationale.adoc @@ -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. diff --git a/tools/bif-coverage/bif-coverage.status b/tools/bif-coverage/bif-coverage.status index 77c4c4c..fce9f5b 100644 --- a/tools/bif-coverage/bif-coverage.status +++ b/tools/bif-coverage/bif-coverage.status @@ -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