200 lines
6.3 KiB
Go
200 lines
6.3 KiB
Go
// 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)
|
|
}
|