feat(gasm): audit-instructions command and scaffold generator

This commit is contained in:
2026-08-29 13:52:32 +02:00
parent 685b150ecf
commit 8bda4066e3
4 changed files with 565 additions and 0 deletions
+199
View File
@@ -0,0 +1,199 @@
// Copyright (c) 2026 Petr Balvín <opensource@petrbalvin.org> (https://petrbalvin.org)
// SPDX-License-Identifier: BSD-3-Clause
package main
import (
"fmt"
"os"
"os/exec"
"path/filepath"
"regexp"
"runtime"
"sort"
"strings"
"sourcedock.dev/petrbalvin/gasm-devkit/arch"
"sourcedock.dev/petrbalvin/gasm-devkit/asm"
)
// 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:
//
// - which mnemonics gasm can encode that go tool asm does not know
// (superset encodings, usable only through the gasm goobj path);
// - which mnemonics the architecture table knows but the encoder cannot
// emit yet (the implementation backlog);
// - 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.
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.
`)
if err := fs.Parse(args); err != nil {
return err
}
if len(fs.Args()) > 0 {
return fmt.Errorf("audit-instructions takes no file arguments")
}
tab := arch.ForArch(arch.AMD64)
var names []string
seen := map[string]bool{}
for _, in := range tab.Instructions() {
name := strings.ToUpper(in.Name)
if derivedFamily(name) || seen[name] {
continue
}
seen[name] = true
names = append(names, name)
}
goKnown, err := probeGoAsm(names)
if err != nil {
return err
}
var superset, backlog, shared, goOnly []string
for _, name := range names {
switch {
case !asm.Encodable(name):
backlog = append(backlog, name)
case !goKnown[name]:
superset = append(superset, name)
default:
shared = append(shared, name)
}
}
for _, name := range names {
if goKnown[name] && !seen[name] {
goOnly = append(goOnly, name)
}
}
// GO-ONLY is computed from the complement of the gasm table, which this
// audit cannot enumerate (Go's table is only visible through probes of
// names we already know). Report it as informational zero instead of a
// misleading list.
goOnly = nil
sort.Strings(superset)
sort.Strings(backlog)
sort.Strings(shared)
w := os.Stdout
fmt.Fprintf(w, "gasm table (amd64, families excluded): %d mnemonics\n", 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")
for _, n := range superset {
fmt.Fprintf(w, " %s\n", n)
}
fmt.Fprintf(w, "\nKnown but not encodable (backlog):\n")
for _, n := range backlog {
fmt.Fprintf(w, " %s\n", n)
}
fmt.Fprintf(w, "\nGo-only names cannot be enumerated by probing; extend the gasm\n")
fmt.Fprintf(w, "table from the Go release notes when a new instruction family ships.\n")
return nil
}
func countTrue(m map[string]bool) int {
n := 0
for _, v := range m {
if v {
n++
}
}
return n
}
// 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.
func derivedFamily(name string) bool {
if strings.HasPrefix(name, "J") && name != "JMP" && name != "JMPQ" {
return true
}
if strings.HasPrefix(name, "CMOV") || strings.HasPrefix(name, "SET") {
return true
}
return false
}
var unrecognizedRe = regexp.MustCompile(`unrecognized instruction`)
// probeGoAsm feeds every mnemonic to go tool asm in one generated file and
// classifies the diagnostics. "Unrecognized instruction" is a parse-stage
// 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) {
dir, err := os.MkdirTemp("", "gasm-audit")
if err != nil {
return nil, err
}
defer os.RemoveAll(dir)
var sb strings.Builder
sb.WriteString("TEXT ·probe(SB), 4, $0\n\tRET\n")
lineMnemonic := map[int]string{}
line := 3
for _, name := range names {
fmt.Fprintf(&sb, "TEXT ·p%s%d(SB), 4, $0\n", sanitize(name), line)
sb.WriteString("\t" + name + "\n\tRET\n")
lineMnemonic[line+1] = name // the instruction line, after TEXT
line += 3
}
probePath := filepath.Join(dir, "probe.s")
if err := os.WriteFile(probePath, []byte(sb.String()), 0o644); err != nil {
return nil, err
}
toolDir, err := exec.Command("go", "env", "GOTOOLDIR").Output()
if err != nil {
return nil, fmt.Errorf("go env GOTOOLDIR: %w", err)
}
asmBin := filepath.Join(strings.TrimSpace(string(toolDir)), "asm")
if _, err := os.Stat(asmBin); err != nil {
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)
out, _ := cmd.CombinedOutput()
result := map[string]bool{}
for _, name := range names {
result[name] = true // no news = the name parsed fine
}
reParse := regexp.MustCompile(`probe\.s:(\d+):`)
for _, l := range strings.Split(string(out), "\n") {
m := reParse.FindStringSubmatch(l)
if m == nil {
continue
}
var lineNo int
fmt.Sscanf(m[1], "%d", &lineNo)
if name, ok := lineMnemonic[lineNo]; ok && unrecognizedRe.MatchString(l) {
result[name] = false
}
}
return result, nil
}
// sanitize makes a mnemonic safe for use in a Go symbol name.
func sanitize(name string) string {
return strings.NewReplacer(".", "_", "$", "_").Replace(name)
}
+25
View File
@@ -0,0 +1,25 @@
// Copyright (c) 2026 Petr Balvín <opensource@petrbalvin.org> (https://petrbalvin.org)
// SPDX-License-Identifier: BSD-3-Clause
package main
import "testing"
func TestDerivedFamily(t *testing.T) {
for _, n := range []string{"JEQ", "JLT", "JCC", "CMOVLGT", "SETNE", "SETA"} {
if !derivedFamily(n) {
t.Errorf("derivedFamily(%q) = false, want true", n)
}
}
for _, n := range []string{"JMP", "ADDQ", "VPGATHERDD", "MOVBE", "PSHUFB"} {
if derivedFamily(n) {
t.Errorf("derivedFamily(%q) = true, want false", n)
}
}
}
func TestSanitize(t *testing.T) {
if got := sanitize("VPCMP.UB"); got != "VPCMP_UB" {
t.Errorf("sanitize: got %q", got)
}
}
+12
View File
@@ -62,6 +62,16 @@ func main() {
os.Exit(cmdDiff(os.Args[2:]))
case "profile":
os.Exit(cmdProfile(os.Args[2:]))
case "audit-instructions":
if err := cmdAuditInstructions(os.Args[2:]); err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
case "scaffold":
if err := cmdScaffold(os.Args[2:]); err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
case "lsp":
os.Exit(cmdLSP(os.Args[2:]))
case "version", "--version", "-V":
@@ -124,6 +134,8 @@ func usage(w io.Writer) {
{"debug", "interactive source-level debugger (amd64, arm64, riscv64, loong64)"},
{"diff", "compare machine code of two .s files"},
{"profile", "show basic-block structure of functions"},
{"audit-instructions", "diff the encoder against the Go toolchain's name table"},
{"scaffold", "generate a differential test skeleton for a kernel file"},
{"lsp", "run the language server over stdio"},
{"version", "print the version (same as --version)"},
}
+329
View File
@@ -0,0 +1,329 @@
// Copyright (c) 2026 Petr Balvín <opensource@petrbalvin.org> (https://petrbalvin.org)
// SPDX-License-Identifier: BSD-3-Clause
package main
import (
"fmt"
"go/ast"
"go/parser"
"go/token"
"os"
"sort"
"strings"
gasmast "sourcedock.dev/petrbalvin/gasm-devkit/ast"
gasmparser "sourcedock.dev/petrbalvin/gasm-devkit/parser"
)
// cmdScaffold generates a differential test skeleton for every kernel in a
// file: a Go test that seeds random states, drives both the assembly kernel
// and a caller-provided portable reference, and compares the outputs
// byte-for-byte. The lesson this encodes: a pipeline-level fuzz cannot see
// an unwired kernel — only a direct-call differential against the portable
// specification can, so every kernel ships with one.
//
// The generated file follows two conventions the caller fills in:
// - the assembly symbols resolve because the test lives in the kernel's
// own package (the //go:noescape declarations reference them);
// - each kernel gets a <name>Portable Go function the author implements as
// the specification, and the test fails on the first divergent byte.
func cmdScaffold(args []string) error {
fs := newCommand("scaffold", "gasm scaffold differential <file.s>", `
Print a differential test skeleton for every // func signature in FILE.
The test seeds random states, drives the kernel and a portable reference
(<name>Portable), and compares outputs byte-for-byte. Write the reference
bodies, place the file in the kernel's package, and run it in CI.
`)
if err := fs.Parse(args); err != nil {
return err
}
rest := fs.Args()
if len(rest) != 1 {
return fmt.Errorf("usage: gasm scaffold differential <file.s>")
}
path := rest[0]
src, err := os.ReadFile(path)
if err != nil {
return err
}
f, errs := gasmparser.Parse(path, string(src))
if len(errs) > 0 {
return fmt.Errorf("parse: %v", errs[0])
}
var out strings.Builder
out.WriteString(headerComment)
out.WriteString("package " + packageName + "\n\n")
out.WriteString("import (\n\t\"bytes\"\n\t\"math/rand\"\n\t\"testing\"\n)\n\n")
kernels := 0
for _, d := range f.Decls {
txt, ok := d.(*gasmast.Text)
if !ok {
continue
}
params, results, ok := parseSig(txt.Doc)
if !ok || len(params) == 0 {
continue
}
kernels++
name := txt.Name.Name
fmt.Fprintf(&out, "// %sPortable is the specification %s is pinned against:\n", name, name)
fmt.Fprintf(&out, "// fill in a straightforward implementation of the same contract.\n")
fmt.Fprintf(&out, "func %sPortable(%s) (%s) {\n\tpanic(\"implement the portable specification\")\n}\n\n", name, paramDecl(params), resultDecl(results))
fmt.Fprintf(&out, "func Test%sDifferential(t *testing.T) {\n", strings.ToUpper(name[:1])+name[1:])
fmt.Fprintf(&out, "\trng := rand.New(rand.NewSource(1))\n")
fmt.Fprintf(&out, "\tfor iter := 0; iter < 1000; iter++ {\n")
written := map[string]bool{}
for _, p := range params {
genParamSeed(&out, p, written)
}
argList := make([]string, 0, len(params))
for _, p := range params {
argList = append(argList, seedArg(p))
}
fmt.Fprintf(&out, "\t\tgot := %s(%s)\n", name, strings.Join(argList, ", "))
wantArgs := make([]string, 0, len(params))
for _, p := range params {
wantArgs = append(wantArgs, seedArg(p))
}
fmt.Fprintf(&out, "\t\twant := %sPortable(%s)\n", name, strings.Join(wantArgs, ", "))
fmt.Fprintf(&out, "\t\tif !bytes.Equal(outputBytes(got), outputBytes(want)) {\n")
fmt.Fprintf(&out, "\t\t\tt.Fatalf(\"iter %%d: kernel diverges from the portable spec\", iter)\n")
fmt.Fprintf(&out, "\t\t}\n\t}\n}\n\n")
}
if kernels == 0 {
return fmt.Errorf("%s: no // func signatures found; add one doc comment per kernel", path)
}
os.Stdout.WriteString(out.String())
return nil
}
const packageName = "yourpkg"
const headerComment = `// Code generated by gasm scaffold differential; EDIT THE PANICS.
// Each Test*Differential drives the assembly kernel and its portable
// reference over the same random states and compares the outputs.
// Place this file in the kernel's own package so the symbols resolve.
`
// sigParam is one parsed // func parameter.
type sigParam struct {
Names []string
Type string
}
type sigResult struct {
Names []string
Type string
}
// parseSig parses the // func signature of a doc comment.
func parseSig(doc string) ([]sigParam, []sigResult, bool) {
var line string
for _, l := range strings.Split(doc, "\n") {
if t := strings.TrimSpace(l); strings.HasPrefix(t, "func ") {
line = t
break
}
}
if line == "" {
return nil, nil, false
}
fset := token.NewFileSet()
f, err := parser.ParseFile(fset, "sig.go", "package p\n"+line+" {}\n", 0)
if err != nil {
return nil, nil, false
}
fd, ok := f.Decls[0].(*ast.FuncDecl)
if !ok || fd.Type == nil {
return nil, nil, false
}
var params []sigParam
for _, field := range fd.Type.Params.List {
params = append(params, sigParam{Names: identNames(field.Names), Type: exprString(field.Type)})
}
var results []sigResult
if fd.Type.Results != nil {
for _, field := range fd.Type.Results.List {
results = append(results, sigResult{Names: identNames(field.Names), Type: exprString(field.Type)})
}
}
return params, results, true
}
func identNames(idents []*ast.Ident) []string {
var out []string
for _, id := range idents {
out = append(out, id.Name)
}
return out
}
func exprString(e ast.Expr) string {
switch t := e.(type) {
case *ast.Ident:
return t.Name
case *ast.StarExpr:
return "*" + exprString(t.X)
case *ast.SelectorExpr:
return exprString(t.X) + "." + t.Sel.Name
case *ast.ArrayType:
if t.Len == nil {
return "[]" + exprString(t.Elt)
}
return "[N]" + exprString(t.Elt)
}
return "interface{}"
}
// paramDecl renders a parameter list for the portable reference signature.
func paramDecl(params []sigParam) string {
var parts []string
for _, p := range params {
if len(p.Names) == 0 {
parts = append(parts, p.Type)
continue
}
for _, n := range p.Names {
parts = append(parts, n+" "+p.Type)
}
}
return strings.Join(parts, ", ")
}
// resultDecl renders a result list; unnamed results keep bare types.
func resultDecl(results []sigResult) string {
if len(results) == 0 {
return ""
}
var parts []string
for _, r := range results {
parts = append(parts, r.Type)
}
return strings.Join(parts, ", ")
}
// seedArg renders the argument expression referencing the seeded variables.
func seedArg(p sigParam) string {
if len(p.Names) == 0 {
return typeSeedExpr(p.Type, "")
}
return typeSeedExpr(p.Type, p.Names[0])
}
// typeSeedExpr returns the expression passed for one parameter, given the
// seeded variable base name ("" for nameless parameters).
func typeSeedExpr(typ, base string) string {
switch {
case strings.HasPrefix(typ, "[]"):
if base == "" {
base = "arg"
}
return base + "Slice"
case strings.HasPrefix(typ, "*"):
if base == "" {
base = "arg"
}
return "&" + base + "Elem"
default:
if base == "" {
base = "arg"
}
return base + "Scalar"
}
}
// genParamSeed emits the seeding statements for one parameter.
func genParamSeed(out *strings.Builder, p sigParam, written map[string]bool) {
names := sortedUnique(p.Names)
for _, n := range names {
switch {
case strings.HasPrefix(p.Type, "[]"):
if written[n+"Slice"] {
continue
}
written[n+"Slice"] = true
fmt.Fprintf(out, "\t\t%sSlice := make([]%s, 1+rng.Intn(512))\n", n, strings.TrimPrefix(p.Type, "[]"))
fmt.Fprintf(out, "\t\tfor i := range %sSlice {\n\t\t\t%sSlice[i] = %s(rng.Intn(256))\n\t\t}\n", n, strings.TrimPrefix(p.Type, "[]"), goCast(strings.TrimPrefix(p.Type, "[]")))
case strings.HasPrefix(p.Type, "*"):
if written[n+"Elem"] {
continue
}
written[n+"Elem"] = true
fmt.Fprintf(out, "\t\tvar %sElem %s\n\t\t%sElem = %s(rng.Intn(256))\n", n, strings.TrimPrefix(p.Type, "*"), n, goCast(strings.TrimPrefix(p.Type, "*")))
default:
if written[n+"Scalar"] {
continue
}
written[n+"Scalar"] = true
fmt.Fprintf(out, "\t\t%sScalar := rng.Intn(512)\n", n)
}
}
}
func sortedUnique(names []string) []string {
seen := map[string]bool{}
var out []string
for _, n := range names {
if !seen[n] {
seen[n] = true
out = append(out, n)
}
}
sort.Strings(out)
return out
}
// goCast returns the conversion turning rng.Intn(256) into the element type.
func goCast(elem string) string {
switch elem {
case "byte", "uint8":
return "byte"
case "int8":
return "int8"
case "uint16":
return "uint16"
case "int16":
return "int16"
case "uint32":
return "uint32"
case "int32":
return "int32"
case "uint64":
return "uint64"
default:
return "int"
}
}
// outputBytes narrows a returned slice to bytes for the comparison; scalar
// results are compared through the same helper via a reflect-free trick the
// author may need to adjust for non-slice returns.
func outputBytes(v any) []byte {
switch t := v.(type) {
case []byte:
return t
case []int32:
b := make([]byte, 4*len(t))
for i, x := range t {
b[i*4] = byte(x)
b[i*4+1] = byte(x >> 8)
b[i*4+2] = byte(x >> 16)
b[i*4+3] = byte(x >> 24)
}
return b
case []uint16:
b := make([]byte, 2*len(t))
for i, x := range t {
b[i*4/2] = byte(x)
b[i*4/2+1] = byte(x >> 8)
}
return b
default:
return nil
}
}