From 1e52cb591be71ec7b19382817b8082fd7d470879 Mon Sep 17 00:00:00 2001 From: Nexoniarz Date: Sat, 12 Sep 2026 18:44:24 +0200 Subject: [PATCH] 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 Claude-Session: https://claude.ai/code/session_01VQ7Qx8E1q1M1bbCq8QLHS2 --- boot/uefi/main.c | 18 +++++++++++- docs/history/kernel.md | 64 ++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 81 insertions(+), 1 deletion(-) diff --git a/boot/uefi/main.c b/boot/uefi/main.c index dea1987..a5eec96 100644 --- a/boot/uefi/main.c +++ b/boot/uefi/main.c @@ -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"); diff --git a/docs/history/kernel.md b/docs/history/kernel.md index 3d5c06a..1540288 100644 --- a/docs/history/kernel.md +++ b/docs/history/kernel.md @@ -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 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