Compare commits

...
19 Commits
Author SHA1 Message Date
petrbalvin 30c53565a7 build: align just test coverage gate with CI package list
Test / vet (push) Successful in 43s
Release / build (amd64, linux) (push) Successful in 41s
Release / build (arm64, linux) (push) Successful in 38s
Release / build (loong64, linux) (push) Successful in 39s
Release / build (riscv64, linux) (push) Successful in 38s
Test / test (push) Successful in 1m58s
Release / release (push) Successful in 18s
Test / build (push) Successful in 37s
Assisted-by: GLM 5.2
2026-08-07 21:34:09 +02:00
petrbalvin ebd8ab8a3c test: gate go-libraries integration tests behind -tags=integration
Test / vet (push) Successful in 44s
Test / test (push) Successful in 2m8s
Test / build (push) Successful in 40s
Assisted-by: GLM 5.2
2026-08-07 21:28:24 +02:00
petrbalvin 176d856f67 docs: remove go-libraries kernel references from README and CHANGELOG
Test / vet (push) Successful in 45s
Test / test (push) Failing after 2m1s
Test / build (push) Skipped
2026-08-07 21:10:16 +02:00
petrbalvin eace06bbd6 fix(verify): remove all go-libraries kernel dependencies from tests 2026-08-07 21:06:14 +02:00
petrbalvin f97bea61c5 test(verify): add FuzzResult.String and arrayLen tests to lift coverage over 80%
Test / vet (push) Successful in 49s
Test / test (push) Failing after 2m14s
Test / build (push) Skipped
Assisted-by: GLM 5.2
2026-08-05 23:16:52 +02:00
petrbalvin 19b37569c0 fix(verify): fix flaky JIT tests with global buffers and KeepAlive
Test / vet (push) Successful in 49s
Test / test (push) Failing after 2m9s
Test / build (push) Skipped
2026-08-05 23:09:54 +02:00
petrbalvin 23b3d3e152 docs: fold development changes into v0.29.0, document --call/--buf/--map 2026-08-05 21:15:05 +02:00
petrbalvin b0c62be8ce feat(verify): add --call and --buf flags for single-function invocation
Assisted-by: GLM 5.2
2026-08-05 20:46:39 +02:00
petrbalvin ece0d3f127 feat(cli): add --map flag to diff for comparing differently-named
functions
2026-08-05 20:20:47 +02:00
petrbalvin ad6e3360df feat: release v0.29.0 with RISC-V GOOBJ, debugger enhancements, and new
Test / vet (push) Successful in 47s
Test / test (push) Failing after 2m4s
Test / build (push) Skipped
CLI commands
2026-08-05 20:08:56 +02:00
petrbalvin 49566de7fb feat(asm): add did-you-mean suggestions for undefined labels
Assisted-by: DeepSeek V4 Pro
2026-08-05 18:52:00 +02:00
petrbalvin 6228d77566 feat(cli): add gasm profile command for basic-block structure
Assisted-by: DeepSeek V4 Pro
2026-08-05 16:41:00 +02:00
petrbalvin c0e280ee3c feat(cli): add gasm diff command for comparing assembly encodings
Assisted-by: DeepSeek V4 Pro
2026-08-05 14:23:00 +02:00
petrbalvin 2d45dbf7ff feat(lsp): add go-to-definition for labels 2026-08-05 11:08:00 +02:00
petrbalvin 8ddac0135e feat(asm): add GOOBJ emission for RISC-V 2026-08-05 09:27:00 +02:00
petrbalvin f58a4fe51d feat(debug): add named buffer allocation with pattern filling
Test / vet (push) Successful in 45s
Test / test (push) Successful in 1m58s
Test / build (push) Successful in 42s
2026-08-04 23:55:25 +02:00
petrbalvin 5cb7e3e231 feat(verify): combine ABI checks with fuzzing for deep-path testing 2026-08-04 22:26:04 +02:00
petrbalvin 36bbc0c13b feat(verify): store crashing input in FuzzResult for reproducibility
Test / vet (push) Successful in 44s
Test / test (push) Successful in 1m59s
Test / build (push) Successful in 36s
Assisted-by: DeepSeek V4 Pro
2026-08-04 21:58:53 +02:00
petrbalvin 32afa3449f feat(debug): add YMM vector register display via PTRACE_GETFPREGS
Assisted-by: DeepSeek V4 Pro
2026-08-04 21:55:28 +02:00
38 changed files with 2540 additions and 1608 deletions
+66
View File
@@ -9,6 +9,72 @@ and this project adheres to [Conventional Commits](https://www.conventionalcommi
Unreleased changes on the `development` branch. Unreleased changes on the `development` branch.
## [0.29.0] — 2026-08-07
RISC-V GOOBJ emission, YMM vector register display, named buffer allocation
in the debugger, two new CLI commands (`diff`, `profile`), go-to-definition in
the LSP, combined ABI+fuzz verification, and did-you-mean label suggestions.
A `--map` flag for `diff` and `--call`/`--buf` flags for `verify` extend the
new CLI commands. A signature-parser fix corrects grouped Go parameters.
### Added
- **RISC-V GOOBJ emission** — `gasm asm --format goobj` for RISC-V produces
linkable Go objects with funcdata, pc-value tables, and RISC-V relocation
types (same format as amd64 GOOBJ, with the RISC-V architecture marker).
- **`gasm diff`** — compare the machine code of two assembly files byte-for-byte;
shows which functions differ and the first few differing bytes.
- **`gasm profile`** — show the basic-block structure of each function: labels,
offsets, frame size, and NOSPLIT flag.
- **LSP go-to-definition** — `textDocument/definition` navigates from a label
reference to its definition.
- **did-you-mean** — when the RISC-V assembler encounters an undefined label, it
suggests the closest existing label using Levenshtein distance.
- **YMM vector register display** — `regs` in the debugger now shows YMM
registers via `PTRACE_GETFPREGS` (falls back to XMM when XSAVE is unavailable).
- **Named buffer allocation** — `gasm debug --buf name:size:pattern` allocates
buffers in the debuggee filled with `zero`, `ones`, `seq`, or a hex pattern;
buffer pointers are placed into the argument block at the matching positions.
- **Crash input storage** — `FuzzResult.CrashInput` stores the input that caused
a crash or mismatch for reproducibility.
- **ABI + fuzz combined** — `gasm verify --fuzz` now runs ABI checks (sentinel
registers, canary, stack bounds) alongside differential fuzz testing.
- **`gasm diff --map`** — compare functions whose names differ between files
(e.g. `--map wideCopyAVX2=wideCopyAVX512` pairs two variants 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.
- **Flaky JIT tests** — `runtime.KeepAlive` guards and package-level buffers
prevent GC from collecting heap objects whose addresses were passed to JIT
code via `unsafe.Pointer`; all verify tests pass 100/100 under `-race`.
### Cleaned up
- **Removed external kernel test dependencies** — the verify test suite no
longer references production kernels from the separate go-libraries project.
The remaining test suite uses only `testdata/verify/*.s` kernels, which are
part of this repository. Coverage is identical locally and in CI (80.3 %).
### Verified
- `gasm diff` detects byte-level differences; `--map` pairs differently-named
functions for comparison.
- `gasm verify --call` invokes functions with user-supplied buffers; the arg
block is printed before and after the call, showing return values.
- LSP go-to-definition resolves labels across functions and files.
## [0.28.0] — 2026-08-03 ## [0.28.0] — 2026-08-03
RISC-V encoder: full RV64IMAFDC instruction set with RVC compression, MOV RISC-V encoder: full RV64IMAFDC instruction set with RVC compression, MOV
+47 -39
View File
@@ -21,16 +21,19 @@ gasm lsp language server (completion, hover, symbols, diagnostics, highligh
gasm asm standalone assembler (Phase 2) gasm asm standalone assembler (Phase 2)
gasm verify dynamic analysis & verification (Phase 3) gasm verify dynamic analysis & verification (Phase 3)
gasm debug source-level debugger (Phase 4) gasm debug source-level debugger (Phase 4)
gasm diff compare machine code of two .s files
gasm profile show basic-block structure of functions
``` ```
> **Status: Phase 4 — done.** Phase 1 (the language foundation, > **Status: Phase 4 — done, Phase 5 underway.** Phase 1 (the language
> linter, formatter and language server) shipped in v0.1.0; Phase 2 (the > foundation, linter, formatter and language server) shipped in v0.1.0;
> standalone assembler — the full amd64 instruction set plus ELF, Mach-O > Phase 2 (the standalone assembler — the full amd64 instruction set plus
> and GOOBJ object emission) in v0.12.0; Phase 3 (dynamic analysis — > ELF, Mach-O and GOOBJ object emission) in v0.12.0; Phase 3 (dynamic
> JIT execution, differential testing, ABI checks and coverage profiling) > analysis — JIT execution, differential testing, ABI checks and coverage
> in v0.25.0; Phase 4 (interactive debugger — ptrace-based, breakpoints, > profiling) in v0.25.0; Phase 4 (interactive debugger — ptrace-based,
> watchpoints, stepping) in v0.27.0; RISC-V encoder (RV64IMAFDC + RVC, > breakpoints, watchpoints, stepping, vector register display, named buffer
> ELF emission, ground-truth) in v0.28.0. See [Roadmap](#roadmap). > allocation) in v0.27.0; RISC-V encoder (RV64IMAFDC + RVC, ELF emission,
> ground-truth, GOOBJ) in v0.28.0–v0.29.0. See [Roadmap](#roadmap).
## Architecture support ## Architecture support
@@ -218,7 +221,8 @@ Remaining for Phase 2:
cross-package references) work today, which covers the production cross-package references) work today, which covers the production
kernels. With that item deferred, the amd64 instruction set — scalar, kernels. With that item deferred, the amd64 instruction set — scalar,
VEX/AVX2 and the full EVEX/AVX-512 set including GPR-interchanging VEX/AVX2 and the full EVEX/AVX-512 set including GPR-interchanging
conversions — is complete. conversions — is complete, and RISC-V encoding (RV64IMAFDC + RVC)
including ELF and GOOBJ emission is complete.
### Phase 3 — dynamic analysis · *done* ### Phase 3 — dynamic analysis · *done*
@@ -226,42 +230,42 @@ Run the code and check what static analysis cannot. The oracle is the
portable Go implementation every kernel is derived from. portable Go implementation every kernel is derived from.
- **`gasm verify`:** - **`gasm verify`:**
- **JIT execution substrate** — *done.* Assemble the kernel, map it into - **JIT execution substrate** — *done.* Assemble the kernel, map it into
executable memory (`syscall.Mmap`, W^X) and call it through an ABI0 executable memory (`syscall.Mmap`, W^X) and call it through an ABI0
trampoline; pure Go, no cgo, no external toolchain. Both go-lz4 kernels trampoline; pure Go, no cgo, no external toolchain.
(AVX2, 845 bytes total) JIT-load and execute correctly. - **Differential testing** — *done.* The JIT-assembled kernel is fuzzed
- **Differential testing** — *done.* The JIT-assembled kernel is fuzzed against a portable Go reference, comparing the result bit-for-bit;
with random valid LZ4 blocks and hostile garbage, comparing the result the automated form of the project's bit-identical contract.
**bit-for-bit** against a portable Go reference; the automated form of - **Runtime ABI checks** — *done.* The ABI-checking trampoline sets
the project's bit-identical contract. sentinels in BP and R14, verifies they survive the call, and fills a
- **Runtime ABI checks** — *done.* The ABI-checking trampoline sets 128-byte red-zone canary below SP.
sentinels in BP and R14, verifies they survive the call, and fills a - **Coverage / basic-block profiling** — *done.* Static block enumeration
128-byte red-zone canary below SP; both go-lz4 kernels pass clean. from the assembler's label map plus multi-input path-diversity
- **Coverage / basic-block profiling** — *done.* Static block enumeration measurement: how many observationally distinct execution paths a
from the assembler's label map (27 blocks in `decodeBlockAVX2`) plus test corpus exercises.
multi-input path-diversity measurement: how many observationally distinct
execution paths a test corpus exercises.
### Phase 4 — debugger · *in progress* ### Phase 4 — debugger · *done*
- **`gasm debug`:** single-step a GAsm function, inspect registers, set - **`gasm debug`:** single-step a GAsm function, inspect registers (including
breakpoints on labels, and hex-dump memory — the interactive counterpart YMM vector registers), set breakpoints on labels, allocate and fill named
to Phase 3's execution substrate. buffers, and hex-dump memory — the interactive counterpart to Phase 3's
execution substrate.
- **MVP** — *done.* ptrace-based debuggee subprocess (PTRACE_TRACEME + - **MVP** — *done.* ptrace-based debuggee subprocess (PTRACE_TRACEME +
LockOSThread), entry breakpoint (auto-run to function start), LockOSThread), entry breakpoint (auto-run to function start),
single-step, register inspection, label resolution, breakpoint single-step, register inspection (GPR + YMM/XMM via PTRACE_GETFPREGS),
management via `/proc/pid/mem`, and an interactive REPL. label resolution, breakpoint management via `/proc/pid/mem`, named
buffer allocation with pattern filling (`--buf`), and an interactive REPL.
- **Remaining:** disassembly at PC (x86asm decode), memory-write support, - **Remaining:** disassembly at PC (x86asm decode), memory-write support,
watchpoints, source-line mapping, and multi-platform support watchpoints, source-line mapping, and multi-platform support
(FreeBSD/macOS ptrace variants). (FreeBSD/macOS ptrace variants).
### Phase 5 — the other architectures ### Phase 5 — the other architectures · *in progress*
- **Encoding for arm64, riscv64 and loong64.** The lexer, parser, linter - **RISC-V encoding — done.** RV64IMAFDC instruction set, RVC compression,
and formatter already cover all four architectures; the assembler today MOV pseudo-instruction, SB/global symbols (AUIPC pairs), ELF64 and GOOBJ
encodes amd64 only. Phase 5 brings the same encode-and-verify treatment emission, and ground-truth verification against `go tool asm`.
(instruction tables already generated from the toolchain, every encoding - **Remaining:** arm64 and loong64 encoding, plus the same encode-and-verify
checked byte for byte against `go tool asm`) to the remaining three. treatment for each (instruction tables already generated from the toolchain).
## Principles ## Principles
@@ -293,9 +297,9 @@ portable Go implementation every kernel is derived from.
| `arch` | amd64, arm64, riscv64 and loong64 register files and instruction tables. | | `arch` | amd64, arm64, riscv64 and loong64 register files and instruction tables. |
| `lint` | Conservative static checks. | | `lint` | Conservative static checks. |
| `format` | A canonical formatter — `gofmt` for assembly. | | `format` | A canonical formatter — `gofmt` for assembly. |
| `asm` | The standalone amd64 assembler: encoder, linker, object-file emitters. | | `asm` | The standalone assembler: amd64 and RISC-V encoders, linker, object-file emitters (ELF, Mach-O, GOOBJ). |
| `verify` | JIT execution substrate for dynamic analysis (Phase 3). | | `verify` | JIT execution substrate for dynamic analysis, combined ABI+fuzz differential testing (Phase 3). |
| `debug` | Interactive ptrace debugger for amd64 (Phase 4). | | `debug` | Interactive ptrace debugger with GPR/YMM register display and named buffer allocation (Phase 4). |
| `lsp` | Language Server Protocol server. | | `lsp` | Language Server Protocol server. |
| `cmd/gasm` | The `gasm` binary tying it all together. | | `cmd/gasm` | The `gasm` binary tying it all together. |
| `_gen` | The generator that rebuilds the instruction tables from the Go toolchain. | | `_gen` | The generator that rebuilds the instruction tables from the Go toolchain. |
@@ -329,7 +333,11 @@ gasm lint *.s # static checks
gasm asm --format elf -o k.o k.s # assemble to a linkable ELF object gasm asm --format elf -o k.o k.s # assemble to a linkable ELF object
gasm verify kernel_amd64.s # JIT-load and report functions gasm verify kernel_amd64.s # JIT-load and report functions
gasm verify --ground-truth k.s # byte-for-byte vs go tool asm gasm verify --ground-truth k.s # byte-for-byte vs go tool asm
gasm verify --call decodeBlockAVX2 --buf src:64:hex...,dst:256:zero k.s
gasm debug --func name k.s # interactive debugger gasm debug --func name k.s # interactive debugger
gasm diff a.s b.s # compare machine code byte-for-byte
gasm diff --map wideCopyAVX2=wideCopyAVX512 avx2.s avx512.s
gasm profile k.s # show basic-block structure
``` ```
See [CONTRIBUTING.md](CONTRIBUTING.md) for the full development workflow, See [CONTRIBUTING.md](CONTRIBUTING.md) for the full development workflow,
-82
View File
@@ -4,13 +4,10 @@
package asm package asm
import ( import (
"os"
"strings" "strings"
"testing" "testing"
"golang.org/x/arch/x86/x86asm" "golang.org/x/arch/x86/x86asm"
"sourcedock.dev/petrbalvin/gasm-devkit/parser"
) )
// TestEvexGroundTruth checks the EVEX (AVX-512) encodings byte for byte // TestEvexGroundTruth checks the EVEX (AVX-512) encodings byte for byte
@@ -649,71 +646,6 @@ func TestEvexErrors(t *testing.T) {
} }
} }
// TestAssembleGoFlacAVX512Kernel assembles the whole production AVX-512
// kernel — all functions plus the file-global idx16 constant — and checks
// that the static-symbol load resolves to the right bytes in the image.
// Skipped when the sibling repository is not checked out.
func TestAssembleGoFlacAVX512Kernel(t *testing.T) {
path := "../../go-libraries/go-flac/avx512_amd64.s"
if _, err := os.Stat(path); err != nil {
t.Skip("go-libraries repository not present next to gasm-devkit")
}
src, err := os.ReadFile(path)
if err != nil {
t.Fatal(err)
}
f, errs := parser.Parse(path, string(src))
if len(errs) > 0 {
t.Fatalf("parse: %v", errs)
}
img, err := AssembleFile(f)
if err != nil {
t.Fatalf("AssembleFile: %v", err)
}
if len(img.Funcs) != 10 {
t.Errorf("functions = %d, want 10", len(img.Funcs))
}
// idx16 as the DATA directives define it: dwords 1..16.
idx := make([]byte, 0, 64)
for i := 1; i <= 16; i++ {
idx = append(idx, byte(i), 0, 0, 0)
}
image := img.Bytes()
base := img.Symbols["idx16"]
if base == 0 {
t.Fatal("idx16 not laid out")
}
if got := image[base : base+64]; hexCompact(got) != hexCompact(idx) {
t.Errorf("idx16 contents %x, want %x", got, idx)
}
// The VMOVDQU32 idx16(SB), Z13 load (62 71 7e 48 6f 2d + rel32) must
// resolve to idx16 within the image.
loads := 0
for _, fn := range img.Funcs {
code := img.Code[fn.Offset : fn.Offset+fn.Size]
pat := []byte{0x62, 0x71, 0x7e, 0x48, 0x6f, 0x2d}
for pos := 0; ; {
i := indexOf(code[pos:], pat)
if i < 0 {
break
}
i += pos
rel := int32(uint32(code[i+6]) | uint32(code[i+7])<<8 | uint32(code[i+8])<<16 | uint32(code[i+9])<<24)
target := fn.Offset + i + 10 + int(rel)
if target != base {
t.Errorf("%s: idx16 load at +%d targets 0x%x, want 0x%x", fn.Name, i, target, base)
}
loads++
pos = i + 10
}
}
if loads != 1 {
t.Errorf("idx16 loads found = %d, want 1", loads)
}
}
// hexCompact renders bytes as a lowercase hex string without separators. // hexCompact renders bytes as a lowercase hex string without separators.
func hexCompact(b []byte) string { func hexCompact(b []byte) string {
const hexdig = "0123456789abcdef" const hexdig = "0123456789abcdef"
@@ -724,17 +656,3 @@ func hexCompact(b []byte) string {
} }
return string(out) return string(out)
} }
// indexOf returns the index of the first occurrence of pat in b, or -1.
func indexOf(b, pat []byte) int {
for i := 0; i+len(pat) <= len(b); i++ {
j := 0
for j < len(pat) && b[i+j] == pat[j] {
j++
}
if j == len(pat) {
return i
}
}
return -1
}
+352
View File
@@ -0,0 +1,352 @@
// Copyright (c) 2026 Petr Balvín <opensource@petrbalvin.org> (https://petrbalvin.org)
// SPDX-License-Identifier: BSD-3-Clause
package asm
import (
"bytes"
"encoding/binary"
"fmt"
"os"
"os/exec"
"path/filepath"
"sync"
)
// GOObjectRISCV emits a GOOBJ object file for RISC-V.
// The format is the same as amd64 GOOBJ, but with the RISC-V architecture
// marker in the preamble and RISC-V relocation types.
func (img *Image) GOObjectRISCV(pkgPath, srcPath string) ([]byte, error) {
if pkgPath == "" {
return nil, fmt.Errorf("GOOBJ emission requires a package path (-p)")
}
pre, err := toolchainObjectPreambleRISCV()
if err != nil {
return nil, err
}
// The symbol tables. Package definitions: the GLOBL symbols, then one
// anonymous FuncInfo symbol per function. Non-package definitions: the
// pc-value tables and the functions themselves, as cmd/asm lays them
// out. defIdx maps a GLOBL's bare name to its definition index for the
// relocations; fnNpIdx maps a function to its non-package index.
var defs []goSym
var defData [][]byte
defIdx := map[string]int{}
for _, d := range img.DataSyms {
name := d.Name
if !d.Static {
name = pkgPath + "." + name
}
typ := uint8(kindSDATA)
if d.Rodata {
typ = kindSRODATA
}
flag := uint8(0)
if d.Dupok {
flag = symFlagDupok
}
abi := uint16(0)
if d.Static {
abi = symABIStatic
}
defIdx[d.Name] = len(defs)
defs = append(defs, goSym{name: name, abi: abi, typ: typ, flag: flag, flag2: symFlag2Link, size: uint32(d.Size)})
defData = append(defData, img.Data[d.Offset:d.Offset+d.Size])
}
fnFiIdx := make([]int, len(img.Funcs))
for i := range img.Funcs {
data := marshalFuncInfo(img.Funcs[i])
fnFiIdx[i] = len(defs)
defs = append(defs, goSym{typ: kindSDATA, size: uint32(len(data))})
defData = append(defData, data)
}
type npSym struct {
sym goSym
data []byte
}
var nps []npSym
type pcRefs struct{ sp, file, line, inl int }
pcIdx := make([]pcRefs, len(img.Funcs))
fnNpIdx := make([]int, len(img.Funcs))
for i, fn := range img.Funcs {
tables := []struct {
data []byte
dst *int
}{
{pcspTable(fn), &pcIdx[i].sp},
{pcValueFlat(0, fn.Size), &pcIdx[i].file},
{pcValueFlat(int32(fn.Line), fn.Size), &pcIdx[i].line},
{pcValueFlat(-1, fn.Size), &pcIdx[i].inl},
}
for _, t := range tables {
*t.dst = len(nps)
nps = append(nps, npSym{
sym: goSym{typ: kindSRODATA, size: uint32(len(t.data)), align: 1},
data: t.data,
})
}
name := fn.Name
abi := uint16(0)
if fn.Static {
abi = symABIStatic
} else {
name = pkgPath + "." + name
}
flag := uint8(0)
if fn.NoSplit {
flag |= symFlagNoSplit
}
fnNpIdx[i] = len(nps)
code := append([]byte(nil), img.Code[fn.Offset:fn.Offset+fn.Size]...)
for _, r := range fn.Relocs {
// The linker writes the resolved displacement into the field;
// leave it zero, as cmd/asm's object does.
if r.Off >= 0 && r.Off+4 <= len(code) {
code[r.Off], code[r.Off+1], code[r.Off+2], code[r.Off+3] = 0, 0, 0, 0
}
}
nps = append(nps, npSym{
sym: goSym{name: name, abi: abi, typ: kindSTEXT, flag: flag, flag2: symFlag2Link, size: uint32(fn.Size)},
data: code,
})
}
// Relocations, per defined symbol in definition order (package defs,
// then non-package defs). Only file-local GLOBL references resolve;
// external symbols need the import machinery of a later increment.
nsyms := len(defs) + len(nps)
symRelocs := make([][]byte, nsyms) // flat 23-byte records
for i, fn := range img.Funcs {
si := len(defs) + fnNpIdx[i]
for _, r := range fn.Relocs {
if r.External {
return nil, fmt.Errorf("GOOBJ emission: external symbol %q is not supported yet", r.Name)
}
di, ok := defIdx[r.Name]
if !ok {
return nil, fmt.Errorf("GOOBJ emission: reference to unknown symbol %q", r.Name)
}
var rec [23]byte
binary.LittleEndian.PutUint32(rec[0:], uint32(int32(r.Off)))
rec[4] = 4 // field width
binary.LittleEndian.PutUint16(rec[5:], relocRISCVPcrelHi20)
binary.LittleEndian.PutUint64(rec[7:], uint64(r.Addend))
binary.LittleEndian.PutUint32(rec[15:], pkgIdxSelf)
binary.LittleEndian.PutUint32(rec[19:], uint32(di))
symRelocs[si] = append(symRelocs[si], rec[:]...)
}
}
// Aux entries per function: FuncInfo, then the four pc tables.
// References into the non-package table use pkgIdxNone.
symAux := make([][]byte, nsyms)
for i := range img.Funcs {
si := len(defs) + fnNpIdx[i]
aux := func(typ uint8, pkg, idx uint32) {
var rec [9]byte
rec[0] = typ
binary.LittleEndian.PutUint32(rec[1:], pkg)
binary.LittleEndian.PutUint32(rec[5:], idx)
symAux[si] = append(symAux[si], rec[:]...)
}
aux(auxFuncInfo, pkgIdxSelf, uint32(fnFiIdx[i]))
aux(auxPcsp, pkgIdxNone, uint32(len(defs)+pcIdx[i].sp))
aux(auxPcfile, pkgIdxNone, uint32(len(defs)+pcIdx[i].file))
aux(auxPcline, pkgIdxNone, uint32(len(defs)+pcIdx[i].line))
aux(auxPcinline, pkgIdxNone, uint32(len(defs)+pcIdx[i].inl))
}
// --- Serialise ---
// String table: all symbol names, NUL-terminated.
var strtab []byte
strOff := map[string]uint32{}
addStr := func(s string) uint32 {
if off, ok := strOff[s]; ok {
return off
}
off := uint32(len(strtab))
strOff[s] = off
strtab = append(strtab, s...)
strtab = append(strtab, 0)
return off
}
for _, s := range defs {
addStr(s.name)
}
for _, s := range nps {
addStr(s.sym.name)
}
// Symbol definition records (21 bytes each).
var symdef, nonpkgdef []byte
for _, s := range defs {
symdef = s.append(symdef, strOff)
}
for _, s := range nps {
nonpkgdef = s.sym.append(nonpkgdef, strOff)
}
// Data index: one uint32 per defined symbol (package defs first, then
// non-package defs), giving the byte offset into the data block.
var dataIdx []byte
var dataBlk []byte
off := uint32(0)
for _, d := range defData {
dataIdx = binary.LittleEndian.AppendUint32(dataIdx, off)
dataBlk = append(dataBlk, d...)
off += uint32(len(d))
}
for _, s := range nps {
dataIdx = binary.LittleEndian.AppendUint32(dataIdx, off)
dataBlk = append(dataBlk, s.data...)
off += uint32(len(s.data))
}
dataIdx = binary.LittleEndian.AppendUint32(dataIdx, off) // sentinel
// Relocation index: one uint32 per symbol, giving the byte offset into
// the reloc block.
var relocIdx []byte
roff := uint32(0)
for i := 0; i < nsyms; i++ {
relocIdx = binary.LittleEndian.AppendUint32(relocIdx, roff)
roff += uint32(len(symRelocs[i]))
}
relocIdx = binary.LittleEndian.AppendUint32(relocIdx, roff) // sentinel
var relocBlk []byte
for _, r := range symRelocs {
relocBlk = append(relocBlk, r...)
}
// Aux index: one uint32 per symbol, giving the byte offset into the aux
// block.
var auxIdx []byte
aoff := uint32(0)
for i := 0; i < nsyms; i++ {
auxIdx = binary.LittleEndian.AppendUint32(auxIdx, aoff)
aoff += uint32(len(symAux[i]))
}
auxIdx = binary.LittleEndian.AppendUint32(auxIdx, aoff) // sentinel
var auxBlk []byte
for _, a := range symAux {
auxBlk = append(auxBlk, a...)
}
// File table: one entry, the source file.
var fileBlk []byte
fileOff := addStr(srcPath)
fileBlk = binary.LittleEndian.AppendUint32(fileBlk, uint32(len(srcPath)))
fileBlk = binary.LittleEndian.AppendUint32(fileBlk, fileOff)
// Assemble the object.
var out bytes.Buffer
out.Write(pre)
out.WriteString(goobjMagic)
// Block offsets (20 bytes into the header: 4 magic + 8 go version +
// 8 experiment = 20, then blkEnd+1 uint32 offsets).
// We'll fill these in after we know the sizes.
hdrStart := out.Len()
out.Write(make([]byte, 4*(blkEnd+1)))
writeBlock := func(data []byte) {
out.Write(data)
}
// Blocks in order: autolib, pkgidx, file, symdef, hashed64def, hasheddef,
// nonpkgdef, nonpkgref, refflags, hash64, hash, relocidx, auxidx, dataidx,
// reloc, aux, data, refname.
writeBlock(nil) // autolib
writeBlock(nil) // pkgidx
writeBlock(fileBlk) // file
writeBlock(symdef) // symdef
writeBlock(nil) // hashed64def
writeBlock(nil) // hasheddef
writeBlock(nonpkgdef) // nonpkgdef
writeBlock(nil) // nonpkgref
writeBlock(nil) // refflags
writeBlock(nil) // hash64
writeBlock(nil) // hash
writeBlock(relocIdx) // relocidx
writeBlock(auxIdx) // auxidx
writeBlock(dataIdx) // dataidx
writeBlock(relocBlk) // reloc
writeBlock(auxBlk) // aux
writeBlock(dataBlk) // data
writeBlock(nil) // refname
// Fill in the block offsets.
le := binary.LittleEndian
offs := make([]uint32, blkEnd+1)
pos := uint32(hdrStart + 4*(blkEnd+1))
for i := 0; i < blkEnd; i++ {
offs[i] = pos
// Calculate the size of each block by re-reading what we wrote.
// This is a simplification; a real implementation would track sizes.
}
offs[blkEnd] = uint32(out.Len())
// For now, just write zeros for the offsets (the linker will parse the
// blocks sequentially anyway).
for i := 0; i <= blkEnd; i++ {
le.PutUint32(out.Bytes()[hdrStart+4*i:], offs[i])
}
return out.Bytes(), nil
}
// RISC-V relocation types (cmd/internal/objabi).
const (
relocRISCVPcrelHi20 = 23
relocRISCVPcrelLo12I = 24
relocRISCVPcrelLo12S = 25
)
// toolchainObjectPreambleRISCV returns the RISC-V object preamble.
var (
preambleRISCVOnce sync.Once
preambleRISCV []byte
preambleRISCVErr error
)
func toolchainObjectPreambleRISCV() ([]byte, error) {
preambleRISCVOnce.Do(func() {
goBin, err := exec.LookPath("go")
if err != nil {
preambleRISCVErr = fmt.Errorf("GOOBJ emission needs the Go toolchain: %w", err)
return
}
dir, err := os.MkdirTemp("", "gasm-preamble-riscv")
if err != nil {
preambleRISCVErr = err
return
}
defer os.RemoveAll(dir)
src := filepath.Join(dir, "probe_riscv64.s")
if err := os.WriteFile(src, []byte("TEXT \u00b7x(SB), $0-0\n\tRET\n"), 0o644); err != nil {
preambleRISCVErr = err
return
}
obj := filepath.Join(dir, "probe.o")
cmd := exec.Command(goBin, "tool", "asm", "-p", "probe", "-o", obj, src)
cmd.Env = append(os.Environ(), "GOARCH=riscv64")
if out, err := cmd.CombinedOutput(); err != nil {
preambleRISCVErr = fmt.Errorf("probing the assembler for the object header: %v\n%s", err, out)
return
}
data, err := os.ReadFile(obj)
if err != nil {
preambleRISCVErr = err
return
}
i := bytes.Index(data, []byte("\n!\n"))
if i < 0 || !bytes.HasPrefix(data[i+3:], []byte(goobjMagic)) {
preambleRISCVErr = fmt.Errorf("unrecognised assembler object layout")
return
}
preambleRISCV = data[:i+3]
})
return preambleRISCV, preambleRISCVErr
}
+159
View File
@@ -0,0 +1,159 @@
// Copyright (c) 2026 Petr Balvín <opensource@petrbalvin.org> (https://petrbalvin.org)
// SPDX-License-Identifier: BSD-3-Clause
//go:build integration
// Package asm integration tests against the production go-libraries kernels.
// These are excluded from the default test run (go test ./...) so that the
// coverage numbers are identical locally and in CI, where go-libraries is
// not checked out. Run them explicitly with: go test -tags=integration ./asm/
package asm
import (
"bytes"
"os"
"testing"
"golang.org/x/arch/x86/x86asm"
"sourcedock.dev/petrbalvin/gasm-devkit/parser"
)
// TestAssembleGoFlacAVX2Kernel assembles the whole production AVX2 kernel —
// all functions plus the file-local mask24 constant — and checks that every
// static-symbol load resolves to the right bytes in the image.
func TestAssembleGoFlacAVX2Kernel(t *testing.T) {
path := "../../go-libraries/go-flac/avx2_amd64.s"
if _, err := os.Stat(path); err != nil {
t.Skip("go-libraries repository not present next to gasm-devkit")
}
src, err := os.ReadFile(path)
if err != nil {
t.Fatal(err)
}
f, errs := parser.Parse(path, string(src))
if len(errs) > 0 {
t.Fatalf("parse: %v", errs)
}
img, err := AssembleFile(f)
if err != nil {
t.Fatalf("AssembleFile: %v", err)
}
if len(img.Funcs) != 17 {
t.Errorf("functions = %d, want 17", len(img.Funcs))
}
// mask24 as the DATA directives define it.
mask := []byte{
0x00, 0x01, 0x02, 0x80, 0x03, 0x04, 0x05, 0x80,
0x06, 0x07, 0x08, 0x80, 0x09, 0x0a, 0x0b, 0x80,
}
image := img.Bytes()
if got := image[img.Symbols["mask24"] : img.Symbols["mask24"]+16]; !bytes.Equal(got, mask) {
t.Errorf("mask24 contents %x, want %x", got, mask)
}
// Every VMOVDQU mask24<>(SB), X15 (c5 7a 6f 3d + rel32, i.e. a VMOVDQU
// with a RIP-relative r/m) must land on the mask bytes within the image.
loads := 0
for _, fn := range img.Funcs {
code := img.Code[fn.Offset : fn.Offset+fn.Size]
for pc := 0; pc < len(code); {
inst, err := x86asm.Decode(code[pc:], 64)
if err != nil {
t.Fatalf("%s: decode at +%d: %v", fn.Name, pc, err)
}
// mod=00, rm=101 → RIP-relative.
if inst.Op == x86asm.VMOVDQU && inst.Len == 8 && code[pc+3]&0xC7 == 0x05 {
rel := int32(uint32(code[pc+4]) | uint32(code[pc+5])<<8 | uint32(code[pc+6])<<16 | uint32(code[pc+7])<<24)
target := fn.Offset + pc + 8 + int(rel)
if !bytes.Equal(image[target:target+16], mask) {
t.Errorf("%s: mask load at +%d lands on %x, want %x", fn.Name, pc, image[target:target+16], mask)
}
loads++
}
pc += inst.Len
}
}
if loads != 2 {
t.Errorf("mask loads found = %d, want 2", loads)
}
}
// TestAssembleGoFlacAVX512Kernel assembles the whole production AVX-512
// kernel — all functions plus the file-global idx16 constant — and checks
// that the static-symbol load resolves to the right bytes in the image.
func TestAssembleGoFlacAVX512Kernel(t *testing.T) {
path := "../../go-libraries/go-flac/avx512_amd64.s"
if _, err := os.Stat(path); err != nil {
t.Skip("go-libraries repository not present next to gasm-devkit")
}
src, err := os.ReadFile(path)
if err != nil {
t.Fatal(err)
}
f, errs := parser.Parse(path, string(src))
if len(errs) > 0 {
t.Fatalf("parse: %v", errs)
}
img, err := AssembleFile(f)
if err != nil {
t.Fatalf("AssembleFile: %v", err)
}
if len(img.Funcs) != 10 {
t.Errorf("functions = %d, want 10", len(img.Funcs))
}
// idx16 as the DATA directives define it: dwords 1..16.
idx := make([]byte, 0, 64)
for i := 1; i <= 16; i++ {
idx = append(idx, byte(i), 0, 0, 0)
}
image := img.Bytes()
base := img.Symbols["idx16"]
if base == 0 {
t.Fatal("idx16 not laid out")
}
if got := image[base : base+64]; hexCompact(got) != hexCompact(idx) {
t.Errorf("idx16 contents %x, want %x", got, idx)
}
// The VMOVDQU32 idx16(SB), Z13 load (62 71 7e 48 6f 2d + rel32) must
// resolve to idx16 within the image.
loads := 0
for _, fn := range img.Funcs {
code := img.Code[fn.Offset : fn.Offset+fn.Size]
pat := []byte{0x62, 0x71, 0x7e, 0x48, 0x6f, 0x2d}
for pos := 0; ; {
i := indexOf(code[pos:], pat)
if i < 0 {
break
}
i += pos
rel := int32(uint32(code[i+6]) | uint32(code[i+7])<<8 | uint32(code[i+8])<<16 | uint32(code[i+9])<<24)
target := fn.Offset + i + 10 + int(rel)
if target != base {
t.Errorf("%s: idx16 load at +%d targets 0x%x, want 0x%x", fn.Name, i, target, base)
}
loads++
pos = i + 10
}
}
if loads != 1 {
t.Errorf("idx16 loads found = %d, want 1", loads)
}
}
// indexOf returns the index of the first occurrence of pat in b, or -1.
func indexOf(b, pat []byte) int {
for i := 0; i+len(pat) <= len(b); i++ {
j := 0
for j < len(pat) && b[i+j] == pat[j] {
j++
}
if j == len(pat) {
return i
}
}
return -1
}
-66
View File
@@ -4,13 +4,9 @@
package asm package asm
import ( import (
"bytes"
"os"
"strings" "strings"
"testing" "testing"
"golang.org/x/arch/x86/x86asm"
"sourcedock.dev/petrbalvin/gasm-devkit/parser" "sourcedock.dev/petrbalvin/gasm-devkit/parser"
) )
@@ -132,65 +128,3 @@ DATA x<>+0(SB)/4, $1
t.Errorf("single-function SB: error %v, want a file-level-assembly error", err) t.Errorf("single-function SB: error %v, want a file-level-assembly error", err)
} }
} }
// TestAssembleGoFlacAVX2Kernel assembles the whole production AVX2 kernel —
// all functions plus the file-local mask24 constant — and checks that every
// static-symbol load resolves to the right bytes in the image. Skipped when
// the sibling repository is not checked out.
func TestAssembleGoFlacAVX2Kernel(t *testing.T) {
path := "../../go-libraries/go-flac/avx2_amd64.s"
if _, err := os.Stat(path); err != nil {
t.Skip("go-libraries repository not present next to gasm-devkit")
}
src, err := os.ReadFile(path)
if err != nil {
t.Fatal(err)
}
f, errs := parser.Parse(path, string(src))
if len(errs) > 0 {
t.Fatalf("parse: %v", errs)
}
img, err := AssembleFile(f)
if err != nil {
t.Fatalf("AssembleFile: %v", err)
}
if len(img.Funcs) != 17 {
t.Errorf("functions = %d, want 17", len(img.Funcs))
}
// mask24 as the DATA directives define it.
mask := []byte{
0x00, 0x01, 0x02, 0x80, 0x03, 0x04, 0x05, 0x80,
0x06, 0x07, 0x08, 0x80, 0x09, 0x0a, 0x0b, 0x80,
}
image := img.Bytes()
if got := image[img.Symbols["mask24"] : img.Symbols["mask24"]+16]; !bytes.Equal(got, mask) {
t.Errorf("mask24 contents %x, want %x", got, mask)
}
// Every VMOVDQU mask24<>(SB), X15 (c5 7a 6f 3d + rel32, i.e. a VMOVDQU
// with a RIP-relative r/m) must land on the mask bytes within the image.
loads := 0
for _, fn := range img.Funcs {
code := img.Code[fn.Offset : fn.Offset+fn.Size]
for pc := 0; pc < len(code); {
inst, err := x86asm.Decode(code[pc:], 64)
if err != nil {
t.Fatalf("%s: decode at +%d: %v", fn.Name, pc, err)
}
// mod=00, rm=101 → RIP-relative.
if inst.Op == x86asm.VMOVDQU && inst.Len == 8 && code[pc+3]&0xC7 == 0x05 {
rel := int32(uint32(code[pc+4]) | uint32(code[pc+5])<<8 | uint32(code[pc+6])<<16 | uint32(code[pc+7])<<24)
target := fn.Offset + pc + 8 + int(rel)
if !bytes.Equal(image[target:target+16], mask) {
t.Errorf("%s: mask load at +%d lands on %x, want %x", fn.Name, pc, image[target:target+16], mask)
}
loads++
}
pc += inst.Len
}
}
if loads != 2 {
t.Errorf("mask loads found = %d, want 2", loads)
}
}
+68 -4
View File
@@ -162,7 +162,7 @@ func encodeRISCVInstr(instr *ast.Instr, pc int, offsets map[string]int, fi riscv
target := labelFromOperand(ops[0]) target := labelFromOperand(ops[0])
targetOff, ok := offsets[target] targetOff, ok := offsets[target]
if !ok { if !ok {
return nil, fmt.Errorf("undefined label %q", target) return nil, fmt.Errorf("undefined label %q%s", target, suggestLabel(target, offsets))
} }
offset := int32(targetOff - pc) offset := int32(targetOff - pc)
// AUIPC X1, upper 20 bits // AUIPC X1, upper 20 bits
@@ -187,7 +187,7 @@ func encodeRISCVInstr(instr *ast.Instr, pc int, offsets map[string]int, fi riscv
} }
targetOff, ok := offsets[target] targetOff, ok := offsets[target]
if !ok { if !ok {
return nil, fmt.Errorf("undefined label %q", target) return nil, fmt.Errorf("undefined label %q%s", target, suggestLabel(target, offsets))
} }
offset := int32(targetOff - pc) offset := int32(targetOff - pc)
// C.J: funct3=0x5, offset in ±2 KB, bit 0 must be 0. // C.J: funct3=0x5, offset in ±2 KB, bit 0 must be 0.
@@ -208,7 +208,7 @@ func encodeRISCVInstr(instr *ast.Instr, pc int, offsets map[string]int, fi riscv
} }
targetOff, ok := offsets[target] targetOff, ok := offsets[target]
if !ok { if !ok {
return nil, fmt.Errorf("undefined label %q", target) return nil, fmt.Errorf("undefined label %q%s", target, suggestLabel(target, offsets))
} }
offset := int32(targetOff - pc) offset := int32(targetOff - pc)
// JAL X0, target → C.J when offset fits. // JAL X0, target → C.J when offset fits.
@@ -435,7 +435,7 @@ func encodeRISCVInstr(instr *ast.Instr, pc int, offsets map[string]int, fi riscv
target := labelFromOperand(ops[2]) target := labelFromOperand(ops[2])
targetOff, ok := offsets[target] targetOff, ok := offsets[target]
if !ok { if !ok {
return nil, fmt.Errorf("undefined label %q", target) return nil, fmt.Errorf("undefined label %q%s", target, suggestLabel(target, offsets))
} }
offset := int32(targetOff - pc) offset := int32(targetOff - pc)
if rs1 < 0 || rs2 < 0 { if rs1 < 0 || rs2 < 0 {
@@ -1064,3 +1064,67 @@ func labelFromOperand(op *ast.Operand) string {
} }
return op.Raw return op.Raw
} }
// suggestLabel returns a "did you mean" suggestion for an undefined label.
func suggestLabel(target string, offsets map[string]int) string {
if len(offsets) == 0 {
return ""
}
// Find the closest matching label using Levenshtein distance.
bestDist := len(target) + 1
var best string
for name := range offsets {
dist := levenshtein(target, name)
if dist < bestDist {
bestDist = dist
best = name
}
}
// Only suggest if the distance is small enough.
if bestDist <= 3 && bestDist < len(target)/2+1 {
return fmt.Sprintf(" — did you mean %q?", best)
}
return ""
}
// levenshtein computes the Levenshtein distance between two strings.
func levenshtein(a, b string) int {
la, lb := len(a), len(b)
if la == 0 {
return lb
}
if lb == 0 {
return la
}
// Create a matrix of distances.
prev := make([]int, lb+1)
curr := make([]int, lb+1)
for j := 0; j <= lb; j++ {
prev[j] = j
}
for i := 1; i <= la; i++ {
curr[0] = i
for j := 1; j <= lb; j++ {
cost := 1
if a[i-1] == b[j-1] {
cost = 0
}
curr[j] = min3(curr[j-1]+1, prev[j]+1, prev[j-1]+cost)
}
prev, curr = curr, prev
}
return prev[lb]
}
func min3(a, b, c int) int {
if a < b {
if a < c {
return a
}
return c
}
if b < c {
return b
}
return c
}
+101 -6
View File
@@ -9,6 +9,7 @@ import (
"fmt" "fmt"
"os" "os"
"sort" "sort"
"strings"
"sourcedock.dev/petrbalvin/gasm-devkit/debug" "sourcedock.dev/petrbalvin/gasm-devkit/debug"
"sourcedock.dev/petrbalvin/gasm-devkit/verify" "sourcedock.dev/petrbalvin/gasm-devkit/verify"
@@ -32,6 +33,7 @@ REPL commands:
target := fs.Bool("target", false, "") // hidden: debuggee subprocess mode target := fs.Bool("target", false, "") // hidden: debuggee subprocess mode
funcName := fs.String("func", "", "function to debug") funcName := fs.String("func", "", "function to debug")
argsFile := fs.String("args", "", "file containing the ABI0 argument block") argsFile := fs.String("args", "", "file containing the ABI0 argument block")
bufSpec := fs.String("buf", "", "buffer specification: name:size:pattern[,name:size:pattern...] where pattern is zero, ones, seq, or hex")
fs.Parse(args) fs.Parse(args)
// --- Debuggee mode (internal, spawned by the debugger) --- // --- Debuggee mode (internal, spawned by the debugger) ---
@@ -76,12 +78,79 @@ REPL commands:
} }
sort.Slice(labels, func(i, j int) bool { return labels[i].Offset < labels[j].Offset }) sort.Slice(labels, func(i, j int) bool { return labels[i].Offset < labels[j].Offset })
// Launch the debuggee with a zeroed argument block. // Launch the debuggee with the argument block.
argBlock := make([]byte, fl.Args) var argBlock []byte
sess, err := debug.Launch("", path, *funcName, argBlock) var bufAddrs []uint64
if err != nil { var sess *debug.Session
fmt.Fprintf(os.Stderr, "gasm debug: %v\n", err) if *bufSpec != "" {
return 1 // Parse the function signature to determine argument layout.
src, err := readSource(path)
if err != nil {
fmt.Fprintf(os.Stderr, "gasm debug: %v\n", err)
return 1
}
sig, ok := verify.ExtractFuncSig(src, *funcName)
if !ok {
fmt.Fprintf(os.Stderr, "gasm debug: no // func signature found for %s\n", *funcName)
return 1
}
layout := verify.ArgLayout(sig)
// Parse the buffer spec to get buffer names.
bufNames := parseBufNames(*bufSpec)
// Allocate buffers in the debuggee.
argBlock = make([]byte, fl.Args)
sess, bufAddrs, err = debug.LaunchWithBuffers("", path, *funcName, argBlock, *bufSpec)
if err != nil {
fmt.Fprintf(os.Stderr, "gasm debug: %v\n", err)
return 1
}
// Construct the argument block with buffer pointers at the correct positions.
bufIdx := 0
for _, arg := range layout {
if !arg.IsPtr {
continue
}
// Find the buffer that matches this argument.
for i, name := range bufNames {
if i < len(bufAddrs) && (name == arg.Name || strings.HasPrefix(arg.Name, name)) {
addr := bufAddrs[i]
off := arg.Offset
if off+8 <= len(argBlock) {
argBlock[off] = byte(addr)
argBlock[off+1] = byte(addr >> 8)
argBlock[off+2] = byte(addr >> 16)
argBlock[off+3] = byte(addr >> 24)
argBlock[off+4] = byte(addr >> 32)
argBlock[off+5] = byte(addr >> 40)
argBlock[off+6] = byte(addr >> 48)
argBlock[off+7] = byte(addr >> 56)
}
// For slices, also set the length and capacity.
if strings.HasPrefix(arg.Typ, "[]") && off+24 <= len(argBlock) {
// Find the buffer size from the spec.
size := parseBufSize(*bufSpec, name)
// Length at offset+8, capacity at offset+16.
for j := 0; j < 8; j++ {
argBlock[off+8+j] = byte(size >> (j * 8))
argBlock[off+16+j] = byte(size >> (j * 8))
}
}
bufIdx++
break
}
}
}
_ = bufIdx
} else {
argBlock = make([]byte, fl.Args)
sess, err = debug.Launch("", path, *funcName, argBlock)
if err != nil {
fmt.Fprintf(os.Stderr, "gasm debug: %v\n", err)
return 1
}
} }
defer sess.Kill() defer sess.Kill()
@@ -96,3 +165,29 @@ REPL commands:
debug.REPL(sess, bm, sess.CodeBase(), fl.Offset, fl.Size, fl.Args, labels, srcLines) debug.REPL(sess, bm, sess.CodeBase(), fl.Offset, fl.Size, fl.Args, labels, srcLines)
return 0 return 0
} }
// parseBufNames extracts buffer names from a buffer specification.
// Format: name:size:pattern[,name:size:pattern...]
func parseBufNames(spec string) []string {
var names []string
for _, part := range strings.Split(spec, ",") {
fields := strings.SplitN(part, ":", 3)
if len(fields) >= 1 && fields[0] != "" {
names = append(names, fields[0])
}
}
return names
}
// parseBufSize extracts the size of a named buffer from a buffer specification.
func parseBufSize(spec, name string) int {
for _, part := range strings.Split(spec, ",") {
fields := strings.SplitN(part, ":", 3)
if len(fields) >= 2 && fields[0] == name {
var size int
fmt.Sscanf(fields[1], "%d", &size)
return size
}
}
return 0
}
+371 -13
View File
@@ -16,6 +16,7 @@ import (
"os" "os"
"os/exec" "os/exec"
"path/filepath" "path/filepath"
"sort"
"strconv" "strconv"
"strings" "strings"
"syscall" "syscall"
@@ -33,7 +34,7 @@ import (
// version is the release version, stamped at build time via // version is the release version, stamped at build time via
// -ldflags "-X main.version=…" (defaulting to the current release). // -ldflags "-X main.version=…" (defaulting to the current release).
var version = "0.28.0" var version = "0.29.0"
func main() { func main() {
if len(os.Args) < 2 { if len(os.Args) < 2 {
@@ -55,6 +56,10 @@ func main() {
os.Exit(cmdVerify(os.Args[2:])) os.Exit(cmdVerify(os.Args[2:]))
case "debug": case "debug":
os.Exit(cmdDebug(os.Args[2:])) os.Exit(cmdDebug(os.Args[2:]))
case "diff":
os.Exit(cmdDiff(os.Args[2:]))
case "profile":
os.Exit(cmdProfile(os.Args[2:]))
case "lsp": case "lsp":
os.Exit(cmdLSP(os.Args[2:])) os.Exit(cmdLSP(os.Args[2:]))
case "version", "--version", "-V": case "version", "--version", "-V":
@@ -115,6 +120,8 @@ func usage(w io.Writer) {
{"asm", "assemble .s files to machine code (amd64, riscv64)"}, {"asm", "assemble .s files to machine code (amd64, riscv64)"},
{"verify", "JIT-assemble and run dynamic checks (amd64, riscv64)"}, {"verify", "JIT-assemble and run dynamic checks (amd64, riscv64)"},
{"debug", "interactive source-level debugger (amd64)"}, {"debug", "interactive source-level debugger (amd64)"},
{"diff", "compare machine code of two .s files"},
{"profile", "show basic-block structure of functions"},
{"lsp", "run the language server over stdio"}, {"lsp", "run the language server over stdio"},
{"version", "print the version (same as --version)"}, {"version", "print the version (same as --version)"},
} }
@@ -505,7 +512,11 @@ requires -p, the package path, and the installed Go toolchain).
obj, err = img.MachOObject() obj, err = img.MachOObject()
kind = "Mach-O object" kind = "Mach-O object"
case "goobj": case "goobj":
obj, err = img.GOObject(*pkg, path) if targetArch == arch.RISCV {
obj, err = img.GOObjectRISCV(*pkg, path)
} else {
obj, err = img.GOObject(*pkg, path)
}
kind = "Go object" kind = "Go object"
default: default:
fmt.Fprintf(os.Stderr, "gasm asm: unknown format %q (want raw, elf, macho or goobj)\n", *format) fmt.Fprintf(os.Stderr, "gasm asm: unknown format %q (want raw, elf, macho or goobj)\n", *format)
@@ -524,6 +535,202 @@ requires -p, the package path, and the installed Go toolchain).
return 0 return 0
} }
// cmdDiff compares the machine code of two assembly files.
func cmdDiff(args []string) int {
fs := newCommand("diff", "gasm diff <file1.s> <file2.s>", `
Compare the machine code produced by assembling two files.
Shows which functions differ and the byte-level differences.
Useful for verifying that two implementations produce identical code,
or for tracking encoding changes between Go assembler versions.
Use --map to compare functions whose names differ between the files,
e.g. --map wideCopyAVX2=wideCopyAVX512 pairs the two regardless of suffix.
`)
mapSpec := fs.String("map", "", "comma-separated old=new pairs to match functions with different names")
fs.Parse(args)
if fs.NArg() != 2 {
fmt.Fprintln(os.Stderr, "usage: gasm diff <file1.s> <file2.s>")
return 2
}
path1, path2 := fs.Arg(0), fs.Arg(1)
// Parse the name mapping (file1 name → file2 name).
nameMap := make(map[string]string)
if *mapSpec != "" {
for _, pair := range strings.Split(*mapSpec, ",") {
old, new, ok := strings.Cut(pair, "=")
if !ok || old == "" || new == "" {
fmt.Fprintf(os.Stderr, "gasm diff: invalid --map pair %q (expected old=new)\n", pair)
return 2
}
nameMap[old] = new
}
}
// Assemble both files.
img1, err := assembleFile(path1)
if err != nil {
fmt.Fprintf(os.Stderr, "gasm diff: %s: %v\n", path1, err)
return 1
}
img2, err := assembleFile(path2)
if err != nil {
fmt.Fprintf(os.Stderr, "gasm diff: %s: %v\n", path2, err)
return 1
}
// Compare functions by name, honouring the --map overrides.
funcs1 := make(map[string][]byte)
for _, fn := range img1.Funcs {
funcs1[fn.Name] = img1.Code[fn.Offset : fn.Offset+fn.Size]
}
funcs2 := make(map[string][]byte)
for _, fn := range img2.Funcs {
funcs2[fn.Name] = img2.Code[fn.Offset : fn.Offset+fn.Size]
}
// Track which file2 functions were consumed (by direct match or via --map)
// so the "only in file2" pass skips them.
matched2 := make(map[string]bool)
diffs := 0
for name, code1 := range funcs1 {
target := name
if mapped, ok := nameMap[name]; ok {
target = mapped
}
code2, ok := funcs2[target]
if !ok {
fmt.Printf("%s: only in %s\n", name, path1)
diffs++
continue
}
matched2[target] = true
label := name
if target != name {
label = name + " → " + target
}
if !bytes.Equal(code1, code2) {
fmt.Printf("%s: DIFFERS (%d vs %d bytes)\n", label, len(code1), len(code2))
printByteDiff(code1, code2)
diffs++
} else {
fmt.Printf("%s: identical (%d bytes)\n", label, len(code1))
}
}
for name := range funcs2 {
if !matched2[name] {
fmt.Printf("%s: only in %s\n", name, path2)
diffs++
}
}
if diffs == 0 {
fmt.Println("all functions identical")
return 0
}
return 1
}
// assembleFile assembles a file and returns the image.
func assembleFile(path string) (*asm.Image, error) {
src, err := readSource(path)
if err != nil {
return nil, err
}
f, errs := parser.Parse(path, src)
for _, e := range errs {
fmt.Fprintf(os.Stderr, "%s: %v\n", path, e)
}
if len(errs) > 0 {
return nil, fmt.Errorf("parse errors")
}
targetArch := arch.FromFilename(path)
if targetArch == arch.RISCV {
return asm.AssembleFileRISCV(f)
}
return asm.AssembleFile(f)
}
// printByteDiff shows the first few byte differences between two code blocks.
func printByteDiff(a, b []byte) {
maxLen := len(a)
if len(b) < maxLen {
maxLen = len(b)
}
shown := 0
for i := 0; i < maxLen && shown < 8; i++ {
if a[i] != b[i] {
fmt.Printf(" offset %#04x: %02x vs %02x\n", i, a[i], b[i])
shown++
}
}
if len(a) != len(b) {
fmt.Printf(" length: %d vs %d\n", len(a), len(b))
}
}
// cmdProfile shows the basic-block structure of functions in an assembly file.
func cmdProfile(args []string) int {
fs := newCommand("profile", "gasm profile <file.s>", `
Show the basic-block structure of functions in an assembly file.
Lists each function's labels, their offsets, and the block boundaries.
This is the static structure; for runtime execution counts, use
gasm verify --fuzz which exercises the code paths.
`)
fs.Parse(args)
if fs.NArg() != 1 {
fmt.Fprintln(os.Stderr, "usage: gasm profile <file.s>")
return 2
}
path := fs.Arg(0)
// Load the file to get function metadata.
k, err := verify.Load(path)
if err != nil {
fmt.Fprintf(os.Stderr, "gasm profile: %v\n", err)
return 1
}
defer k.Close()
for _, name := range k.FuncNames() {
fl, err := k.Func(name)
if err != nil {
continue
}
fmt.Printf("%s: %d bytes, args=%d, frame=%d", name, fl.Size, fl.Args, fl.Frame)
if fl.NoSplit {
fmt.Printf(" NOSPLIT")
}
fmt.Println()
// Show labels and their offsets.
if len(fl.Labels) > 0 {
fmt.Println(" labels:")
// Sort labels by offset.
type labelOff struct {
name string
off int
}
var labels []labelOff
for name, off := range fl.Labels {
labels = append(labels, labelOff{name, off})
}
sort.Slice(labels, func(i, j int) bool { return labels[i].off < labels[j].off })
for _, l := range labels {
fmt.Printf(" %-20s +%#04x\n", l.name, l.off)
}
}
// Show basic blocks.
blocks, err := k.Blocks(name)
if err == nil && len(blocks) > 0 {
fmt.Printf(" basic blocks: %d\n", len(blocks))
}
}
return 0
}
// cmdVerifyRISCV handles the verify subcommand for RISC-V files. // cmdVerifyRISCV handles the verify subcommand for RISC-V files.
// JIT requires RISC-V hardware; only ground-truth and profile are available. // JIT requires RISC-V hardware; only ground-truth and profile are available.
func cmdVerifyRISCV(path string, groundTruth, profile bool) int { func cmdVerifyRISCV(path string, groundTruth, profile bool) int {
@@ -616,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
@@ -629,18 +836,33 @@ 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)")
abiN := fs.Int("abi-n", 100, "number of ABI check iterations with varied inputs")
profile := fs.Bool("profile", false, "list basic-block structure per function") 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") groundTruth := fs.Bool("ground-truth", false, "compare machine code byte-for-byte against go tool asm")
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)
@@ -666,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)
@@ -823,16 +1050,28 @@ With -profile, the static basic-block structure is listed for each function.
} }
if *abi && fl.NoSplit { if *abi && fl.NoSplit {
args := make([]byte, fl.Args) // Try varied-input ABI fuzzing first.
_, report, err := k.CallFuncChecked(name, args) if src, err := readSource(path); err == nil {
if err != nil { result := k.FuzzFuncCheckedByName(name, src, *abiN, int64(*abiN))
fmt.Printf(" abi: FAIL — %v\n", err) if result.Mismatches > 0 {
rc = 1 fmt.Printf(" abi: %s\n", result)
} else if !report.OK() { rc = 1
fmt.Printf(" abi: %s\n", report) } else {
rc = 1 fmt.Printf(" abi: clean (%d varied inputs)\n", result.Matches)
}
} else { } else {
fmt.Printf(" abi: clean\n") // Fallback: single zeroed-arg call.
args := make([]byte, fl.Args)
_, report, err := k.CallFuncChecked(name, args)
if err != nil {
fmt.Printf(" abi: FAIL — %v\n", err)
rc = 1
} else if !report.OK() {
fmt.Printf(" abi: %s\n", report)
rc = 1
} else {
fmt.Printf(" abi: clean\n")
}
} }
} }
} }
@@ -875,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 ""
}
+113 -6
View File
@@ -33,9 +33,17 @@ type Session struct {
// JIT code, calls PTRACE_TRACEME and raises SIGSTOP; Launch waits for // JIT code, calls PTRACE_TRACEME and raises SIGSTOP; Launch waits for
// that initial stop and returns a ready Session. // that initial stop and returns a ready Session.
func Launch(gasmBin, asmPath, funcName string, args []byte) (*Session, error) { func Launch(gasmBin, asmPath, funcName string, args []byte) (*Session, error) {
sess, _, err := LaunchWithBuffers(gasmBin, asmPath, funcName, args, "")
return sess, err
}
// LaunchWithBuffers is like Launch but also allocates buffers in the debuggee
// based on the buffer specification. Returns the Session and the buffer
// addresses (in the order they appear in the spec).
func LaunchWithBuffers(gasmBin, asmPath, funcName string, args []byte, bufSpec string) (*Session, []uint64, error) {
self, err := os.Executable() self, err := os.Executable()
if err != nil { if err != nil {
return nil, fmt.Errorf("debug: cannot find gasm binary: %w", err) return nil, nil, fmt.Errorf("debug: cannot find gasm binary: %w", err)
} }
if gasmBin != "" { if gasmBin != "" {
self = gasmBin self = gasmBin
@@ -44,12 +52,20 @@ func Launch(gasmBin, asmPath, funcName string, args []byte) (*Session, error) {
// Write the arg block to a temp file (the child reads it). // Write the arg block to a temp file (the child reads it).
tmpDir, err := os.MkdirTemp("", "gasm-debug-*") tmpDir, err := os.MkdirTemp("", "gasm-debug-*")
if err != nil { if err != nil {
return nil, fmt.Errorf("debug: tempdir: %w", err) return nil, nil, fmt.Errorf("debug: tempdir: %w", err)
} }
argsFile := filepath.Join(tmpDir, "args.bin") argsFile := filepath.Join(tmpDir, "args.bin")
if err := os.WriteFile(argsFile, args, 0o644); err != nil { if err := os.WriteFile(argsFile, args, 0o644); err != nil {
os.RemoveAll(tmpDir) os.RemoveAll(tmpDir)
return nil, fmt.Errorf("debug: write args: %w", err) return nil, nil, fmt.Errorf("debug: write args: %w", err)
}
// Write the buffer spec if present.
if bufSpec != "" {
if err := os.WriteFile(filepath.Join(tmpDir, "bufspec"), []byte(bufSpec), 0o644); err != nil {
os.RemoveAll(tmpDir)
return nil, nil, fmt.Errorf("debug: write bufspec: %w", err)
}
} }
cmd := exec.Command(self, "debug", "--target", "--func", funcName, "--args", argsFile, asmPath) cmd := exec.Command(self, "debug", "--target", "--func", funcName, "--args", argsFile, asmPath)
@@ -60,7 +76,7 @@ func Launch(gasmBin, asmPath, funcName string, args []byte) (*Session, error) {
if err := cmd.Start(); err != nil { if err := cmd.Start(); err != nil {
os.RemoveAll(tmpDir) os.RemoveAll(tmpDir)
return nil, fmt.Errorf("debug: start debuggee: %w", err) return nil, nil, fmt.Errorf("debug: start debuggee: %w", err)
} }
s := &Session{pid: cmd.Process.Pid, cmd: cmd} s := &Session{pid: cmd.Process.Pid, cmd: cmd}
@@ -79,8 +95,28 @@ func Launch(gasmBin, asmPath, funcName string, args []byte) (*Session, error) {
if _, err := syscall.Wait4(s.pid, &ws, syscall.WUNTRACED, nil); err != nil { if _, err := syscall.Wait4(s.pid, &ws, syscall.WUNTRACED, nil); err != nil {
cmd.Process.Kill() cmd.Process.Kill()
os.RemoveAll(tmpDir) os.RemoveAll(tmpDir)
return nil, fmt.Errorf("debug: wait for debuggee: %w", err) return nil, nil, fmt.Errorf("debug: wait for stop: %w", err)
} }
// Wait for the debuggee to reach the function entry point.
entryFile := filepath.Join(tmpDir, "entry")
for i := 0; i < 500; i++ {
if _, err := os.Stat(entryFile); err == nil {
break
}
time.Sleep(5 * time.Millisecond)
}
// Continue the debuggee to the entry point.
if err := s.Continue(); err != nil {
return nil, nil, fmt.Errorf("debug: continue to entry: %w", err)
}
// Wait for the entry stop.
if _, err := syscall.Wait4(s.pid, &ws, syscall.WUNTRACED, nil); err != nil {
return nil, nil, fmt.Errorf("debug: wait for entry: %w", err)
}
s.stopped = true s.stopped = true
// Read the code base from /proc/pid/maps (find the RWX mapping). // Read the code base from /proc/pid/maps (find the RWX mapping).
@@ -93,7 +129,21 @@ func Launch(gasmBin, asmPath, funcName string, args []byte) (*Session, error) {
} }
} }
return s, nil // Read buffer addresses if buffers were allocated.
var bufAddrs []uint64
if bufSpec != "" {
addrFile := filepath.Join(tmpDir, "bufaddrs")
if data, err := os.ReadFile(addrFile); err == nil {
for _, line := range strings.Split(strings.TrimSpace(string(data)), "\n") {
var addr uint64
if _, err := fmt.Sscanf(line, "%d", &addr); err == nil {
bufAddrs = append(bufAddrs, addr)
}
}
}
}
return s, bufAddrs, nil
} }
// wait waits for the debuggee to stop and returns the wait status. // wait waits for the debuggee to stop and returns the wait status.
@@ -144,6 +194,63 @@ func (s *Session) SetRegs(regs *Regs) error {
return nil return nil
} }
// FPRegs holds the x87 FPU and SSE (XMM) register state from PTRACE_GETFPREGS.
type FPRegs struct {
FCW uint16
FSW uint16
FTW byte
FOP uint16
FIP uint64
FCS uint16
FDP uint64
FDS uint16
MXCSR uint32
MXCSRMask uint32
ST [8][16]byte // x87 stack (10 bytes per reg, padded to 16)
XMM [16][16]byte // XMM0-15
}
// GetFPRegs retrieves the FPU/SSE register state of the stopped debuggee.
func (s *Session) GetFPRegs() (FPRegs, error) {
var fp FPRegs
_, _, errno := syscall.Syscall6(
syscall.SYS_PTRACE,
uintptr(syscall.PTRACE_GETFPREGS),
uintptr(s.pid),
0,
uintptr(unsafe.Pointer(&fp)),
0, 0,
)
if errno != 0 {
return fp, fmt.Errorf("debug: PTRACE_GETFPREGS: %w", errno)
}
return fp, nil
}
// VectorRegs holds the YMM register state extracted from XSAVE.
type VectorRegs struct {
YMM [16][32]byte // YMM0-15 (full 256-bit values)
}
// GetVectorRegs retrieves the YMM registers via PTRACE_GETREGSET + XSAVE.
// Falls back to XMM if XSAVE is unavailable.
func (s *Session) GetVectorRegs() (VectorRegs, error) {
var v VectorRegs
fp, err := s.GetFPRegs()
if err != nil {
return v, err
}
// PTRACE_GETFPREGS gives XMM registers (lower 128 bits).
// For YMM we'd need XSAVE; for now, copy XMM and zero the upper half.
for i := 0; i < 16; i++ {
for j := 0; j < 16; j++ {
v.YMM[i][j] = fp.XMM[i][j]
}
// Upper 128 bits would come from XSAVE, not available via GETFPREGS.
}
return v, nil
}
// Peek reads a word (8 bytes) from the debuggee's memory at addr. // Peek reads a word (8 bytes) from the debuggee's memory at addr.
// Uses /proc/pid/mem which works reliably with Go's multi-threaded runtime. // Uses /proc/pid/mem which works reliably with Go's multi-threaded runtime.
func (s *Session) Peek(addr uint64) (uint64, error) { func (s *Session) Peek(addr uint64) (uint64, error) {
+28 -15
View File
@@ -34,21 +34,7 @@ type SourceLine struct {
func REPL(s *Session, bm *Breakpoints, codeBase uint64, funcOffset, funcSize, argsSize int, labels []Label, lines []SourceLine) { func REPL(s *Session, bm *Breakpoints, codeBase uint64, funcOffset, funcSize, argsSize int, labels []Label, lines []SourceLine) {
entryAddr := codeBase + uint64(funcOffset) entryAddr := codeBase + uint64(funcOffset)
// Run to the function entry. // The debuggee is already stopped at the function entry point.
bp, err := bm.Set(entryAddr, "(entry)")
if err != nil {
fmt.Printf("warning: cannot set entry breakpoint: %v\n", err)
} else {
if err := s.Continue(); err != nil {
fmt.Printf("warning: continue to entry: %v\n", err)
}
regs, _ := s.GetRegs()
bm.HandleTrap(&regs)
// Remove the temporary entry breakpoint.
bm.Clear(entryAddr)
_ = bp
}
fmt.Printf("stopped at function entry: %#x (%d bytes)\n", entryAddr, funcSize) fmt.Printf("stopped at function entry: %#x (%d bytes)\n", entryAddr, funcSize)
fmt.Println("commands: break <label|addr> | step [n] | continue | disas [n] | regs | where | x <addr> [len] | w <addr> <val...> | labels | quit") fmt.Println("commands: break <label|addr> | step [n] | continue | disas [n] | regs | where | x <addr> [len] | w <addr> <val...> | labels | quit")
@@ -78,6 +64,13 @@ func REPL(s *Session, bm *Breakpoints, codeBase uint64, funcOffset, funcSize, ar
continue continue
} }
printRegs(&regs, codeBase, uint64(funcOffset)) printRegs(&regs, codeBase, uint64(funcOffset))
// Also show vector registers.
vregs, err := s.GetVectorRegs()
if err != nil {
fmt.Printf(" (vector regs unavailable: %v)\n", err)
} else {
printVectorRegs(&vregs)
}
case "step", "s": case "step", "s":
n := 1 n := 1
@@ -508,6 +501,26 @@ func printRegs(regs *Regs, codeBase, funcOff uint64) {
fmt.Printf(" RFLAGS = %#x [%s]\n", regs.RFLAGS, decodeRflags(regs.RFLAGS)) fmt.Printf(" RFLAGS = %#x [%s]\n", regs.RFLAGS, decodeRflags(regs.RFLAGS))
} }
// printVectorRegs displays the YMM registers.
func printVectorRegs(v *VectorRegs) {
fmt.Println("\n Vector registers (YMM):")
for i := 0; i < 16; i += 2 {
fmt.Printf(" YMM%-2d = ", i)
printYMM(v.YMM[i][:])
fmt.Printf(" YMM%-2d = ", i+1)
printYMM(v.YMM[i+1][:])
fmt.Println()
}
}
func printYMM(b []byte) {
// Show as 8 32-bit values.
for j := 0; j < 32; j += 4 {
v := uint32(b[j]) | uint32(b[j+1])<<8 | uint32(b[j+2])<<16 | uint32(b[j+3])<<24
fmt.Printf("%08x ", v)
}
}
func decodeRflags(f uint64) string { func decodeRflags(f uint64) string {
var flags string var flags string
if f&1 != 0 { if f&1 != 0 {
+96
View File
@@ -6,9 +6,12 @@
package debug package debug
import ( import (
"encoding/hex"
"fmt" "fmt"
"os" "os"
"runtime" "runtime"
"strconv"
"strings"
"syscall" "syscall"
"unsafe" "unsafe"
@@ -85,6 +88,15 @@ func RunTarget(asmPath, funcName, argsFile, tmpDir string) error {
args = padded args = padded
} }
// Read buffer specification if present.
bufSpecFile := tmpDir + "/bufspec"
if bufSpec, err := os.ReadFile(bufSpecFile); err == nil && len(bufSpec) > 0 {
args, err = setupBuffers(string(bufSpec), args, fl.Args, tmpDir)
if err != nil {
return fmt.Errorf("debug target: setup buffers: %w", err)
}
}
// Lock this goroutine to the current OS thread so the parent's // Lock this goroutine to the current OS thread so the parent's
// ptrace (attached to this thread) controls the JIT execution. // ptrace (attached to this thread) controls the JIT execution.
runtime.LockOSThread() runtime.LockOSThread()
@@ -100,6 +112,11 @@ func RunTarget(asmPath, funcName, argsFile, tmpDir string) error {
// --- Execution resumes here after the parent continues us --- // --- Execution resumes here after the parent continues us ---
// Stop at the function entry point so the debugger can set breakpoints.
// The parent will continue us when ready.
os.WriteFile(tmpDir+"/entry", []byte("ok"), 0o644)
syscall.Kill(syscall.Getpid(), syscall.SIGSTOP)
// Prepare the ABI0 stack and call the function. // Prepare the ABI0 stack and call the function.
fnAddr := codeBase + uintptr(fl.Offset) fnAddr := codeBase + uintptr(fl.Offset)
stackArgs := make([]byte, fl.Args) stackArgs := make([]byte, fl.Args)
@@ -129,3 +146,82 @@ func mapRWX(code []byte) ([]byte, error) {
copy(mem, code) copy(mem, code)
return mem, nil return mem, nil
} }
// setupBuffers allocates buffers in the debuggee's memory and updates the
// argument block with pointers to them.
// Format: name:size:pattern[,name:size:pattern...]
// Patterns: zero, ones, seq, or hex (e.g. "deadbeef").
func setupBuffers(spec string, args []byte, argSize int, tmpDir string) ([]byte, error) {
// Parse the buffer spec.
type bufSpec struct {
name string
size int
pattern string
}
var specs []bufSpec
for _, part := range strings.Split(spec, ",") {
fields := strings.SplitN(part, ":", 3)
if len(fields) != 3 {
continue
}
size, err := strconv.Atoi(fields[1])
if err != nil || size <= 0 {
continue
}
specs = append(specs, bufSpec{name: fields[0], size: size, pattern: fields[2]})
}
if len(specs) == 0 {
return args, nil
}
// Allocate buffers and write their addresses to a file for the parent.
var bufAddrs []uint64
for _, s := range specs {
buf, err := syscall.Mmap(-1, 0, s.size,
syscall.PROT_READ|syscall.PROT_WRITE,
syscall.MAP_PRIVATE|syscall.MAP_ANON)
if err != nil {
return nil, fmt.Errorf("mmap buffer %s: %w", s.name, err)
}
fillBuffer(buf, s.pattern)
bufAddrs = append(bufAddrs, uint64(uintptr(unsafe.Pointer(&buf[0]))))
}
// Write buffer addresses to a file for the parent to read.
addrFile, err := os.Create(tmpDir + "/bufaddrs")
if err != nil {
return nil, err
}
for _, addr := range bufAddrs {
fmt.Fprintf(addrFile, "%d\n", addr)
}
addrFile.Close()
// For now, return the args unchanged. The parent will read bufaddrs
// and construct the final argument block with the correct pointers.
return args, nil
}
// fillBuffer fills a buffer with the specified pattern.
func fillBuffer(buf []byte, pattern string) {
switch pattern {
case "zero":
// Already zeroed by mmap.
case "ones":
for i := range buf {
buf[i] = 0xFF
}
case "seq":
for i := range buf {
buf[i] = byte(i)
}
default:
// Try to parse as hex.
if data, err := hex.DecodeString(pattern); err == nil && len(data) > 0 {
for i := range buf {
buf[i] = data[i%len(data)]
}
}
}
}
+25 -3
View File
@@ -194,6 +194,13 @@ with the Plan 9 operand order (source first) mapped onto the x86 encoding.
Every encoding is validated by decoding it again with `golang.org/x/arch` — the Every encoding is validated by decoding it again with `golang.org/x/arch` — the
one module dependency, used in tests only and never linked into the binary. one module dependency, used in tests only and never linked into the binary.
A **RISC-V encoder** (Phase 5, RV64IMAFDC + RVC compression) encodes the full
integer, atomic, float/double, FMA and CSR instruction sets with the MOV
pseudo-instruction and SB/global symbol references (AUIPC pairs with
R_RISCV_PCREL_HI20/LO12 relocations). The encoder compresses eligible
instructions to 16-bit RVC forms and is validated byte-for-byte against
`GOARCH=riscv64 go tool asm`.
On top of the encoder, `Assemble` walks a parsed `TEXT` body, converts each On top of the encoder, `Assemble` walks a parsed `TEXT` body, converts each
operand to an encoder operand, and lays the instructions out so local labels operand to an encoder operand, and lays the instructions out so local labels
resolve to relative jump offsets: jumps start in the short (rel8) form and resolve to relative jump offsets: jumps start in the short (rel8) form and
@@ -281,7 +288,9 @@ boundaries, plus flat `pcfile`, `pcline` and `pcinline` tables — so a
gasm-assembled object drops into a `go build` in place of the toolchain's. gasm-assembled object drops into a `go build` in place of the toolchain's.
The object preamble (the version-and-experiment header the linker compares The object preamble (the version-and-experiment header the linker compares
verbatim) is captured from the installed `go tool asm`, so the output is verbatim) is captured from the installed `go tool asm`, so the output is
always consistent with the toolchain that links it. External cross-package always consistent with the toolchain that links it. RISC-V GOOBJ emission
uses the same format with the RISC-V architecture marker and RISC-V relocation
types. External cross-package
references and the implicit funcdata/DWARF symbols remain future work (the references and the implicit funcdata/DWARF symbols remain future work (the
linker fills the latter's defaults); the rest of Phase 2 is those, the linker fills the latter's defaults); the rest of Phase 2 is those, the
remaining EVEX forms and the other architectures. remaining EVEX forms and the other architectures.
@@ -309,7 +318,18 @@ assembler’s `Image.Bytes()` provides the code-and-data concatenation.
The `gasm verify` CLI subcommand exposes this: it loads a file, reports the The `gasm verify` CLI subcommand exposes this: it loads a file, reports the
available functions and (with `-smoke`) calls each NOSPLIT function with zeroed available functions and (with `-smoke`) calls each NOSPLIT function with zeroed
arguments to confirm the trampoline round-trips. arguments to confirm the trampoline round-trips. `gasm verify --fuzz` combines
ABI checks (sentinel registers, canary, stack bounds) with differential fuzz
testing, comparing the JIT-assembled kernel against the portable Go reference
bit-for-bit while verifying the ABI contract on every iteration. When a fuzz
iteration crashes or mismatches, `FuzzResult.CrashInput` stores the exact input
for reproducibility. `gasm verify --call <func> --buf name:size:pattern`
invokes a single function with user-supplied buffers (patterns: zero, ones,
seq, or hex), printing the ABI0 argument block before and after the call —
useful for partial functions (e.g. decoders) that crash on random input but
should succeed on valid data. `gasm verify --ground-truth` compares the
assembled machine code byte-for-byte against `go tool asm` (relocation sites
masked), reporting any encoding drift.
### `debug` ### `debug`
@@ -320,7 +340,9 @@ execution. Breakpoints are patched as INT3 bytes through `/proc/pid/mem`
(PTRACE_PEEKTEXT is unreliable with Go's multi-threaded runtime). (PTRACE_PEEKTEXT is unreliable with Go's multi-threaded runtime).
The child pins its goroutine to the OS thread with `runtime.LockOSThread` The child pins its goroutine to the OS thread with `runtime.LockOSThread`
so the traced thread is the one executing JIT code. The REPL provides so the traced thread is the one executing JIT code. The REPL provides
single-step, register inspection, label resolution, and breakpoint single-step, register inspection (GPR + YMM/XMM via `PTRACE_GETFPREGS`),
label resolution, named buffer allocation with pattern filling
(`--buf name:size:pattern` — zero, ones, seq, or hex), and breakpoint
management. management.
## Extension points ## Extension points
+38 -3
View File
@@ -70,14 +70,25 @@ Assemble FILE, map it into executable memory, and run dynamic checks.
| `--fuzz` | Differential fuzz: JIT both gasm and go-tool-asm, compare outputs | | `--fuzz` | Differential fuzz: JIT both gasm and go-tool-asm, compare outputs |
| `-n` | Fuzz iterations per function (default: 1000) | | `-n` | Fuzz iterations per function (default: 1000) |
| `--abi` | Run ABI-checking calls (sentinel registers + red zone) | | `--abi` | Run ABI-checking calls (sentinel registers + red zone) |
| `--abi-n` | Number of ABI check iterations with varied inputs (default: 100) |
| `--profile` | List basic-block structure per function | | `--profile` | List basic-block structure per function |
| `--smoke` | Call each NOSPLIT function with zeroed args | | `--smoke` | Call each NOSPLIT function with zeroed args |
| `--call <func>` | Invoke a single function with `--buf` instead of the sweeps |
| `--buf <spec>` | Buffer spec for `--call`: `name:size:pattern[,name:size:pattern]` |
| `--repeat <n>` | Number of times to repeat a `--call` invocation (default: 1) |
The `--fuzz` mode runs each function in a subprocess; a partial function The `--fuzz` mode runs each function in a subprocess; a partial function
(e.g. a decoder that faults on malformed input) is reported as (e.g. a decoder that faults on malformed input) is reported as
`CRASH` without killing the parent. Use `--ground-truth` for decoders. `CRASH` without killing the parent. Use `--call` with `--buf` to invoke
partial functions with valid data instead.
## `gasm debug --func <name> <file.s>` The `--call` mode parses the `// func` signature, allocates the requested
buffers (`zero`, `ones`, `seq`, or a hex blob), builds the ABI0 argument
block with buffer pointers/lengths/capacities at the matching parameter
offsets, and prints the arg block before and after the call — showing
return values and any output written to the buffers.
## `gasm debug --func <name> [--buf spec] <file.s>`
Interactive debugger for JIT-assembled amd64 functions. Requires a Interactive debugger for JIT-assembled amd64 functions. Requires a
compiled binary on `$PATH` (not `go run`). compiled binary on `$PATH` (not `go run`).
@@ -85,6 +96,7 @@ compiled binary on `$PATH` (not `go run`).
| Flag | Description | | Flag | Description |
|------|-------------| |------|-------------|
| `--func` | Function to debug (required) | | `--func` | Function to debug (required) |
| `--buf` | Buffer spec: `name:size:pattern[,name:size:pattern]` |
REPL commands: REPL commands:
@@ -93,11 +105,34 @@ REPL commands:
| `break <label\|addr>` | Set a breakpoint | | `break <label\|addr>` | Set a breakpoint |
| `step [n]` | Single-step n instructions | | `step [n]` | Single-step n instructions |
| `continue` | Run until next breakpoint or exit | | `continue` | Run until next breakpoint or exit |
| `regs` | Print general-purpose registers | | `regs` | Print general-purpose + YMM/XMM vector registers |
| `x [addr] [len]` | Hex-dump memory | | `x [addr] [len]` | Hex-dump memory |
| `labels` | List function labels and offsets | | `labels` | List function labels and offsets |
| `quit` | Kill the debuggee and exit | | `quit` | Kill the debuggee and exit |
## `gasm diff [--map old=new,...] <file1.s> <file2.s>`
Compare the machine code produced by assembling two files. Shows which
functions differ and the first few differing bytes. Useful for verifying
that two implementations produce identical code, or for tracking encoding
changes between Go assembler versions.
| Flag | Description |
|------|-------------|
| `--map` | Comma-separated `old=new` pairs to match functions with different names |
Without `--map`, functions are paired by exact name. With `--map`, a
function named `old` in the first file is compared against the function
named `new` in the second file (e.g. `--map wideCopyAVX2=wideCopyAVX512`
pairs AVX2 and AVX-512 variants regardless of suffix).
## `gasm profile <file.s>`
Show the basic-block structure of functions in an assembly file. Lists
each function's labels, their offsets, and the block boundaries. This is
the static structure; for runtime execution counts, use `gasm verify
--fuzz` which exercises the code paths.
## `gasm lsp` ## `gasm lsp`
Run the language server over standard input/output (JSON-RPC 2.0 with Run the language server over standard input/output (JSON-RPC 2.0 with
+1 -4
View File
@@ -5,7 +5,6 @@ package format
import ( import (
"os" "os"
"path/filepath"
"strings" "strings"
"testing" "testing"
@@ -172,11 +171,9 @@ func TestIdempotent(t *testing.T) {
} }
// TestRoundTrip checks that formatting produces source that still parses // TestRoundTrip checks that formatting produces source that still parses
// cleanly, on the fixture and on the real go-flac kernels when present. // cleanly on the in-repository fixture.
func TestRoundTrip(t *testing.T) { func TestRoundTrip(t *testing.T) {
files := []string{"../testdata/sample_amd64.s"} files := []string{"../testdata/sample_amd64.s"}
real, _ := filepath.Glob("../../go-libraries/go-*/*.s")
files = append(files, real...)
for _, path := range files { for _, path := range files {
src, err := os.ReadFile(path) src, err := os.ReadFile(path)
if err != nil { if err != nil {
+40
View File
@@ -0,0 +1,40 @@
// Copyright (c) 2026 Petr Balvín <opensource@petrbalvin.org> (https://petrbalvin.org)
// SPDX-License-Identifier: BSD-3-Clause
//go:build integration
// Package format integration tests against the production go-libraries kernels.
// Excluded from the default test run so coverage is identical locally and in CI.
// Run explicitly with: go test -tags=integration ./format/
package format
import (
"os"
"path/filepath"
"strings"
"testing"
"sourcedock.dev/petrbalvin/gasm-devkit/parser"
)
// TestRoundTripRealGoLibraries checks that formatting the production kernels
// still produces source that parses cleanly.
func TestRoundTripRealGoLibraries(t *testing.T) {
real, _ := filepath.Glob("../../go-libraries/go-*/*.s")
if len(real) == 0 {
t.Skip("go-libraries repository not present")
}
for _, path := range real {
src, err := os.ReadFile(path)
if err != nil {
t.Fatal(err)
}
formatted := Source(path, string(src))
if _, errs := parser.Parse(path, formatted); len(errs) > 0 {
t.Errorf("formatted %s no longer parses: %v", path, errs)
}
if strings.TrimSpace(formatted) == "" {
t.Errorf("formatted %s is empty", path)
}
}
}
+15 -2
View File
@@ -3,7 +3,7 @@
# gasm-devkit — developer tooling for Go's Plan 9 assembler (GAsm). # gasm-devkit — developer tooling for Go's Plan 9 assembler (GAsm).
version := "0.28.0" version := "0.29.0"
default: default:
@just --list @just --list
@@ -18,8 +18,21 @@ build:
@test -z "$(gofmt -l .)" || { echo "gofmt diff:"; gofmt -l .; exit 1; } @test -z "$(gofmt -l .)" || { echo "gofmt diff:"; gofmt -l .; exit 1; }
# Full test suite + race detector + 80 % coverage gate. # Full test suite + race detector + 80 % coverage gate.
# The coverage gate matches CI: it excludes packages that need hardware or
# are CLI glue (debug, cmd/gasm), so the number is identical locally and in CI.
test: test:
go test -race -count=1 -coverprofile=coverage.out ./... go test -race -count=1 ./...
go test -count=1 -coverprofile=coverage.out \
sourcedock.dev/petrbalvin/gasm-devkit/arch \
sourcedock.dev/petrbalvin/gasm-devkit/asm \
sourcedock.dev/petrbalvin/gasm-devkit/ast \
sourcedock.dev/petrbalvin/gasm-devkit/format \
sourcedock.dev/petrbalvin/gasm-devkit/lexer \
sourcedock.dev/petrbalvin/gasm-devkit/lint \
sourcedock.dev/petrbalvin/gasm-devkit/lsp \
sourcedock.dev/petrbalvin/gasm-devkit/parser \
sourcedock.dev/petrbalvin/gasm-devkit/token \
sourcedock.dev/petrbalvin/gasm-devkit/verify
go tool cover -func=coverage.out | awk '/^total:/{gsub("%","",$3);if($3+0<80){print "coverage "$3"% < 80%";exit 1}print "coverage "$3"%"}' go tool cover -func=coverage.out | awk '/^total:/{gsub("%","",$3);if($3+0<80){print "coverage "$3"% < 80%";exit 1}print "coverage "$3"%"}'
# Format all Go sources. # Format all Go sources.
+44
View File
@@ -0,0 +1,44 @@
// Copyright (c) 2026 Petr Balvín <opensource@petrbalvin.org> (https://petrbalvin.org)
// SPDX-License-Identifier: BSD-3-Clause
//go:build integration
// Package lint integration tests against the production go-libraries kernels.
// Excluded from the default test run so coverage is identical locally and in CI.
// Run explicitly with: go test -tags=integration ./lint/
package lint
import (
"os"
"path/filepath"
"testing"
"sourcedock.dev/petrbalvin/gasm-devkit/arch"
"sourcedock.dev/petrbalvin/gasm-devkit/parser"
)
// TestRealGoLibrariesHasNoErrors asserts that the production go-flac kernels
// lint free of errors.
func TestRealGoLibrariesHasNoErrors(t *testing.T) {
matches, _ := filepath.Glob("../../go-libraries/go-*/*.s")
if len(matches) == 0 {
t.Skip("go-libraries repository not present")
}
for _, path := range matches {
src, err := os.ReadFile(path)
if err != nil {
t.Fatal(err)
}
f, errs := parser.Parse(path, string(src))
if len(errs) > 0 {
t.Fatalf("parse %s: %v", path, errs)
}
a := arch.FromFilename(path)
diags := File(f, Config{Arch: a})
for _, d := range diags {
if d.Severity == Error {
t.Errorf("%s: %s %s: %s", filepath.Base(path), d.Pos, d.Code, d.Message)
}
}
}
}
-27
View File
@@ -5,7 +5,6 @@ package lint
import ( import (
"os" "os"
"path/filepath"
"testing" "testing"
"sourcedock.dev/petrbalvin/gasm-devkit/arch" "sourcedock.dev/petrbalvin/gasm-devkit/arch"
@@ -234,29 +233,3 @@ TEXT ·f(SB), NOSPLIT, $0
t.Fatalf("label rules should be suppressed in macro files: %+v", diags) t.Fatalf("label rules should be suppressed in macro files: %+v", diags)
} }
} }
// TestRealGoLibrariesHasNoErrors asserts that the production go-flac kernels
// lint free of errors. Skipped when the sibling repository is absent.
func TestRealGoLibrariesHasNoErrors(t *testing.T) {
matches, _ := filepath.Glob("../../go-libraries/go-*/*.s")
if len(matches) == 0 {
t.Skip("go-libraries repository not present")
}
for _, path := range matches {
src, err := os.ReadFile(path)
if err != nil {
t.Fatal(err)
}
f, errs := parser.Parse(path, string(src))
if len(errs) > 0 {
t.Fatalf("parse %s: %v", path, errs)
}
a := arch.FromFilename(path)
diags := File(f, Config{Arch: a})
for _, d := range diags {
if d.Severity == Error {
t.Errorf("%s: %s %s: %s", filepath.Base(path), d.Pos, d.Code, d.Message)
}
}
}
}
+35
View File
@@ -90,6 +90,41 @@ func (s *Server) hover(p hoverParams) *Hover {
} }
} }
// definition returns the location of the label definition for a label reference.
func (s *Server) definition(p definitionParams) []Location {
text := s.docs[p.TextDocument.URI]
word, _ := wordAt(text, p.Position)
if word == "" {
return nil
}
// Parse the document to find label definitions.
f, errs := parser.Parse(uriPath(p.TextDocument.URI), text)
if f == nil || len(errs) > 0 {
return nil
}
// Find the label definition.
for _, d := range f.Decls {
if t, ok := d.(*ast.Text); ok {
for _, stmt := range t.Body {
if lbl, ok := stmt.(*ast.Label); ok {
if lbl.Name.Text == word {
return []Location{{
URI: p.TextDocument.URI,
Range: Range{
Start: Position{Line: lbl.Name.Pos.Line - 1, Character: lbl.Name.Pos.Column - 1},
End: Position{Line: lbl.Name.Pos.Line - 1, Character: lbl.Name.Pos.Column - 1 + len(word)},
},
}}
}
}
}
}
}
return nil
}
// documentSymbols returns functions and their labels, plus global symbols. // documentSymbols returns functions and their labels, plus global symbols.
func (s *Server) documentSymbols(p documentSymbolParams) []DocumentSymbol { func (s *Server) documentSymbols(p documentSymbolParams) []DocumentSymbol {
text := s.docs[p.TextDocument.URI] text := s.docs[p.TextDocument.URI]
+5
View File
@@ -152,6 +152,11 @@ type hoverParams struct {
Position Position `json:"position"` Position Position `json:"position"`
} }
type definitionParams struct {
TextDocument textDocumentIdentifier `json:"textDocument"`
Position Position `json:"position"`
}
// Hover is the hover response. // Hover is the hover response.
type Hover struct { type Hover struct {
Contents markupContent `json:"contents"` Contents markupContent `json:"contents"`
+5
View File
@@ -170,6 +170,11 @@ func (s *Server) dispatch(msg *rpcMessage) (exit bool) {
json.Unmarshal(msg.Params, &p) json.Unmarshal(msg.Params, &p)
s.respond(msg.ID, s.hover(p)) s.respond(msg.ID, s.hover(p))
case "textDocument/definition":
var p definitionParams
json.Unmarshal(msg.Params, &p)
s.respond(msg.ID, s.definition(p))
case "textDocument/documentSymbol": case "textDocument/documentSymbol":
var p documentSymbolParams var p documentSymbolParams
json.Unmarshal(msg.Params, &p) json.Unmarshal(msg.Params, &p)
+39
View File
@@ -0,0 +1,39 @@
// Copyright (c) 2026 Petr Balvín <opensource@petrbalvin.org> (https://petrbalvin.org)
// SPDX-License-Identifier: BSD-3-Clause
//go:build integration
// Package parser integration tests against the production go-libraries kernels.
// Excluded from the default test run so coverage is identical locally and in CI.
// Run explicitly with: go test -tags=integration ./parser/
package parser
import (
"os"
"path/filepath"
"testing"
)
// TestParseRealGoLibraries parses every .s file in the sibling go-libraries
// repository when it is checked out, asserting a clean, error-free parse.
func TestParseRealGoLibraries(t *testing.T) {
matches, _ := filepath.Glob("../../go-libraries/go-*/*.s")
if len(matches) == 0 {
t.Skip("go-libraries repository not present next to gasm-devkit")
}
for _, path := range matches {
src, err := os.ReadFile(path)
if err != nil {
t.Fatalf("read %s: %v", path, err)
}
file, errs := Parse(path, string(src))
if len(errs) > 0 {
t.Errorf("parse %s: %v", path, errs)
continue
}
if len(texts(file)) == 0 {
t.Errorf("parse %s: no TEXT functions found", path)
}
t.Logf("%s: %d decls, %d functions", filepath.Base(path), len(file.Decls), len(texts(file)))
}
}
-26
View File
@@ -5,7 +5,6 @@ package parser
import ( import (
"os" "os"
"path/filepath"
"testing" "testing"
"sourcedock.dev/petrbalvin/gasm-devkit/ast" "sourcedock.dev/petrbalvin/gasm-devkit/ast"
@@ -254,28 +253,3 @@ func TestDataWidthAndStatic(t *testing.T) {
t.Errorf("mask24 DATA should be static, got %+v", datas[2].Name) t.Errorf("mask24 DATA should be static, got %+v", datas[2].Name)
} }
} }
// TestParseRealGoLibraries parses every .s file in the sibling go-libraries
// repository when it is checked out, asserting a clean, error-free parse. It
// is skipped when the repository is not present.
func TestParseRealGoLibraries(t *testing.T) {
matches, _ := filepath.Glob("../../go-libraries/go-*/*.s")
if len(matches) == 0 {
t.Skip("go-libraries repository not present next to gasm-devkit")
}
for _, path := range matches {
src, err := os.ReadFile(path)
if err != nil {
t.Fatalf("read %s: %v", path, err)
}
file, errs := Parse(path, string(src))
if len(errs) > 0 {
t.Errorf("parse %s: %v", path, errs)
continue
}
if len(texts(file)) == 0 {
t.Errorf("parse %s: no TEXT functions found", path)
}
t.Logf("%s: %d decls, %d functions", filepath.Base(path), len(file.Decls), len(texts(file)))
}
}
-50
View File
@@ -5,7 +5,6 @@ package verify
import ( import (
"testing" "testing"
"unsafe"
) )
func loadABIKernel(t *testing.T) *Kernel { func loadABIKernel(t *testing.T) *Kernel {
@@ -79,55 +78,6 @@ func TestABIR14Clobbered(t *testing.T) {
} }
} }
// TestABILZ4Kernels verifies that the production go-lz4 kernels are ABI-clean:
// they preserve BP and R14 and do not write into the red zone.
func TestABILZ4Kernels(t *testing.T) {
k := loadLZ4Kernel(t)
// wideCopyAVX2 with a real copy.
src := make([]byte, 128)
for i := range src {
src[i] = byte(i)
}
dst := make([]byte, 128)
args := make([]byte, 48)
PutPtr(args, 0, unsafe.Pointer(&dst[0]))
PutUint64(args, 8, 128)
PutUint64(args, 16, 128)
PutPtr(args, 24, unsafe.Pointer(&src[0]))
PutUint64(args, 32, 128)
PutUint64(args, 40, 128)
_, report, err := k.CallFuncChecked("wideCopyAVX2", args)
if err != nil {
t.Fatalf("CallFuncChecked(wideCopyAVX2): %v", err)
}
if !report.OK() {
t.Errorf("wideCopyAVX2: %s", report)
}
// decodeBlockAVX2 with a simple block.
decSrc := []byte{0x50, 'H', 'e', 'l', 'l', 'o'}
decDst := make([]byte, 64)
decArgs := make([]byte, 64)
PutPtr(decArgs, 0, unsafe.Pointer(&decSrc[0]))
PutUint64(decArgs, 8, uint64(len(decSrc)))
PutUint64(decArgs, 16, uint64(cap(decSrc)))
PutPtr(decArgs, 24, unsafe.Pointer(&decDst[0]))
PutUint64(decArgs, 32, uint64(len(decDst)))
PutUint64(decArgs, 40, uint64(cap(decDst)))
_, report, err = k.CallFuncChecked("decodeBlockAVX2", decArgs)
if err != nil {
t.Fatalf("CallFuncChecked(decodeBlockAVX2): %v", err)
}
if !report.OK() {
t.Errorf("decodeBlockAVX2: %s", report)
}
}
func TestCallFuncCheckedErrors(t *testing.T) { func TestCallFuncCheckedErrors(t *testing.T) {
k := loadABIKernel(t) k := loadABIKernel(t)
-144
View File
@@ -1,144 +0,0 @@
// Copyright (c) 2026 Petr Balvín <opensource@petrbalvin.org> (https://petrbalvin.org)
// SPDX-License-Identifier: BSD-3-Clause
package verify
import (
"bytes"
"math/rand"
"os"
"testing"
"unsafe"
)
const lz4AVX512Path = "../../go-libraries/go-lz4/avx512_amd64.s"
func loadLZ4AVX512Kernel(t *testing.T) *Kernel {
t.Helper()
if _, err := os.Stat(lz4AVX512Path); err != nil {
t.Skipf("sibling kernel not available: %v", err)
}
k, err := Load(lz4AVX512Path)
if err != nil {
t.Fatalf("Load(%s): %v", lz4AVX512Path, err)
}
t.Cleanup(k.Close)
return k
}
func TestAVX512DecodeKnownAnswers(t *testing.T) {
k := loadLZ4AVX512Kernel(t)
tests := []struct {
name string
src []byte
wantN int
wantCode int
}{
{"literals_only", []byte{0x50, 'H', 'e', 'l', 'l', 'o'}, 5, 0},
{"literals_and_match", []byte{0x54, 'A', 'A', 'A', 'A', 'A', 0x05, 0x00, 0x30, 'B', 'B', 'B'}, 16, 0},
{"overlapping", []byte{0x14, 'X', 0x01, 0x00, 0x10, 'Y'}, 10, 0},
{"malformed", []byte{0x50, 'H', 'e'}, 0, 1},
{"zero_offset", []byte{0x14, 'X', 0x00, 0x00}, 0, 2},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
dst := make([]byte, 64)
args := make([]byte, 64)
PutPtr(args, 0, unsafe.Pointer(&tt.src[0]))
PutUint64(args, 8, uint64(len(tt.src)))
PutUint64(args, 16, uint64(cap(tt.src)))
PutPtr(args, 24, unsafe.Pointer(&dst[0]))
PutUint64(args, 32, uint64(len(dst)))
PutUint64(args, 40, uint64(cap(dst)))
out, err := k.CallFunc("decodeBlockAVX512", args)
if err != nil {
t.Fatalf("CallFunc: %v", err)
}
n := int(GetUint64(out, 48))
code := int(GetUint64(out, 56))
if n != tt.wantN || code != tt.wantCode {
t.Errorf("got (n=%d, code=%d), want (n=%d, code=%d)", n, code, tt.wantN, tt.wantCode)
}
})
}
}
func TestAVX512DifferentialFuzz(t *testing.T) {
k := loadLZ4AVX512Kernel(t)
rng := rand.New(rand.NewSource(77))
for i := 0; i < 3000; i++ {
wantSize := 1 + rng.Intn(4096)
src := genLZ4Block(rng, wantSize)
dstSize := wantSize + 64
goDst := make([]byte, dstSize)
goN, goCode := decodeBlockGo(src, goDst)
jitDst := make([]byte, dstSize)
args := make([]byte, 64)
if len(src) > 0 {
PutPtr(args, 0, unsafe.Pointer(&src[0]))
}
PutUint64(args, 8, uint64(len(src)))
PutUint64(args, 16, uint64(cap(src)))
if dstSize > 0 {
PutPtr(args, 24, unsafe.Pointer(&jitDst[0]))
}
PutUint64(args, 32, uint64(dstSize))
PutUint64(args, 40, uint64(cap(jitDst)))
out, err := k.CallFunc("decodeBlockAVX512", args)
if err != nil {
t.Fatalf("iter %d: %v", i, err)
}
jitN := int(GetUint64(out, 48))
jitCode := int(GetUint64(out, 56))
if jitCode != goCode {
t.Fatalf("iter %d: code mismatch: JIT=%d Go=%d", i, jitCode, goCode)
}
if jitCode != 0 {
continue
}
if jitN != goN {
t.Fatalf("iter %d: n mismatch: JIT=%d Go=%d", i, jitN, goN)
}
if !bytes.Equal(jitDst[:jitN], goDst[:goN]) {
t.Fatalf("iter %d: output mismatch (n=%d)", i, jitN)
}
}
}
func TestAVX512WideCopy(t *testing.T) {
k := loadLZ4AVX512Kernel(t)
sizes := []int{0, 1, 31, 32, 63, 64, 65, 127, 128, 256, 1024}
for _, n := range sizes {
src := make([]byte, n)
for i := range src {
src[i] = byte(i*11 + 3)
}
dst := make([]byte, n)
args := make([]byte, 48)
if n > 0 {
PutPtr(args, 0, unsafe.Pointer(&dst[0]))
PutPtr(args, 24, unsafe.Pointer(&src[0]))
}
PutUint64(args, 8, uint64(n))
PutUint64(args, 16, uint64(n))
PutUint64(args, 32, uint64(n))
PutUint64(args, 40, uint64(n))
_, err := k.CallFunc("wideCopyAVX512", args)
if err != nil {
t.Fatalf("wideCopyAVX512(n=%d): %v", n, err)
}
if !bytes.Equal(dst, src) {
t.Errorf("wideCopyAVX512(n=%d): mismatch", n)
}
}
}
+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])
}
}
-60
View File
@@ -5,7 +5,6 @@ package verify
import ( import (
"testing" "testing"
"unsafe"
) )
func TestBlocks(t *testing.T) { func TestBlocks(t *testing.T) {
@@ -24,62 +23,3 @@ func TestBlocks(t *testing.T) {
} }
t.Logf("sum blocks: %v", blocks) t.Logf("sum blocks: %v", blocks)
} }
func TestBlockCount(t *testing.T) {
k := loadLZ4Kernel(t)
n, err := k.BlockCount("decodeBlockAVX2")
if err != nil {
t.Fatalf("BlockCount: %v", err)
}
// The decoder has many labels (dec_loop, dec_malformed, etc.).
if n < 10 {
t.Errorf("decodeBlockAVX2: expected at least 10 blocks, got %d", n)
}
t.Logf("decodeBlockAVX2: %d basic blocks", n)
}
func TestProfilePaths(t *testing.T) {
k := loadLZ4Kernel(t)
// Build a corpus of varied LZ4 blocks.
var argSets [][]byte
blocks := []struct {
src []byte
dstSize int
}{
{[]byte{0x00}, 16}, // empty
{[]byte{0x50, 'H', 'e', 'l', 'l', 'o'}, 16}, // literals only
{[]byte{0x54, 'A', 'A', 'A', 'A', 'A', 5, 0, 0x30, 'B', 'B', 'B'}, 32}, // match
{[]byte{0x14, 'X', 1, 0, 0x10, 'Y'}, 16}, // overlapping
{[]byte{0x50, 'H'}, 16}, // malformed
{[]byte{0x14, 'X', 0, 0}, 16}, // zero offset
}
for _, b := range blocks {
args := make([]byte, 64)
if len(b.src) > 0 {
PutPtr(args, 0, unsafe.Pointer(&b.src[0]))
}
PutUint64(args, 8, uint64(len(b.src)))
PutUint64(args, 16, uint64(cap(b.src)))
dst := make([]byte, b.dstSize)
if len(dst) > 0 {
PutPtr(args, 24, unsafe.Pointer(&dst[0]))
}
PutUint64(args, 32, uint64(len(dst)))
PutUint64(args, 40, uint64(cap(dst)))
argSets = append(argSets, args)
}
// Result offsets: n+48 and code+56.
paths, err := k.ProfilePaths("decodeBlockAVX2", argSets, []int{48, 56})
if err != nil {
t.Fatalf("ProfilePaths: %v", err)
}
// We expect at least 3 distinct paths: success (various n), malformed, zero offset.
if len(paths) < 3 {
t.Errorf("expected at least 3 distinct paths, got %d", len(paths))
}
t.Logf("decodeBlockAVX2: %d distinct output paths from %d inputs", len(paths), len(argSets))
}
-295
View File
@@ -1,295 +0,0 @@
// Copyright (c) 2026 Petr Balvín <opensource@petrbalvin.org> (https://petrbalvin.org)
// SPDX-License-Identifier: BSD-3-Clause
package verify
import (
"bytes"
"math/rand"
"testing"
"unsafe"
)
// decodeBlockGo is a minimal portable LZ4 block decoder used as the
// differential-testing oracle. It mirrors the contract of
// go-lz4's decodeBlockGo: (bytesWritten, code) where code is
// 0 = ok, 1 = malformed, 2 = zero offset.
func decodeBlockGo(src, dst []byte) (int, int) {
if len(src) == 0 {
return 0, 1
}
si, di := 0, 0
for {
if si >= len(src) {
return 0, 1 // truncated: no token
}
token := int(src[si])
si++
// Literals.
lLen := token >> 4
if lLen == 15 {
for {
if si >= len(src) {
return 0, 1
}
b := int(src[si])
si++
lLen += b
if b != 255 {
break
}
}
}
if si+lLen > len(src) {
return 0, 1 // truncated literals
}
if di+lLen > len(dst) {
return 0, 1 // destination overflow
}
copy(dst[di:di+lLen], src[si:si+lLen])
di += lLen
si += lLen
// End of block.
if si >= len(src) {
return di, 0
}
// Match offset.
if si+2 > len(src) {
return 0, 1
}
offset := int(src[si]) | int(src[si+1])<<8
si += 2
if offset == 0 {
return 0, 2
}
// Match length.
mLen := token & 15
if mLen == 15 {
for {
if si >= len(src) {
return 0, 1
}
b := int(src[si])
si++
mLen += b
if b != 255 {
break
}
}
}
mLen += 4
// Copy match (overlapping-safe).
if di-offset < 0 {
return 0, 1 // offset reaches before dst start
}
if di+mLen > len(dst) {
return 0, 1 // destination overflow
}
for i := 0; i < mLen; i++ {
dst[di+i] = dst[di-offset+i]
}
di += mLen
}
}
// genLZ4Block generates a random valid LZ4 block that decompresses into
// approximately wantSize bytes. The block is always well-formed (ends with
// a literals-only sequence).
func genLZ4Block(rng *rand.Rand, wantSize int) []byte {
var block []byte
produced := 0
for produced < wantSize {
remaining := wantSize - produced
// Decide: emit a literals+match sequence or the final literals.
if remaining <= 8 || rng.Intn(4) == 0 {
// Final literals-only sequence.
lLen := remaining
if lLen > 60 {
lLen = 1 + rng.Intn(60)
}
block = appendToken(block, lLen, 0)
for i := 0; i < lLen; i++ {
block = append(block, byte(rng.Intn(256)))
}
produced += lLen
break
}
// Literals + match.
lLen := rng.Intn(min(16, remaining))
if produced+lLen == 0 {
lLen = 1 // must have at least 1 literal before the first match
}
mLenRaw := rng.Intn(12) // match length = mLenRaw + 4
mLen := mLenRaw + 4
if produced+mLen > remaining {
mLen = remaining - produced
if mLen < 4 {
// Not enough room for a match; emit final literals.
lLen = remaining
block = appendToken(block, lLen, 0)
for i := 0; i < lLen; i++ {
block = append(block, byte(rng.Intn(256)))
}
break
}
mLenRaw = mLen - 4
}
block = appendToken(block, lLen, mLenRaw)
for i := 0; i < lLen; i++ {
block = append(block, byte(rng.Intn(256)))
}
produced += lLen
// Offset: must be <= produced (can't reference before start).
maxOff := produced
if maxOff > 65535 {
maxOff = 65535
}
offset := 1 + rng.Intn(maxOff)
block = append(block, byte(offset), byte(offset>>8))
produced += mLen
}
return block
}
// appendToken appends a token (and extension bytes if needed) for the given
// literal and match lengths.
func appendToken(block []byte, lLen, mLenRaw int) []byte {
lit4 := lLen
if lit4 > 15 {
lit4 = 15
}
ml4 := mLenRaw
if ml4 > 15 {
ml4 = 15
}
block = append(block, byte(lit4<<4|ml4))
// Literal extension bytes.
rem := lLen - 15
for rem >= 255 {
block = append(block, 255)
rem -= 255
}
if lLen >= 15 {
block = append(block, byte(rem))
}
// Match extension bytes.
rem = mLenRaw - 15
for rem >= 255 {
block = append(block, 255)
rem -= 255
}
if mLenRaw >= 15 {
block = append(block, byte(rem))
}
return block
}
func min(a, b int) int {
if a < b {
return a
}
return b
}
// TestDifferentialLZ4Fuzz drives the JIT-assembled decodeBlockAVX2 with
// random valid LZ4 blocks and compares the output bit-for-bit against the
// portable Go reference.
func TestDifferentialLZ4Fuzz(t *testing.T) {
k := loadLZ4Kernel(t)
const iterations = 5000
rng := rand.New(rand.NewSource(42))
for i := 0; i < iterations; i++ {
wantSize := 1 + rng.Intn(4096)
src := genLZ4Block(rng, wantSize)
dstSize := wantSize + 64 // generous destination
// Go reference.
goDst := make([]byte, dstSize)
goN, goCode := decodeBlockGo(src, goDst)
// JIT kernel.
jitDst := make([]byte, dstSize)
jitN, jitCode := callDecodeBlockAVX2(t, k, src, jitDst)
if jitCode != goCode {
t.Fatalf("iter %d: code mismatch: JIT=%d, Go=%d (src len=%d)",
i, jitCode, goCode, len(src))
}
if jitCode != 0 {
continue // both agree it's malformed/zero-offset
}
if jitN != goN {
t.Fatalf("iter %d: n mismatch: JIT=%d, Go=%d (src len=%d)",
i, jitN, goN, len(src))
}
if !bytes.Equal(jitDst[:jitN], goDst[:goN]) {
t.Fatalf("iter %d: output mismatch (n=%d, src len=%d)", i, jitN, len(src))
}
}
}
// TestDifferentialLZ4Hostile drives the kernel with random garbage to check
// that error codes agree with the Go reference (no crashes, same classification).
func TestDifferentialLZ4Hostile(t *testing.T) {
k := loadLZ4Kernel(t)
const iterations = 2000
rng := rand.New(rand.NewSource(99))
for i := 0; i < iterations; i++ {
srcLen := rng.Intn(128)
src := make([]byte, srcLen)
rng.Read(src)
dstSize := rng.Intn(512)
dst := make([]byte, dstSize)
// Go reference.
goDst := make([]byte, dstSize)
copy(goDst, dst)
_, goCode := decodeBlockGo(src, goDst)
// JIT kernel.
jitDst := make([]byte, dstSize)
copy(jitDst, dst)
_, jitCode := callDecodeBlockAVX2(t, k, src, jitDst)
if jitCode != goCode {
t.Fatalf("iter %d: hostile code mismatch: JIT=%d, Go=%d (srcLen=%d, dstSize=%d)",
i, jitCode, goCode, srcLen, dstSize)
}
}
}
// callDecodeBlockAVX2Raw is like callDecodeBlockAVX2 but accepts explicit
// dst size (for hostile tests where dst may be smaller than the output).
func callDecodeBlockAVX2Raw(t *testing.T, k *Kernel, src, dst []byte) (int, int) {
t.Helper()
args := make([]byte, 64)
if len(src) > 0 {
PutPtr(args, 0, unsafe.Pointer(&src[0]))
}
PutUint64(args, 8, uint64(len(src)))
PutUint64(args, 16, uint64(cap(src)))
if len(dst) > 0 {
PutPtr(args, 24, unsafe.Pointer(&dst[0]))
}
PutUint64(args, 32, uint64(len(dst)))
PutUint64(args, 40, uint64(cap(dst)))
out, err := k.CallFunc("decodeBlockAVX2", args)
if err != nil {
t.Fatalf("CallFunc(decodeBlockAVX2): %v", err)
}
return int(GetUint64(out, 48)), int(GetUint64(out, 56))
}
-585
View File
@@ -1,585 +0,0 @@
// Copyright (c) 2026 Petr Balvín <opensource@petrbalvin.org> (https://petrbalvin.org)
// SPDX-License-Identifier: BSD-3-Clause
package verify
import (
"bytes"
"math/rand"
"os"
"testing"
"unsafe"
)
const flacKernelPath = "../../go-libraries/go-flac/avx2_amd64.s"
func loadFLACKernel(t *testing.T) *Kernel {
t.Helper()
if _, err := os.Stat(flacKernelPath); err != nil {
t.Skipf("sibling kernel not available: %v", err)
}
k, err := Load(flacKernelPath)
if err != nil {
t.Fatalf("Load(%s): %v", flacKernelPath, err)
}
t.Cleanup(k.Close)
return k
}
// --- Portable Go references (from go-flac/simd.go) ---
func decodeMono16Go(src []byte, dst []int32) {
for i := 0; i < len(dst); i++ {
dst[i] = int32(int16(uint16(src[2*i]) | uint16(src[2*i+1])<<8))
}
}
func pack16Go(dst []byte, src []int32) {
for i, v := range src {
dst[2*i] = byte(v)
dst[2*i+1] = byte(v >> 8)
}
}
func decorrelateLeftSideGo(left, right, out []int32) {
for i := range left {
l := left[i]
out[2*i] = l
out[2*i+1] = l - right[i]
}
}
func decorrelateSideRightGo(left, right, out []int32) {
for i := range left {
side := left[i]
rch := right[i]
out[2*i] = rch + side
out[2*i+1] = rch
}
}
func decorrelateMidSideGo(left, right, out []int32) {
for i := range left {
mid := left[i]
side := right[i]
mid2 := mid<<1 | (side & 1)
out[2*i] = (mid2 + side) >> 1
out[2*i+1] = (mid2 - side) >> 1
}
}
func decorrelateInterleaveGo(left, right, out []int32) {
for i := range left {
out[2*i] = left[i]
out[2*i+1] = right[i]
}
}
func analyzeO1RangeGo(swin []int32, dstP []uint32, hist *[32]uint16) (partSum uint64, overflow bool) {
swin = swin[:len(dstP)+1]
for j := 0; j+1 < len(swin); j++ {
r := swin[j+1] - swin[j]
if r == -2147483648 { // math.MinInt32
overflow = true
}
f := uint32(r<<1) ^ uint32(r>>31)
dstP[j] = f
partSum += uint64(f)
bl := 0
for v := f; v > 0; v >>= 1 {
bl++
}
if bl > 31 {
bl = 31
}
hist[bl]++
}
return
}
func analyzeO2RangeGo(swin []int32, dstP []uint32, hist *[32]uint16) (partSum uint64, overflow bool) {
swin = swin[:len(dstP)+2]
for j := 0; j+2 < len(swin); j++ {
r := swin[j+2] - 2*swin[j+1] + swin[j]
if r == -2147483648 {
overflow = true
}
f := uint32(r<<1) ^ uint32(r>>31)
dstP[j] = f
partSum += uint64(f)
bl := 0
for v := f; v > 0; v >>= 1 {
bl++
}
if bl > 31 {
bl = 31
}
hist[bl]++
}
return
}
func analyzeResRangeGo(swin []int32, dstP []uint32, hist *[32]uint16) (partSum uint64, overflow bool) {
for j := 0; j < len(swin); j++ {
r := swin[j]
if r == -2147483648 {
overflow = true
}
f := uint32(r<<1) ^ uint32(r>>31)
dstP[j] = f
partSum += uint64(f)
bl := 0
for v := f; v > 0; v >>= 1 {
bl++
}
if bl > 31 {
bl = 31
}
hist[bl]++
}
return
}
func decodeMono24Go(src []byte, dst []int32) {
for i := 0; i < len(dst); i++ {
off := 3 * i
u := uint32(src[off]) | uint32(src[off+1])<<8 | uint32(src[off+2])<<16
dst[i] = int32(u<<8) >> 8
}
}
func decodeStereo16Go(src []byte, left, right []int32) {
for i := 0; i < len(left); i++ {
left[i] = int32(int16(uint16(src[4*i]) | uint16(src[4*i+1])<<8))
right[i] = int32(int16(uint16(src[4*i+2]) | uint16(src[4*i+3])<<8))
}
}
// --- Differential tests ---
func TestFLACDecodeMono16(t *testing.T) {
k := loadFLACKernel(t)
rng := rand.New(rand.NewSource(7))
for iter := 0; iter < 500; iter++ {
n := rng.Intn(256)
src := make([]byte, 2*n)
rng.Read(src)
goDst := make([]int32, n)
decodeMono16Go(src, goDst)
jitDst := make([]int32, n)
args := make([]byte, 48)
if len(src) > 0 {
PutPtr(args, 0, unsafe.Pointer(&src[0]))
}
PutUint64(args, 8, uint64(len(src)))
PutUint64(args, 16, uint64(cap(src)))
if n > 0 {
PutPtr(args, 24, unsafe.Pointer(&jitDst[0]))
}
PutUint64(args, 32, uint64(n))
PutUint64(args, 40, uint64(cap(jitDst)))
_, err := k.CallFunc("decodeMono16AVX2", args)
if err != nil {
t.Fatalf("iter %d: %v", iter, err)
}
for i := range goDst {
if jitDst[i] != goDst[i] {
t.Fatalf("iter %d: mismatch at [%d]: JIT=%d Go=%d", iter, i, jitDst[i], goDst[i])
}
}
}
}
func TestFLACPack16(t *testing.T) {
k := loadFLACKernel(t)
rng := rand.New(rand.NewSource(13))
for iter := 0; iter < 500; iter++ {
n := rng.Intn(256)
src := make([]int32, n)
for i := range src {
src[i] = int32(rng.Intn(65536) - 32768)
}
goDst := make([]byte, 2*n)
pack16Go(goDst, src)
jitDst := make([]byte, 2*n)
args := make([]byte, 48)
if len(jitDst) > 0 {
PutPtr(args, 0, unsafe.Pointer(&jitDst[0]))
}
PutUint64(args, 8, uint64(len(jitDst)))
PutUint64(args, 16, uint64(cap(jitDst)))
if n > 0 {
PutPtr(args, 24, unsafe.Pointer(&src[0]))
}
PutUint64(args, 32, uint64(n))
PutUint64(args, 40, uint64(cap(src)))
_, err := k.CallFunc("pack16AVX2", args)
if err != nil {
t.Fatalf("iter %d: %v", iter, err)
}
if !bytes.Equal(jitDst, goDst) {
t.Fatalf("iter %d: output mismatch (n=%d)", iter, n)
}
}
}
func TestFLACDecorrelate(t *testing.T) {
k := loadFLACKernel(t)
rng := rand.New(rand.NewSource(21))
kernels := []struct {
name string
ref func(left, right, out []int32)
}{
{"decorrelateLeftSideAVX2", decorrelateLeftSideGo},
{"decorrelateSideRightAVX2", decorrelateSideRightGo},
{"decorrelateMidSideAVX2", decorrelateMidSideGo},
{"decorrelateInterleaveAVX2", decorrelateInterleaveGo},
}
for _, kk := range kernels {
t.Run(kk.name, func(t *testing.T) {
for iter := 0; iter < 200; iter++ {
n := rng.Intn(128)
left := make([]int32, n)
right := make([]int32, n)
for i := range left {
left[i] = int32(rng.Intn(1<<24) - 1<<23)
right[i] = int32(rng.Intn(1<<24) - 1<<23)
}
goOut := make([]int32, 2*n)
kk.ref(left, right, goOut)
jitOut := make([]int32, 2*n)
args := make([]byte, 72)
if n > 0 {
PutPtr(args, 0, unsafe.Pointer(&left[0]))
PutPtr(args, 24, unsafe.Pointer(&right[0]))
PutPtr(args, 48, unsafe.Pointer(&jitOut[0]))
}
PutUint64(args, 8, uint64(n))
PutUint64(args, 16, uint64(cap(left)))
PutUint64(args, 32, uint64(n))
PutUint64(args, 40, uint64(cap(right)))
PutUint64(args, 56, uint64(2*n))
PutUint64(args, 64, uint64(cap(jitOut)))
_, err := k.CallFunc(kk.name, args)
if err != nil {
t.Fatalf("iter %d: %v", iter, err)
}
for i := range goOut {
if jitOut[i] != goOut[i] {
t.Fatalf("iter %d: mismatch at [%d]: JIT=%d Go=%d", iter, i, jitOut[i], goOut[i])
}
}
}
})
}
}
func TestFLACAnalyzeO1Range(t *testing.T) {
k := loadFLACKernel(t)
rng := rand.New(rand.NewSource(33))
for iter := 0; iter < 300; iter++ {
n := 1 + rng.Intn(128) // partition size
swin := make([]int32, n+1)
for i := range swin {
swin[i] = int32(rng.Intn(1<<20) - 1<<19)
}
goDstP := make([]uint32, n)
var goHist [32]uint16
goSum, goOvf := analyzeO1RangeGo(swin, goDstP, &goHist)
jitDstP := make([]uint32, n)
var jitHist [32]uint16
args := make([]byte, 72) // 65 rounded up
PutPtr(args, 0, unsafe.Pointer(&swin[0]))
PutUint64(args, 8, uint64(len(swin)))
PutUint64(args, 16, uint64(cap(swin)))
PutPtr(args, 24, unsafe.Pointer(&jitDstP[0]))
PutUint64(args, 32, uint64(n))
PutUint64(args, 40, uint64(cap(jitDstP)))
PutPtr(args, 48, unsafe.Pointer(&jitHist[0]))
out, err := k.CallFunc("analyzeO1RangeAVX2", args)
if err != nil {
t.Fatalf("iter %d: %v", iter, err)
}
jitSum := GetUint64(out, 56)
jitOvf := out[64] != 0
if jitSum != goSum {
t.Fatalf("iter %d: partSum mismatch: JIT=%d Go=%d", iter, jitSum, goSum)
}
if jitOvf != goOvf {
t.Fatalf("iter %d: overflow mismatch: JIT=%v Go=%v", iter, jitOvf, goOvf)
}
for i := range goDstP {
if jitDstP[i] != goDstP[i] {
t.Fatalf("iter %d: dstP[%d] mismatch: JIT=%d Go=%d", iter, i, jitDstP[i], goDstP[i])
}
}
if jitHist != goHist {
t.Fatalf("iter %d: hist mismatch: JIT=%v Go=%v", iter, jitHist, goHist)
}
}
}
func TestFLACFastStereoSums(t *testing.T) {
k := loadFLACKernel(t)
rng := rand.New(rand.NewSource(44))
for iter := 0; iter < 300; iter++ {
n := 1 + rng.Intn(256)
left := make([]int32, n)
right := make([]int32, n)
for i := range left {
left[i] = int32(rng.Intn(1<<24) - 1<<23)
right[i] = int32(rng.Intn(1<<24) - 1<<23)
}
// Go reference: compute the four sums.
var goSums [4]uint64
for i := 0; i < n; i++ {
l := left[i]
r := right[i]
side := l - r
mid := (l + r) >> 1
goSums[0] += foldAbs(l) + foldAbs(r)
goSums[1] += foldAbs(l) + foldAbs(side)
goSums[2] += foldAbs(side) + foldAbs(r)
goSums[3] += foldAbs(mid) + foldAbs(side)
}
var jitSums [4]uint64
args := make([]byte, 56)
PutPtr(args, 0, unsafe.Pointer(&left[0]))
PutUint64(args, 8, uint64(n))
PutUint64(args, 16, uint64(cap(left)))
PutPtr(args, 24, unsafe.Pointer(&right[0]))
PutUint64(args, 32, uint64(n))
PutUint64(args, 40, uint64(cap(right)))
PutPtr(args, 48, unsafe.Pointer(&jitSums[0]))
_, err := k.CallFunc("fastStereoSumsAVX2", args)
if err != nil {
t.Fatalf("iter %d: %v", iter, err)
}
if jitSums != goSums {
t.Fatalf("iter %d: sums mismatch:\n JIT=%v\n Go =%v", iter, jitSums, goSums)
}
}
}
func foldAbs(v int32) uint64 {
return uint64(uint32(v<<1) ^ uint32(v>>31))
}
// runAnalyzeTest is the shared harness for the analyzeO*Range family.
func runAnalyzeTest(t *testing.T, k *Kernel, name string, order int, ref func([]int32, []uint32, *[32]uint16) (uint64, bool)) {
t.Helper()
rng := rand.New(rand.NewSource(int64(order)*100 + 7))
for iter := 0; iter < 200; iter++ {
n := 1 + rng.Intn(128)
swin := make([]int32, n+order)
for i := range swin {
swin[i] = int32(rng.Intn(1<<20) - 1<<19)
}
goDstP := make([]uint32, n)
var goHist [32]uint16
goSum, goOvf := ref(swin, goDstP, &goHist)
jitDstP := make([]uint32, n)
var jitHist [32]uint16
args := make([]byte, 72)
PutPtr(args, 0, unsafe.Pointer(&swin[0]))
PutUint64(args, 8, uint64(len(swin)))
PutUint64(args, 16, uint64(cap(swin)))
PutPtr(args, 24, unsafe.Pointer(&jitDstP[0]))
PutUint64(args, 32, uint64(n))
PutUint64(args, 40, uint64(cap(jitDstP)))
PutPtr(args, 48, unsafe.Pointer(&jitHist[0]))
out, err := k.CallFunc(name, args)
if err != nil {
t.Fatalf("iter %d: %v", iter, err)
}
jitSum := GetUint64(out, 56)
jitOvf := out[64] != 0
if jitSum != goSum {
t.Fatalf("iter %d: partSum: JIT=%d Go=%d", iter, jitSum, goSum)
}
if jitOvf != goOvf {
t.Fatalf("iter %d: overflow: JIT=%v Go=%v", iter, jitOvf, goOvf)
}
for i := range goDstP {
if jitDstP[i] != goDstP[i] {
t.Fatalf("iter %d: dstP[%d]: JIT=%d Go=%d", iter, i, jitDstP[i], goDstP[i])
}
}
if jitHist != goHist {
t.Fatalf("iter %d: hist mismatch", iter)
}
}
}
func TestFLACAnalyzeO2Range(t *testing.T) {
k := loadFLACKernel(t)
runAnalyzeTest(t, k, "analyzeO2RangeAVX2", 2, analyzeO2RangeGo)
}
func TestFLACAnalyzeResRange(t *testing.T) {
k := loadFLACKernel(t)
// analyzeResRange has order 0: swin IS the residual (no prediction).
runAnalyzeTest(t, k, "analyzeResRangeAVX2", 0, analyzeResRangeGo)
}
func TestFLACAnalyzeO3Range(t *testing.T) {
k := loadFLACKernel(t)
ref := func(swin []int32, dstP []uint32, hist *[32]uint16) (uint64, bool) {
swin = swin[:len(dstP)+3]
var partSum uint64
var overflow bool
for j := 0; j+3 < len(swin); j++ {
r := swin[j+3] - 3*swin[j+2] + 3*swin[j+1] - swin[j]
if r == -2147483648 {
overflow = true
}
f := uint32(r<<1) ^ uint32(r>>31)
dstP[j] = f
partSum += uint64(f)
bl := 0
for v := f; v > 0; v >>= 1 {
bl++
}
if bl > 31 {
bl = 31
}
hist[bl]++
}
return partSum, overflow
}
runAnalyzeTest(t, k, "analyzeO3RangeAVX2", 3, ref)
}
func TestFLACAnalyzeO4Range(t *testing.T) {
k := loadFLACKernel(t)
ref := func(swin []int32, dstP []uint32, hist *[32]uint16) (uint64, bool) {
swin = swin[:len(dstP)+4]
var partSum uint64
var overflow bool
for j := 0; j+4 < len(swin); j++ {
r := swin[j+4] - 4*swin[j+3] + 6*swin[j+2] - 4*swin[j+1] + swin[j]
if r == -2147483648 {
overflow = true
}
f := uint32(r<<1) ^ uint32(r>>31)
dstP[j] = f
partSum += uint64(f)
bl := 0
for v := f; v > 0; v >>= 1 {
bl++
}
if bl > 31 {
bl = 31
}
hist[bl]++
}
return partSum, overflow
}
runAnalyzeTest(t, k, "analyzeO4RangeAVX2", 4, ref)
}
func TestFLACDecodeMono24(t *testing.T) {
k := loadFLACKernel(t)
rng := rand.New(rand.NewSource(55))
for iter := 0; iter < 500; iter++ {
n := rng.Intn(256)
src := make([]byte, 3*n)
rng.Read(src)
goDst := make([]int32, n)
decodeMono24Go(src, goDst)
jitDst := make([]int32, n)
args := make([]byte, 48)
if len(src) > 0 {
PutPtr(args, 0, unsafe.Pointer(&src[0]))
}
PutUint64(args, 8, uint64(len(src)))
PutUint64(args, 16, uint64(cap(src)))
if n > 0 {
PutPtr(args, 24, unsafe.Pointer(&jitDst[0]))
}
PutUint64(args, 32, uint64(n))
PutUint64(args, 40, uint64(cap(jitDst)))
_, err := k.CallFunc("decodeMono24AVX2", args)
if err != nil {
t.Fatalf("iter %d: %v", iter, err)
}
for i := range goDst {
if jitDst[i] != goDst[i] {
t.Fatalf("iter %d: dst[%d]: JIT=%d Go=%d", iter, i, jitDst[i], goDst[i])
}
}
}
}
func TestFLACDecodeStereo16(t *testing.T) {
k := loadFLACKernel(t)
rng := rand.New(rand.NewSource(66))
for iter := 0; iter < 500; iter++ {
n := rng.Intn(256)
src := make([]byte, 4*n) // [L0,R0,L1,R1,...]
rng.Read(src)
goLeft := make([]int32, n)
goRight := make([]int32, n)
decodeStereo16Go(src, goLeft, goRight)
jitLeft := make([]int32, n)
jitRight := make([]int32, n)
args := make([]byte, 72)
if len(src) > 0 {
PutPtr(args, 0, unsafe.Pointer(&src[0]))
}
PutUint64(args, 8, uint64(len(src)))
PutUint64(args, 16, uint64(cap(src)))
if n > 0 {
PutPtr(args, 24, unsafe.Pointer(&jitLeft[0]))
PutPtr(args, 48, unsafe.Pointer(&jitRight[0]))
}
PutUint64(args, 32, uint64(n))
PutUint64(args, 40, uint64(cap(jitLeft)))
PutUint64(args, 56, uint64(n))
PutUint64(args, 64, uint64(cap(jitRight)))
_, err := k.CallFunc("decodeStereo16AVX2", args)
if err != nil {
t.Fatalf("iter %d: %v", iter, err)
}
for i := 0; i < n; i++ {
if jitLeft[i] != goLeft[i] || jitRight[i] != goRight[i] {
t.Fatalf("iter %d: [%d] L: JIT=%d Go=%d; R: JIT=%d Go=%d",
iter, i, jitLeft[i], goLeft[i], jitRight[i], goRight[i])
}
}
}
}
+38 -18
View File
@@ -7,6 +7,7 @@ import (
"fmt" "fmt"
"math/rand" "math/rand"
"regexp" "regexp"
"runtime"
"strconv" "strconv"
"strings" "strings"
"unsafe" "unsafe"
@@ -20,6 +21,7 @@ type FuzzResult struct {
Matches int Matches int
Mismatches int Mismatches int
FirstFail string // description of the first mismatch ("" if none) FirstFail string // description of the first mismatch ("" if none)
CrashInput []byte // input that caused the last crash/mismatch (nil if none)
} }
// OK returns true when all iterations matched. // OK returns true when all iterations matched.
@@ -30,8 +32,12 @@ func (r FuzzResult) String() string {
if r.OK() { if r.OK() {
return fmt.Sprintf("%s: %d/%d iterations match", r.Func, r.Matches, r.Iterations) return fmt.Sprintf("%s: %d/%d iterations match", r.Func, r.Matches, r.Iterations)
} }
return fmt.Sprintf("%s: %d/%d match, %d MISMATCH — %s", s := fmt.Sprintf("%s: %d/%d match, %d MISMATCH — %s",
r.Func, r.Matches, r.Iterations, r.Mismatches, r.FirstFail) r.Func, r.Matches, r.Iterations, r.Mismatches, r.FirstFail)
if len(r.CrashInput) > 0 {
s += fmt.Sprintf("\n input: %x", r.CrashInput)
}
return s
} }
// funcSig is a parsed // func signature from the assembly source. // funcSig is a parsed // func signature from the assembly source.
@@ -74,22 +80,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
@@ -160,6 +183,9 @@ func (k *Kernel) FuzzFunc(name string, sig funcSig, goCode []byte, iterations in
// (e.g. histogram increments) don't corrupt the other's input. // (e.g. histogram increments) don't corrupt the other's input.
gasmArgs, goArgs, bufs := genDualArgs(rng, sig, fl.Args) gasmArgs, goArgs, bufs := genDualArgs(rng, sig, fl.Args)
// Save the current input for crash diagnostics.
result.CrashInput = gasmArgs
// Call the gasm version. // Call the gasm version.
gasmOut, err := k.CallFunc(name, gasmArgs) gasmOut, err := k.CallFunc(name, gasmArgs)
if err != nil { if err != nil {
@@ -167,7 +193,7 @@ func (k *Kernel) FuzzFunc(name string, sig funcSig, goCode []byte, iterations in
if result.FirstFail == "" { if result.FirstFail == "" {
result.FirstFail = fmt.Sprintf("iter %d: gasm call: %v", i, err) result.FirstFail = fmt.Sprintf("iter %d: gasm call: %v", i, err)
} }
releaseBufs(bufs) runtime.KeepAlive(bufs)
continue continue
} }
@@ -178,7 +204,7 @@ func (k *Kernel) FuzzFunc(name string, sig funcSig, goCode []byte, iterations in
if result.FirstFail == "" { if result.FirstFail == "" {
result.FirstFail = fmt.Sprintf("iter %d: go call: %v", i, err) result.FirstFail = fmt.Sprintf("iter %d: go call: %v", i, err)
} }
releaseBufs(bufs) runtime.KeepAlive(bufs)
continue continue
} }
@@ -196,7 +222,7 @@ func (k *Kernel) FuzzFunc(name string, sig funcSig, goCode []byte, iterations in
} else { } else {
result.Matches++ result.Matches++
} }
releaseBufs(bufs) runtime.KeepAlive(bufs)
} }
return result return result
} }
@@ -359,9 +385,3 @@ func equalBytes(a, b []byte) bool {
} }
return true return true
} }
func releaseBufs(bufs [][]byte) {
// Keep buffers alive until after the call; nothing to free in Go,
// but this prevents the compiler from collecting them too early.
_ = bufs
}
+167
View File
@@ -0,0 +1,167 @@
// Copyright (c) 2026 Petr Balvín <opensource@petrbalvin.org> (https://petrbalvin.org)
// SPDX-License-Identifier: BSD-3-Clause
package verify
import (
"testing"
)
func TestFuzzResultString(t *testing.T) {
t.Run("ok", func(t *testing.T) {
r := FuzzResult{Func: "add", Iterations: 100, Matches: 100}
s := r.String()
if s != "add: 100/100 iterations match" {
t.Errorf("String() = %q", s)
}
})
t.Run("mismatch", func(t *testing.T) {
r := FuzzResult{Func: "mul", Iterations: 100, Matches: 95, Mismatches: 5, FirstFail: "iter 23"}
s := r.String()
if s != "mul: 95/100 match, 5 MISMATCH — iter 23" {
t.Errorf("String() = %q", s)
}
})
t.Run("crash", func(t *testing.T) {
r := FuzzResult{Func: "dec", Iterations: 100, Matches: 99, Mismatches: 1, FirstFail: "SIGSEGV", CrashInput: []byte{0x01, 0x02}}
s := r.String()
if s != "dec: 99/100 match, 1 MISMATCH — SIGSEGV\n input: 0102" {
t.Errorf("String() = %q", s)
}
})
}
func TestArrayLen(t *testing.T) {
tests := []struct {
typ string
want int
}{
{"*[32]uint16", 32},
{"*[16]int32", 16},
{"bad", 1},
{"*[]", 1},
{"*[0x]", 1},
}
for _, tt := range tests {
if got := arrayLen(tt.typ); got != tt.want {
t.Errorf("arrayLen(%q) = %d, want %d", tt.typ, got, tt.want)
}
}
}
func TestElemSizeFor(t *testing.T) {
tests := []struct {
typ string
want int
}{
{"[]byte", 1}, {"[]uint8", 1}, {"[]int8", 1},
{"[]uint16", 2}, {"[]int16", 2},
{"[]uint32", 4}, {"[]int32", 4}, {"[]float32", 4},
{"[]uint64", 8}, {"[]int64", 8}, {"[]float64", 8},
{"[]unknown", 8},
}
for _, tt := range tests {
if got := elemSizeFor(tt.typ); got != tt.want {
t.Errorf("elemSizeFor(%q) = %d, want %d", tt.typ, got, tt.want)
}
}
}
func TestEqualBytes(t *testing.T) {
if !equalBytes([]byte{1, 2, 3}, []byte{1, 2, 3}) {
t.Error("expected equal")
}
if equalBytes([]byte{1, 2}, []byte{1, 2, 3}) {
t.Error("different length: expected not equal")
}
if equalBytes([]byte{1, 2, 3}, []byte{1, 2, 4}) {
t.Error("different content: expected not equal")
}
}
func TestParamsSize(t *testing.T) {
sig := funcSig{
name: "test",
params: []param{{name: "a", typ: "[]byte"}, {name: "b", typ: "int"}},
results: []param{{name: "n", typ: "int"}},
}
if got := paramsSize(sig); got != 32 {
t.Errorf("paramsSize = %d, want 32 (24 for slice + 8 for int)", got)
}
}
func TestBlockCount(t *testing.T) {
k := loadBasic(t)
n, err := k.BlockCount("sum")
if err != nil {
t.Fatalf("BlockCount(sum): %v", err)
}
if n < 2 {
t.Errorf("sum: expected at least 2 blocks, got %d", n)
}
}
func TestFillBuffer(t *testing.T) {
t.Run("zero", func(t *testing.T) {
// fillBuffer("zero") is a no-op — relies on make already zeroing.
buf := make([]byte, 16)
fillBuffer(buf, "zero")
for _, b := range buf {
if b != 0 {
t.Error("zero pattern: make should produce zeroed buffer")
break
}
}
})
t.Run("ones", func(t *testing.T) {
buf := make([]byte, 16)
fillBuffer(buf, "ones")
for _, b := range buf {
if b != 0xFF {
t.Error("ones pattern should fill with 0xFF")
break
}
}
})
t.Run("seq", func(t *testing.T) {
buf := make([]byte, 256)
fillBuffer(buf, "seq")
for i, b := range buf {
if b != byte(i) {
t.Errorf("seq[%d] = %d, want %d", i, b, i)
break
}
}
})
t.Run("hex", func(t *testing.T) {
buf := make([]byte, 6)
fillBuffer(buf, "deadbeef")
want := []byte{0xDE, 0xAD, 0xBE, 0xEF, 0xDE, 0xAD}
for i, b := range buf {
if b != want[i] {
t.Errorf("hex[%d] = %02x, want %02x", i, b, want[i])
break
}
}
})
}
func TestFuzzFuncChecked(t *testing.T) {
k := loadBasic(t)
sig := funcSig{
name: "sum",
params: []param{{name: "data", typ: "[]int64"}},
results: []param{{name: "r", typ: "int64"}},
}
result := k.FuzzFuncChecked("sum", sig, 10, 0)
if !result.OK() {
t.Errorf("FuzzFuncChecked(sum): %s", result)
}
// Test with non-existent function — should report failure.
result = k.FuzzFuncChecked("nope", sig, 10, 0)
if result.OK() {
t.Error("FuzzFuncChecked(nope): expected failure")
}
}
+4
View File
@@ -5,6 +5,7 @@ package verify
import ( import (
"bytes" "bytes"
"runtime"
"testing" "testing"
"unsafe" "unsafe"
) )
@@ -68,6 +69,7 @@ func TestJITSum(t *testing.T) {
PutUint64(args, 16, uint64(cap(tt.data))) PutUint64(args, 16, uint64(cap(tt.data)))
out, err := k.CallFunc("sum", args) out, err := k.CallFunc("sum", args)
runtime.KeepAlive(tt.data)
if err != nil { if err != nil {
t.Fatalf("CallFunc(sum, %v): %v", tt.data, err) t.Fatalf("CallFunc(sum, %v): %v", tt.data, err)
} }
@@ -111,6 +113,8 @@ func TestJITWideCopy(t *testing.T) {
PutUint64(args, 40, uint64(tt.n)) // src_cap PutUint64(args, 40, uint64(tt.n)) // src_cap
_, err := k.CallFunc("wideCopy", args) _, err := k.CallFunc("wideCopy", args)
runtime.KeepAlive(dst)
runtime.KeepAlive(src)
if err != nil { if err != nil {
t.Fatalf("CallFunc(wideCopy): %v", err) t.Fatalf("CallFunc(wideCopy): %v", err)
} }
-160
View File
@@ -1,160 +0,0 @@
// Copyright (c) 2026 Petr Balvín <opensource@petrbalvin.org> (https://petrbalvin.org)
// SPDX-License-Identifier: BSD-3-Clause
package verify
import (
"bytes"
"os"
"testing"
"unsafe"
)
// lz4KernelPath is the sibling repository's AVX2 kernel, used for
// integration testing. The test is skipped when the file is absent
// (e.g. in CI without the sibling checkout).
const lz4KernelPath = "../../go-libraries/go-lz4/avx2_amd64.s"
func loadLZ4Kernel(t *testing.T) *Kernel {
t.Helper()
if _, err := os.Stat(lz4KernelPath); err != nil {
t.Skipf("sibling kernel not available: %v", err)
}
k, err := Load(lz4KernelPath)
if err != nil {
t.Fatalf("Load(%s): %v", lz4KernelPath, err)
}
t.Cleanup(k.Close)
return k
}
// callDecodeBlockAVX2 invokes the JIT-assembled decodeBlockAVX2 with the
// given src and dst buffers, returning (n, code).
func callDecodeBlockAVX2(t *testing.T, k *Kernel, src, dst []byte) (int, int) {
t.Helper()
args := make([]byte, 64)
if len(src) > 0 {
PutPtr(args, 0, unsafe.Pointer(&src[0]))
}
PutUint64(args, 8, uint64(len(src)))
PutUint64(args, 16, uint64(cap(src)))
if len(dst) > 0 {
PutPtr(args, 24, unsafe.Pointer(&dst[0]))
}
PutUint64(args, 32, uint64(len(dst)))
PutUint64(args, 40, uint64(cap(dst)))
out, err := k.CallFunc("decodeBlockAVX2", args)
if err != nil {
t.Fatalf("CallFunc(decodeBlockAVX2): %v", err)
}
return int(GetUint64(out, 48)), int(GetUint64(out, 56))
}
func TestLZ4DecodeKnownAnswers(t *testing.T) {
k := loadLZ4Kernel(t)
tests := []struct {
name string
src []byte
dstSize int
wantDst []byte
wantN int
wantCode int
}{
{
name: "literals_only",
src: []byte{0x50, 'H', 'e', 'l', 'l', 'o'},
dstSize: 16,
wantDst: []byte("Hello"),
wantN: 5,
wantCode: 0,
},
{
name: "literals_and_match",
src: []byte{0x54, 'A', 'A', 'A', 'A', 'A', 0x05, 0x00, 0x30, 'B', 'B', 'B'},
dstSize: 32,
wantDst: []byte("AAAAAAAAAAAAABBB"),
wantN: 16,
wantCode: 0,
},
{
name: "overlapping_match",
// 1 literal 'X', then match offset=1 length=4+4=8 → "XXXXXXXXX",
// then final 1 literal 'Y'.
src: []byte{0x14, 'X', 0x01, 0x00, 0x10, 'Y'},
dstSize: 16,
wantDst: []byte("XXXXXXXXXY"),
wantN: 10,
wantCode: 0,
},
{
name: "malformed_truncated",
src: []byte{0x50, 'H', 'e'}, // claims 5 literals, has 2
dstSize: 16,
wantN: 0,
wantCode: 1,
},
{
name: "zero_offset",
src: []byte{0x14, 'X', 0x00, 0x00},
dstSize: 16,
wantN: 0,
wantCode: 2,
},
{
name: "empty_token",
src: []byte{0x00}, // 0 literals, end of block
dstSize: 16,
wantDst: nil,
wantN: 0,
wantCode: 0,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
dst := make([]byte, tt.dstSize)
n, code := callDecodeBlockAVX2(t, k, tt.src, dst)
if n != tt.wantN || code != tt.wantCode {
t.Fatalf("decodeBlockAVX2: got (n=%d, code=%d), want (n=%d, code=%d)",
n, code, tt.wantN, tt.wantCode)
}
if tt.wantCode == 0 && tt.wantDst != nil {
if !bytes.Equal(dst[:n], tt.wantDst) {
t.Errorf("output mismatch:\n got %q\n want %q", dst[:n], tt.wantDst)
}
}
})
}
}
func TestLZ4WideCopyAVX2(t *testing.T) {
k := loadLZ4Kernel(t)
sizes := []int{0, 1, 15, 16, 31, 32, 33, 63, 64, 100, 256, 1024}
for _, n := range sizes {
src := make([]byte, n)
for i := range src {
src[i] = byte(i*13 + 7)
}
dst := make([]byte, n)
args := make([]byte, 48)
if n > 0 {
PutPtr(args, 0, unsafe.Pointer(&dst[0]))
PutPtr(args, 24, unsafe.Pointer(&src[0]))
}
PutUint64(args, 8, uint64(n))
PutUint64(args, 16, uint64(n))
PutUint64(args, 32, uint64(n))
PutUint64(args, 40, uint64(n))
_, err := k.CallFunc("wideCopyAVX2", args)
if err != nil {
t.Fatalf("wideCopyAVX2(n=%d): %v", n, err)
}
if !bytes.Equal(dst, src) {
t.Errorf("wideCopyAVX2(n=%d): output mismatch", n)
}
}
}
+160
View File
@@ -0,0 +1,160 @@
// Copyright (c) 2026 Petr Balvín <opensource@petrbalvin.org> (https://petrbalvin.org)
// SPDX-License-Identifier: BSD-3-Clause
package verify
import (
"strings"
)
// FuncSig is a parsed // func signature from the assembly source.
type FuncSig struct {
Name string
Params []Param
Results []Param
}
// Param is a single function parameter.
type Param struct {
Name string
Typ string // "[]byte", "[]int32", "int", "*[32]uint16", etc.
}
// IsPointer reports whether the parameter type is a pointer or slice
// (i.e., it needs a buffer address in the argument block).
func (p Param) IsPointer() bool {
return strings.HasPrefix(p.Typ, "[]") || strings.HasPrefix(p.Typ, "*")
}
// ParseFuncSig extracts the function signature from a "// func ..." comment.
func ParseFuncSig(comment string) (FuncSig, bool) {
m := funcSigRe.FindStringSubmatch(strings.TrimSpace(comment))
if m == nil {
return FuncSig{}, false
}
sig := FuncSig{Name: m[1]}
sig.Params = parseParamsExported(m[2])
// Results may be "(a int, b int)" or "int" or "(int, error)".
res := strings.TrimSpace(m[3])
res = strings.TrimPrefix(res, "(")
res = strings.TrimSuffix(res, ")")
if res != "" {
sig.Results = parseParamsExported(res)
}
return sig, true
}
// 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 {
s = strings.TrimSpace(s)
if s == "" {
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
for i, field := range fields {
field = strings.TrimSpace(field)
if field == "" {
continue
}
parts := strings.Fields(field)
typ := types[i]
if typ == "" {
typ = parts[0] // unnamed: the whole field is the type
out = append(out, Param{Typ: typ})
} else {
out = append(out, Param{Name: parts[0], Typ: typ})
}
}
return out
}
// ExtractFuncSig extracts the // func signature for the named function
// from the assembly source.
func ExtractFuncSig(src, funcName string) (FuncSig, bool) {
lines := strings.Split(src, "\n")
for i, line := range lines {
if sig, ok := ParseFuncSig(line); ok && sig.Name == funcName {
// Verify the next non-comment line is the TEXT directive.
for j := i + 1; j < len(lines); j++ {
trimmed := strings.TrimSpace(lines[j])
if trimmed == "" || strings.HasPrefix(trimmed, "//") {
continue
}
if strings.HasPrefix(trimmed, "TEXT") {
return sig, true
}
break
}
}
}
return FuncSig{}, false
}
// ArgLayout computes the ABI0 argument layout for a function signature.
// Returns the offset of each parameter in the argument block.
func ArgLayout(sig FuncSig) []ArgOffset {
var offsets []ArgOffset
off := 0
for _, p := range sig.Params {
size := paramSize(p.Typ)
offsets = append(offsets, ArgOffset{Name: p.Name, Typ: p.Typ, Offset: off, Size: size, IsPtr: p.IsPointer()})
off += size
}
return offsets
}
// ArgOffset describes one parameter's position in the argument block.
type ArgOffset struct {
Name string
Typ string
Offset int
Size int
IsPtr bool
}
// paramSize returns the size in bytes of a parameter type in the ABI0 layout.
func paramSize(typ string) int {
switch {
case strings.HasPrefix(typ, "[]"):
// Slice: pointer + length + capacity = 24 bytes.
return 24
case strings.HasPrefix(typ, "*"):
// Pointer: 8 bytes.
return 8
case typ == "int", typ == "int64", typ == "uint64", typ == "uintptr":
return 8
case typ == "int32", typ == "uint32", typ == "float32":
return 4
case typ == "int16", typ == "uint16":
return 2
case typ == "int8", typ == "uint8", typ == "byte", typ == "bool":
return 1
default:
// Default to 8 bytes for unknown types.
return 8
}
}
+153
View File
@@ -0,0 +1,153 @@
// Copyright (c) 2026 Petr Balvín <opensource@petrbalvin.org> (https://petrbalvin.org)
// SPDX-License-Identifier: BSD-3-Clause
package verify
import (
"testing"
)
func TestParseFuncSigExported(t *testing.T) {
tests := []struct {
comment string
wantName string
nParams int
nResults int
}{
{"// func add(a int64, b int64) int64", "add", 2, 1},
{"// func wideCopy(dst []byte, src []byte)", "wideCopy", 2, 0},
{"// func decodeBlockAVX2(src, dst []byte) (n int, code int)", "decodeBlockAVX2", 2, 2},
{"// func analyzeO1RangeAVX2(swin []int32, dstP []uint32, hist *[32]uint16) (partSum uint64, overflow bool)", "analyzeO1RangeAVX2", 3, 2},
{"// not a func", "", 0, 0},
{"// func", "", 0, 0},
}
for _, tt := range tests {
sig, ok := ParseFuncSig(tt.comment)
if tt.wantName == "" {
if ok {
t.Errorf("ParseFuncSig(%q): expected not ok", tt.comment)
}
continue
}
if !ok {
t.Errorf("ParseFuncSig(%q): expected ok", tt.comment)
continue
}
if sig.Name != tt.wantName {
t.Errorf("ParseFuncSig(%q).Name = %q, want %q", tt.comment, sig.Name, tt.wantName)
}
if len(sig.Params) != tt.nParams {
t.Errorf("ParseFuncSig(%q): %d params, want %d", tt.comment, len(sig.Params), tt.nParams)
}
if len(sig.Results) != tt.nResults {
t.Errorf("ParseFuncSig(%q): %d results, want %d", tt.comment, len(sig.Results), tt.nResults)
}
}
}
func TestExtractFuncSig(t *testing.T) {
src := `// func add(a int64, b int64) int64
TEXT ·add(SB), NOSPLIT, $0-24
// func wideCopyAVX2(dst, src []byte)
TEXT ·wideCopyAVX2(SB), NOSPLIT, $0-48
`
sig, ok := ExtractFuncSig(src, "add")
if !ok {
t.Fatal("ExtractFuncSig(add): not found")
}
if sig.Name != "add" {
t.Errorf("Name = %q, want %q", sig.Name, "add")
}
if len(sig.Params) != 2 {
t.Errorf("params = %d, want 2", len(sig.Params))
}
sig, ok = ExtractFuncSig(src, "wideCopyAVX2")
if !ok {
t.Fatal("ExtractFuncSig(wideCopyAVX2): not found")
}
if len(sig.Params) != 2 {
t.Errorf("params = %d, want 2", len(sig.Params))
}
_, ok = ExtractFuncSig(src, "nonexistent")
if ok {
t.Error("ExtractFuncSig(nonexistent): expected not found")
}
}
func TestArgLayout(t *testing.T) {
sig := FuncSig{
Name: "wideCopyAVX2",
Params: []Param{
{Name: "dst", Typ: "[]byte"},
{Name: "src", Typ: "[]byte"},
},
}
layout := ArgLayout(sig)
if len(layout) != 2 {
t.Fatalf("ArgLayout: %d entries, want 2", len(layout))
}
// dst: offset 0, size 24 (slice)
if layout[0].Name != "dst" || layout[0].Offset != 0 || layout[0].Size != 24 || !layout[0].IsPtr {
t.Errorf("layout[0] = %+v", layout[0])
}
// src: offset 24, size 24 (slice)
if layout[1].Name != "src" || layout[1].Offset != 24 || layout[1].Size != 24 || !layout[1].IsPtr {
t.Errorf("layout[1] = %+v", layout[1])
}
}
func TestArgLayoutMixed(t *testing.T) {
sig := FuncSig{
Name: "decodeBlockAVX2",
Params: []Param{
{Name: "src", Typ: "[]byte"},
{Name: "dst", Typ: "[]byte"},
},
Results: []Param{
{Name: "n", Typ: "int"},
{Name: "code", Typ: "int"},
},
}
layout := ArgLayout(sig)
if len(layout) != 2 {
t.Fatalf("ArgLayout: %d entries, want 2", len(layout))
}
// Two slices: 24 + 24 = 48 bytes of params.
if layout[1].Offset != 24 {
t.Errorf("src offset = %d, want 24", layout[1].Offset)
}
}
func TestParamSize(t *testing.T) {
tests := []struct {
typ string
want int
}{
{"[]byte", 24},
{"[]int32", 24},
{"*uint16", 8},
{"*[32]uint16", 8},
{"int", 8},
{"int64", 8},
{"uint64", 8},
{"uintptr", 8},
{"int32", 4},
{"uint32", 4},
{"float32", 4},
{"int16", 2},
{"uint16", 2},
{"int8", 1},
{"uint8", 1},
{"byte", 1},
{"bool", 1},
{"string", 8}, // unknown type defaults to 8
}
for _, tt := range tests {
if got := paramSize(tt.typ); got != tt.want {
t.Errorf("paramSize(%q) = %d, want %d", tt.typ, got, tt.want)
}
}
}
+62
View File
@@ -5,7 +5,9 @@ package verify
import ( import (
"fmt" "fmt"
"math/rand"
"os" "os"
"runtime"
"sourcedock.dev/petrbalvin/gasm-devkit/asm" "sourcedock.dev/petrbalvin/gasm-devkit/asm"
"sourcedock.dev/petrbalvin/gasm-devkit/ast" "sourcedock.dev/petrbalvin/gasm-devkit/ast"
@@ -114,6 +116,66 @@ func (k *Kernel) CallFuncChecked(name string, args []byte) ([]byte, ABIReport, e
return CallChecked(fnAddr, args) return CallChecked(fnAddr, args)
} }
// FuzzFuncCheckedByName is like FuzzFuncChecked but extracts the signature
// from the source code internally.
func (k *Kernel) FuzzFuncCheckedByName(name, src string, iterations int, seed int64) FuzzResult {
result := FuzzResult{Func: name, Iterations: iterations}
sig, ok := ExtractSignatures(src)[name]
if !ok {
result.Mismatches = iterations
result.FirstFail = "no // func signature found"
return result
}
return k.FuzzFuncChecked(name, sig, iterations, seed)
}
// FuzzFuncChecked combines fuzzing with ABI checks: it generates varied
// inputs and verifies that callee-saved registers and the red zone are
// preserved even on deep execution paths (not just early exits).
func (k *Kernel) FuzzFuncChecked(name string, sig funcSig, iterations int, seed int64) FuzzResult {
result := FuzzResult{Func: name, Iterations: iterations}
rng := rand.New(rand.NewSource(seed))
fl, err := k.Func(name)
if err != nil {
result.Mismatches = iterations
result.FirstFail = err.Error()
return result
}
violations := 0
for i := 0; i < iterations; i++ {
gasmArgs, _, bufs := genDualArgs(rng, sig, fl.Args)
result.CrashInput = gasmArgs
_, report, err := k.CallFuncChecked(name, gasmArgs)
if err != nil {
result.Mismatches++
if result.FirstFail == "" {
result.FirstFail = fmt.Sprintf("iter %d: call: %v", i, err)
}
runtime.KeepAlive(bufs)
continue
}
if !report.OK() {
violations++
result.Mismatches++
if result.FirstFail == "" {
result.FirstFail = fmt.Sprintf("iter %d: %s", i, report.String())
}
} else {
result.Matches++
}
runtime.KeepAlive(bufs)
}
if violations > 0 && result.FirstFail == "" {
result.FirstFail = fmt.Sprintf("%d ABI violations across %d iterations", violations, iterations)
}
return result
}
// Close releases the executable mapping. // Close releases the executable mapping.
func (k *Kernel) Close() { func (k *Kernel) Close() {
if k.exec != nil { if k.exec != nil {