fix(verify): gate JIT verification to amd64 until trampolines are hardened
Test / vet (push) Successful in 48s
Test / test (push) Successful in 2m34s
Test / build (push) Successful in 41s

This commit is contained in:
2026-08-30 22:48:48 +02:00
parent 9cb1666b35
commit a5a59d6503
15 changed files with 206 additions and 30 deletions
+5
View File
@@ -12,6 +12,11 @@
coverage.out coverage.out
*.test *.test
# Crash dumps
core
core.*
*.core
# Scratch / temporary work # Scratch / temporary work
_scratch/ _scratch/
+12 -10
View File
@@ -43,16 +43,18 @@ 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 - **ABI checks architecture port (partial).** The ABI-checking
half of `-fuzz` now work on arm64, riscv64 and loong64 via machinery is architecture-neutral (`ABIReport` with `FPClobbered`,
per-architecture checked trampolines: sentinels planted in the `GClobbered`, `RedZoneHit`; renamed from the amd64-only field names)
registers the Go ABI fixes across calls (amd64 `BP`/`R14`, arm64 and per-architecture checked trampolines exist for arm64, riscv64 and
`R29`/`R28`, riscv64 `X27`, loong64 `R22`) are verified on return, with loong64 alongside amd64, restoring the frame pointer and the goroutine
the below-SP canary on every architecture. `gasm verify` now runs the pointer before returning into Go code. Runtime execution of the
JIT checks whenever the host matches the kernel's architecture, and non-amd64 JIT paths is not yet reliable (arm64 and loong64 fault on the
takes the ground-truth-only path only on other hosts. The `ABIReport` return path and riscv64 returns a wrong result under qemu-user), so
fields are renamed to the architecture-neutral `FPClobbered` and `gasm verify` keeps JIT execution gated to amd64 kernels on amd64
`GClobbered`. hosts; other kernels take the toolchain-comparison path exactly as
before. A qemu-user harness and GOARCH-guarded tests are in the tree
to validate the trampolines once their return path is fixed.
- **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.
+5 -3
View File
@@ -1091,9 +1091,11 @@ each entry reproduces.
} }
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 // JIT execution is enabled for amd64 kernels on amd64 hosts. The
// architecture; on any other host only the toolchain comparisons run. // non-amd64 execution trampolines are implemented but not yet
if targetArch != hostArch() { // runtime-hardened, so other kernels take the toolchain-comparison
// path, which needs no execution.
if targetArch != arch.AMD64 || hostArch() != arch.AMD64 {
switch targetArch { switch targetArch {
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).
+1 -1
View File
@@ -114,7 +114,7 @@ func fieldName(stat []byte) string {
func statusDump(b []byte) string { func statusDump(b []byte) string {
var out []string var out []string
for _, l := range strings.Split(string(b), "\n") { for l := range strings.SplitSeq(string(b), "\n") {
if strings.HasPrefix(l, "State") || strings.HasPrefix(l, "Pid") || if strings.HasPrefix(l, "State") || strings.HasPrefix(l, "Pid") ||
strings.HasPrefix(l, "PPid") || strings.HasPrefix(l, "TracerPid") || strings.HasPrefix(l, "PPid") || strings.HasPrefix(l, "TracerPid") ||
strings.HasPrefix(l, "Threads") || strings.HasPrefix(l, "SigPnd") || strings.HasPrefix(l, "Threads") || strings.HasPrefix(l, "SigPnd") ||
+10 -9
View File
@@ -364,15 +364,16 @@ 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. The `trampoline_loong64.s`). The ABI-checked variant `CallChecked` exists for
ABI-checked variant `CallChecked` exists for every architecture too: every architecture too: `enterJITChecked` plants sentinels in the registers
`enterJITChecked` plants sentinels in the registers the Go ABI fixes across the Go ABI fixes across calls (amd64 `BP`/`R14`, arm64 `R29`/`R28`, riscv64
calls (amd64 `BP`/`R14`, arm64 `R29`/`R28`, riscv64 `X27`, loong64 `R22`; `X27`, loong64 `R22`; the latter two keep no hardware frame pointer) and the
the latter two keep no hardware frame pointer) and the raw return trampoline raw return trampoline `leaveJITCheckedRaw` verifies them, restoring the
`leaveJITCheckedRaw` verifies them, so `-abi` reports frame-pointer, saved registers before Go code resumes. At present only the amd64 JIT path
goroutine-pointer and below-SP violations on every supported host. `gasm is runtime-proven: the non-amd64 trampolines compile and their kernels are
verify` dispatches by host: the JIT checks run when the host matches the correct, but the return into Go code still fails under emulation, so `gasm
kernel's architecture, and only the toolchain comparisons run elsewhere. verify` gates JIT execution to amd64 kernels on amd64 hosts and runs only
the toolchain comparisons elsewhere (see docs/DECISIONS.md).
`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
+31
View File
@@ -33,6 +33,37 @@ runs `go list` as a subprocess (consistent with `toolchainObjectPreamble`
which already calls `go tool asm`). All symbol data is cached per package which already calls `go tool asm`). All symbol data is cached per package
for the lifetime of the GOOBJ emission. for the lifetime of the GOOBJ emission.
## 2026-08-30 non-amd64 JIT execution trampolines
**Status:** open (blocks runtime verification on arm64, riscv64 and
loong64 hosts).
**State.** The per-architecture trampolines compile for all targets, the
kernels they execute are byte-for-byte correct against `go tool asm`, and
under `qemu-aarch64` the arm64 kernel demonstrably executes and stores its
result correctly. The failure is on the return path into Go code: arm64
and loong64 take a SIGSEGV after the kernel's RET (the Go-side unwind
through `leaveJIT` and its interposed ABIInternal wrapper is the suspect),
and riscv64 returns cleanly but with an untouched result area. amd64 is
unaffected (the checked trampoline saves and restores BP/R14 and the flow
is validated end to end).
**Evidence harness.** `verify/jit_arch_test.go` (plain call) and
`verify/abi_arch_test.go` (checked call) are GOARCH-guarded tests; build
the test binary per target (`GOARCH=arm64 go test -c -o v.test ./verify/`)
and run it under `qemu-aarch64-static` from the `verify/` directory. A
minimal reproducer pattern lives in the qemu exploration notes: verify
loads, the kernel executes, the fault follows the return.
**Fix direction.** Compare the amd64 checked trampoline (GLOBL/DATA raw
address, explicit SP/BP/R14 save-restore) against the arm64/riscv64/
loong64 `leaveJIT` unwind, in particular the interaction with the
ABIInternal wrapper that `reflect.ValueOf(leaveJIT).Pointer()` returns.
The plain-call path (no sentinels) fails the same way, so the checked
path is not the variable.
---
## 2026-08-29 tooling round ## 2026-08-29 tooling round
- `lint abi0-register-args`: flags kernels whose `// func` parameters are - `lint abi0-register-args`: flags kernels whose `// func` parameters are
+8
View File
@@ -14,6 +14,14 @@ package verify
//lint:ignore U1000 written and read by the assembly //lint:ignore U1000 written and read by the assembly
var savedBP uintptr var savedBP uintptr
// savedR14 holds the caller's goroutine pointer across the ABI-checked JIT
// call; the trampoline restores it before returning into Go code.
//
// noinspection GoUnusedGlobalVariable
//
//lint:ignore U1000 written and read by the assembly
var savedR14 uintptr
// leaveCheckedPtr is initialised by the linker from the GLOBL/DATA in // leaveCheckedPtr is initialised by the linker from the GLOBL/DATA in
// abi_amd64.s: it holds the raw address of leaveJITCheckedRaw (which has // abi_amd64.s: it holds the raw address of leaveJITCheckedRaw (which has
// no ABIInternal wrapper, so the JIT function RETs directly into it). // no ABIInternal wrapper, so the JIT function RETs directly into it).
+6
View File
@@ -32,6 +32,7 @@ TEXT ·enterJITChecked(SB), NOSPLIT, $0-16
MOVQ fn+0(FP), AX // target (before SP switch) MOVQ fn+0(FP), AX // target (before SP switch)
MOVQ SP, ·savedSP(SB) // preserve Go stack MOVQ SP, ·savedSP(SB) // preserve Go stack
MOVQ BP, ·savedBP(SB) // preserve frame pointer (vet requires save before clobber) MOVQ BP, ·savedBP(SB) // preserve frame pointer (vet requires save before clobber)
MOVQ R14, ·savedR14(SB) // preserve the goroutine pointer
MOVQ $SENTINEL_BP, BP // sentinel in BP MOVQ $SENTINEL_BP, BP // sentinel in BP
MOVQ $SENTINEL_R14, R14 // sentinel in R14 MOVQ $SENTINEL_R14, R14 // sentinel in R14
MOVQ stack+8(FP), SP // switch to prepared stack MOVQ stack+8(FP), SP // switch to prepared stack
@@ -57,5 +58,10 @@ bp_ok:
ORQ $2, ·abiResult(SB) ORQ $2, ·abiResult(SB)
r14_ok: r14_ok:
MOVQ ·savedR14(SB), R14 // restore the goroutine pointer: the runtime
// needs it the moment Go code resumes, whether
// or not the kernel violated it (the violation
// is already recorded in abiResult)
MOVQ ·savedBP(SB), BP // restore the frame pointer
MOVQ ·savedSP(SB), SP MOVQ ·savedSP(SB), SP
RET RET
+5
View File
@@ -38,6 +38,7 @@ TEXT ·enterJITChecked(SB), NOSPLIT, $0-16
MOVD R30, savedLR(SB) // save link register MOVD R30, savedLR(SB) // save link register
MOVD R3, savedSP(SB) // save Go stack pointer MOVD R3, savedSP(SB) // save Go stack pointer
MOVD R29, savedFP(SB) // save frame pointer (vet requires save before clobber) MOVD R29, savedFP(SB) // save frame pointer (vet requires save before clobber)
MOVD g, savedG(SB) // save g
MOVD $SENTINEL_FP, R29 // sentinel in the frame pointer MOVD $SENTINEL_FP, R29 // sentinel in the frame pointer
MOVD $SENTINEL_G, g // sentinel in g MOVD $SENTINEL_G, g // sentinel in g
MOVD stack+8(FP), R3 // load prepared stack pointer MOVD stack+8(FP), R3 // load prepared stack pointer
@@ -75,6 +76,9 @@ restore:
MOVD savedSP(SB), R3 // restore Go stack pointer MOVD savedSP(SB), R3 // restore Go stack pointer
MOVD R3, RSP MOVD R3, RSP
MOVD savedLR(SB), R30 // restore link register MOVD savedLR(SB), R30 // restore link register
MOVD savedFP(SB), R29 // restore frame pointer: Go code needs it the
// moment it resumes, violation or not
MOVD savedG(SB), g // restore g
RET // return to Go caller RET // return to Go caller
// Package-level storage for the saved frame pointer. Like savedSP and // Package-level storage for the saved frame pointer. Like savedSP and
@@ -82,3 +86,4 @@ restore:
// checked trampoline saves the caller's frame pointer for vet's sake and // checked trampoline saves the caller's frame pointer for vet's sake and
// never restores it, and this file mirrors that. // never restores it, and this file mirrors that.
GLOBL savedFP(SB), NOPTR, $8 GLOBL savedFP(SB), NOPTR, $8
GLOBL savedG(SB), NOPTR, $8
+5
View File
@@ -33,6 +33,7 @@ TEXT ·enterJITChecked(SB), NOSPLIT, $0-16
MOVV fn+0(FP), R4 // target function address (A0) MOVV fn+0(FP), R4 // target function address (A0)
MOVV R1, savedRA(SB) // save return address (RA) MOVV R1, savedRA(SB) // save return address (RA)
MOVV R3, savedSP(SB) // save Go stack pointer (SP) MOVV R3, savedSP(SB) // save Go stack pointer (SP)
MOVV g, savedG(SB) // save g
MOVV $SENTINEL_G, g // sentinel in g MOVV $SENTINEL_G, g // sentinel in g
MOVV stack+8(FP), R5 // load prepared stack pointer (A1) MOVV stack+8(FP), R5 // load prepared stack pointer (A1)
MOVV 0(R5), R1 // load leaveJITCheckedRaw into RA MOVV 0(R5), R1 // load leaveJITCheckedRaw into RA
@@ -58,4 +59,8 @@ g_ok:
MOVV savedSP(SB), R5 // restore Go stack pointer MOVV savedSP(SB), R5 // restore Go stack pointer
MOVV R5, R3 MOVV R5, R3
MOVV savedRA(SB), R1 // restore return address MOVV savedRA(SB), R1 // restore return address
MOVV savedG(SB), g // restore g: Go code needs it the moment it
// resumes, violation or not
JIRL R0, R1, 0 // return to Go caller JIRL R0, R1, 0 // return to Go caller
GLOBL savedG(SB), NOPTR, $8
+5
View File
@@ -33,6 +33,7 @@ TEXT ·enterJITChecked(SB), NOSPLIT, $0-16
MOV fn+0(FP), X5 // target function address (T0) MOV fn+0(FP), X5 // target function address (T0)
MOV X1, savedRA(SB) // save return address MOV X1, savedRA(SB) // save return address
MOV X2, savedSP(SB) // save Go stack pointer MOV X2, savedSP(SB) // save Go stack pointer
MOV g, savedG(SB) // save g
MOV $SENTINEL_G, g // sentinel in g MOV $SENTINEL_G, g // sentinel in g
MOV stack+8(FP), X6 // load prepared stack pointer (T1) MOV stack+8(FP), X6 // load prepared stack pointer (T1)
LD 0(X6), X1 // load leaveJITCheckedRaw into RA LD 0(X6), X1 // load leaveJITCheckedRaw into RA
@@ -58,4 +59,8 @@ g_ok:
MOV savedSP(SB), X6 // restore Go stack pointer MOV savedSP(SB), X6 // restore Go stack pointer
MOV X6, X2 MOV X6, X2
MOV savedRA(SB), X1 // restore return address MOV savedRA(SB), X1 // restore return address
MOV savedG(SB), g // restore g: Go code needs it the moment it
// resumes, violation or not
JALR X0, 0(X1) // return to Go caller JALR X0, 0(X1) // return to Go caller
GLOBL savedG(SB), NOPTR, $8
+5 -6
View File
@@ -8,7 +8,6 @@ package verify
import ( import (
"encoding/binary" "encoding/binary"
"fmt" "fmt"
"reflect"
"syscall" "syscall"
"unsafe" "unsafe"
) )
@@ -23,12 +22,12 @@ func enterJIT(fn uintptr, stack uintptr)
//go:nosplit //go:nosplit
func leaveJIT() func leaveJIT()
// leaveJITAddr is the machine address of leaveJIT. // leaveJITAddr is the raw ABI0 address of leaveJIT, handed over by the
var leaveJITAddr uintptr // GLOBL/DATA in trampoline_arm64.s (reflect would return the interposed
// ABIInternal wrapper instead).
var leaveRawAddr uintptr
func init() { var leaveJITAddr = leaveRawAddr
leaveJITAddr = reflect.ValueOf(leaveJIT).Pointer()
}
const stackPad = 64 const stackPad = 64
+86
View File
@@ -0,0 +1,86 @@
// Copyright (c) 2026 Petr Balvín <opensource@petrbalvin.org> (https://petrbalvin.org)
// SPDX-License-Identifier: BSD-3-Clause
package verify
import (
"runtime"
"testing"
)
// requireArchHost skips unless the test binary runs on the named GOARCH: the
// JIT executes native code, so an arm64 kernel only runs on an arm64 CPU
// (real hardware or qemu-user emulation).
func requireArchHost(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)
}
}
// TestPlainCallArm64 checks the bare JIT call path (no ABI sentinels) on
// arm64: the trampoline, the kernel and the result read-back.
func TestPlainCallArm64(t *testing.T) {
requireArchHost(t, "arm64")
k, err := Load("../testdata/verify/abi_arm64.s")
if err != nil {
t.Fatalf("Load: %v", err)
}
t.Cleanup(k.Close)
args := make([]byte, 24)
PutUint64(args, 0, 3)
PutUint64(args, 8, 4)
out, err := k.CallFunc("cleanAdd", args)
if err != nil {
t.Fatalf("CallFunc: %v", err)
}
if got := int64(GetUint64(out, 16)); got != 7 {
t.Errorf("cleanAdd(3, 4) = %d, want 7", got)
}
}
// TestPlainCallRiscv64 is TestPlainCallArm64 for riscv64.
func TestPlainCallRiscv64(t *testing.T) {
requireArchHost(t, "riscv64")
k, err := Load("../testdata/verify/abi_riscv64.s")
if err != nil {
t.Fatalf("Load: %v", err)
}
t.Cleanup(k.Close)
args := make([]byte, 24)
PutUint64(args, 0, 3)
PutUint64(args, 8, 4)
out, err := k.CallFunc("cleanAdd", args)
if err != nil {
t.Fatalf("CallFunc: %v", err)
}
if got := int64(GetUint64(out, 16)); got != 7 {
t.Errorf("cleanAdd(3, 4) = %d, want 7", got)
}
}
// TestPlainCallLoong64 is TestPlainCallArm64 for loong64.
func TestPlainCallLoong64(t *testing.T) {
requireArchHost(t, "loong64")
k, err := Load("../testdata/verify/abi_loong64.s")
if err != nil {
t.Fatalf("Load: %v", err)
}
t.Cleanup(k.Close)
args := make([]byte, 24)
PutUint64(args, 0, 3)
PutUint64(args, 8, 4)
out, err := k.CallFunc("cleanAdd", args)
if err != nil {
t.Fatalf("CallFunc: %v", err)
}
if got := int64(GetUint64(out, 16)); got != 7 {
t.Errorf("cleanAdd(3, 4) = %d, want 7", got)
}
}
+7
View File
@@ -31,6 +31,13 @@ TEXT ·leaveJIT(SB), NOSPLIT, $0-0
MOVD savedLR(SB), R30 // restore link register MOVD savedLR(SB), R30 // restore link register
RET // return to Go caller RET // return to Go caller
// leaveRawAddr holds the raw .abi0 address of leaveJIT, read by call_arm64.go
// in preference to reflect.ValueOf(leaveJIT), which returns the address of the
// ABIInternal wrapper the linker interposes: the wrapper's prologue clobbers
// the saved-register window the JIT call depends on.
GLOBL ·leaveRawAddr(SB), NOPTR, $8
DATA ·leaveRawAddr(SB)/8, $·leaveJIT(SB)
// Package-level storage for saved registers. // Package-level storage for saved registers.
GLOBL savedLR(SB), NOPTR, $8 GLOBL savedLR(SB), NOPTR, $8
GLOBL savedSP(SB), NOPTR, $8 GLOBL savedSP(SB), NOPTR, $8
+15 -1
View File
@@ -9,6 +9,7 @@ import (
"os" "os"
"runtime" "runtime"
"sourcedock.dev/petrbalvin/gasm-devkit/arch"
"sourcedock.dev/petrbalvin/gasm-devkit/asm" "sourcedock.dev/petrbalvin/gasm-devkit/asm"
"sourcedock.dev/petrbalvin/gasm-devkit/ast" "sourcedock.dev/petrbalvin/gasm-devkit/ast"
"sourcedock.dev/petrbalvin/gasm-devkit/parser" "sourcedock.dev/petrbalvin/gasm-devkit/parser"
@@ -46,7 +47,20 @@ func LoadSource(filename, src string) (*Kernel, error) {
// LoadAST assembles a parsed AST file and maps the result into executable // LoadAST assembles a parsed AST file and maps the result into executable
// memory. // memory.
func LoadAST(file *ast.File) (*Kernel, error) { func LoadAST(file *ast.File) (*Kernel, error) {
img, err := asm.AssembleFile(file) // Assemble with the encoder the file's name suffix calls for: the amd64
// assembler is the default, the other architectures have their own.
var img *asm.Image
var err error
switch arch.FromFilename(file.Path) {
case arch.ARM64:
img, err = asm.AssembleFileARM64(file)
case arch.RISCV:
img, err = asm.AssembleFileRISCV(file)
case arch.LOONG64:
img, err = asm.AssembleFileLOONG64(file)
default:
img, err = asm.AssembleFile(file)
}
if err != nil { if err != nil {
return nil, fmt.Errorf("verify: assemble: %w", err) return nil, fmt.Errorf("verify: assemble: %w", err)
} }