Interrupt Controllers

July 16, 2026 · View on GitHub

Between a device line and the CPU vector sits an interrupt controller. NØNOS supports two: the legacy 8259 PIC, which it remaps out of the way of the CPU exception vectors, and the local APIC, which is the preferred controller once it is up. This page documents both and the gate that decides which one an acknowledgement goes to. The code is under src/interrupts/pic/ and src/interrupts/apic/.

The 8259 PIC

The PIC delivers its lines starting at vector 8 by default, which collides with the CPU exception vectors, so the first thing the kernel does is remap it. pic::init (src/interrupts/pic/init.rs:25) runs the 8259 four-word initialization sequence on the master and slave pair:

  save the current interrupt masks
  ICW1: begin init, expect ICW4          (master and slave)
  ICW2: master vector offset 0x20 (32)   slave offset 0x28 (40)
  ICW3: cascade wiring (slave on line 2)
  ICW4: 8086 mode
  restore the saved masks

After this the master's eight lines land on vectors 32 to 39 and the slave's on 40 to 47, which is the legacy IRQ range the IDT reserves; timer IRQ 0 becomes vector 32, keyboard IRQ 1 becomes vector 33. The remap preserves the masks that were in place rather than unmasking everything, and the module exposes mask_irq / unmask_irq and mask_all for line-level control, and send_eoi to acknowledge a line.

The local APIC

The local APIC is the modern per-CPU controller, and the interrupts module's APIC surface is a thin façade over the system APIC driver: apic::init delegates to sys::apic::init (src/interrupts/apic/init.rs:17), apic::send_eoi to sys::apic::eoi, and apic::is_enabled reports whether the APIC came up. The APIC is where the SMP inter-processor interrupts and the LAPIC timer live; this module consumes it for the end-of-interrupt path and leaves the bring-up to the driver.

Choosing the controller

Every handler's acknowledgement goes through the same gate:

  if apic::is_enabled():  apic::send_eoi()
  else:                   pic::send_eoi(irq_line)

The kernel prefers the APIC and falls back to the PIC only when the APIC is not enabled. This is the single decision that keeps the two controllers from disagreeing: an interrupt is acknowledged to exactly one of them, chosen by whether the APIC is live, so a line is never double-acknowledged or left hanging. In the normal boot the APIC comes up early and the PIC, having been remapped to safe vectors, sits masked as a fallback rather than a participant.

Security analysis

The controllers sit below the vector layer, so their security role is narrow but real: keep the exception vectors clear, keep acknowledgement single-owner, and keep line programming out of userspace. Three properties.

The remap clears the exception range. The PIC delivers on vectors 8 to 15 by default, which overlaps the CPU exception vectors, so an unmapped PIC would let a spurious hardware line masquerade as a double fault or a general protection fault. pic::init remaps the master to 32 and the slave to 40 (pic/init.rs:25) before interrupts are ever enabled, so a hardware line can never land on an exception vector. The remap also preserves the masks that were in place rather than unmasking everything, so bring-up does not open lines the kernel is not ready to service.

Acknowledgement goes to exactly one controller. Every handler routes its EOI through the same gate: apic::send_eoi() when apic::is_enabled(), else pic::send_eoi(irq_line). Because the choice is made by one predicate, a line is acknowledged to the live controller and only that one, so it is never double-acknowledged or left hanging. In a normal boot the APIC comes up early and the remapped PIC sits masked as a fallback, not a participant, which is why the two never disagree about whether a line was serviced.

Line programming is kernel-only. The PIC mask registers and the IO-APIC redirection table are touched only from these kernel modules and from the broker IRQ bind path; a capsule reaches neither. The MMIO grant path withholds the MSI-X table and the broker owns the IO-APIC, so a driver capsule can ask for an interrupt but can never mask another device's line, redirect a GSI, or change a destination. The honest boundary is that these façades are thin: the APIC surface here is init, eoi, and is_enabled only, and the real bring-up, the LAPIC destination programming, and x2APIC mode live in the system APIC driver, so a routing bug is almost always in that driver rather than in this end-of-interrupt façade.

Debugging the controllers

Controller-level bugs are quiet, because a mis-routed or mis-acknowledged line does not fault, it just fails to arrive or fails to arrive again. Two shapes cover almost all of it.

A line fires once and then never again. That is a missing or misdirected EOI. If a handler returns without acknowledging, the controller holds the in-service bit and delivers nothing further on that line. The check is that the handler's tail reached the EOI gate and that apic::is_enabled() reports the controller the line actually came from; an APIC-delivered interrupt acknowledged to the PIC (or the reverse) leaves the real controller un-cleared. A spurious vector on 0xFF (VECTOR_APIC_SPURIOUS) is the LAPIC's own signal that a line was withdrawn before it was serviced and is expected occasionally, not a bug.

A line is bound but silent. This is the hard one and it only shows on real hardware. The broker MkIrqBind succeeded, the grant is valid, but the interrupt never reaches the handler. The usual cause is the IO-APIC destination. INTx bind reads dest_apic_id = apic::id() (src/hardware/broker/irq/bind.rs:80) and hands it to program_route_external (src/arch/x86_64/interrupt/ioapic/ops_route.rs:94), which builds Rte::fixed(vector, dest_apic_id) and writes the destination into the top byte of the redirection entry's high dword (ops_route.rs:51). The subtlety is that apic::id() returns the running CPU's real LAPIC id read from LAPIC_ID >> 24 (ops_core.rs:22), which is not always 0: on real hardware the boot CPU's APIC id can be non-zero, so any code that hardcoded destination 0 would route the line to a core that never services it and the interrupt would vanish with no error. The diagnosis is to compare the destination field actually written into the redirection entry against the APIC id of the CPU running the driver's wait loop; if they differ, the line is being delivered to a core that is not listening. ioapic_set_irq does print "[APIC] ERROR: GSI outside primary IOAPIC range" when the GSI falls outside the IO-APIC's pin range, which catches a bad GSI, but a wrong-but-in-range destination is silent by nature. This is the controller-side view of the "claimed the device and bound the IRQ but no events arrive" failure documented from the grant side on the broker IRQ page.

Source map

  src/interrupts/pic/init.rs           the 8259 remap sequence
  src/interrupts/pic/mask.rs           per-line and global masking
  src/interrupts/pic/eoi.rs            the PIC end-of-interrupt
  src/interrupts/apic/                 the façade over sys::apic (init, eoi, is_enabled)
  src/arch/x86_64/interrupt/ioapic/ops_route.rs  program_route_external, the redirection-entry destination
  src/arch/x86_64/interrupt/apic/ops_core.rs     apic::id(), the running LAPIC id

Every reference above is verified against those trees. The vectors these controllers deliver on are on the IDT page, the EOI gate lives at the tail of every handler, the LAPIC bring-up and IPIs are on the SMP page, and the bind path that programs the IO-APIC destination is on the broker IRQ page.