Operating Systems · Interrupts and Timers

From Hardware Line to Virtual IRQ Number

How the kernel maps a controller-specific interrupt source onto a stable software number, what the irq_desc and irq_chip split buys, and why shared lines need an action list.

A driver asks the kernel for “interrupt 47” and receives a callback when its device signals. Nothing in that sentence survives contact with the hardware. The number 47 exists nowhere on the bus; a device asserts a line, a controller translates the line into a vector, and the vector is an index into a per-CPU table that was programmed during boot. The kernel’s job in the middle is to hide all of that behind a number that stays valid for the lifetime of the device, and to do so without letting the hardware’s irregularities leak into driver code.

The abstraction has three parts. An interrupt descriptor holds everything the kernel knows about one software interrupt number. An interrupt chip holds the operations that can be performed on the hardware behind it. An interrupt domain holds the translation from a controller’s own numbering into the kernel’s. Drivers see only the first.

The descriptor array, dense and then sparse

Historically the kernel kept a static array of NR_IRQS descriptors, with NR_IRQS a compile-time constant per architecture. The array was simple and the memory cost was proportional to the maximum possible number of interrupts rather than the number in use, which on a machine with a handful of devices and a generous constant was mostly waste.

Modern kernels build the same structure on demand. With sparse interrupt support, descriptors are allocated when a mapping is created and freed when it is torn down, and irq_to_desc() walks a maple tree, keyed by the interrupt number, rather than indexing an array. The array-shaped interface survives at the API boundary because drivers still pass small integers around; only the lookup changed, and the tree costs logarithmic time against the array’s constant time in exchange for allocating only the descriptors that exist.

Each descriptor carries a struct irq_data with the hardware interrupt number, the domain pointer, and the chip pointer, plus a handle_irq function that implements the flow, a linked list of actions, a lock, and a per-descriptor lockdep class. The split between descriptor and data exists so that the data can be passed to chip operations without exposing the whole descriptor, and so that hierarchical controllers can share one descriptor across stacked domains.

Chips describe hardware, domains describe numbering

A struct irq_chip is a table of operations on a controller: mask, unmask, acknowledge, end-of-interrupt, set the trigger type, set affinity, and a handful of optional callbacks for suspend and resume. A controller driver fills in the operations it supports and leaves the rest null; the generic layer checks for null and either skips the call or falls back to a default.

The important structural point is that a chip is per-controller, not per-line. Two hundred lines behind one IOAPIC share a single irq_chip, and the operations take an irq_data argument so they know which line they are acting on. This is why the same code path works for a discrete controller, an MSI-capable endpoint, and a GPIO controller whose “interrupt” is really a pin-change event: they all present the same verb set, with different implementations behind it.

A domain is the translation layer. It answers one question: given a controller-local number, called a hardware interrupt or hwirq, what is the kernel’s software number? The answer is stored in a reverse map, and the domain’s map operation is what populates it.

/* A minimal linear domain: hwirq N maps to the N-th allocated virq. */
static int demo_domain_map(struct irq_domain *d, unsigned int virq,
                           irq_hw_number_t hwirq)
{
    irq_set_chip_and_handler(virq, &demo_chip, handle_level_irq);
    irq_set_chip_data(virq, d->host_data);
    irq_set_status_flags(virq, IRQ_LEVEL);   /* mark the flow as level-triggered */
    return 0;
}

static const struct irq_domain_ops demo_ops = {
    .map = demo_domain_map,
    .xlate = irq_domain_xlate_twocell,  /* <controller, hwirq> in DT */
};

A linear domain keeps a table indexed by hwirq and is the right choice when hwirqs are small and dense. A hierarchical domain handles the case where controllers are chained, as when a GPIO expander sits behind an I2C bus: the child domain’s hwirqs are translated to the parent’s, and the parent’s chip is invoked for masking while the child’s handler is the one that runs. Chained controllers are why handle_irq lives on the descriptor rather than being chosen once per chip.

Why the indirection is worth its cost

The obvious alternative is to let drivers program vectors directly. That works on a small system and fails on three counts.

First, vector space is scarce. On x86-64 the usable external window is roughly two hundred vectors per CPU out of the 256 table entries, and some of those are consumed by the local APIC timer, the thermal sensor, and the error-reporting banks before any device is considered. A system with more interrupt sources than vectors must share, and sharing needs a software-side identity that outlives any single vector assignment.

Second, vectors move. A device that migrates between CPUs, or a controller that reassigns a vector after a firmware update or a hotplug event, would force every driver to re-register. With a virtual number the driver’s registration is stable and only the mapping beneath it changes.

Third, the mapping is described in firmware. A device tree node lists its interrupt parent and the specifier cells that identify the line; ACPI describes the same thing through interrupt link objects and GSI numbers. Both formats are controller-specific and neither resembles a Linux interrupt number, so a translation step is unavoidable. The domain is where the format-specific knowledge lives, which keeps it out of both the driver and the generic layer.

The cost is one lookup per interrupt. Entry code converts a vector to a descriptor through a small array indexed by vector, and the hwirq-to-virq direction is only needed at registration and at teardown. The runtime path does not pay for the indirection, which is why it survives.

Registering a handler

A driver registers with request_irq, passing the virtual number, a handler, flags, a name, and a cookie. The name appears in /proc/interrupts and in the descriptor’s action entry; the cookie is passed back to the handler so one function can serve several devices.

The flags are where the interesting contracts live. IRQF_SHARED permits several actions on one line and requires that every handler be able to identify whether its own device caused the interrupt. IRQF_ONESHOT keeps the line masked until a threaded handler finishes, which is what a level-triggered line needs when the handler sleeps. IRQF_TRIGGER_RISING and its siblings override the trigger type the firmware described, which is occasionally necessary and usually a sign of a wrong description. IRQF_NOBALANCING excludes a line from automatic affinity adjustment; IRQF_PERCPU marks an interrupt that is private to one CPU.

Two failure modes follow directly from sharing. If a handler returns “not mine” for an interrupt its device did raise, the kernel concludes the interrupt is spurious if no other action claimed it, and after enough such events disables the line. If a handler returns “mine” when it was not, the kernel notes that the interrupt was handled and stops counting it as spurious, so a later device’s interrupt is never serviced and the line stays asserted. Both are logic errors in the driver that present as hardware faults.

The action loop itself does not stop at the first handler that claims the interrupt. It calls every action on the descriptor and accumulates the results, because on a shared line more than one device may have signalled in the same interval and stopping early would leave the second one’s assertion in place. The cost of the rule is that every handler on a busy shared line runs on every assertion it did not raise; the benefit is that no device on the line can be starved by an earlier one.

A related contract concerns the handler’s return value in the threaded case. A primary handler installed with request_threaded_irq runs in hard interrupt context and is expected to acknowledge the device and then return IRQ_WAKE_THREAD; the kernel wakes the thread, which runs the handler that may sleep. Returning IRQ_HANDLED from the primary handler without waking the thread is legal and means the work was completed in context. Returning IRQ_NONE means the interrupt belonged to another action on the line.

Teardown and the hazards around it

free_irq removes one action from the list and, when the list becomes empty, releases the line. The function waits for any handler currently executing on another CPU, which it does by waiting for that CPU’s in-progress interrupt to finish, and the wait sleeps. It therefore cannot be called from interrupt context and cannot be called while holding a lock that a handler takes. The resulting deadlock is common enough that the kernel’s lock validator has a dedicated check for it.

The descriptor itself is not freed at the same moment in every configuration. A shared line with one remaining action keeps its descriptor; a line with none may have its mapping removed, and if the mapping was created dynamically the virtual number can later be reused by a different device. A driver that caches a virtual number across a teardown and re-registration is therefore holding a number that may now belong to someone else.

Deferred registration has a matching hazard. A handler that is registered before the device is fully initialized can fire and touch state that does not exist yet; the usual sequence is to mask the line in hardware, initialize, then register and unmask. The generic layer offers disable_irq and disable_irq_nosync for the masking step, and the difference between them is whether the call waits for a handler that is already running.

Where the layers meet

The vector-to-descriptor array and the descriptor’s handle_irq are what the assembly entry stub calls into, as described in How a Hardware Interrupt Reaches a Handler on x86-64. The choice of flow handler, and the mask-on-entry behaviour of level-triggered lines, is decided there rather than by the driver.

Above the flow handler, the action list is the last software layer before driver code. What a driver may do inside that callback is bounded by the fact that it runs in interrupt context, and the mechanisms for moving work out of it are the subject of Top Halves and Bottom Halves. The hardware that assigns the vectors in the first place, and the difference between a wired line and a message, is covered in Interrupt Controllers from the 8259A to Message-Signalled Interrupts.

References

  1. Generic IRQ Handling — The Linux kernel documentation
  2. IRQ Domain — The Linux kernel documentation
  3. Linux Device Drivers, third edition, Chapter 10: Interrupt Handling — Corbet, Rubini & Kroah-Hartman, O'Reilly Media