diff --git a/cmd/gasm/main.go b/cmd/gasm/main.go index 5aca56f..d7f9fb9 100644 --- a/cmd/gasm/main.go +++ b/cmd/gasm/main.go @@ -29,7 +29,7 @@ import ( // version is the release version, stamped at build time via // -ldflags "-X main.version=…" (defaulting to the current release). -var version = "0.18.0" +var version = "0.19.0" func main() { if len(os.Args) < 2 { diff --git a/justfile b/justfile index c3147d2..f7b0e48 100644 --- a/justfile +++ b/justfile @@ -3,7 +3,7 @@ # gasm-devkit — developer tooling for Go's Plan 9 assembler (GAsm). -version := "0.18.0" +version := "0.19.0" default: @just --list diff --git a/testdata/verify/abi_amd64.s b/testdata/verify/abi_amd64.s new file mode 100644 index 0000000..ddeb340 --- /dev/null +++ b/testdata/verify/abi_amd64.s @@ -0,0 +1,28 @@ +// Copyright (c) 2026 Petr Balvín (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 diff --git a/verify/abi_amd64.go b/verify/abi_amd64.go new file mode 100644 index 0000000..e29fd1f --- /dev/null +++ b/verify/abi_amd64.go @@ -0,0 +1,130 @@ +// Copyright (c) 2026 Petr Balvín (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 +} diff --git a/verify/abi_amd64.s b/verify/abi_amd64.s new file mode 100644 index 0000000..63751b2 --- /dev/null +++ b/verify/abi_amd64.s @@ -0,0 +1,61 @@ +// Copyright (c) 2026 Petr Balvín (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 diff --git a/verify/abi_other.go b/verify/abi_other.go new file mode 100644 index 0000000..ddf3f9b --- /dev/null +++ b/verify/abi_other.go @@ -0,0 +1,26 @@ +// Copyright (c) 2026 Petr Balvín (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") +} diff --git a/verify/abi_test.go b/verify/abi_test.go new file mode 100644 index 0000000..c3e966b --- /dev/null +++ b/verify/abi_test.go @@ -0,0 +1,145 @@ +// Copyright (c) 2026 Petr Balvín (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") + } +} diff --git a/verify/jit_test.go b/verify/jit_test.go index fe878ae..254c778 100644 --- a/verify/jit_test.go +++ b/verify/jit_test.go @@ -157,3 +157,59 @@ func TestMapZeroLength(t *testing.T) { 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") + } +} diff --git a/verify/verify.go b/verify/verify.go index f91d488..bf8fac0 100644 --- a/verify/verify.go +++ b/verify/verify.go @@ -98,6 +98,22 @@ func (k *Kernel) CallFunc(name string, args []byte) ([]byte, error) { 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 {