panic: the crash screen reaches every display, not just the console's

"it also doesn't show crash screen on virtio gpu or just other gpu than default
vga" -- true, and on the virtio-gpu it was worse than nothing. The head kept
showing the console from a second before the panic: one screen saying the
kernel is dead, the other showing a shell prompt.

panic() painted into the console framebuffer and stopped there. Two cases were
missing, and they are not the same problem.

**The kernel still drives it.** display_acquire() hands back a buffer; the
report is copied in and presented. Ordinary, and a no-op when the display
class's buffer *is* the console's, so a one-screen machine does not do the work
twice.

**A process drives it.** The hard one, and the one hybrid.md owed an answer
for. The kernel let go of the device deliberately, and the process holding it
will never run again -- there is no scheduling after a panic -- so there is
nobody left to ask. It is not a race that can be won by being careful.

So the userspace driver says where it is scanning out *in advance*:
PCIIOC_PANICFB, registered by vgpud after bring-up and after every modeset. On
a panic the kernel paints the report into that memory and asks the kernel
driver the device was taken from to put it on screen one last time --
`panic_present` in struct pci_driver, which virtio-gpu implements by resetting
the device out from under a driver that is still nominally running. That is
safe here and nowhere else, because nothing is going to run again.

The panic path allocates nothing. It cannot: a corrupted heap is one of the
ways a machine gets here, and an allocator that deadlocks takes the crash
screen *and* the serial log's last chance to be read. So the driver keeps its
BAR mappings across the handover, rebuilds one queue from 20 KB of static
memory, and bounds every spin -- including the reset acknowledge, which is an
unbounded `while` in probe() and correct there.

**The registration is checked like the promise it is.** The range must lie
inside a block that process got from PCIIOC_DMA on that device. Without that,
a claimed device would be a way to nominate any physical address in the machine
-- page tables, kernel text -- to be overwritten with four megabytes of crash
report at the one moment nothing is left to notice. That is a worse bug than
the one this fixes. It goes away with the claim and with the process.

What it buys is worth stating plainly: a device can be evicted to userspace
without the kernel losing its ability to tell you the machine died. The
alternative was a driver boundary that is also a blind spot.

Verified both ways under QEMU with two heads, `echo c > /proc/sysrq`, and a
screendump of each over QMP:

  vgpud holding the device   both heads byte-identical, serial says
                             "The report is on the virtio-gpu display too."
  kernel holding it          same, via display_acquire/display_present
                             (/proc/pci shows 1af4:1050 [virtio-gpu])

Both supported configurations: one boot each, no traps, no panics, socktest and
memfdtest green.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Nexoniarz 2026-09-22 16:53:16 +02:00
commit e72205cdc0
10 changed files with 567 additions and 9 deletions

View file

@ -39,6 +39,24 @@ int mappages(pml4e_t *pgdir, void *va, uint64 size, uint64 pa, int perm);
// renumbers it afterwards.
static struct proc *owner[NPCI];
// What used to drive each device, for the one thing a kernel driver is still
// needed for after it has been told to let go: the crash screen.
//
// d->drv is cleared when the claim succeeds, deliberately -- a driver that has
// handed a device over must not be reachable through the device it no longer
// drives. This is the exception, and it is narrow: nothing calls through here
// except panic(), the device is reset first, and by then the process that was
// driving it is not going to run again.
static const struct pci_driver *released_drv[NPCI];
// Where a userspace display driver is scanning out, so panic() can put the
// report there. See PCIIOC_PANICFB in include/pciuser.h.
struct panicfb {
uint64 paddr;
uint32 width, height, stride;
};
static struct panicfb panicfb[NPCI];
static struct spinlock pciuser_lock;
// Interrupt delivery, one device per process.
@ -339,8 +357,10 @@ pciuser_release_all(struct proc *p)
if (mine)
owner[i] = 0;
release(&pciuser_lock);
if (mine)
if (mine) {
panicfb[i].paddr = 0;
pciuser_stop(i);
}
}
// And its ports. The bitmap itself is closed by the next switchuvm() on each
@ -469,6 +489,7 @@ do_claim(uint64 argp)
return -EBUSY;
if (d->drv->release((struct pci_dev *)d) != 0)
return -EBUSY;
released_drv[idx] = d->drv;
((struct pci_dev *)d)->driver = 0;
((struct pci_dev *)d)->drv = 0;
}
@ -554,8 +575,10 @@ do_release(uint64 argp)
acquire(&pciuser_lock);
int mine = (owner[idx] == myproc());
if (mine)
if (mine) {
owner[idx] = 0;
panicfb[idx].paddr = 0;
}
release(&pciuser_lock);
// Same halt as the death path, for the same reason. A driver that releases
@ -685,6 +708,84 @@ do_dma(uint64 argp)
return 0;
}
// Register where this process is scanning out, so panic() can draw there.
//
// The check is the whole of the security of this call: the range has to lie
// inside a block this process got from PCIIOC_DMA. Without it, a claimed
// device would be a way to nominate any physical address in the machine as the
// place the kernel writes four megabytes of crash report -- page tables, the
// kernel's own text -- at the one moment nothing is left to notice.
static int
do_panicfb(uint64 argp)
{
struct pciuser_panicfb req;
struct proc *p = myproc();
if (copy_from_user(&req, argp, sizeof(req)) < 0)
return -EFAULT;
if ((int)req.index >= pci_count() || (int)req.index >= NPCI)
return -ENODEV;
if (owner[req.index] != p)
return -EACCES; // claim it first
if (req.paddr == 0) { // unregister
acquire(&pciuser_lock);
panicfb[req.index].paddr = 0;
release(&pciuser_lock);
return 0;
}
if (req.width == 0 || req.height == 0 || req.stride < req.width * 4)
return -EINVAL;
uint64 need = (uint64)req.height * req.stride;
uint64 end = req.paddr + need;
if (end < req.paddr)
return -EINVAL;
int ok = 0;
acquire(&pciuser_lock);
for (int i = 0; i < PCIUSER_MAXDMA; i++) {
uint64 base, lim;
if (dmablk[i].proc != p || !dmablk[i].kva)
continue;
base = dma_phys(dmablk[i].kva);
lim = base + dmablk[i].size;
if (req.paddr >= base && end <= lim) { ok = 1; break; }
}
if (ok) {
panicfb[req.index].paddr = req.paddr;
panicfb[req.index].width = req.width;
panicfb[req.index].height = req.height;
panicfb[req.index].stride = req.stride;
}
release(&pciuser_lock);
// Not EINVAL: the argument is fine, the memory is not this process's to
// nominate. EACCES is the difference between "you asked wrong" and "no".
return ok ? 0 : -EACCES;
}
// The panic path's view of the above. Called with interrupts off, from a
// machine that is already dead: no locks, because whoever held one is not
// going to release it, and a crash screen that deadlocks is worse than no
// crash screen.
int
pciuser_panic_surface(int idx, uint64 *paddr, int *w, int *h, uint32 *stride,
const struct pci_driver **drv)
{
if (idx < 0 || idx >= NPCI || owner[idx] == 0 || panicfb[idx].paddr == 0)
return 0;
if (!released_drv[idx] || !released_drv[idx]->panic_present)
return 0;
*paddr = panicfb[idx].paddr;
*w = (int)panicfb[idx].width;
*h = (int)panicfb[idx].height;
*stride = panicfb[idx].stride;
*drv = released_drv[idx];
return 1;
}
// Hand a device's interrupts to the calling process.
static int
do_irq(uint64 argp)
@ -795,6 +896,7 @@ pciuser_ioctl(uint64 request, uint64 argp)
case PCIIOC_DMA: return do_dma(argp);
case PCIIOC_IRQ: return do_irq(argp);
case PCIIOC_CFG: return do_cfg(argp);
case PCIIOC_PANICFB: return do_panicfb(argp);
default: return -ENOTTY;
}
}