From 8f84dac10ba4098ff822b0cca81bc30472d06559 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Petr=20Balv=C3=ADn?= Date: Sun, 30 Aug 2026 11:27:54 +0200 Subject: [PATCH] feat(verify): ABI checks on arm64, riscv64 and loong64 Assisted-by: GLM 5.3 Flash --- CHANGELOG.md | 10 +++ cmd/gasm/main.go | 51 ++++++++---- docs/ARCHITECTURE.md | 10 ++- testdata/verify/abi_arm64.s | 30 +++++++ testdata/verify/abi_loong64.s | 21 +++++ testdata/verify/abi_riscv64.s | 21 +++++ verify/abi.go | 71 +++++++++++++++++ verify/abi_amd64.go | 101 ----------------------- verify/abi_arch_test.go | 145 ++++++++++++++++++++++++++++++++++ verify/abi_arm64.go | 33 ++++++++ verify/abi_arm64.s | 84 ++++++++++++++++++++ verify/abi_loong64.go | 33 ++++++++ verify/abi_loong64.s | 61 ++++++++++++++ verify/abi_other.go | 17 +--- verify/abi_report.go | 54 +++++++++++++ verify/abi_riscv64.go | 33 ++++++++ verify/abi_riscv64.s | 61 ++++++++++++++ verify/abi_test.go | 8 +- verify/jit_test.go | 4 +- 19 files changed, 710 insertions(+), 138 deletions(-) create mode 100644 testdata/verify/abi_arm64.s create mode 100644 testdata/verify/abi_loong64.s create mode 100644 testdata/verify/abi_riscv64.s create mode 100644 verify/abi.go create mode 100644 verify/abi_arch_test.go create mode 100644 verify/abi_arm64.go create mode 100644 verify/abi_arm64.s create mode 100644 verify/abi_loong64.go create mode 100644 verify/abi_loong64.s create mode 100644 verify/abi_report.go create mode 100644 verify/abi_riscv64.go create mode 100644 verify/abi_riscv64.s diff --git a/CHANGELOG.md b/CHANGELOG.md index fe58a85..dd966af 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -27,6 +27,16 @@ Unreleased changes on the `development` branch. architectures via hand-written assembly trampolines (`trampoline_{arm64,riscv64,loong64}.s`) that save the Go stack, switch to a prepared stack, and branch to the JIT function. +- **ABI checks on all architectures.** `gasm verify -abi` and the ABI + half of `-fuzz` now work on arm64, riscv64 and loong64 via + per-architecture checked trampolines: sentinels planted in the + registers the Go ABI fixes across calls (amd64 `BP`/`R14`, arm64 + `R29`/`R28`, riscv64 `X27`, loong64 `R22`) are verified on return, with + the below-SP canary on every architecture. `gasm verify` now runs the + JIT checks whenever the host matches the kernel's architecture, and + takes the ground-truth-only path only on other hosts. The `ABIReport` + fields are renamed to the architecture-neutral `FPClobbered` and + `GClobbered`. - **Hardware watchpoints on all architectures.** arm64 uses DBGWVR/DBGWCR via `PTRACE_SETREGSET` with `NT_ARM_HW_BREAK`; riscv64 and loong64 use `PTRACE_POKEUSER` to access trigger/debug registers. diff --git a/cmd/gasm/main.go b/cmd/gasm/main.go index a7d817d..16aa9eb 100644 --- a/cmd/gasm/main.go +++ b/cmd/gasm/main.go @@ -185,6 +185,22 @@ func newCommand(name, usageLine, long string) *flag.FlagSet { } // readSource returns the contents of path, or stdin when path is "-". +// hostArch maps the running GOARCH onto the arch package's identifiers. +// It returns arch.Unknown on hosts the toolkit cannot JIT for. +func hostArch() arch.Arch { + switch runtime.GOARCH { + case "amd64": + return arch.AMD64 + case "arm64": + return arch.ARM64 + case "riscv64": + return arch.RISCV + case "loong64": + return arch.LOONG64 + } + return arch.Unknown +} + func readSource(path string) (string, error) { if path == "-" { b, err := io.ReadAll(os.Stdin) @@ -1065,21 +1081,26 @@ decoders) that crash on random input but should succeed on valid data. } path := set.Arg(0) targetArch := arch.FromFilename(path) - switch targetArch { - case arch.AMD64: - // JIT-based verification below. - case arch.RISCV: - // RISC-V: ground-truth only (no JIT on non-RISC-V hosts). - return cmdVerifyRISCV(path, *groundTruth, *profile) - case arch.LOONG64: - // LoongArch: ground-truth only (no JIT on non-LoongArch hosts). - return cmdVerifyLOONG64(path, *groundTruth, *profile) - case arch.ARM64: - // AArch64: ground-truth only (no JIT on non-ARM64 hosts). - return cmdVerifyARM64(path, *groundTruth, *profile) - default: - fmt.Fprintln(os.Stderr, "gasm verify: unsupported architecture") - return 1 + // JIT execution requires the host CPU to match the kernel's + // architecture; on any other host only the toolchain comparisons run. + if targetArch != hostArch() { + switch targetArch { + case arch.RISCV: + // RISC-V: ground-truth only (no JIT on non-RISC-V hosts). + return cmdVerifyRISCV(path, *groundTruth, *profile) + case arch.LOONG64: + // LoongArch: ground-truth only (no JIT on non-LoongArch hosts). + return cmdVerifyLOONG64(path, *groundTruth, *profile) + case arch.ARM64: + // AArch64: ground-truth only (no JIT on non-ARM64 hosts). + return cmdVerifyARM64(path, *groundTruth, *profile) + case arch.AMD64: + fmt.Fprintln(os.Stderr, "gasm verify: JIT-based checks need an amd64 host; use --ground-truth here") + return 1 + default: + fmt.Fprintln(os.Stderr, "gasm verify: unsupported architecture") + return 1 + } } k, err := verify.Load(path) diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index bb8a8b5..ad98ffa 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -360,7 +360,15 @@ and returns). A 64-byte pad below the return address accommodates the ABIInternal wrapper that the Go runtime interposes on assembly functions. Every supported architecture carries its own hand-written trampoline pair (`trampoline_amd64.s`, `trampoline_arm64.s`, `trampoline_riscv64.s`, -`trampoline_loong64.s`), so `Call` works wherever the toolkit runs. +`trampoline_loong64.s`), so `Call` works wherever the toolkit runs. The +ABI-checked variant `CallChecked` exists for every architecture too: +`enterJITChecked` plants sentinels in the registers the Go ABI fixes across +calls (amd64 `BP`/`R14`, arm64 `R29`/`R28`, riscv64 `X27`, loong64 `R22`; +the latter two keep no hardware frame pointer) and the raw return trampoline +`leaveJITCheckedRaw` verifies them, so `-abi` reports frame-pointer, +goroutine-pointer and below-SP violations on every supported host. `gasm +verify` dispatches by host: the JIT checks run when the host matches the +kernel's architecture, and only the toolchain comparisons run elsewhere. `Load` / `LoadSource` / `LoadAST` parse, assemble and map a `.s` file in one step, returning a `Kernel` whose `CallFunc` method marshals the argument block diff --git a/testdata/verify/abi_arm64.s b/testdata/verify/abi_arm64.s new file mode 100644 index 0000000..53e8b2d --- /dev/null +++ b/testdata/verify/abi_arm64.s @@ -0,0 +1,30 @@ +// 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 + MOVD a+0(FP), R0 + MOVD b+8(FP), R1 + ADD R0, R1, R0 + MOVD R0, ret+16(FP) + RET + +// func dirtyFP(a int64) int64 +// Deliberately clobbers R29, the frame pointer (an ABI violation for a +// NOSPLIT frame=0 function). +TEXT ·dirtyFP(SB), NOSPLIT, $0-16 + MOVD $0x1234, R29 + MOVD a+0(FP), R0 + MOVD R0, ret+8(FP) + RET + +// func dirtyG(a int64) int64 +// Deliberately clobbers R28, the goroutine pointer (a serious ABI violation). +TEXT ·dirtyG(SB), NOSPLIT, $0-16 + MOVD $0x5678, R28 + MOVD a+0(FP), R0 + MOVD R0, ret+8(FP) + RET diff --git a/testdata/verify/abi_loong64.s b/testdata/verify/abi_loong64.s new file mode 100644 index 0000000..cc21d76 --- /dev/null +++ b/testdata/verify/abi_loong64.s @@ -0,0 +1,21 @@ +// 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 the goroutine pointer. +TEXT ·cleanAdd(SB), NOSPLIT, $0-24 + MOVV a+0(FP), R4 + MOVV b+8(FP), R5 + ADDV R4, R5, R4 + MOVV R4, ret+16(FP) + RET + +// func dirtyG(a int64) int64 +// Deliberately clobbers R22, the goroutine pointer (a serious ABI violation). +TEXT ·dirtyG(SB), NOSPLIT, $0-16 + MOVV $0x5678, R22 + MOVV a+0(FP), R4 + MOVV R4, ret+8(FP) + RET diff --git a/testdata/verify/abi_riscv64.s b/testdata/verify/abi_riscv64.s new file mode 100644 index 0000000..36c1ead --- /dev/null +++ b/testdata/verify/abi_riscv64.s @@ -0,0 +1,21 @@ +// 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 the goroutine pointer. +TEXT ·cleanAdd(SB), NOSPLIT, $0-24 + MOV a+0(FP), X5 + MOV b+8(FP), X6 + ADD X5, X6, X5 + MOV X5, ret+16(FP) + RET + +// func dirtyG(a int64) int64 +// Deliberately clobbers X27, the goroutine pointer (a serious ABI violation). +TEXT ·dirtyG(SB), NOSPLIT, $0-16 + MOV $0x5678, X27 + MOV a+0(FP), X5 + MOV X5, ret+8(FP) + RET diff --git a/verify/abi.go b/verify/abi.go new file mode 100644 index 0000000..0cd9d59 --- /dev/null +++ b/verify/abi.go @@ -0,0 +1,71 @@ +// Copyright (c) 2026 Petr Balvín (https://petrbalvin.org) +// SPDX-License-Identifier: BSD-3-Clause + +//go:build amd64 || arm64 || riscv64 || loong64 + +package verify + +import ( + "encoding/binary" + "fmt" + "syscall" + "unsafe" +) + +// CallChecked invokes the function at fnAddr with ABI sentinels and a +// canary below SP, returning both the argument block (with results) and an +// ABIReport. +// +// The architecture-specific parts live in abi_.s: enterJITChecked +// plants sentinels in the registers the Go ABI fixes across calls (the +// frame pointer and the goroutine pointer) before switching to the +// prepared stack, and the raw return trampoline leaveJITCheckedRaw +// compares them and records violations in abiResult. +func CallChecked(fnAddr uintptr, args []byte) ([]byte, ABIReport, error) { + report := ABIReport{} + + // Reset the global result. + abiResult = 0 + + // Prepare the stack: [canary][padding][leaveJITCheckedRaw][args...] + // The canary 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 func() { _ = syscall.Munmap(stackMem) }() + + // Fill the canary window with the detection pattern. + for i := range redZoneSize { + stackMem[i] = redZoneFill + } + + // Return address and args after the canary 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.FPClobbered = res&1 != 0 + report.GClobbered = res&2 != 0 + + // Check the canary window. + for i := range redZoneSize { + 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.go b/verify/abi_amd64.go index f07e47c..1a562ff 100644 --- a/verify/abi_amd64.go +++ b/verify/abi_amd64.go @@ -5,17 +5,6 @@ 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. // Written and read by enterJITChecked/leaveJITChecked (abi_amd64.s); no Go // code references it, which GoLand cannot see inside assembly. @@ -50,93 +39,3 @@ func enterJITChecked(fn uintptr, stack uintptr) //lint:ignore U1000 the assembly obtains this address through leaveCheckedPtr //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 at fnAddr 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 func() { _ = syscall.Munmap(stackMem) }() - - // Fill the red zone with the canary pattern. - for i := range redZoneSize { - 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 := range redZoneSize { - 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_arch_test.go b/verify/abi_arch_test.go new file mode 100644 index 0000000..f9583f7 --- /dev/null +++ b/verify/abi_arch_test.go @@ -0,0 +1,145 @@ +// Copyright (c) 2026 Petr Balvín (https://petrbalvin.org) +// SPDX-License-Identifier: BSD-3-Clause + +package verify + +import ( + "runtime" + "testing" +) + +// requireHost skips the test unless the test binary runs on the named +// architecture: the JIT executes native code, so a kernel assembled for +// arm64 only runs on an arm64 host. +func requireHost(t *testing.T, goarch string) { + t.Helper() + if runtime.GOARCH != goarch { + t.Skipf("runs only on %s hosts (this host is %s)", goarch, runtime.GOARCH) + } +} + +func TestABIArm64(t *testing.T) { + requireHost(t, "arm64") + + k, err := Load("../testdata/verify/abi_arm64.s") + if err != nil { + t.Fatalf("Load: %v", err) + } + t.Cleanup(k.Close) + + // cleanAdd preserves everything and computes correctly. + 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) + } + + // dirtyFP clobbers the frame pointer (R29). + out, report, err = k.CallFuncChecked("dirtyFP", make([]byte, 16)) + if err != nil { + t.Fatalf("CallFuncChecked: %v", err) + } + if got := int64(GetUint64(out, 8)); got != 0x1234 { + t.Errorf("dirtyFP returned %d, want %d", got, int64(0x1234)) + } + if !report.FPClobbered { + t.Error("dirtyFP: expected frame pointer clobbered, but report says clean") + } + if report.GClobbered { + t.Errorf("dirtyFP: only R29 should be clobbered: %s", report) + } + + // dirtyG clobbers the goroutine pointer (R28). + _, report, err = k.CallFuncChecked("dirtyG", make([]byte, 16)) + if err != nil { + t.Fatalf("CallFuncChecked: %v", err) + } + if !report.GClobbered { + t.Error("dirtyG: expected g clobbered, but report says clean") + } + if report.FPClobbered { + t.Errorf("dirtyG: only R28 should be clobbered: %s", report) + } +} + +func TestABIRiscv64(t *testing.T) { + requireHost(t, "riscv64") + + k, err := Load("../testdata/verify/abi_riscv64.s") + if err != nil { + t.Fatalf("Load: %v", err) + } + t.Cleanup(k.Close) + + // cleanAdd preserves g and computes correctly. + 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) + } + + // dirtyG clobbers the goroutine pointer (X27). + _, report, err = k.CallFuncChecked("dirtyG", make([]byte, 16)) + if err != nil { + t.Fatalf("CallFuncChecked: %v", err) + } + if !report.GClobbered { + t.Error("dirtyG: expected g clobbered, but report says clean") + } + if report.FPClobbered || report.RedZoneHit { + t.Errorf("dirtyG: unexpected additional violations: %s", report) + } +} + +func TestABILoong64(t *testing.T) { + requireHost(t, "loong64") + + k, err := Load("../testdata/verify/abi_loong64.s") + if err != nil { + t.Fatalf("Load: %v", err) + } + t.Cleanup(k.Close) + + // cleanAdd preserves g and computes correctly. + 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) + } + + // dirtyG clobbers the goroutine pointer (R22). + _, report, err = k.CallFuncChecked("dirtyG", make([]byte, 16)) + if err != nil { + t.Fatalf("CallFuncChecked: %v", err) + } + if !report.GClobbered { + t.Error("dirtyG: expected g clobbered, but report says clean") + } + if report.FPClobbered || report.RedZoneHit { + t.Errorf("dirtyG: unexpected additional violations: %s", report) + } +} diff --git a/verify/abi_arm64.go b/verify/abi_arm64.go new file mode 100644 index 0000000..1e4576c --- /dev/null +++ b/verify/abi_arm64.go @@ -0,0 +1,33 @@ +// Copyright (c) 2026 Petr Balvín (https://petrbalvin.org) +// SPDX-License-Identifier: BSD-3-Clause + +//go:build arm64 + +package verify + +// leaveCheckedPtr is initialised by the linker from the GLOBL/DATA in +// abi_arm64.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 R29 (frame pointer) and R28 (g), +// switches to the prepared stack and branches to fn. +// The body lives in abi_arm64.s and reads the parameters from the frame by +// name, which GoLand cannot see. +// +// noinspection GoUnusedParameter +// +//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_arm64.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. +// +// noinspection GoUnusedFunction +// +//lint:ignore U1000 the assembly obtains this address through leaveCheckedPtr +//go:nosplit +func leaveJITCheckedRaw() diff --git a/verify/abi_arm64.s b/verify/abi_arm64.s new file mode 100644 index 0000000..7176c96 --- /dev/null +++ b/verify/abi_arm64.s @@ -0,0 +1,84 @@ +// Copyright (c) 2026 Petr Balvín (https://petrbalvin.org) +// SPDX-License-Identifier: BSD-3-Clause + +#include "textflag.h" + +// ABI-checking trampoline for arm64. Sets sentinel values in the +// registers the Go ABI fixes across calls 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 ABI on arm64 guarantees: +// - R29 is the frame pointer (NOSPLIT frame=0 functions must not touch it). +// - R28 is the goroutine pointer (g) and must survive across any call. +// The assembler spells this register "g"; R28 is not accepted. +// - R18 is the platform register and must never be written. The Go +// assembler offers no spelling that addresses it, so the check below +// cannot cover it. + +// Sentinel values chosen to be unlikely in normal execution. +#define SENTINEL_FP 0xDEADBEEFCAFEF00D +#define SENTINEL_G 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 R29, R28 and R18, switches to the prepared stack and +// branches to fn. The prepared stack's first word must be the address of +// leaveJITCheckedRaw (read from leaveCheckedPtr). Only R0 and R3 are used +// as scratch: caller-saved, and not among the checked registers. +TEXT ·enterJITChecked(SB), NOSPLIT, $0-16 + MOVD fn+0(FP), R0 // target (before SP switch) + MOVD R30, savedLR(SB) // save link register + MOVD R3, savedSP(SB) // save Go stack pointer + MOVD R29, savedFP(SB) // save frame pointer (vet requires save before clobber) + MOVD $SENTINEL_FP, R29 // sentinel in the frame pointer + MOVD $SENTINEL_G, g // sentinel in g + MOVD stack+8(FP), R3 // load prepared stack pointer + MOVD 0(R3), R30 // load leaveJITCheckedRaw into LR + ADD $8, R3, R3 // advance past the return slot + MOVD R3, RSP // switch to prepared stack + JMP (R0) // branch to JIT function + +// 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 R29 and g 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 + MOVD $0, R4 // accumulated violation bits + + // Check the frame pointer against the sentinel. + MOVD $SENTINEL_FP, R3 + CMP R29, R3 + BEQ fp_ok + MOVD $1, R5 + ORR R5, R4, R4 +fp_ok: + // Check g against the sentinel. + MOVD $SENTINEL_G, R3 + CMP g, R3 + BEQ g_ok + MOVD $2, R5 + ORR R5, R4, R4 +g_ok: + CBZ R4, restore + MOVD R4, ·abiResult(SB) + +restore: + MOVD savedSP(SB), R3 // restore Go stack pointer + MOVD R3, RSP + MOVD savedLR(SB), R30 // restore link register + RET // return to Go caller + +// Package-level storage for the saved frame pointer. Like savedSP and +// savedLR in trampoline_arm64.s, this is assembly-side state: the amd64 +// checked trampoline saves the caller's frame pointer for vet's sake and +// never restores it, and this file mirrors that. +GLOBL savedFP(SB), NOPTR, $8 diff --git a/verify/abi_loong64.go b/verify/abi_loong64.go new file mode 100644 index 0000000..7f9474e --- /dev/null +++ b/verify/abi_loong64.go @@ -0,0 +1,33 @@ +// Copyright (c) 2026 Petr Balvín (https://petrbalvin.org) +// SPDX-License-Identifier: BSD-3-Clause + +//go:build loong64 + +package verify + +// leaveCheckedPtr is initialised by the linker from the GLOBL/DATA in +// abi_loong64.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 a sentinel in R22 (the goroutine pointer; loong64 +// keeps no hardware frame pointer), switches to the prepared stack and +// jumps to fn. The body lives in abi_loong64.s and reads the parameters +// from the frame by name, which GoLand cannot see. +// +// noinspection GoUnusedParameter +// +//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_loong64.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. +// +// noinspection GoUnusedFunction +// +//lint:ignore U1000 the assembly obtains this address through leaveCheckedPtr +//go:nosplit +func leaveJITCheckedRaw() diff --git a/verify/abi_loong64.s b/verify/abi_loong64.s new file mode 100644 index 0000000..03aaa54 --- /dev/null +++ b/verify/abi_loong64.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 for LoongArch 64. Sets a sentinel value in the +// register the Go ABI fixes across calls before entering the JIT function +// and checks whether it 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 ABI on loong64 guarantees: +// - R22 holds the goroutine pointer (g) and must survive across any +// call. Go keeps no hardware frame pointer on loong64. The assembler +// spells this register "g"; R22 is not accepted. + +// Sentinel value chosen to be unlikely in normal execution. +#define SENTINEL_G 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 a sentinel in g (R22), switches to the prepared stack and jumps to +// fn. The prepared stack's first word must be the address of +// leaveJITCheckedRaw (read from leaveCheckedPtr). Only R4 and R5 are used +// as scratch: caller-saved, and R22 is not among them. +TEXT ·enterJITChecked(SB), NOSPLIT, $0-16 + MOVV fn+0(FP), R4 // target function address (A0) + MOVV R1, savedRA(SB) // save return address (RA) + MOVV R3, savedSP(SB) // save Go stack pointer (SP) + MOVV $SENTINEL_G, g // sentinel in g + MOVV stack+8(FP), R5 // load prepared stack pointer (A1) + MOVV 0(R5), R1 // load leaveJITCheckedRaw into RA + ADDV $8, R5, R5 // advance past the return slot + MOVV R5, R3 // switch to prepared stack (SP) + JIRL R0, R4, 0 // jump to JIT function + +// 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 g exactly as the function left it. It +// checks the sentinel, records violations in abiResult, then restores the +// Go stack and returns. +TEXT ·leaveJITCheckedRaw(SB), NOSPLIT, $0-0 + // Check g against the sentinel. + MOVV $SENTINEL_G, R5 + BEQ g, R5, g_ok + MOVV ·abiResult(SB), R4 + MOVV $2, R6 + OR R6, R4, R4 + MOVV R4, ·abiResult(SB) + +g_ok: + MOVV savedSP(SB), R5 // restore Go stack pointer + MOVV R5, R3 + MOVV savedRA(SB), R1 // restore return address + JIRL R0, R1, 0 // return to Go caller diff --git a/verify/abi_other.go b/verify/abi_other.go index 20415d5..f61585e 100644 --- a/verify/abi_other.go +++ b/verify/abi_other.go @@ -7,20 +7,7 @@ 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. +// CallChecked is unavailable on unsupported architectures. func CallChecked(fnAddr uintptr, args []byte) ([]byte, ABIReport, error) { - return nil, ABIReport{}, fmt.Errorf("verify: ABI checks require amd64") + return nil, ABIReport{}, fmt.Errorf("verify: ABI checks are not supported on this architecture") } diff --git a/verify/abi_report.go b/verify/abi_report.go new file mode 100644 index 0000000..ccf57ff --- /dev/null +++ b/verify/abi_report.go @@ -0,0 +1,54 @@ +// Copyright (c) 2026 Petr Balvín (https://petrbalvin.org) +// SPDX-License-Identifier: BSD-3-Clause + +package verify + +// abiResult records register-clobber violations detected by the ABI-checking +// trampolines. Bit 0: frame pointer clobbered. Bit 1: goroutine pointer +// clobbered. +var abiResult uint64 + +// ABIReport describes the result of an ABI-checking call. The register +// fields are architecture-dependent: +// +// - FPClobbered: the frame pointer the Go runtime maintains +// (amd64 BP, arm64 R29). riscv64 and loong64 keep no hardware frame +// pointer, so the field is always false there. +// - GClobbered: the goroutine pointer (amd64 R14, arm64 R28, +// riscv64 X27, loong64 R22). +type ABIReport struct { + FPClobbered bool + GClobbered bool + RedZoneHit bool +} + +// OK returns true when no violations were detected. +func (r ABIReport) OK() bool { + return !r.FPClobbered && !r.GClobbered && !r.RedZoneHit +} + +// String returns a human-readable summary. +func (r ABIReport) String() string { + if r.OK() { + return "ABI clean" + } + s := "ABI violation:" + if r.FPClobbered { + s += " frame pointer clobbered" + } + if r.GClobbered { + s += " goroutine pointer clobbered" + } + if r.RedZoneHit { + s += " stack below SP written" + } + return s +} + +// redZoneSize is the canary window below the prepared stack pointer. On +// amd64 it is the System V red zone; on every architecture a Go function +// must not write below SP, so any corruption there is a bug. +const redZoneSize = 128 + +// redZoneFill is the byte pattern used to detect writes below SP. +const redZoneFill = 0xA5 diff --git a/verify/abi_riscv64.go b/verify/abi_riscv64.go new file mode 100644 index 0000000..6ea9ed5 --- /dev/null +++ b/verify/abi_riscv64.go @@ -0,0 +1,33 @@ +// Copyright (c) 2026 Petr Balvín (https://petrbalvin.org) +// SPDX-License-Identifier: BSD-3-Clause + +//go:build riscv64 + +package verify + +// leaveCheckedPtr is initialised by the linker from the GLOBL/DATA in +// abi_riscv64.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 a sentinel in X27 (the goroutine pointer; riscv64 +// keeps no hardware frame pointer), switches to the prepared stack and +// jumps to fn. The body lives in abi_riscv64.s and reads the parameters +// from the frame by name, which GoLand cannot see. +// +// noinspection GoUnusedParameter +// +//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_riscv64.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. +// +// noinspection GoUnusedFunction +// +//lint:ignore U1000 the assembly obtains this address through leaveCheckedPtr +//go:nosplit +func leaveJITCheckedRaw() diff --git a/verify/abi_riscv64.s b/verify/abi_riscv64.s new file mode 100644 index 0000000..3488eb3 --- /dev/null +++ b/verify/abi_riscv64.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 for riscv64. Sets a sentinel value in the +// register the Go ABI fixes across calls before entering the JIT function +// and checks whether it 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 ABI on riscv64 guarantees: +// - X27 holds the goroutine pointer (g) and must survive across any +// call. Go keeps no hardware frame pointer on riscv64. The assembler +// spells this register "g"; X27 is not accepted. + +// Sentinel value chosen to be unlikely in normal execution. +#define SENTINEL_G 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 a sentinel in g (X27), switches to the prepared stack and jumps to +// fn. The prepared stack's first word must be the address of +// leaveJITCheckedRaw (read from leaveCheckedPtr). Only X5 and X6 are used +// as scratch: caller-saved, and X27 is not among them. +TEXT ·enterJITChecked(SB), NOSPLIT, $0-16 + MOV fn+0(FP), X5 // target function address (T0) + MOV X1, savedRA(SB) // save return address + MOV X2, savedSP(SB) // save Go stack pointer + MOV $SENTINEL_G, g // sentinel in g + MOV stack+8(FP), X6 // load prepared stack pointer (T1) + LD 0(X6), X1 // load leaveJITCheckedRaw into RA + ADD $8, X6, X6 // advance past the return slot + MOV X6, X2 // switch to prepared stack (SP) + JALR X0, 0(X5) // jump to JIT function + +// 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 g exactly as the function left it. It +// checks the sentinel, records violations in abiResult, then restores the +// Go stack and returns. +TEXT ·leaveJITCheckedRaw(SB), NOSPLIT, $0-0 + // Check g against the sentinel. + MOV $SENTINEL_G, X6 + BEQ g, X6, g_ok + MOV ·abiResult(SB), X7 + MOV $2, X5 + OR X5, X7, X7 + MOV X7, ·abiResult(SB) + +g_ok: + MOV savedSP(SB), X6 // restore Go stack pointer + MOV X6, X2 + MOV savedRA(SB), X1 // restore return address + JALR X0, 0(X1) // return to Go caller diff --git a/verify/abi_test.go b/verify/abi_test.go index e0555dc..8a5c9e7 100644 --- a/verify/abi_test.go +++ b/verify/abi_test.go @@ -49,10 +49,10 @@ func TestABIBPClobbered(t *testing.T) { if got := int64(GetUint64(out, 8)); got != 42 { t.Errorf("dirtyBP(42) = %d, want 42", got) } - if !report.BPClobbered { + if !report.FPClobbered { t.Error("dirtyBP: expected BP clobbered, but report says clean") } - if report.R14Clobbered { + if report.GClobbered { t.Error("dirtyBP: R14 should not be clobbered") } } @@ -70,10 +70,10 @@ func TestABIR14Clobbered(t *testing.T) { if got := int64(GetUint64(out, 8)); got != 99 { t.Errorf("dirtyR14(99) = %d, want 99", got) } - if !report.R14Clobbered { + if !report.GClobbered { t.Error("dirtyR14: expected R14 clobbered, but report says clean") } - if report.BPClobbered { + if report.FPClobbered { t.Error("dirtyR14: BP should not be clobbered") } } diff --git a/verify/jit_test.go b/verify/jit_test.go index e10f87f..4b4218a 100644 --- a/verify/jit_test.go +++ b/verify/jit_test.go @@ -202,7 +202,7 @@ func TestABIReportString(t *testing.T) { if r.String() != "ABI clean" { t.Errorf("clean report = %q", r.String()) } - r.BPClobbered = true + r.FPClobbered = true if r.OK() { t.Error("expected not OK with BP clobbered") } @@ -210,7 +210,7 @@ func TestABIReportString(t *testing.T) { if s == "ABI clean" { t.Error("expected violation string, got clean") } - r.R14Clobbered = true + r.GClobbered = true r.RedZoneHit = true s = r.String() if s == "ABI clean" {