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:
parent
c84d06c5b1
commit
35ea398334
4 changed files with 358 additions and 24 deletions
205
boot/uefi/main.c
205
boot/uefi/main.c
|
|
@ -205,6 +205,21 @@ print(const CHAR16 *s)
|
|||
ST->ConOut->OutputString(ST->ConOut, (CHAR16 *)s);
|
||||
}
|
||||
|
||||
// A number in the base a person reads sizes in. `u64_to_dec` is further down
|
||||
// and works on char; this is the CHAR16 the firmware console wants.
|
||||
static void
|
||||
print_dec(UINT64 v)
|
||||
{
|
||||
CHAR16 tmp[24], out[25];
|
||||
int n = 0, i = 0;
|
||||
|
||||
if (v == 0) tmp[n++] = L'0';
|
||||
while (v && n < 23) { tmp[n++] = (CHAR16)(L'0' + (v % 10)); v /= 10; }
|
||||
while (n > 0) out[i++] = tmp[--n];
|
||||
out[i] = 0;
|
||||
print(out);
|
||||
}
|
||||
|
||||
static void
|
||||
print_hex(UINT64 v)
|
||||
{
|
||||
|
|
@ -618,10 +633,26 @@ static int
|
|||
mode_usable(EFI_GRAPHICS_OUTPUT_MODE_INFORMATION *mi)
|
||||
{
|
||||
if (!mi) return 0;
|
||||
|
||||
// Formats 0 and 1 are the two with a linear 32-bit layout. 2 is a bitmask
|
||||
// format nobody ships and 3 is Blt-only, which has no aperture at all --
|
||||
// see the virtio GOP, and the reboot loop it once caused.
|
||||
if (mi->PixelFormat > 1) return 0;
|
||||
if (mi->HorizontalResolution < 800 || mi->VerticalResolution < 600) return 0;
|
||||
if (mi->HorizontalResolution % 8) return 0;
|
||||
if (mi->PixelsPerScanLine != mi->HorizontalResolution) return 0;
|
||||
if (mi->HorizontalResolution < 640 || mi->VerticalResolution < 480) return 0;
|
||||
|
||||
// A padded row is not a broken mode. This used to require
|
||||
// PixelsPerScanLine == HorizontalResolution, which is true of QEMU and
|
||||
// routinely false of real hardware: a GPU is entitled to align each row,
|
||||
// and 1920x1080 arriving with a scanline of 1984 or 2048 pixels is
|
||||
// ordinary. The stride is carried through to the kernel, so the only
|
||||
// thing this rejected was every machine that padded -- including,
|
||||
// probably, the laptop that could not be offered 1920x1080.
|
||||
if (mi->PixelsPerScanLine < mi->HorizontalResolution) return 0;
|
||||
|
||||
// The width-divisible-by-eight rule went with it. It excluded 1366x768,
|
||||
// which is the panel on a very large number of laptops, and protected
|
||||
// nothing: the drawing code steps by the stride and has done for as long
|
||||
// as there has been a stride to step by.
|
||||
return 1;
|
||||
}
|
||||
|
||||
|
|
@ -1211,6 +1242,112 @@ mirrors_show(void)
|
|||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Choosing a display, and saying what was chosen
|
||||
//
|
||||
// LocateProtocol answers with *a* GOP. On a laptop with switchable graphics
|
||||
// there are several -- the integrated adapter, the discrete one, sometimes a
|
||||
// console splitter on top of both -- and which one comes back first is not
|
||||
// something the specification says anything about. Drawing into the wrong one
|
||||
// is a black screen with the firmware's cursor still blinking on it, which is
|
||||
// indistinguishable from a loader that crashed.
|
||||
//
|
||||
// So all of them are looked at, and the choice is made on what they report
|
||||
// rather than on the order they were handed over.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
static const CHAR16 *
|
||||
fmt_name(UINT32 f)
|
||||
{
|
||||
switch (f) {
|
||||
case 0: return L"RGBX";
|
||||
case 1: return L"BGRX";
|
||||
case 2: return L"bitmask";
|
||||
default: return L"Blt-only";
|
||||
}
|
||||
}
|
||||
|
||||
static void
|
||||
describe_gop(EFI_GRAPHICS_OUTPUT_PROTOCOL *g, const CHAR16 *tag)
|
||||
{
|
||||
EFI_GRAPHICS_OUTPUT_MODE_INFORMATION *mi = g->Mode ? g->Mode->Info : 0;
|
||||
|
||||
print(tag);
|
||||
if (!mi) { print(L" (no mode information)\r\n"); return; }
|
||||
|
||||
print(L" "); print_dec(mi->HorizontalResolution);
|
||||
print(L"x"); print_dec(mi->VerticalResolution);
|
||||
print(L" "); print(fmt_name(mi->PixelFormat));
|
||||
print(L" scanline "); print_dec(mi->PixelsPerScanLine);
|
||||
print(L" fb "); print_hex(g->Mode->FrameBufferBase);
|
||||
print(L" size "); print_hex(g->Mode->FrameBufferSize);
|
||||
print(L"\r\n");
|
||||
}
|
||||
|
||||
// Better, for this purpose, means: it has an aperture we can write into, its
|
||||
// current mode is one we can read, and failing a tie, it is bigger. A GOP with
|
||||
// no framebuffer address is not a candidate at all -- the kernel needs a
|
||||
// linear aperture and cannot Blt.
|
||||
static int
|
||||
gop_score(EFI_GRAPHICS_OUTPUT_PROTOCOL *g)
|
||||
{
|
||||
EFI_GRAPHICS_OUTPUT_MODE_INFORMATION *mi = g->Mode ? g->Mode->Info : 0;
|
||||
|
||||
if (!mi || !g->Mode->FrameBufferBase || !g->Mode->FrameBufferSize)
|
||||
return 0;
|
||||
if (!mode_usable(mi))
|
||||
return 1; /* has memory; mode needs changing */
|
||||
return 2;
|
||||
}
|
||||
|
||||
static EFI_GRAPHICS_OUTPUT_PROTOCOL *
|
||||
pick_gop(EFI_GRAPHICS_OUTPUT_PROTOCOL *first)
|
||||
{
|
||||
EFI_GUID gop_guid = EFI_GRAPHICS_OUTPUT_PROTOCOL_GUID;
|
||||
EFI_GRAPHICS_OUTPUT_PROTOCOL *best = first;
|
||||
EFI_HANDLE *handles = 0;
|
||||
UINTN count = 0, i;
|
||||
int best_score = first ? gop_score(first) : 0;
|
||||
UINT64 best_area = 0;
|
||||
|
||||
if (first && first->Mode && first->Mode->Info)
|
||||
best_area = (UINT64)first->Mode->Info->HorizontalResolution *
|
||||
first->Mode->Info->VerticalResolution;
|
||||
|
||||
if (!BS->LocateHandleBuffer ||
|
||||
EFI_ERROR(BS->LocateHandleBuffer(ByProtocol, &gop_guid, 0,
|
||||
&count, &handles)) || !handles)
|
||||
return best;
|
||||
|
||||
print(L"schos: "); print_dec((UINT32)count); print(L" display adapter(s)\r\n");
|
||||
|
||||
for (i = 0; i < count; i++) {
|
||||
EFI_GRAPHICS_OUTPUT_PROTOCOL *g = 0;
|
||||
int score;
|
||||
UINT64 area;
|
||||
|
||||
if (EFI_ERROR(BS->HandleProtocol(handles[i], &gop_guid, (void **)&g)) ||
|
||||
!g || !g->Mode)
|
||||
continue;
|
||||
|
||||
describe_gop(g, L" gop:");
|
||||
|
||||
score = gop_score(g);
|
||||
area = g->Mode->Info
|
||||
? (UINT64)g->Mode->Info->HorizontalResolution *
|
||||
g->Mode->Info->VerticalResolution
|
||||
: 0;
|
||||
|
||||
if (score > best_score || (score == best_score && area > best_area)) {
|
||||
best = g;
|
||||
best_score = score;
|
||||
best_area = area;
|
||||
}
|
||||
}
|
||||
|
||||
return best;
|
||||
}
|
||||
|
||||
/* Wait briefly for F8, exactly as the BIOS loader does, and show the same
|
||||
* menu if it comes. One prompt, one key, one screen -- the resolution chooser
|
||||
* lives in that menu rather than in a second prompt of its own.
|
||||
|
|
@ -1250,6 +1387,24 @@ setup_framebuffer(void)
|
|||
return 0;
|
||||
}
|
||||
|
||||
// Which adapter, on a machine that has more than one. Everything this
|
||||
// prints goes to the firmware's text console, which is cleared a few lines
|
||||
// below before the splash is drawn -- so on a boot that works nobody ever
|
||||
// sees it, and on a boot that dies before the splash it is the only thing
|
||||
// on the screen.
|
||||
gop = pick_gop(gop);
|
||||
describe_gop(gop, L"schos: display");
|
||||
|
||||
// No aperture anywhere means no graphical console, and saying so beats
|
||||
// leaving the firmware's cursor blinking on a black screen while the
|
||||
// kernel starts behind it.
|
||||
if (!gop->Mode || !gop->Mode->FrameBufferBase || !gop->Mode->FrameBufferSize) {
|
||||
print(L"schos: this display has no linear framebuffer, only Blt.\r\n"
|
||||
L" The kernel needs an aperture it can write to, so the\r\n"
|
||||
L" console will be serial-only. Boot continues.\r\n");
|
||||
return 0;
|
||||
}
|
||||
|
||||
// The other displays, before anything is drawn: the F8 menu is the first
|
||||
// thing on screen and there is no reason for it to be the one screen that
|
||||
// does not reach them.
|
||||
|
|
@ -1263,32 +1418,54 @@ setup_framebuffer(void)
|
|||
// Re-read after any mode change: Info and FrameBufferBase both describe the mode
|
||||
// that is current, and the framebuffer can move when the mode changes.
|
||||
EFI_GRAPHICS_OUTPUT_MODE_INFORMATION *info = gop->Mode->Info;
|
||||
UINT32 fb = (UINT32)gop->Mode->FrameBufferBase;
|
||||
|
||||
// The layout kernel/dev/vga.c reads, byte for byte. It is a VBE mode-info
|
||||
// block as the BIOS path leaves it, and the kernel takes exactly four
|
||||
// fields out of it -- so those four are what get written, at the offsets
|
||||
// it uses.
|
||||
// **Sixty-four bits, and not truncated.** `(UINT32)FrameBufferBase` is
|
||||
// what this was, and on a machine whose aperture sits above 4 GB -- any
|
||||
// laptop with a discrete GPU -- it produced a plausible-looking address
|
||||
// pointing at nothing. The splash went into it, the kernel was handed it,
|
||||
// and the screen kept the firmware's own text cursor blinking on black.
|
||||
UINT64 fb = gop->Mode->FrameBufferBase;
|
||||
UINT64 fbsz = gop->Mode->FrameBufferSize;
|
||||
UINT32 bpp = 32; // GOP's 8-bit formats are 32bpp
|
||||
UINT32 pitch = info->PixelsPerScanLine * (bpp / 8);
|
||||
|
||||
// The display block, which can describe a real machine. See
|
||||
// SCHBOOT_FB_BASE for what the VBE one cannot say.
|
||||
*(UINT64 *)SCHBOOT_FB_BASE = fb;
|
||||
*(UINT64 *)SCHBOOT_FB_SIZE = fbsz;
|
||||
*(UINT32 *)SCHBOOT_FB_WIDTH = info->HorizontalResolution;
|
||||
*(UINT32 *)SCHBOOT_FB_HEIGHT = info->VerticalResolution;
|
||||
*(UINT32 *)SCHBOOT_FB_STRIDE = pitch;
|
||||
*(UINT32 *)SCHBOOT_FB_FORMAT = info->PixelFormat;
|
||||
|
||||
// The VBE block as well, byte for byte as the BIOS path leaves it: it is
|
||||
// what the kernel read before this block existed, and what the BIOS loader
|
||||
// still writes. Anything that does not fit is left at zero rather than
|
||||
// wrapped -- a truncated address in here is exactly the bug above, and a
|
||||
// zero is at least honest.
|
||||
UINT8 *vbe = (UINT8 *)SCHBOOT_VBE_INFO;
|
||||
zero(vbe, 512);
|
||||
|
||||
UINT32 bpp = 32; // GOP's 8-bit formats are 32bpp
|
||||
UINT16 stride = (UINT16)(info->PixelsPerScanLine * (bpp / 8));
|
||||
|
||||
*(UINT16 *)(vbe + 16) = stride; // bytes/line
|
||||
if (pitch <= 0xFFFF)
|
||||
*(UINT16 *)(vbe + 16) = (UINT16)pitch; // bytes/line
|
||||
*(UINT16 *)(vbe + 18) = (UINT16)info->HorizontalResolution;
|
||||
*(UINT16 *)(vbe + 20) = (UINT16)info->VerticalResolution;
|
||||
*(UINT8 *)(vbe + 25) = (UINT8)bpp;
|
||||
*(UINT32 *)(vbe + 40) = fb;
|
||||
if (fb <= 0xFFFFFFFFULL)
|
||||
*(UINT32 *)(vbe + 40) = (UINT32)fb;
|
||||
|
||||
// The same UI the BIOS loader draws, on the framebuffer this one just
|
||||
// obtained. Everything above this point is firmware-specific; nothing below
|
||||
// it is, which is the whole reason it can be shared.
|
||||
//
|
||||
// UINTN, so the pointer is the whole address. The loader runs with the
|
||||
// firmware's identity map, so a physical address above 4 GB is directly
|
||||
// usable here -- it is the *kernel* that needs an ioremap for one.
|
||||
struct ui_fb uifb;
|
||||
uifb.base = (ui_u8 *)(UINTN)fb;
|
||||
uifb.width = info->HorizontalResolution;
|
||||
uifb.height = info->VerticalResolution;
|
||||
uifb.stride = stride;
|
||||
uifb.stride = pitch;
|
||||
// Tell the firmware its console is empty before drawing over it. ConOut
|
||||
// keeps its own idea of what is on screen and repaints that region when it
|
||||
// feels like it -- a screenshot showed a black band across the top third,
|
||||
|
|
|
|||
Loading…
Reference in a new issue