irq_chip and Flow Handlers
The Linux generic interrupt (IRQ) layer cleanly splits “how to poke this specific controller” from “what sequence of pokes a given interrupt trigger type requires.” The first half is
struct irq_chip— the controller driver, a table of callbacks (->irq_ack,->irq_mask/->irq_unmask,->irq_eoi,->irq_set_affinity,->irq_set_type) that know how to drive an Advanced Programmable Interrupt Controller (APIC), a Generic Interrupt Controller (GIC), or a GPIO expander, but know nothing about edge-versus-level semantics. The second half is the flow handler —handle_edge_irq,handle_level_irq,handle_fasteoi_irq,handle_percpu_irq— each encoding the correct order of those callbacks around running the device handlers, an order that differs because edge-triggered and level-triggered interrupts have fundamentally different acknowledge-and-rearm rules. This separation is why the samegic_chipworks whether a line is wired edge or level: the flow handler, not the chip, owns the timing. Verified against Linux v6.12 LTS (include/linux/irq.h,kernel/irq/chip.c).
This note explains struct irq_chip and its callbacks, then walks each major flow handler line-by-line from the v6.12 source, with the why — why edge and level need different sequences, why “ack early” versus “mask then ack then unmask later” versus “single EOI at the end” are the three archetypes. It is the controller-abstraction companion to IRQ Descriptors and irq_desc, which covers the descriptor (irq_desc) that holds both the irq_chip (inside irq_data) and the flow handler (->handle_irq). See The Generic IRQ Subsystem for the overview.
Mental Model
The split is a strategy/mechanism decomposition. The irq_chip is the mechanism: a set of primitive operations on one controller (“mask this line,” “send EOI,” “acknowledge”). The flow handler is the strategy: given a trigger type, what order do I invoke those primitives, and when do I run the device’s handler? The generic layer chooses the strategy (the flow handler) based on how the line was configured (edge vs. level vs. per-CPU), and the strategy calls into the chip’s mechanism. Neither knows the other’s details: the flow handler doesn’t know whether ->irq_mask writes a GIC register or an APIC one; the chip doesn’t know whether it’s being driven for an edge or level line.
flowchart TB ENTRY["CPU traps → generic_handle_irq()<br/>→ desc->handle_irq(desc)"] --> FLOW{"Which flow handler?<br/>(chosen by trigger type)"} FLOW -->|edge| EDGE["handle_edge_irq<br/>ack early, loop while PENDING"] FLOW -->|level| LEVEL["handle_level_irq<br/>mask+ack, run, unmask after"] FLOW -->|"fasteoi (APIC/GIC)"| FEOI["handle_fasteoi_irq<br/>run, single eoi at end"] FLOW -->|per-CPU| PERCPU["handle_percpu_irq<br/>ack, run, eoi (no lock)"] EDGE --> CHIP["struct irq_chip callbacks<br/>->irq_ack ->irq_mask<br/>->irq_unmask ->irq_eoi"] LEVEL --> CHIP FEOI --> CHIP PERCPU --> CHIP CHIP --> HW["controller hardware<br/>APIC / GIC / GPIO"]
Strategy meets mechanism. What it shows: the descriptor’s ->handle_irq (the flow handler, picked from the trigger type) sits above struct irq_chip (the per-controller callback table); all four flow-handler strategies drive the same chip primitives in different orders. The insight to take: changing a line from edge to level changes the flow handler — the strategy — not the chip; that is the whole point of the split.
struct irq_chip — The Controller Driver
struct irq_chip is a table of callbacks supplied by a controller driver (the APIC code, irq-gic.c, a GPIO driver). Abridged to the dispatch-critical members (include/linux/irq.h, v6.12):
struct irq_chip {
const char *name;
unsigned int (*irq_startup)(struct irq_data *data);
void (*irq_shutdown)(struct irq_data *data);
void (*irq_enable)(struct irq_data *data);
void (*irq_disable)(struct irq_data *data);
void (*irq_ack)(struct irq_data *data);
void (*irq_mask)(struct irq_data *data);
void (*irq_mask_ack)(struct irq_data *data);
void (*irq_unmask)(struct irq_data *data);
void (*irq_eoi)(struct irq_data *data);
int (*irq_set_affinity)(struct irq_data *data,
const struct cpumask *dest, bool force);
int (*irq_retrigger)(struct irq_data *data);
int (*irq_set_type)(struct irq_data *data, unsigned int flow_type);
int (*irq_set_wake)(struct irq_data *data, unsigned int on);
/* ... bus_lock, MSI, NMI, vCPU-affinity callbacks ... */
unsigned long flags;
};The load-bearing callbacks, and what each means to the controller:
->irq_ack— acknowledge the interrupt at the controller, telling it “I have seen this, stop signalling it as a new event.” For an edge controller this clears the latched edge so a subsequent edge can be detected. Called early byhandle_edge_irq.->irq_mask/->irq_unmask— disable/enable the line at the controller. A masked line is held off entirely; the controller will not deliver it. These bracket the device handler inhandle_level_irq.->irq_mask_ack— an optimization: mask and acknowledge in one register write, where the hardware supports it.mask_ack_irq()uses it if present, else falls back toirq_maskthenirq_ack.->irq_eoi— “End Of Interrupt,” the modern controllers’ single completion signal. Telling an APIC or GIC “done, you may deliver the next one.” Called once, at the end, byhandle_fasteoi_irq.->irq_set_affinity— steer this interrupt to a CPU (or set of CPUs). Programs the controller’s routing register (the I/O APIC redirection entry, the GIC distributor’s target register). This is the hardware action behind IRQ affinity.->irq_set_type— configure the trigger type (IRQ_TYPE_EDGE_RISING,IRQ_TYPE_LEVEL_HIGH, …). This is the callback that, among other things, decides which flow handler the core installs, because the core inspects the returned type to pickhandle_edge_irqvs.handle_level_irq.->irq_retrigger— re-inject an interrupt the kernel deferred (resend), used by the software-resend path when an interrupt arrives while it cannot be handled.
The flags field carries IRQCHIP_* capability bits (include/linux/irq.h, v6.12):
enum {
IRQCHIP_SET_TYPE_MASKED = (1 << 0),
IRQCHIP_EOI_IF_HANDLED = (1 << 1),
IRQCHIP_MASK_ON_SUSPEND = (1 << 2),
IRQCHIP_ONOFFLINE_ENABLED = (1 << 3),
IRQCHIP_SKIP_SET_WAKE = (1 << 4),
IRQCHIP_ONESHOT_SAFE = (1 << 5),
IRQCHIP_EOI_THREADED = (1 << 6),
IRQCHIP_SUPPORTS_LEVEL_MSI = (1 << 7),
IRQCHIP_SUPPORTS_NMI = (1 << 8),
IRQCHIP_ENABLE_WAKEUP_ON_SUSPEND = (1 << 9),
IRQCHIP_AFFINITY_PRE_STARTUP = (1 << 10),
IRQCHIP_IMMUTABLE = (1 << 11),
};IRQCHIP_EOI_IF_HANDLED and IRQCHIP_EOI_THREADED directly shape handle_fasteoi_irq’s EOI timing (below). IRQCHIP_ONESHOT_SAFE tells the core a chip needs no extra masking for IRQF_ONESHOT threaded handlers. A chip is wired into a descriptor with irq_set_chip_and_handler_name(irq, chip, handler, name), which sets both irq_data.chip and irq_desc->handle_irq — binding the mechanism and the strategy together.
Why Edge and Level Need Different Handlers
The crux. A level-triggered interrupt asserts a line and holds it asserted until the device is serviced (its status register cleared). A edge-triggered interrupt is a momentary transition (rising/falling) that the controller latches; the line does not stay asserted.
This produces opposite hazards:
-
Level — the re-storm hazard. If you simply ack a level interrupt and run the handler with the line still unmasked, the controller sees the line still asserted (the device hasn’t been serviced yet) and immediately re-delivers — an interrupt storm before your handler even finishes. The fix: mask the line first, ack it, run the handler (which clears the device’s condition, de-asserting the line), then unmask. Masking holds off the spurious re-fires; by the time you unmask, the line is genuinely inactive.
-
Edge — the lost-interrupt hazard. An edge is a fleeting event. If you mask an edge line and a new edge arrives while masked, the controller may still latch it (good) — but if your strategy is “mask, run handler, unmask,” any edge that arrives after you ack but before you finish must not be dropped. So the edge strategy acks early (to clear the latch and allow re-latching) and then loops: after running the handler, it checks whether a new edge was latched (
IRQS_PENDING) and, if so, runs the handler again — draining all edges that arrived during processing before returning. Masking is used only defensively (when an edge arrives while the handler is mid-flight) and is immediately undone.
In one sentence: level interrupts re-fire until the device is serviced, so you mask around the handler; edge interrupts can be lost if not promptly re-armed, so you ack early and loop. The flow handlers encode exactly these two disciplines.
handle_level_irq — Mask, Ack, Run, Unmask
From kernel/irq/chip.c (v6.12):
void handle_level_irq(struct irq_desc *desc)
{
raw_spin_lock(&desc->lock);
mask_ack_irq(desc); /* (1) mask AND ack up front */
if (!irq_may_run(desc))
goto out_unlock;
desc->istate &= ~(IRQS_REPLAY | IRQS_WAITING);
if (unlikely(!desc->action || irqd_irq_disabled(&desc->irq_data))) {
desc->istate |= IRQS_PENDING; /* no handler: leave masked, mark pending */
goto out_unlock;
}
kstat_incr_irqs_this_cpu(desc);
handle_irq_event(desc); /* (2) run the device handler(s) */
cond_unmask_irq(desc); /* (3) unmask now that line is de-asserted */
out_unlock:
raw_spin_unlock(&desc->lock);
}Line (1), mask_ack_irq(), is the linchpin — it masks the line before anything else so the still-asserted level cannot re-storm:
static inline void mask_ack_irq(struct irq_desc *desc)
{
if (desc->irq_data.chip->irq_mask_ack) {
desc->irq_data.chip->irq_mask_ack(&desc->irq_data);
irq_state_set_masked(desc);
} else {
mask_irq(desc);
if (desc->irq_data.chip->irq_ack)
desc->irq_data.chip->irq_ack(&desc->irq_data);
}
}If the chip offers a combined ->irq_mask_ack, one register write does both; otherwise the core masks then acks separately. Step (2) runs handle_irq_event(), which walks desc->action and calls each device handler — the handler clears the device condition, de-asserting the line. Step (3), cond_unmask_irq(), unmasks conditionally:
static void cond_unmask_irq(struct irq_desc *desc)
{
if (!irqd_irq_disabled(&desc->irq_data) &&
irqd_irq_masked(&desc->irq_data) && !desc->threads_oneshot)
unmask_irq(desc);
}It refuses to unmask if the IRQ was disabled meanwhile, or if a threaded IRQF_ONESHOT handler is still pending (threads_oneshot non-zero) — in that case the line stays masked until the thread finishes, which is the whole point of IRQF_ONESHOT. The hazard this avoids: unmasking a level line whose device condition the threaded handler has not yet cleared would re-storm.
handle_edge_irq — Ack Early, Loop While Pending
void handle_edge_irq(struct irq_desc *desc)
{
raw_spin_lock(&desc->lock);
desc->istate &= ~(IRQS_REPLAY | IRQS_WAITING);
if (!irq_may_run(desc)) {
desc->istate |= IRQS_PENDING;
mask_ack_irq(desc);
goto out_unlock;
}
if (irqd_irq_disabled(&desc->irq_data) || !desc->action) {
desc->istate |= IRQS_PENDING;
mask_ack_irq(desc);
goto out_unlock;
}
kstat_incr_irqs_this_cpu(desc);
/* Start handling the irq */
desc->irq_data.chip->irq_ack(&desc->irq_data); /* (A) ACK EARLY */
do {
if (unlikely(!desc->action)) {
mask_irq(desc);
goto out_unlock;
}
/* A new edge arrived while handling; we may have masked it.
* Re-enable if it wasn't disabled meanwhile. */
if (unlikely(desc->istate & IRQS_PENDING)) {
if (!irqd_irq_disabled(&desc->irq_data) &&
irqd_irq_masked(&desc->irq_data))
unmask_irq(desc); /* (C) re-arm */
}
handle_irq_event(desc); /* (B) run handler */
} while ((desc->istate & IRQS_PENDING) &&
!irqd_irq_disabled(&desc->irq_data)); /* (D) loop on new edges */
out_unlock:
raw_spin_unlock(&desc->lock);
}The structure embodies the edge discipline. Step (A) acks before running the handler — clearing the latched edge so the controller can latch the next one. Step (B) runs the handler. The do … while at (D) is the lost-interrupt defense: if another edge arrived while the handler ran (the kernel sets IRQS_PENDING when a still-in-progress edge fires), the loop runs the handler again rather than returning and dropping the event. Step (C) handles the sub-case where a re-entrant edge caused the line to be masked: it unmasks to re-arm. The kernel-doc above the function (v6.12) states this directly: “After the ack another interrupt can happen on the same source even before the first one is handled… This requires to reenable the interrupt inside of the loop which handles the interrupts which have arrived while the handler was running. If all pending interrupts are handled, the loop is left.”
Contrast with level: edge acks first then loops; level masks first then unmasks after. The asymmetry is the entire reason two handlers exist.
handle_fasteoi_irq — Single EOI at the End
Modern controllers (APIC, GIC) handle the masking/acking flow in hardware and expose only a single completion signal, the EOI. For these, handle_fasteoi_irq issues exactly one chip callback — ->irq_eoi — after servicing:
void handle_fasteoi_irq(struct irq_desc *desc)
{
struct irq_chip *chip = desc->irq_data.chip;
raw_spin_lock(&desc->lock);
if (!irq_may_run(desc)) {
if (irqd_needs_resend_when_in_progress(&desc->irq_data))
desc->istate |= IRQS_PENDING;
goto out;
}
desc->istate &= ~(IRQS_REPLAY | IRQS_WAITING);
if (unlikely(!desc->action || irqd_irq_disabled(&desc->irq_data))) {
desc->istate |= IRQS_PENDING;
mask_irq(desc);
goto out;
}
kstat_incr_irqs_this_cpu(desc);
if (desc->istate & IRQS_ONESHOT)
mask_irq(desc); /* oneshot: keep masked for the thread */
handle_irq_event(desc);
cond_unmask_eoi_irq(desc, chip); /* unmask if needed, then EOI */
if (unlikely(desc->istate & IRQS_PENDING))
check_irq_resend(desc, false);
raw_spin_unlock(&desc->lock);
return;
out:
if (!(chip->flags & IRQCHIP_EOI_IF_HANDLED))
chip->irq_eoi(&desc->irq_data); /* EOI even when not handled, unless flag set */
raw_spin_unlock(&desc->lock);
}The kernel-doc (v6.12) frames the design: “Only a single callback will be issued to the chip: an ->eoi() call when the interrupt has been serviced. This enables support for modern forms of interrupt handlers, which handle the flow details in hardware, transparently.” The IRQCHIP_EOI_IF_HANDLED flag in the out: path controls whether a not-handled interrupt (no action, or disabled) still gets an EOI — some controllers require the EOI unconditionally to advance their priority state, others do not. The IRQS_ONESHOT masking interacts with threaded IRQs: the line is masked across the hard-IRQ half and only unmasked after the thread completes, so a level-asserted device cannot re-fire while its threaded handler runs.
This is the handler used for almost every interrupt on a contemporary x86 or ARM server — it is the “transparent controller” path, and its single-EOI simplicity is why APIC/GIC interrupt handling is cheap.
handle_percpu_irq — No Locking, Per-CPU
Some interrupts are per-CPU by nature — the local timer, IPIs, performance-counter overflow. Each CPU has its own instance of the line, so there is no cross-CPU sharing and therefore no descriptor lock is needed — the cheapest flow handler:
void handle_percpu_irq(struct irq_desc *desc)
{
struct irq_chip *chip = irq_desc_get_chip(desc);
__kstat_incr_irqs_this_cpu(desc);
if (chip->irq_ack)
chip->irq_ack(&desc->irq_data);
handle_irq_event_percpu(desc);
if (chip->irq_eoi)
chip->irq_eoi(&desc->irq_data);
}Notice: no raw_spin_lock(&desc->lock) at all, and it uses __kstat_incr_irqs_this_cpu (the lighter, non-tot_count increment). The sequence is ack → run → eoi. The variant handle_percpu_devid_irq is the same but passes a per-CPU dev_id (action->percpu_dev_id) to the handler, used where the same Linux IRQ number means a different device on each CPU. Because there is no lock and no shared state, these handlers have the lowest dispatch overhead — appropriate for the highest-frequency interrupts in the system.
handle_simple_irq and Demultiplexed Sources
handle_simple_irq does no chip flow control at all — no ack, no mask, no eoi — and just runs the handler under the descriptor lock:
void handle_simple_irq(struct irq_desc *desc)
{
raw_spin_lock(&desc->lock);
if (!irq_may_run(desc))
goto out_unlock;
desc->istate &= ~(IRQS_REPLAY | IRQS_WAITING);
if (unlikely(!desc->action || irqd_irq_disabled(&desc->irq_data))) {
desc->istate |= IRQS_PENDING;
goto out_unlock;
}
kstat_incr_irqs_this_cpu(desc);
handle_irq_event(desc);
out_unlock:
raw_spin_unlock(&desc->lock);
}Its kernel-doc says it plainly: “the caller is expected to handle the ack, clear, mask and unmask issues if necessary.” This is for software-decoded interrupts — a demultiplexing parent handler that has already done all the hardware acking and is now dispatching a synthetic child IRQ via generic_handle_irq() (see IRQ Descriptors and irq_desc for the dispatch path). handle_untracked_irq is a further-stripped variant that also skips statistics and spurious-detection, used when a demux source cannot even identify which child generated the interrupt.
Failure Modes and Common Misunderstandings
Wrong flow handler for the trigger type. Installing handle_edge_irq on a level line means the line is never masked around the handler — a level device that asserts continuously produces an interrupt storm (the line stays asserted, the controller keeps delivering). The reverse, handle_level_irq on an edge line, can lose edges that arrive during the masked window. The core normally picks the right one from ->irq_set_type, but a driver that hand-installs a handler with irq_set_chip_and_handler() can get this wrong. Symptom: storms (level-on-edge-handler) or missed interrupts / hung device (edge-on-level-handler).
Forgetting EOI semantics on fasteoi chips. A fasteoi controller that does not receive its ->irq_eoi will not deliver further interrupts of equal-or-lower priority — the system goes deaf on that line (and possibly others) after the first interrupt. The out: path in handle_fasteoi_irq exists precisely so that even an unhandled interrupt is EOI’d (unless IRQCHIP_EOI_IF_HANDLED opts out).
Assuming irq_mask stops an in-flight handler. Masking prevents new deliveries; it does not abort a handler already running on another CPU. The descriptor’s lock plus the IRQD_IRQ_INPROGRESS state bit (checked by irq_may_run()) serialize re-entry — masking is a hardware-delivery gate, not a software mutex.
Per-CPU handlers and shared state. handle_percpu_irq takes no descriptor lock. A driver whose per-CPU interrupt handler touches shared (non-per-CPU) state without its own locking has a race the flow handler will not save it from.
Alternatives and When to Choose Them
The flow-handler family is not a menu drivers pick from freely — the controller driver and the trigger configuration together determine it: a GIC/APIC line gets handle_fasteoi_irq; a legacy PIC or simple GPIO level line gets handle_level_irq; an edge GPIO gets handle_edge_irq; a per-CPU source gets handle_percpu_irq. The genuine choice point is when you write a controller driver (an irq_chip) for new hardware: you implement the callbacks and tell the core which flow handler matches your hardware’s ack/eoi model. For “transparent” hardware that does its own flow control, expose ->irq_eoi and use fasteoi; for raw edge/level controllers, expose ->irq_ack/->irq_mask/->irq_unmask and let the edge/level handlers drive them. The decision is a property of the silicon, not a tuning knob.
Production Notes
On x86, the per-CPU local timer, IPIs, and the bulk of device interrupts (via the I/O APIC or MSI) route through handle_fasteoi_irq or handle_percpu_irq — the lock-free and single-EOI paths — which is why interrupt dispatch is not a scalability bottleneck even at millions of interrupts per second. The edge/level handlers dominate on embedded ARM/GPIO-heavy systems where lines are individually wired and trigger types vary per pin. When debugging an interrupt that “fires once and stops,” the first check is which flow handler is installed (visible in /sys/kernel/debug/irq/irqs/<N>) versus the line’s actual electrical trigger type — a mismatch there is the classic cause. The IRQCHIP_* flags on the chip (also dumped in debugfs) explain EOI and masking quirks; IRQCHIP_EOI_THREADED, for instance, defers the EOI to after a threaded handler, changing when the controller considers the interrupt complete.
See Also
- IRQ Descriptors and irq_desc — sibling: the
irq_descthat holds the chip (inirq_data) and the flow handler (->handle_irq) - The Generic IRQ Subsystem — parent: the overall generic-IRQ architecture
- IRQ Domains and Interrupt Mapping — sibling: how the controller’s hwirq maps to a Linux IRQ before any of this runs
- Interrupt Controllers APIC and GIC — the hardware the
irq_chipcallbacks drive - Threaded Interrupt Handlers — the
IRQF_ONESHOT/threads_oneshotinteraction with level and fasteoi masking - IRQ Affinity and irqbalance — driven by
->irq_set_affinity - Shared Interrupt Lines — the
->actionchain thathandle_irq_eventwalks - Linux Interrupts and Deferred Work MOC — the map this note belongs to (section B)