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.
267 lines
12 KiB
Plaintext
267 lines
12 KiB
Plaintext
= 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").
|