boot: the loader jumped into the framebuffer, because rbx was not clobbered

Boot died straight after "exiting boot services" with a KVM internal error,
RIP=0x801a5dc5 and RBX=0x80000000 -- the framebuffer base. The bytes at RIP were
0x0C0E13 and 0xD1D5DC: SCH_BG and a grey from the splash's metal gradient. The
machine was executing the picture of itself starting up.

The hand-off to the kernel writes rbx and did not say so:

    "mov %1, %%rbx\n\t"      <- writes rbx
    "jmp *%2\n\t"
    : : "r"(PML4_ADDR), "r"(fb), "r"(entry)
    : "memory");            <- rbx not declared clobbered

With rbx absent from the clobber list the compiler may allocate an input there,
and clang 21 allocated `entry`:

    movq %rax, %cr3
    movq %rcx, %rbx         <- the framebuffer, into rbx
    jmpq *%rbx              <- %2 was rbx, and now holds the framebuffer

Declaring "rbx" forbids the allocation. Verified in the generated assembly
rather than by reasoning: jmpq *%rbx became jmpq *%r14, and the machine now
boots through to schinit: ready.

This had been latent since the loader was written. It worked only because every
compiler that had built it happened to choose a different register, and it
surfaced the first time the binary was relinked with a different clang -- which
only happened because the previous commit fixed the Makefile dependency that had
been leaving the loader stale. A stale artifact hides the bug the fresh one
reveals.

It is also the third time this project has been broken by a register the code
depends on but never asks for: docs/history/kernel.md section 4 (the BIOS
loader passing the framebuffer in EAX by luck) and section 8 (%fs:0 used as
scratch when the ABI reserves it). Written up as section 7b, with the one-line
recipe that settles all three -- read the generated assembly.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VQ7Qx8E1q1M1bbCq8QLHS2
This commit is contained in:
Nexoniarz 2026-09-12 18:44:24 +02:00
commit 1e52cb591b
2 changed files with 81 additions and 1 deletions

View file

@ -1164,13 +1164,29 @@ EfiMain(EFI_HANDLE image, EFI_SYSTEM_TABLE *systab)
// RBX carries the framebuffer address into kmain, and must never be 1 -- // RBX carries the framebuffer address into kmain, and must never be 1 --
// that value marks an application processor in entry64high. A physical // that value marks an application processor in entry64high. A physical
// MMIO address or zero, so it cannot collide. // MMIO address or zero, so it cannot collide.
//
// "rbx" is in the clobber list because this writes it, and leaving it out
// is not a tidiness problem -- it is a jump to the wrong address. Without
// it the compiler may allocate an *input* into rbx, and it did: clang 21
// put `entry` there, so the second instruction overwrote the jump target
// with the framebuffer address and the third jumped into video memory. The
// machine then executed pixels until KVM gave up.
//
// movq %rax, %cr3 <- %0, the page tables
// movq %rcx, %rbx <- %1, the framebuffer, into rbx
// jmpq *%rbx <- %2 was rbx, and now holds the framebuffer
//
// It had worked for as long as the compiler happened to choose differently.
// That is the same failure as the BIOS loader's EAX hand-off in
// docs/history/kernel.md section 4: a register the code depends on, that
// nothing in the source actually asks for.
__asm__ volatile( __asm__ volatile(
"mov %0, %%cr3\n\t" "mov %0, %%cr3\n\t"
"mov %1, %%rbx\n\t" "mov %1, %%rbx\n\t"
"jmp *%2\n\t" "jmp *%2\n\t"
: :
: "r"((UINT64)PML4_ADDR), "r"((UINT64)fb), "r"(entry) : "r"((UINT64)PML4_ADDR), "r"((UINT64)fb), "r"(entry)
: "memory"); : "rbx", "memory");
for (;;) for (;;)
__asm__ volatile("hlt"); __asm__ volatile("hlt");

View file

@ -15,6 +15,7 @@ only how it got that way.
| [5](#5-at-syscalls-ignoredrejected-real-dirfds) | `*at()` ignored real dirfds, and `ls -l` hung | | [5](#5-at-syscalls-ignoredrejected-real-dirfds) | `*at()` ignored real dirfds, and `ls -l` hung |
| [6](#6-boot-speed) | Boot speed, in the loader | | [6](#6-boot-speed) | Boot speed, in the loader |
| [7](#7-at-syscalls-real-signal-delivery-and-mksh-job-control) | Real signal delivery, and mksh's job control | | [7](#7-at-syscalls-real-signal-delivery-and-mksh-job-control) | Real signal delivery, and mksh's job control |
| [7b](#7b-the-loader-jumped-into-the-framebuffer-and-executed-pixels) | The loader jumped into the framebuffer and executed pixels |
| [8](#8-two-abi-assumptions-that-only-gcc-was-keeping-true) | Two ABI assumptions only GCC was keeping true | | [8](#8-two-abi-assumptions-that-only-gcc-was-keeping-true) | Two ABI assumptions only GCC was keeping true |
| [9](#9-the-boot-looked-like-a-hobby-os-and-two-things-were-why) | The boot looked like a hobby OS | | [9](#9-the-boot-looked-like-a-hobby-os-and-two-things-were-why) | The boot looked like a hobby OS |
| [10](#10-a-full-screen-program-typed-the-whole-session-into-the-shell) | A full-screen program typed the session into the shell | | [10](#10-a-full-screen-program-typed-the-whole-session-into-the-shell) | A full-screen program typed the session into the shell |
@ -444,6 +445,69 @@ signal-delivery mechanism, which is now confirmed solid.
--- ---
## 7b. The loader jumped into the framebuffer and executed pixels
The same bug as section 4, in the other loader, found five years later by a
compiler upgrade. Worth its own entry because it is the *third* time a register
the code depends on but never asks for has broken this project.
**Symptom.** Boot died immediately after `schos: exiting boot services` with a
KVM internal error and a register dump:
RIP=00000000801a5dc5 RBX=0000000080000000
Code=13 0e 0c 00 13 0e 0c 00 ... dc d5 d1 00 dc <d5> d1 00
`0x80000000` is the framebuffer. `0x0C0E13` is `SCH_BG` and `0xD1D5DC` is a grey
from the splash's metal gradient. So `RIP` was *inside video memory* and the
bytes being executed were the boot logo. The machine was executing the picture
of itself starting up.
**Cause.** `boot/uefi/main.c`'s hand-off to the kernel:
"mov %0, %%cr3\n\t"
"mov %1, %%rbx\n\t" <- writes rbx
"jmp *%2\n\t"
: : "r"(PML4_ADDR), "r"(fb), "r"(entry)
: "memory"); <- rbx NOT declared clobbered
Nothing told the compiler rbx was written, so it was free to allocate an
*input* there. clang 21 allocated `entry`:
movq %rax, %cr3
movq %rcx, %rbx <- the framebuffer, into rbx
jmpq *%rbx <- %2 was rbx; it now holds the framebuffer
The fix is `"rbx"` in the clobber list, which forbids the allocation. Confirmed
by reading the generated assembly before and after -- `jmpq *%rbx` became
`jmpq *%r14`.
**Why it appeared now.** It did not. It had been latent since the loader was
written and worked only because every compiler that had built it happened to
choose a different register. It surfaced the first time the binary was relinked
with a different clang.
That is the whole lesson, and it is identical to section 4's: **a register the
code depends on, that nothing in the source actually asks for, is not a
dependency -- it is a coincidence with a long half-life.** Section 4 was the
BIOS loader passing the framebuffer in EAX by luck; section 8 was `%fs:0` used
as scratch when the ABI reserves it; this is an asm block writing a register it
did not declare.
Reading the *generated* assembly is what settles all three, and it takes a
minute:
clang $(UEFI_CFLAGS) -S -o - boot/uefi/main.c | grep -B3 'jmpq.*\*'
**A second failure sat underneath it and is worth its own note.** The loader had
been stale for half a day -- the Makefile rule for `BOOTX64.EFI` did not list
`logo.h`, `theme.h` or `font.h`, all of which `boot/common/ui.c` includes, so a
new logo never rebuilt it. The splash kept drawing an old mask while `make`
reported nothing to do. Fixing that dependency is what caused the relink, which
is what exposed the register bug. A stale artifact hides the bug that the fresh
one reveals.
---
## 8. Two ABI assumptions that only GCC was keeping true ## 8. Two ABI assumptions that only GCC was keeping true
Found when the build moved from GCC-in-WSL to clang-on-Windows. Neither is a Found when the build moved from GCC-in-WSL to clang-on-Windows. Neither is a