Document the riscv64 kernel stack trampoline fix
Write-up of the 2026-08-02 riscv64 boot crash investigation and fix
(commit 7366275), in the same style as docs/lithosananke/amd64-isr-fix.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Sonnet 5
parent
757ce97dc1
commit
0fa9bd730e
@@ -0,0 +1,183 @@
|
|||||||
|
# Kernel Stack Trampoline Fix — riscv64
|
||||||
|
|
||||||
|
**Date**: 2026-08-02
|
||||||
|
**Branch**: `master`
|
||||||
|
**Commit**: `7366275`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Problem
|
||||||
|
|
||||||
|
The riscv64 kernel crashed partway through VM bootstrap during the 2026-08-01
|
||||||
|
DoE campaign run, well after amd64 and aarch64 had both booted cleanly to
|
||||||
|
`ok>` in the same session. The crash was a wild jump: execution landed at an
|
||||||
|
address nowhere inside the kernel's own linked image, then almost immediately
|
||||||
|
faulted trying to load from a small, near-null address.
|
||||||
|
|
||||||
|
```
|
||||||
|
Registering FORTH-79 Standard word set...
|
||||||
|
Registering FORTH-79 arithmetic words...
|
||||||
|
FORTH-79 Standard word set registration complete
|
||||||
|
*** EXCEPTION (riscv64) ***
|
||||||
|
scause = 0x000000000000000d
|
||||||
|
sepc = 0x00000000be27c3c2
|
||||||
|
stval = 0x00000000000074d8
|
||||||
|
```
|
||||||
|
|
||||||
|
`scause = 0xd` is a supervisor-mode load page fault. `sepc` (~0xbe27c3c2) sat
|
||||||
|
near the top of QEMU's 1 GB RAM window — nowhere close to the kernel's own
|
||||||
|
`.text`/`.data`/`.bss` footprint (`0x400000`–`0xcdf9a0`, confirmed via `nm`/
|
||||||
|
`readelf` on the exact build that produced the log). `stval` (0x74d8, ~30 KB)
|
||||||
|
stayed fixed across rebuilds even as `sepc` drifted — the signature of a
|
||||||
|
corrupted return address rather than a fixed logic bug at one source line.
|
||||||
|
|
||||||
|
## Investigation
|
||||||
|
|
||||||
|
Two dead ends were ruled out before the real cause surfaced:
|
||||||
|
|
||||||
|
1. **ELF relocation / buffer overlap in the split-build loader.** The
|
||||||
|
codebase has a `!MONOLITHIC_BUILD` path (`elf_load_kernel()` in
|
||||||
|
`elf_loader.c`) that loads `kernel.elf` from the ESP into a UEFI
|
||||||
|
`AllocateAnyPages` buffer. This looked like a plausible overlap candidate
|
||||||
|
at first, but `Makefile.starkernel` defaults to `MONOLITHIC=1`, meaning
|
||||||
|
`kernel_main` is linked directly into the loader and that code path never
|
||||||
|
runs. Confirmed by checking the built binary for the investigation's own
|
||||||
|
diagnostic strings — absent, because the compilation unit was built with
|
||||||
|
`-DMONOLITHIC_BUILD` and the code was preprocessed out.
|
||||||
|
2. **`RAW_LOG()` silently no-ops on every non-amd64 arch.** All of
|
||||||
|
`load_kernel_from_esp()`'s narration (and an early ConOut-based
|
||||||
|
diagnostic) never reached the captured serial log, on riscv64, in any
|
||||||
|
build. `RAW_LOG` was defined only for `ARCH_AMD64`; everywhere else it
|
||||||
|
expanded to `((void)0)`. This made straightforward diagnosis impossible
|
||||||
|
until a real raw-UART writer was added for riscv64 (see Secondary Fix).
|
||||||
|
|
||||||
|
With real output available, `console_println()` checkpoints were bisected
|
||||||
|
through `vm_init_with_host()` and `sk_vm_bootstrap_parity()`. Every checkpoint
|
||||||
|
inside `vm_init_with_host()` printed successfully, including the very last
|
||||||
|
line of the function — but the checkpoint immediately after its *return*
|
||||||
|
never appeared. Re-running with a trivial, unrelated local-variable addition
|
||||||
|
shifted the crash earlier and changed the exception type entirely (from a
|
||||||
|
load fault to an instruction-fetch fault at `sepc == stval`, i.e. the CPU
|
||||||
|
tried to *execute* an unmapped address). A crash whose location and even
|
||||||
|
category shift under unrelated code changes is the standard signature of
|
||||||
|
stack corruption, not a fixed bug at a specific call site.
|
||||||
|
|
||||||
|
## Root Cause
|
||||||
|
|
||||||
|
`kernel_main.c` documented the gap directly, once read closely:
|
||||||
|
|
||||||
|
> On amd64, `kernel_entry.S` switches the stack from UEFI's default to a
|
||||||
|
> 2 MiB zero-initialised BSS stack and tail-calls this function as
|
||||||
|
> `kernel_main_impl`. On aarch64 and riscv64 the assembly trampoline is not
|
||||||
|
> yet implemented and the UEFI loader calls `kernel_main` directly.
|
||||||
|
|
||||||
|
amd64 has carried a dedicated stack-switch trampoline
|
||||||
|
(`src/starkernel/arch/amd64/kernel_entry.S`) since early on, with its own
|
||||||
|
comment explaining why: *"UEFI hands control to kernel_main() running on a
|
||||||
|
small UEFI-provided stack (typically 128 KB–256 KB). The FORTH interpreter +
|
||||||
|
DOE experiment loop can easily exceed that depth."* riscv64 never had the
|
||||||
|
equivalent. It ran the entire VM bootstrap — 27 chained word-registration
|
||||||
|
modules, physics/SSM init, Tripod capsule birth — directly on whatever small
|
||||||
|
stack EDK2's riscv64 firmware handed the loader, overflowing it and
|
||||||
|
corrupting a return address. aarch64 has the same theoretical exposure but
|
||||||
|
happens not to trip it, apparently because its firmware's default boot stack
|
||||||
|
is larger — incidental, not a guarantee.
|
||||||
|
|
||||||
|
## Fix
|
||||||
|
|
||||||
|
**`src/starkernel/arch/riscv64/kernel_entry.S`** (new) — RISC-V stack-switch
|
||||||
|
trampoline mirroring amd64's:
|
||||||
|
|
||||||
|
```asm
|
||||||
|
.section .bss
|
||||||
|
.align 4
|
||||||
|
.global g_kernel_stack
|
||||||
|
g_kernel_stack:
|
||||||
|
.space 0x200000
|
||||||
|
g_kernel_stack_top:
|
||||||
|
.global g_kernel_stack_top
|
||||||
|
|
||||||
|
.section .text
|
||||||
|
.extern kernel_main_impl
|
||||||
|
.global kernel_main
|
||||||
|
kernel_main:
|
||||||
|
ld t0, BOOT_INFO_KERNEL_STACK_BASE_OFFSET(a0)
|
||||||
|
bnez t0, .Ldynamic_stack
|
||||||
|
la t0, g_kernel_stack_top
|
||||||
|
j .Lstack_ready
|
||||||
|
.Ldynamic_stack:
|
||||||
|
ld t1, BOOT_INFO_KERNEL_STACK_SIZE_OFFSET(a0)
|
||||||
|
add t0, t0, t1
|
||||||
|
.Lstack_ready:
|
||||||
|
andi t0, t0, -16
|
||||||
|
mv sp, t0
|
||||||
|
mv s0, zero
|
||||||
|
tail kernel_main_impl
|
||||||
|
```
|
||||||
|
|
||||||
|
**`src/starkernel/kernel_main.c`** — riscv64 now builds `kernel_main_impl`
|
||||||
|
(invoked via the trampoline) instead of `kernel_main` directly, same pattern
|
||||||
|
as amd64:
|
||||||
|
|
||||||
|
```c
|
||||||
|
#if defined(__x86_64__) || defined(__riscv)
|
||||||
|
void kernel_main_impl(BootInfo *boot_info) {
|
||||||
|
#else
|
||||||
|
void kernel_main(BootInfo *boot_info) {
|
||||||
|
#endif
|
||||||
|
```
|
||||||
|
|
||||||
|
**`Makefile.starkernel`** — wires the new file into the riscv64 loader build:
|
||||||
|
|
||||||
|
```makefile
|
||||||
|
ifeq ($(ARCH),riscv64)
|
||||||
|
LOADER_ASM += $(KERNEL_SRC)/arch/$(ARCH)/kernel_entry.S
|
||||||
|
endif
|
||||||
|
```
|
||||||
|
|
||||||
|
## Secondary Fix
|
||||||
|
|
||||||
|
`RAW_LOG()` in `src/starkernel/boot/uefi_loader.c` was a no-op on every arch
|
||||||
|
except amd64, silencing all of the loader's own diagnostic messages on
|
||||||
|
riscv64 (and aarch64). Added a real raw-UART writer for riscv64, targeting
|
||||||
|
QEMU virt's 16550-compatible `uart8250` at MMIO `0x10000000` (matches
|
||||||
|
`Domain0 Region03` in the OpenSBI boot banner) — the same byte-polling
|
||||||
|
protocol as the existing amd64 port-I/O version, just MMIO instead of `outb`/
|
||||||
|
`inb`. Existing and future `RAW_LOG()` call sites in the loader now actually
|
||||||
|
produce output on riscv64.
|
||||||
|
|
||||||
|
## Result
|
||||||
|
|
||||||
|
| Criterion | Before | After |
|
||||||
|
|---|---|---|
|
||||||
|
| `sepc` after word registration | Wild address outside kernel image (`0xbe27c3c2`-ish, drifting per build) | N/A — no longer occurs |
|
||||||
|
| Exception | Load or instruction-fetch page fault, non-deterministic between builds | None |
|
||||||
|
| `[Hera] ok>` (riscv64) | Never reached | ✅ reached, Tripod fleet (Hera/Hermes/Artemis) runs to completion |
|
||||||
|
| `PASS: E2E msg flow` | Never logged | ✅ |
|
||||||
|
| `PARITY:MAMA_INIT` / `Init: Mama birth OK` | Never logged | ✅ |
|
||||||
|
| Loader `RAW_LOG()` output on riscv64 | Silent no-op | ✅ visible in serial log |
|
||||||
|
|
||||||
|
## Acceptance Testing
|
||||||
|
|
||||||
|
All three architectures must boot to `ok>`, run the Tripod fleet, and reach
|
||||||
|
`[Hera] ok>`. Per repo convention, run in this exact order, one QEMU instance
|
||||||
|
at a time, always `clean` before `qemu`:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
make -f Makefile.starkernel ARCH=amd64 clean qemu
|
||||||
|
make -f Makefile.starkernel ARCH=aarch64 clean qemu
|
||||||
|
make -f Makefile.starkernel ARCH=riscv64 clean qemu
|
||||||
|
```
|
||||||
|
|
||||||
|
Verify each log:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
grep "ok>" logs/<session>/<arch>/qemu-<arch>-*.log
|
||||||
|
grep "PASS: E2E msg flow" logs/<session>/<arch>/qemu-<arch>-*.log
|
||||||
|
grep "PARITY:MAMA_INIT" logs/<session>/<arch>/qemu-<arch>-*.log
|
||||||
|
grep "EXCEPTION" logs/<session>/<arch>/qemu-<arch>-*.log # must be empty
|
||||||
|
```
|
||||||
|
|
||||||
|
All three architectures confirmed clean on 2026-08-02 (see
|
||||||
|
`logs/20260802-064050/riscv64/`, `logs/20260802-064424/amd64/`,
|
||||||
|
`logs/20260802-064656/aarch64/`, committed alongside this fix).
|
||||||
Reference in New Issue
Block a user