325 lines
16 KiB
Markdown
325 lines
16 KiB
Markdown
# Plan 9 assembly tooling, inside and outside Go
|
|
|
|
> **Warning: this is an experiment.** gasm-devkit is under active
|
|
> development and is not stable. The version is 0.x.x: commands, flags,
|
|
> output formats and behaviour can change without warning at any time.
|
|
> A 1.0.0 release is light years away. Nothing in this document is a
|
|
> stability promise. For all of that, this is not a paper project: gasm
|
|
> is already in active use and is tested on real assembly work. Only
|
|
> amd64 is validated on real hardware; the other three architectures run
|
|
> under emulation ([Validation status](#validation-status)).
|
|
|
|
**GAsm** is Go's Plan 9 assembler, and Go ships it without tooling:
|
|
there is no formatter, no linter and no debugger for `.s` files, and no
|
|
assembler that works without a Go installation. Developers write
|
|
assembly blind, validate it by benchmark, and debug it by print
|
|
statement. gasm-devkit is the missing toolkit: a single, self-contained
|
|
binary, `gasm`, that serves both purposes.
|
|
|
|
- **Help develop Plan 9 assembly.** Formatting, linting, disassembly,
|
|
dynamic verification, a source-level debugger and a language server,
|
|
for `.s` files in Go programs.
|
|
- **Use Plan 9 assembly outside the Go toolchain.** `gasm asm` encodes
|
|
on its own and writes raw images or linkable ELF objects with DWARF5
|
|
debug sections, with no Go installation in the loop; the Go
|
|
toolchain's own GOOBJ format, which `go build` consumes in place of
|
|
the toolchain's output, needs the installed toolchain.
|
|
|
|
## Why Plan 9 assembly
|
|
|
|
Plan 9 assembly is the quiet triumph of the field. One syntax across
|
|
every architecture Go builds for: the same source-first operand order,
|
|
the same four pseudo-registers, the same frame convention, whether the
|
|
target is x86, ARM, RISC-V or LoongArch. Learn it once and you can
|
|
read a kernel on any of them.
|
|
|
|
Compare the alternatives. Intel syntax and AT&T syntax disagree on the
|
|
one question every instruction answers, which operand is the source
|
|
and which is the destination, so half the world writes it one way,
|
|
half the other, and every assembly programmer carries both in their
|
|
head forever. GNU as settles the argument with directives that switch
|
|
dialects mid-file (`.intel_syntax noprefix`), a percent sign on every
|
|
register and a dollar on every immediate: punctuation that carries
|
|
nothing the operand order did not already say. And the x86 family
|
|
fragments again underneath: NASM is not MASM is not GAS, each with its
|
|
own directive zoo and macro language, so every project picks a dialect
|
|
and every reader learns a different one by accident.
|
|
|
|
Plan 9 assembly has none of it. Registers are bare names. Memory is
|
|
one notation, `offset(base)`, extended by an index and a scale when
|
|
the instruction needs it. Arguments arrive named and offset-checked:
|
|
`x+0(FP)` is the argument x, on every architecture, and `go vet`
|
|
polices the offsets against the Go prototype.
|
|
|
|
```text
|
|
AT&T (GNU as): movq %rax, -16(%rbp)
|
|
Plan 9 (Go): MOVQ AX, total-16(SP)
|
|
```
|
|
|
|
The same lines, but only one of them tells you what the number is for.
|
|
The syntax is uppercase, regular and boring, which is the highest
|
|
compliment a language for machine code can earn. gasm-devkit exists
|
|
to give that syntax the tooling it deserves.
|
|
|
|
## Features
|
|
|
|
- **Front end.** A hand-written lexer and an error-tolerant parser produce a
|
|
typed AST with source positions; `gasm tokens` and `gasm parse` expose them
|
|
directly.
|
|
- **Formatter.** `gasm fmt` canonicalises indentation, operand spacing,
|
|
per-function mnemonic alignment and blank-line layout: `gofmt` for assembly,
|
|
operating recursively on directories the way `go fmt` does. `-l` lists
|
|
files whose formatting differs and `-d` prints a unified diff.
|
|
- **Linter.** `gasm lint` runs 18 conservative static checks, among them
|
|
`undefined-label`, `abi-argsize` (declared argument area vs the `// func`
|
|
signature), `register-clobber` (Go ABI register liveness over the
|
|
control-flow graph), `stack-imbalance`, `abi0-register-args` and
|
|
`unencodable-instruction`.
|
|
- **Standalone assembler.** `gasm asm` encodes all four architectures without
|
|
the Go toolchain and writes raw images or linkable ELF objects (with DWARF5
|
|
debug sections) with no Go installation needed, or the Go toolchain's own
|
|
GOOBJ format, which needs the installed toolchain and which `go build`
|
|
consumes in place of the toolchain's output. Framed functions get the
|
|
stack-split guard and the morestack block, byte-identical to the
|
|
toolchain's, so split functions link too.
|
|
- **Disassembler.** `gasm dis` lists a `.s` file's functions at their real
|
|
offsets after assembling, or disassembles raw bytes from a file or stdin.
|
|
- **Dynamic verification.** `gasm verify` JIT-loads assembled functions into
|
|
executable memory: smoke calls, ABI checks (sentinel registers, red-zone
|
|
canary), differential fuzzing against the `go tool asm` build, and
|
|
byte-for-byte ground-truth comparison of the machine code.
|
|
- **Debugger.** `gasm debug` is a source-level ptrace debugger with
|
|
breakpoints (optionally conditional), hardware watchpoints, register and
|
|
memory inspection, and headless script runs that report instruction and
|
|
label coverage.
|
|
- **Language server.** `gasm lsp` serves completion, hover, document symbols,
|
|
push and pull diagnostics, semantic-token highlighting, go-to-definition,
|
|
find references, rename, formatting, inlay hints, code actions, signature
|
|
help, document highlights, workspace symbol search, #include document
|
|
links and folding ranges over stdio; definition, references and rename
|
|
work across every open document.
|
|
- **Comparators and audits.** `gasm diff` compares the machine code of two
|
|
assembly files byte-for-byte, `gasm profile` shows basic-block structure,
|
|
`gasm audit-instructions` diffs the encoder against the installed toolchain,
|
|
and `gasm scaffold` generates a differential test skeleton for a kernel.
|
|
|
|
### Architecture support
|
|
|
|
Four architectures, the four that matter in practice:
|
|
|
|
| Architecture | GOARCH | File suffix | Instructions recognised |
|
|
|--------------|-------------|--------------|---------------------------------------------|
|
|
| AMD64 | `amd64` | `_amd64.s` | 1600 + common opcodes + traditional aliases |
|
|
| ARM64 | `arm64` | `_arm64.s` | 538 + common opcodes |
|
|
| RISC-V | `riscv64` | `_riscv64.s` | 961 + common opcodes |
|
|
| LoongArch | `loong64` | `_loong64.s` | 799 + common opcodes |
|
|
|
|
"Common opcodes" are the instructions shared by every architecture (`RET`,
|
|
`JMP`, `NOP`, `CALL`, `TEXT`, `FUNCDATA`, `PCDATA`, ...). AMD64 additionally
|
|
carries the traditional conditional-jump spellings (`JZ`, `JNZ`, `JA`, `JC`,
|
|
...) that the assembler accepts as aliases. The tables are generated from
|
|
the Go toolchain's own assembler source (`just gen` refreshes them), so
|
|
every mnemonic the real assembler accepts is recognised; what the encoder
|
|
can emit today is narrower, and a recognised but unencodable instruction is
|
|
reported as an explicit error, never as a wrong byte.
|
|
|
|
The same measurement runs over GOROOT's whole assembly corpus:
|
|
`gasm audit-instructions --corpus` reports 136 of 433 attemptable files
|
|
(31.4 %) assembling for every target architecture today (files named for
|
|
other Go ports are counted but never attempted), with the top failure
|
|
reasons per architecture; the number moves with every release.
|
|
|
|
### Validation status
|
|
|
|
**Only amd64 is validated on real hardware.** The other three
|
|
architectures are validated under qemu-user emulation, because the
|
|
project owns no arm64, riscv64 or loong64 machine, and emulation is the
|
|
only substitute available for the hardware. The distinction matters and
|
|
is stated rather than implied: everything below is a claim about what has
|
|
actually been executed.
|
|
|
|
| Layer | amd64 | arm64, riscv64, loong64 |
|
|
|---|---|---|
|
|
| Encoding: byte-for-byte against `go tool asm` | native hardware | native hardware (the toolchain cross-assembles any GOARCH on any host) |
|
|
| Execution: JIT calls, ABI checks, differential fuzzing | native hardware | qemu-user emulation |
|
|
| Debugger: ptrace tracing, breakpoints, watchpoints, coverage | native hardware | emulation cannot run ptrace; the layer compiles and its architecture-neutral units run under `go test ./...`, nothing more |
|
|
|
|
Consequences, stated plainly. An emulator is a model of a CPU, not the
|
|
CPU: instruction semantics are implemented in software and can differ
|
|
from silicon in ways a test suite does not reveal. A kernel that passes
|
|
under qemu-user is therefore not proven correct on real hardware, and a
|
|
discrepancy found on real hardware is a defect in gasm, reported like any
|
|
other. Encoding parity is the exception: the byte comparison against the
|
|
toolchain runs on the host for every architecture, so no emulator stands
|
|
between the claim and the evidence. The debugger is the weakest case: on
|
|
the three emulated architectures its per-architecture ptrace code has
|
|
been compiled and read, never executed. Its architecture-neutral units
|
|
run under `go test ./...`, which the race workflow and a manual run
|
|
perform; the default `just test` gate does not sweep `./debug/...`.
|
|
|
|
## The documentation goal
|
|
|
|
The toolkit is the primary goal. The secondary one is documentation: a
|
|
specification of the Plan 9 assembly language and of the GOOBJ object
|
|
format that is 100 % complete, detailed enough to implement against,
|
|
and written to a professional standard. These are the two subjects this
|
|
project works with every day, and they are the two for which no usable
|
|
documentation exists.
|
|
|
|
Go documents the language on a single page, "A Quick Guide to Go's
|
|
Assembler", which carries no section for loong64, one of the four
|
|
architectures gasm supports, and covers a fraction of what each
|
|
assembler accepts. What exists beyond it lives as comments inside the
|
|
toolchain's internal source: per-architecture reference manuals for
|
|
arm64, ppc64, riscv64 and loong64, written for the toolchain's own
|
|
maintainers rather than for an outside reader, and none at all for
|
|
amd64. GOOBJ fares worst of all. The format that `go build` consumes
|
|
has no specification anywhere: it is described by a comment in an
|
|
internal package, it is not a stable interface, and it can change with
|
|
any toolchain release.
|
|
|
|
The gap is therefore filled the only way it can be filled: by reverse
|
|
engineering the toolchain itself, the same work the encoders already
|
|
perform. Most of the documentation can come from nowhere else, and it
|
|
is written as that knowledge is produced during development. It is
|
|
verified the way the code is verified: an encoding documented here is
|
|
one that differential tests against `go tool asm` confirm
|
|
byte-for-byte, and a format field documented here is one the linker
|
|
demonstrably reads. The result will live in this repository, so that
|
|
Plan 9 assembly finally carries a reference its own tooling is built
|
|
against.
|
|
|
|
## Direction
|
|
|
|
The plan, in the order it is being worked:
|
|
|
|
- **Extended instruction support.** Two layers. First, encoding
|
|
coverage for every mnemonic the Go toolchain itself accepts, closed in
|
|
order of how often real code needs each instruction;
|
|
`gasm audit-instructions` measures the gap. Second, the larger work:
|
|
an extended instruction set the toolchain does not know at all. The
|
|
toolchain-derived tables stay generated and untouched; only the
|
|
extended instructions are hand-maintained, with their own spellings
|
|
and encoders, verified by execution (on real hardware for amd64, under
|
|
emulation for the rest, per the validation status above) because the
|
|
toolchain offers no ground truth to compare against. The gaps exist
|
|
on every architecture, amd64 included.
|
|
- **Full GOOBJ and ELF compilation.** The destination is a complete,
|
|
standalone compilation path: linkable ELF objects for consumers outside
|
|
Go, and GOOBJ objects that `go build` links directly. Through GOOBJ, a
|
|
Go program will be able to use machine instructions that the Go
|
|
toolchain itself does not support; through ELF, Plan 9 assembly becomes
|
|
usable outside Go entirely.
|
|
- **Platforms: Linux and FreeBSD.** Linux is supported today on all four
|
|
architectures and is where the binary builds. FreeBSD follows: the
|
|
JIT's executable-memory mapping and the ptrace debugger layer are the
|
|
two pieces of porting work. Other unix systems may follow those two.
|
|
- **Four architectures, no more.** amd64, arm64, riscv64 and loong64.
|
|
No others are planned.
|
|
|
|
## Install
|
|
|
|
Prebuilt binaries for linux/amd64, linux/arm64, linux/riscv64 and
|
|
linux/loong64 are on the
|
|
[releases page](https://sourcedock.dev/petrbalvin/gasm-devkit/releases).
|
|
From source (Go 1.27.1):
|
|
|
|
```sh
|
|
go install sourcedock.dev/petrbalvin/gasm-devkit/cmd/gasm@latest
|
|
```
|
|
|
|
Or from a repository checkout:
|
|
|
|
```sh
|
|
just install
|
|
```
|
|
|
|
The installed binary reports the version the toolchain recorded: the tag
|
|
on a tagged checkout, a pseudo-version naming the commit below one.
|
|
|
|
## Quick start
|
|
|
|
```sh
|
|
cat > hello_amd64.s <<'EOF'
|
|
#include "textflag.h"
|
|
|
|
// func add(a, b int) int
|
|
TEXT ·add(SB), NOSPLIT, $0-24
|
|
MOVQ a+0(FP), AX
|
|
ADDQ b+8(FP), AX
|
|
MOVQ AX, ret+16(FP)
|
|
RET
|
|
EOF
|
|
|
|
gasm lint hello_amd64.s # static checks
|
|
gasm asm -o hello.bin hello_amd64.s # assemble to a raw image
|
|
gasm verify --call add --args a=2,b=3 hello_amd64.s # JIT-call it with arguments
|
|
```
|
|
|
|
## Usage
|
|
|
|
```sh
|
|
gasm fmt # reformat every .s below here, like go fmt
|
|
gasm fmt -w kernel_amd64.s # canonicalise one file in place
|
|
gasm fmt -l *.s # list files whose formatting differs
|
|
gasm fmt -d kernel_amd64.s # print a unified diff instead
|
|
gasm lint *.s # static checks
|
|
gasm asm --format elf -o k.o k.s # assemble to a linkable ELF object
|
|
gasm asm --format goobj -p pkg/path -o k.o k.s # Go object, consumed by go build
|
|
gasm dis k.s # assemble, then list each function
|
|
gasm dis -a amd64 - < dump.bin # disassemble raw bytes from stdin
|
|
gasm verify --ground-truth k.s # byte-for-byte vs go tool asm
|
|
gasm verify --fuzz k.s # differential fuzz vs the go tool asm build
|
|
gasm debug --func name k.s # interactive debugger
|
|
gasm debug --func name --script cmds.txt --timeout 30s k.s # headless run
|
|
gasm debug --func name --cover k.s # instruction and label coverage
|
|
gasm diff a.s b.s # compare machine code byte-for-byte
|
|
gasm diff --map wideCopyAVX2=wideCopyAVX512 avx2.s avx512.s
|
|
gasm profile k.s # show basic-block structure
|
|
gasm audit-instructions # encoder vs go tool asm name diff
|
|
gasm scaffold differential k.s # generate a differential test skeleton
|
|
```
|
|
|
|
Run `gasm --help` for the command overview and `gasm <command> -h` for a
|
|
command's flags. [docs/CLI.md](docs/CLI.md) is the full reference.
|
|
|
|
### Editor integration
|
|
|
|
`gasm lsp` speaks the Language Server Protocol over standard input/output, so
|
|
any LSP-capable editor can use it: point your editor's LSP client at the
|
|
binary and associate it with `.s` files. Syntax highlighting is delivered as
|
|
LSP semantic tokens, so no editor-specific grammar is required. The server
|
|
infers the target architecture from the file-name suffix
|
|
(`_amd64.s` / `_arm64.s` / `_riscv64.s` / `_loong64.s`).
|
|
|
|
## Development
|
|
|
|
```sh
|
|
just build # compile, zero errors and zero warnings
|
|
just test # the suite, no cache, the 80 % coverage floor
|
|
just gates # build, fmt-check, vet, test, race: the definition of done
|
|
just fmt # gofmt the tree
|
|
just gen # regenerate the instruction tables from the Go toolchain
|
|
```
|
|
|
|
See [CONTRIBUTING.md](CONTRIBUTING.md) for the development workflow and
|
|
[docs/DEVELOPMENT.md](docs/DEVELOPMENT.md) for setup details and every
|
|
recipe.
|
|
|
|
## Documentation
|
|
|
|
- [docs/CLI.md](docs/CLI.md): full command reference
|
|
- man pages: `just install-man` installs gasm(1) and one page per command
|
|
except `version`, which is documented inside gasm(1) instead, into
|
|
~/.local/share/man (MANDIR overrides); `just uninstall-man` removes
|
|
them
|
|
- [docs/ARCHITECTURE.md](docs/ARCHITECTURE.md): components and data flow
|
|
- [docs/DEVELOPMENT.md](docs/DEVELOPMENT.md): development setup and recipes
|
|
- [CHANGELOG.md](CHANGELOG.md): release history
|
|
|
|
## Licence
|
|
|
|
BSD-3-Clause; see [LICENSE](LICENSE).
|
|
|
|
Copyright © 2026 [Petr Balvín](https://petrbalvin.org)
|