IRQ Domains and Interrupt Mapping
An IRQ domain (
struct irq_domain) is the kernel’s solution to a vocabulary problem: every interrupt controller numbers its inputs in its own hardware-IRQ (hwirq) space — line 7 on this GPIO chip, line 31 on that GIC, message 0 on this MSI controller — but the rest of the kernel wants one flat, global namespace of Linux virtual IRQ numbers (virq) so thatrequest_irq(42, ...)means the same thing everywhere. A domain is the per-controller dictionary that translates between the two: a hwirq → virq mapping plus a reverse map (revmap) for the hot path that goes the other way, virq-from-hwirq, when an interrupt actually fires. Without domains, a board with three interrupt controllers each claiming “IRQ 5” could not coexist; with them, each controller owns its hwirq space privately and the core hands out globally-unique virqs (irqdomain.c, v6.12). This note is pinned to Linux 6.12 LTS with 6.18 LTS deltas called out; verified against the raw kernel trees, not the rendered docs.
This note explains the mechanism — the mapping types, the create/find API, the hierarchical stack used by Message-Signaled Interrupts (MSI) and chained controllers, and how Device Tree and ACPI feed names into a domain. It connects up to Linux Interrupts and Deferred Work MOC and across to IRQ Descriptors and irq_desc (what a virq points at), irq_chip and Flow Handlers (the controller driver behind the domain), and Requesting and Freeing IRQs (what drivers do with the virq a domain produces).
Mental Model: A Domain Is a Per-Controller Dictionary
Think of the system as a tree of interrupt controllers. At the leaves sit devices; at the trunk sits the CPU’s interrupt input. Each controller in that tree is a node, and each node owns an irq_domain. The domain’s job is purely translation: given a hwirq that its controller understands, produce (or look up) the Linux virq that the generic IRQ layer uses, and vice-versa.
The translation runs in two directions, and they are deliberately asymmetric. The forward direction (hwirq → allocate a virq) happens rarely — once, at device setup, when a driver maps its interrupt. It can afford to be slow: it allocates an irq_desc, takes a mutex, walks data structures. The reverse direction (hwirq → find the existing virq) happens on every single interrupt, inside the flow handler, and must be fast. That is why a domain carries a dedicated reverse-map structure optimized for lookup, separate from the forward allocation path.
flowchart TB subgraph hw["Hardware-IRQ spaces (per controller)"] GPIO["GPIO chip<br/>hwirq 0..31"] GIC["GIC<br/>hwirq 0..1019"] MSIc["MSI controller<br/>hwirq = msi index"] end subgraph dom["irq_domain per controller (the dictionary)"] DG["GPIO domain<br/>revmap: linear[32]"] DR["GIC domain<br/>revmap: linear[1020]"] DM["MSI domain<br/>revmap: radix tree"] end subgraph virq["Linux virq space (flat, global)"] V["irq_desc[virq]<br/>one descriptor per virq"] end GPIO --> DG --> V GIC --> DR --> V MSIc --> DM --> V V -.->|"on interrupt: irq_resolve_mapping(domain, hwirq) → virq"| DG
How three controllers with overlapping hwirq numbers coexist. What it shows: each controller keeps its private hwirq numbering; its domain maps those numbers into one shared virq space where irq_desc lives. The insight to take: “IRQ 5” is meaningless without a domain — it is (domain, hwirq=5) that is unique. The dashed arrow is the hot path: when an interrupt fires, the controller’s flow handler calls into the domain’s revmap to recover the virq, then dispatches the registered handler.
Mechanical Walk-through: The Reverse Map
The heart of a domain is its revmap, and v6.12 stores it two ways inside the same struct irq_domain, chosen by the hwirq’s magnitude (irqdomain.h, v6.12, lines 169–198):
struct irq_domain {
...
irq_hw_number_t hwirq_max; /* top hwirq this domain serves */
unsigned int revmap_size; /* size of the linear table */
struct radix_tree_root revmap_tree; /* overflow store, sparse hwirqs */
struct irq_data __rcu *revmap[] __counted_by(revmap_size);
};The trailing revmap[] is a flexible array member: it is allocated as part of the same kzalloc as the domain itself (struct_size(domain, revmap, info->size), irqdomain.c line 235), so a linear domain of size N is one contiguous allocation holding the domain header plus N irq_data pointers. The lookup is then trivial. From __irq_resolve_mapping() (irqdomain.c lines 1044–1084):
rcu_read_lock();
/* Check if the hwirq is in the linear revmap. */
if (hwirq < domain->revmap_size)
data = rcu_dereference(domain->revmap[hwirq]); /* O(1) array index */
else
data = radix_tree_lookup(&domain->revmap_tree, hwirq); /* sparse */
...
rcu_read_unlock();This is the single most important line of the whole subsystem: a hwirq below revmap_size is an array index — constant time, cache-friendly, exactly what you want on every interrupt. A hwirq above that ceiling falls back to the radix tree, which is a tree of pointers keyed by integer, O(log n)-ish but sparse-friendly. The two structures together give domains their named mapping types:
- Linear —
revmap_sizeset to the controller’s full hwirq count, radix tree empty. Best when hwirqs are dense and bounded (a GIC with 1020 lines: allocaterevmap[1020]and every lookup is an array index). Created withirq_domain_create_linear()(line 476) — or the olderirq_domain_add_linear()(see version note below). - Tree —
revmap_size0, everything goes through the radix tree. Best when the hwirq space is huge and sparse — classically MSI, where the “hwirq” might be a wide message index and only a handful are live. Created withirq_domain_create_tree()(line 494). - No-map (direct) —
IRQ_DOMAIN_FLAG_NO_MAP, gated byCONFIG_IRQ_DOMAIN_NOMAP(line 438). Here the virq number is the hwirq — no translation table at all.__irq_resolve_mapping()special-cases this: if the domain is nomap, it returnshwirqdirectly as the irq (lines 1057–1066). This is used by a few controllers (notably some powerpc/XICS setups) where the hardware number space is already the global number space, so a map would be pure overhead.
Uncertain
Verify: the exact present-day set of upstream users of the no-map / direct domain in 6.12/6.18 (the text cites powerpc/XICS from memory of the historical design). Reason: the no-map path is real and present in the source (
IRQ_DOMAIN_FLAG_NO_MAP,CONFIG_IRQ_DOMAIN_NOMAP), but the specific driver consumers were not enumerated from agrepof the consulted trees. To resolve:grep -rl irq_domain.*nomap\|IRQ_DOMAIN_FLAG_NO_MAP drivers/ arch/in the v6.12 tree. uncertain
The fact that linear and tree live in the same struct, switched on a per-hwirq comparison, is the clever part: a “linear” domain transparently spills oversized hwirqs into its radix tree, so the two are not rigid categories but a fast-path/slow-path hybrid.
Creating and Finding Mappings: The Forward Path
A driver that owns an interrupt does not poke the revmap directly — it calls a create function, and the core does the allocation. The two everyday entry points are thin wrappers around the same machinery.
irq_create_mapping(domain, hwirq) (irqdomain.h lines 524–528) maps a single hwirq and returns its virq, creating the mapping if it does not yet exist. It is an inline wrapper over irq_create_mapping_affinity(domain, hwirq, NULL) (irqdomain.c line 834), whose body shows the discipline precisely:
mutex_lock(&domain->root->mutex);
virq = irq_find_mapping(domain, hwirq); /* already mapped? reuse it */
if (virq) goto out;
virq = irq_create_mapping_affinity_locked(domain, hwirq, affinity);
out:
mutex_unlock(&domain->root->mutex);
return virq;Two things to read off this. First, mapping is idempotent: it checks for an existing mapping first and returns it, so calling irq_create_mapping() twice for the same hwirq does not leak a second virq (“Only one mapping per hardware interrupt is permitted,” per the kerneldoc at line 829). Second, the lock is domain->root->mutex — in a hierarchy, all layers share the root domain’s mutex, which is how concurrent allocations across the stack stay consistent.
irq_find_mapping(domain, hwirq) (irqdomain.h line 545) is the pure lookup — no allocation. It is what a flow handler calls (via irq_resolve_mapping) on every interrupt; it returns 0 if there is no mapping. This is the read side of the revmap walked above.
For multi-vector devices the relevant call is irq_domain_alloc_irqs(domain, nr_irqs, node, arg) (irqdomain.h lines 624–628), a wrapper over __irq_domain_alloc_irqs(...). This allocates a contiguous block of nr_irqs virqs at once — exactly what a network card wants when it asks for 64 MSI-X vectors. The kerneldoc on __irq_domain_alloc_irqs (lines 1651–1671) spells out the deliberate two-phase design: phase one (__irq_domain_alloc_irqs) allocates the irq_descs and any hardware resources; phase two (irq_domain_activate_irq) actually programs the hardware. Splitting them “makes it easier to rollback when failing to allocate resources” — if activation of vector 40 fails, the already-allocated descriptors can be unwound cleanly.
Hierarchical Domains: A Stack of Controllers
The single-domain picture above is the flat case. Real interrupt routing on modern hardware is a stack: a device’s MSI message lands in an MSI controller, which is wired into an interrupt remapper (on x86) or an Interrupt Translation Service (the GIC’s ITS on ARM), which ultimately delivers a CPU vector. Each of those is a controller with its own hwirq space, and the kernel models the stack as a hierarchy of irq_domains (CONFIG_IRQ_DOMAIN_HIERARCHY).
A hierarchical domain carries a parent pointer (irqdomain.h line 186) and the IRQ_DOMAIN_FLAG_HIERARCHY flag (line 203). The key property: one virq carries an irq_data at every level of the stack. When you allocate from the top (the per-device MSI domain), each layer in turn allocates from its parent, building a chain of irq_data structures all keyed by the same virq but each describing that layer’s view (its own hwirq, its own irq_chip).
flowchart TB DEV["PCIe device emits MSI write"] --> L0 subgraph stack["Hierarchical irq_domain stack (top = closest to device)"] L0["MSI domain (per-device)<br/>irq_chip: pci_msi<br/>alloc → asks parent"] L1["ITS / IRQ-remap domain<br/>irq_chip: its / intel_ir<br/>alloc → asks parent"] L2["CPU vector domain (root)<br/>irq_chip: apic / gic<br/>alloc → returns vector"] end L0 -->|"parent"| L1 -->|"parent"| L2 L2 --> CPU["CPU vector delivered"]
An MSI hierarchy, top-down. What it shows: allocating one MSI interrupt walks the stack: the per-device MSI domain asks the ITS/remap domain, which asks the root CPU-vector domain, each layer reserving its own resource. The insight to take: a single virq has multiple irq_data/irq_chip pairs — one per layer — and an operation like masking propagates down the stack (irq_chip_mask_parent). This is how “mask this MSI” can mean both “mask at the device” and “mask the vector” without the driver knowing the topology.
The allocation engine is irq_domain_alloc_irqs_hierarchy() (irqdomain.c lines 1592–1602), which simply calls domain->ops->alloc(domain, irq_base, nr_irqs, arg). The trick is that a layer’s .alloc callback typically calls irq_domain_alloc_irqs_parent() (line 648) before doing its own work — that is the recursion that walks up the stack. The setup function irq_domain_alloc_irqs_locked() (lines 1604–1648) ties it together: allocate descriptors, allocate per-layer irq_data, run the hierarchy alloc, then irq_domain_trim_hierarchy() (drop layers a particular vector does not need) and irq_domain_insert_irq() to publish each virq into the revmap.
Hierarchies are constructed with irq_domain_create_hierarchy(parent, flags, size, fwnode, ops, host_data) (irqdomain.h line 599); the parent argument is what links a new domain onto the stack. Operations that must traverse the stack have _parent helpers — irq_chip_mask_parent, irq_chip_eoi_parent, irq_domain_free_irqs_parent (line 652) — so a leaf irq_chip can forward an operation up without knowing how many layers sit above it. This is the entire reason MSI and MSI-X work cleanly across wildly different platforms: the per-device MSI domain is generic, and the platform-specific routing (Intel IRQ remap, AMD IOMMU, GIC ITS) is just the parent layer.
See Message-Signaled Interrupts MSI and MSI-X for the device-side message mechanics and Interrupt Controllers APIC and GIC for what sits at the bottom of these stacks.
Device Tree and ACPI: Where the Names Come From
A domain maps hwirq → virq, but something has to tell a driver which hwirq, in which domain, its device is wired to. On most embedded and ARM systems that source is the Device Tree (DT); on x86 servers and ACPI-based ARM it is ACPI tables. Both ultimately produce an irq_fwspec (a firmware-neutral interrupt specifier) that the core resolves to a virq.
In Device Tree, a device node names its interrupt parent and its line(s):
gpio: gpio@... {
compatible = "...";
interrupt-controller; /* this node IS a controller */
#interrupt-cells = <2>; /* each spec is 2 numbers: line, flags */
};
device@... {
interrupt-parent = <&gpio>; /* whose controller? */
interrupts = <7 IRQ_TYPE_LEVEL_HIGH>; /* line 7, level-high */
};The walk is done by of_irq_parse_one() (drivers/of/irq.c line 342, v6.12), which climbs interrupt-parent links until it reaches a node bearing #interrupt-cells (the controller), reads that many cells from the interrupts property, and produces an of_phandle_args. The convenience wrapper irq_of_parse_and_map(dev, index) (line 38) chains that with irq_create_of_mapping() to hand the driver a ready virq:
unsigned int irq_of_parse_and_map(struct device_node *dev, int index)
{
struct of_phandle_args oirq;
if (of_irq_parse_one(dev, index, &oirq))
return 0;
return irq_create_of_mapping(&oirq); /* → fwspec → domain → virq */
}irq_create_of_mapping() builds an irq_fwspec, finds the matching domain with irq_find_matching_fwspec() (irqdomain.c line 537), calls the domain’s .translate/.xlate op to turn the DT cells into a (hwirq, type) pair, and then maps it. The number of cells is controller-specific: a one-cell binding uses irq_domain_xlate_onecell() (line 1099) where the cell is the hwirq; a two-cell binding (irq_domain_xlate_twocell, line 1124) carries (hwirq, flags). This is why #interrupt-cells exists: it tells the parser how many numbers an interrupts entry contains, and the domain’s xlate function defines what they mean.
On ACPI systems the interrupt routing comes from tables — the MADT (Multiple APIC Description Table) describes the APIC/GIC topology, and _PRT/GSI (Global System Interrupt) numbers feed the equivalent of the DT path. The same irq_fwspec abstraction is the meeting point: ACPI builds a fwspec from a GSI, finds the matching domain, and maps it, so the core code below the fwspec is identical regardless of firmware flavor.
Uncertain
Verify: the precise ACPI → fwspec → domain code path names in 6.12/6.18 (the DT path was read line-by-line from
drivers/of/irq.c; the ACPI side is described at the level of which tables and concepts feed in, not the exact function chain). Reason: the ACPI GSI/_PRT/MADT mapping functions (e.g.acpi_register_gsi,irq_create_fwspec_mapping) were not read line-by-line from the consulted trees in this task. To resolve: readdrivers/acpi/irq.candarch/x86/kernel/apic/GSI handling in the v6.12 tree. uncertain
Version Discipline: 6.12 vs 6.18
The mapping mechanism — revmap, linear/tree/no-map, hierarchy, fwspec — is stable across 6.12 and 6.18. The constructor API surface is mid-cleanup, and this is the version fact most likely to bite. In v6.12 the device-tree-node-based helpers irq_domain_add_linear() / irq_domain_add_tree() are ordinary functions. In v6.18 the same header marks them, together with of_node_to_fwnode(), with the comment “Deprecated functions. Will be removed in the merge window” (irqdomain.h, v6.18, lines 706–744). New code should use the fwnode-based irq_domain_create_linear() / irq_domain_create_tree(), or the most general irq_domain_instantiate(&info) with an irq_domain_info descriptor. The reverse-map struct layout (revmap[] __counted_by(revmap_size), radix revmap_tree, the IRQ_DOMAIN_FLAG_NO_MAP flag) is byte-for-byte the same shape in both 6.12 and 6.18 (6.18 irqdomain.h lines 175, 201), so everything in the mechanical sections above holds for both.
Failure Modes and Common Misunderstandings
“IRQ 5” is not a global constant. The most common conceptual error is treating a hwirq as if it were the virq. On a legacy x86 PC the first 16 IRQs happen to be set up as a legacy domain where hwirq == virq, which lulls people into thinking the numbers are universal. They are not: on any board with multiple controllers, (domain, hwirq) is the unique identity, and the virq is whatever the core allocated.
Forgetting to map before requesting. request_irq() (see Requesting and Freeing IRQs) takes a virq. If a driver passes a raw hwirq, or a virq for which no mapping exists, it gets -EINVAL because there is no irq_desc. The fix is to obtain the virq from irq_of_parse_and_map(), platform_get_irq(), or irq_create_mapping() first.
Double-mapping is harmless but double-disposing is not. Because irq_create_mapping() is idempotent, mapping twice is fine. But irq_dispose_mapping() tears down the revmap entry; calling it while the virq is still in use, or twice, corrupts the mapping. Managed allocation (devm) and the MSI core handle this for you; hand-rolled domain users must be careful.
Revmap ceiling surprises. A linear domain sized too small silently routes large hwirqs into the radix tree — correct, but slower, and a sign the revmap_size/hwirq_max was misjudged. Conversely, a linear domain sized for a huge sparse space wastes memory on a giant mostly-NULL revmap[] array; that case wants a tree domain instead.
Alternatives and When to Choose Them
There is no real “alternative” to domains for in-tree controllers — they are the mandated abstraction. The choice is which mapping type:
- Dense, bounded hwirq space (a GIC, an I/O-APIC, a GPIO bank): linear. Array-indexed lookup, predictable memory.
- Sparse, large hwirq space (MSI/MSI-X message indices): tree. Radix tree pays only for live entries.
- hwirq already equals the global number (some powerpc): no-map / direct. Skip the table entirely.
- Stacked controllers / MSI / remapping: hierarchical domains layered with
irq_domain_create_hierarchy(), regardless of the per-layer mapping type.
A driver author who is consuming interrupts almost never builds a domain — they call platform_get_irq() / irq_of_parse_and_map() and get a virq. Domains are built by interrupt-controller drivers (the irqchip drivers under drivers/irqchip/), which is the right mental separation: domains are controller infrastructure, virqs are the driver-facing currency.
Production Notes
The domain layer is why a single mlx5 or nvme driver binary works identically on an x86 box (LAPIC + I/O-APIC + IRQ remap) and an ARM server (GIC + ITS): the driver asks for N MSI-X vectors, the per-device MSI domain allocates down whatever hierarchy the platform built, and the driver receives N virqs with no platform-specific code. When something goes wrong, /proc/interrupts shows the per-virq counters and the chip name (the bottom-of-stack irq_chip), and /sys/kernel/debug/irq/domains/ (with CONFIG_GENERIC_IRQ_DEBUGFS) dumps each domain’s mapping and hierarchy — the first place to look when an interrupt “fires into nowhere” because a mapping was never created or was disposed early.
Uncertain
Verify: the exact debugfs path (
/sys/kernel/debug/irq/domains/) and its gating config in 6.12/6.18. Reason: stated from general knowledge ofCONFIG_GENERIC_IRQ_DEBUGFS; not confirmed against a fetched source in this task. To resolve: checkkernel/irq/debugfs.cin the v6.12 tree for the registered paths. uncertain
See Also
- IRQ Descriptors and irq_desc — what a virq indexes into; the per-interrupt descriptor a domain mapping ultimately points at
- irq_chip and Flow Handlers — the controller driver and per-virq dispatch that sits behind every domain
- Requesting and Freeing IRQs — the driver API that consumes the virq a domain produces
- Message-Signaled Interrupts MSI and MSI-X — the canonical user of tree-type and hierarchical domains
- Interrupt Controllers APIC and GIC — the hardware at the root of every hierarchy
- The Generic IRQ Subsystem — the layer domains plug into
- Linux Interrupts and Deferred Work MOC — parent map (section B, The Generic IRQ Layer)