Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f5088c52fc | ||
|
|
f52e23f1bc | ||
|
|
c9775c2b95 |
+59
-1
@@ -24,11 +24,12 @@ import (
|
||||
"sourcedock.dev/petrbalvin/gasm-devkit/lint"
|
||||
"sourcedock.dev/petrbalvin/gasm-devkit/lsp"
|
||||
"sourcedock.dev/petrbalvin/gasm-devkit/parser"
|
||||
"sourcedock.dev/petrbalvin/gasm-devkit/verify"
|
||||
)
|
||||
|
||||
// version is the release version, stamped at build time via
|
||||
// -ldflags "-X main.version=…" (defaulting to the current release).
|
||||
var version = "0.16.0"
|
||||
var version = "0.19.0"
|
||||
|
||||
func main() {
|
||||
if len(os.Args) < 2 {
|
||||
@@ -46,6 +47,8 @@ func main() {
|
||||
os.Exit(cmdLint(os.Args[2:]))
|
||||
case "asm":
|
||||
os.Exit(cmdAsm(os.Args[2:]))
|
||||
case "verify":
|
||||
os.Exit(cmdVerify(os.Args[2:]))
|
||||
case "lsp":
|
||||
os.Exit(cmdLSP(os.Args[2:]))
|
||||
case "version", "--version", "-V":
|
||||
@@ -80,6 +83,7 @@ Commands:
|
||||
fmt canonicalise formatting (gofmt for assembly)
|
||||
lint run static checks
|
||||
asm assemble .s files to machine code (amd64)
|
||||
verify JIT-assemble and run dynamic checks (amd64)
|
||||
lsp run the language server over stdio
|
||||
version print the version (same as --version)
|
||||
|
||||
@@ -466,3 +470,57 @@ requires -p, the package path, and the installed Go toolchain).
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func cmdVerify(args []string) int {
|
||||
fs := newCommand("verify", "gasm verify <file.s>", `
|
||||
Assemble FILE (amd64), map it into executable memory and report the available
|
||||
functions. This confirms the assembled image is self-consistent (no
|
||||
unresolved external symbols) and executable — the prerequisite for dynamic
|
||||
testing.
|
||||
|
||||
With -smoke, each NOSPLIT function is called with a zeroed argument block to
|
||||
confirm the JIT trampoline works end-to-end. This is safe only for functions
|
||||
that tolerate nil pointers and zero lengths in their arguments.
|
||||
`)
|
||||
smoke := fs.Bool("smoke", false, "call each NOSPLIT function with zeroed args")
|
||||
fs.Parse(args)
|
||||
if fs.NArg() != 1 {
|
||||
fmt.Fprintln(os.Stderr, "usage: gasm verify [-smoke] <file.s>")
|
||||
return 2
|
||||
}
|
||||
path := fs.Arg(0)
|
||||
if arch.FromFilename(path) != arch.AMD64 {
|
||||
fmt.Fprintln(os.Stderr, "gasm verify: only amd64 is supported")
|
||||
return 1
|
||||
}
|
||||
|
||||
k, err := verify.Load(path)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "gasm verify: %v\n", err)
|
||||
return 1
|
||||
}
|
||||
defer k.Close()
|
||||
|
||||
names := k.FuncNames()
|
||||
fmt.Printf("%s: %d functions JIT-loaded\n", path, len(names))
|
||||
rc := 0
|
||||
for _, name := range names {
|
||||
fl, _ := k.Func(name)
|
||||
flags := ""
|
||||
if fl.NoSplit {
|
||||
flags = " NOSPLIT"
|
||||
}
|
||||
fmt.Printf(" %s: %d bytes, args=%d, frame=%d%s\n", name, fl.Size, fl.Args, fl.Frame, flags)
|
||||
if *smoke && fl.NoSplit {
|
||||
args := make([]byte, fl.Args)
|
||||
_, err := k.CallFunc(name, args)
|
||||
if err != nil {
|
||||
fmt.Printf(" smoke: FAIL — %v\n", err)
|
||||
rc = 1
|
||||
} else {
|
||||
fmt.Printf(" smoke: OK\n")
|
||||
}
|
||||
}
|
||||
}
|
||||
return rc
|
||||
}
|
||||
|
||||
@@ -284,6 +284,31 @@ 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
|
||||
remaining EVEX forms and the other architectures.
|
||||
|
||||
### `verify`
|
||||
|
||||
The dynamic-analysis substrate (Phase 3). It JIT-loads assembled images into
|
||||
executable memory and invokes them directly, enabling differential testing,
|
||||
runtime ABI checks and coverage profiling.
|
||||
|
||||
The execution model is pure Go (stdlib only). `Map` copies machine code into
|
||||
an anonymous `syscall.Mmap` mapping and enforces W^X (write the bytes, then
|
||||
`mprotect` to read-execute). `Call` prepares a stack whose first word is the
|
||||
address of an assembly trampoline (`leaveJIT`), lays the ABI0 argument
|
||||
block after it, switches to that stack via `enterJIT` (which saves the Go
|
||||
stack pointer in a package global and jumps to the target), and recovers
|
||||
control when the function RETs into `leaveJIT` (which restores the Go stack
|
||||
and returns). A 64-byte pad below the return address accommodates the
|
||||
ABIInternal wrapper that the Go runtime interposes on assembly functions.
|
||||
|
||||
`Load` / `LoadSource` / `LoadAST` parse, assemble and map a `.s` file in one
|
||||
step, returning a `Kernel` whose `CallFunc` method marshals the argument block
|
||||
by name. The image must be self-contained (no external relocations); the
|
||||
assembler’s `Image.Bytes()` provides the code-and-data concatenation.
|
||||
|
||||
The `gasm verify` CLI subcommand exposes this: it loads a file, reports the
|
||||
available functions and (with `-smoke`) calls each NOSPLIT function with zeroed
|
||||
arguments to confirm the trampoline round-trips.
|
||||
|
||||
## Extension points
|
||||
|
||||
- **New architecture:** add an entry to the generator in `_gen`, run
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
|
||||
# gasm-devkit — developer tooling for Go's Plan 9 assembler (GAsm).
|
||||
|
||||
version := "0.16.0"
|
||||
version := "0.19.0"
|
||||
|
||||
default:
|
||||
@just --list
|
||||
|
||||
Vendored
+28
@@ -0,0 +1,28 @@
|
||||
// Copyright (c) 2026 Petr Balvín <opensource@petrbalvin.org> (https://petrbalvin.org)
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
#include "textflag.h"
|
||||
|
||||
// func cleanAdd(a, b int64) int64
|
||||
// A well-behaved function that preserves all callee-saved registers.
|
||||
TEXT ·cleanAdd(SB), NOSPLIT, $0-24
|
||||
MOVQ a+0(FP), AX
|
||||
ADDQ b+8(FP), AX
|
||||
MOVQ AX, ret+16(FP)
|
||||
RET
|
||||
|
||||
// func dirtyBP(a int64) int64
|
||||
// Deliberately clobbers BP (an ABI violation for a NOSPLIT frame=0 function).
|
||||
TEXT ·dirtyBP(SB), NOSPLIT, $0-16
|
||||
MOVQ $0x1234, BP
|
||||
MOVQ a+0(FP), AX
|
||||
MOVQ AX, ret+8(FP)
|
||||
RET
|
||||
|
||||
// func dirtyR14(a int64) int64
|
||||
// Deliberately clobbers R14 (the goroutine pointer — a serious ABI violation).
|
||||
TEXT ·dirtyR14(SB), NOSPLIT, $0-16
|
||||
MOVQ $0x5678, R14
|
||||
MOVQ a+0(FP), AX
|
||||
MOVQ AX, ret+8(FP)
|
||||
RET
|
||||
Vendored
+67
@@ -0,0 +1,67 @@
|
||||
// Copyright (c) 2026 Petr Balvín <opensource@petrbalvin.org> (https://petrbalvin.org)
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
#include "textflag.h"
|
||||
|
||||
// func add(a, b int64) int64
|
||||
TEXT ·add(SB), NOSPLIT, $0-24
|
||||
MOVQ a+0(FP), AX
|
||||
ADDQ b+8(FP), AX
|
||||
MOVQ AX, ret+16(FP)
|
||||
RET
|
||||
|
||||
// func sum(data []int64) int64
|
||||
// Sums all elements of the slice.
|
||||
TEXT ·sum(SB), NOSPLIT, $0-32
|
||||
MOVQ data_base+0(FP), SI
|
||||
MOVQ data_len+8(FP), CX
|
||||
XORQ AX, AX
|
||||
TESTQ CX, CX
|
||||
JZ sum_done
|
||||
|
||||
sum_loop:
|
||||
ADDQ (SI), AX
|
||||
ADDQ $8, SI
|
||||
DECQ CX
|
||||
JNZ sum_loop
|
||||
|
||||
sum_done:
|
||||
MOVQ AX, ret+24(FP)
|
||||
RET
|
||||
|
||||
// func wideCopy(dst, src []byte)
|
||||
// Non-overlapping copy of min(len(dst), len(src)) bytes using 32-byte moves.
|
||||
TEXT ·wideCopy(SB), NOSPLIT, $0-48
|
||||
MOVQ dst_base+0(FP), DI
|
||||
MOVQ dst_len+8(FP), BX
|
||||
MOVQ src_base+24(FP), SI
|
||||
MOVQ src_len+32(FP), R8
|
||||
CMPQ BX, R8
|
||||
JLE wc_have_n
|
||||
MOVQ R8, BX
|
||||
|
||||
wc_have_n:
|
||||
CMPQ BX, $32
|
||||
JB wc_small
|
||||
|
||||
VMOVDQU (SI), Y0
|
||||
VMOVDQU Y0, (DI)
|
||||
VMOVDQU -32(SI)(BX*1), Y0
|
||||
VMOVDQU Y0, -32(DI)(BX*1)
|
||||
VZEROUPPER
|
||||
RET
|
||||
|
||||
wc_small:
|
||||
TESTQ BX, BX
|
||||
JZ wc_done
|
||||
|
||||
wc_byte:
|
||||
MOVB (SI), R8B
|
||||
MOVB R8B, (DI)
|
||||
INCQ SI
|
||||
INCQ DI
|
||||
DECQ BX
|
||||
JNZ wc_byte
|
||||
|
||||
wc_done:
|
||||
RET
|
||||
@@ -0,0 +1,130 @@
|
||||
// Copyright (c) 2026 Petr Balvín <opensource@petrbalvin.org> (https://petrbalvin.org)
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
//go:build amd64
|
||||
|
||||
package verify
|
||||
|
||||
import (
|
||||
"encoding/binary"
|
||||
"fmt"
|
||||
"syscall"
|
||||
"unsafe"
|
||||
)
|
||||
|
||||
// abiResult records register-clobber violations detected by the ABI-checking
|
||||
// trampoline. Bit 0: BP clobbered. Bit 1: R14 clobbered.
|
||||
var abiResult uint64
|
||||
|
||||
// savedBP holds the caller's frame pointer across the ABI-checked JIT call.
|
||||
// Referenced by enterJITChecked to satisfy go vet's save-before-clobber rule.
|
||||
var savedBP uintptr
|
||||
|
||||
// leaveCheckedPtr is initialised by the linker from the GLOBL/DATA in
|
||||
// abi_amd64.s: it holds the raw address of leaveJITCheckedRaw (which has
|
||||
// no ABIInternal wrapper, so the JIT function RETs directly into it).
|
||||
var leaveCheckedPtr uintptr
|
||||
|
||||
// enterJITChecked sets sentinels in BP and R14, switches to the prepared
|
||||
// stack and jumps to fn.
|
||||
//
|
||||
//go:nosplit
|
||||
func enterJITChecked(fn uintptr, stack uintptr)
|
||||
|
||||
// leaveJITCheckedRaw is the raw return trampoline for ABI checks. Its
|
||||
// address is obtained from the GLOBL in abi_amd64.s (leaveCheckedPtr),
|
||||
// which points to the .abi0 code — NOT the ABIInternal wrapper that this
|
||||
// declaration would generate. The declaration exists solely to satisfy
|
||||
// go vet's "missing Go declaration" check.
|
||||
//
|
||||
//go:nosplit
|
||||
func leaveJITCheckedRaw()
|
||||
|
||||
// ABIReport describes the result of an ABI-checking call.
|
||||
type ABIReport struct {
|
||||
BPClobbered bool // BP was modified by the function
|
||||
R14Clobbered bool // R14 (goroutine pointer) was modified
|
||||
RedZoneHit bool // the 128-byte red zone below SP was written
|
||||
}
|
||||
|
||||
// OK returns true when no violations were detected.
|
||||
func (r ABIReport) OK() bool {
|
||||
return !r.BPClobbered && !r.R14Clobbered && !r.RedZoneHit
|
||||
}
|
||||
|
||||
// String returns a human-readable summary.
|
||||
func (r ABIReport) String() string {
|
||||
if r.OK() {
|
||||
return "ABI clean"
|
||||
}
|
||||
s := "ABI violation:"
|
||||
if r.BPClobbered {
|
||||
s += " BP clobbered"
|
||||
}
|
||||
if r.R14Clobbered {
|
||||
s += " R14 clobbered"
|
||||
}
|
||||
if r.RedZoneHit {
|
||||
s += " red-zone written"
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
// redZoneSize is the System V AMD64 red zone: 128 bytes below SP that a
|
||||
// leaf function may use without adjusting SP. Go does not use the red zone,
|
||||
// so any write there is a bug.
|
||||
const redZoneSize = 128
|
||||
|
||||
// redZoneFill is the byte pattern used to detect red-zone writes.
|
||||
const redZoneFill = 0xA5
|
||||
|
||||
// CallChecked invokes the function with ABI sentinels and a red-zone
|
||||
// canary, returning both the argument block (with results) and an ABIReport.
|
||||
func CallChecked(fnAddr uintptr, args []byte) ([]byte, ABIReport, error) {
|
||||
report := ABIReport{}
|
||||
|
||||
// Reset the global result.
|
||||
abiResult = 0
|
||||
|
||||
// Prepare the stack: [red-zone canary][padding][leaveJITCheckedRaw][args...]
|
||||
// The red zone sits below the initial SP, so the function would have to
|
||||
// write below SP to corrupt it.
|
||||
totalSize := redZoneSize + stackPad + 8 + len(args) + 64
|
||||
stackMem, err := syscall.Mmap(-1, 0, totalSize,
|
||||
syscall.PROT_READ|syscall.PROT_WRITE, syscall.MAP_PRIVATE|syscall.MAP_ANON)
|
||||
if err != nil {
|
||||
return nil, report, fmt.Errorf("verify: stack mmap: %w", err)
|
||||
}
|
||||
defer syscall.Munmap(stackMem)
|
||||
|
||||
// Fill the red zone with the canary pattern.
|
||||
for i := 0; i < redZoneSize; i++ {
|
||||
stackMem[i] = redZoneFill
|
||||
}
|
||||
|
||||
// Return address and args after the red zone and padding.
|
||||
retOff := redZoneSize + stackPad
|
||||
binary.LittleEndian.PutUint64(stackMem[retOff:retOff+8], uint64(leaveCheckedPtr))
|
||||
copy(stackMem[retOff+8:], args)
|
||||
|
||||
stackBase := uintptr(unsafe.Pointer(&stackMem[retOff]))
|
||||
enterJITChecked(fnAddr, stackBase)
|
||||
|
||||
// Read the register-clobber result.
|
||||
res := abiResult
|
||||
report.BPClobbered = res&1 != 0
|
||||
report.R14Clobbered = res&2 != 0
|
||||
|
||||
// Check the red zone.
|
||||
for i := 0; i < redZoneSize; i++ {
|
||||
if stackMem[i] != redZoneFill {
|
||||
report.RedZoneHit = true
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
// Copy out the argument area.
|
||||
out := make([]byte, len(args))
|
||||
copy(out, stackMem[retOff+8:retOff+8+len(args)])
|
||||
return out, report, nil
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
// Copyright (c) 2026 Petr Balvín <opensource@petrbalvin.org> (https://petrbalvin.org)
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
#include "textflag.h"
|
||||
|
||||
// ABI-checking trampoline. Sets sentinel values in the callee-saved
|
||||
// registers (BP, R14) before entering the JIT function and checks whether
|
||||
// they survived on return.
|
||||
//
|
||||
// The return trampoline (leaveJITCheckedRaw) is a raw TEXT symbol with no
|
||||
// Go function declaration, so the toolchain does NOT interpose an
|
||||
// ABIInternal wrapper — the JIT function RETs directly into the check code,
|
||||
// which sees the registers exactly as the function left them.
|
||||
//
|
||||
// Go ABI0 on amd64 guarantees:
|
||||
// - BP is callee-saved (NOSPLIT frame=0 functions must not touch it).
|
||||
// - R14 holds the goroutine pointer and must survive across any call.
|
||||
|
||||
// Sentinel values chosen to be unlikely in normal execution.
|
||||
#define SENTINEL_BP 0xDEADBEEFCAFEF00D
|
||||
#define SENTINEL_R14 0x0BADF00DDEADBEEF
|
||||
|
||||
// GLOBL holding the raw address of the leave trampoline, read by Go.
|
||||
GLOBL ·leaveCheckedPtr(SB), NOPTR, $8
|
||||
DATA ·leaveCheckedPtr(SB)/8, $·leaveJITCheckedRaw(SB)
|
||||
|
||||
// func enterJITChecked(fn uintptr, stack uintptr)
|
||||
// Sets sentinels in BP and R14, switches to the prepared stack and jumps
|
||||
// to fn. The prepared stack's return address must be leaveJITCheckedRaw
|
||||
// (read from leaveCheckedPtr).
|
||||
TEXT ·enterJITChecked(SB), NOSPLIT, $0-16
|
||||
MOVQ fn+0(FP), AX // target (before SP switch)
|
||||
MOVQ SP, ·savedSP(SB) // preserve Go stack
|
||||
MOVQ BP, ·savedBP(SB) // preserve frame pointer (vet requires save before clobber)
|
||||
MOVQ $SENTINEL_BP, BP // sentinel in BP
|
||||
MOVQ $SENTINEL_R14, R14 // sentinel in R14
|
||||
MOVQ stack+8(FP), SP // switch to prepared stack
|
||||
JMP AX
|
||||
|
||||
// leaveJITCheckedRaw is the raw return trampoline. It has NO Go function
|
||||
// declaration, so no ABIInternal wrapper is generated — the JIT function's
|
||||
// RET lands here directly, seeing BP and R14 exactly as the function left
|
||||
// them. It checks the sentinels, records violations in abiResult, then
|
||||
// restores the Go stack and returns.
|
||||
TEXT ·leaveJITCheckedRaw(SB), NOSPLIT, $0-0
|
||||
// Check BP against the sentinel.
|
||||
MOVQ $SENTINEL_BP, CX
|
||||
CMPQ BP, CX
|
||||
JEQ bp_ok
|
||||
ORQ $1, ·abiResult(SB)
|
||||
|
||||
bp_ok:
|
||||
// Check R14 against the sentinel.
|
||||
MOVQ $SENTINEL_R14, CX
|
||||
CMPQ R14, CX
|
||||
JEQ r14_ok
|
||||
ORQ $2, ·abiResult(SB)
|
||||
|
||||
r14_ok:
|
||||
MOVQ ·savedSP(SB), SP
|
||||
RET
|
||||
@@ -0,0 +1,26 @@
|
||||
// Copyright (c) 2026 Petr Balvín <opensource@petrbalvin.org> (https://petrbalvin.org)
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
//go:build !amd64
|
||||
|
||||
package verify
|
||||
|
||||
import "fmt"
|
||||
|
||||
// ABIReport describes the result of an ABI-checking call.
|
||||
type ABIReport struct {
|
||||
BPClobbered bool
|
||||
R14Clobbered bool
|
||||
RedZoneHit bool
|
||||
}
|
||||
|
||||
// OK returns true when no violations were detected.
|
||||
func (r ABIReport) OK() bool { return false }
|
||||
|
||||
// String returns a human-readable summary.
|
||||
func (r ABIReport) String() string { return "verify: ABI checks require amd64" }
|
||||
|
||||
// CallChecked is unavailable on non-amd64 architectures.
|
||||
func CallChecked(fnAddr uintptr, args []byte) ([]byte, ABIReport, error) {
|
||||
return nil, ABIReport{}, fmt.Errorf("verify: ABI checks require amd64")
|
||||
}
|
||||
@@ -0,0 +1,145 @@
|
||||
// Copyright (c) 2026 Petr Balvín <opensource@petrbalvin.org> (https://petrbalvin.org)
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
package verify
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"unsafe"
|
||||
)
|
||||
|
||||
func loadABIKernel(t *testing.T) *Kernel {
|
||||
t.Helper()
|
||||
k, err := Load("../testdata/verify/abi_amd64.s")
|
||||
if err != nil {
|
||||
t.Fatalf("Load: %v", err)
|
||||
}
|
||||
t.Cleanup(k.Close)
|
||||
return k
|
||||
}
|
||||
|
||||
func TestABIClean(t *testing.T) {
|
||||
k := loadABIKernel(t)
|
||||
|
||||
args := make([]byte, 24)
|
||||
PutUint64(args, 0, 3)
|
||||
PutUint64(args, 8, 4)
|
||||
|
||||
out, report, err := k.CallFuncChecked("cleanAdd", args)
|
||||
if err != nil {
|
||||
t.Fatalf("CallFuncChecked: %v", err)
|
||||
}
|
||||
if got := int64(GetUint64(out, 16)); got != 7 {
|
||||
t.Errorf("cleanAdd(3, 4) = %d, want 7", got)
|
||||
}
|
||||
if !report.OK() {
|
||||
t.Errorf("cleanAdd: %s", report)
|
||||
}
|
||||
}
|
||||
|
||||
func TestABIBPClobbered(t *testing.T) {
|
||||
k := loadABIKernel(t)
|
||||
|
||||
args := make([]byte, 16)
|
||||
PutUint64(args, 0, 42)
|
||||
|
||||
out, report, err := k.CallFuncChecked("dirtyBP", args)
|
||||
if err != nil {
|
||||
t.Fatalf("CallFuncChecked: %v", err)
|
||||
}
|
||||
if got := int64(GetUint64(out, 8)); got != 42 {
|
||||
t.Errorf("dirtyBP(42) = %d, want 42", got)
|
||||
}
|
||||
if !report.BPClobbered {
|
||||
t.Error("dirtyBP: expected BP clobbered, but report says clean")
|
||||
}
|
||||
if report.R14Clobbered {
|
||||
t.Error("dirtyBP: R14 should not be clobbered")
|
||||
}
|
||||
}
|
||||
|
||||
func TestABIR14Clobbered(t *testing.T) {
|
||||
k := loadABIKernel(t)
|
||||
|
||||
args := make([]byte, 16)
|
||||
PutUint64(args, 0, 99)
|
||||
|
||||
out, report, err := k.CallFuncChecked("dirtyR14", args)
|
||||
if err != nil {
|
||||
t.Fatalf("CallFuncChecked: %v", err)
|
||||
}
|
||||
if got := int64(GetUint64(out, 8)); got != 99 {
|
||||
t.Errorf("dirtyR14(99) = %d, want 99", got)
|
||||
}
|
||||
if !report.R14Clobbered {
|
||||
t.Error("dirtyR14: expected R14 clobbered, but report says clean")
|
||||
}
|
||||
if report.BPClobbered {
|
||||
t.Error("dirtyR14: BP should not be clobbered")
|
||||
}
|
||||
}
|
||||
|
||||
// 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) {
|
||||
k := loadABIKernel(t)
|
||||
|
||||
// Nonexistent function.
|
||||
_, _, err := k.CallFuncChecked("nope", make([]byte, 8))
|
||||
if err == nil {
|
||||
t.Fatal("expected error for nonexistent function")
|
||||
}
|
||||
|
||||
// Arg block too small.
|
||||
_, _, err = k.CallFuncChecked("cleanAdd", make([]byte, 8))
|
||||
if err == nil {
|
||||
t.Fatal("expected error for too-small arg block")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
// Copyright (c) 2026 Petr Balvín <opensource@petrbalvin.org> (https://petrbalvin.org)
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
//go:build amd64
|
||||
|
||||
package verify
|
||||
|
||||
import (
|
||||
"encoding/binary"
|
||||
"fmt"
|
||||
"reflect"
|
||||
"syscall"
|
||||
"unsafe"
|
||||
)
|
||||
|
||||
// savedSP holds the Go stack pointer while a JIT call is in flight.
|
||||
// Referenced by the assembly trampoline (trampoline_amd64.s).
|
||||
var savedSP uintptr
|
||||
|
||||
// enterJIT switches to the prepared stack and jumps to fn.
|
||||
// It does not return normally; the JIT function's RET transfers control
|
||||
// to leaveJIT, which restores the Go stack.
|
||||
//
|
||||
//go:nosplit
|
||||
func enterJIT(fn uintptr, stack uintptr)
|
||||
|
||||
// leaveJIT restores the Go stack after a JIT function returns.
|
||||
// Its address is placed as the return address on the prepared stack.
|
||||
//
|
||||
//go:nosplit
|
||||
func leaveJIT()
|
||||
|
||||
// leaveJITAddr is the machine address of leaveJIT, resolved once at init.
|
||||
var leaveJITAddr uintptr
|
||||
|
||||
func init() {
|
||||
leaveJITAddr = reflect.ValueOf(leaveJIT).Pointer()
|
||||
}
|
||||
|
||||
// stackPad is padding below the return address on the prepared stack.
|
||||
// The ABIInternal wrapper that leaveJIT's address resolves to executes
|
||||
// PUSHQ BP and CALL before reaching the raw assembly, writing up to 16
|
||||
// bytes below the return-address slot. 64 bytes of headroom is ample.
|
||||
const stackPad = 64
|
||||
|
||||
// Call invokes the assembled function at fnAddr with the given ABI0 argument
|
||||
// block (the raw bytes that would appear at FP+0). It returns the argument
|
||||
// block after the call, which contains any results the function wrote back
|
||||
// (the ABI0 convention shares the argument area for inputs and outputs).
|
||||
//
|
||||
// The function must be NOSPLIT (no stack growth) and must not reference
|
||||
// external symbols — the image is self-contained.
|
||||
func Call(fnAddr uintptr, args []byte) ([]byte, error) {
|
||||
// Prepare the stack: [padding][leaveJIT addr][args...]
|
||||
stackSize := stackPad + 8 + len(args) + 64 // padding + ret + args + safety
|
||||
stackMem, err := syscall.Mmap(-1, 0, stackSize,
|
||||
syscall.PROT_READ|syscall.PROT_WRITE, syscall.MAP_PRIVATE|syscall.MAP_ANON)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("verify: stack mmap: %w", err)
|
||||
}
|
||||
defer syscall.Munmap(stackMem)
|
||||
|
||||
// The return address sits after the padding; the function's SP will
|
||||
// point here, leaving stackPad bytes below for the wrapper's pushes.
|
||||
retOff := stackPad
|
||||
binary.LittleEndian.PutUint64(stackMem[retOff:retOff+8], uint64(leaveJITAddr))
|
||||
// The ABI0 argument area follows the return address.
|
||||
copy(stackMem[retOff+8:], args)
|
||||
|
||||
stackBase := uintptr(unsafe.Pointer(&stackMem[retOff]))
|
||||
enterJIT(fnAddr, stackBase)
|
||||
|
||||
// Copy out the (possibly modified) argument area.
|
||||
out := make([]byte, len(args))
|
||||
copy(out, stackMem[retOff+8:retOff+8+len(args)])
|
||||
return out, nil
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
// Copyright (c) 2026 Petr Balvín <opensource@petrbalvin.org> (https://petrbalvin.org)
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
//go:build !amd64
|
||||
|
||||
package verify
|
||||
|
||||
import "fmt"
|
||||
|
||||
// Call is unavailable on non-amd64 architectures.
|
||||
func Call(fnAddr uintptr, args []byte) ([]byte, error) {
|
||||
return nil, fmt.Errorf("verify: JIT execution requires amd64")
|
||||
}
|
||||
@@ -0,0 +1,295 @@
|
||||
// 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))
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
// Copyright (c) 2026 Petr Balvín <opensource@petrbalvin.org> (https://petrbalvin.org)
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
// Package verify provides the dynamic-analysis substrate for gasm: it
|
||||
// JIT-assembles Plan 9 amd64 kernels into executable memory and calls them
|
||||
// directly, enabling differential testing against portable Go references,
|
||||
// runtime ABI checks and basic-block coverage profiling.
|
||||
//
|
||||
// The execution model is pure Go (stdlib only): machine code is mapped with
|
||||
// syscall.Mmap and invoked through an assembly trampoline that switches to a
|
||||
// prepared ABI0 stack. No cgo, no external toolchain.
|
||||
package verify
|
||||
|
||||
import (
|
||||
"encoding/binary"
|
||||
"fmt"
|
||||
"syscall"
|
||||
"unsafe"
|
||||
)
|
||||
|
||||
// Executable maps a copy of code into a read-execute memory region suitable
|
||||
// for direct invocation. The mapping is anonymous and private; the original
|
||||
// slice is not retained. Call Unmap to release the region.
|
||||
type Executable struct {
|
||||
addr uintptr // base address of the mapping
|
||||
size int
|
||||
mem []byte // the mmap'd slice (for Unmap)
|
||||
}
|
||||
|
||||
// Map copies code into a freshly allocated RX region and returns it.
|
||||
// The mapping is PROT_READ|PROT_EXEC; writes are not permitted after the
|
||||
// copy, matching W^X policy.
|
||||
func Map(code []byte) (*Executable, error) {
|
||||
size := len(code)
|
||||
if size == 0 {
|
||||
return nil, fmt.Errorf("verify: cannot map zero-length code")
|
||||
}
|
||||
// Round up to the page size.
|
||||
const pageSize = 4096
|
||||
mapSize := (size + pageSize - 1) &^ (pageSize - 1)
|
||||
|
||||
mem, err := syscall.Mmap(-1, 0, mapSize,
|
||||
syscall.PROT_READ|syscall.PROT_WRITE, syscall.MAP_PRIVATE|syscall.MAP_ANON)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("verify: mmap: %w", err)
|
||||
}
|
||||
copy(mem, code)
|
||||
|
||||
// Remove write permission (W^X).
|
||||
if err := syscall.Mprotect(mem, syscall.PROT_READ|syscall.PROT_EXEC); err != nil {
|
||||
syscall.Munmap(mem)
|
||||
return nil, fmt.Errorf("verify: mprotect: %w", err)
|
||||
}
|
||||
return &Executable{
|
||||
addr: uintptr(unsafe.Pointer(&mem[0])),
|
||||
size: size,
|
||||
mem: mem,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Unmap releases the executable region.
|
||||
func (e *Executable) Unmap() {
|
||||
if e.mem != nil {
|
||||
syscall.Munmap(e.mem)
|
||||
e.mem = nil
|
||||
}
|
||||
}
|
||||
|
||||
// FuncAddr returns the absolute address of a function at the given offset
|
||||
// within the mapped image.
|
||||
func (e *Executable) FuncAddr(offset int) uintptr {
|
||||
return e.addr + uintptr(offset)
|
||||
}
|
||||
|
||||
// PutUint64 writes v into buf at byte offset off (little-endian).
|
||||
func PutUint64(buf []byte, off int, v uint64) {
|
||||
binary.LittleEndian.PutUint64(buf[off:off+8], v)
|
||||
}
|
||||
|
||||
// GetUint64 reads a little-endian uint64 from buf at byte offset off.
|
||||
func GetUint64(buf []byte, off int) uint64 {
|
||||
return binary.LittleEndian.Uint64(buf[off : off+8])
|
||||
}
|
||||
|
||||
// PutPtr writes a pointer value into buf at byte offset off.
|
||||
func PutPtr(buf []byte, off int, p unsafe.Pointer) {
|
||||
binary.LittleEndian.PutUint64(buf[off:off+8], uint64(uintptr(p)))
|
||||
}
|
||||
@@ -0,0 +1,215 @@
|
||||
// Copyright (c) 2026 Petr Balvín <opensource@petrbalvin.org> (https://petrbalvin.org)
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
package verify
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"testing"
|
||||
"unsafe"
|
||||
)
|
||||
|
||||
func loadBasic(t *testing.T) *Kernel {
|
||||
t.Helper()
|
||||
k, err := Load("../testdata/verify/basic_amd64.s")
|
||||
if err != nil {
|
||||
t.Fatalf("Load: %v", err)
|
||||
}
|
||||
t.Cleanup(k.Close)
|
||||
return k
|
||||
}
|
||||
|
||||
func TestJITAdd(t *testing.T) {
|
||||
k := loadBasic(t)
|
||||
|
||||
tests := []struct {
|
||||
a, b, want int64
|
||||
}{
|
||||
{0, 0, 0},
|
||||
{1, 2, 3},
|
||||
{-1, 1, 0},
|
||||
{1 << 62, 1 << 62, -9223372036854775808}, // overflow wraps (MinInt64)
|
||||
{-100, -200, -300},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
args := make([]byte, 24)
|
||||
PutUint64(args, 0, uint64(tt.a))
|
||||
PutUint64(args, 8, uint64(tt.b))
|
||||
|
||||
out, err := k.CallFunc("add", args)
|
||||
if err != nil {
|
||||
t.Fatalf("CallFunc(add, %d, %d): %v", tt.a, tt.b, err)
|
||||
}
|
||||
got := int64(GetUint64(out, 16))
|
||||
if got != tt.want {
|
||||
t.Errorf("add(%d, %d) = %d, want %d", tt.a, tt.b, got, tt.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestJITSum(t *testing.T) {
|
||||
k := loadBasic(t)
|
||||
|
||||
tests := []struct {
|
||||
data []int64
|
||||
want int64
|
||||
}{
|
||||
{nil, 0},
|
||||
{[]int64{1}, 1},
|
||||
{[]int64{1, 2, 3, 4, 5}, 15},
|
||||
{[]int64{-10, 20, -30, 40}, 20},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
args := make([]byte, 32)
|
||||
if len(tt.data) > 0 {
|
||||
PutPtr(args, 0, unsafe.Pointer(&tt.data[0]))
|
||||
}
|
||||
PutUint64(args, 8, uint64(len(tt.data)))
|
||||
PutUint64(args, 16, uint64(cap(tt.data)))
|
||||
|
||||
out, err := k.CallFunc("sum", args)
|
||||
if err != nil {
|
||||
t.Fatalf("CallFunc(sum, %v): %v", tt.data, err)
|
||||
}
|
||||
got := int64(GetUint64(out, 24))
|
||||
if got != tt.want {
|
||||
t.Errorf("sum(%v) = %d, want %d", tt.data, got, tt.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestJITWideCopy(t *testing.T) {
|
||||
k := loadBasic(t)
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
n int
|
||||
}{
|
||||
{"empty", 0},
|
||||
{"tiny", 7},
|
||||
{"exact32", 32},
|
||||
{"overlap_range", 48},
|
||||
{"exact64", 64},
|
||||
{"unaligned", 45},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
src := make([]byte, tt.n)
|
||||
for i := range src {
|
||||
src[i] = byte(i * 7)
|
||||
}
|
||||
dst := make([]byte, tt.n)
|
||||
|
||||
args := make([]byte, 48)
|
||||
if tt.n > 0 {
|
||||
PutPtr(args, 0, unsafe.Pointer(&dst[0]))
|
||||
PutPtr(args, 24, unsafe.Pointer(&src[0]))
|
||||
}
|
||||
PutUint64(args, 8, uint64(tt.n)) // dst_len
|
||||
PutUint64(args, 16, uint64(tt.n)) // dst_cap
|
||||
PutUint64(args, 32, uint64(tt.n)) // src_len
|
||||
PutUint64(args, 40, uint64(tt.n)) // src_cap
|
||||
|
||||
_, err := k.CallFunc("wideCopy", args)
|
||||
if err != nil {
|
||||
t.Fatalf("CallFunc(wideCopy): %v", err)
|
||||
}
|
||||
if !bytes.Equal(dst, src) {
|
||||
t.Errorf("wideCopy: dst ≠ src\n got %x\n want %x", dst, src)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestKernelFuncNames(t *testing.T) {
|
||||
k := loadBasic(t)
|
||||
names := k.FuncNames()
|
||||
want := []string{"add", "sum", "wideCopy"}
|
||||
if len(names) != len(want) {
|
||||
t.Fatalf("FuncNames() = %v, want %v", names, want)
|
||||
}
|
||||
for i, n := range names {
|
||||
if n != want[i] {
|
||||
t.Errorf("FuncNames()[%d] = %q, want %q", i, n, want[i])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestKernelFuncNotFound(t *testing.T) {
|
||||
k := loadBasic(t)
|
||||
_, err := k.CallFunc("nonexistent", make([]byte, 8))
|
||||
if err == nil {
|
||||
t.Fatal("expected error for nonexistent function")
|
||||
}
|
||||
}
|
||||
|
||||
func TestKernelArgTooSmall(t *testing.T) {
|
||||
k := loadBasic(t)
|
||||
_, err := k.CallFunc("add", make([]byte, 8)) // needs 24
|
||||
if err == nil {
|
||||
t.Fatal("expected error for too-small arg block")
|
||||
}
|
||||
}
|
||||
|
||||
func TestMapZeroLength(t *testing.T) {
|
||||
_, err := Map(nil)
|
||||
if err == nil {
|
||||
t.Fatal("expected error for zero-length code")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadSourceError(t *testing.T) {
|
||||
_, err := LoadSource("bad.s", "TEXT ·f(SB), NOSPLIT\n\tBADINSTRUCTION\n")
|
||||
// The parser may or may not error on unknown instructions (it's
|
||||
// error-tolerant), but the assembler will reject it.
|
||||
if err == nil {
|
||||
t.Log("LoadSource succeeded unexpectedly (parser is error-tolerant)")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadSourceParseError(t *testing.T) {
|
||||
// A completely invalid file that the parser rejects.
|
||||
_, err := LoadSource("empty.s", "")
|
||||
if err != nil {
|
||||
t.Logf("expected: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFuncLookup(t *testing.T) {
|
||||
k := loadBasic(t)
|
||||
fl, err := k.Func("add")
|
||||
if err != nil {
|
||||
t.Fatalf("Func(add): %v", err)
|
||||
}
|
||||
if fl.Name != "add" {
|
||||
t.Errorf("Func(add).Name = %q, want %q", fl.Name, "add")
|
||||
}
|
||||
if fl.Args != 24 {
|
||||
t.Errorf("Func(add).Args = %d, want 24", fl.Args)
|
||||
}
|
||||
_, err = k.Func("nonexistent")
|
||||
if err == nil {
|
||||
t.Fatal("expected error for nonexistent function")
|
||||
}
|
||||
}
|
||||
|
||||
func TestABIReportString(t *testing.T) {
|
||||
r := ABIReport{}
|
||||
if r.String() != "ABI clean" {
|
||||
t.Errorf("clean report = %q", r.String())
|
||||
}
|
||||
r.BPClobbered = true
|
||||
if r.OK() {
|
||||
t.Error("expected not OK with BP clobbered")
|
||||
}
|
||||
s := r.String()
|
||||
if s == "ABI clean" {
|
||||
t.Error("expected violation string, got clean")
|
||||
}
|
||||
r.R14Clobbered = true
|
||||
r.RedZoneHit = true
|
||||
s = r.String()
|
||||
if s == "ABI clean" {
|
||||
t.Error("expected violation string for all flags")
|
||||
}
|
||||
}
|
||||
@@ -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 (
|
||||
"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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
// Copyright (c) 2026 Petr Balvín <opensource@petrbalvin.org> (https://petrbalvin.org)
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
#include "textflag.h"
|
||||
|
||||
// ABI0 JIT trampoline. enterJIT switches from the Go stack to a prepared
|
||||
// stack and jumps to the assembled function; when the function RETs, control
|
||||
// lands in leaveJIT, which restores the Go stack and returns to the Go caller.
|
||||
//
|
||||
// The prepared stack must begin with the address of leaveJIT (the return
|
||||
// address the JIT function will pop), followed by the function's ABI0
|
||||
// argument area.
|
||||
//
|
||||
// Single-threaded: savedSP is a package global, so only one JIT call may be
|
||||
// in flight at a time. gasm verify runs sequentially.
|
||||
|
||||
// func enterJIT(fn uintptr, stack uintptr)
|
||||
// Switches to the prepared stack and jumps to fn. Does not return normally;
|
||||
// the JIT function's RET transfers control to leaveJIT.
|
||||
TEXT ·enterJIT(SB), NOSPLIT, $0-16
|
||||
MOVQ fn+0(FP), AX // target function address (before SP switch)
|
||||
MOVQ SP, ·savedSP(SB) // preserve the Go stack pointer
|
||||
MOVQ stack+8(FP), SP // switch to the prepared stack
|
||||
JMP AX
|
||||
|
||||
// func leaveJIT()
|
||||
// Restores the Go stack pointer and returns to enterJIT's caller.
|
||||
TEXT ·leaveJIT(SB), NOSPLIT, $0-0
|
||||
MOVQ ·savedSP(SB), SP
|
||||
RET
|
||||
@@ -0,0 +1,122 @@
|
||||
// Copyright (c) 2026 Petr Balvín <opensource@petrbalvin.org> (https://petrbalvin.org)
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
package verify
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
|
||||
"sourcedock.dev/petrbalvin/gasm-devkit/asm"
|
||||
"sourcedock.dev/petrbalvin/gasm-devkit/ast"
|
||||
"sourcedock.dev/petrbalvin/gasm-devkit/parser"
|
||||
)
|
||||
|
||||
// Kernel is a JIT-loaded assembly image ready for direct invocation.
|
||||
// It wraps an executable memory mapping and the function layout metadata
|
||||
// needed to marshal ABI0 calls.
|
||||
type Kernel struct {
|
||||
exec *Executable
|
||||
img *asm.Image
|
||||
funcs map[string]int // function name → index into img.Funcs
|
||||
}
|
||||
|
||||
// Load parses, assembles and maps a .s file into executable memory.
|
||||
// The returned Kernel is ready for Call. The caller must call Close to
|
||||
// release the mapping.
|
||||
func Load(path string) (*Kernel, error) {
|
||||
src, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("verify: %w", err)
|
||||
}
|
||||
return LoadSource(path, string(src))
|
||||
}
|
||||
|
||||
// LoadSource parses, assembles and maps assembly source into executable memory.
|
||||
func LoadSource(filename, src string) (*Kernel, error) {
|
||||
file, errs := parser.Parse(filename, src)
|
||||
if len(errs) > 0 {
|
||||
return nil, fmt.Errorf("verify: parse %s: %v", filename, errs[0])
|
||||
}
|
||||
return LoadAST(file)
|
||||
}
|
||||
|
||||
// LoadAST assembles a parsed AST file and maps the result into executable
|
||||
// memory.
|
||||
func LoadAST(file *ast.File) (*Kernel, error) {
|
||||
img, err := asm.AssembleFile(file)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("verify: assemble: %w", err)
|
||||
}
|
||||
if len(img.Externals) > 0 {
|
||||
return nil, fmt.Errorf("verify: unresolved external symbols: %v", img.Externals)
|
||||
}
|
||||
code := img.Bytes()
|
||||
exec, err := Map(code)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
funcs := make(map[string]int, len(img.Funcs))
|
||||
for i, f := range img.Funcs {
|
||||
funcs[f.Name] = i
|
||||
}
|
||||
return &Kernel{exec: exec, img: img, funcs: funcs}, nil
|
||||
}
|
||||
|
||||
// Func returns the layout metadata for the named function.
|
||||
func (k *Kernel) Func(name string) (asm.FuncLayout, error) {
|
||||
idx, ok := k.funcs[name]
|
||||
if !ok {
|
||||
return asm.FuncLayout{}, fmt.Errorf("verify: function %q not found", name)
|
||||
}
|
||||
return k.img.Funcs[idx], nil
|
||||
}
|
||||
|
||||
// FuncNames returns the names of all functions in the kernel, in source order.
|
||||
func (k *Kernel) FuncNames() []string {
|
||||
names := make([]string, len(k.img.Funcs))
|
||||
for i, f := range k.img.Funcs {
|
||||
names[i] = f.Name
|
||||
}
|
||||
return names
|
||||
}
|
||||
|
||||
// CallFunc invokes the named function with the given ABI0 argument block.
|
||||
// The arg block is the raw bytes of the function's argument/result area
|
||||
// (as declared by the TEXT $frame-args suffix). Returns the arg block
|
||||
// after the call (with any results written back by the function).
|
||||
func (k *Kernel) CallFunc(name string, args []byte) ([]byte, error) {
|
||||
idx, ok := k.funcs[name]
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("verify: function %q not found", name)
|
||||
}
|
||||
fl := k.img.Funcs[idx]
|
||||
if len(args) < fl.Args {
|
||||
return nil, fmt.Errorf("verify: %s: arg block too small: got %d, need %d", name, len(args), fl.Args)
|
||||
}
|
||||
fnAddr := k.exec.FuncAddr(fl.Offset)
|
||||
return Call(fnAddr, args)
|
||||
}
|
||||
|
||||
// CallFuncChecked invokes the named function with ABI sentinels and a
|
||||
// red-zone canary, returning the argument block and an ABIReport that
|
||||
// records any callee-saved register or red-zone violations.
|
||||
func (k *Kernel) CallFuncChecked(name string, args []byte) ([]byte, ABIReport, error) {
|
||||
idx, ok := k.funcs[name]
|
||||
if !ok {
|
||||
return nil, ABIReport{}, fmt.Errorf("verify: function %q not found", name)
|
||||
}
|
||||
fl := k.img.Funcs[idx]
|
||||
if len(args) < fl.Args {
|
||||
return nil, ABIReport{}, fmt.Errorf("verify: %s: arg block too small: got %d, need %d", name, len(args), fl.Args)
|
||||
}
|
||||
fnAddr := k.exec.FuncAddr(fl.Offset)
|
||||
return CallChecked(fnAddr, args)
|
||||
}
|
||||
|
||||
// Close releases the executable mapping.
|
||||
func (k *Kernel) Close() {
|
||||
if k.exec != nil {
|
||||
k.exec.Unmap()
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user