From 94c4756d47e5477c9b49a038f7fa47939a4fd854 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Petr=20Balv=C3=ADn?= Date: Mon, 31 Aug 2026 12:13:43 +0200 Subject: [PATCH] fix(verify): fix non-amd64 JIT trampolines and validate under qemu --- CHANGELOG.md | 39 ++++++++++++++++++++++--------------- cmd/gasm/main.go | 21 ++++++++++++-------- docs/ARCHITECTURE.md | 11 ++++++----- docs/DECISIONS.md | 20 +++++++++++++++++-- verify/abi_arm64.s | 4 ++-- verify/abi_loong64.s | 4 ++-- verify/abi_riscv64.s | 4 ++-- verify/trampoline_arm64.s | 7 +++++-- verify/trampoline_loong64.s | 5 +++-- verify/trampoline_riscv64.s | 5 +++-- 10 files changed, 77 insertions(+), 43 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 14856cb..b4339a6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,10 +11,13 @@ Unreleased changes on the `development` branch. ### Added -- **Multi-architecture debugger.** `gasm debug` works on arm64, riscv64 - and loong64 in addition to amd64. Each architecture has its own ptrace - register access, disassembler (`golang.org/x/arch`), register display, - and stop-info handler. The REPL is fully arch-neutral. +- **Multi-architecture debugger.** `gasm debug` carries + per-architecture ptrace register access, disassemblers + (`golang.org/x/arch`), register display, FP register views and + stop-info handlers for arm64, riscv64 and loong64, and the REPL is + arch-neutral. Sessions are runtime-validated on amd64; the other + hosts execute through the now-working JIT trampolines, but their + ptrace loops have not seen hardware yet. - **Headless debugging.** `gasm debug --script` runs REPL commands from a file (or stdin) and exits; `--timeout` kills the debuggee when a run hangs, with the watchdog armed before the ptrace attach. `--cover` runs @@ -43,18 +46,22 @@ 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 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. +- **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 and + the saved registers restored before Go code resumes, with the + below-SP canary on every architecture. The root cause of the broken + non-amd64 calls was a stack misalignment: the trampolines advanced SP + past the linkage slot while the kernels read their first argument at + SP+8. riscv64 is validated end to end under qemu-user; arm64 shares + the fix and matches the observed frame convention; loong64 stays + ground-truth-only until hardware validation (the Go runtime cannot + start under the available loong64 emulators). `verify.Load` now + assembles each file with the encoder its name suffix calls for. 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. diff --git a/cmd/gasm/main.go b/cmd/gasm/main.go index d83ef1f..1f977bc 100644 --- a/cmd/gasm/main.go +++ b/cmd/gasm/main.go @@ -1049,8 +1049,11 @@ With -smoke, each NOSPLIT function is called with a zeroed argument block to confirm the JIT trampoline works end-to-end. This is safe only for functions that tolerate nil pointers and zero lengths in their arguments. -With -abi, each function is called with sentinel values in the callee-saved -registers (BP, R14) and a red-zone canary below SP; violations are reported. +With -abi, each function is called with sentinel values in the registers +the Go ABI fixes across calls (the frame pointer and the goroutine +pointer) plus a canary below SP; violations are reported. JIT-based +checks run when the host matches the file's architecture (all but +loong64, which is ground-truth only for now). With -fuzz, each function with a // func signature is differentially fuzzed against the go-tool-asm version in a subprocess (so a crash on a partial @@ -1091,17 +1094,19 @@ each entry reproduces. } path := set.Arg(0) targetArch := arch.FromFilename(path) - // 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 { + // JIT execution runs when the host CPU matches the kernel's + // architecture, except loong64: its trampoline is implemented but not + // yet validated against real hardware (the Go runtime cannot start + // under the available loong64 emulators), so those kernels take the + // toolchain-comparison path. + if targetArch != hostArch() || targetArch == arch.LOONG64 { 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). + // LoongArch: ground-truth only (trampoline not yet + // hardware-validated). return cmdVerifyLOONG64(path, *groundTruth, *profile) case arch.ARM64: // AArch64: ground-truth only (no JIT on non-ARM64 hosts). diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 7a90dbb..f16daf9 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -369,11 +369,12 @@ 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). +saved registers before Go code resumes. riscv64 is validated end to +end under qemu-user emulation; arm64 shares the same stack convention and +fix; loong64 stays ground-truth-only until hardware validation (see +docs/DECISIONS.md). `gasm verify` runs the JIT checks when the host +matches the kernel's architecture and the toolchain comparisons +elsewhere. `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 667314e..47c8c60 100644 --- a/docs/DECISIONS.md +++ b/docs/DECISIONS.md @@ -35,8 +35,24 @@ 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). +**Status:** resolved for riscv64 (validated end to end under qemu-user) +and arm64 (fix in place, consistent with the observed frame convention); +open for loong64 until hardware validation. + +**Root cause (found 2026-08-31).** The trampolines advanced SP past the +leave-address slot after loading it, while the assembled kernels read +their first argument at SP+8 per the frame convention (the amd64 path +already kept SP on that slot). Removing the advance fixed riscv64 +immediately (plain and checked ABI tests pass under qemu-user); the +arm64 kernel's pre-fix trace showed exactly the same SP+8 reading. The +apparent arm64/loong64 "crashes in the JIT" turned out to be dominated +by an unrelated instability: the Go 1.26 and 1.27 runtimes crash under +qemu-user arm64 emulation (GC worker start, identical signature with the +JIT tests skipped, both qemu 7.2 and 10.2), and the Go loong64 runtime +does not start at all. `gasm verify` therefore keeps loong64 kernels on +the ground-truth path until hardware validation; the GOARCH-guarded +tests (`verify/jit_arch_test.go`, `verify/abi_arch_test.go`) are the +hardware validation entry point. **State.** The per-architecture trampolines compile for all targets, the kernels they execute are byte-for-byte correct against `go tool asm`, and diff --git a/verify/abi_arm64.s b/verify/abi_arm64.s index b0494d5..e594062 100644 --- a/verify/abi_arm64.s +++ b/verify/abi_arm64.s @@ -43,8 +43,8 @@ TEXT ·enterJITChecked(SB), NOSPLIT, $0-16 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 + MOVD R3, RSP // SP stays on the leave slot: the kernel + // reads its first argument at SP+8 JMP (R0) // branch to JIT function // leaveJITCheckedRaw is the raw return trampoline. It has NO Go function diff --git a/verify/abi_loong64.s b/verify/abi_loong64.s index a9f05ea..18170b6 100644 --- a/verify/abi_loong64.s +++ b/verify/abi_loong64.s @@ -37,8 +37,8 @@ TEXT ·enterJITChecked(SB), NOSPLIT, $0-16 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) + MOVV R5, R3 // SP stays on the leave slot: the kernel + // reads its first argument at SP+8 JIRL R0, R4, 0 // jump to JIT function // leaveJITCheckedRaw is the raw return trampoline. It has NO Go function diff --git a/verify/abi_riscv64.s b/verify/abi_riscv64.s index 44159a5..0164d8a 100644 --- a/verify/abi_riscv64.s +++ b/verify/abi_riscv64.s @@ -37,8 +37,8 @@ TEXT ·enterJITChecked(SB), NOSPLIT, $0-16 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) + MOV X6, X2 // SP stays on the leave slot: the kernel + // reads its first argument at SP+8 JALR X0, 0(X5) // jump to JIT function // leaveJITCheckedRaw is the raw return trampoline. It has NO Go function diff --git a/verify/trampoline_arm64.s b/verify/trampoline_arm64.s index eaf0140..c0bad91 100644 --- a/verify/trampoline_arm64.s +++ b/verify/trampoline_arm64.s @@ -20,8 +20,11 @@ TEXT ·enterJIT(SB), NOSPLIT, $0-16 MOVD R3, savedSP(SB) // save Go stack pointer MOVD stack+8(FP), R3 // load prepared stack pointer MOVD 0(R3), R30 // load leaveJIT address into LR - ADD $8, R3, R3 // advance past return address - MOVD R3, RSP // switch to prepared stack + MOVD R3, RSP // switch to the prepared stack: SP stays on + // the leave slot, so the kernel reads its + // first argument at SP+8 per the frame + // convention (amd64 lays the stack out the + // same way) JMP (R0) // branch to JIT function // func leaveJIT() diff --git a/verify/trampoline_loong64.s b/verify/trampoline_loong64.s index 29751ec..64e0f89 100644 --- a/verify/trampoline_loong64.s +++ b/verify/trampoline_loong64.s @@ -16,8 +16,9 @@ TEXT ·enterJIT(SB), NOSPLIT, $0-16 MOVV R3, savedSP(SB) // save Go stack pointer (SP) MOVV stack+8(FP), R5 // load prepared stack pointer (A1) MOVV 0(R5), R1 // load leaveJIT address into RA - ADDV $8, R5, R5 // advance past return address - MOVV R5, R3 // switch to prepared stack (SP) + MOVV R5, R3 // switch to the prepared stack: SP stays on + // the leave slot, so the kernel reads its + // first argument at SP+8 JIRL R0, R4, 0 // jump to JIT function // func leaveJIT() diff --git a/verify/trampoline_riscv64.s b/verify/trampoline_riscv64.s index 7794f1d..041a64f 100644 --- a/verify/trampoline_riscv64.s +++ b/verify/trampoline_riscv64.s @@ -16,8 +16,9 @@ TEXT ·enterJIT(SB), NOSPLIT, $0-16 MOV X2, savedSP(SB) // save Go stack pointer MOV stack+8(FP), X6 // load prepared stack pointer (T1) LD 0(X6), X1 // load leaveJIT address into RA - ADD $8, X6, X6 // advance past return address - MOV X6, X2 // switch to prepared stack (SP) + MOV X6, X2 // switch to the prepared stack: SP stays on + // the leave slot, so the kernel reads its + // first argument at SP+8 JALR X0, 0(X5) // jump to JIT function // func leaveJIT()