gfx: the second head shows the boot, not just what comes after it

Two things, both asked for.

**The crash report no longer says which monitors it reached.** "The report is
on the virtio-gpu display too" was a line about the crash screen printed on the
crash screen. The report says what happened and where to read it again; that is
not part of it.

**And the second head now shows the F8 menu and the boot.** It used to be black
until vgpud started several seconds into userspace -- through the splash,
through the menu, through every boot message -- which from the outside is
indistinguishable from a display that does not work. Three stages now cover the
whole boot and hand over to each other without coordinating:

*The loader.* LocateProtocol() answers with the first GOP and offers no way to
ask for the rest, so the rest are found by handle and fed the primary's
framebuffer as a Blt source at the points where a screen is finished: the menu,
the resolution chooser, system information, the splash, each segment of the
kernel load. Blt rather than a second framebuffer to draw into, because a
virtio GOP does not have one -- the same fact that made a virtio-only machine
reboot-loop two commits ago.

*The kernel.* gfx remembers which rows were drawn, as one span rather than a
list of rectangles, because console output is lines and a span is what lines
make. display_mirror_console() copies that span across: a line of boot text is
one cell-row transfer, not a screen. Primed once after pci_init(), because a
display that has just appeared has a black scanout and the console it should be
showing was drawn by the loader into a framebuffer it knows nothing about.

*Then vgpud*, from the same console framebuffer, unchanged.

**Two bugs found building it, both worth the retelling.**

The mirror ran per character from inside vt_feed -- which is inside the console
lock -- and vga_disp_acquire() announces its back buffer with a cprintf. A CPU
taking a lock it already holds, with a call chain that said exactly that. It
runs at the end of consolewrite() and cprintf() now, outside the lock, which is
also the better batching: one transfer per write instead of one per character.

And "is this the console's own screen" was asked of the pointer acquire()
returned. vga_fb hands back a back buffer it copies onto the console's
framebuffer afterwards, so on a one-screen machine the answer was "a different
screen" -- and the console was mirrored onto itself through two megabytes of
intermediate, allocated for the purpose. display_ops grew scanout_phys, which
is a fact about the hardware rather than a policy a driver can get subtly
wrong, and it is asked first: it has no side effects and acquire() has two.

Verified with two heads under QEMU, screendumping the virtio head every 400 ms
from power-on: splash, splash, splash, then the console, and it stays. The F8
menu appears on it; choosing 1024x768 there brings both heads up at 1024x768
byte-identical. A panic still reaches both. Both supported configurations: one
boot each, no traps, no panics, socktest and memfdtest green.

One gap remains and is documented rather than hidden: while the kernel's driver
resets the device during probe, QEMU shows "Display output is not active" for a
fraction of a second. A device being taken over has no scanout until the new
driver sets one.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Nexoniarz 2026-09-22 21:47:35 +02:00
commit 182cc76588
14 changed files with 437 additions and 42 deletions

View file

@ -170,6 +170,13 @@ cprintf(char *fmt, ...)
if (locking)
release(&cons.lock);
va_end(ap);
// The kernel's own output reaches the second display too, and for the same
// reason it is done here rather than in consputc(): outside the lock. On a
// quiet boot almost nothing comes through this path -- kinfo() goes to the
// log -- so this is mostly for a verbose boot, where it is the whole point.
if (locking)
display_mirror_console();
}
// A line for the log, and for the screen only on a verbose boot.
@ -753,6 +760,19 @@ consolewrite(struct inode *ip, uint64 off, char *buf, int n)
for(i = 0; i < n; i++)
vt_out(v, buf[i] & 0xff);
release(&cons.lock);
// And onto a second display, if the kernel drives one.
//
// **After the lock, not inside it.** A display driver may have something to
// say -- vga_disp_acquire() announces its back buffer, the virtio driver
// complains when the device does not answer -- and cprintf() from inside
// this lock is a CPU acquiring a lock it already holds, which is a panic
// with a very confusing call chain. Found exactly that way.
//
// Once per write() rather than once per character, which is also the better
// batching: a line of output is one transfer instead of eighty.
display_mirror_console();
ilock(ip);
return n;

View file

@ -193,6 +193,18 @@ kmain(uint32 fb_addr)
pciuser_init();
pci_init();
if (boot_verbose) cprintf("verbose boot: pci_init done\n");
// A second display has just appeared, with a black scanout of its own, and
// the console it ought to be showing was drawn before it existed -- by the
// loader, into a framebuffer this device knows nothing about. So it is
// caught up here, once, rather than waiting for the next line of output:
// on a quiet boot that is most of a second of one screen showing the splash
// and the other showing nothing.
//
// After pci_init() rather than inside device_add(), because a driver's
// attached() hook -- which is what tells the instance apart from its
// siblings -- does not run until the device has been published.
display_mirror_console();
pinit();
if (boot_verbose) cprintf("verbose boot: pinit done\n");
binit();

View file

@ -18,6 +18,7 @@
#include "netdev.h"
#include "theme.h"
#include "text.h"
#include "spinlock.h"
#include "gfx.h"
#include "pci.h"
#include "mmu.h"
@ -181,6 +182,18 @@ display_present(int x, int y, int w, int h)
((struct display_ops *)d->ops)->present(d, x, y, w, h);
}
// Where this display's pixels actually end up. 0 when the driver does not say,
// which callers must read as "assume it is the console's" -- see the comment
// on scanout_phys in include/display.h.
uint64
display_scanout_phys(void)
{
struct device *d = display_get();
if (!d || !d->ops || !((struct display_ops *)d->ops)->scanout_phys)
return 0;
return ((struct display_ops *)d->ops)->scanout_phys(d);
}
void
display_wait_vblank(void)
{
@ -219,8 +232,14 @@ display_wait_vblank(void)
// mirror uses: the overlap, centred if the surface is larger, top-left if it
// is smaller. Both sides are XRGB8888; anything else is refused by whoever
// registered it.
static void
panic_copy_console(volatile uchar *dst, int dw, int dh, uint32 dstride)
// Copy rows [r0, r1] of the console framebuffer into a surface, with the same
// rules vgpud's mirror uses: the overlap, centred if the surface is larger,
// top-left if it is smaller. Both sides are XRGB8888; anything else is refused
// by whoever registered it. Returns the destination row span it wrote, so the
// caller can present exactly that.
static int
copy_console_rows(volatile uchar *dst, int dw, int dh, uint32 dstride,
int r0, int r1, int *out_y, int *out_h)
{
const volatile uchar *src = gfx_fb_ptr();
int sw = (int)gfx_width(), sh = (int)gfx_height();
@ -229,15 +248,130 @@ panic_copy_console(volatile uchar *dst, int dw, int dh, uint32 dstride)
int ch = sh < dh ? sh : dh;
int x0 = (dw - cw) / 2, y0 = (dh - ch) / 2;
if (!src || gfx_fb_bpp() != 32)
return;
if (!src || gfx_fb_bpp() != 32 || cw <= 0 || ch <= 0)
return 0;
if (r0 < 0) r0 = 0;
if (r1 > ch - 1) r1 = ch - 1;
if (r1 < r0)
return 0;
for (int y = 0; y < ch; y++) {
for (int y = r0; y <= r1; y++) {
const volatile uint32 *s = (const volatile uint32 *)(src + (uint64)y * sstride);
volatile uint32 *d = (volatile uint32 *)(dst + (uint64)(y0 + y) * dstride);
for (int x = 0; x < cw; x++)
d[x0 + x] = s[x];
}
*out_y = y0 + r0;
*out_h = r1 - r0 + 1;
return 1;
}
static void
panic_copy_console(volatile uchar *dst, int dw, int dh, uint32 dstride)
{
int y, h;
copy_console_rows(dst, dw, dh, dstride, 0, dh, &y, &h);
}
// --- the console, on a second screen ---------------------------------------
//
// A machine with two displays shows the console on one of them and whatever
// the other device happened to be left holding on the other -- which during
// boot is nothing at all: a black rectangle next to the machine starting up.
//
// So the rows the console draws are copied across as they are drawn. Cheap
// because gfx remembers which rows those were: a line of boot text is one
// cell-row transfer, not a screen.
//
// This is the same job vgpud does from userspace, and the two do not overlap.
// While the kernel drives the second GPU this runs; the moment vgpud claims
// it, display_acquire() returns nothing here and vgpud's mirror takes over
// from the same console framebuffer. The handover needs no coordination
// because neither side owns anything the other wants.
void
display_mirror_console(void)
{
// cprintf() from inside the display driver would come straight back here
// and present again, for ever.
static int inside;
// And two terminals on two CPUs would interleave into one command queue.
// The lock is held across the copy and the present, which is microseconds of
// polled virtio and the same thing the panic path does with everything else
// already stopped.
static struct spinlock lk;
static int lk_init;
// Which buffer was last brought up to date; see below.
static volatile uchar *last_buf;
struct display_mode m;
uint32 stride = 0;
volatile uchar *buf;
int r0, r1, y, h;
if (inside || !gfx_ready())
return;
// Is there a second screen at all? Asked first, and asked of the *scanout*,
// because both halves of that matter.
//
// Of the scanout, because the buffer acquire() returns is not the same
// thing: vga_fb hands back a back buffer it copies onto the console's own
// framebuffer afterwards, so comparing pointers said "a different screen"
// about the only screen there is -- and then mirrored the console onto
// itself through two megabytes of intermediate.
//
// First, because this question has no side effects and acquire() has two:
// it allocates a back buffer on first call, and it announces it with a
// cprintf -- which comes straight back here. On a one-screen machine both
// were pure waste, and the cprintf was a panic.
uint64 phys = display_scanout_phys();
if (phys == 0 || phys == gfx_fb_phys())
return;
if (!lk_init) {
initlock(&lk, "dispmirror");
lk_init = 1;
}
acquire(&lk);
// Set before the first call into the driver, not after: a driver may
// cprintf, cprintf comes back here, and the second entry would block on a
// lock its own call chain is holding.
inside = 1;
buf = display_acquire(&stride);
if (!buf || display_mode(&m) != 0 || m.bpp != 32) {
inside = 0;
release(&lk);
return;
}
// A buffer we have not written before is owed the whole screen, not the
// damage since last time.
//
// This is what a handover looks like from here: the loader draws the splash
// straight into the framebuffer, the kernel's display driver probes some
// milliseconds later with a black scanout of its own, and gfx has no damage
// to report because gfx did not draw any of it. Without this the second head
// went black at that moment and filled in a line at a time as output
// arrived. It happens once per driver, and the same rule covers the device
// coming back from a userspace driver that exited.
int have = gfx_damage_take(&r0, &r1);
if (buf != last_buf) {
last_buf = buf;
r0 = 0;
r1 = (int)m.height - 1;
have = 1;
}
if (!have) {
inside = 0;
release(&lk);
return;
}
if (copy_console_rows(buf, m.width, m.height, stride ? stride : m.stride,
r0, r1, &y, &h))
display_present(0, y, m.width, h);
inside = 0;
release(&lk);
}
void
@ -252,15 +386,14 @@ display_panic_present(void)
{
struct display_mode m;
uint32 stride = 0;
struct device *d = display_get();
volatile uchar *buf = display_acquire(&stride);
if (buf && display_mode(&m) == 0 && m.bpp == 32 &&
(uint64)buf != (uint64)gfx_fb_ptr()) {
uint64 phys = display_scanout_phys();
if (buf && phys && phys != gfx_fb_phys() &&
display_mode(&m) == 0 && m.bpp == 32) {
panic_copy_console(buf, m.width, m.height, stride ? stride : m.stride);
display_present(0, 0, m.width, m.height);
cprintf(" The report is on the %s display too.\n",
d ? d->name : "second");
}
}
@ -283,8 +416,7 @@ display_panic_present(void)
continue;
panic_copy_console((volatile uchar *)P2V(paddr), w, h, stride);
if (drv->panic_present((struct pci_dev *)pci_get(i), paddr, w, h) == 0)
cprintf(" The report is on the %s display too.\n", drv->name);
drv->panic_present((struct pci_dev *)pci_get(i), paddr, w, h);
}
}

View file

@ -69,6 +69,41 @@ static void draw_glyph_px(int x, int y, uint32 cp, uint32 fg, uint32 bg);
static struct fb_info scanout;
static volatile uchar *fb;
// What has been drawn since anyone last asked.
//
// The console framebuffer is one screen on a machine that may have two, and
// the second one is a copy of this that somebody has to make. Copying all of
// it every time would be four megabytes to move one line of text, so the rows
// that were touched are remembered as a single span -- not a list of
// rectangles, because console output is lines and a span is what lines make.
//
// Rows rather than pixels: the copy is row-at-a-time anyway, and a column
// range would save nothing a memcpy cares about.
static int dmg_y0 = -1, dmg_y1 = -1;
static void
damage(int y0, int y1)
{
if (y0 < 0) y0 = 0;
if (y1 >= (int)scanout.height) y1 = (int)scanout.height - 1;
if (y1 < y0) return;
if (dmg_y0 < 0 || y0 < dmg_y0) dmg_y0 = y0;
if (dmg_y1 < 0 || y1 > dmg_y1) dmg_y1 = y1;
}
// Hand the span over and forget it. Returns 0 when nothing has changed, which
// is the common case and the reason the caller can afford to ask often.
int
gfx_damage_take(int *y0, int *y1)
{
if (dmg_y0 < 0)
return 0;
*y0 = dmg_y0;
*y1 = dmg_y1;
dmg_y0 = dmg_y1 = -1;
return 1;
}
static int cursor_x = 0;
static int cursor_y = 0;
// Current fill color for glyph backgrounds, backspace erase, and scroll --
@ -125,6 +160,8 @@ gfx_draw_pixel(int x, int y, uint32 color)
uint32 offset = (y * scanout.stride) + (x * (scanout.bpp / 8));
damage(y, y);
if (scanout.bpp == 32) {
*(volatile uint32*)(fb + offset) = color;
} else if (scanout.bpp == 24) {
@ -404,6 +441,7 @@ gfx_scroll_up(uint32 bg)
uint32 total_size = scanout.stride * scanout.height;
memmove((void*)fb, (void*)(fb + line_size), total_size - line_size);
damage(0, (int)scanout.height - 1); // a scroll moves every row
for (int y = (int)scanout.height - CHAR_HEIGHT; y < (int)scanout.height; y++)
for (int x = 0; x < (int)scanout.width; x++)
@ -566,6 +604,8 @@ gfx_fill_rect(int x0, int y0, int w, int h, uint32 color)
if (w <= 0 || h <= 0)
return;
damage(y0, y0 + h - 1);
// Only the 32bpp case gets the fast path; anything else is rare enough that
// correctness through gfx_draw_pixel is the better trade.
if (scanout.bpp != 32) {

View file

@ -748,8 +748,8 @@ vt_csi(struct vt *v, int final)
// Feed one output byte to a terminal. The serial side has already had the
// raw byte; this is the framebuffer's interpretation of it.
void
vt_feed(struct vt *v, int c)
static void
vt_feed_1(struct vt *v, int c)
{
switch (v->esc_state) {
case VE_ESC:
@ -898,3 +898,9 @@ vt_cycle(int dir)
}
return 0; // nowhere else to go; not an error
}
void
vt_feed(struct vt *v, int c)
{
vt_feed_1(v, c);
}

View file

@ -225,11 +225,22 @@ vga_disp_wait_vblank(struct device *d)
break;
}
// Whatever acquire() handed out, the pixels end up here: the linear
// framebuffer this driver published as the console's scanout. Saying so is
// what keeps the console mirror from copying the console onto itself.
static uint64
vga_disp_scanout_phys(struct device *d)
{
(void)d;
return fb_phys;
}
const struct display_ops vga_display_ops = {
.mode = vga_disp_mode,
.acquire = vga_disp_acquire,
.present = vga_disp_present,
.wait_vblank = vga_disp_wait_vblank,
.scanout_phys = vga_disp_scanout_phys,
};
// Put the VBE framebuffer back on screen.

View file

@ -402,11 +402,24 @@ vgpu_present(struct device *d, int x, int y, int w, int h)
// No wait_vblank. The device presents whole rectangles atomically, so there is
// nothing to synchronise against -- and a null op is more honest than one that
// spins and achieves nothing. display_wait_vblank() handles the absence.
// The guest-side framebuffer, which is this device's own memory and not the
// console's -- on every machine this driver has run on. It is written as a
// comparison rather than as `return 0` because a machine whose *only* display
// is a virtio-gpu would have the console here, and then the honest answer
// changes without this line needing to.
static uint64
vgpu_scanout_phys(struct device *d)
{
struct vgpu *g = vgpu_of(d);
return (g && g->fb) ? dma_phys(g->fb) : 0;
}
const struct display_ops virtio_gpu_display_ops = {
.mode = vgpu_mode,
.acquire = vgpu_acquire,
.present = vgpu_present,
.wait_vblank = 0,
.scanout_phys = vgpu_scanout_phys,
};
// --- probe -----------------------------------------------------------------