APIC Timer ISR Fix — amd64
Date: 2026-06-03
Branch: claude/starforth-architecture-review-oubN0
Commits: fb0c256, 901f827
Problem
The LithosAnanke amd64 kernel was triple-faulting on the first APIC timer interrupt,
causing QEMU to exit immediately after printing ok>. The adaptive heartbeat never
fired and Heartbeat: N ticks never appeared in serial logs.
Root Cause
The kernel is compiled with -fPIC -fvisibility=hidden (required for UEFI PE
relocation). Assembly symbols isr_stub_table and isr_stub0 in isr.S were
declared .global with default visibility. The C compiler therefore accessed
them through GOT indirection. However, the linker — building a final non-shared
binary — collapsed the GOT references to point directly at the symbols themselves.
The net effect: "load from GOT[isr_stub_table]" loaded the 8-byte instruction
bytes of isr_stub0 (0x006a90eb006a006a) rather than its address. Both
link_time_addr and runtime_addr in arch_interrupts_init() became the same
wrong value (instruction bytes), so reloc_offset = 0 and all 256 IDT entries
received garbage handler addresses. IDT[32] was set to 0x6a006afffffe91e9
(non-canonical), which triggered #GP → double fault → triple fault on the first
timer delivery.
The bug was confirmed by disassembling arch_interrupts_init() in the loader PE:
the compiler emitted a mov (load from memory) for &isr_stub0 instead of a
lea (compute address), reading the function's instruction bytes.
Fix
Two changes, one line each:
src/starkernel/arch/amd64/isr.S — Added .hidden directives for all symbols
referenced from C:
.hidden isr_stub_table
.hidden isr_stub0
.hidden isr_stub32
.hidden isr_stub_default
.hidden isr_common_entry
src/starkernel/arch/amd64/interrupts.c — Added visibility attributes to the
extern declarations:
extern void *isr_stub_table[IDT_ENTRIES] __attribute__((visibility("hidden")));
extern void isr_stub0(void) __attribute__((visibility("hidden")));
With hidden visibility, the compiler uses direct RIP-relative lea instead of GOT
indirection, yielding correct runtime addresses for all 256 IDT entries.
Result
| Criterion | Before | After |
|---|---|---|
| IDT[32] handler | 0x6a006afffffe91e9 (corrupt) |
0x3d90251c (valid canonical address) |
| First timer interrupt | #GP → triple fault → QEMU exit | ISR fires cleanly, returns via iretq |
PARITY:OK |
✅ (unaffected) | ✅ |
Init: init.4th OK |
✅ (unaffected) | ✅ |
ok> REPL |
✅ reached, then immediate crash | ✅ runs indefinitely |
Heartbeat: N ticks |
Never | Every second at 100 Hz, TIME-TRUST ≈ 0.999 |
Secondary Fix
repl_print_dec32, repl_lapic_ccr, repl_print_hex32, and idle_iters in
repl.c are x86-64 specific (LAPIC MMIO, pushfq). They were unguarded, causing
-Werror=unused-function / unused-variable failures on aarch64 (clang) and
riscv64 (gcc). All moved inside #ifdef ARCH_AMD64.
Acceptance Testing
All three architectures must boot to ok> with PARITY:OK and Init: init.4th OK.
amd64 additionally shows Heartbeat: N ticks TIME-TRUST≈0.999 repeating at 1 Hz.
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:
grep "PARITY:OK" logs/qemu-<arch>-*.log
grep "Init: init.4th OK" logs/qemu-<arch>-*.log
grep "ok>" logs/qemu-<arch>-*.log
grep "Heartbeat:" logs/qemu-amd64-*.log | head -5