From c9775c2b951a8b2f7c1e5f09d4d5831e41be792b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Petr=20Balv=C3=ADn?= Date: Wed, 22 Jul 2026 09:28:11 +0200 Subject: [PATCH] feat(verify): add the JIT execution substrate and gasm verify subcommand Assisted-by: Qwen 3.8 Max Preview --- cmd/gasm/main.go | 60 ++++++++++++- docs/ARCHITECTURE.md | 25 ++++++ justfile | 2 +- testdata/verify/basic_amd64.s | 67 ++++++++++++++ verify/call_amd64.go | 77 ++++++++++++++++ verify/call_other.go | 13 +++ verify/jit.go | 88 +++++++++++++++++++ verify/jit_test.go | 159 +++++++++++++++++++++++++++++++++ verify/lz4_test.go | 160 ++++++++++++++++++++++++++++++++++ verify/trampoline_amd64.s | 30 +++++++ verify/verify.go | 106 ++++++++++++++++++++++ 11 files changed, 785 insertions(+), 2 deletions(-) create mode 100644 testdata/verify/basic_amd64.s create mode 100644 verify/call_amd64.go create mode 100644 verify/call_other.go create mode 100644 verify/jit.go create mode 100644 verify/jit_test.go create mode 100644 verify/lz4_test.go create mode 100644 verify/trampoline_amd64.s create mode 100644 verify/verify.go diff --git a/cmd/gasm/main.go b/cmd/gasm/main.go index 9b9f876..18868c7 100644 --- a/cmd/gasm/main.go +++ b/cmd/gasm/main.go @@ -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.17.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 ", ` +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] ") + 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 +} diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 107358e..49166bb 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -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 diff --git a/justfile b/justfile index 6b969d8..c516c29 100644 --- a/justfile +++ b/justfile @@ -3,7 +3,7 @@ # gasm-devkit — developer tooling for Go's Plan 9 assembler (GAsm). -version := "0.16.0" +version := "0.17.0" default: @just --list diff --git a/testdata/verify/basic_amd64.s b/testdata/verify/basic_amd64.s new file mode 100644 index 0000000..455e424 --- /dev/null +++ b/testdata/verify/basic_amd64.s @@ -0,0 +1,67 @@ +// Copyright (c) 2026 Petr Balvín (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 diff --git a/verify/call_amd64.go b/verify/call_amd64.go new file mode 100644 index 0000000..5dbb398 --- /dev/null +++ b/verify/call_amd64.go @@ -0,0 +1,77 @@ +// Copyright (c) 2026 Petr Balvín (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 +} diff --git a/verify/call_other.go b/verify/call_other.go new file mode 100644 index 0000000..8221296 --- /dev/null +++ b/verify/call_other.go @@ -0,0 +1,13 @@ +// Copyright (c) 2026 Petr Balvín (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") +} diff --git a/verify/jit.go b/verify/jit.go new file mode 100644 index 0000000..9331f21 --- /dev/null +++ b/verify/jit.go @@ -0,0 +1,88 @@ +// Copyright (c) 2026 Petr Balvín (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))) +} diff --git a/verify/jit_test.go b/verify/jit_test.go new file mode 100644 index 0000000..fe878ae --- /dev/null +++ b/verify/jit_test.go @@ -0,0 +1,159 @@ +// Copyright (c) 2026 Petr Balvín (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") + } +} diff --git a/verify/lz4_test.go b/verify/lz4_test.go new file mode 100644 index 0000000..f63831b --- /dev/null +++ b/verify/lz4_test.go @@ -0,0 +1,160 @@ +// Copyright (c) 2026 Petr Balvín (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) + } + } +} diff --git a/verify/trampoline_amd64.s b/verify/trampoline_amd64.s new file mode 100644 index 0000000..7b3d7f6 --- /dev/null +++ b/verify/trampoline_amd64.s @@ -0,0 +1,30 @@ +// Copyright (c) 2026 Petr Balvín (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 diff --git a/verify/verify.go b/verify/verify.go new file mode 100644 index 0000000..f91d488 --- /dev/null +++ b/verify/verify.go @@ -0,0 +1,106 @@ +// Copyright (c) 2026 Petr Balvín (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) +} + +// Close releases the executable mapping. +func (k *Kernel) Close() { + if k.exec != nil { + k.exec.Unmap() + } +}