feat(verify): add --call and --buf flags for single-function invocation

Assisted-by: GLM 5.2
This commit is contained in:
2026-08-05 20:46:39 +02:00
parent ece0d3f127
commit b0c62be8ce
6 changed files with 518 additions and 17 deletions
+14
View File
@@ -14,6 +14,20 @@ Unreleased changes on the `development` branch.
- **`gasm diff --map`** — compare functions whose names differ between files - **`gasm diff --map`** — compare functions whose names differ between files
(e.g. `--map wideCopyAVX2=wideCopyAVX512` pairs AVX2 and AVX-512 variants (e.g. `--map wideCopyAVX2=wideCopyAVX512` pairs AVX2 and AVX-512 variants
regardless of suffix). Unmapped functions fall back to the original name match. regardless of suffix). Unmapped functions fall back to the original name match.
- **`gasm verify --call`** — invoke a single function with user-supplied buffers
(`--buf name:size:pattern`) instead of the smoke/abi/fuzz sweeps. Patterns:
`zero`, `ones`, `seq`, or a hex blob. Useful for partial functions (e.g.
decoders) that crash on random input but should succeed on valid data.
The arg block is printed before and after the call, showing return values.
- **`gasm verify --ground-truth`** now documented in `--help` (was already a flag,
just missing from the help text).
### Fixed
- **Signature parser** — grouped Go parameters like `dst, src []byte` are now
parsed correctly (both get type `[]byte`). Previously the first name was
treated as its own type (`dst` with size 8), causing wrong ABI0 arg-block
layout in both `verify --call` and the fuzzer.
## [0.29.0] — 2026-08-05 ## [0.29.0] — 2026-08-05
+140 -2
View File
@@ -823,7 +823,7 @@ func cmdVerifyRISCV(path string, groundTruth, profile bool) int {
} }
func cmdVerify(args []string) int { func cmdVerify(args []string) int {
fs := newCommand("verify", "gasm verify [-smoke] [-abi] [-profile] <file.s>", ` fs := newCommand("verify", "gasm verify [-smoke] [-abi] [-fuzz] [-ground-truth] [-profile] [-call] <file.s>", `
Assemble FILE (amd64), map it into executable memory and report the available Assemble FILE (amd64), map it into executable memory and report the available
functions. This confirms the assembled image is self-consistent (no functions. This confirms the assembled image is self-consistent (no
unresolved external symbols) and executable — the prerequisite for dynamic unresolved external symbols) and executable — the prerequisite for dynamic
@@ -836,7 +836,18 @@ that tolerate nil pointers and zero lengths in their arguments.
With -abi, each function is called with sentinel values in the callee-saved With -abi, each function is called with sentinel values in the callee-saved
registers (BP, R14) and a red-zone canary below SP; violations are reported. registers (BP, R14) and a red-zone canary below SP; violations are reported.
With -fuzz, each function with a // func signature is differentially fuzzed
against the go-tool-asm version in a subprocess (so a crash on a partial
function is reported, not fatal).
With -ground-truth, the assembled machine code is compared byte-for-byte
against go tool asm (relocation sites masked), reporting any encoding drift.
With -profile, the static basic-block structure is listed for each function. With -profile, the static basic-block structure is listed for each function.
With -call, a single function is invoked with user-supplied buffers (-buf)
instead of the smoke/abi/fuzz sweeps. Useful for partial functions (e.g.
decoders) that crash on random input but should succeed on valid data.
`) `)
smoke := fs.Bool("smoke", false, "call each NOSPLIT function with zeroed args") 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)") abi := fs.Bool("abi", false, "run ABI-checking calls (sentinel registers + red zone)")
@@ -846,9 +857,12 @@ With -profile, the static basic-block structure is listed for each function.
fuzz := fs.Bool("fuzz", false, "differential fuzz: JIT both gasm and go-tool-asm versions, compare outputs") fuzz := fs.Bool("fuzz", false, "differential fuzz: JIT both gasm and go-tool-asm versions, compare outputs")
fuzzN := fs.Int("n", 1000, "number of fuzz iterations per function") fuzzN := fs.Int("n", 1000, "number of fuzz iterations per function")
fuzzOne := fs.String("fuzz-one", "", "") // hidden: fuzz a single function (subprocess mode) fuzzOne := fs.String("fuzz-one", "", "") // hidden: fuzz a single function (subprocess mode)
call := fs.String("call", "", "call a single function with -buf instead of the sweeps")
bufSpec := fs.String("buf", "", "buffer spec for -call: name:size:pattern[,name:size:pattern] (zero, ones, seq, or hex)")
repeat := fs.Int("repeat", 1, "number of times to repeat a -call invocation")
fs.Parse(args) fs.Parse(args)
if fs.NArg() != 1 { if fs.NArg() != 1 {
fmt.Fprintln(os.Stderr, "usage: gasm verify [-smoke] [-abi] [-profile] <file.s>") fmt.Fprintln(os.Stderr, "usage: gasm verify [-smoke] [-abi] [-fuzz] [-ground-truth] [-profile] [-call] <file.s>")
return 2 return 2
} }
path := fs.Arg(0) path := fs.Arg(0)
@@ -874,6 +888,11 @@ With -profile, the static basic-block structure is listed for each function.
fmt.Printf("%s: %d functions JIT-loaded\n", path, len(names)) fmt.Printf("%s: %d functions JIT-loaded\n", path, len(names))
rc := 0 rc := 0
// Single-function call mode: invoke one function with user-supplied buffers.
if *call != "" {
return cmdVerifyCall(k, path, *call, *bufSpec, *repeat)
}
// Subprocess mode: fuzz a single function and exit. // Subprocess mode: fuzz a single function and exit.
if *fuzzOne != "" { if *fuzzOne != "" {
gt, err := verify.GroundTruth(path) gt, err := verify.GroundTruth(path)
@@ -1095,3 +1114,122 @@ func fuzzInSubprocess(path, funcName string, n int) string {
} }
return strings.TrimSpace(string(out)) return strings.TrimSpace(string(out))
} }
// cmdVerifyCall implements `gasm verify --call <func> [--buf spec] [--repeat n]`.
// It invokes a single function with user-supplied buffers and prints the arg
// block before and after the call, so the user can inspect return values and
// any output written to the buffers.
func cmdVerifyCall(k *verify.Kernel, path, funcName, bufSpec string, repeat int) int {
fl, err := k.Func(funcName)
if err != nil {
fmt.Fprintf(os.Stderr, "gasm verify: %v\n", err)
return 1
}
if !fl.NoSplit {
fmt.Fprintf(os.Stderr, "gasm verify: %s is not NOSPLIT (frame=%d); --call supports NOSPLIT functions only\n", funcName, fl.Frame)
return 1
}
// Parse the // func signature to lay out the argument block.
src, err := readSource(path)
if err != nil {
fmt.Fprintf(os.Stderr, "gasm verify: %v\n", err)
return 1
}
sig, ok := verify.ExtractFuncSig(src, funcName)
if !ok {
fmt.Fprintf(os.Stderr, "gasm verify: no // func signature found for %s\n", funcName)
return 1
}
layout := verify.ArgLayout(sig)
// Allocate the requested buffers (if any) and build the arg block.
specs, err := verify.ParseBufSpec(bufSpec)
if err != nil {
fmt.Fprintf(os.Stderr, "gasm verify: %v\n", err)
return 1
}
var pool verify.BufPool
if err := pool.Alloc(specs); err != nil {
fmt.Fprintf(os.Stderr, "gasm verify: %v\n", err)
return 1
}
defer pool.Close()
args := pool.BuildArgs(layout, fl.Args)
fmt.Printf("%s: %d bytes, args=%d\n", funcName, fl.Size, fl.Args)
fmt.Printf(" signature: func %s(%s) %s\n", sig.Name, formatParams(sig.Params), formatResults(sig.Results))
if len(specs) > 0 {
fmt.Printf(" buffers:\n")
for _, s := range specs {
fmt.Printf(" %s: %d bytes, pattern=%s\n", s.Name, s.Size, s.Pattern)
}
}
fmt.Printf(" args before: %s\n", hexDump(args))
rc := 0
for i := 0; i < repeat; i++ {
out, err := k.CallFunc(funcName, args)
if err != nil {
fmt.Printf(" call %d: FAIL — %v\n", i+1, err)
rc = 1
continue
}
if repeat == 1 {
fmt.Printf(" args after: %s\n", hexDump(out))
} else if i == repeat-1 {
fmt.Printf(" args after %d calls: %s\n", repeat, hexDump(out))
}
fmt.Printf(" call %d: OK\n", i+1)
}
return rc
}
// formatParams renders a parameter list as "a []byte, b []byte".
func formatParams(ps []verify.Param) string {
var parts []string
for _, p := range ps {
if p.Name != "" {
parts = append(parts, p.Name+" "+p.Typ)
} else {
parts = append(parts, p.Typ)
}
}
return strings.Join(parts, ", ")
}
// formatResults renders a result list as "(n int, code int)" or "int".
func formatResults(rs []verify.Param) string {
if len(rs) == 0 {
return ""
}
if len(rs) == 1 && rs[0].Name == "" {
return rs[0].Typ
}
return "(" + formatParams(rs) + ")"
}
// hexDump returns a one-line hex dump of buf, truncated to 64 bytes.
func hexDump(buf []byte) string {
const max = 64
n := len(buf)
if n > max {
n = max
}
var sb strings.Builder
for i := 0; i < n; i++ {
if i > 0 {
sb.WriteByte(' ')
}
fmt.Fprintf(&sb, "%02x", buf[i])
}
return fmt.Sprintf("%s%s (%d bytes)", sb.String(), truncMark(len(buf), max), len(buf))
}
// truncMark returns "…" when the buffer is longer than max, else "".
func truncMark(n, max int) string {
if n > max {
return "…"
}
return ""
}
+150
View File
@@ -0,0 +1,150 @@
// Copyright (c) 2026 Petr Balvín <opensource@petrbalvin.org> (https://petrbalvin.org)
// SPDX-License-Identifier: BSD-3-Clause
package verify
import (
"encoding/binary"
"encoding/hex"
"fmt"
"strings"
"unsafe"
)
// BufSpec is one buffer allocation request parsed from the user's --buf spec.
type BufSpec struct {
Name string
Size int // declared slice length and capacity
Pattern string // "zero", "ones", "seq", or a hex blob
}
// ParseBufSpec parses a "name:size:pattern[,name:size:pattern]" spec string
// into individual buffer specs. Empty input yields an empty slice.
func ParseBufSpec(spec string) ([]BufSpec, error) {
if spec == "" {
return nil, nil
}
var out []BufSpec
for _, part := range strings.Split(spec, ",") {
fields := strings.SplitN(part, ":", 3)
if len(fields) != 3 {
return nil, fmt.Errorf("verify: invalid buffer spec %q (expected name:size:pattern)", part)
}
var size int
if _, err := fmt.Sscanf(fields[1], "%d", &size); err != nil || size <= 0 {
return nil, fmt.Errorf("verify: invalid buffer size %q in %q", fields[1], part)
}
out = append(out, BufSpec{Name: fields[0], Size: size, Pattern: fields[2]})
}
return out, nil
}
// allocatedBuf is one live buffer in a pool.
type allocatedBuf struct {
spec BufSpec
data []byte // Size + safetyMargin bytes; the first Size are the live region
}
// safetyMargin is the extra bytes allocated past the declared size so SIMD
// over-reads and functions that read slightly past len never touch unmapped
// memory. Matches the margin used by the fuzz generator.
const safetyMargin = 8192
// BufPool is a set of allocated buffers held alive for the duration of one or
// more calls. Buffers live on the Go heap (the JIT call is in-process); the
// pool keeps the backing slices referenced so the GC does not collect them
// before the call returns.
type BufPool struct {
bufs []allocatedBuf
}
// Alloc allocates and fills the buffers described by specs. The returned
// pool must be kept alive until every call using it has returned.
func (p *BufPool) Alloc(specs []BufSpec) error {
for _, s := range specs {
data := make([]byte, s.Size+safetyMargin)
fillBuffer(data, s.Pattern)
p.bufs = append(p.bufs, allocatedBuf{spec: s, data: data})
}
return nil
}
// Close releases the pool. No-op for Go-heap buffers, but keeps the API
// symmetric with debug's mmap-backed pool.
func (p *BufPool) Close() {
p.bufs = nil
}
// findByName returns the buffer with the given spec name, if any.
func (p *BufPool) findByName(name string) *allocatedBuf {
for i := range p.bufs {
if p.bufs[i].spec.Name == name {
return &p.bufs[i]
}
}
return nil
}
// BuildArgs constructs an ABI0 argument block of argSize bytes for the given
// layout, placing each buffer's pointer/length/capacity at the matching
// parameter offset. Parameters whose names match a buffer spec get the
// buffer address; non-pointer parameters and unmatched pointers are zeroed.
//
// Matching is by exact name, then by prefix (a buffer named "src" matches a
// parameter named "src" or "srcBuf"), mirroring the debug allocator.
func (p *BufPool) BuildArgs(layout []ArgOffset, argSize int) []byte {
args := make([]byte, argSize)
for _, a := range layout {
if !a.IsPtr {
continue
}
buf := p.matchBuf(a.Name)
if buf == nil {
continue
}
if a.Offset+8 <= len(args) {
binary.LittleEndian.PutUint64(args[a.Offset:a.Offset+8], uint64(uintptr(unsafe.Pointer(&buf.data[0]))))
}
if strings.HasPrefix(a.Typ, "[]") && a.Offset+24 <= len(args) {
binary.LittleEndian.PutUint64(args[a.Offset+8:a.Offset+16], uint64(buf.spec.Size))
binary.LittleEndian.PutUint64(args[a.Offset+16:a.Offset+24], uint64(buf.spec.Size))
}
}
return args
}
// matchBuf finds a buffer matching the parameter name (exact, then prefix).
func (p *BufPool) matchBuf(name string) *allocatedBuf {
if b := p.findByName(name); b != nil {
return b
}
for i := range p.bufs {
if strings.HasPrefix(name, p.bufs[i].spec.Name) {
return &p.bufs[i]
}
}
return nil
}
// fillBuffer fills buf with the named pattern: "zero" (no-op, already zeroed),
// "ones" (0xFF), "seq" (i mod 256), or a hex blob repeated to fill.
func fillBuffer(buf []byte, pattern string) {
switch pattern {
case "zero":
// Already zeroed by make.
case "ones":
for i := range buf {
buf[i] = 0xFF
}
case "seq":
for i := range buf {
buf[i] = byte(i)
}
default:
if data, err := hex.DecodeString(pattern); err == nil && len(data) > 0 {
for i := range buf {
buf[i] = data[i%len(data)]
}
}
}
}
+158
View File
@@ -0,0 +1,158 @@
// Copyright (c) 2026 Petr Balvín <opensource@petrbalvin.org> (https://petrbalvin.org)
// SPDX-License-Identifier: BSD-3-Clause
package verify
import (
"testing"
)
func TestParseParamsExported(t *testing.T) {
tests := []struct {
input string
names []string
types []string
isPtr []bool
}{
{
input: "dst []byte, src []byte",
names: []string{"dst", "src"},
types: []string{"[]byte", "[]byte"},
isPtr: []bool{true, true},
},
{
input: "dst, src []byte",
names: []string{"dst", "src"},
types: []string{"[]byte", "[]byte"},
isPtr: []bool{true, true},
},
{
input: "a, b int",
names: []string{"a", "b"},
types: []string{"int", "int"},
isPtr: []bool{false, false},
},
{
input: "src []byte, dst []byte",
names: []string{"src", "dst"},
types: []string{"[]byte", "[]byte"},
isPtr: []bool{true, true},
},
{
input: "swin []int32, dstP []uint32, hist *[32]uint16",
names: []string{"swin", "dstP", "hist"},
types: []string{"[]int32", "[]uint32", "*[32]uint16"},
isPtr: []bool{true, true, true},
},
{
input: "n int, code int",
names: []string{"n", "code"},
types: []string{"int", "int"},
isPtr: []bool{false, false},
},
}
for _, tt := range tests {
params := parseParamsExported(tt.input)
if len(params) != len(tt.names) {
t.Errorf("parseParamsExported(%q): got %d params, want %d", tt.input, len(params), len(tt.names))
continue
}
for i, p := range params {
if p.Name != tt.names[i] {
t.Errorf("parseParamsExported(%q)[%d].Name = %q, want %q", tt.input, i, p.Name, tt.names[i])
}
if p.Typ != tt.types[i] {
t.Errorf("parseParamsExported(%q)[%d].Typ = %q, want %q", tt.input, i, p.Typ, tt.types[i])
}
if p.IsPointer() != tt.isPtr[i] {
t.Errorf("parseParamsExported(%q)[%d].IsPointer() = %v, want %v", tt.input, i, p.IsPointer(), tt.isPtr[i])
}
}
}
}
func TestParseBufSpec(t *testing.T) {
t.Run("empty", func(t *testing.T) {
specs, err := ParseBufSpec("")
if err != nil || len(specs) != 0 {
t.Errorf("ParseBufSpec(\"\") = %v, %v; want nil, nil", specs, err)
}
})
t.Run("single", func(t *testing.T) {
specs, err := ParseBufSpec("dst:64:zero")
if err != nil {
t.Fatal(err)
}
if len(specs) != 1 || specs[0].Name != "dst" || specs[0].Size != 64 || specs[0].Pattern != "zero" {
t.Errorf("ParseBufSpec(\"dst:64:zero\") = %+v; want [{dst 64 zero}]", specs)
}
})
t.Run("multiple", func(t *testing.T) {
specs, err := ParseBufSpec("dst:64:zero,src:128:seq")
if err != nil {
t.Fatal(err)
}
if len(specs) != 2 {
t.Fatalf("got %d specs, want 2", len(specs))
}
if specs[0].Name != "dst" || specs[1].Name != "src" {
t.Errorf("names = %s, %s; want dst, src", specs[0].Name, specs[1].Name)
}
})
t.Run("invalid", func(t *testing.T) {
_, err := ParseBufSpec("bad")
if err == nil {
t.Error("ParseBufSpec(\"bad\") should error")
}
})
t.Run("zero-size", func(t *testing.T) {
_, err := ParseBufSpec("dst:0:zero")
if err == nil {
t.Error("ParseBufSpec(\"dst:0:zero\") should error on zero size")
}
})
}
func TestBufPoolBuildArgs(t *testing.T) {
specs, err := ParseBufSpec("dst:64:seq,src:128:zero")
if err != nil {
t.Fatal(err)
}
var pool BufPool
if err := pool.Alloc(specs); err != nil {
t.Fatal(err)
}
defer pool.Close()
// Layout for wideCopyAVX2(dst, src []byte): dst at 0, src at 24.
layout := []ArgOffset{
{Name: "dst", Typ: "[]byte", Offset: 0, Size: 24, IsPtr: true},
{Name: "src", Typ: "[]byte", Offset: 24, Size: 24, IsPtr: true},
}
args := pool.BuildArgs(layout, 48)
// dst.ptr should be non-zero.
if args[0] == 0 && args[1] == 0 && args[2] == 0 && args[3] == 0 {
t.Error("dst.ptr is zero; expected a buffer address")
}
// dst.len should be 64 (0x40).
if args[8] != 0x40 {
t.Errorf("dst.len = %d, want 64", args[8])
}
// dst.cap should be 64.
if args[16] != 0x40 {
t.Errorf("dst.cap = %d, want 64", args[16])
}
// src.ptr should be non-zero.
if args[24] == 0 && args[25] == 0 && args[26] == 0 && args[27] == 0 {
t.Error("src.ptr is zero; expected a buffer address")
}
// src.len should be 128 (0x80).
if args[32] != 0x80 {
t.Errorf("src.len = %d, want 128", args[32])
}
}
+25 -8
View File
@@ -79,22 +79,39 @@ func parseParams(s string) []param {
if s == "" { if s == "" {
return nil return nil
} }
fields := strings.Split(s, ",")
// First pass: extract the type from each field (if present).
types := make([]string, len(fields))
for i, field := range fields {
parts := strings.Fields(strings.TrimSpace(field))
if len(parts) >= 2 {
types[i] = parts[len(parts)-1]
}
}
// Propagate types backward: a field without a type inherits from the next
// field that has one (e.g. "dst" inherits "[]byte" from "src []byte").
for i := range fields {
if types[i] == "" {
for j := i + 1; j < len(fields); j++ {
if types[j] != "" {
types[i] = types[j]
break
}
}
}
}
var out []param var out []param
for _, field := range strings.Split(s, ",") { for i, field := range fields {
field = strings.TrimSpace(field) field = strings.TrimSpace(field)
if field == "" { if field == "" {
continue continue
} }
parts := strings.Fields(field) parts := strings.Fields(field)
if len(parts) == 1 { typ := types[i]
// Unnamed: "int" or "[]byte". if typ == "" {
out = append(out, param{typ: parts[0]}) out = append(out, param{typ: parts[0]})
} else { } else {
// Named: "a []byte" or shared "a, b []int32" (handled by the out = append(out, param{name: parts[0], typ: typ})
// comma split above — "a" alone means the type follows in the
// next field; this is a simplification that covers the common
// case where each param has its own type).
out = append(out, param{name: parts[0], typ: parts[1]})
} }
} }
return out return out
+31 -7
View File
@@ -44,25 +44,49 @@ func ParseFuncSig(comment string) (FuncSig, bool) {
return sig, true return sig, true
} }
// parseParamsExported splits "a []byte, b []int32" into typed parameters. // parseParamsExported splits a parameter list like "a []byte, b []int32" or
// "dst, src []byte" into typed parameters. Go syntax allows grouped names
// where the type at the end applies to every name in the group:// "dst, src []byte" means both dst and src are []byte.
func parseParamsExported(s string) []Param { func parseParamsExported(s string) []Param {
s = strings.TrimSpace(s) s = strings.TrimSpace(s)
if s == "" { if s == "" {
return nil return nil
} }
fields := strings.Split(s, ",")
// First pass: extract the type from each field (if present).
types := make([]string, len(fields))
for i, field := range fields {
parts := strings.Fields(strings.TrimSpace(field))
if len(parts) >= 2 {
types[i] = parts[len(parts)-1]
}
}
// Propagate types backward: a field without a type inherits the type from
// the next field that has one (e.g. "dst" inherits "[]byte" from "src []byte").
for i := range fields {
if types[i] == "" {
for j := i + 1; j < len(fields); j++ {
if types[j] != "" {
types[i] = types[j]
break
}
}
}
}
// Second pass: build params.
var out []Param var out []Param
for _, field := range strings.Split(s, ",") { for i, field := range fields {
field = strings.TrimSpace(field) field = strings.TrimSpace(field)
if field == "" { if field == "" {
continue continue
} }
parts := strings.Fields(field) parts := strings.Fields(field)
if len(parts) == 1 { typ := types[i]
// Unnamed: "int" or "[]byte". if typ == "" {
out = append(out, Param{Typ: parts[0]}) typ = parts[0] // unnamed: the whole field is the type
out = append(out, Param{Typ: typ})
} else { } else {
// Named: "a []byte". out = append(out, Param{Name: parts[0], Typ: typ})
out = append(out, Param{Name: parts[0], Typ: parts[1]})
} }
} }
return out return out