feat(verify): ABI checks on arm64, riscv64 and loong64

Assisted-by: GLM 5.3 Flash
This commit is contained in:
2026-08-30 11:27:54 +02:00
parent 6d7f10f13e
commit 8f84dac10b
19 changed files with 710 additions and 138 deletions
+10
View File
@@ -27,6 +27,16 @@ Unreleased changes on the `development` branch.
architectures via hand-written assembly trampolines architectures via hand-written assembly trampolines
(`trampoline_{arm64,riscv64,loong64}.s`) that save the Go stack, switch (`trampoline_{arm64,riscv64,loong64}.s`) that save the Go stack, switch
to a prepared stack, and branch to the JIT function. 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 - **Hardware watchpoints on all architectures.** arm64 uses DBGWVR/DBGWCR
via `PTRACE_SETREGSET` with `NT_ARM_HW_BREAK`; riscv64 and loong64 use via `PTRACE_SETREGSET` with `NT_ARM_HW_BREAK`; riscv64 and loong64 use
`PTRACE_POKEUSER` to access trigger/debug registers. `PTRACE_POKEUSER` to access trigger/debug registers.
+23 -2
View File
@@ -185,6 +185,22 @@ func newCommand(name, usageLine, long string) *flag.FlagSet {
} }
// readSource returns the contents of path, or stdin when path is "-". // 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) { func readSource(path string) (string, error) {
if path == "-" { if path == "-" {
b, err := io.ReadAll(os.Stdin) b, err := io.ReadAll(os.Stdin)
@@ -1065,9 +1081,10 @@ decoders) that crash on random input but should succeed on valid data.
} }
path := set.Arg(0) path := set.Arg(0)
targetArch := arch.FromFilename(path) targetArch := arch.FromFilename(path)
// 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 { switch targetArch {
case arch.AMD64:
// JIT-based verification below.
case arch.RISCV: case arch.RISCV:
// RISC-V: ground-truth only (no JIT on non-RISC-V hosts). // RISC-V: ground-truth only (no JIT on non-RISC-V hosts).
return cmdVerifyRISCV(path, *groundTruth, *profile) return cmdVerifyRISCV(path, *groundTruth, *profile)
@@ -1077,10 +1094,14 @@ decoders) that crash on random input but should succeed on valid data.
case arch.ARM64: case arch.ARM64:
// AArch64: ground-truth only (no JIT on non-ARM64 hosts). // AArch64: ground-truth only (no JIT on non-ARM64 hosts).
return cmdVerifyARM64(path, *groundTruth, *profile) 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: default:
fmt.Fprintln(os.Stderr, "gasm verify: unsupported architecture") fmt.Fprintln(os.Stderr, "gasm verify: unsupported architecture")
return 1 return 1
} }
}
k, err := verify.Load(path) k, err := verify.Load(path)
if err != nil { if err != nil {
+9 -1
View File
@@ -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. ABIInternal wrapper that the Go runtime interposes on assembly functions.
Every supported architecture carries its own hand-written trampoline pair Every supported architecture carries its own hand-written trampoline pair
(`trampoline_amd64.s`, `trampoline_arm64.s`, `trampoline_riscv64.s`, (`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 `Load` / `LoadSource` / `LoadAST` parse, assemble and map a `.s` file in one
step, returning a `Kernel` whose `CallFunc` method marshals the argument block step, returning a `Kernel` whose `CallFunc` method marshals the argument block
+30
View File
@@ -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"
// 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
+21
View File
@@ -0,0 +1,21 @@
// 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 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
+21
View File
@@ -0,0 +1,21 @@
// 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 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
+71
View File
@@ -0,0 +1,71 @@
// Copyright (c) 2026 Petr Balvín <opensource@petrbalvin.org> (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_<arch>.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
}
-101
View File
@@ -5,17 +5,6 @@
package verify 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. // savedBP holds the caller's frame pointer across the ABI-checked JIT call.
// Written and read by enterJITChecked/leaveJITChecked (abi_amd64.s); no Go // Written and read by enterJITChecked/leaveJITChecked (abi_amd64.s); no Go
// code references it, which GoLand cannot see inside assembly. // 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 //lint:ignore U1000 the assembly obtains this address through leaveCheckedPtr
//go:nosplit //go:nosplit
func leaveJITCheckedRaw() 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
}
+145
View File
@@ -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 (
"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)
}
}
+33
View File
@@ -0,0 +1,33 @@
// Copyright (c) 2026 Petr Balvín <opensource@petrbalvin.org> (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()
+84
View File
@@ -0,0 +1,84 @@
// Copyright (c) 2026 Petr Balvín <opensource@petrbalvin.org> (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
+33
View File
@@ -0,0 +1,33 @@
// Copyright (c) 2026 Petr Balvín <opensource@petrbalvin.org> (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()
+61
View File
@@ -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 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
+2 -15
View File
@@ -7,20 +7,7 @@ package verify
import "fmt" import "fmt"
// ABIReport describes the result of an ABI-checking call. // CallChecked is unavailable on unsupported architectures.
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) { 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")
} }
+54
View File
@@ -0,0 +1,54 @@
// Copyright (c) 2026 Petr Balvín <opensource@petrbalvin.org> (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
+33
View File
@@ -0,0 +1,33 @@
// Copyright (c) 2026 Petr Balvín <opensource@petrbalvin.org> (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()
+61
View File
@@ -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 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
+4 -4
View File
@@ -49,10 +49,10 @@ func TestABIBPClobbered(t *testing.T) {
if got := int64(GetUint64(out, 8)); got != 42 { if got := int64(GetUint64(out, 8)); got != 42 {
t.Errorf("dirtyBP(42) = %d, want 42", got) t.Errorf("dirtyBP(42) = %d, want 42", got)
} }
if !report.BPClobbered { if !report.FPClobbered {
t.Error("dirtyBP: expected BP clobbered, but report says clean") t.Error("dirtyBP: expected BP clobbered, but report says clean")
} }
if report.R14Clobbered { if report.GClobbered {
t.Error("dirtyBP: R14 should not be clobbered") 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 { if got := int64(GetUint64(out, 8)); got != 99 {
t.Errorf("dirtyR14(99) = %d, want 99", got) t.Errorf("dirtyR14(99) = %d, want 99", got)
} }
if !report.R14Clobbered { if !report.GClobbered {
t.Error("dirtyR14: expected R14 clobbered, but report says clean") t.Error("dirtyR14: expected R14 clobbered, but report says clean")
} }
if report.BPClobbered { if report.FPClobbered {
t.Error("dirtyR14: BP should not be clobbered") t.Error("dirtyR14: BP should not be clobbered")
} }
} }
+2 -2
View File
@@ -202,7 +202,7 @@ func TestABIReportString(t *testing.T) {
if r.String() != "ABI clean" { if r.String() != "ABI clean" {
t.Errorf("clean report = %q", r.String()) t.Errorf("clean report = %q", r.String())
} }
r.BPClobbered = true r.FPClobbered = true
if r.OK() { if r.OK() {
t.Error("expected not OK with BP clobbered") t.Error("expected not OK with BP clobbered")
} }
@@ -210,7 +210,7 @@ func TestABIReportString(t *testing.T) {
if s == "ABI clean" { if s == "ABI clean" {
t.Error("expected violation string, got clean") t.Error("expected violation string, got clean")
} }
r.R14Clobbered = true r.GClobbered = true
r.RedZoneHit = true r.RedZoneHit = true
s = r.String() s = r.String()
if s == "ABI clean" { if s == "ABI clean" {