boot: a framebuffer address is sixty-four bits, and there is more than one GPU

The first piece of real-hardware work, and it is three bugs that QEMU cannot
show you. The report was an ASUS laptop -- Ryzen 7000-series APU, Radeon
integrated, RTX discrete -- booting to a white underscore on a black screen,
and unable to be offered 1920x1080.

**The framebuffer address was truncated.** The loader handed over
`(UINT32)gop->Mode->FrameBufferBase` and the kernel read a 32-bit field out of
a VBE mode-info block -- a *BIOS* structure the UEFI path had been filling in
because the kernel already knew how to read one. A machine that puts its
aperture above 4 GB, which is ordinary on a laptop with a discrete GPU, got a
plausible-looking address pointing at nothing. The splash went into it, the
kernel was handed it, and the firmware's own cursor kept blinking on an
otherwise black screen. Every step in that sequence succeeded on a number that
was wrong, which is why there is no error message anywhere in it.

SCHBOOT_FB_BASE is the loader's own display block: 64-bit address, 64-bit
size, 32-bit width, height and stride, and the pixel format. The VBE block is
still written for the BIOS loader, but anything that does not fit is left at
zero rather than wrapped -- a truncated address is this bug, and a zero is at
least honest.

The kernel then has a problem the loader does not: its direct map covers the
low 4 GB, so a framebuffer above it needs ioremap(), which needs kpdpt, which
kvmalloc() has not allocated when the early console starts. A low framebuffer
is attached exactly as before; a high one is attached immediately after
kvmalloc(), still thousands of lines before anything else.

**There was more than one adapter, and the first one was taken.**
LocateProtocol answers with *a* GOP and the specification says nothing about
which. QEMU with a stdvga and a virtio-gpu has **three** handles and two of
them are Blt-only with a framebuffer address of zero -- so this is not a
hardware-only hazard, it was a one-in-three chance of drawing into address 0
that happened to keep coming up heads. Every handle is scored now: no
aperture, no candidacy; a readable current mode beats one that needs changing;
size breaks the tie.

**mode_usable() rejected modes real machines have.** A padded scanline (every
GPU that aligns its rows -- and very likely the reason 1920x1080 could not be
offered), a width not divisible by eight (1366x768, which is half the laptops
in the world), and anything below 800x600 (the only mode some firmware gives
over a KVM). All three passed on QEMU, which is why they survived. The mode
list now offers 1366x768 and 640x480 where it did not.

The loader also prints what it found, to the firmware's text console, which is
cleared before the splash: invisible on a boot that works, and the only thing
on the screen on a boot that dies before the splash.

One process note, recorded because it cost a wrong test: `make UEFI_KEEP=1`
ships the loader that is already built. Its own comment says "a build that
quietly ships a stale bootloader is how you spend an evening debugging code
that is not running", and that is exactly what happened -- a regression run
that looked clean had tested the old loader. Loader work gets a full build.

Both supported configurations verified after a clean build: one boot each, no
traps, no panics, socktest and memfdtest green, and the adapter report shows
the right one being chosen out of three.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Nexoniarz 2026-09-23 19:19:25 +02:00
commit 35ea398334
4 changed files with 358 additions and 24 deletions

View file

@ -68,6 +68,74 @@ a missing table.
**The E820 cap is 40 entries, not 32.** Real UEFI firmware produces far more
descriptors than QEMU's, and the space was already there.
### Choosing a display, on a machine that has more than one
`LocateProtocol` answers with *a* GOP, and the specification says nothing about
which. On QEMU with a stdvga and a virtio-gpu there are **three** handles, and
two of them report `PixelBltOnly` with a framebuffer address of zero:
```
schos: 3 display adapter(s)
gop: 1280x800 BGRX scanline 1280 fb 0x0000000080000000 size 0x3e8000
gop: 1280x800 Blt-only scanline 1280 fb 0x0000000000000000 size 0x0
gop: 1280x800 Blt-only scanline 1280 fb 0x0000000000000000 size 0x0
schos: display 1280x800 BGRX scanline 1280 fb 0x0000000080000000 size 0x3e8000
```
A laptop with switchable graphics is the same shape: an integrated adapter, a
discrete one, and often a console splitter over both. Taking the first handle
means a one-in-three chance of drawing into an adapter with no aperture — a
black screen with the firmware's own cursor still blinking on it, which looks
exactly like a loader that crashed.
So every handle is examined and scored: an adapter with no framebuffer address
is not a candidate at all, one whose current mode is readable beats one that
would need changing, and size breaks the tie. The report above is printed to
the firmware's text console, which is cleared a few lines later before the
splash — so on a boot that works nobody sees it, and on a boot that dies before
the splash it is the only thing on the screen.
### A framebuffer address is sixty-four bits
The loader used to hand over `(UINT32)gop->Mode->FrameBufferBase`, and the
kernel used to read a 32-bit field out of a *VBE mode-info block* — a BIOS
structure the UEFI path had been filling in because the kernel already knew how
to read one.
Any machine that puts its aperture above 4 GB — which is ordinary on a laptop
with a discrete GPU — got a truncated address that pointed at nothing. The
splash was drawn into it, the kernel was handed it, and the screen kept the
firmware's cursor. There is no error anywhere in that sequence; every step
succeeded on a number that was wrong.
`SCHBOOT_FB_BASE` is the fix: the loader's own display block, with a 64-bit
address, a 64-bit size, 32-bit width, height and stride, and the pixel format.
The VBE block is still written for the BIOS loader and for anything that has
not been taught to look at the new one, but anything that does not fit is left
at **zero** rather than wrapped — a truncated address is the bug above, and a
zero is at least honest about it.
The kernel then has a second problem the loader does not: it runs on its own
page tables, and its direct map covers the low 4 GB. A framebuffer above that
needs `ioremap()`, which needs `kpdpt`, which `kvmalloc()` has not allocated
when the early console is set up. So a low framebuffer is attached exactly as
before, and a high one is attached a few dozen instructions later, immediately
after `kvmalloc()` — still thousands of lines before anything else happens.
### Modes a real machine actually has
`mode_usable()` rejected three things it should not have:
| Was rejected | Why it was wrong |
| --- | --- |
| `PixelsPerScanLine != HorizontalResolution` | A GPU is entitled to pad each row, and 1920×1080 arriving with a 1984- or 2048-pixel scanline is ordinary. The stride is carried through to the kernel, so this excluded nothing but every machine that pads. |
| `HorizontalResolution % 8` | Excludes 1366×768, the panel on a very large number of laptops. |
| resolutions below 800×600 | 640×480 is a legitimate fallback and the only mode some firmware offers over a KVM. |
All three passed on QEMU, which is why they survived. None of them protected
anything: the drawing code steps by the stride and has done for as long as
there has been a stride to step by.
### The entry point
The kernel publishes a `sch64_header` — magic `SCH64BOO`, 8-byte aligned within