fix(verify): fix non-amd64 JIT trampolines and validate under qemu

This commit is contained in:
2026-08-31 12:34:50 +02:00
parent a5a59d6503
commit 94c4756d47
10 changed files with 77 additions and 43 deletions
+23 -16
View File
@@ -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.
+13 -8
View File
@@ -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).
+6 -5
View File
@@ -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
+18 -2
View File
@@ -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
+2 -2
View File
@@ -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
+2 -2
View File
@@ -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
+2 -2
View File
@@ -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
+5 -2
View File
@@ -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()
+3 -2
View File
@@ -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()
+3 -2
View File
@@ -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()