diff --git a/cmd/gasm/main.go b/cmd/gasm/main.go index 4f3cd1a..43b7066 100644 --- a/cmd/gasm/main.go +++ b/cmd/gasm/main.go @@ -8,6 +8,7 @@ package main import ( + "bytes" "flag" "fmt" "io" @@ -29,7 +30,7 @@ import ( // version is the release version, stamped at build time via // -ldflags "-X main.version=…" (defaulting to the current release). -var version = "0.24.0" +var version = "0.25.0" func main() { if len(os.Args) < 2 { @@ -490,6 +491,7 @@ With -profile, the static basic-block structure is listed for each function. smoke := fs.Bool("smoke", false, "call each NOSPLIT function with zeroed args") abi := fs.Bool("abi", false, "run ABI-checking calls (sentinel registers + red zone)") profile := fs.Bool("profile", false, "list basic-block structure per function") + groundTruth := fs.Bool("ground-truth", false, "compare machine code byte-for-byte against go tool asm") fs.Parse(args) if fs.NArg() != 1 { fmt.Fprintln(os.Stderr, "usage: gasm verify [-smoke] [-abi] [-profile] ") @@ -511,6 +513,61 @@ With -profile, the static basic-block structure is listed for each function. names := k.FuncNames() fmt.Printf("%s: %d functions JIT-loaded\n", path, len(names)) rc := 0 + + // Ground-truth comparison: assemble with go tool asm and compare bytes. + if *groundTruth { + gt, err := verify.GroundTruth(path) + if err != nil { + fmt.Fprintf(os.Stderr, "gasm verify: ground truth: %v\n", err) + return 1 + } + matched, total := 0, 0 + for _, name := range names { + fl, _ := k.Func(name) + gasmCode := k.Image().Code[fl.Offset : fl.Offset+fl.Size] + goCode, ok := gt[name] + if !ok { + fmt.Printf(" %s: SKIP (not in go tool asm output)\n", name) + continue + } + total++ + // Compare, masking relocation sites (disp32 fields that the + // Go linker fills at link time — gasm resolves them internally). + gasmCmp := make([]byte, len(gasmCode)) + goCmp := make([]byte, len(goCode)) + copy(gasmCmp, gasmCode) + copy(goCmp, goCode) + for _, r := range fl.Relocs { + for j := r.Off; j < r.Off+4 && j < len(gasmCmp); j++ { + gasmCmp[j] = 0 + } + for j := r.Off; j < r.Off+4 && j < len(goCmp); j++ { + goCmp[j] = 0 + } + } + if bytes.Equal(gasmCmp, goCmp) { + matched++ + if len(fl.Relocs) > 0 { + fmt.Printf(" %s: MATCH (%d bytes, %d relocs masked)\n", name, fl.Size, len(fl.Relocs)) + } else { + fmt.Printf(" %s: MATCH (%d bytes)\n", name, fl.Size) + } + } else { + fmt.Printf(" %s: MISMATCH (gasm %d bytes, go %d bytes)\n", name, fl.Size, len(goCode)) + for i := 0; i < len(gasmCmp) && i < len(goCmp); i++ { + if gasmCmp[i] != goCmp[i] { + fmt.Printf(" first diff at byte %d: gasm=%02x go=%02x\n", i, gasmCmp[i], goCmp[i]) + break + } + } + rc = 1 + } + } + fmt.Printf("ground truth: %d/%d functions byte-identical\n", matched, total) + if matched < total { + rc = 1 + } + } for _, name := range names { fl, _ := k.Func(name) flags := "" diff --git a/justfile b/justfile index 4f44be8..3fd789e 100644 --- a/justfile +++ b/justfile @@ -3,7 +3,7 @@ # gasm-devkit — developer tooling for Go's Plan 9 assembler (GAsm). -version := "0.24.0" +version := "0.25.0" default: @just --list diff --git a/verify/groundtruth.go b/verify/groundtruth.go new file mode 100644 index 0000000..5a157f7 --- /dev/null +++ b/verify/groundtruth.go @@ -0,0 +1,173 @@ +// Copyright (c) 2026 Petr Balvín (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 +} diff --git a/verify/groundtruth_test.go b/verify/groundtruth_test.go new file mode 100644 index 0000000..7bfaf98 --- /dev/null +++ b/verify/groundtruth_test.go @@ -0,0 +1,77 @@ +// Copyright (c) 2026 Petr Balvín (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 +} diff --git a/verify/verify.go b/verify/verify.go index bf8fac0..131c92d 100644 --- a/verify/verify.go +++ b/verify/verify.go @@ -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 +}