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:
parent
6c42ab3b1a
commit
1e52cb591b
2 changed files with 81 additions and 1 deletions
|
|
@ -1164,13 +1164,29 @@ EfiMain(EFI_HANDLE image, EFI_SYSTEM_TABLE *systab)
|
|||
// RBX carries the framebuffer address into kmain, and must never be 1 --
|
||||
// that value marks an application processor in entry64high. A physical
|
||||
// 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(
|
||||
"mov %0, %%cr3\n\t"
|
||||
"mov %1, %%rbx\n\t"
|
||||
"jmp *%2\n\t"
|
||||
:
|
||||
: "r"((UINT64)PML4_ADDR), "r"((UINT64)fb), "r"(entry)
|
||||
: "memory");
|
||||
: "rbx", "memory");
|
||||
|
||||
for (;;)
|
||||
__asm__ volatile("hlt");
|
||||
|
|
|
|||
|
|
@ -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 |
|
||||
| [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 |
|
||||
| [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 |
|
||||
| [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 |
|
||||
|
|
@ -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
|
||||
|
||||
Found when the build moved from GCC-in-WSL to clang-on-Windows. Neither is a
|
||||
|
|
|
|||
Loading…
Reference in a new issue