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

@ -33,6 +33,12 @@ int boot_verbose;
int
kmain(uint32 fb_addr)
{
// Carried out of the block below, for the framebuffer that could not be
// attached there because it needs page tables that do not exist yet.
struct fb_info fb_info_early;
uint64 fb_early = 0;
int fb_early_ok = 0, fb_attached = 0;
boot_verbose = *(uchar*)P2V(SCHBOOT_VERBOSE_FLAG);
// On a verbose boot, put the kernel log on the screen from the first line.
@ -74,16 +80,39 @@ kmain(uint32 fb_addr)
// real-mode IVT, the BIOS data area, the AP trampoline at 0x7000 and the
// early page tables. The machine reset before printing anything and came
// back round to the firmware, for ever.
uint32 fb_phys = *(uint32*)P2V(SCHBOOT_VBE_LFB);
bi.ptr = (volatile uchar *)P2V((uint64)fb_phys);
bi.width = *(uint16*)P2V(SCHBOOT_VBE_WIDTH);
bi.height = *(uint16*)P2V(SCHBOOT_VBE_HEIGHT);
bi.stride = *(uint16*)P2V(SCHBOOT_VBE_STRIDE);
bi.bpp = *(uchar *)P2V(SCHBOOT_VBE_BPP);
// Only if the loader actually described something. A zero here means the
// BIOS path or a firmware that gave no GOP, and drawing into address zero
// is a worse failure than staying quiet.
if (fb_phys && bi.width && bi.height && bi.stride && bi.bpp >= 16) {
// The UEFI loader's own display block if it wrote one, and the VBE block
// otherwise. See SCHBOOT_FB_BASE: the VBE structure is a BIOS one and its
// framebuffer address is 32 bits, which is not enough to describe a
// machine whose aperture is above 4 GB.
uint64 fb_phys = *(uint64*)P2V(SCHBOOT_FB_BASE);
if (fb_phys) {
bi.width = *(uint32*)P2V(SCHBOOT_FB_WIDTH);
bi.height = *(uint32*)P2V(SCHBOOT_FB_HEIGHT);
bi.stride = *(uint32*)P2V(SCHBOOT_FB_STRIDE);
bi.bpp = 32;
} else {
fb_phys = *(uint32*)P2V(SCHBOOT_VBE_LFB);
bi.width = *(uint16*)P2V(SCHBOOT_VBE_WIDTH);
bi.height = *(uint16*)P2V(SCHBOOT_VBE_HEIGHT);
bi.stride = *(uint16*)P2V(SCHBOOT_VBE_STRIDE);
bi.bpp = *(uchar *)P2V(SCHBOOT_VBE_BPP);
}
bi.ptr = (volatile uchar *)P2V(fb_phys);
// Only if the loader actually described something, and only if the direct
// map reaches it. A framebuffer above 4 GB needs ioremap(), and ioremap()
// needs kpdpt, which kvmalloc() has not allocated yet -- so that case is
// picked up further down, a few dozen instructions later, rather than
// here. Everything else about the early console is unchanged.
//
// A zero here means the BIOS path or a firmware that gave no GOP, and
// drawing into address zero is a worse failure than staying quiet.
fb_early = fb_phys;
fb_early_ok = fb_phys && bi.width && bi.height && bi.stride && bi.bpp >= 16;
if (fb_early_ok && fb_phys + (uint64)bi.stride * bi.height <= 4ULL << 30) {
fb_attached = 1;
gfx_attach_scanout(&bi);
// Before vt_init, which reads the cell size to work out how many rows
// and columns there are. The font is in the kernel image, so this needs
@ -92,6 +121,7 @@ kmain(uint32 fb_addr)
gfx_clear(0x0C0E13); /* SCH_BG; theme.h is not included here */
cons_serial_only = 0;
}
fb_info_early = bi;
}
uartearlyinit();
@ -127,6 +157,32 @@ kmain(uint32 fb_addr)
if (boot_verbose) cprintf("verbose boot: dma_init done\n");
kvmalloc();
if (boot_verbose) cprintf("verbose boot: kvmalloc done\n");
// The other kind of framebuffer: one the direct map does not reach.
//
// Any machine with a discrete GPU is entitled to put its aperture above
// 4 GB, and plenty do. P2V() cannot describe it, so the early block above
// deliberately left it alone and it is mapped here instead -- the first
// point in the boot where there is a page table to map it into. The console
// starts a few lines later than it would on a low framebuffer, which is
// still thousands of lines earlier than anything else.
if (fb_early_ok && !fb_attached) {
uint64 bytes = (uint64)fb_info_early.stride * fb_info_early.height;
void *va = ioremap(fb_early, bytes);
if (va) {
fb_info_early.ptr = (volatile uchar *)va;
gfx_attach_scanout(&fb_info_early);
vt_init();
gfx_clear(0x0C0E13); /* SCH_BG */
cons_serial_only = 0;
kinfo("gfx: framebuffer at %p is above the direct map; mapped at %p\n",
(void *)fb_early, va);
} else {
cprintf("gfx: cannot map the framebuffer at %p; no graphical console\n",
(void *)fb_early);
}
}
// ACPI first, the legacy MP table only if it finds nothing. Modern firmware
// publishes a complete MADT and an MP table with one processor in it, so
// asking the older interface first means never seeing more than one CPU.