From 57c0ca8b09e5aa47f4838135d3e86ce39624a5a0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Petr=20Balv=C3=ADn?= Date: Sun, 30 Aug 2026 21:16:44 +0200 Subject: [PATCH] feat(gasm): audit-instructions for arm64, riscv64 and loong64 Assisted-by: GLM 5.3 Flash --- CHANGELOG.md | 10 ++- cmd/gasm/audit.go | 163 ++++++++++++++++++++++++++++++++++------- cmd/gasm/audit_test.go | 43 ++++++++++- docs/CLI.md | 18 +++-- 4 files changed, 197 insertions(+), 37 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e2fc081..bcb85e2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -43,10 +43,12 @@ Unreleased changes on the `development` branch. - **`gasm verify --args`.** Scalar arguments (`name=value`, decimal or `0x` hex) can now be supplied to a `--call` invocation alongside `--buf` buffers, closing the gap where only buffers could be supplied. -- **`gasm audit-instructions`.** Black-box diff of the amd64 encoder - against the installed `go tool asm`: superset encodings (gasm-only, - shippable via `gasm asm --format goobj`), known-but-unencodable names - (the backlog) and go-only names (feature gaps). +- **`gasm audit-instructions`.** Black-box diff of a gasm encoder + against the installed `go tool asm`, for amd64, arm64, riscv64 and + loong64 (`gasm audit-instructions `): superset encodings + (gasm-only, shippable via `gasm asm --format goobj`), + known-but-unencodable names (the backlog) and go-only names + (feature gaps). - **`gasm scaffold differential`.** Prints a differential test skeleton for every `// func` signature in a kernel file: random seed states, the kernel call and a portable reference (`Portable`), compared diff --git a/cmd/gasm/audit.go b/cmd/gasm/audit.go index 7a77407..d78b0bb 100644 --- a/cmd/gasm/audit.go +++ b/cmd/gasm/audit.go @@ -16,15 +16,15 @@ import ( "sourcedock.dev/petrbalvin/gasm-devkit/arch" "sourcedock.dev/petrbalvin/gasm-devkit/asm" + "sourcedock.dev/petrbalvin/gasm-devkit/parser" ) -// cmdAuditInstructions cross-checks the gasm amd64 encoder against the Go -// toolchain's own assembler, probed black-box: every mnemonic in the gasm -// table is offered to go tool asm in a battery of representative operand -// shapes, and a mnemonic counts as known to Go when at least one shape -// produces an error other than "unrecognized instruction" (a wrong-shape -// error still proves the mnemonic exists in Go's tables). The audit -// answers three questions at a glance: +// cmdAuditInstructions cross-checks a gasm encoder against the Go toolchain's +// own assembler, probed black-box: every mnemonic in the gasm table is offered +// to go tool asm in its bare form, and a mnemonic counts as known to Go when +// the error is anything but "unrecognized instruction" (a wrong-shape error +// still proves the mnemonic exists in Go's tables). The audit answers three +// questions at a glance: // // - which mnemonics gasm can encode that go tool asm does not know // (superset encodings, usable only through the gasm goobj path); @@ -33,36 +33,46 @@ import ( // - which mnemonics go tool asm knows that gasm cannot encode (feature // gaps). // -// Derived families (Jcc, CMOVcc, SETcc) exist on both sides by construction -// and are excluded from the diff. +// The amd64 derived families (Jcc, CMOVcc, SETcc) exist on both sides by +// 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", ` -Compare the gasm amd64 encoder 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 operand shapes per -mnemonic, so the audit tracks whatever toolchain `+"`go env GOROOT`"+` names. + fs := newCommand("audit-instructions", "gasm audit-instructions [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. `) if err := fs.Parse(args); err != nil { return err } - if len(fs.Args()) > 0 { - return fmt.Errorf("audit-instructions takes no file arguments") + archName := "amd64" + switch n := len(fs.Args()); { + case n > 1: + return fmt.Errorf("audit-instructions takes at most one architecture argument") + case n == 1: + archName = strings.ToLower(fs.Arg(0)) + } + a, err := auditArch(archName) + if err != nil { + return err } - tab := arch.ForArch(arch.AMD64) + tab := arch.ForArch(a) var names []string seen := map[string]bool{} for _, in := range tab.Instructions() { name := strings.ToUpper(in.Name) - if derivedFamily(name) || seen[name] { + if a == arch.AMD64 && derivedFamily(name) || seen[name] { continue } seen[name] = true names = append(names, name) } - goKnown, err := probeGoAsm(names) + goKnown, err := probeGoAsm(goarchName(a), names) if err != nil { return err } @@ -70,7 +80,7 @@ mnemonic, so the audit tracks whatever toolchain `+"`go env GOROOT`"+` names. var superset, backlog, shared []string for _, name := range names { switch { - case !asm.Encodable(name): + case !gasmEncodable(a, name): backlog = append(backlog, name) case !goKnown[name]: superset = append(superset, name) @@ -86,7 +96,7 @@ mnemonic, so the audit tracks whatever toolchain `+"`go env GOROOT`"+` names. slices.Sort(shared) w := os.Stdout - fmt.Fprintf(w, "gasm table (amd64, families excluded): %d mnemonics\n", len(names)) + fmt.Fprintf(w, "gasm table (%s, families excluded): %d mnemonics\n", archName, len(names)) fmt.Fprintf(w, "gasm encodable: %d go tool asm recognized: %d\n", len(shared)+len(superset), countTrue(goKnown)) fmt.Fprintf(w, "shared: %d\n", len(shared)) fmt.Fprintf(w, "\nSuperset encodings (gasm-only; ship via gasm asm --format goobj):\n") @@ -102,6 +112,34 @@ mnemonic, so the audit tracks whatever toolchain `+"`go env GOROOT`"+` names. return nil } +// auditArch resolves the audit's architecture argument. +func auditArch(name string) (arch.Arch, error) { + switch strings.ToLower(name) { + case "amd64": + return arch.AMD64, nil + case "arm64": + return arch.ARM64, nil + case "riscv64", "riscv": + return arch.RISCV, nil + case "loong64", "loong": + return arch.LOONG64, nil + } + return arch.Unknown, fmt.Errorf("unknown architecture %q: want amd64, arm64, riscv64 or loong64", name) +} + +// goarchName maps an arch identifier onto its GOARCH spelling. +func goarchName(a arch.Arch) string { + switch a { + case arch.ARM64: + return "arm64" + case arch.RISCV: + return "riscv64" + case arch.LOONG64: + return "loong64" + } + return "amd64" +} + func countTrue(m map[string]bool) int { n := 0 for _, v := range m { @@ -115,7 +153,8 @@ func countTrue(m map[string]bool) int { // derivedFamily reports whether a mnemonic belongs to a family both // assemblers construct from condition codes rather than list exhaustively // (JEQ/CMOVLGT/SETNE and friends). Such names never probe cleanly, so -// including them in the diff would be noise. +// including them in the diff would be noise. amd64 only: the other +// architectures list their conditional branches outright. func derivedFamily(name string) bool { if strings.HasPrefix(name, "J") && name != "JMP" && name != "JMPQ" { return true @@ -133,7 +172,7 @@ var unrecognizedRe = regexp.MustCompile(`unrecognized instruction`) // verdict on the mnemonic alone, so a single bare-instruction probe per // mnemonic decides recognition; the combined file still reports every line's // error even when others fail. -func probeGoAsm(names []string) (map[string]bool, error) { +func probeGoAsm(goarch string, names []string) (map[string]bool, error) { dir, err := os.MkdirTemp("", "gasm-audit") if err != nil { return nil, err @@ -164,7 +203,7 @@ func probeGoAsm(names []string) (map[string]bool, error) { return nil, fmt.Errorf("go tool asm not found at %s", asmBin) } cmd := exec.Command(asmBin, "-p", "probe", "-o", filepath.Join(dir, "probe.o"), probePath) - cmd.Env = append(os.Environ(), "GOARCH="+runtime.GOARCH, "GOOS="+runtime.GOOS) + cmd.Env = append(os.Environ(), "GOARCH="+goarch, "GOOS="+runtime.GOOS) out, _ := cmd.CombinedOutput() result := map[string]bool{} @@ -188,6 +227,80 @@ func probeGoAsm(names []string) (map[string]bool, error) { return result, nil } +// probeShapes lists representative operand shapes for the encodability +// probe. The assemblers report an unknown mnemonic and a known mnemonic +// with no supported form alike ("unsupported instruction"), so only +// a shape that assembles cleanly counts, and the backlog over-approximates: +// a name whose real forms the battery misses lands there. amd64 keeps its +// exact table-driven check. +func probeShapes(a arch.Arch) []string { + switch a { + case arch.ARM64: + return []string{ + "X0, X1, X2", "X0, X1", "X0", "$1, X0", "X0, (X1)", "(X0), X1", + "X0, (X1, 8)", "(SP), X0", "F0, F1, F2", "F0, F1", "F0", + "V0.B16, V1.B16, V2.B16", "p2", "X0, p2", "X0, X1, p2", + // The conditional select family spells the condition first + // and takes R register spellings. + "EQ, R0, R1, R2", "EQ, R0, R1", "EQ, R0", + "GE, F0, F1, F2", "NE, F0, F1, $0", + } + case arch.RISCV: + return []string{ + "X5, X6, X7", "X5, X6", "X5", "$1, X5", "X5, (X6)", "$1, X5, X6", + "(X5), X6", "F0, F1, F2", "F0, F1", "p2", "X1, p2", "X0, p2", + "X5, X6, p2", "p2(SB)", + } + case arch.LOONG64: + return []string{ + "R4, R5, R6", "R4, R5", "R4", "$1, R4", "R4, (R5)", "(R4), R5", + "F0, F1, F2", "F0, F1", "p2", "R1, p2", "R4, p2", + "$1, R4, R5, R6", "$65536, R4", "R4, R5, p2", "p2(SB)", + } + } + return nil +} + +// gasmEncodable reports whether the gasm encoder for a can emit the +// mnemonic, decided by trial assembly over the shape battery. +func gasmEncodable(a arch.Arch, name string) bool { + switch a { + case arch.ARM64, arch.RISCV, arch.LOONG64: + default: + return asm.Encodable(name) + } + for _, shape := range probeShapes(a) { + if gasmAssembles(a, name, shape) { + return true + } + } + return false +} + +// gasmAssembles reports whether a one-instruction probe file containing name +// with the given operand shape assembles without error. +func gasmAssembles(a arch.Arch, name, shape string) bool { + src := "TEXT ·p(SB), NOSPLIT, $0\n\t" + name + if shape != "" { + src += " " + shape + } + src += "\n\tRET\np2:\n\tRET\n" + f, errs := parser.Parse("probe.s", src) + if len(errs) > 0 { + return false + } + var err error + switch a { + case arch.ARM64: + _, err = asm.AssembleFileARM64(f) + case arch.RISCV: + _, err = asm.AssembleFileRISCV(f) + case arch.LOONG64: + _, err = asm.AssembleFileLOONG64(f) + } + return err == nil +} + // sanitize makes a mnemonic safe for use in a Go symbol name. func sanitize(name string) string { return strings.NewReplacer(".", "_", "$", "_").Replace(name) diff --git a/cmd/gasm/audit_test.go b/cmd/gasm/audit_test.go index 39e4909..99d1426 100644 --- a/cmd/gasm/audit_test.go +++ b/cmd/gasm/audit_test.go @@ -3,7 +3,11 @@ package main -import "testing" +import ( + "testing" + + "sourcedock.dev/petrbalvin/gasm-devkit/arch" +) func TestDerivedFamily(t *testing.T) { for _, n := range []string{"JEQ", "JLT", "JCC", "CMOVLGT", "SETNE", "SETA"} { @@ -23,3 +27,40 @@ func TestSanitize(t *testing.T) { t.Errorf("sanitize: got %q", got) } } + +func TestAuditArch(t *testing.T) { + for in, want := range map[string]arch.Arch{ + "amd64": arch.AMD64, "arm64": arch.ARM64, + "riscv64": arch.RISCV, "riscv": arch.RISCV, + "loong64": arch.LOONG64, "LOONG": arch.LOONG64, + } { + got, err := auditArch(in) + if err != nil || got != want { + t.Errorf("auditArch(%q) = %v, %v; want %v", in, got, err, want) + } + } + if _, err := auditArch("mips"); err == nil { + t.Error("auditArch(mips) must fail") + } +} + +func TestGasmEncodable(t *testing.T) { + cases := []struct { + a arch.Arch + yes string + no string + }{ + {arch.AMD64, "ADDQ", "NOSUCHMNEMONIC"}, + {arch.ARM64, "ADD", "NOSUCHMNEMONIC"}, + {arch.RISCV, "ADD", "NOSUCHMNEMONIC"}, + {arch.LOONG64, "ADDV", "NOSUCHMNEMONIC"}, + } + for _, c := range cases { + if !gasmEncodable(c.a, c.yes) { + t.Errorf("%s: %s should be encodable", c.a, c.yes) + } + if gasmEncodable(c.a, c.no) { + t.Errorf("%s: %s should not be encodable", c.a, c.no) + } + } +} diff --git a/docs/CLI.md b/docs/CLI.md index f0c240a..e918406 100644 --- a/docs/CLI.md +++ b/docs/CLI.md @@ -154,14 +154,18 @@ each function's labels, their offsets, and the block boundaries. This is the static structure; for runtime execution counts, use `gasm verify --fuzz` which exercises the code paths. -## `gasm audit-instructions` +## `gasm audit-instructions [amd64|arm64|riscv64|loong64]` -Compare the gasm amd64 encoder against the installed `go tool asm` and -print the diff: superset encodings (gasm-only spellings, shippable via -`gasm asm --format goobj`), known-but-unencodable names (the encoder -backlog) and go-only names (feature gaps). The Go side is probed -black-box with a battery of operand shapes per mnemonic, so the audit -tracks whatever toolchain `go env GOROOT` provides. +Compare the gasm encoder for the given architecture (default amd64) +against the installed `go tool asm` and print the diff: superset +encodings (gasm-only spellings, shippable via `gasm asm --format goobj`), +known-but-unencodable names (the encoder backlog) and go-only names +(feature gaps). The Go side is probed black-box with a battery of operand +shapes per mnemonic, so the audit tracks whatever toolchain +`go env GOROOT` provides. On non-amd64 architectures the backlog is an +over-approximation: a name counts as encodable only when a probe shape +assembles cleanly, so a name whose real forms the battery misses lands +in the backlog. ## `gasm scaffold differential `