diff --git a/.gitignore b/.gitignore index 3cc1740..5003eba 100644 --- a/.gitignore +++ b/.gitignore @@ -12,6 +12,11 @@ coverage.out *.test +# Crash dumps +core +core.* +*.core + # Scratch / temporary work _scratch/ diff --git a/CHANGELOG.md b/CHANGELOG.md index d49f724..14856cb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -43,16 +43,18 @@ 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`. +- **ABI checks architecture port (partial).** The ABI-checking + machinery is architecture-neutral (`ABIReport` with `FPClobbered`, + `GClobbered`, `RedZoneHit`; renamed from the amd64-only field names) + and per-architecture checked trampolines exist for arm64, riscv64 and + loong64 alongside amd64, restoring the frame pointer and the goroutine + pointer before returning into Go code. Runtime execution of the + non-amd64 JIT paths is not yet reliable (arm64 and loong64 fault on the + return path and riscv64 returns a wrong result under qemu-user), so + `gasm verify` keeps JIT execution gated to amd64 kernels on amd64 + 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 via `PTRACE_SETREGSET` with `NT_ARM_HW_BREAK`; riscv64 and loong64 use `PTRACE_POKEUSER` to access trigger/debug registers. diff --git a/cmd/gasm/main.go b/cmd/gasm/main.go index 9ed6762..d83ef1f 100644 --- a/cmd/gasm/main.go +++ b/cmd/gasm/main.go @@ -1091,9 +1091,11 @@ each entry reproduces. } path := set.Arg(0) 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() { + // JIT execution is enabled for amd64 kernels on amd64 hosts. The + // non-amd64 execution trampolines are implemented but not yet + // runtime-hardened, so other kernels take the toolchain-comparison + // path, which needs no execution. + if targetArch != arch.AMD64 || hostArch() != arch.AMD64 { switch targetArch { case arch.RISCV: // RISC-V: ground-truth only (no JIT on non-RISC-V hosts). diff --git a/debug/ptrace_integration_test.go b/debug/ptrace_integration_test.go index df4c1d1..ee92009 100644 --- a/debug/ptrace_integration_test.go +++ b/debug/ptrace_integration_test.go @@ -114,7 +114,7 @@ func fieldName(stat []byte) string { func statusDump(b []byte) 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") || strings.HasPrefix(l, "PPid") || strings.HasPrefix(l, "TracerPid") || strings.HasPrefix(l, "Threads") || strings.HasPrefix(l, "SigPnd") || diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 3fcaaa2..7a90dbb 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -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. 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. 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. +`trampoline_loong64.s`). 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, restoring the +saved registers before Go code resumes. At present only the amd64 JIT path +is runtime-proven: the non-amd64 trampolines compile and their kernels are +correct, but the return into Go code still fails under emulation, so `gasm +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 step, returning a `Kernel` whose `CallFunc` method marshals the argument block diff --git a/docs/DECISIONS.md b/docs/DECISIONS.md index 560897d..667314e 100644 --- a/docs/DECISIONS.md +++ b/docs/DECISIONS.md @@ -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 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 - `lint abi0-register-args`: flags kernels whose `// func` parameters are diff --git a/verify/abi_amd64.go b/verify/abi_amd64.go index 1a562ff..cdd107a 100644 --- a/verify/abi_amd64.go +++ b/verify/abi_amd64.go @@ -14,6 +14,14 @@ package verify //lint:ignore U1000 written and read by the assembly 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 // abi_amd64.s: it holds the raw address of leaveJITCheckedRaw (which has // no ABIInternal wrapper, so the JIT function RETs directly into it). diff --git a/verify/abi_amd64.s b/verify/abi_amd64.s index 63751b2..6d89f22 100644 --- a/verify/abi_amd64.s +++ b/verify/abi_amd64.s @@ -32,6 +32,7 @@ TEXT ·enterJITChecked(SB), NOSPLIT, $0-16 MOVQ fn+0(FP), AX // target (before SP switch) MOVQ SP, ·savedSP(SB) // preserve Go stack 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_R14, R14 // sentinel in R14 MOVQ stack+8(FP), SP // switch to prepared stack @@ -57,5 +58,10 @@ bp_ok: ORQ $2, ·abiResult(SB) 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 RET diff --git a/verify/abi_arm64.s b/verify/abi_arm64.s index 7176c96..b0494d5 100644 --- a/verify/abi_arm64.s +++ b/verify/abi_arm64.s @@ -38,6 +38,7 @@ TEXT ·enterJITChecked(SB), NOSPLIT, $0-16 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 g, savedG(SB) // save g 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 @@ -75,6 +76,9 @@ restore: MOVD savedSP(SB), R3 // restore Go stack pointer MOVD R3, RSP 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 // 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 // never restores it, and this file mirrors that. GLOBL savedFP(SB), NOPTR, $8 +GLOBL savedG(SB), NOPTR, $8 diff --git a/verify/abi_loong64.s b/verify/abi_loong64.s index 03aaa54..a9f05ea 100644 --- a/verify/abi_loong64.s +++ b/verify/abi_loong64.s @@ -33,6 +33,7 @@ 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 g, savedG(SB) // save g 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 @@ -58,4 +59,8 @@ g_ok: MOVV savedSP(SB), R5 // restore Go stack pointer MOVV R5, R3 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 + +GLOBL savedG(SB), NOPTR, $8 diff --git a/verify/abi_riscv64.s b/verify/abi_riscv64.s index 3488eb3..44159a5 100644 --- a/verify/abi_riscv64.s +++ b/verify/abi_riscv64.s @@ -33,6 +33,7 @@ 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 g, savedG(SB) // save g 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 @@ -58,4 +59,8 @@ g_ok: MOV savedSP(SB), X6 // restore Go stack pointer MOV X6, X2 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 + +GLOBL savedG(SB), NOPTR, $8 diff --git a/verify/call_arm64.go b/verify/call_arm64.go index d62124c..aa2cc91 100644 --- a/verify/call_arm64.go +++ b/verify/call_arm64.go @@ -8,7 +8,6 @@ package verify import ( "encoding/binary" "fmt" - "reflect" "syscall" "unsafe" ) @@ -23,12 +22,12 @@ func enterJIT(fn uintptr, stack uintptr) //go:nosplit func leaveJIT() -// leaveJITAddr is the machine address of leaveJIT. -var leaveJITAddr uintptr +// leaveJITAddr is the raw ABI0 address of leaveJIT, handed over by the +// GLOBL/DATA in trampoline_arm64.s (reflect would return the interposed +// ABIInternal wrapper instead). +var leaveRawAddr uintptr -func init() { - leaveJITAddr = reflect.ValueOf(leaveJIT).Pointer() -} +var leaveJITAddr = leaveRawAddr const stackPad = 64 diff --git a/verify/jit_arch_test.go b/verify/jit_arch_test.go new file mode 100644 index 0000000..873fd2c --- /dev/null +++ b/verify/jit_arch_test.go @@ -0,0 +1,86 @@ +// Copyright (c) 2026 Petr Balvín (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) + } +} diff --git a/verify/trampoline_arm64.s b/verify/trampoline_arm64.s index b3872d1..eaf0140 100644 --- a/verify/trampoline_arm64.s +++ b/verify/trampoline_arm64.s @@ -31,6 +31,13 @@ TEXT ·leaveJIT(SB), NOSPLIT, $0-0 MOVD savedLR(SB), R30 // restore link register 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. GLOBL savedLR(SB), NOPTR, $8 GLOBL savedSP(SB), NOPTR, $8 diff --git a/verify/verify.go b/verify/verify.go index d7887ca..0ccf505 100644 --- a/verify/verify.go +++ b/verify/verify.go @@ -9,6 +9,7 @@ import ( "os" "runtime" + "sourcedock.dev/petrbalvin/gasm-devkit/arch" "sourcedock.dev/petrbalvin/gasm-devkit/asm" "sourcedock.dev/petrbalvin/gasm-devkit/ast" "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 // memory. 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 { return nil, fmt.Errorf("verify: assemble: %w", err) }