The generic IRQ subsystem is the kernel’s hardware abstraction for interrupts. A driver requests an interrupt by a global Linux IRQ number and supplies a handler; underneath, the irq_domain translates each controller’s local hardware number into that global number, a flow handler encapsulates the edge-versus-level acknowledge sequence, and an irq_chip encapsulates the controller registers. This walkthrough follows one interrupt from its Device Tree property to your handler function, naming the real structures and code paths along the way.
Interrupts look simple: a device asserts a line, the CPU runs a handler, execution resumes. The difficulty is that almost every word in that sentence is hardware-specific — the CPU entry, the interrupt controller, the electrical trigger type, and the numbering all differ by platform. The generic IRQ subsystem exists to hide those differences behind one model so that driver code stays portable. This post is a source-level walkthrough of that model, aimed at embedded and kernel engineers who want to know not just the API but what runs beneath it.
Why a generic IRQ subsystem exists
Before the generic layer, each architecture and often each board reimplemented interrupt handling. Four kinds of variation made that necessary:
- CPU entry differs by architecture. Arm64 vectors to an exception handler, x86 uses an IDT, RISC-V traps on
scause. - The controller differs by SoC. An Arm system has a GIC; controllers are often cascaded, so a GPIO interrupt arrives through a GPIO controller wired into the GIC.
- The electrical behaviour differs per line. Level-triggered and edge-triggered lines need a different acknowledge, mask, and unmask order. Getting it wrong causes lost interrupts or interrupt storms.
- The numbering differs per controller. Every controller numbers its inputs from zero, so two controllers both have a “line 3”.
The subsystem separates three concerns that used to be tangled together in each of those reimplementations. What the driver wants (“call my function for interrupt N”) is the portable driver API. How the line must be driven (the ack/mask/unmask sequence, which depends only on edge versus level) is the flow handler. How to talk to this controller (the register writes to mask, unmask, acknowledge) is the irq_chip. Because these are separated, a GPIO driver, a GIC driver, and a network driver each implement only their own layer and compose cleanly.
The big picture: the layers
From the driver at the top to the silicon at the bottom:
Driver request_irq() / free_irq() / enable_irq() ...
------------------------------------------------------------------------
High-level API kernel/irq/manage.c
------------------------------------------------------------------------
Flow handler handle_level_irq(), handle_edge_irq(),
kernel/irq/chip.c handle_fasteoi_irq(), handle_simple_irq(),
handle_percpu_irq()
------------------------------------------------------------------------
Chip abstraction struct irq_chip: irq_mask(), irq_unmask(),
the irqchip driver irq_ack(), irq_eoi(), irq_set_type() ...
------------------------------------------------------------------------
Hardware GIC / GPIO controller / PMIC ...Two supporting objects make those layers usable: the irq_desc, one per Linux IRQ number, which ties a flow handler, a chip, and the registered handler together; and the irq_domain, which maps a controller’s local hardware number to the global Linux IRQ number a driver uses. The kernel’s own description in Documentation/core-api/genericirq.rst notes that each interrupt descriptor is assigned its own high-level flow handler, normally one of the generic implementations.
The core data structures
Five structures carry the whole design. Understanding how they connect is most of understanding the subsystem.
struct irq_desc — the descriptor, one per Linux IRQ (include/linux/irqdesc.h). This is the anchor object:
struct irq_desc {
struct irq_data irq_data; /* passed to chip callbacks */
irq_flow_handler_t handle_irq; /* the flow handler */
struct irqaction *action; /* registered handler list */
unsigned int status_use_accessors;
unsigned int depth; /* nested disable count */
unsigned int irq_count; /* for detecting stuck IRQs */
const char *name;
/* ... per-CPU stats, affinity, etc. ... */
};The three fields that matter most are handle_irq (the flow handler the core calls), action (the driver handler list), and the embedded irq_data.
struct irq_data — what the chip sees (include/linux/irq.h). The core passes this, not irq_desc, to chip callbacks, so it carries exactly what a chip driver may know:
struct irq_data {
unsigned int irq; /* Linux IRQ number (virq) */
irq_hw_number_t hwirq; /* controller-local number */
struct irq_chip *chip;
struct irq_domain *domain;
void *chip_data;
#ifdef CONFIG_IRQ_DOMAIN_HIERARCHY
struct irq_data *parent_data; /* next controller up */
#endif
};The hwirq and irq fields are the two number spaces discussed in the next section; parent_data is what lets stacked controllers work.
struct irq_chip — the controller driver’s contract (include/linux/irq.h). A set of callbacks the driver fills in:
struct irq_chip {
const char *name;
void (*irq_ack)(struct irq_data *data);
void (*irq_mask)(struct irq_data *data);
void (*irq_unmask)(struct irq_data *data);
void (*irq_eoi)(struct irq_data *data);
int (*irq_set_type)(struct irq_data *data, unsigned int flow_type);
int (*irq_set_affinity)(struct irq_data *data,
const struct cpumask *dest, bool force);
/* ... */
};These are pure register operations. The chip never decides when they run — that is the flow handler’s job.
struct irqaction — one registered handler (include/linux/interrupt.h). Each request_irq() creates one and links it onto irq_desc.action; shared interrupts have several:
struct irqaction {
irq_handler_t handler; /* runs in hard-IRQ context */
void *dev_id;
struct irqaction *next; /* next sharer of this IRQ */
unsigned int irq;
unsigned int flags; /* IRQF_SHARED, ... */
const char *name;
};struct irq_domain — the number translator (include/linux/irqdomain.h), covered next.
How they connect: a driver holds a virq. irq_to_desc(virq) gives the irq_desc. Inside it, handle_irq is the flow handler, irq_data.chip is the controller driver, and action is the driver’s own handler. The irq_domain is what produced that virq from a hwirq in the first place.
Linux IRQ numbers versus hardware IRQs: the irq_domain
This is the concept that confuses people most. A driver works with a Linux IRQ number, called virq (virtual IRQ): a single global space for the whole system. A controller works with a hardware IRQ number (hwirq) that is local to it and starts at zero. They cannot be the same number, because every controller has a hwirq 0, 1, 2. The irq_domain is the mapping between the two:
{ irq_domain (a controller) , hwirq } -> virqAs the kernel documentation puts it, interrupt controllers do not by themselves provide reverse mapping of the controller-local IRQ number into the Linux IRQ number space; the irq_domain library adds that mapping on top. Each controller registers one domain when it probes, with an ops table:
struct irq_domain_ops {
/* v1 / legacy interface */
int (*map)(struct irq_domain *d, unsigned int virq, irq_hw_number_t hw);
int (*xlate)(struct irq_domain *d, struct device_node *node,
const u32 *intspec, unsigned int intsize,
unsigned long *out_hwirq, unsigned int *out_type);
/* v2 / hierarchical interface */
int (*translate)(struct irq_domain *d, struct irq_fwspec *fwspec,
unsigned long *out_hwirq, unsigned int *out_type);
int (*alloc)(struct irq_domain *d, unsigned int virq,
unsigned int nr_irqs, void *arg);
void (*free)(struct irq_domain *d, unsigned int virq, unsigned int nr_irqs);
};The .xlate / .translate callback turns a Device Tree interrupt specifier into a hwirq plus a trigger type. The .map (v1) or .alloc (v2) callback associates a virq with a hwirq and sets up the irq_desc. A domain is created with, for example, irq_domain_create_linear() (the older irq_domain_add_linear() takes a struct device_node *); “linear” means the reverse map is a plain array, used when hwirq numbers are small and dense, as on a GIC. The key mapping functions in kernel/irq/irqdomain.c are irq_create_mapping() (allocate a virq for a hwirq), irq_find_mapping() (look up an existing one), and irq_resolve_mapping() (the reverse lookup used on the hot path).
Device Tree: describing the interrupt
On most embedded platforms the wiring is described in the Device Tree, and the subsystem reads it at boot. Four properties do the work: interrupt-controller marks a node as a controller; #interrupt-cells says how many cells describe one interrupt on it; interrupt-parent names which controller a node is wired to; and interrupts (or the self-contained interrupts-extended) is the specifier itself.
gic: interrupt-controller@8000000 {
compatible = "arm,gic-v3";
interrupt-controller;
#interrupt-cells = <3>;
reg = <0x8000000 0x10000>, /* distributor */
<0x80a0000 0xf60000>; /* redistributor */
};
uart0: serial@9000000 {
compatible = "arm,pl011";
reg = <0x9000000 0x1000>;
interrupt-parent = <&gic>;
interrupts = <GIC_SPI 1 IRQ_TYPE_LEVEL_HIGH>;
};The GIC uses three cells: the type (GIC_SPI = shared peripheral, GIC_PPI = per-CPU private); the number relative to that type; and the flags (trigger type, plus a CPU mask for PPIs). The macros come from include/dt-bindings/interrupt-controller/. A GIC SPI 1 corresponds to hardware INTID 33, because SPIs start at 32 and the binding applies that offset. IRQ_TYPE_LEVEL_HIGH is 4 and IRQ_TYPE_EDGE_RISING is 1. When a device’s lines go to more than one controller, interrupts-extended names the parent per entry:
interrupts-extended = <&gic GIC_SPI 1 IRQ_TYPE_LEVEL_HIGH>,
<&pmic_intc 5 IRQ_TYPE_EDGE_RISING>;The second entry has only two cells because that controller declares #interrupt-cells = <2>. The cell count is per-controller, which is exactly why the parent must be known before the specifier can be parsed at all.
Boot-time walkthrough: from Device Tree to a live mapping
This path runs once per interrupt line, at probe time, to turn Device Tree text into a usable virq. A driver almost never calls the low-level functions directly; for a platform device it calls platform_get_irq(pdev, 0), which for a DT-probed device leads to of_irq_get() and then the core parser:
platform_get_irq(pdev, index)
-> of_irq_get(np, index) drivers/of/irq.c
-> of_irq_parse_one(np, index, &oirq) -- fills struct of_phandle_args
-> of_irq_parse_raw(...) -- walks interrupt-parent,
applies interrupt-map,
reads #interrupt-cells
-> irq_create_of_mapping(&oirq)
-> irq_create_fwspec_mapping(&fwspec) kernel/irq/irqdomain.c
-> irq_find_mapping(domain, hwirq) -- already mapped? reuse
-> domain->ops->translate(...) -- specifier -> hwirq + type
-> __irq_domain_alloc_irqs(...) -- allocate virq + irq_desc
-> domain->ops->alloc(...) -- chip driver sets chip,
handler, chip_data
-> irq_set_irq_type(virq, type) -- program trigger via
chip->irq_set_type()The important handoffs: of_irq_parse_raw() does the Device Tree graph walk, following interrupt-parent up the tree and applying any interrupt-map (used by bridges such as PCI to remap child interrupts). The controller driver’s translate callback is the decoder — for the GIC this is gic_irq_domain_translate() in drivers/irqchip/irq-gic-v3.c, which turns <GIC_SPI 1 IRQ_TYPE_LEVEL_HIGH> into hwirq = 33, type = IRQ_TYPE_LEVEL_HIGH. The alloc callback then builds the descriptor: it sets the irq_chip, installs the correct flow handler (via irq_domain_set_info() or irq_set_chip_and_handler()), and stores any chip_data. After this, irq_to_desc(virq) returns a fully populated descriptor. The older, non-hierarchical spelling of the same idea is irq_of_parse_and_map(np, index), still seen in many drivers.
Real SoCs stack controllers — a GPIO line enters a GPIO controller that forwards a summary interrupt to the GIC. The hierarchical irq_domain (enabled by CONFIG_IRQ_DOMAIN_HIERARCHY) models this by chaining irq_data through parent_data, so one virq has a per-controller irq_data at each level. Allocation with irq_domain_alloc_irqs() runs each domain’s .alloc in turn, top-most first, each calling down to its parent; helpers such as irq_chip_mask_parent() let a middle-layer chip forward an operation upward.
Registering a handler: request_irq
A driver attaches to the virq with request_irq(). The handler it passes runs in hard-IRQ context. The trace lives in kernel/irq/manage.c:
request_irq(irq, handler, flags, name, dev)
-> allocate struct irqaction (handler, flags, dev_id = dev)
-> __setup_irq(irq, desc, action)
-> if IRQF_SHARED: append to desc->action list (flags must match)
-> irq_startup(desc, ...) -- first handler: enable the line
-> chip->irq_startup() or chip->irq_enable() / chip->irq_unmask()Two points matter in practice. For shared interrupts (IRQF_SHARED), every handler is appended to the action list, all sharers must agree on flags, and each handler must return IRQ_NONE promptly when the interrupt was not its device’s. And dev_id must be unique and non-NULL for shared interrupts, because free_irq(irq, dev_id) uses it to find which irqaction to remove.
Runtime walkthrough: taking an interrupt
This is the hot path, traced from silicon to your function on an Arm64 GICv3 system:
CPU takes an IRQ exception
-> arch IRQ vector -> el1_interrupt() arch/arm64/kernel/entry-common.c
-> handle_arch_irq() (function pointer set by the irqchip via set_handle_irq)
-> gic_handle_irq(regs) drivers/irqchip/irq-gic-v3.c
-> read ICC_IAR1_EL1 -> irqnr (the hwirq)
-> generic_handle_domain_irq(domain, irqnr) kernel/irq/irqdesc.c
-> irq_resolve_mapping(domain, hwirq) -> irq_desc
-> handle_irq_desc(desc)
-> generic_handle_irq_desc(desc)
-> desc->handle_irq(desc) -- the FLOW HANDLERThe flow handler (here handle_fasteoi_irq(), typical for the GIC, in kernel/irq/chip.c) does the chip sequencing and dispatches to the drivers:
handle_fasteoi_irq(desc)
-> raw_spin_lock(&desc->lock)
-> handle_irq_event(desc) kernel/irq/handle.c
-> handle_irq_event_percpu(desc)
-> __handle_irq_event_percpu(desc)
for each action in desc->action:
res = action->handler(irq, action->dev_id); /* your handler */
/* res is IRQ_HANDLED or IRQ_NONE */
-> chip->irq_eoi(&desc->irq_data) -- end of interrupt to the GIC
-> raw_spin_unlock(&desc->lock)So there are four steps: the arch entry reads the controller to get a hwirq; generic_handle_domain_irq() turns that hwirq into an irq_desc; the flow handler runs the chip’s ack/eoi sequence; and inside it, __handle_irq_event_percpu() walks the action list calling each driver’s handler. A handler returns IRQ_HANDLED or IRQ_NONE.
Flow handlers: why edge and level differ
The flow handler exists because the correct acknowledge order depends on trigger type, and that policy belongs in one place, not in every chip driver.
- handle_level_irq() — level-triggered. The line stays asserted until serviced, so it masks and acks first, runs the handlers, then unmasks. Masking first stops the still-asserted line from re-firing immediately.
- handle_edge_irq() — edge-triggered. A new pulse can arrive mid-handling, so it acks first and does not mask; if an edge arrives while handling, it is noted and the handler loops again, so no edge is lost.
- handle_fasteoi_irq() — for modern controllers such as the GIC with a single end-of-interrupt register. It runs the handlers and calls
irq_eoi()once. This is the common case on Arm. - handle_simple_irq() — no chip acknowledge; for software-decoded or already-acked interrupts.
- handle_percpu_irq() — for per-CPU interrupts (timers, IPIs) where no shared locking is needed.
The chip driver picks the handler when it builds the mapping, usually keyed off the trigger type it programmed. This is the concrete payoff of the separation: the GIC driver contains no edge-versus-level logic, and handle_edge_irq() contains no GIC register addresses.
The irq_chip: what a controller driver implements
A controller driver fills in irq_chip callbacks that are pure register operations, registers an irq_domain, and (if not the root) forwards its summary interrupt. A typical peripheral controller implements irq_mask/irq_unmask (set/clear one line’s enable bit), irq_ack and/or irq_eoi (clear the pending state), irq_set_type (program edge/level, which usually also selects the flow handler), and, on SMP, irq_set_affinity. The kernel provides defaults; the generic default_enable simply calls the chip’s irq_unmask:
default_enable(struct irq_data *data)
{
desc->irq_data.chip->irq_unmask(data);
}For simple memory-mapped controllers, CONFIG_GENERIC_IRQ_CHIP provides struct irq_chip_generic and helpers so a driver gets working mask/unmask/ack routines against a register bank with almost no code. If this layer of kernel and driver internals is what you work on day to day, it is the core of TECH VEDA’s Linux Kernel Infrastructure training.
Debugging the interrupt path
Start with /proc/interrupts. The columns are per-CPU counts, then the controller, the trigger type, and the registered handler:
raghu@techveda.org:~$ cat /proc/interrupts
CPU0 CPU1
27: 12043 11890 GICv3 30 Level arch_timer
35: 6 0 GICv3 33 Level uart-pl011
48: 0 0 GICv3 53 Level mmc0
IPI0: 2201 2190 Rescheduling interruptsIf a device is wired but its line never increments, the interrupt is not reaching the CPU — suspect the Device Tree specifier, the trigger type, or a masked line. If it increments but the driver never runs, suspect a shared-IRQ handler returning IRQ_NONE. With CONFIG_GENERIC_IRQ_DEBUGFS, the objects from the sections above become visible:
raghu@techveda.org:~$ sudo cat /sys/kernel/debug/irq/irqs/35
handler: handle_fasteoi_irq
device: uart-pl011
status: 0x00000400
...You can steer an interrupt to a CPU through /proc/irq/, and confirm the handler actually runs with the irq tracepoints:
raghu@techveda.org:~$ echo 1 > /proc/irq/35/smp_affinity_list
raghu@techveda.org:~$ sudo sh -c 'echo 1 > /sys/kernel/tracing/events/irq/enable'
raghu@techveda.org:~$ sudo cat /sys/kernel/tracing/trace_pipe
<idle>-0 [001] d.h. 4213.1: irq_handler_entry: irq=35 name=uart-pl011
<idle>-0 [001] d.h. 4213.1: irq_handler_exit: irq=35 ret=handledThis confirms whether the handler ran and what it returned, without adding print statements to the driver.
Key CONFIG symbols and source map
| Symbol | What it enables |
|---|---|
CONFIG_SPARSE_IRQ | irq_desc allocated on demand in a radix tree, needed for large or hotplug IRQ spaces. |
CONFIG_IRQ_DOMAIN | The hwirq-to-virq mapping library. |
CONFIG_IRQ_DOMAIN_HIERARCHY | Stacked domains (GPIO-over-GIC, MSI, ITS). |
CONFIG_GENERIC_IRQ_CHIP | irq_chip_generic helpers for simple mmio controllers. |
CONFIG_GENERIC_IRQ_MULTI_HANDLER | The set_handle_irq() root-handler mechanism. |
CONFIG_GENERIC_IRQ_DEBUGFS | The /sys/kernel/debug/irq/ interface. |
CONFIG_GENERIC_IRQ_SHOW | The /proc/interrupts output. |
| Path | Contents |
|---|---|
kernel/irq/irqdesc.c | irq_desc, generic_handle_domain_irq(), irq_resolve_mapping() |
kernel/irq/chip.c | Flow handlers, default chip callbacks |
kernel/irq/handle.c | handle_irq_event(), __handle_irq_event_percpu() |
kernel/irq/manage.c | request_irq(), __setup_irq() |
kernel/irq/irqdomain.c | irq_domain, mapping functions |
drivers/of/irq.c | of_irq_parse_raw(), irq_of_parse_and_map() |
drivers/irqchip/ | Controller drivers, e.g. irq-gic-v3.c |
Key takeaways
- A driver requests an interrupt by a global Linux number and supplies a handler; it is portable and knows nothing about the controller.
- The
irq_domaingives every controller a privatehwirqspace while presenting one globalvirqspace, and stacks to model multi-controller SoCs. - The flow handler owns the edge-versus-level acknowledge policy in five well-tested functions.
- The
irq_chipreduces a controller driver to register pokes plus a domain registration. - Once you can name the layer a symptom belongs to — DT specifier, domain mapping, flow handler, chip callback, or driver handler — debugging an interrupt becomes a matter of looking in one place.
Frequently asked questions
What is the difference between a hwirq and a Linux IRQ number?
A hwirq is local to one interrupt controller and starts at zero, so several controllers can each have the same value. The Linux IRQ number, or virq, is a single global number a driver uses. The irq_domain maps a controller and its hwirq to a virq.
Where does the edge-versus-level handling live?
In the flow handler, not the chip driver. handle_level_irq() masks and acks before running handlers and unmasks after; handle_edge_irq() acks first and loops if a new edge arrives. The chip driver only implements the register operations these handlers call.
How does a Device Tree “interrupts” property become a usable IRQ?
At probe time of_irq_parse_raw() walks the Device Tree to find the controller and raw specifier, the controller’s translate callback converts the specifier into a hwirq and trigger type, and irq_create_fwspec_mapping() allocates a virq and its irq_desc. A driver reaches this through platform_get_irq() or irq_of_parse_and_map().
My device’s count in /proc/interrupts stays at zero. What does that indicate?
The interrupt is not reaching the CPU. Check the Device Tree specifier and the programmed trigger type, and confirm the line is not left masked. If the count rises but your handler never runs, look at whether a shared handler is returning IRQ_NONE.
Further reading
- Linux kernel documentation: Linux generic IRQ handling
- Linux kernel documentation: The irq_domain interrupt number mapping library
- Device Tree interrupt bindings —
Documentation/devicetree/bindings/interrupt-controller/ - Source:
kernel/irq/,drivers/of/irq.c,drivers/irqchip/irq-gic-v3.c




