hybrid: interrupts to userspace, and a crashed driver that stays crashed

docs/plan/hybrid.md mechanism 3. A process asks for a device's interrupts with
PCIIOC_IRQ and then reads them, which is how Linux's uio does it and for the
same reason: a descriptor composes with poll, and a driver that stops reading
falls behind rather than losing events. read() returns a count rather than a
flag, so a driver that was busy sees it missed three and acts once.

Proven against an e1000e by asserting a cause through its ICS register and
reading the interrupt back out in userspace:

    interrupts routed here (line 10)
    interrupt delivered, count = 1
    ICR = 0x80000004

0x80000004 is the cause the test set plus INT_ASSERTED -- the device's own
register, read by userspace as its acknowledgement.

**The masking is the design, not a detail.** A PCI line is level-triggered and
stays asserted until the cause is cleared at the device, which is a different
register on every chip and so is something only the driver can do. The kernel
masks the line on arrival and userspace's write() unmasks it. Signalling EOI on
a still-asserted line re-enters the handler immediately and for ever; this
kernel did exactly that by accident at 72,000 interrupts a second, and what it
looked like from outside was a clock running seven times slow (§13).

Which decides what happens when a driver dies mid-interrupt: the line is left
**masked**. The instinct is to unmask -- do not leave the device dead -- and it
is wrong, because unmasking an asserted line with no handler is a crashed
driver taking the machine down with it. Masked is safe and not permanent: the
next PCIIOC_IRQ arms it. Both halves verified by crashing a driver on purpose
and then starting another one.

A line already carrying kernel handlers is refused outright. Masking is
per-line, so handing out a shared one would silence whichever kernel driver is
also there, with nothing logged. MSI is the real answer and pci_msi_enable() is
already in the tree waiting for a caller.

Also fixed, found by running the crash twice: DMA buffers were never returned
when a process exited. The arena is 4 MB and contiguous, so a driver that
restarts a few times exhausted it -- "out of DMA memory, 3584 free" -- which is
a poor property for an interface whose whole promise is that a driver can crash
and come back.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VQ7Qx8E1q1M1bbCq8QLHS2
This commit is contained in:
Nexoniarz 2026-09-13 18:11:00 +02:00
commit 72074a960e
6 changed files with 331 additions and 12 deletions

View file

@ -13,6 +13,10 @@
//
// pcitest list the bus
// pcitest <index> claim that device, map BAR0, read it, allocate DMA
// pcitest <index> x the same, then assert an interrupt and exit WITHOUT
// acking it -- which is what a driver that crashed looks
// like from the kernel's side, and the case that used to
// leave a level-triggered line asserted with no handler
#include "types.h"
#include "user.h"
@ -89,7 +93,7 @@ main(int argc, char *argv[])
}
if (argc == 1) {
puts(" # bdf vendor:device class driver\n");
puts(" # bdf vendor:device irq class driver\n");
for (int i = 0; i < count; i++) {
struct pciuser_info in;
in.index = (unsigned int)i;
@ -98,7 +102,8 @@ main(int argc, char *argv[])
puts(" "); putn(i); puts(" ");
puthex(in.bus, 2); puts(":"); puthex(in.slot, 2);
puts("."); puthex(in.func, 1); puts(" ");
puthex(in.vendor, 4); puts(":"); puthex(in.device, 4); puts(" ");
puthex(in.vendor, 4); puts(":"); puthex(in.device, 4); puts(" ");
if (in.irq == 0xFF) puts(" - "); else { putn(in.irq); puts(" "); }
puts(class_name(in.class));
puts(" ");
puts(in.driver[0] ? in.driver : "-");
@ -191,6 +196,52 @@ main(int argc, char *argv[])
else
puts(" DMA buffer did not read back correctly\n");
puts("\nmechanisms 2 and 4 work: a driver could live out here.\n");
// Mechanism 3. Ask for the device's interrupts, then make it interrupt.
//
// The e1000 family has an Interrupt Cause Set register at 0x00C8: writing
// a cause bit asserts the line exactly as the hardware would, which is the
// only way to test interrupt delivery without traffic arriving from
// somewhere. Reading ICR at 0x00C0 clears it, which is the ack the kernel
// cannot do for us.
if (in.vendor != 0x8086 || in.class != 0x02) {
puts("\nmechanisms 2 and 4 work: a driver could live out here.\n");
puts("(interrupts untested -- that part knows only Intel NICs)\n");
exit();
}
if (ioctl(fd, PCIIOC_IRQ, &idx) < 0) {
puts(" interrupts refused -- the line is shared with a kernel driver\n");
puts("\nmechanisms 2 and 4 work: a driver could live out here.\n");
exit();
}
puts(" interrupts routed here (line "); putn(in.irq); puts(")\n");
volatile unsigned int *IMS = &regs[0x00D0 / 4]; // interrupt mask set
volatile unsigned int *ICS = &regs[0x00C8 / 4]; // interrupt cause set
volatile unsigned int *ICR = &regs[0x00C0 / 4]; // cause read, clears
*IMS = 0x00000004; // LSC: link status change, a cause with no traffic
(void)*ICR; // clear anything already pending
*ICS = 0x00000004; // assert it
// Crash simulation: assert the cause and leave, without reading or
// acking. The line is asserted and this process is about to stop existing.
if (argc > 2 && argv[2][0] == 'x') {
puts(" interrupt asserted; exiting WITHOUT acking (simulated crash)\n");
exit();
}
unsigned long long n = 0;
if (read(fd, &n, sizeof n) != (int)sizeof n) {
puts(" read() did not return an interrupt\n");
exit();
}
puts(" interrupt delivered, count = "); putn((int)n); puts("\n");
unsigned int cause = *ICR; // ack at the device
puts(" ICR = 0x"); puthex(cause, 8); puts("\n");
write(fd, &n, sizeof n); // re-arm
puts("\nall three mechanisms work: a driver could live out here.\n");
exit();
}