feat(verify): add universal --ground-truth verification against go tool asm
Assisted-by: Qwen 3.8 Max Preview
This commit is contained in:
@@ -0,0 +1,173 @@
|
||||
// Copyright (c) 2026 Petr Balvín <opensource@petrbalvin.org> (https://petrbalvin.org)
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
package verify
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/binary"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// GroundTruth assembles the given .s file with the Go toolchain's own
|
||||
// assembler and returns the machine code bytes for each TEXT function,
|
||||
// keyed by the function's short name (the part after the middle dot).
|
||||
// This is the universal oracle: any file that `go tool asm` accepts can
|
||||
// be verified, with no hand-written reference.
|
||||
func GroundTruth(path string) (map[string][]byte, error) {
|
||||
goroot := runtime.GOROOT()
|
||||
asmBin := filepath.Join(goroot, "pkg", "tool", runtime.GOOS+"_"+runtime.GOARCH, "asm")
|
||||
if _, err := os.Stat(asmBin); err != nil {
|
||||
return nil, fmt.Errorf("verify: go tool asm not found at %s: %w", asmBin, err)
|
||||
}
|
||||
includeDir := filepath.Join(goroot, "pkg", "include")
|
||||
|
||||
// Create a temp file for the object output.
|
||||
tmpDir, err := os.MkdirTemp("", "gasm-verify-*")
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("verify: tempdir: %w", err)
|
||||
}
|
||||
defer os.RemoveAll(tmpDir)
|
||||
objPath := filepath.Join(tmpDir, "out.o")
|
||||
|
||||
// Derive a package name from the file name (the assembler needs -p).
|
||||
base := filepath.Base(path)
|
||||
pkg := strings.TrimSuffix(base, ".s")
|
||||
pkg = strings.TrimSuffix(pkg, "_amd64")
|
||||
|
||||
cmd := exec.Command(asmBin, "-I", includeDir, "-p", pkg, "-o", objPath, path)
|
||||
if out, err := cmd.CombinedOutput(); err != nil {
|
||||
return nil, fmt.Errorf("verify: go tool asm: %w\n%s", err, out)
|
||||
}
|
||||
|
||||
objData, err := os.ReadFile(objPath)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("verify: read object: %w", err)
|
||||
}
|
||||
return extractGOOBJCode(objData)
|
||||
}
|
||||
|
||||
// GOOBJ block indices (cmd/internal/goobj).
|
||||
const (
|
||||
blkAutolib = iota
|
||||
blkPkgIdx
|
||||
blkFile
|
||||
blkSymdef
|
||||
blkHashed64def
|
||||
blkHasheddef
|
||||
blkNonpkgdef
|
||||
blkNonpkgref
|
||||
blkRefFlags
|
||||
blkHash64
|
||||
blkHash
|
||||
blkRelocIdx
|
||||
blkAuxIdx
|
||||
blkDataIdx
|
||||
blkReloc
|
||||
blkAux
|
||||
blkData
|
||||
blkRefName
|
||||
blkEnd
|
||||
)
|
||||
|
||||
const goobjMagic = "\x00go120ld"
|
||||
|
||||
// extractGOOBJCode parses a GOOBJ payload and returns the code bytes for
|
||||
// each non-package STEXT symbol (the functions).
|
||||
func extractGOOBJCode(data []byte) (map[string][]byte, error) {
|
||||
// Find the GOOBJ header (after the "go object ..." preamble).
|
||||
i := bytes.Index(data, []byte(goobjMagic))
|
||||
if i < 0 {
|
||||
return nil, fmt.Errorf("verify: no GOOBJ magic in object file")
|
||||
}
|
||||
b := data[i:]
|
||||
le := binary.LittleEndian
|
||||
|
||||
// Read block offsets (20 bytes into the header: 4 magic + 8 go version
|
||||
// + 8 experiment = 20, then blkEnd+1 uint32 offsets).
|
||||
var offs [blkEnd + 1]uint32
|
||||
for j := 0; j <= blkEnd; j++ {
|
||||
offs[j] = le.Uint32(b[20+4*j:])
|
||||
}
|
||||
blk := func(idx int) []byte { return b[offs[idx]:offs[idx+1]] }
|
||||
|
||||
// Parse non-package symbol definitions (blkNonpkgdef): each entry is
|
||||
// 21 bytes: [nameLen:4][nameOff:4][abi:2][type:1][flag:1][flag2:1][size:4][align:4].
|
||||
const symSize = 21
|
||||
nonpkg := blk(blkNonpkgdef)
|
||||
nSyms := len(nonpkg) / symSize
|
||||
|
||||
// Data index (blkDataIdx): one uint32 per defined symbol across ALL
|
||||
// definition blocks (blkSymdef + blkHashed64def + blkHasheddef +
|
||||
// blkNonpkgdef), in that order. We need the offset for the nonpkg
|
||||
// symbols, which come last.
|
||||
dataIdx := blk(blkDataIdx)
|
||||
dataBlk := blk(blkData)
|
||||
|
||||
// Count symbols in the preceding definition blocks.
|
||||
preceding := 0
|
||||
for _, bi := range []int{blkSymdef, blkHashed64def, blkHasheddef} {
|
||||
preceding += len(blk(bi)) / symSize
|
||||
}
|
||||
|
||||
// Symbol name offsets in the GOOBJ symbol table are absolute byte
|
||||
// offsets from the start of the GOOBJ payload (the magic).
|
||||
readStr := func(off, ln uint32) string {
|
||||
if int(off+ln) > len(b) {
|
||||
return ""
|
||||
}
|
||||
return string(b[off : off+ln])
|
||||
}
|
||||
|
||||
result := make(map[string][]byte)
|
||||
const kindSTEXT = 1
|
||||
for s := 0; s < nSyms; s++ {
|
||||
x := nonpkg[s*symSize:]
|
||||
nameLen := le.Uint32(x[0:])
|
||||
nameOff := le.Uint32(x[4:])
|
||||
typ := x[10]
|
||||
size := le.Uint32(x[13:])
|
||||
|
||||
if typ != kindSTEXT || size == 0 {
|
||||
continue
|
||||
}
|
||||
name := readStr(nameOff, nameLen)
|
||||
// Strip the package prefix (everything up to and including the
|
||||
// last middle dot or period-dot).
|
||||
name = stripPkg(name)
|
||||
|
||||
// Data offset from the index (nonpkg symbols follow the preceding blocks).
|
||||
diIdx := preceding + s
|
||||
if (diIdx+1)*4 > len(dataIdx) {
|
||||
continue
|
||||
}
|
||||
dOff := le.Uint32(dataIdx[diIdx*4:])
|
||||
if int(dOff+size) > len(dataBlk) {
|
||||
continue
|
||||
}
|
||||
code := make([]byte, size)
|
||||
copy(code, dataBlk[dOff:dOff+size])
|
||||
result[name] = code
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// stripPkg removes the package path prefix from a symbol name, leaving
|
||||
// just the function name. "pkg/path·FuncName" → "FuncName".
|
||||
func stripPkg(name string) string {
|
||||
if i := strings.LastIndex(name, "\u00B7"); i >= 0 {
|
||||
return name[i+len("\u00B7"):]
|
||||
}
|
||||
if i := strings.LastIndex(name, "\"."); i >= 0 {
|
||||
return name[i+2:]
|
||||
}
|
||||
if i := strings.LastIndex(name, "."); i >= 0 {
|
||||
return name[i+1:]
|
||||
}
|
||||
return name
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
// Copyright (c) 2026 Petr Balvín <opensource@petrbalvin.org> (https://petrbalvin.org)
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
package verify
|
||||
|
||||
import (
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestGroundTruthBasic(t *testing.T) {
|
||||
// Use the simple test kernel — it assembles with go tool asm.
|
||||
gt, err := GroundTruth("../testdata/verify/basic_amd64.s")
|
||||
if err != nil {
|
||||
t.Fatalf("GroundTruth: %v", err)
|
||||
}
|
||||
if len(gt) == 0 {
|
||||
t.Fatal("no functions extracted from ground truth")
|
||||
}
|
||||
// The "add" function should be present and non-empty.
|
||||
code, ok := gt["add"]
|
||||
if !ok {
|
||||
t.Fatalf("function 'add' not found in ground truth; got: %v", keys(gt))
|
||||
}
|
||||
if len(code) == 0 {
|
||||
t.Fatal("add: zero-length code")
|
||||
}
|
||||
t.Logf("ground truth functions: %v", keys(gt))
|
||||
}
|
||||
|
||||
func TestGroundTruthComparison(t *testing.T) {
|
||||
// Assemble with gasm and compare against go tool asm.
|
||||
k, err := Load("../testdata/verify/basic_amd64.s")
|
||||
if err != nil {
|
||||
t.Fatalf("Load: %v", err)
|
||||
}
|
||||
defer k.Close()
|
||||
|
||||
gt, err := GroundTruth("../testdata/verify/basic_amd64.s")
|
||||
if err != nil {
|
||||
t.Fatalf("GroundTruth: %v", err)
|
||||
}
|
||||
|
||||
for _, name := range k.FuncNames() {
|
||||
fl, _ := k.Func(name)
|
||||
gasmCode := k.Image().Code[fl.Offset : fl.Offset+fl.Size]
|
||||
goCode, ok := gt[name]
|
||||
if !ok {
|
||||
t.Errorf("%s: not in ground truth", name)
|
||||
continue
|
||||
}
|
||||
if len(gasmCode) != len(goCode) {
|
||||
t.Errorf("%s: size mismatch: gasm=%d go=%d", name, len(gasmCode), len(goCode))
|
||||
continue
|
||||
}
|
||||
for i := range gasmCode {
|
||||
if gasmCode[i] != goCode[i] {
|
||||
t.Errorf("%s: byte %d differs: gasm=%02x go=%02x", name, i, gasmCode[i], goCode[i])
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestGroundTruthBadFile(t *testing.T) {
|
||||
_, err := GroundTruth("/nonexistent/file_amd64.s")
|
||||
if err == nil {
|
||||
t.Fatal("expected error for nonexistent file")
|
||||
}
|
||||
}
|
||||
|
||||
func keys(m map[string][]byte) []string {
|
||||
out := make([]string, 0, len(m))
|
||||
for k := range m {
|
||||
out = append(out, k)
|
||||
}
|
||||
return out
|
||||
}
|
||||
@@ -120,3 +120,8 @@ func (k *Kernel) Close() {
|
||||
k.exec.Unmap()
|
||||
}
|
||||
}
|
||||
|
||||
// Image returns the assembled image (code + data + metadata).
|
||||
func (k *Kernel) Image() *asm.Image {
|
||||
return k.img
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user