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
(`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.
+36 -15
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 "-".
// 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)
+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.
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
+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
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
}
+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"
// 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")
}
+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 {
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")
}
}
+2 -2
View File
@@ -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" {