Compare commits

..
9 Commits
Author SHA1 Message Date
petrbalvin 93c47a312a feat(docs): man pages for gasm and every command, guarded against CLI drift
Test / test (push) Successful in 2m4s
Assisted-by: GLM 5.3 Flash
2026-09-19 21:18:43 +02:00
petrbalvin 708d0a0a5e docs: trim the changelog entries to user-visible deltas
Assisted-by: GLM 5.3 Flash
2026-09-19 20:54:56 +02:00
petrbalvin 3c8f7cb411 test(format): pin the fuzz-found crashers as regression seeds
Assisted-by: GLM 5.3 Flash
2026-09-19 20:48:51 +02:00
petrbalvin 7c5b7a1419 docs: add the changelog entries and the corpus number to the readme
Assisted-by: GLM 5.3 Flash
2026-09-19 20:48:51 +02:00
petrbalvin bc3f448738 feat(format): fuzz targets for the parser and formatter
Assisted-by: GLM 5.3 Flash
2026-09-19 20:41:43 +02:00
petrbalvin f37f183577 feat(riscv64): GOROOT instruction shapes, DATA order and offset expressions
Assisted-by: GLM 5.3 Flash
2026-09-19 19:58:43 +02:00
petrbalvin 1e77e58250 feat(gasm): audit a .s corpus with audit-instructions --corpus
Assisted-by: GLM 5.3 Flash
2026-09-19 19:27:30 +02:00
petrbalvin 1d0969ed64 feat(gasm): select the asm and diff architecture with -GOARCH
Assisted-by: GLM 5.3 Flash
2026-09-19 19:20:47 +02:00
petrbalvin 23c001be51 feat(asm): encode indirect JMP and CALL on all four architectures
Assisted-by: GLM 5.3 Flash
2026-09-19 19:17:07 +02:00
68 changed files with 2362 additions and 154 deletions
+8 -1
View File
@@ -79,9 +79,16 @@ jobs:
# hanging test reports its own goroutine dump rather than a silent job kill.
# The pattern is `packages` in the project's justfile: the logic packages, since a
# thin cmd/ would drag the total under the floor. release.yml runs the same
# command, so the floor is the same number everywhere.
# command, so the floor is the same number everywhere. ./verify/... carries the
# live oracle-parity comparison against `go tool asm` (the TestGroundTruth
# suites); the runner's Go setup provides both the tool and GOROOT.
run: go test -count=1 -timeout 10m -coverprofile=coverage.out ./arch/... ./asm/... ./ast/... ./disasm/... ./format/... ./lexer/... ./lint/... ./lsp/... ./parser/... ./token/... ./verify/...
- name: Oracle parity
# Re-run the live go-tool-asm comparison as its own step so that a parity
# regression names the gate that failed instead of hiding inside the suite.
run: go test -count=1 -timeout 10m -run 'TestGroundTruth' ./verify/...
- name: Coverage floor
run: |
perl -e '
+57 -6
View File
@@ -7,6 +7,46 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [development]
### Added
- **Indirect JMP and CALL on all four architectures.** `JMP AX`,
`CALL AX`, `JMP (BX)` and the memory forms encode at byte parity with
the toolchain (FF /2 and FF /4 on amd64); arm64 lowers `JMP (R0)` to
BR and accepts the raw BR/BLR spellings; riscv64 lowers `JMP (X5)` to
JALR; loong64 accepts the raw `JIRL rd, rj, off` spelling the Go
assembler cannot express. A frameless amd64 function containing a
CALL now receives the toolchain's forced base-pointer frame. The
verify trampolines join the ground-truth lists, and a lint check for
control flow through registers and memory extends to the new forms.
- **`gasm asm -GOARCH` and `gasm diff -GOARCH`.** The target
architecture can be named explicitly instead of inferred from the
file-name suffix, which is how the suffix-less majority of GOROOT's
`.s` files (cpu_x86.s, stub.s, ...) become assemblable.
- **`gasm audit-instructions --corpus [dir]`.** Assembles every `.s`
file under a directory (default GOROOT/src) with the gasm encoder
only: suffixed files for their architecture, suffix-less files for
all four, as a GOARCH build would. Reports the headline number (108
of 627 GOROOT files, 17.2 %, assemble for every target architecture,
against 23 in the previous release), the per-architecture pass rates
and the most common failure reasons with a representative file each,
which drive the encodability backlog by frequency.
- **Fuzz targets for the parser and the formatter.** FuzzParse (no
panic, always a usable file) and FuzzFormatIdempotency (formatting
twice equals formatting once; clean input stays clean) seed
themselves from the repository's kernels, so the plain test suite
replays every seed in CI and `just fuzz` runs the mutation engine on
demand.
- **Oracle parity as its own CI step.** The push pipeline already ran
the live go-tool-asm comparison inside the suite; a dedicated step
now names that gate when it fails.
- **Man pages.** docs/man carries gasm(1) and one page per command,
written in roff: synopsis, description, every flag with its default,
exit status, worked examples and cross-references.
`just install-man` compresses them into ~/.local/share/man (MANDIR
overrides) and `just uninstall-man` removes them. A test builds the
binary and compares every command's `-h` output with its page, so the
pages cannot drift from the CLI.
### Changed
- **Canonical just recipes.** `just gates` is the definition of done
@@ -52,12 +92,23 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Fixed
- **The dependency statement was wrong.** `golang.org/x/arch` is not
test-only: `gasm dis` and the debugger's listings decode through it, so
it is linked into the binary. `CONTRIBUTING.md` and
`docs/ARCHITECTURE.md` said otherwise.
- **The CLI reference listed 17 of the 18 lint rules.** The missing
`reserved-register-write` is documented with the rest.
- **riscv64 JALR silently jumped to the wrong register.** The trampoline
form `JALR X0, 0(X5)` read the memory operand's base as the destination,
encoding a jump to X0 with no diagnostic; the destination is the first
operand. The leaf detection shared the confusion, so affected functions
also grew a bogus prologue. `JALR X0, 0(X1)` as written in the verify
trampoline was mis-encoded since its introduction.
- **DATA lines demanded their GLOBL first.** collectData processed the
declarations in file order, but the Plan 9 convention puts every DATA
line before its symbol's GLOBL; correctly ordered files (most of
GOROOT's) failed with "no matching GLOBL". Two passes: symbols are
registered before initialisers are applied.
- **The formatter lost idempotency on degenerate lines.** Illegal tokens
survived into the output, a label sharing its line with a
non-instruction split into a line the parser rejects, stray-operand
lines entered the alignment width computation, and rendered `/ *`,
`> >` sequences re-lexed as comments and shifts. The label, width and
spacing rules now agree between passes.
## [0.33.0] - 2026-09-14
+9 -1
View File
@@ -118,6 +118,11 @@ 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 108 of 627 files (17.2 %)
assembling for every target architecture today, with the top failure
reasons per architecture; the number moves with every release.
## Direction
The plan, in the order it is being worked:
@@ -236,8 +241,11 @@ recipe.
## Documentation
- [docs/ARCHITECTURE.md](docs/ARCHITECTURE.md): components and data flow
- [docs/CLI.md](docs/CLI.md): full command reference
- man pages: `just install-man` installs gasm(1) and one page per command
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
+36
View File
@@ -236,6 +236,11 @@ func encodeARM64Instr(instr *ast.Instr, pc int, offsets map[string]int, fi arm64
return encodeARM64BranchCond(mnem, enc.op, ops, pc, offsets, resolve)
}
// Unconditional register branches (BR, BLR).
if enc, ok := a64InstrTable[mnem]; ok && enc.format == a64FUncondBranch {
return encodeARM64RegBranch(mnem, enc.op, ops)
}
// ADD/SUB immediate.
if mnem == "ADD" || mnem == "ADDW" || mnem == "SUB" || mnem == "SUBW" ||
mnem == "CMP" || mnem == "CMPW" || mnem == "CMN" || mnem == "CMNW" {
@@ -337,6 +342,24 @@ func encodeARM64Branch(mnem string, ops []*ast.Operand, pc int, offsets map[stri
}
op := ops[0]
// Register-indirect: JMP (R0) is BR R0, CALL (R0) is BLR R0. The
// toolchain's spelling carries no offset and no index; anything else
// is reported rather than silently dropped.
if op.Addr.Sym == nil && op.Addr.Base != "" {
if op.Addr.Offset != 0 || op.Addr.Index != "" {
return nil, fmt.Errorf("%s: invalid indirect branch operand %q", mnem, op.Raw)
}
rn := arm64RegNum(op.Addr.Base)
if rn < 0 {
return nil, fmt.Errorf("%s: unknown branch register %q", mnem, op.Addr.Base)
}
opc := uint32(0) // BR
if link {
opc = 1 // BLR
}
return a64wordLE(a64UncondBranch(opc, uint32(rn), 0)), nil
}
// Symbol reference: BL sym(SB), or B sym(SB) for a tail call, against a
// relocation (R_CALLARM64 either way).
if op.Addr.Sym != nil && op.Addr.Sym.Pseudo == "SB" {
@@ -373,6 +396,19 @@ func encodeARM64Branch(mnem string, ops []*ast.Operand, pc int, offsets map[stri
return a64wordLE(a64Branch(bop, int32(rel))), nil
}
// encodeARM64RegBranch encodes BR/BLR through a register operand:
// BR Xn = 0xd61f0000 | Rn<<5, BLR Xn = 0xd63f0000 | Rn<<5.
func encodeARM64RegBranch(mnem string, baseOp uint32, ops []*ast.Operand) ([]byte, error) {
if len(ops) != 1 {
return nil, fmt.Errorf("%s expects 1 operand, got %d", mnem, len(ops))
}
rn := arm64RegNum(operandRegName(ops[0]))
if rn < 0 {
return nil, fmt.Errorf("%s expects a register operand", mnem)
}
return a64wordLE(uint32(baseOp) | 31<<16 | uint32(rn)<<5), nil
}
// encodeARM64BranchCond encodes a conditional branch (B.cond) to a label.
func encodeARM64BranchCond(mnem string, baseOp uint32, ops []*ast.Operand, pc int, offsets map[string]int, resolve func(string) string) ([]byte, error) {
if len(ops) != 1 {
+39
View File
@@ -572,3 +572,42 @@ func leWords(b []byte) []uint32 {
}
return w
}
// TestArm64IndirectBranch pins the indirect branch forms in a leaf function:
// JMP (Rn) lowers to BR Rn, matching the toolchain's spelling, and the raw
// BR/BLR mnemonics encode directly (a gasm superset the toolchain's front
// end does not accept). CALL (Rn) shares the BLR path and its non-leaf
// prologue parity is covered by the ground-truth kernel.
func TestArm64IndirectBranch(t *testing.T) {
src := `#include "textflag.h"
TEXT ·f(SB), NOSPLIT, $0-0
JMP (R0)
BR R5
BLR R6
RET
`
f, errs := parser.Parse("test_arm64.s", src)
if len(errs) > 0 {
t.Fatalf("parse: %v", errs)
}
img, err := AssembleFileARM64(f)
if err != nil {
t.Fatalf("AssembleFileARM64: %v", err)
}
want := []uint32{
0xd61f0000, // BR R0
0xd61f00a0, // BR R5
0xd63f00c0, // BLR R6
0xd65f03c0, // RET (BR LR)
}
got := leWords(img.Code)
if len(got) != len(want) {
t.Fatalf("word count = %d, want %d", len(got), len(want))
}
for i := range want {
if got[i] != want[i] {
t.Errorf("word %d = %08x, want %08x", i, got[i], want[i])
}
}
}
+66 -1
View File
@@ -337,7 +337,18 @@ func computeFrame(t *ast.Text) frameInfo {
if t.Frame != nil && t.Frame.Imm.HasVal {
fi.size = int(t.Frame.Imm.Val)
}
if fi.size > 0 {
if fi.size == 0 && hasCall(t) {
// The toolchain gives a frameless function containing a CALL an
// 8-byte frame for the pushed base pointer: the prologue saves BP
// with no stack adjustment, every RET pops it back, FP references
// pass one extra slot, and the virtual SP is the hardware SP.
fi.size = 8
fi.useFP = true
fi.fpAdjust = int64(fi.size) + 16 // return address + saved BP + args base
fi.spAdjust = 0
fi.prologue = []byte{0x55, 0x48, 0x89, 0xE5} // PUSHQ BP; MOVQ SP, BP
fi.epilogue = []byte{0x5D} // POPQ BP
} else if fi.size > 0 {
fi.useFP = true
fi.fpAdjust = int64(fi.size) + 16 // frame + saved BP + return address
fi.spAdjust = int64(fi.size)
@@ -518,6 +529,13 @@ func instrSize(s *ast.Instr, fi frameInfo, long bool, link *linkInfo) (int, erro
if (mnem == "CALL" || mnem == "JMP") && isSBCall(s) {
return 5, nil // opcode + rel32, always the long form
}
if (mnem == "CALL" || mnem == "JMP") && indirectJumpTarget(s) {
code, err := encodeIndirectJump(s, mnem)
if err != nil {
return 0, err
}
return len(code), nil
}
return jumpSize(mnem, long), nil
}
code, _, err := encodeInstr(s, 0, nil, fi, false, nil, link)
@@ -585,6 +603,15 @@ func encodeInstr(s *ast.Instr, pc int, offsets map[string]int, fi frameInfo, lon
}
return append(prefix, code...), ps, nil
}
if (mnem == "CALL" || mnem == "JMP") && indirectJumpTarget(s) {
// JMP/CALL through a register or memory: no relocation and no
// label to resolve, the operand fully determines the bytes.
code, err = encodeIndirectJump(s, mnem)
if err != nil {
return nil, nil, err
}
return append(prefix, code...), nil, nil
}
code, err = encodeJump(s, mnem, pc+len(prefix), offsets, long, resolve)
} else {
code, ps, err = encodeNormal(s, fi, link)
@@ -706,6 +733,44 @@ func labelName(op *ast.Operand) (string, bool) {
return "", false
}
// indirectJumpTarget reports whether the JMP/CALL operand addresses a
// register or a memory location rather than a label or a static symbol.
// A bare identifier is a register when the register table knows the name and
// a label otherwise, which is exactly how the parser cannot distinguish them.
func indirectJumpTarget(s *ast.Instr) bool {
if len(s.Operands) != 1 || s.Operands[0].Kind != ast.OpAddr {
return false
}
a := s.Operands[0].Addr
if a.Base != "" || a.Index != "" {
return true
}
if a.Sym != nil && a.Sym.Pseudo == "" && a.Sym.Name != "" {
if _, ok := ParseReg(a.Sym.Name); ok {
return true
}
}
return false
}
// encodeIndirectJump assembles a JMP/CALL through a register or memory
// operand, which carries no relocation and no label to resolve.
func encodeIndirectJump(s *ast.Instr, mnem string) ([]byte, error) {
ops := make([]Operand, len(s.Operands))
for i, op := range s.Operands {
o, err := operandFromAST(op, 8, frameInfo{}, nil)
if err != nil {
return nil, err
}
ops[i] = o
}
e := &enc{}
if err := e.encodeIndirectBranch(mnem, ops); err != nil {
return nil, err
}
return e.out, nil
}
// spReg is the hardware stack pointer used to realise FP/SP pseudo-operands.
var spReg = Reg{idx: 4, size: 8}
+14 -4
View File
@@ -40,10 +40,20 @@ func (e *enc) encode(mnem string, ops []Operand) error {
return e.encodeRet()
case upper == "NOP":
return e.emit(&instr{opcode: []byte{0x90}, modrm: -1, sib: -1})
case upper == "CALL":
return e.encodeJmpRel(ops, []byte{0xE8})
case upper == "JMP":
return e.encodeJmpRel(ops, []byte{0xE9})
case upper == "CALL" || upper == "JMP":
// Through a register or memory: FF /2 (CALL) or FF /4 (JMP).
// Anything else is a rel32 against a label resolved by the assembler.
if len(ops) == 1 {
switch ops[0].(type) {
case Reg, Mem:
return e.encodeIndirectBranch(upper, ops)
}
}
opcode := []byte{0xE8}
if upper == "JMP" {
opcode = []byte{0xE9}
}
return e.encodeJmpRel(ops, opcode)
}
if cc, ok := condCode(upper); ok {
return e.encodeJcc(cc, ops)
+33
View File
@@ -181,6 +181,39 @@ func TestControl(t *testing.T) {
checkOp(t, x86asm.JBE, "JLS", Imm(0))
}
// TestIndirectControlFlow pins the indirect JMP/CALL forms: FF /4 for JMP and
// FF /2 for CALL through a register or memory. A REX appears only for the
// extended registers, never REX.W: the branch operand size is fixed at 64
// bits in long mode.
func TestIndirectControlFlow(t *testing.T) {
cases := []struct {
name string
mnem string
ops []Operand
want string
}{
{"JMP AX", "JMP", []Operand{AX}, "ffe0"},
{"CALL AX", "CALL", []Operand{AX}, "ffd0"},
{"JMP (BX)", "JMP", []Operand{Ptr(BX, 0, 8)}, "ff23"},
{"CALL (BX)", "CALL", []Operand{Ptr(BX, 0, 8)}, "ff13"},
{"JMP 8(BX)", "JMP", []Operand{Ptr(BX, 8, 8)}, "ff6308"},
{"CALL -16(BX)", "CALL", []Operand{Ptr(BX, -16, 8)}, "ff53f0"},
{"JMP R8", "JMP", []Operand{Reg{idx: 8, size: 2}}, "41ffe0"},
{"CALL R9", "CALL", []Operand{Reg{idx: 9, size: 2}}, "41ffd1"},
{"JMP R15", "JMP", []Operand{Reg{idx: 15, size: 2}}, "41ffe7"},
}
for _, c := range cases {
code, err := Encode(c.mnem, c.ops...)
if err != nil {
t.Errorf("%s: %v", c.name, err)
continue
}
if got := fmt.Sprintf("%x", code); got != c.want {
t.Errorf("%s: got %s, want %s", c.name, got, c.want)
}
}
}
// TestSSEMoveGroundTruth checks the legacy (non-VEX) SSE moves byte for byte
// against the Go assembler. wantOp is the decoder's name, which differs from
// the Plan 9 spelling for the octa moves (MOVOU = MOVDQU, MOVO = MOVDQA).
+18
View File
@@ -575,6 +575,24 @@ func (e *enc) encodeJmpRel(ops []Operand, opcode []byte) error {
return e.emit(&instr{opcode: opcode, modrm: -1, sib: -1, imm: le32(int64(imm))})
}
// encodeIndirectBranch encodes JMP/CALL through a register or memory operand:
// FF /4 for JMP, FF /2 for CALL. The operand size is fixed at 64 bits in
// 64-bit mode, so no REX.W is emitted; a REX appears only for R8-R15 bases.
func (e *enc) encodeIndirectBranch(mnem string, ops []Operand) error {
if len(ops) != 1 {
return fmt.Errorf("%s expects 1 operand, got %d", mnem, len(ops))
}
digit := 4 // JMP r/m64
if mnem == "CALL" {
digit = 2 // CALL r/m64
}
i := &instr{opcode: []byte{0xFF}, modrm: -1, sib: -1}
if err := setRMDigit(i, digit, ops[0], 8); err != nil {
return err
}
return e.emit(i)
}
// condCode maps a Plan 9 conditional-jump mnemonic to its x86 condition code.
func condCode(upper string) (int, bool) {
if len(upper) < 2 || upper[0] != 'J' || upper == "JMP" {
+77 -70
View File
@@ -440,83 +440,90 @@ type dataSym struct {
}
// collectData gathers the file's static symbols (GLOBL) and their initial
// contents (DATA) into byte buffers, in declaration order.
// contents (DATA) into byte buffers. Two passes: the Plan 9 convention puts
// every DATA line before its symbol's GLOBL, so the symbols are registered
// before the initialisers are applied.
func collectData(f *ast.File) ([]dataSym, error) {
index := map[string]int{}
var syms []dataSym
for _, d := range f.Decls {
switch dd := d.(type) {
case *ast.Globl:
if dd.Name == nil || dd.Name.Pseudo != "SB" {
continue
}
name := dd.Name.Name
if _, dup := index[name]; dup {
return nil, fmt.Errorf("duplicate GLOBL %q", name)
}
size := 0
if dd.Size != nil && dd.Size.Imm.HasVal {
size = int(dd.Size.Imm.Val)
}
index[name] = len(syms)
ds := dataSym{
name: name,
pkg: dd.Name.Pkg,
buf: make([]byte, size),
size: size,
static: dd.Name.Static,
}
for _, f := range dd.Flags {
switch f {
case "RODATA":
ds.rodata = true
case "DUPOK":
ds.dupok = true
default:
// Legacy numeric flag constants (runtime/textflag.h):
// DUPOK is 2, RODATA is 8; combinations arrive as one
// number (e.g. 10 = RODATA|DUPOK).
if n, err := strconv.Atoi(f); err == nil {
if n&2 != 0 {
ds.dupok = true
}
if n&8 != 0 {
ds.rodata = true
}
gd, ok := d.(*ast.Globl)
if !ok {
continue
}
if gd.Name == nil || gd.Name.Pseudo != "SB" {
continue
}
name := gd.Name.Name
if _, dup := index[name]; dup {
return nil, fmt.Errorf("duplicate GLOBL %q", name)
}
size := 0
if gd.Size != nil && gd.Size.Imm.HasVal {
size = int(gd.Size.Imm.Val)
}
index[name] = len(syms)
ds := dataSym{
name: name,
pkg: gd.Name.Pkg,
buf: make([]byte, size),
size: size,
static: gd.Name.Static,
}
for _, f := range gd.Flags {
switch f {
case "RODATA":
ds.rodata = true
case "DUPOK":
ds.dupok = true
default:
// Legacy numeric flag constants (runtime/textflag.h):
// DUPOK is 2, RODATA is 8; combinations arrive as one
// number (e.g. 10 = RODATA|DUPOK).
if n, err := strconv.Atoi(f); err == nil {
if n&2 != 0 {
ds.dupok = true
}
if n&8 != 0 {
ds.rodata = true
}
}
}
syms = append(syms, ds)
case *ast.Data:
if dd.Name == nil || dd.Name.Pseudo != "SB" {
continue
}
i, ok := index[dd.Name.Name]
if !ok {
return nil, fmt.Errorf("DATA %q: no matching GLOBL", dd.Name.Name)
}
if dd.Value == nil || !dd.Value.Imm.HasVal {
return nil, fmt.Errorf("DATA %q: value must be an integer immediate", dd.Name.Name)
}
w := dd.Width
switch w {
case 1, 2, 4, 8:
default:
return nil, fmt.Errorf("DATA %q: invalid width %d (want 1, 2, 4 or 8)", dd.Name.Name, w)
}
off := dd.Name.Offset
buf := syms[i].buf
if off < 0 || off+int64(w) > int64(len(buf)) {
return nil, fmt.Errorf("DATA %q+%d/%d exceeds GLOBL size %d", dd.Name.Name, off, w, len(buf))
}
v := dd.Value.Imm.Val
if dd.Value.Imm.Neg {
v = -v
}
for j := range w {
buf[off+int64(j)] = byte(v >> (8 * j))
}
}
syms = append(syms, ds)
}
for _, d := range f.Decls {
dd, ok := d.(*ast.Data)
if !ok {
continue
}
if dd.Name == nil || dd.Name.Pseudo != "SB" {
continue
}
i, ok := index[dd.Name.Name]
if !ok {
return nil, fmt.Errorf("DATA %q: no matching GLOBL", dd.Name.Name)
}
if dd.Value == nil || !dd.Value.Imm.HasVal {
return nil, fmt.Errorf("DATA %q: value must be an integer immediate", dd.Name.Name)
}
w := dd.Width
switch w {
case 1, 2, 4, 8:
default:
return nil, fmt.Errorf("DATA %q: invalid width %d (want 1, 2, 4 or 8)", dd.Name.Name, w)
}
off := dd.Name.Offset
buf := syms[i].buf
if off < 0 || off+int64(w) > int64(len(buf)) {
return nil, fmt.Errorf("DATA %q+%d/%d exceeds GLOBL size %d", dd.Name.Name, off, w, len(buf))
}
v := dd.Value.Imm.Val
if dd.Value.Imm.Neg {
v = -v
}
for j := range w {
buf[off+int64(j)] = byte(v >> (8 * j))
}
}
return syms, nil
+44
View File
@@ -6,6 +6,7 @@ package asm
import (
"fmt"
"math/bits"
"strconv"
"strings"
"sourcedock.dev/petrbalvin/gasm-devkit/ast"
@@ -258,6 +259,9 @@ func encodeLOONG64Instr(instr *ast.Instr, pc int, offsets map[string]int, fi loo
// 16-bit branches (BEQ/BNE/BLT/BGE/BLTU/BGEU) and JIRL.
if op, ok := l64branchTable[mnem]; ok {
if mnem == "JIRL" {
return encodeLOONG64Jirl(op, ops)
}
return encodeLOONG64Branch16(mnem, op, ops, pc, offsets, resolve)
}
// Single-register branches with 21-bit offsets (BLTZ/BGEZ/BLEZ/BGTZ,
@@ -554,6 +558,46 @@ func encodeLOONG64Branch(instr *ast.Instr, mnem string, pc int, offsets map[stri
return l64wordLE(l64bbl(opc, v)), nil
}
// encodeLOONG64Jirl encodes the raw JIRL spelling, JIRL rd, rj, offset, the
// form the verify trampolines use. The (rj) indirect form without an offset
// is handled by encodeLOONG64Branch.
func encodeLOONG64Jirl(op uint32, ops []*ast.Operand) ([]byte, error) {
if len(ops) != 3 {
return nil, fmt.Errorf("JIRL expects 3 operands, got %d", len(ops))
}
rd := l64Reg(ops[0])
rj := l64Reg(ops[1])
if rd < 0 || rj < 0 {
return nil, fmt.Errorf("invalid register operand")
}
off, ok := l64offsetOperand(ops[2])
if !ok {
return nil, fmt.Errorf("JIRL expects an immediate offset, got %q", ops[2].Raw)
}
if (int64(off)<<16)>>16 != int64(off) {
return nil, fmt.Errorf("JIRL offset %d out of the 16-bit range", off)
}
return l64wordLE(l64irr16(op, int(off), rj, rd)), nil
}
// l64offsetOperand reads a bare numeric branch offset: an immediate ($n) or a
// plain number, which parses as an empty address carrying the digits in Raw.
func l64offsetOperand(op *ast.Operand) (int32, bool) {
if op.Imm.HasVal {
v := op.Imm.Val
if op.Imm.Neg {
v = -v
}
return int32(v), true
}
if op.Kind == ast.OpAddr && op.Addr.Sym == nil && op.Addr.Base == "" && op.Addr.Index == "" {
if v, err := strconv.ParseInt(op.Raw, 0, 64); err == nil {
return int32(v), true
}
}
return 0, false
}
// encodeLOONG64Branch16 encodes a 16-bit branch (BEQ/BNE/BLT/BGE/BLTU/BGEU):
// INSTR rj, rd, label, or INSTR rj, label with rd = R0, which the toolchain
// turns into the 21-bit BEQZ/BNEZ form when the register is the only operand.
+37
View File
@@ -291,3 +291,40 @@ done:
t.Errorf("code = % x\nwant % x", code, want)
}
}
// TestLOONG64IndirectBranch pins the indirect branch encodings: JMP (Rj) and
// JAL (Rj) lower to jirl, and the raw JIRL spelling encodes the written
// offset (the Go loong64 assembler deletes raw JIRL instructions entirely,
// so this form is a gasm-only superset with faithful semantics).
func TestLOONG64IndirectBranch(t *testing.T) {
fn := firstTextLOONG64(t, `#include "textflag.h"
TEXT ·f(SB), NOSPLIT, $0-0
JMP (R4)
JIRL R0, R4, 8
RET
`)
code := assembleLOONG64Helper(t, fn)
wantWords(t, code,
0x4C000080, // jirl r0, r4, 0
0x4C002080, // jirl r0, r4, 8
0x4C000020, // jirl r0, r1, 0 (RET)
)
// JAL (R5) links, so the toolchain gives the function its autosize-8
// prologue and epilogue around the call and the closing RET.
fn = firstTextLOONG64(t, `#include "textflag.h"
TEXT ·f(SB), NOSPLIT, $0-0
JAL (R5)
RET
`)
code = assembleLOONG64Helper(t, fn)
wantWords(t, code,
0x29FFE061, // addi.d r1, r2, -8 (prologue)
0x02FFE063, // addi.d r3, r3, -8
0x29C00061, // st.d r1, r2, 0 (prologue saves RA)
0x4C0000A1, // jirl r1, r5, 0
0x28C00061, // ld.d r1, r2, 0 (epilogue restores RA)
0x02C02063, // addi.d r3, r3, 8
0x4C000020, // jirl r0, r1, 0 (RET)
)
}
+181 -12
View File
@@ -135,6 +135,43 @@ func assembleRISCV(t *ast.Text) ([]byte, map[string]int, []Reloc, []LineEntry, [
return out, offsets, relocs, lines, spadj, nil
}
// riscvImmAlias maps the R-type ALU mnemonics onto their I-type immediate
// forms: the toolchain accepts ADD $imm, rj, rd and emits addi. Applied
// whenever the first operand is an immediate.
var riscvImmAlias = map[string]string{
"ADD": "ADDI",
"ADDW": "ADDIW",
"AND": "ANDI",
"OR": "ORI",
"XOR": "XORI",
"SLL": "SLLI",
"SRL": "SRLI",
"SRA": "SRAI",
"SLLW": "SLLIW",
"SRLW": "SRLIW",
"SRAW": "SRAIW",
}
// riscvNormaliseImmAlias rewrites the mnemonic to its immediate form when the
// first operand is an immediate: the toolchain accepts ADD $imm, rj, rd and
// emits addi, and SUB $imm becomes addi with the negated immediate. The
// second result reports that negation; the operand itself is left untouched
// because several passes normalise the same instruction.
func riscvNormaliseImmAlias(mnem string, ops []*ast.Operand) (string, bool) {
if len(ops) >= 2 && isImmOperand(ops[0]) {
switch strings.ToUpper(mnem) {
case "SUB":
return "ADDI", true
case "SUBW":
return "ADDIW", true
}
if alias, ok := riscvImmAlias[strings.ToUpper(mnem)]; ok {
return alias, false
}
}
return mnem, false
}
// riscvInstrSize returns the encoded size in bytes of a RISC-V instruction.
// Most instructions are 4 bytes; MOV with a large immediate and I-type
// arithmetic with a large immediate expand to several (possibly compressed)
@@ -142,10 +179,12 @@ func assembleRISCV(t *ast.Text) ([]byte, map[string]int, []Reloc, []LineEntry, [
func riscvInstrSize(instr *ast.Instr, fi riscvFrameInfo) int {
mnem := instr.Mnemonic.Text
ops := instr.Operands
var immNeg bool
mnem, immNeg = riscvNormaliseImmAlias(mnem, ops)
if mnem == "RET" {
return len(riscvReturn(fi))
}
if mnem == "MOV" && len(ops) == 2 {
if strings.HasPrefix(mnem, "MOV") && len(ops) == 2 {
// MOV $sym(SB), rd → 8 bytes (AUIPC + ADDI).
if isImmOperand(ops[0]) && ops[0].Imm.Sym != nil && ops[0].Imm.Sym.Pseudo == "SB" {
return 8
@@ -173,7 +212,11 @@ func riscvInstrSize(instr *ast.Instr, fi riscvFrameInfo) int {
}
// I-type arithmetic with a large immediate expands to several instructions.
if (mnem == "ADDI" || mnem == "ANDI" || mnem == "ORI" || mnem == "XORI") && len(ops) >= 1 && isImmOperand(ops[0]) {
return riscvItypeImmediateSize(mnem, immFromOperand(ops[0]))
imm := immFromOperand(ops[0])
if immNeg {
imm = -imm
}
return riscvItypeImmediateSize(mnem, imm)
}
return 4
}
@@ -192,6 +235,8 @@ func isBranchLike(mnem string) bool {
func encodeRISCVInstr(instr *ast.Instr, pc int, offsets map[string]int, fi riscvFrameInfo, relocs *[]Reloc) ([]byte, error) {
mnem := instr.Mnemonic.Text
ops := instr.Operands
var immNeg bool
mnem, immNeg = riscvNormaliseImmAlias(mnem, ops)
var word uint32
// Handle pseudo-instructions and special cases first.
@@ -208,6 +253,18 @@ func encodeRISCVInstr(instr *ast.Instr, pc int, offsets map[string]int, fi riscv
}
op := ops[0]
if op.Addr.Sym == nil || op.Addr.Sym.Pseudo != "SB" {
// CALL (X5): an indirect call, the toolchain's JALR X1, 0(X5).
if op.Addr.Sym == nil && op.Addr.Base != "" {
if op.Addr.Offset != 0 || op.Addr.Index != "" {
return nil, fmt.Errorf("CALL: invalid indirect operand %q", op.Raw)
}
rs1 := riscvRegNum(op.Addr.Base)
if rs1 < 0 {
return nil, fmt.Errorf("CALL: unknown branch register %q", op.Addr.Base)
}
word = riscvIType(riscvEnc{0x67, 0x0, 0x00}, 1, rs1, 0)
return []byte{byte(word), byte(word >> 8), byte(word >> 16), byte(word >> 24)}, nil
}
return nil, fmt.Errorf("CALL: local branch target is not supported (use CALL sym(SB))")
}
if relocs != nil {
@@ -229,6 +286,18 @@ func encodeRISCVInstr(instr *ast.Instr, pc int, offsets map[string]int, fi riscv
return []byte{byte(word), byte(word >> 8), byte(word >> 16), byte(word >> 24)}, nil
}
target = labelFromOperand(ops[0])
// JMP (X5): an indirect branch, the toolchain's JALR X0, 0(X5).
if ops[0].Addr.Sym == nil && ops[0].Addr.Base != "" {
if ops[0].Addr.Offset != 0 || ops[0].Addr.Index != "" {
return nil, fmt.Errorf("JMP: invalid indirect operand %q", ops[0].Raw)
}
rs1 := riscvRegNum(ops[0].Addr.Base)
if rs1 < 0 {
return nil, fmt.Errorf("JMP: unknown branch register %q", ops[0].Addr.Base)
}
word = riscvIType(riscvEnc{0x67, 0x0, 0x00}, 0, rs1, 0)
return []byte{byte(word), byte(word >> 8), byte(word >> 16), byte(word >> 24)}, nil
}
}
targetOff, ok := offsets[target]
if !ok {
@@ -255,14 +324,50 @@ func encodeRISCVInstr(instr *ast.Instr, pc int, offsets map[string]int, fi riscv
return []byte{byte(word), byte(word >> 8), byte(word >> 16), byte(word >> 24)}, nil
// MOV is a pseudo-instruction that the Go assembler uses for loads,
// stores, register moves and immediate loads.
case "MOV":
// stores, register moves and immediate loads. The width suffixes
// (MOVB/MOVH/MOVW and unsigned forms) select the access width, and
// MOVD/MOVF address the FP registers.
case "MOV", "MOVB", "MOVBU", "MOVH", "MOVHU", "MOVW", "MOVWU", "MOVF", "MOVD":
return encodeRISCVMov(instr, fi, relocs)
// JALR: indirect jump/call. Plan 9: JALR rs1, rd or JALR offset(rs1).
case "JALR":
return encodeRISCVJALR(instr, fi)
// Branch-zero pseudos: BEQZ/BNEZ compare against X0, and BLTZ/BGEZ/
// BLEZ/BGTZ reorder the register operands of BLT/BGE accordingly.
case "BEQZ", "BNEZ", "BLTZ", "BGEZ", "BLEZ", "BGTZ":
if len(ops) != 2 {
return nil, fmt.Errorf("%s expects 2 operands, got %d", mnem, len(ops))
}
rs := regFromOperand(ops[0])
if rs < 0 {
return nil, fmt.Errorf("%s: invalid register", mnem)
}
target := labelFromOperand(ops[1])
targetOff, ok := offsets[target]
if !ok {
return nil, fmt.Errorf("undefined label %q%s", target, suggestLabel(target, offsets))
}
var enc riscvEnc
rs1, rs2 := rs, 0
switch mnem {
case "BEQZ":
enc = riscvEnc{0x63, 0x0, 0x00} // beq rs, x0
case "BNEZ":
enc = riscvEnc{0x63, 0x1, 0x00} // bne rs, x0
case "BLTZ":
enc = riscvEnc{0x63, 0x4, 0x00} // blt rs, x0
case "BGEZ":
enc = riscvEnc{0x63, 0x5, 0x00} // bge rs, x0
case "BLEZ":
enc, rs1, rs2 = riscvEnc{0x63, 0x5, 0x00}, 0, rs // bge x0, rs
case "BGTZ":
enc, rs1, rs2 = riscvEnc{0x63, 0x4, 0x00}, 0, rs // blt x0, rs
}
word = riscvBType(enc, rs1, rs2, int32(targetOff-pc))
return []byte{byte(word), byte(word >> 8), byte(word >> 16), byte(word >> 24)}, nil
// System instructions with no operands.
case "FENCE", "ECALL", "EBREAK":
enc, ok := riscvInstrTable[mnem]
@@ -457,6 +562,9 @@ func encodeRISCVInstr(instr *ast.Instr, pc int, offsets map[string]int, fi riscv
// two-operand form INSTR $imm, rd uses rd as the source.
case len(ops) == 3 && isITypeInstr(mnem):
imm := immFromOperand(ops[0]) // immediate
if immNeg {
imm = -imm // SUB $imm arrived through the ADDI alias
}
rs1 := regFromOperand(ops[1]) // source register
rd := regFromOperand(ops[2]) // destination
if rd < 0 || rs1 < 0 {
@@ -466,6 +574,9 @@ func encodeRISCVInstr(instr *ast.Instr, pc int, offsets map[string]int, fi riscv
case len(ops) == 2 && isITypeInstr(mnem):
imm := immFromOperand(ops[0])
if immNeg {
imm = -imm
}
rd := regFromOperand(ops[1])
if rd < 0 {
return nil, fmt.Errorf("invalid register in %s", mnem)
@@ -602,7 +713,7 @@ func encodeRISCVMov(instr *ast.Instr, fi riscvFrameInfo, relocs *[]Reloc) ([]byt
if rd < 0 || rs1 < 0 {
return nil, fmt.Errorf("MOV load: invalid operand")
}
return riscvFrameMemOp(riscvEnc{0x03, 0x3, 0x00}, false, rd, rs1, off), nil
return riscvFrameMemOp(riscvMovEnc(strings.ToUpper(instr.Mnemonic.Text), false), false, rd, rs1, off), nil
}
// Register → memory (store).
@@ -619,21 +730,70 @@ func encodeRISCVMov(instr *ast.Instr, fi riscvFrameInfo, relocs *[]Reloc) ([]byt
if rs2 < 0 || rs1 < 0 {
return nil, fmt.Errorf("MOV store: invalid operand")
}
return riscvFrameMemOp(riscvEnc{0x23, 0x3, 0x00}, true, rs2, rs1, off), nil
return riscvFrameMemOp(riscvMovEnc(strings.ToUpper(instr.Mnemonic.Text), true), true, rs2, rs1, off), nil
}
// Register → register (ADDI $0, src, dst).
// Register → register: MOVD/MOVF are FP moves (fsgnj with rs2 = rs1),
// everything else is ADDI $0, src, dst.
{
rs1 := regFromOperand(src)
rd := regFromOperand(dst)
if rd < 0 || rs1 < 0 {
return nil, fmt.Errorf("MOV: invalid register operand")
}
mnem := strings.ToUpper(instr.Mnemonic.Text)
if mnem == "MOVD" || mnem == "MOVF" {
op := uint32(0x20000053) // FSGNJ.S
if mnem == "MOVD" {
op = 0x22000053 // FSGNJ.D
}
return wordLE(op | uint32(rs1)<<15 | uint32(rs1)<<20 | uint32(rd)<<7), nil
}
word := riscvIType(riscvEnc{0x13, 0x0, 0x00}, rd, rs1, 0)
return []byte{byte(word), byte(word >> 8), byte(word >> 16), byte(word >> 24)}, nil
}
}
// riscvMovEnc returns the load (store=false) or store (store=true) opcode for
// a MOV-family mnemonic: the suffix selects the access width, MOVD and MOVF
// select the FP load/store opcodes, and bare MOV is the 64-bit integer form.
func riscvMovEnc(mnem string, store bool) riscvEnc {
if store {
switch mnem {
case "MOVB":
return riscvEnc{0x23, 0x0, 0x00} // SB
case "MOVH":
return riscvEnc{0x23, 0x1, 0x00} // SH
case "MOVW":
return riscvEnc{0x23, 0x2, 0x00} // SW
case "MOVF":
return riscvEnc{0x27, 0x2, 0x00} // FSW
case "MOVD":
return riscvEnc{0x27, 0x3, 0x00} // FSD
}
return riscvEnc{0x23, 0x3, 0x00} // SD
}
switch mnem {
case "MOVB":
return riscvEnc{0x03, 0x0, 0x00} // LB
case "MOVBU":
return riscvEnc{0x03, 0x4, 0x00} // LBU
case "MOVH":
return riscvEnc{0x03, 0x1, 0x00} // LH
case "MOVHU":
return riscvEnc{0x03, 0x5, 0x00} // LHU
case "MOVW":
return riscvEnc{0x03, 0x2, 0x00} // LW
case "MOVWU":
return riscvEnc{0x03, 0x6, 0x00} // LWU
case "MOVF":
return riscvEnc{0x07, 0x2, 0x00} // FLW
case "MOVD":
return riscvEnc{0x07, 0x3, 0x00} // FLD
}
return riscvEnc{0x03, 0x3, 0x00} // LD
}
// riscvFrameMemOp encodes a register-relative load (store=false, I-type
// width 0x03) or store (store=true, S-type width 0x23) of the 64-bit width
// at off(rs1). Offsets beyond the signed 12-bit range materialise the
@@ -886,25 +1046,34 @@ func word16(w uint16) []byte {
}
// encodeRISCVJALR encodes the JALR indirect jump/call instruction.
// Plan 9: JALR rs1, rd (2 regs) or JALR offset(rs1) (memory → rd=X1).
// Plan 9: JALR rs1, rd (2 regs), JALR rd, offset(rs1) (the trampoline
// form), or JALR offset(rs1) (memory → rd=X1).
func encodeRISCVJALR(instr *ast.Instr, fi riscvFrameInfo) ([]byte, error) {
ops := instr.Operands
// JALR rd, offset(rs1): the memory operand's base is the jump-target
// register, not the destination.
if len(ops) == 2 && isMemOperand(ops[1]) {
rd := regFromOperand(ops[0])
rs1, imm := memFromOperandWithFrame(ops[1], fi)
if rd < 0 || rs1 < 0 {
return nil, fmt.Errorf("JALR: invalid register operand")
}
return wordLE(riscvIType(riscvEnc{0x67, 0x0, 0x00}, rd, rs1, imm)), nil
}
if len(ops) == 2 {
rs1 := regFromOperand(ops[0])
rd := regFromOperand(ops[1])
if rd < 0 || rs1 < 0 {
return nil, fmt.Errorf("JALR: invalid register operand")
}
word := riscvIType(riscvEnc{0x67, 0x0, 0x00}, rd, rs1, 0)
return wordLE(word), nil
return wordLE(riscvIType(riscvEnc{0x67, 0x0, 0x00}, rd, rs1, 0)), nil
}
if len(ops) == 1 {
rs1, imm := memFromOperandWithFrame(ops[0], fi)
if rs1 < 0 {
return nil, fmt.Errorf("JALR: invalid memory operand")
}
word := riscvIType(riscvEnc{0x67, 0x0, 0x00}, 1, rs1, imm)
return wordLE(word), nil
return wordLE(riscvIType(riscvEnc{0x67, 0x0, 0x00}, 1, rs1, imm)), nil
}
return nil, fmt.Errorf("JALR expects 1 or 2 operands, got %d", len(ops))
}
+2
View File
@@ -328,6 +328,8 @@ var riscvCvtTable = map[string]riscvCvtEnc{
"FCVTSWU": {0x68, 0x1, 0x53}, // uint32 → float32
"FCVTSL": {0x68, 0x2, 0x53}, // int64 → float32
"FCVTSLU": {0x68, 0x3, 0x53}, // uint64 → float32
"FCLASSS": {0x70, 0x0, 0x53}, // classify float32 → GPR mask
"FCLASSD": {0x70, 0x0, 0x53}, // classify float64 → GPR mask
"FCVTDW": {0x69, 0x0, 0x53}, // int32 → float64
"FCVTDWU": {0x69, 0x1, 0x53}, // uint32 → float64
"FCVTDL": {0x69, 0x2, 0x53}, // int64 → float64
+21
View File
@@ -761,3 +761,24 @@ sub:
t.Error("expected error for CALL to local label, got nil")
}
}
// TestRISCVIndirectBranch pins the indirect branch encodings: JMP (X5) is the
// toolchain's JALR X0, 0(X5), and the trampoline form JALR rd, offset(rs1)
// takes its destination from the first operand (regression: the base
// register was once read as the destination, silently jumping to X0).
func TestRISCVIndirectBranch(t *testing.T) {
fn := firstTextRISCV(t, `#include "textflag.h"
TEXT ·f(SB), NOSPLIT, $0-0
JMP (X5)
JALR X0, 0(X6)
JALR X28, 0(X9)
RET
`)
code := assembleRISCVHelper(t, fn)
wantWords(t, code,
0x00028067, // jalr x0, 5(x0), 0
0x00030067, // jalr x0, 6(x0), 0
0x00048e67, // jalr x28, 9(x0), 0
0x00008067, // jalr x0, 1(x0), 0 (RET)
)
}
+10 -3
View File
@@ -93,12 +93,19 @@ func riscvIsLeaf(t *ast.Text) bool {
return false
}
case "JALR":
// JALR rs1, rd, a call when rd is X1; JALR offset(rs1) always
// links to X1.
// JALR rd, offset(rs1) links when the destination register (the
// first operand) is X1; JALR rs1, rd links when the second
// register is X1; JALR offset(rs1) always links to X1.
if len(in.Operands) == 1 {
return false
}
if len(in.Operands) >= 2 && regFromOperand(in.Operands[1]) == 1 {
if isMemOperand(in.Operands[1]) {
if regFromOperand(in.Operands[0]) == 1 {
return false
}
continue
}
if regFromOperand(in.Operands[1]) == 1 {
return false
}
}
+206 -1
View File
@@ -37,17 +37,29 @@ import (
// construction and are excluded from the diff; the other architectures list
// their conditional branches outright.
func cmdAuditInstructions(args []string) error {
fs := newCommand("audit-instructions", "gasm audit-instructions [amd64|arm64|riscv64|loong64]", `
fs := newCommand("audit-instructions", "gasm audit-instructions [--corpus [dir]] [amd64|arm64|riscv64|loong64]", `
Compare the gasm encoder for the given architecture (default amd64) against
go tool asm and print the diff: superset encodings (gasm-only, shippable via
gasm asm --format goobj), known-but-unencodable names (the backlog) and go-
only names (feature gaps). The Go side is probed black-box with a battery
of bare mnemonics, so the audit tracks whatever toolchain `+"`go env GOROOT`"+`
provides.
With --corpus the audit changes shape: it assembles every .s file under the
given directory (default GOROOT/src) with the gasm encoder only, no
toolchain probing. A file whose name carries a recognisable _arch suffix is
attempted for that architecture; a file without one is attempted for all
four, exactly as a GOARCH build would compile it. The report gives the
per-architecture pass rates and the most common failure reasons, which drive
the encodability backlog by frequency rather than by table order.
`)
corpus := fs.Bool("corpus", false, "assemble a corpus of .s files and report pass rates and failure reasons")
if err := fs.Parse(args); err != nil {
return err
}
if *corpus {
return cmdAuditCorpus(fs.Args())
}
archName := "amd64"
switch n := len(fs.Args()); {
case n > 1:
@@ -305,3 +317,196 @@ func gasmAssembles(a arch.Arch, name, shape string) bool {
func sanitize(name string) string {
return strings.NewReplacer(".", "_", "$", "_").Replace(name)
}
// --- corpus audit -----------------------------------------------------------
// corpusTarget is one architecture row of the corpus report.
type corpusTarget struct {
a arch.Arch
name string
}
// corpusTally accumulates one architecture's attempts over the corpus.
type corpusTally struct {
attempted int
assembled int
reasons map[string]int // failure reason → count
example map[string]string // failure reason → one representative file
}
func (t *corpusTally) fail(path, reason string) {
t.reasons[reason]++
if t.example[reason] == "" {
t.example[reason] = path
}
}
// cmdAuditCorpus implements audit-instructions --corpus.
func cmdAuditCorpus(args []string) error {
if len(args) > 1 {
return fmt.Errorf("audit-instructions --corpus takes at most one directory argument")
}
root := ""
if len(args) == 1 {
root = args[0]
} else {
out, err := exec.Command("go", "env", "GOROOT").Output()
if err != nil {
return fmt.Errorf("locate GOROOT: %w", err)
}
root = filepath.Join(strings.TrimSpace(string(out)), "src")
}
stats, err := runCorpusAudit(root)
if err != nil {
return err
}
printCorpusStats(stats)
return nil
}
// corpusStats is the outcome of one corpus audit run.
type corpusStats struct {
root string
files int
generic int // files attempted for all four architectures
full int // files that assembled for every target architecture
targets []corpusTarget
tallies []*corpusTally
}
// runCorpusAudit assembles every .s file under root and returns the stats.
func runCorpusAudit(root string) (*corpusStats, error) {
files, err := asmFiles(root)
if err != nil {
return nil, err
}
targets := []corpusTarget{
{arch.AMD64, "amd64"},
{arch.ARM64, "arm64"},
{arch.RISCV, "riscv64"},
{arch.LOONG64, "loong64"},
}
tallies := make([]*corpusTally, len(targets))
for i := range tallies {
tallies[i] = &corpusTally{reasons: map[string]int{}, example: map[string]string{}}
}
// full is the north-star number: a file counts when every architecture
// its name allows assembles it.
full, generic := 0, 0
for _, path := range files {
src, err := readSource(path)
if err != nil {
return nil, err
}
f, errs := parser.Parse(path, src)
var wanted []int // indexes into targets
if a := arch.FromFilename(path); a != arch.Unknown {
for i, tg := range targets {
if tg.a == a {
wanted = append(wanted, i)
}
}
} else {
generic++
for i := range targets {
wanted = append(wanted, i)
}
}
ok := true
for _, i := range wanted {
tg, t := targets[i], tallies[i]
t.attempted++
var err error
if len(errs) > 0 {
err = errs[0] // a parse failure is a failure for every target
} else {
_, err = assembleFile(tg.a, f)
}
if err != nil {
ok = false
t.fail(path, corpusReason(err))
continue
}
t.assembled++
}
if ok && len(wanted) > 0 {
full++
}
}
return &corpusStats{
root: root,
files: len(files),
generic: generic,
full: full,
targets: targets,
tallies: tallies,
}, nil
}
// printCorpusStats renders the corpus audit report.
func printCorpusStats(s *corpusStats) {
fmt.Printf("corpus %s: %d files (%d generic, attempted for all architectures)\n", s.root, s.files, s.generic)
fmt.Printf(" assemble for every target architecture: %d (%.1f%%)\n", s.full, 100*float64(s.full)/float64(max(s.files, 1)))
for i, tg := range s.targets {
t := s.tallies[i]
fmt.Printf(" %s: %d/%d attempted\n", tg.name, t.assembled, t.attempted)
for _, r := range topReasons(t) {
fmt.Printf(" %4d %s\n", t.reasons[r], r)
fmt.Printf(" e.g. %s\n", t.example[r])
}
}
}
// corpusReason buckets an assembly or parse failure for the histogram.
func corpusReason(err error) string {
msg := err.Error()
switch {
case strings.Contains(msg, "unsupported"), strings.Contains(msg, "cannot encode"):
return "instruction not encodable"
case strings.Contains(msg, "undefined label"):
return "undefined label"
case strings.Contains(msg, "undefined symbol"), strings.Contains(msg, "external symbol"), strings.Contains(msg, "file-level assembly"):
return "undefined symbol or external"
case strings.Contains(msg, "operand"), strings.Contains(msg, "operand form"):
return "unsupported operand form"
default:
return "other: " + firstLine(msg)
}
}
// topReasons returns at most five reasons, most frequent first.
func topReasons(t *corpusTally) []string {
type kv struct {
k string
n int
}
var kvs []kv
for k, n := range t.reasons {
kvs = append(kvs, kv{k, n})
}
slices.SortFunc(kvs, func(a, b kv) int { return b.n - a.n })
if len(kvs) > 5 {
kvs = kvs[:5]
}
out := make([]string, len(kvs))
for i, kv := range kvs {
out[i] = kv.k
}
return out
}
// firstLine returns the first line of an error message, truncated.
func firstLine(msg string) string {
if i := strings.IndexByte(msg, '\n'); i >= 0 {
msg = msg[:i]
}
if len(msg) > 80 {
msg = msg[:80]
}
return msg
}
+37 -11
View File
@@ -459,7 +459,7 @@ hover, document symbols, diagnostics and semantic-token highlighting.
}
func cmdAsm(args []string) int {
fs := newCommand("asm", "gasm asm [--format raw|elf|goobj] [-p pkg] [-o out] <file>", `
fs := newCommand("asm", "gasm asm [--format raw|elf|goobj] [-p pkg] [-GOARCH arch] [-o out] <file>", `
Assemble FILE without the Go toolchain: every TEXT function is encoded to
machine code and printed as a hex dump. Supported architectures: amd64
(including VEX/AVX2 and EVEX/AVX-512), arm64 (AArch64 integer, FP,
@@ -477,13 +477,22 @@ requires -p, the package path, and the installed Go toolchain).
out := fs.String("o", "", "write the output to this file")
format := fs.String("format", "raw", "output format: raw (concatenated image), elf or goobj (Go object)")
pkg := fs.String("p", "", "package path for --format goobj (qualifies the exported symbols)")
archName := fs.String("GOARCH", "", "target architecture: amd64, arm64, riscv64 or loong64 (overrides the file-name suffix)")
fs.Parse(args)
if fs.NArg() != 1 {
fmt.Fprintln(os.Stderr, "usage: gasm asm [--format raw|elf|goobj] [-p pkg] [-o out] <file>")
fmt.Fprintln(os.Stderr, "usage: gasm asm [--format raw|elf|goobj] [-p pkg] [-GOARCH arch] [-o out] <file>")
return 2
}
path := fs.Arg(0)
targetArch := arch.FromFilename(path)
if *archName != "" {
a, err := auditArch(*archName)
if err != nil {
fmt.Fprintf(os.Stderr, "gasm asm: %v\n", err)
return 2
}
targetArch = a
}
src, err := readSource(path)
if err != nil {
fmt.Fprintln(os.Stderr, "gasm:", err)
@@ -502,8 +511,10 @@ requires -p, the package path, and the installed Go toolchain).
fmt.Fprintf(os.Stderr, "%s: %v\n", path, err)
return 1
}
if len(img.Funcs) == 0 {
fmt.Fprintln(os.Stderr, "gasm asm: no assemblable TEXT functions found")
if len(img.Funcs) == 0 && len(img.Data) == 0 {
// A file with neither code nor data assembles to nothing, which is
// almost always a wrong architecture rather than an intent.
fmt.Fprintln(os.Stderr, "gasm asm: no assemblable TEXT functions or GLOBL data found")
return 1
}
for _, fn := range img.Funcs {
@@ -594,7 +605,7 @@ requires -p, the package path, and the installed Go toolchain).
// cmdDiff compares the machine code of two assembly files.
func cmdDiff(args []string) int {
set := newCommand("diff", "gasm diff <file1.s> <file2.s>", `
set := newCommand("diff", "gasm diff [-GOARCH arch] <file1.s> <file2.s>", `
Compare the machine code produced by assembling two files.
Shows which functions differ and the byte-level differences.
Useful for verifying that two implementations produce identical code,
@@ -604,12 +615,22 @@ Use --map to compare functions whose names differ between the files,
e.g. --map wideCopyAVX2=wideCopyAVX512 pairs the two regardless of suffix.
`)
mapSpec := set.String("map", "", "comma-separated old=new pairs to match functions with different names")
archName := set.String("GOARCH", "", "target architecture for both files: amd64, arm64, riscv64 or loong64")
set.Parse(args)
if set.NArg() != 2 {
fmt.Fprintln(os.Stderr, "usage: gasm diff <file1.s> <file2.s>")
fmt.Fprintln(os.Stderr, "usage: gasm diff [-GOARCH arch] <file1.s> <file2.s>")
return 2
}
path1, path2 := set.Arg(0), set.Arg(1)
forced := arch.Unknown
if *archName != "" {
a, err := auditArch(*archName)
if err != nil {
fmt.Fprintf(os.Stderr, "gasm diff: %v\n", err)
return 2
}
forced = a
}
// Parse the name mapping (file1 name → file2 name).
nameMap := make(map[string]string)
@@ -625,12 +646,12 @@ e.g. --map wideCopyAVX2=wideCopyAVX512 pairs the two regardless of suffix.
}
// Assemble both files.
img1, err := assemblePath(path1)
img1, err := assemblePath(path1, forced)
if err != nil {
fmt.Fprintf(os.Stderr, "gasm diff: %s: %v\n", path1, err)
return 1
}
img2, err := assemblePath(path2)
img2, err := assemblePath(path2, forced)
if err != nil {
fmt.Fprintf(os.Stderr, "gasm diff: %s: %v\n", path2, err)
return 1
@@ -705,8 +726,9 @@ func assembleFile(targetArch arch.Arch, f *ast.File) (*asm.Image, error) {
}
}
// assemblePath reads, parses and assembles a file (used by cmdDiff).
func assemblePath(path string) (*asm.Image, error) {
// assemblePath reads, parses and assembles a file (used by cmdDiff). A
// non-Unknown forced architecture overrides the file-name suffix.
func assemblePath(path string, forced arch.Arch) (*asm.Image, error) {
src, err := readSource(path)
if err != nil {
return nil, err
@@ -718,7 +740,11 @@ func assemblePath(path string) (*asm.Image, error) {
if len(errs) > 0 {
return nil, fmt.Errorf("parse errors")
}
return assembleFile(arch.FromFilename(path), f)
target := forced
if target == arch.Unknown {
target = arch.FromFilename(path)
}
return assembleFile(target, f)
}
// printByteDiff shows the first few byte differences between two code blocks.
+55
View File
@@ -293,3 +293,58 @@ func TestSweepCheckLines(t *testing.T) {
t.Errorf("sweepCheckLines = %q, want %q", got, want)
}
}
// TestRunCorpusAudit drives the corpus audit over a small fixture tree: one
// suffixed amd64 file, one suffixed arm64 file whose body is not arm64, one
// generic file, and one file that does not parse.
func TestRunCorpusAudit(t *testing.T) {
dir := t.TempDir()
write := func(name, src string) {
t.Helper()
if err := os.WriteFile(filepath.Join(dir, name), []byte(src), 0o644); err != nil {
t.Fatal(err)
}
}
write("good_amd64.s", "#include \"textflag.h\"\nTEXT ·add(SB), NOSPLIT, $0-0\n\tMOVQ AX, BX\n\tRET\n")
write("bad_arm64.s", "#include \"textflag.h\"\nTEXT ·f(SB), NOSPLIT, $0-0\n\tMOVQ AX, BX\n\tRET\n")
write("generic.s", "#include \"textflag.h\"\nTEXT ·g(SB), NOSPLIT, $0-0\n\tRET\n")
write("broken.s", "#include \"textflag.h\"\nTEXT ·b(SB), NOSPLIT, $0-0\n\tJMP nowhere\n\tRET\n")
stats, err := runCorpusAudit(dir)
if err != nil {
t.Fatalf("runCorpusAudit: %v", err)
}
if stats.files != 4 {
t.Errorf("files = %d, want 4", stats.files)
}
if stats.generic != 2 {
t.Errorf("generic = %d, want 2 (generic.s and broken.s)", stats.generic)
}
// good_amd64 and generic.s assemble everywhere they are attempted.
if stats.full != 2 {
t.Errorf("full = %d, want 2", stats.full)
}
get := func(name string) *corpusTally {
for i, tg := range stats.targets {
if tg.name == name {
return stats.tallies[i]
}
}
t.Fatalf("no tally for %s", name)
return nil
}
// amd64: good_amd64 + generic.s + broken.s; the broken file fails to parse.
if a := get("amd64"); a.attempted != 3 || a.assembled != 2 {
t.Errorf("amd64 = %d/%d, want 2/3", a.assembled, a.attempted)
}
// arm64: bad_arm64 (MOVQ is not arm64) + generic.s + broken.s.
if a := get("arm64"); a.attempted != 3 || a.assembled != 1 {
t.Errorf("arm64 = %d/%d, want 1/3", a.assembled, a.attempted)
}
if r := get("amd64").reasons["instruction not encodable"]; r != 0 {
t.Errorf("amd64 unexpected unencodable reason: %d", r)
}
if r := get("arm64").reasons["instruction not encodable"]; r != 1 {
t.Errorf("arm64 unencodable reasons = %d, want 1", r)
}
}
+157
View File
@@ -0,0 +1,157 @@
// Copyright (c) 2026 Petr Balvín <opensource@petrbalvin.org> (https://petrbalvin.org)
// SPDX-License-Identifier: BSD-3-Clause
package main
import (
"os"
"os/exec"
"path/filepath"
"regexp"
"strings"
"testing"
)
// TestManPagesTrackTheCLI builds the binary once, then compares every
// command's live `-h` output with its docs/man/gasm-<command>.1 page: the
// flag sets must agree both ways, and the page's SYNOPSIS line must carry
// the command's usage line. A flag or a usage change that skips the man
// page fails here, so the pages cannot drift from the binary.
func TestManPagesTrackTheCLI(t *testing.T) {
if testing.Short() {
t.Skip("builds the gasm binary")
}
bin := filepath.Join(t.TempDir(), "gasm")
if out, err := exec.Command("go", "build", "-o", bin, ".").CombinedOutput(); err != nil {
t.Fatalf("build gasm: %v\n%s", err, out)
}
for _, cmd := range []string{
"tokens", "parse", "fmt", "lint", "asm", "dis", "verify",
"debug", "diff", "profile", "audit-instructions", "scaffold", "lsp",
} {
t.Run(cmd, func(t *testing.T) {
raw, err := os.ReadFile(filepath.Join("..", "..", "docs", "man", "gasm-"+cmd+".1"))
if err != nil {
t.Fatalf("read man page: %v", err)
}
page := string(raw)
out, _ := exec.Command(bin, cmd, "-h").CombinedOutput()
help := string(out)
binFlags := helpFlags(help)
pageFlags := roffFlags(page)
for f := range binFlags {
if !pageFlags[f] {
t.Errorf("flag -%s is in the binary's help but missing from the man page", f)
}
}
for f := range pageFlags {
if !binFlags[f] {
t.Errorf("flag -%s is in the man page but the binary does not accept it", f)
}
}
want := helpUsage(help)
got := roffSynopsis(page)
if want != "" && got != want {
t.Errorf("SYNOPSIS drift:\n page: %s\nbinary: %s", got, want)
}
})
}
}
// helpFlags extracts the flag names from a `gasm <cmd> -h` output.
func helpFlags(help string) map[string]bool {
m := map[string]bool{}
inFlags := false
for line := range strings.SplitSeq(help, "\n") {
if strings.TrimRight(line, " \t") == "Flags:" {
inFlags = true
continue
}
if !inFlags {
continue
}
if !strings.HasPrefix(line, " -") {
continue
}
token := strings.FieldsFunc(strings.TrimLeft(line, " "), func(r rune) bool {
return r == ' ' || r == '\t'
})
if len(token) == 0 {
continue
}
m[strings.TrimLeft(token[0], "-")] = true
}
return m
}
var roffEscape = regexp.MustCompile(`\\f[BIRP]`)
// roffFlags extracts the flag names from a man page's OPTIONS section.
func roffFlags(page string) map[string]bool {
m := map[string]bool{}
inOptions := false
for line := range strings.SplitSeq(page, "\n") {
if strings.HasPrefix(line, ".SH ") {
inOptions = strings.HasPrefix(line, ".SH OPTIONS")
continue
}
if !inOptions {
continue
}
// Flag entries are written as either `.B \-flag` or `\fB\-flag`.
var body string
switch {
case strings.HasPrefix(line, `.B \-`):
body = line[3:]
case strings.HasPrefix(line, `\fB\-`):
body = line[1:]
default:
continue
}
name := roffEscape.ReplaceAllString(body, "")
name = strings.ReplaceAll(name, `\-`, "-")
name = strings.TrimSpace(name)
if i := strings.IndexAny(name, " \t"); i >= 0 {
name = name[:i]
}
m[strings.TrimLeft(name, "-")] = true
}
return m
}
// helpUsage returns the command's usage line without the "Usage: " prefix.
func helpUsage(help string) string {
for line := range strings.SplitSeq(help, "\n") {
if strings.HasPrefix(line, "Usage: ") {
return normaliseUsage(line[len("Usage: "):])
}
}
return ""
}
// roffSynopsis returns the page's SYNOPSIS usage line, unescaped.
func roffSynopsis(page string) string {
inSyn := false
for line := range strings.SplitSeq(page, "\n") {
if strings.HasPrefix(line, ".SH ") {
inSyn = strings.HasPrefix(line, ".SH SYNOPSIS")
continue
}
if !inSyn || !strings.HasPrefix(line, ".B ") {
continue
}
return normaliseUsage(strings.ReplaceAll(line[3:], `\-`, "-"))
}
return ""
}
// normaliseUsage flattens whitespace and drops the roff font escapes so that
// the binary's usage line and the page's SYNOPSIS line compare equal.
func normaliseUsage(s string) string {
s = roffEscape.ReplaceAllString(s, "")
return strings.Join(strings.Fields(s), " ")
}
+2 -1
View File
@@ -161,7 +161,8 @@ Two deeper analyses sit on top of the AST:
- **`unreachable-code`.** Code after a `RET` and before the next label is
dead. The check is suppressed for any function whose reachability cannot be
decided statically: those using PC-relative jumps (`JMP 2(PC)`),
register-indirect branches (`JALR`/`JR`/`JIRL`/`BR`/`BLR`), or living in a
register-indirect branches (`JALR`/`JR`/`JIRL`/`BR`/`BLR`, or a `JMP`/`CALL`
through a register or memory operand), or living in a
file with `#ifdef` conditionals. `UNDEF` is deliberately not a terminator:
code after it is occasionally intentional metadata.
- **`register-clobber` (register liveness).** The linter builds the function's
+37 -5
View File
@@ -3,6 +3,10 @@
The reference below is taken from the program's own `--help`. If the two disagree, the
program is right and this file is a defect.
The same reference is installed as man pages: `just install-man` puts gasm(1) and one
page per command into ~/.local/share/man (`MANDIR` overrides), and a test compares each
page against the binary so the two cannot drift apart.
## Synopsis
```sh
@@ -134,18 +138,21 @@ gasm lint kernel_amd64.s
## asm
```text
Usage: gasm asm [--format raw|elf|goobj] [-p pkg] [-o out] <file>
Usage: gasm asm [--format raw|elf|goobj] [-p pkg] [-GOARCH arch] [-o out] <file>
```
| Flag | Default | Effect |
|---|---|---|
| `-format` | `raw` | output format: `raw` (concatenated image), `elf` or `goobj` (Go object) |
| `-p` | empty | package path for `--format goobj`, qualifying the exported symbols |
| `-GOARCH` | empty | target architecture: `amd64`, `arm64`, `riscv64` or `loong64`; overrides the file-name suffix |
| `-o` | empty | write the output to this file instead of a hex dump on stdout |
Supported architectures: amd64 (VEX/AVX2 and EVEX/AVX-512 included), arm64,
riscv64 (RV64IMAFDC and RVC) and loong64, selected from the file's `_arch.s`
suffix. `raw` concatenates the functions and the data section into one
riscv64 (RV64IMAFDC and RVC) and loong64, taken from the file's `_arch.s`
suffix or from `-GOARCH`, which is how files whose names carry no
recognisable suffix (most of GOROOT's, for example `cpu_x86.s`) are
assembled. `raw` concatenates the functions and the data section into one
self-consistent image; `elf` emits a relocatable object that links with the
system toolchain; `goobj` emits the Go toolchain's own object format, which
`cmd/link` consumes directly.
@@ -292,11 +299,12 @@ gasm debug --func add --cover hello_amd64.s
## diff
```text
Usage: gasm diff <file1.s> <file2.s>
Usage: gasm diff [-GOARCH arch] <file1.s> <file2.s>
```
| Flag | Default | Effect |
|---|---|---|
| `-GOARCH` | empty | target architecture for both files, overriding the file-name suffixes |
| `-map` | empty | comma-separated `old=new` pairs to match functions with different names |
Functions are paired by exact name unless `--map` says otherwise, so
@@ -334,7 +342,7 @@ add: 16 bytes, args=24, frame=0 NOSPLIT
## audit-instructions
```text
Usage: gasm audit-instructions [amd64|arm64|riscv64|loong64]
Usage: gasm audit-instructions [--corpus [dir]] [amd64|arm64|riscv64|loong64]
```
Compare the gasm encoder for the given architecture (default amd64) against the
@@ -357,6 +365,30 @@ gasm encodable: 580 go tool asm recognized: 1542
shared: 580
```
With `--corpus` the audit changes shape: it assembles every `.s` file under
DIR (default `GOROOT/src`) with the gasm encoder only, no toolchain probing.
A file whose name carries a recognisable `_arch` suffix is attempted for that
architecture; a file without one is attempted for all four, exactly as a
`GOARCH` build would compile it. The report gives the headline number (files
that assemble for every target architecture), the per-architecture pass rates
and the most common failure reasons with one representative file each, which
drive the encodability backlog by frequency rather than by table order. A run
over GOROOT takes under a second.
```sh
gasm audit-instructions --corpus
gasm audit-instructions --corpus "$(go env GOROOT)/src/crypto"
```
```text
corpus /usr/local/go/src: 627 files (365 generic, attempted for all architectures)
assemble for every target architecture: 108 (17.2%)
amd64: 77/464 attempted
148 instruction not encodable
e.g. /usr/local/go/src/cmd/asm/internal/asm/testdata/386enc.s
...
```
## scaffold
```text
+62
View File
@@ -0,0 +1,62 @@
.TH GASM-ASM 1 "2026-09-19" "gasm 0.33.0" "User Commands"
.SH NAME
gasm-asm \- assemble Plan 9 assembly without the Go toolchain
.SH SYNOPSIS
.B gasm asm [\-\-format raw|elf|goobj] [\-p pkg] [\-GOARCH arch] [\-o out] <file>
.SH DESCRIPTION
Assemble FILE without the Go toolchain: every TEXT function is encoded
to machine code and printed as a hex dump. Supported architectures:
amd64 (including VEX/AVX2 and EVEX/AVX-512), arm64 (AArch64 integer,
FP, conditional select, CRC32 and MOV pseudo), riscv64 (RV64IMAFDC and
RVC) and loong64 (LoongArch base ISA).
.PP
With
.B \-o
the output is written to a file instead. The
.B \-\-format
flag selects what is written:
.B raw
(the default) concatenates the functions and the data section into one
self-consistent image;
.B elf
emits a relocatable object (.text/.data sections, a symbol table and
one PC32 relocation per static-symbol reference) that links with the
system toolchain;
.B goobj
emits the Go toolchain's own object format, which cmd/link consumes
directly (it requires
.BR \-p ,
the package path, and the installed Go toolchain).
.PP
Framed functions receive the stack-split guard and the trailing
morestack block, byte-identical to the toolchain's output, so split
functions link too.
.SH OPTIONS
.TP
.B \-\-format \fIraw|elf|goobj\fR
Output format; the default is raw.
.TP
.B \-p \fIpkg\fR
Package path for --format goobj, qualifying the exported symbols.
.TP
.B \-GOARCH \fIarch\fR
Target architecture: amd64, arm64, riscv64 or loong64; overrides the
file-name suffix, which is how the suffix-less majority of GOROOT's
files (cpu_x86.s, stub.s, ...) become assemblable.
.TP
.B \-o \fIfile\fR
Write the output to this file instead of a hex dump on stdout.
.SH EXIT STATUS
Exits 0 on success, 1 when parsing or assembly fails, and 2 on a usage
error.
.SH EXAMPLES
.nf
gasm asm \-o hello.bin hello_amd64.s raw image
gasm asm \-\-format elf \-o k.o k.s linkable ELF object
gasm asm \-\-format goobj \-p pkg/path \-o k.o k.s Go object for go build
gasm asm \-GOARCH amd64 cpu_x86.s arch override
.fi
.SH SEE ALSO
.BR gasm (1),
.BR gasm\-dis (1),
.BR gasm\-verify (1)
+47
View File
@@ -0,0 +1,47 @@
.TH GASM-AUDIT-INSTRUCTIONS 1 "2026-09-19" "gasm 0.33.0" "User Commands"
.SH NAME
gasm-audit-instructions \- diff the encoder against the Go toolchain, or measure a corpus
.SH SYNOPSIS
.B gasm audit\-instructions [\-\-corpus [\fIdir\fR]] [amd64|arm64|riscv64|loong64]
.SH DESCRIPTION
Compare the gasm encoder for the given architecture (default amd64)
against
.B go tool asm
and print the diff: superset encodings (gasm-only, shippable via
.BR "gasm asm \-\-format goobj" ),
known-but-unencodable names (the backlog) and go-only names (feature
gaps). The Go side is probed black-box with a battery of bare
mnemonics, so the audit tracks whatever toolchain
.B go env GOROOT
provides.
.PP
With
.BR \-\-corpus ,
the audit changes shape: it assembles every
.I .s
file under the given directory (default GOROOT/src) with the gasm
encoder only, no toolchain probing. A file whose name carries a
recognisable _arch suffix is attempted for that architecture; a file
without one is attempted for all four, exactly as a GOARCH build would
compile it. The report gives the headline number (files that assemble
for every target architecture), the per-architecture pass rates and the
most common failure reasons, which drive the encodability backlog by
frequency rather than by table order. A run over GOROOT takes under a
second.
.SH OPTIONS
.TP
.B \-\-corpus [\fIdir\fR]
Assemble a corpus of .s files and report pass rates and failure
reasons.
.SH EXIT STATUS
The mnemonic-diff mode reports through its output and exits 0; a failed
probe or an unknown architecture exits non-zero.
.SH EXAMPLES
.nf
gasm audit\-instructions amd64
gasm audit\-instructions \-\-corpus
gasm audit\-instructions \-\-corpus "$(go env GOROOT)/src/crypto"
.fi
.SH SEE ALSO
.BR gasm (1),
.BR gasm\-asm (1)
+113
View File
@@ -0,0 +1,113 @@
.TH GASM-DEBUG 1 "2026-09-19" "gasm 0.33.0" "User Commands"
.SH NAME
gasm-debug \- interactive source-level debugger for JIT-assembled functions
.SH SYNOPSIS
.B gasm debug <file.s> \-\-func <name>
.SH DESCRIPTION
Interactive debugger for JIT-assembled functions. Launches the function
in a traced subprocess (ptrace), then provides a REPL for
single-stepping, breakpoints, register and memory inspection.
.PP
With
.B \-\-script
the REPL commands run from a file and the session ends: the headless
mode CI and scripts use.
.B \-\-cover
runs to completion with a breakpoint on every instruction and reports
which executed and how often, the label-level coverage view.
.SH REPL COMMANDS
.TP
.B break \fIlabel|addr\fR [\fBif \fIreg op val\fR]
Set a breakpoint, optionally conditional on a register comparison
(register against register or immediate).
.TP
.B delete \fIlabel|addr\fR
Remove a breakpoint.
.TP
.B info break
List all breakpoints.
.TP
.BR step " [" n ], " s
Single-step n instructions; the default is 1.
.TP
.BR next ", " n
Step over a CALL.
.TP
.BR finish ", " fin
Run until the function returns.
.TP
.BR continue ", " c
Run until a breakpoint, watchpoint or exit.
.TP
.BR disas " [" n ], " u
Disassemble n instructions at PC.
.TP
.B regs
Print general-purpose and vector registers.
.TP
.B where
Show the source line and nearest label at PC.
.TP
.B stack
Show the stack near RSP (return address and ABI0 args).
.TP
.BR bt ", " backtrace
Backtrace: current frame plus return address.
.TP
.B x [\fIaddr\fR] [\fIlen\fR]
Hex-dump memory; the defaults are the current PC and 64 bytes.
.TP
.B w \fIaddr val...\fR
Write bytes to memory.
.TP
.B set \fIreg value\fR
Set a register.
.TP
.B watch \fIaddr\fR [\fBr|w\fR] [\fIsize\fR]
Set a hardware watchpoint; writes are watched by default.
.TP
.B unwatch [\fIslot\fR]
Clear one watchpoint, or all without an argument.
.TP
.BR labels ", " l
List function labels and offsets.
.TP
.BR help ", " h ", " ?
Show command help.
.TP
.BR quit ", " q
Kill the debuggee and exit.
.SH OPTIONS
.TP
.B \-args \fIfile\fR
File containing the ABI0 argument block.
.TP
.B \-buf \fIspec\fR
Buffer specification: name:size:pattern[,name:size:pattern...] where
pattern is zero, ones, seq, or hex.
.TP
.B \-cover
Run to completion with a breakpoint on every instruction and report
which executed and how often.
.TP
.B \-func \fIname\fR
Function to debug.
.TP
.B \-script \fIfile\fR
Run REPL commands from a file (one per line) and exit; - reads stdin.
.TP
.B \-timeout \fIduration\fR
Kill the debuggee after this duration (e.g. 30s); for headless --script
runs.
.SH EXIT STATUS
Exits 0 when the scripted session completes and 1 when the debuggee
crashes or a check fails; the debugger is Linux-only.
.SH EXAMPLES
.nf
gasm debug \-\-func name k.s
gasm debug \-\-func name \-\-script cmds.txt \-\-timeout 30s k.s
gasm debug \-\-func name \-\-cover k.s
.fi
.SH SEE ALSO
.BR gasm (1),
.BR gasm\-verify (1)
+35
View File
@@ -0,0 +1,35 @@
.TH GASM-DIFF 1 "2026-09-19" "gasm 0.33.0" "User Commands"
.SH NAME
gasm-diff \- compare the machine code of two assembly files
.SH SYNOPSIS
.B gasm diff [\-GOARCH arch] <file1.s> <file2.s>
.SH DESCRIPTION
Compare the machine code produced by assembling two files. Shows which
functions differ and the byte-level differences. Useful for verifying
that two implementations produce identical code, or for tracking
encoding changes between Go assembler versions.
.PP
Functions are paired by exact name unless
.B \-\-map
says otherwise, so
.B \-\-map wideCopyAVX2=wideCopyAVX512
pairs two variants regardless of suffix.
.SH OPTIONS
.TP
.B \-GOARCH \fIarch\fR
Target architecture for both files: amd64, arm64, riscv64 or loong64;
overrides the file-name suffixes.
.TP
.B \-\-map \fIspec\fR
Comma-separated old=new pairs to match functions with different names.
.SH EXIT STATUS
Exits 0 when every paired function is identical and 1 when anything
differs; a usage error exits 2.
.SH EXAMPLES
.nf
gasm diff hello_amd64.s hello_amd64.s
gasm diff \-\-map wideCopyAVX2=wideCopyAVX512 avx2.s avx512.s
.fi
.SH SEE ALSO
.BR gasm (1),
.BR gasm\-asm (1)
+36
View File
@@ -0,0 +1,36 @@
.TH GASM-DIS 1 "2026-09-19" "gasm 0.33.0" "User Commands"
.SH NAME
gasm-dis \- disassemble machine code to instruction text
.SH SYNOPSIS
.B gasm dis [\-a arch] <file>
.SH DESCRIPTION
Disassemble machine code to instruction text, decoded through
golang.org/x/arch.
.PP
With a
.I .s
file, the file is assembled first and the listing follows the real
layout: one block per TEXT function, local labels printed at their
offsets. The architecture comes from the file-name suffix, or from
.BR \-a .
.PP
With any other file, or
.B \-
for standard input, the bytes are disassembled linearly and
.B \-a
selects the architecture (amd64, arm64, riscv64 or loong64).
.SH OPTIONS
.TP
.B \-a \fIarch\fR
Architecture for raw input: amd64, arm64, riscv64 or loong64.
.SH EXIT STATUS
Exits 0 on success, 1 when assembly or decoding fails, and 2 on a usage
error.
.SH EXAMPLES
.nf
gasm dis k.s assemble, then list each function
gasm dis \-a amd64 \- < dump.bin disassemble raw bytes from stdin
.fi
.SH SEE ALSO
.BR gasm (1),
.BR gasm\-asm (1)
+52
View File
@@ -0,0 +1,52 @@
.TH GASM-FMT 1 "2026-09-19" "gasm 0.33.0" "User Commands"
.SH NAME
gasm-fmt \- canonicalise the formatting of Plan 9 assembly sources
.SH SYNOPSIS
.B gasm fmt [\-w|\-l|\-d] [path...]
.SH DESCRIPTION
Canonicalise the formatting of Plan 9 assembly sources: indentation,
operand spacing, per-function mnemonic alignment and blank-line layout
(exactly one blank line before each label, TEXT and GLOBL block).
Formatting is idempotent and preserves every line, comments included.
.PP
With no paths, or a directory path, every
.I .s
file below it is reformatted in place and the changed files are listed,
the way
.B go fmt
does;
.B .
and
.B _
directories are skipped. Explicit file paths print to stdout unless
.B \-w
is given.
.PP
.B \-l
and
.B \-d
rewrite nothing:
.B \-l
prints the paths whose formatting differs from gasm's (empty output
means everything is formatted, which is what a CI check wants),
.B \-d
prints the diffs. They are mutually exclusive.
.SH OPTIONS
.TP
.B \-d
Print diffs instead of rewriting files.
.TP
.B \-l
List files whose formatting differs from gasm's.
.TP
.B \-w
Write the result to the source file.
.SH EXAMPLES
.nf
gasm fmt reformat every .s below here
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
.fi
.SH SEE ALSO
.BR gasm (1)
+90
View File
@@ -0,0 +1,90 @@
.TH GASM-LINT 1 "2026-09-19" "gasm 0.33.0" "User Commands"
.SH NAME
gasm-lint \- run the static checks over assembly files
.SH SYNOPSIS
.B gasm lint <file...>
.SH DESCRIPTION
Run the static checks over the given files and print diagnostics as
\fIfile:line:col: severity: message [code]\fR. The exit status is
non-zero when an error-severity diagnostic is found; warnings (e.g. the
register-clobber audit) do not affect it.
.PP
The checks are conservative: they report what can be proven wrong and
stay quiet otherwise, so a clean lint run is meaningful without
suppression lists.
.SH RULES
.TP
.B unknown-instruction
The mnemonic is not in the architecture's instruction table.
.TP
.B operand-count
The operand count disagrees with the instruction's declared arity.
.TP
.B undefined-label
A jump target names no label in the function.
.TP
.B duplicate-label
Two labels in one function share a name.
.TP
.B missing-ret
The function can fall off its end without a terminator.
.TP
.B missing-textflag-include
TEXT flags are used without including textflag.h.
.TP
.B abi-argsize
The declared frame or argument size disagrees with the
.B //\ function
signature.
.TP
.B unreachable-code
Code after RET and before the next label is dead; suppressed for
functions with PC-relative or register-indirect control flow.
.TP
.B register-clobber
A register the Go ABI fixes across calls is written without save and
restore, computed by liveness over the control-flow graph.
.TP
.B funcdata-pcdata
FUNCDATA and PCDATA indices are malformed.
.TP
.B unused-label
A label no jump reaches.
.TP
.B invalid-textflag
A TEXT flag combination the toolchain rejects.
.TP
.B stack-imbalance
The function does not restore the stack pointer on every path.
.TP
.B register-width-mismatch
An operand register has the wrong width for the instruction.
.TP
.B abi0-register-args
A call passes arguments in registers where ABI0 expects the stack
frame.
.TP
.B nonportable-register-name
A register spelling that does not exist on the target architecture.
.TP
.B unencodable-instruction
The mnemonic is known to the table but the encoder cannot assemble it
yet (amd64).
.TP
.B reserved-register-write
A write to the register the runtime reserves (arm64 R18).
.SH OPTIONS
.TP
.B \-disable \fIcodes\fR
Comma-separated rule codes to disable.
.SH EXIT STATUS
Exits 0 when no error-severity diagnostic is found, 1 otherwise, and 2
on a usage error.
.SH EXAMPLES
.nf
gasm lint kernel_amd64.s
gasm lint \-disable register-clobber,unused-label *.s
.fi
.SH SEE ALSO
.BR gasm (1),
.BR gasm\-asm (1)
+24
View File
@@ -0,0 +1,24 @@
.TH GASM-LSP 1 "2026-09-19" "gasm 0.33.0" "User Commands"
.SH NAME
gasm-lsp \- run the Plan 9 assembly language server
.SH SYNOPSIS
.B gasm lsp
.SH DESCRIPTION
Run the language server over standard input/output: JSON-RPC 2.0 with
Content-Length framing. Point an LSP-capable editor at the binary and
associate it with
.I .s
files; the target architecture is inferred from the file suffix
(_amd64.s, _arm64.s, _riscv64.s, _loong64.s).
.PP
Provides 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; definition, references and rename work across
every open document. Syntax highlighting is delivered as LSP semantic
tokens, so no editor-specific grammar is required.
.SH EXIT STATUS
Runs until the client closes the session; exits 0 on a clean shutdown.
.SH SEE ALSO
.BR gasm (1)
+20
View File
@@ -0,0 +1,20 @@
.TH GASM-PARSE 1 "2026-09-19" "gasm 0.33.0" "User Commands"
.SH NAME
gasm-parse \- parse an assembly file and report syntax errors
.SH SYNOPSIS
.B gasm parse <file>
.SH DESCRIPTION
Parse FILE and report syntax errors on stderr. The parser is
error-tolerant and line-oriented: a malformed line becomes a diagnostic
and parsing continues, so one run reports every syntax error in the
file rather than the first.
.PP
On success, print how many declarations and TEXT functions the file
contains. FILE may be
.B \-
to read standard input.
.SH EXIT STATUS
Exits 0 when the file parses without errors and 1 otherwise.
.SH SEE ALSO
.BR gasm (1),
.BR gasm\-tokens (1)
+16
View File
@@ -0,0 +1,16 @@
.TH GASM-PROFILE 1 "2026-09-19" "gasm 0.33.0" "User Commands"
.SH NAME
gasm-profile \- show the basic-block structure of functions
.SH SYNOPSIS
.B gasm profile <file.s>
.SH DESCRIPTION
Show the basic-block structure of functions in an assembly file: each
function's labels, their offsets, and the block boundaries. This is
the static structure; for runtime execution counts, use
.BR "gasm verify \-fuzz" ,
which exercises the code paths.
.SH EXIT STATUS
Exits 0 on success and 1 when the file cannot be assembled.
.SH SEE ALSO
.BR gasm (1),
.BR gasm\-verify (1)
+22
View File
@@ -0,0 +1,22 @@
.TH GASM-SCAFFOLD 1 "2026-09-19" "gasm 0.33.0" "User Commands"
.SH NAME
gasm-scaffold \- generate a differential test skeleton for a kernel file
.SH SYNOPSIS
.B gasm scaffold differential <file.s>
.SH DESCRIPTION
Print a differential test skeleton for every
.B //\ func
signature in FILE. The test seeds random states, drives the kernel and
a portable reference (\fI<name>Portable\fR), and compares outputs
byte-for-byte. Write the reference bodies, place the file in the
kernel's package, and run it in CI.
.SH EXIT STATUS
Exits 0 when the skeleton is written to stdout and 1 when the file
cannot be parsed; a usage error exits 2.
.SH EXAMPLES
.nf
gasm scaffold differential kernel_amd64.s > kernel_differential_test.go
.fi
.SH SEE ALSO
.BR gasm (1),
.BR gasm\-verify (1)
+19
View File
@@ -0,0 +1,19 @@
.TH GASM-TOKENS 1 "2026-09-19" "gasm 0.33.0" "User Commands"
.SH NAME
gasm-tokens \- print the lexical token stream of an assembly file
.SH SYNOPSIS
.B gasm tokens <file>
.SH DESCRIPTION
Print the lexical token stream of FILE: position, token kind and text,
one token per line. FILE may be
.B \-
to read standard input.
.PP
This is the front end's raw view, for when the assembler's own
diagnostic is not enough: a mis-scanned operand or a swallowed comment
shows up here as the tokens the parser actually received.
.SH EXIT STATUS
Exits 0 on success and 1 when the file cannot be read.
.SH SEE ALSO
.BR gasm (1),
.BR gasm\-parse (1)
+117
View File
@@ -0,0 +1,117 @@
.TH GASM-VERIFY 1 "2026-09-19" "gasm 0.33.0" "User Commands"
.SH NAME
gasm-verify \- JIT-assemble a file and run dynamic checks against it
.SH SYNOPSIS
.B gasm verify [\-smoke] [\-abi] [\-fuzz] [\-ground\-truth] [\-profile] [\-call] <file.s>
.SH DESCRIPTION
Assemble FILE, map it into executable memory and report the available
functions. This confirms the assembled image is self-consistent (no
unresolved external symbols) and executable, the prerequisite for
dynamic testing.
.PP
With
.BR \-smoke ,
each NOSPLIT function is called with a zeroed argument block to confirm
the JIT trampoline works end-to-end. This is safe only for functions
that tolerate nil pointers and zero lengths in their arguments.
.PP
With
.BR \-abi ,
each function is called with sentinel values in the registers the Go
ABI fixes across calls (the frame pointer and the goroutine pointer)
plus a canary below SP; violations are reported. JIT-based checks run
when the host matches the file's architecture (all but loong64, which
is ground-truth only for now).
.PP
With
.BR \-fuzz ,
each function with a
.B //\ func
signature is differentially fuzzed against the go-tool-asm version in a
subprocess (so a crash on a partial function is reported, not fatal).
.PP
With
.BR \-ground\-truth ,
the assembled machine code is compared byte-for-byte against
.B go tool asm
(relocation sites masked), reporting any encoding drift.
.PP
With
.BR \-profile ,
the static basic-block structure is listed for each function.
.PP
With
.BR \-call ,
a single function is invoked with user-supplied buffers
.RB ( \-buf )
instead of the smoke/abi/fuzz sweeps. Useful for partial functions
(e.g. decoders) that crash on random input but should succeed on valid
data.
.PP
With
.B \-save\-corpus
(and
.BR \-fuzz ),
every input that crashes or mismatches is written to the directory as
replayable JSON.
.B \-replay
re-runs saved entries against the kernel, one child process per entry,
so an input that crashed the original run crashes only the child: the
report says whether each entry reproduces.
.SH OPTIONS
.TP
.B \-abi
Run ABI-checking calls (sentinel registers and red zone).
.TP
.B \-abi\-n \fIn\fR
Number of ABI check iterations with varied inputs; the default is 100.
.TP
.B \-args \fIspec\fR
Scalar args for -call: name=value[,name=value] (decimal or 0x hex).
.TP
.B \-buf \fIspec\fR
Buffer spec for -call: name:size:pattern[,name:size:pattern] where
pattern is zero, ones, seq, or hex.
.TP
.B \-call \fIname\fR
Call a single function with -buf instead of the sweeps.
.TP
.B \-fuzz
Differential fuzz: JIT both the gasm and the go-tool-asm versions and
compare outputs.
.TP
.B \-ground\-truth
Compare machine code byte-for-byte against go tool asm.
.TP
.B \-n \fIn\fR
Number of fuzz iterations per function; the default is 1000.
.TP
.B \-profile
List basic-block structure per function.
.TP
.B \-repeat \fIn\fR
Number of times to repeat a -call invocation; the default is 1.
.TP
.B \-replay \fIdir\fR
Replay saved corpus entries (JSON files in this directory) against the
kernel.
.TP
.B \-save\-corpus \fIdir\fR
With -fuzz: write each failing input to this directory as replayable
JSON.
.TP
.B \-smoke
Call each NOSPLIT function with zeroed args.
.SH EXIT STATUS
Exits 0 when every requested check passes and 1 when any check fails;
a file that cannot be assembled exits 1 and a usage error exits 2.
.SH EXAMPLES
.nf
gasm verify \-\-call add \-\-args a=2,b=3 hello_amd64.s
gasm verify \-\-ground\-truth k.s
gasm verify \-\-fuzz \-n 500 k.s
.fi
.SH SEE ALSO
.BR gasm (1),
.BR gasm\-asm (1),
.BR gasm\-debug (1)
+96
View File
@@ -0,0 +1,96 @@
.TH GASM 1 "2026-09-19" "gasm 0.33.0" "User Commands"
.SH NAME
gasm \- developer tooling for Go's Plan 9 assembler
.SH SYNOPSIS
.B gasm
.I command
.RI [ arguments ]
.br
.B gasm
.BR \-h | \-\-help
.br
.B gasm
.BR \-V | \-\-version
.SH DESCRIPTION
.B gasm
bundles a lexer, parser, formatter, linter, standalone assembler and
language server for Plan 9 assembly into one self-contained binary. It
serves two purposes: it brings developer tooling to the
.I .s
files of Go programs, and it assembles Plan 9 assembly without the Go
toolchain at all, to raw images, linkable ELF objects with DWARF5 debug
sections, or the Go toolchain's own GOOBJ format, which
.B go build
consumes directly.
.PP
Four architectures are covered: amd64 (including VEX/AVX2 and
EVEX/AVX-512), arm64, riscv64 (RV64IMAFDC and RVC) and loong64. The
target architecture is inferred from the file-name suffix
(\fI_amd64.s\fR, \fI_arm64.s\fR, \fI_riscv64.s\fR, \fI_loong64.s\fR) or
named explicitly with \fB\-GOARCH\fR where the commands accept it.
.SH COMMANDS
.TP
.B gasm\-tokens(1)
Print the lexical token stream.
.TP
.B gasm\-parse(1)
Parse a file and report syntax errors.
.TP
.B gasm\-fmt(1)
Canonicalise formatting: gofmt for assembly.
.TP
.B gasm\-lint(1)
Run the static checks.
.TP
.B gasm\-asm(1)
Assemble \fI.s\fR files to machine code, raw images, ELF objects or GOOBJ.
.TP
.B gasm\-dis(1)
Disassemble machine code, raw bytes or an assembled \fI.s\fR file.
.TP
.B gasm\-verify(1)
JIT-assemble and run dynamic checks: smoke calls, ABI checks,
differential fuzzing, ground-truth comparison.
.TP
.B gasm\-debug(1)
Interactive source-level debugger.
.TP
.B gasm\-diff(1)
Compare the machine code of two files byte-for-byte.
.TP
.B gasm\-profile(1)
Show the basic-block structure of functions.
.TP
.B gasm\-audit\-instructions(1)
Diff the encoder against the Go toolchain's name table, or measure a
corpus of \fI.s\fR files.
.TP
.B gasm\-scaffold(1)
Generate a differential test skeleton for a kernel file.
.TP
.B gasm\-lsp(1)
Run the language server over standard input/output.
.TP
.B gasm version
Print the version, the same as \fB\-\-version\fR.
.SH GLOBAL FLAGS
.TP
.BR \-h ", " \-\-help
Show the command overview.
.TP
.BR \-V ", " \-\-version
Print the version the toolchain recorded at build time.
.SH EXIT STATUS
Exits 0 on success, 1 when a command fails, and 2 on a usage error. An
unknown command exits 2.
.SH SEE ALSO
.BR gasm\-asm (1),
.BR gasm\-fmt (1),
.BR gasm\-lint (1),
.BR gasm\-verify (1),
.BR gasm\-debug (1)
.PP
The full command reference, with worked examples and every flag, is in
docs/CLI.md of the repository
.UR https://sourcedock.dev/petrbalvin/gasm-devkit
.UE .
+96 -9
View File
@@ -50,12 +50,31 @@ func Source(src string) string {
}
case len(line) >= 2 && line[1].Kind == token.Colon:
inf.kind = kLabel
// Peel stacked labels exactly as the render pass does; the
// instruction after the last one is rendered at the
// function's alignment width, so its mnemonic counts here.
rest := line[2:]
for len(rest) >= 2 && rest[0].Kind == token.Ident && rest[1].Kind == token.Colon &&
!isDirective(rest[0].Text) {
rest = rest[2:]
}
if len(rest) > 0 && rest[0].Kind == token.Ident && !isDirective(rest[0].Text) {
inf.mnemLen = len(rest[0].Text)
if funcID >= 0 && inf.mnemLen > maxWidth[funcID] {
maxWidth[funcID] = inf.mnemLen
}
}
default:
inf.kind = kInstr
inf.funcID = funcID
inf.mnemLen = len(line[0].Text)
if funcID >= 0 && inf.mnemLen > maxWidth[funcID] {
maxWidth[funcID] = inf.mnemLen
// Only an identifier mnemonic takes the alignment width; a
// line starting with anything else renders unpadded, so its
// length must not enter the width either.
if line[0].Kind == token.Ident {
inf.mnemLen = len(line[0].Text)
if funcID >= 0 && inf.mnemLen > maxWidth[funcID] {
maxWidth[funcID] = inf.mnemLen
}
}
}
}
@@ -83,12 +102,35 @@ func Source(src string) string {
out = line[0].Text + " " + renderOps(line[1:])
inBody = line[0].Text == "TEXT"
case kLabel:
out = line[0].Text + ":"
// A label may share its line with an instruction; emit the
// instruction on the following line.
if rest := line[2:]; len(rest) > 0 {
out += "\n" + renderInstr(rest, maxWidth[inf.funcID])
// Every label, and a trailing instruction, becomes its own
// output line: separate outLines keep the blank-line pass
// honest about what it is looking at.
outs = append(outs, outLine{kind: kLabel, text: line[0].Text + ":"})
rest := line[2:]
for len(rest) >= 2 && rest[0].Kind == token.Ident && rest[1].Kind == token.Colon &&
!isDirective(rest[0].Text) {
outs = append(outs, outLine{kind: kLabel, text: rest[0].Text + ":"})
rest = rest[2:]
}
// A label may share its line with an instruction; the canonical
// form puts the instruction on the following line. Trailing
// content that does not start an instruction (a stray operand
// token) stays on the label line: splitting it off would produce
// a line the parser rejects.
if len(rest) > 0 && rest[0].Kind == token.Ident && isDirective(rest[0].Text) {
// A bare directive cannot start a line of its own (the
// parser wants a symbol per line), so a directive sharing
// the label's line stays there.
outs[len(outs)-1].text += " " + strings.TrimRight(renderOps(rest), " \t")
} else if len(rest) > 0 && rest[0].Kind == token.Ident {
outs = append(outs, outLine{kind: kInstr, text: strings.TrimRight(renderInstr(rest, maxWidth[inf.funcID]), " \t")})
if strings.EqualFold(rest[0].Text, "RET") {
inBody = false
}
} else if len(rest) > 0 {
outs[len(outs)-1].text += " " + renderOps(rest)
}
continue
case kInstr:
out = renderInstr(line, maxWidth[inf.funcID])
// A RET ends the body for indentation purposes: comments that
@@ -192,6 +234,13 @@ func renderInstr(line []token.Token, width int) string {
if ops == "" {
return "\t" + mnem
}
// Alignment is a mnemonic convention: a line that does not start with
// an identifier (a stray operand token the parser tolerates) renders
// unpadded, so that no alignment width can depend on it and the output
// stays stable across passes.
if line[0].Kind != token.Ident {
return "\t" + mnem + " " + ops
}
if width < len(mnem) {
width = len(mnem)
}
@@ -217,7 +266,19 @@ func renderPreproc(line []token.Token) string {
func renderOps(toks []token.Token) string {
var b strings.Builder
for i, t := range toks {
if i > 0 && spaceBetween(toks[i-1], t) {
sp := i > 0 && spaceBetween(toks[i-1], t)
// The accumulated text ending in '/' must never meet a '/' or '*':
// the pair would re-lex as a comment and the next pass would see a
// different line, whatever the token boundaries were.
if !sp && i > 0 && (t.Kind == token.Slash || t.Kind == token.Star) && strings.HasSuffix(b.String(), "/") {
sp = true
}
if sp {
b.WriteByte(' ')
} else if i > 0 && wouldMerge(toks[i-1], t) {
// The tight spelling would re-lex as something else ('/'
// before '*' opens a comment), which would make the next
// formatting pass see a different line.
b.WriteByte(' ')
}
b.WriteString(t.Text)
@@ -225,8 +286,27 @@ func renderOps(toks []token.Token) string {
return b.String()
}
// wouldMerge reports whether writing prev immediately before cur would
// re-lex as something other than those two tokens: a '/' before a '*' opens
// a comment, '>' before '>' shifts, and adjacent operators regroup.
func wouldMerge(prev, cur token.Token) bool {
var kinds []token.Kind
for _, t := range lexer.Tokenize(prev.Text + cur.Text) {
if t.Kind == token.EOF {
break
}
kinds = append(kinds, t.Kind)
}
return len(kinds) != 2 || kinds[0] != prev.Kind || kinds[1] != cur.Kind
}
// spaceBetween decides whether a single space separates prev and cur.
func spaceBetween(prev, cur token.Token) bool {
// '/' beside '/' or '*' would form a comment opener in the output and
// make the next pass see a different line; keep them separated.
if prev.Kind == token.Slash && (cur.Kind == token.Slash || cur.Kind == token.Star) {
return true
}
switch cur.Kind {
case token.RParen:
return false
@@ -274,6 +354,13 @@ func splitLines(toks []token.Token) [][]token.Token {
if t.Kind == token.EOF {
break
}
if t.Kind == token.Illegal {
// Illegal tokens carry no canonical spelling: the parser
// reports them as errors where they matter, and the formatter
// drops them so that a stray character cannot survive into the
// output and make the next pass render a different file.
continue
}
if t.Kind == token.Newline {
lines = append(lines, cur)
cur = nil
+47
View File
@@ -0,0 +1,47 @@
// Copyright (c) 2026 Petr Balvín <opensource@petrbalvin.org> (https://petrbalvin.org)
// SPDX-License-Identifier: BSD-3-Clause
package format
import (
"os"
"path/filepath"
"testing"
"sourcedock.dev/petrbalvin/gasm-devkit/parser"
)
// FuzzFormatIdempotency hammers the formatter with arbitrary input. The
// contract: formatting twice equals formatting once, and input that parses
// cleanly still parses cleanly after formatting. The seed corpus carries the
// repository's kernels, so a plain `go test` run replays every seed as a
// regression case and CI exercises them without any fuzzing budget.
func FuzzFormatIdempotency(f *testing.F) {
for _, pattern := range []string{
"../testdata/*.s",
"../testdata/verify/*.s",
} {
files, _ := filepath.Glob(pattern)
for _, path := range files {
if b, err := os.ReadFile(path); err == nil {
f.Add(string(b))
}
}
}
f.Add("TEXT ·f(SB), NOSPLIT, $0\n\tMOVQ AX, BX\n\tRET\n")
f.Add("TEXT ·f(SB),NOSPLIT,$0\n\tMOVQ AX,BX\n\n\n\tRET\n")
f.Add("garbage ### ???\n")
f.Fuzz(func(t *testing.T, src string) {
once := Source(src)
twice := Source(once)
if once != twice {
t.Fatalf("formatting is not idempotent:\nfirst: %q\nsecond: %q", once, twice)
}
if _, errs := parser.Parse("in.s", src); len(errs) == 0 {
if _, errs := parser.Parse("out.s", once); len(errs) > 0 {
t.Fatalf("formatted output of clean input does not parse: %v\n%s", errs[0], once)
}
}
})
}
@@ -0,0 +1,2 @@
go test fuzz v1
string("0:A:")
@@ -0,0 +1,2 @@
go test fuzz v1
string("$0/ *")
@@ -0,0 +1,2 @@
go test fuzz v1
string("A:TEXT")
@@ -0,0 +1,2 @@
go test fuzz v1
string("TEXT\n0:A:A0\nA 0")
@@ -0,0 +1,2 @@
go test fuzz v1
string("00/ /*")
@@ -0,0 +1,2 @@
go test fuzz v1
string("TEXT \n0:RET\n/*0")
@@ -0,0 +1,2 @@
go test fuzz v1
string("A:00")
@@ -0,0 +1,2 @@
go test fuzz v1
string("0:A\n0:")
@@ -0,0 +1,2 @@
go test fuzz v1
string("0\\")
@@ -0,0 +1,2 @@
go test fuzz v1
string("TEXT \n\" \nA\"")
@@ -0,0 +1,2 @@
go test fuzz v1
string("TEXT\nA:A0000\n0A")
@@ -0,0 +1,2 @@
go test fuzz v1
string("0> > >>")
@@ -0,0 +1,2 @@
go test fuzz v1
string("0:TEXT:")
@@ -0,0 +1,2 @@
go test fuzz v1
string("0:TEXT")
+35
View File
@@ -13,6 +13,10 @@ packages := "./arch/... ./asm/... ./ast/... ./disasm/... ./format/... ./lexer/..
bindir := env_var_or_default("BINDIR", env_var("HOME") / ".local" / "bin")
# Where `install-man` puts the gzip-compressed pages (man1 below it). Exported because
# the Perl recipes read it from the environment.
export MANDIR := env_var_or_default("MANDIR", env_var("HOME") / ".local" / "share" / "man")
default:
@just --list
@@ -84,6 +88,37 @@ install: build
uninstall:
rm -f "{{bindir}}/{{binary}}"
# Install the man pages under docs/man into mandir/man1, gzip-compressed. Not a gate: a
# convenience for the person at the keyboard; man finds them through ~/.local/share/man.
install-man:
#!/usr/bin/env perl
my $out = $ENV{MANDIR} . q{/man1};
system(q{mkdir}, q{-p}, $out) == 0 or die qq{mkdir $out: $!\n};
for my $p (glob q{docs/man/*.1}) {
open(my $g, q{-|}, q{gzip}, q{-c}, $p) or die qq{gzip $p: $!\n};
my $content = do { local $/; <$g> };
close($g);
my $base = $p;
$base =~ s{docs/man/}{};
open(my $o, q{>}, qq{$out/$base.gz}) or die qq{write $out/$base.gz: $!\n};
print {$o} $content;
close($o);
print qq{$out/$base.gz\n};
}
# Remove the installed man pages.
uninstall-man:
#!/usr/bin/env perl
for my $p (glob q{docs/man/*.1}) {
my $base = $p;
$base =~ s{docs/man/}{};
my $f = $ENV{MANDIR} . q{/man1/} . $base . q{.gz};
if (-f $f) {
unlink($f) or die qq{unlink $f: $!\n};
print qq{removed $f\n};
}
}
# Run the program. The flag is there because `go run` does not stamp the build otherwise.
run:
go run -buildvcs=true {{package}}
+29 -8
View File
@@ -209,10 +209,10 @@ func lintText(t *ast.Text, tab *arch.Table, archKnown bool, cfg Config, macros m
lastTerminal := false
hasMacro := false
instrCount := 0
dead := false // inside a region unreachable from above
reportedDead := false // the current dead region has already been reported
hasPCRel := referencesPC(t) // PC-relative jumps defeat reachability analysis
hasIndirect := hasIndirectBranch(t) // register-indirect branches do too
dead := false // inside a region unreachable from above
reportedDead := false // the current dead region has already been reported
hasPCRel := referencesPC(t) // PC-relative jumps defeat reachability analysis
hasIndirect := hasIndirectBranch(t, tab) // register-indirect branches do too
// Unreachable-code analysis is only sound in functions whose control flow is
// fully label-resolvable: no PC-relative jumps, no register-indirect
// branches, and (file-level) no preprocessor conditionals.
@@ -549,10 +549,12 @@ func referencesPC(t *ast.Text) bool {
}
// hasIndirectBranch reports whether a function transfers control through a
// register (JALR/JR/JIRL/BR/BLR). Such targets are computed at runtime, so
// reachability cannot be determined statically and the unreachable-code check is
// suppressed for the whole function.
func hasIndirectBranch(t *ast.Text) bool {
// register or a computed memory address: the RISC branch-register mnemonics
// (JALR/JR/JIRL/BR/BLR), or a JMP/CALL whose target is a register or memory
// operand rather than a label or symbol. Such targets are computed at
// runtime, so reachability cannot be determined statically and the
// unreachable-code check is suppressed for the whole function.
func hasIndirectBranch(t *ast.Text, tab *arch.Table) bool {
for _, s := range t.Body {
in, ok := s.(*ast.Instr)
if !ok {
@@ -561,11 +563,30 @@ func hasIndirectBranch(t *ast.Text) bool {
switch strings.ToUpper(in.Mnemonic.Text) {
case "JALR", "JR", "JIRL", "BR", "BLR":
return true
case "JMP", "CALL":
if indirectJumpTarget(in, tab) {
return true
}
}
}
return false
}
// indirectJumpTarget reports whether the JMP/CALL operand addresses a
// register or a memory location rather than a label or a static symbol. The
// parser delivers a bare register and a bare label in the same shape, so
// register membership decides.
func indirectJumpTarget(in *ast.Instr, tab *arch.Table) bool {
if len(in.Operands) != 1 || in.Operands[0].Kind != ast.OpAddr {
return false
}
a := in.Operands[0].Addr
if a.Base != "" || a.Index != "" {
return true
}
return a.Sym != nil && a.Sym.Pseudo == "" && a.Sym.Name != "" && tab.IsRegister(a.Sym.Name)
}
// isMacroInvocation reports whether a mnemonic is a macro invocation rather
// than a machine instruction. No Plan 9 mnemonic contains an underscore, so an
// underscore is a reliable macro marker (the runtime headers define macros such
+31
View File
@@ -8,6 +8,7 @@ import (
"testing"
"sourcedock.dev/petrbalvin/gasm-devkit/arch"
"sourcedock.dev/petrbalvin/gasm-devkit/ast"
"sourcedock.dev/petrbalvin/gasm-devkit/parser"
)
@@ -495,3 +496,33 @@ TEXT ·f(SB), NOSPLIT, $0
t.Fatalf("amd64 must not be flagged: %+v", diags)
}
}
// TestHasIndirectBranchShape checks that a JMP/CALL through a register or
// memory suppresses reachability analysis, while a same-named label does not.
func TestHasIndirectBranchShape(t *testing.T) {
tab := arch.ForArch(arch.AMD64)
indirect := `TEXT ·f(SB), NOSPLIT, $0
JMP AX
RET
`
f, errs := parser.Parse("t_amd64.s", indirect)
if len(errs) > 0 {
t.Fatalf("parse: %v", errs)
}
if !hasIndirectBranch(f.Decls[0].(*ast.Text), tab) {
t.Error("JMP AX: indirect branch not detected")
}
label := `TEXT ·f(SB), NOSPLIT, $0
loop:
JMP loop
RET
`
f, errs = parser.Parse("t_amd64.s", label)
if len(errs) > 0 {
t.Fatalf("parse: %v", errs)
}
if hasIndirectBranch(f.Decls[0].(*ast.Text), tab) {
t.Error("JMP loop: label treated as an indirect branch")
}
}
+41
View File
@@ -0,0 +1,41 @@
// Copyright (c) 2026 Petr Balvín <opensource@petrbalvin.org> (https://petrbalvin.org)
// SPDX-License-Identifier: BSD-3-Clause
package parser
import (
"os"
"path/filepath"
"testing"
)
// FuzzParse hammers the parser with arbitrary input. The contract: no panic,
// and always a usable file, whether or not diagnostics were reported. The
// seed corpus carries the repository's kernels, so a plain `go test` run
// replays every seed as a regression case and CI exercises them without any
// fuzzing budget.
func FuzzParse(f *testing.F) {
for _, pattern := range []string{
"../testdata/*.s",
"../testdata/verify/*.s",
} {
files, _ := filepath.Glob(pattern)
for _, path := range files {
if b, err := os.ReadFile(path); err == nil {
f.Add(string(b))
}
}
}
f.Add("TEXT ·f(SB), NOSPLIT, $0\n\tRET\n")
f.Add("garbage ### ??? ::: \xff\xfe\n")
f.Add("#define A(x) x+1\nTEXT ·f(SB), $0\n\tA(2)\n\tRET\n")
f.Add("DATA t<>+0(SB)/8, $1\nGLOBL t<>(SB), RODATA, $8\n")
f.Add("TEXT ·f(SB), $0\n\tJMP (AX)\n\tCALL (BX)\n\tRET\n")
f.Fuzz(func(t *testing.T, src string) {
file, _ := Parse("fuzz.s", src)
if file == nil {
t.Fatal("Parse returned a nil file")
}
})
}
+47 -16
View File
@@ -438,25 +438,56 @@ func parseAddress(g []token.Token) ast.Address {
// Optional leading displacement before a '(' base group. A sign pushes
// the parenthesis one token further out: -4(DX) has it at i+2.
if isSignedNumber(g, i) {
paren := i + 1
if g[i].Kind == token.Minus || g[i].Kind == token.Plus {
paren = i + 2
j := i
neg := false
if g[j].Kind == token.Minus {
neg = true
j++
} else if g[j].Kind == token.Plus {
j++
}
if paren < len(g) && g[paren].Kind == token.LParen {
neg := false
if g[i].Kind == token.Minus {
neg = true
i++
} else if g[i].Kind == token.Plus {
i++
if j < len(g) && g[j].Kind == token.Number {
v := parseInt(g[j].Text)
j++
// A term may carry a *number factor: 0*8(base).
for j+1 < len(g) && g[j].Kind == token.Star && g[j+1].Kind == token.Number {
v *= parseInt(g[j+1].Text)
j += 2
}
if i < len(g) && g[i].Kind == token.Number {
addr.Offset = parseInt(g[i].Text)
addr.HasOff = true
if neg {
addr.Offset = -addr.Offset
if neg {
v = -v
}
// Further +/- terms, each with its optional factor:
// 3*8+8(base), 8-4*2(base).
for {
termNeg := false
if j < len(g) && g[j].Kind == token.Minus {
termNeg = true
} else if j < len(g) && g[j].Kind == token.Plus {
} else {
break
}
i++
if j+1 < len(g) && g[j+1].Kind == token.Number {
tv := parseInt(g[j+1].Text)
j += 2
for j+1 < len(g) && g[j].Kind == token.Star && g[j+1].Kind == token.Number {
tv *= parseInt(g[j+1].Text)
j += 2
}
if termNeg {
tv = -tv
}
v += tv
continue
}
break
}
// Commit only when the expression is followed by the base
// group; a bare number stays untouched for the caller.
if j < len(g) && g[j].Kind == token.LParen {
addr.Offset = v
addr.HasOff = true
i = j
}
}
}
+18
View File
@@ -0,0 +1,18 @@
// Copyright (c) 2026 Petr Balvín <opensource@petrbalvin.org> (https://petrbalvin.org)
// SPDX-License-Identifier: BSD-3-Clause
// Indirect control flow: JMP/CALL through a register or memory, byte-compared
// against go tool asm. A CALL in the body also exercises the toolchain's
// forced base-pointer frame on a frameless function.
#include "textflag.h"
// func f()
TEXT ·f(SB), NOSPLIT, $0
JMP AX
CALL AX
JMP (BX)
CALL (BX)
JMP 8(BX)
JMP R8
RET
+14
View File
@@ -0,0 +1,14 @@
// Copyright (c) 2026 Petr Balvín <opensource@petrbalvin.org> (https://petrbalvin.org)
// SPDX-License-Identifier: BSD-3-Clause
// Indirect control flow: JMP (R0) and CALL (R0) lower to BR/BLR, the only
// indirect-branch spellings the toolchain accepts (the raw BR/BLR mnemonics
// stay a gasm superset).
#include "textflag.h"
// func f()
TEXT ·f(SB), NOSPLIT, $0
JMP (R0)
CALL (R0)
RET
+14
View File
@@ -0,0 +1,14 @@
// Copyright (c) 2026 Petr Balvín <opensource@petrbalvin.org> (https://petrbalvin.org)
// SPDX-License-Identifier: BSD-3-Clause
// Indirect control flow: JMP (R4) and JAL (R5) are the toolchain's spellings
// for jirl; the raw JIRL instruction is deliberately absent, because the Go
// loong64 assembler deletes it and a parity kernel could not hold it.
#include "textflag.h"
// func f()
TEXT ·f(SB), NOSPLIT, $0-0
JMP (R4)
JAL (R5)
RET
+19
View File
@@ -0,0 +1,19 @@
// Copyright (c) 2026 Petr Balvín <opensource@petrbalvin.org> (https://petrbalvin.org)
// SPDX-License-Identifier: BSD-3-Clause
// Indirect control flow: JMP (X5) lowers to JALR X0, 0(X5), the trampoline
// form JALR rd, offset(rs1) encodes with the destination first, and a linking
// JALR through X1 is the toolchain's only indirect call.
#include "textflag.h"
// func f()
TEXT ·leaf(SB), NOSPLIT, $0-0
JMP (X5)
JALR X0, 0(X6)
RET
// func g()
TEXT ·calls(SB), NOSPLIT, $0-0
JALR X1, 0(X8)
RET
+36
View File
@@ -0,0 +1,36 @@
// Copyright (c) 2026 Petr Balvín <opensource@petrbalvin.org> (https://petrbalvin.org)
// SPDX-License-Identifier: BSD-3-Clause
// GOROOT-derived shapes: the MOV width suffixes for narrow loads and the
// branch-zero pseudos. Deliberately absent: the immediate ALU aliases
// (AND/SUB $imm) and the FP memory forms, whose RVC compression the encoder
// does not reproduce yet, so a parity kernel could not hold them.
#include "textflag.h"
// func mix(x int64, y int64) int64
TEXT ·mix(SB), NOSPLIT, $0-24
MOV x+0(FP), X5
MOVWU 0(X5), X11
MOVB 1(X5), X12
MOVBU 2(X5), X13
ADD X11, X12, X14
ADD X13, X14, X15
MOV X15, ret+16(FP)
RET
// func branchy(n int64) int64
TEXT ·branchy(SB), NOSPLIT, $0-16
MOV n+0(FP), X5
BEQZ X5, zero
BNEZ X5, one
BLTZ X5, zero
BGEZ X5, one
zero:
MOV $0, X6
MOV X6, ret+8(FP)
RET
one:
MOV $1, X6
MOV X6, ret+8(FP)
RET
+2 -2
View File
@@ -39,7 +39,7 @@ TEXT ·enterJITChecked(SB), NOSPLIT, $0-16
MOVV 0(R5), R1 // load leaveJITCheckedRaw into RA
MOVV R5, R3 // SP stays on the leave slot: the kernel
// reads its first argument at SP+8
JIRL R0, R4, 0 // jump to JIT function
JMP (R4) // jump to JIT function
// leaveJITCheckedRaw is the raw return trampoline. It has NO Go function
// declaration, so no ABIInternal wrapper is generated; the JIT function's
@@ -61,6 +61,6 @@ g_ok:
MOVV savedRA(SB), R1 // restore return address
MOVV savedG(SB), g // restore g: Go code needs it the moment it
// resumes, violation or not
JIRL R0, R1, 0 // return to Go caller
JMP (R1) // return to Go caller
GLOBL savedG(SB), NOPTR, $8
+1
View File
@@ -25,6 +25,7 @@ func TestGroundTruthARM64(t *testing.T) {
"../testdata/verify/call_arm64.s",
"../testdata/verify/bigframe_arm64.s",
"../testdata/verify/guard_arm64.s",
"../testdata/verify/indirect_arm64.s",
} {
t.Run(path, func(t *testing.T) {
src, err := os.ReadFile(path)
+1
View File
@@ -98,6 +98,7 @@ func TestGroundTruthAMD64(t *testing.T) {
"../testdata/verify/basic_amd64.s",
"../testdata/verify/bigframe_amd64.s",
"../testdata/verify/guard_amd64.s",
"../testdata/verify/indirect_amd64.s",
} {
t.Run(path, func(t *testing.T) {
f, errs := parser.Parse(path, mustRead(t, path))
+2
View File
@@ -24,6 +24,8 @@ func TestGroundTruthLOONG64(t *testing.T) {
"../testdata/verify/fp_loong64.s",
"../testdata/verify/bigframe_loong64.s",
"../testdata/verify/guard_loong64.s",
"../testdata/verify/indirect_loong64.s",
"trampoline_loong64.s",
} {
t.Run(path, func(t *testing.T) {
src, err := os.ReadFile(path)
+3
View File
@@ -27,6 +27,9 @@ func TestGroundTruthRISCV(t *testing.T) {
"../testdata/verify/call_riscv64.s",
"../testdata/verify/bigframe_riscv64.s",
"../testdata/verify/guard_riscv64.s",
"../testdata/verify/indirect_riscv64.s",
"../testdata/verify/misc_riscv64.s",
"trampoline_riscv64.s",
} {
t.Run(path, func(t *testing.T) {
testGroundTruthRISCVFile(t, path)
+3 -3
View File
@@ -6,7 +6,7 @@
// ABI0 JIT trampoline for LoongArch 64.
//
// enterJIT saves Go SP and RA (R1), switches to the prepared stack, and
// jumps to the JIT function. When the function RETs (JIRL zero, ra, 0),
// jumps to the JIT function. When the function RETs (JMP (R1), the toolchain's spelling for jirl zero, ra, 0),
// control lands in leaveJIT.
// func enterJIT(fn uintptr, stack uintptr)
@@ -19,14 +19,14 @@ TEXT ·enterJIT(SB), NOSPLIT, $0-16
MOVV R5, R3 // switch to the prepared stack: SP stays on
// the leave slot, so the kernel reads its
// first argument at SP+8
JIRL R0, R4, 0 // jump to JIT function
JMP (R4) // jump to JIT function
// func leaveJIT()
TEXT ·leaveJIT(SB), NOSPLIT, $0-0
MOVV savedSP(SB), R5 // restore Go stack pointer
MOVV R5, R3 // restore SP
MOVV savedRA(SB), R1 // restore return address
JIRL R0, R1, 0 // return to Go caller
JMP (R1) // return to Go caller
GLOBL savedRA(SB), NOPTR, $8