feat(gasm): audit-instructions for arm64, riscv64 and loong64

Assisted-by: GLM 5.3 Flash
This commit is contained in:
2026-08-30 21:42:12 +02:00
parent 75bd83fd52
commit 57c0ca8b09
4 changed files with 197 additions and 37 deletions
+6 -4
View File
@@ -43,10 +43,12 @@ Unreleased changes on the `development` branch.
- **`gasm verify --args`.** Scalar arguments (`name=value`, decimal or - **`gasm verify --args`.** Scalar arguments (`name=value`, decimal or
`0x` hex) can now be supplied to a `--call` invocation alongside `--buf` `0x` hex) can now be supplied to a `--call` invocation alongside `--buf`
buffers, closing the gap where only buffers could be supplied. buffers, closing the gap where only buffers could be supplied.
- **`gasm audit-instructions`.** Black-box diff of the amd64 encoder - **`gasm audit-instructions`.** Black-box diff of a gasm encoder
against the installed `go tool asm`: superset encodings (gasm-only, against the installed `go tool asm`, for amd64, arm64, riscv64 and
shippable via `gasm asm --format goobj`), known-but-unencodable names loong64 (`gasm audit-instructions <arch>`): superset encodings
(the backlog) and go-only names (feature gaps). (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 - **`gasm scaffold differential`.** Prints a differential test skeleton
for every `// func` signature in a kernel file: random seed states, the for every `// func` signature in a kernel file: random seed states, the
kernel call and a portable reference (`<name>Portable`), compared kernel call and a portable reference (`<name>Portable`), compared
+138 -25
View File
@@ -16,15 +16,15 @@ import (
"sourcedock.dev/petrbalvin/gasm-devkit/arch" "sourcedock.dev/petrbalvin/gasm-devkit/arch"
"sourcedock.dev/petrbalvin/gasm-devkit/asm" "sourcedock.dev/petrbalvin/gasm-devkit/asm"
"sourcedock.dev/petrbalvin/gasm-devkit/parser"
) )
// cmdAuditInstructions cross-checks the gasm amd64 encoder against the Go // cmdAuditInstructions cross-checks a gasm encoder against the Go toolchain's
// toolchain's own assembler, probed black-box: every mnemonic in the gasm // own assembler, probed black-box: every mnemonic in the gasm table is offered
// table is offered to go tool asm in a battery of representative operand // to go tool asm in its bare form, and a mnemonic counts as known to Go when
// shapes, and a mnemonic counts as known to Go when at least one shape // the error is anything but "unrecognized instruction" (a wrong-shape error
// produces an error other than "unrecognized instruction" (a wrong-shape // still proves the mnemonic exists in Go's tables). The audit answers three
// error still proves the mnemonic exists in Go's tables). The audit // questions at a glance:
// answers three questions at a glance:
// //
// - which mnemonics gasm can encode that go tool asm does not know // - which mnemonics gasm can encode that go tool asm does not know
// (superset encodings, usable only through the gasm goobj path); // (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 // - which mnemonics go tool asm knows that gasm cannot encode (feature
// gaps). // gaps).
// //
// Derived families (Jcc, CMOVcc, SETcc) exist on both sides by construction // The amd64 derived families (Jcc, CMOVcc, SETcc) exist on both sides by
// and are excluded from the diff. // construction and are excluded from the diff; the other architectures list
// their conditional branches outright.
func cmdAuditInstructions(args []string) error { func cmdAuditInstructions(args []string) error {
fs := newCommand("audit-instructions", "gasm audit-instructions", ` fs := newCommand("audit-instructions", "gasm audit-instructions [amd64|arm64|riscv64|loong64]", `
Compare the gasm amd64 encoder against go tool asm and print the diff: Compare the gasm encoder for the given architecture (default amd64) against
superset encodings (gasm-only, shippable via gasm asm --format goobj), go tool asm and print the diff: superset encodings (gasm-only, shippable via
known-but-unencodable names (the backlog) and go-only names (feature gaps). gasm asm --format goobj), known-but-unencodable names (the backlog) and go-
The Go side is probed black-box with a battery of operand shapes per only names (feature gaps). The Go side is probed black-box with a battery
mnemonic, so the audit tracks whatever toolchain `+"`go env GOROOT`"+` names. of bare mnemonics, so the audit tracks whatever toolchain `+"`go env GOROOT`"+`
provides.
`) `)
if err := fs.Parse(args); err != nil { if err := fs.Parse(args); err != nil {
return err return err
} }
if len(fs.Args()) > 0 { archName := "amd64"
return fmt.Errorf("audit-instructions takes no file arguments") 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 var names []string
seen := map[string]bool{} seen := map[string]bool{}
for _, in := range tab.Instructions() { for _, in := range tab.Instructions() {
name := strings.ToUpper(in.Name) name := strings.ToUpper(in.Name)
if derivedFamily(name) || seen[name] { if a == arch.AMD64 && derivedFamily(name) || seen[name] {
continue continue
} }
seen[name] = true seen[name] = true
names = append(names, name) names = append(names, name)
} }
goKnown, err := probeGoAsm(names) goKnown, err := probeGoAsm(goarchName(a), names)
if err != nil { if err != nil {
return err return err
} }
@@ -70,7 +80,7 @@ mnemonic, so the audit tracks whatever toolchain `+"`go env GOROOT`"+` names.
var superset, backlog, shared []string var superset, backlog, shared []string
for _, name := range names { for _, name := range names {
switch { switch {
case !asm.Encodable(name): case !gasmEncodable(a, name):
backlog = append(backlog, name) backlog = append(backlog, name)
case !goKnown[name]: case !goKnown[name]:
superset = append(superset, name) superset = append(superset, name)
@@ -86,7 +96,7 @@ mnemonic, so the audit tracks whatever toolchain `+"`go env GOROOT`"+` names.
slices.Sort(shared) slices.Sort(shared)
w := os.Stdout 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, "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, "shared: %d\n", len(shared))
fmt.Fprintf(w, "\nSuperset encodings (gasm-only; ship via gasm asm --format goobj):\n") 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 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 { func countTrue(m map[string]bool) int {
n := 0 n := 0
for _, v := range m { 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 // derivedFamily reports whether a mnemonic belongs to a family both
// assemblers construct from condition codes rather than list exhaustively // assemblers construct from condition codes rather than list exhaustively
// (JEQ/CMOVLGT/SETNE and friends). Such names never probe cleanly, so // (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 { func derivedFamily(name string) bool {
if strings.HasPrefix(name, "J") && name != "JMP" && name != "JMPQ" { if strings.HasPrefix(name, "J") && name != "JMP" && name != "JMPQ" {
return true return true
@@ -133,7 +172,7 @@ var unrecognizedRe = regexp.MustCompile(`unrecognized instruction`)
// verdict on the mnemonic alone, so a single bare-instruction probe per // verdict on the mnemonic alone, so a single bare-instruction probe per
// mnemonic decides recognition; the combined file still reports every line's // mnemonic decides recognition; the combined file still reports every line's
// error even when others fail. // 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") dir, err := os.MkdirTemp("", "gasm-audit")
if err != nil { if err != nil {
return nil, err 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) 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 := 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() out, _ := cmd.CombinedOutput()
result := map[string]bool{} result := map[string]bool{}
@@ -188,6 +227,80 @@ func probeGoAsm(names []string) (map[string]bool, error) {
return result, nil 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 <arch> 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. // sanitize makes a mnemonic safe for use in a Go symbol name.
func sanitize(name string) string { func sanitize(name string) string {
return strings.NewReplacer(".", "_", "$", "_").Replace(name) return strings.NewReplacer(".", "_", "$", "_").Replace(name)
+42 -1
View File
@@ -3,7 +3,11 @@
package main package main
import "testing" import (
"testing"
"sourcedock.dev/petrbalvin/gasm-devkit/arch"
)
func TestDerivedFamily(t *testing.T) { func TestDerivedFamily(t *testing.T) {
for _, n := range []string{"JEQ", "JLT", "JCC", "CMOVLGT", "SETNE", "SETA"} { 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) 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)
}
}
}
+11 -7
View File
@@ -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 the static structure; for runtime execution counts, use `gasm verify
--fuzz` which exercises the code paths. --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 Compare the gasm encoder for the given architecture (default amd64)
print the diff: superset encodings (gasm-only spellings, shippable via against the installed `go tool asm` and print the diff: superset
`gasm asm --format goobj`), known-but-unencodable names (the encoder encodings (gasm-only spellings, shippable via `gasm asm --format goobj`),
backlog) and go-only names (feature gaps). The Go side is probed known-but-unencodable names (the encoder backlog) and go-only names
black-box with a battery of operand shapes per mnemonic, so the audit (feature gaps). The Go side is probed black-box with a battery of operand
tracks whatever toolchain `go env GOROOT` provides. 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 <file.s>` ## `gasm scaffold differential <file.s>`