blaise/docs/inline-asm-design.adoc
Graeme Geldenhuys 0396ed7b1c 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.
2026-06-25 01:01:41 +01:00

267 lines
12 KiB
Plaintext
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

= 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").