feat(verify): ABI checks on arm64, riscv64 and loong64
Assisted-by: GLM 5.3 Flash
This commit is contained in:
@@ -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
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
Vendored
+30
@@ -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
|
||||
Vendored
+21
@@ -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
|
||||
Vendored
+21
@@ -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
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
@@ -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
|
||||
@@ -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()
|
||||
@@ -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
@@ -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")
|
||||
}
|
||||
|
||||
@@ -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
|
||||
@@ -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()
|
||||
@@ -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
@@ -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
@@ -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" {
|
||||
|
||||
Reference in New Issue
Block a user