Interrupt Controllers APIC and GIC
An interrupt controller is the hardware block that sits between a device’s interrupt line and the CPU’s trap machinery. Its job is routing (which of the N CPUs should be interrupted?) and prioritization (if several interrupts are pending, which one is delivered first, and which can preempt which?). Without it a CPU could not tell which device fired, could not steer interrupts to a chosen core, and could not mask one device while servicing another. On x86 the controller is the Advanced Programmable Interrupt Controller (APIC) family — a per-CPU Local APIC (LAPIC) plus one or more I/O APICs — which replaced the ancient Intel 8259 Programmable Interrupt Controller (PIC). On ARM it is the Generic Interrupt Controller (GIC) — a global Distributor plus a per-CPU CPU interface (GICv2) or Redistributor + system-register CPU interface (GICv3). Linux never lets a driver touch these directly; it drives every controller through an
irq_chipdriver, the x86 ones living inarch/x86/kernel/apic/and the ARM ones indrivers/irqchip/(verified against Linux v6.12 LTS).
This note is the hardware-routing companion to the generic IRQ layer. It explains the two dominant controller families — the x86 APIC and the ARM GIC — focusing on the same three responsibilities each must implement: receive an interrupt event, decide which CPU and at what priority, and present it as a vector the CPU can trap on. The Linux-side abstraction that wraps these controllers (struct irq_chip, the flow handlers, IRQ descriptors) lives in irq_chip and Flow Handlers and The Generic IRQ Subsystem; this note covers the silicon those drivers program. For the RISC-V analogues see Platform-Level Interrupt Controller (PLIC, the external-interrupt aggregator) and Core Local Interruptor (CLINT, the per-hart timer/software-interrupt block) — different architecture, same routing-and-prioritizing role.
Mental Model
Think of an interrupt controller as a mux-plus-priority-encoder with a per-CPU mailbox. Devices on one side assert interrupt events; CPUs on the other side want to be told “service interrupt vector V now.” In between, the controller decides which CPU’s mailbox gets which event, in what order. The design splits naturally into two halves: a shared/global part that knows about all device sources and the routing rules, and a per-CPU part that holds the priority state for one processor and is what that CPU actually reads to acknowledge an interrupt. On x86 the global part is the I/O APIC and the per-CPU part is the Local APIC; on ARM the global part is the Distributor and the per-CPU part is the CPU interface (with the Redistributor added in GICv3 to hold per-CPU configuration). The two families are strikingly parallel once you see this split.
flowchart LR subgraph DEV["Device sources"] D1["NIC pin / line"] D2["Disk controller"] D3["Another CPU (IPI)"] end subgraph GLOBAL["Global routing part"] IOAPIC["x86: I/O APIC<br/>(redirection table)<br/>ARM: Distributor (GICD)"] end subgraph PERCPU["Per-CPU part"] LAPIC0["x86: Local APIC (CPU0)<br/>ARM: CPU interface +<br/>Redistributor (CPU0)"] LAPIC1["...(CPU1)"] end D1 --> IOAPIC D2 --> IOAPIC IOAPIC -->|"routed by dest field"| LAPIC0 IOAPIC --> LAPIC1 D3 -->|"IPI delivered directly"| LAPIC0 LAPIC0 -->|"vector → CPU trap"| CPU0["CPU0 traps to kernel"]
The universal shape of an interrupt controller. What it shows: device sources feed a global routing block (I/O APIC on x86, Distributor on ARM) whose programmable routing decides which per-CPU block (Local APIC / CPU interface) receives the event; that per-CPU block presents a vector and traps the CPU. Inter-processor interrupts skip the global block — one CPU pokes another CPU’s per-CPU block directly. The insight to take: “routing” is literally a destination field written into a table entry, and “the interrupt arrives at CPU N” means “CPU N’s per-CPU block latched a pending vector and raised the CPU’s interrupt input.”
The x86 APIC Family
What it replaced: the 8259 PIC
Before the APIC, the PC used a pair of cascaded Intel 8259 PICs, giving 15 usable interrupt request lines (IRQ0–IRQ15, with IRQ2 consumed by the cascade). The 8259 is fundamentally a uniprocessor device: it has a fixed priority order, no concept of steering an interrupt to a particular CPU, and a programmed-I/O register interface. The Linux I/O APIC code still carries this legacy: it notes that “Traditionally ISA IRQ2 is the cascade IRQ, and is not available to devices,” and it must special-case the legacy IRQs 0–15 because “there may be multiple IOAPIC pins sharing the same ISA IRQ number and irqdomain only supports 1:1 mapping between IOAPIC pin and IRQ number” (io_apic.c, v6.12). On a modern boot the 8259 is typically left configured but masked, or routed through an I/O APIC pin in virtual wire / ExtINT mode purely for compatibility — the kernel comment reads “If the i8259 is routed through an IOAPIC Put that IOAPIC in virtual wire mode so legacy interrupts can be delivered” (ibid.). The PIC is history you still trip over, not a controller new code targets.
The Local APIC (LAPIC)
Every logical CPU has its own Local APIC. It is the per-CPU half of the mux model: it receives interrupts routed to this CPU, holds the per-CPU priority state, owns the local timer that drives the scheduler tick on that core, and is the path one CPU uses to send an inter-processor interrupt (IPI) to another. Its registers are a fixed bank; the offsets (from apicdef.h, v6.12) include APIC_ID (0x20, this CPU’s APIC identity), APIC_LVR (0x30, the version register), APIC_TASKPRI (0x80, the task-priority register that masks low-priority vectors), APIC_EOI (0xB0, write to signal end-of-interrupt), APIC_SPIV (0xF0, the spurious-interrupt vector register, whose bit APIC_SPIV_APIC_ENABLED = (1 << 8) is the master enable), APIC_ICR/APIC_ICR2 (0x300/0x310, the interrupt command register used to send IPIs), and the Local Vector Table (LVT) entries APIC_LVTT (0x320, the timer), APIC_LVTTHMR (0x330, thermal), APIC_LVTERR (0x370, APIC error), and APIC_LVT0/APIC_LVT1 (0x350/0x360, the two external/NMI lines) (apicdef.h, v6.12).
setup_local_APIC() in apic.c brings a LAPIC online: it temporarily disables the APIC by clearing the enable bit in APIC_SPIV, configures the logical-destination format if needed, sets the task-priority register to mask vectors 0–31 (those ranges are reserved for CPU exceptions, not device interrupts), clears any stale in-service/interrupt-request bits, then re-enables the APIC and installs the spurious-interrupt vector (apic.c, v6.12). The spurious vector (SPURIOUS_APIC_VECTOR, conventionally 0xFF) catches the race where an interrupt is withdrawn between the CPU sampling its input and reading which vector fired; handle_spurious_interrupt() decides whether an apic_eoi() is owed.
The LAPIC timer is one of the most important per-CPU services. __setup_APIC_LVTT() programs the timer LVT for one of three modes — periodic (APIC_LVT_TIMER_PERIODIC = (1 << 17)), one-shot ((0 << 17)), or TSC-deadline (APIC_LVT_TIMER_TSCDEADLINE = (2 << 17), available only when X86_FEATURE_TSC_DEADLINE_TIMER is present) — and calibrate_APIC_clock() measures the bus clock against a known reference to establish lapic_timer_period. This timer is the hardware behind the periodic tick and the high-resolution timers, which is why the interrupt subsystem and timekeeping are coupled (see Linux Time and Timers MOC).
The I/O APIC
Device pins do not connect to a Local APIC; they connect to an I/O APIC, the global routing half. Each I/O APIC has a redirection table with one entry (a Redirection Table Entry, RTE — struct IO_APIC_route_entry in the kernel) per input pin. The entry tells the controller, for that pin: which vector to deliver, the delivery mode (fixed, lowest-priority, NMI, etc.), destination mode (physical vs logical, dest_mode_logical), polarity (active_low), trigger (is_level for level vs edge), a mask bit, the remote IRR (pending-acknowledge) bit, the delivery status, and the destination APIC ID(s) to route to (io_apic.c, v6.12). The I/O APIC is accessed through an index/data register pair: native_io_apic_read() writes the register number to io_apic->index then reads io_apic->data. Crucially, the driver writes the high word of an RTE before the low word, because the low word contains the mask bit — the comment is explicit: “When we write a new IO APIC routing entry, we need to write the high word first! If the mask bit in the low word is clear, we will enable the interrupt, and we need to make sure the entry is fully populated before that happens” (ibid.).
The single most illuminating comment in the entire file reframes what the I/O APIC is: “The I/OAPIC is just a device for generating MSI messages from legacy interrupt pins. Various fields of the RTE translate into bits of the resulting MSI which had a historical meaning” (ibid.). In other words, even a “pin” interrupt is, internally, turned into a memory-write message that targets a Local APIC — which is exactly the mechanism that Message-Signaled Interrupts MSI and MSI-X exposes directly to PCIe devices. The redirection table’s dest field is “routing”: change it and the same pin’s interrupts land on a different CPU. Linux exposes this as a hierarchical IRQ domain, the ioapic_irqdomain, whose mp_ioapic_irqdomain_ops implement alloc (allocate an IRQ and pin the entry), activate (write the RTE to hardware), deactivate (mask the pin), and free (ibid.).
The kernel uses Global System Interrupt (GSI) numbers to name pins hardware-independently across multiple I/O APICs; each I/O APIC owns a [gsi_base, gsi_end] range, so firmware tables (ACPI) and the kernel agree on a flat numbering regardless of how many chips exist.
x2APIC — scaling past 255 CPUs
The original (“xAPIC”) interface is memory-mapped at a fixed physical base and uses an 8-bit APIC ID, which caps a system at 255 addressable processors (ID 0xFF is the broadcast value). Large servers blow past that. x2APIC mode solves it two ways: it widens the APIC ID to 32 bits, and it replaces the MMIO register window with Model-Specific Registers (MSRs) — apic_read/apic_write become rdmsr/wrmsr against the APIC MSR range. The MSR interface is also faster (no uncached MMIO round-trip and no need for the serializing fence on most accesses; TSC-deadline writes still require an mfence per the Intel SDM). __x2apic_enable() sets the X2APIC_ENABLE bit in MSR_IA32_APICBASE, and the kernel tracks an x2apic_state machine through X2APIC_OFF, X2APIC_DISABLED, X2APIC_ON, and X2APIC_ON_LOCKED (apic.c, v6.12). The 32-bit ID is what makes “send this interrupt to physical CPU #800” expressible at all.
Uncertain
Verify: the precise statement that x2APIC writes are non-serializing for most accesses while TSC-deadline requires
mfence. Reason: this is paraphrased from theapic.ccomments and the Intel SDM as summarized during research, not quoted verbatim from the SDM itself. To resolve: check the Intel SDM Vol. 3A x2APIC chapter on register-access ordering semantics directly. uncertain
The ARM GIC
The ARM Generic Interrupt Controller plays the same role with different vocabulary. Per ARM’s own description, it splits into a Distributor (global; configures and routes shared interrupts), one Redistributor per core (GICv3; holds per-core configuration and the LPI data-structure pointers), and a per-core CPU interface (the registers a core touches while handling an interrupt) (Arm GIC fundamentals). The mapping onto the mental model is exact: Distributor ≈ I/O APIC (global routing), Redistributor + CPU interface ≈ Local APIC (per-CPU state and acknowledge path).
The four interrupt classes
The GIC names interrupts by who can receive them and how they are generated, and assigns each class a fixed interrupt-ID range (irq-gic-v3.c, v6.12):
- SGI — Software-Generated Interrupt (IDs 0–15). Generated by software writing a register, used for inter-processor interrupts — the ARM equivalent of the x86 IPI. Per ARM, an SGI is “generated by a write to an SGI register in the GIC.” This is the only interrupt a CPU raises on purpose to poke another CPU.
- PPI — Private Peripheral Interrupt (IDs 16–31). A peripheral interrupt private to one core — e.g. that core’s local timer. Configuration lives in the per-core Redistributor (GICv3) because it is inherently per-CPU.
- SPI — Shared Peripheral Interrupt (IDs 32–1019). A peripheral interrupt that can be delivered to any connected core — a NIC, a disk controller. These are configured in the global Distributor, whose
dest/affinity routing decides which core handles them. This is the bread-and-butter device interrupt. - LPI — Locality-specific Peripheral Interrupt (IDs 8192+). Introduced in GICv3, LPIs have, in ARM’s words, “a very different programming model” from the other three: they are message-based, generated by an MSI write that the ITS translates (see below). LPIs are how GICv3 supports the thousands of MSI vectors a modern PCIe device wants.
GICv3 also adds extended ranges the driver enumerates — EPPI (1024–1055) and ESPI (4096–5119) — for systems that need more private/shared lines than the classic ranges allow (irq-gic-v3.c, v6.12).
GICv2 vs GICv3 — the CPU-interface change
The biggest architectural jump from GICv2 to GICv3 is how a core talks to its CPU interface. In GICv2 the CPU interface is memory-mapped (you read a register at a physical address to acknowledge an interrupt). In GICv3 the CPU interface moved into system registers (ICC_* accessed via MRS/MSR instructions) for speed, and a new per-core Redistributor block was added to hold the PPI/SGI configuration and LPI pointers that used to be scattered. The ARM64 boot requirements make the mode switch concrete: to use a GICv3 in native v3 mode the firmware must set the System-Register-Enable bits (ICC_SRE_EL3.SRE/.Enable and ICC_SRE_EL2.SRE/.Enable to 0b1), whereas to fall back to GICv2 compatibility mode it clears ICC_SRE_EL3.SRE to 0b0 (booting.rst, v6.12). Linux supports both with separate drivers — irq-gic.c for GICv2 and irq-gic-v3.c for GICv3 — under drivers/irqchip/.
How GICv3 acknowledges an interrupt
The acknowledge path is the per-CPU half in action. gic_handle_irq() reads the Interrupt Acknowledge Register, gic_read_iar() (the ICC_IAR1_EL1 system register), which atomically returns the interrupt ID of the highest-priority pending interrupt and moves it to “active.” The driver dispatches with generic_handle_domain_irq(gic_data.domain, irqnr) into the generic IRQ layer, then signals completion by writing the ID back to ICC_EOIR1_EL1 (write_gicreg(irqnr, ICC_EOIR1_EL1) followed by an isb() to serialize) (irq-gic-v3.c, v6.12). Priority masking is done through ICC_PMR_EL1 — interrupts below the running priority are held. The driver ships two irq_chips: gic_chip (classic EOI-then-deactivate in one write) and gic_eoimode1_chip (split mode, where EOI drops priority but a separate write to GICD_ICACTIVER deactivates — needed for virtualization, where a guest must be allowed to “EOI” without truly deactivating the host’s interrupt).
To send an IPI, gic_ipi_send_mask() encodes the set of target CPUs into ICC_SGI1R_EL1, computing each target’s cluster affinity via gic_cpu_to_affinity() and the MPIDR_TO_SGI_AFFINITY() macros (ibid.) — i.e. an IPI is literally “write an SGI register naming the destination cores,” the direct ARM mirror of writing the x86 APIC_ICR.
The ITS — GICv3’s MSI engine
GICv3 handles message-signaled interrupts through a dedicated block, the Interrupt Translation Service (ITS). A device performs an MSI memory write carrying a DeviceID (which device) and an EventID (which of its interrupts); the ITS looks these up in per-device Interrupt Translation Tables (ITTs) and translates the pair into an LPI targeted at a particular CPU collection (irq-gic-v3-its.c, v6.12). The ITS is programmed through a command queue carrying commands like MAPD (map a device), MAPTI (map an event to an LPI), and MOVI (move an LPI to a different collection / CPU). The driver allocates the LPI configuration table (PROPBASE) sized “to cover 2 ^ lpi_id_bits LPIs … (one configuration byte per interrupt)” (ibid.). The ITS is therefore the GICv3 analogue of the x86 path where an MSI write is decoded into a Local-APIC-targeted vector — the device-facing half of that story is Message-Signaled Interrupts MSI and MSI-X.
How Linux Drives Them: irq_chip in drivers/irqchip/
Linux deliberately hides all of the above behind one abstraction so a driver author never writes a redirection-table entry or an ICC_* register. Each controller is a struct irq_chip — a callback table (->irq_mask, ->irq_unmask, ->irq_eoi, ->irq_set_affinity, ->irq_set_type) — and the generic layer’s flow handlers decide the order of those calls based on edge-vs-level semantics; both are detailed in irq_chip and Flow Handlers. The APIC chips live under arch/x86/kernel/apic/; the GIC chips and almost every other SoC controller live under drivers/irqchip/ (irq-gic.c, irq-gic-v3.c, irq-gic-v3-its.c, and dozens of vendor blocks). Controllers stack into hierarchical IRQ domains: on x86 the I/O APIC domain sits below an (optional) interrupt-remapping domain below the CPU-vector domain, so “there may be multiple interrupt controllers involved in delivering an interrupt from the device to the target CPU,” and each layer’s irq_chip “may implement a required action by itself or by cooperating with its parent irq_chip” (irq-domain.html). The mapping from a controller’s hardware interrupt number to a Linux IRQ number is the job of IRQ Domains and Interrupt Mapping.
Comparison and When Each Applies
You do not choose between APIC and GIC — the architecture chooses for you (x86 → APIC, ARM → GIC, RISC-V → PLIC/AIA). The value of seeing them side by side is recognizing the shared structure, so knowledge transfers:
| Concept | x86 APIC | ARM GIC (v3) | RISC-V |
|---|---|---|---|
| Global routing block | I/O APIC (redirection table) | Distributor (GICD) | PLIC |
| Per-CPU acknowledge block | Local APIC | CPU interface (ICC_*) + Redistributor | PLIC context / CLINT |
| Device pin interrupt | I/O APIC RTE → LAPIC | SPI via Distributor | PLIC external source |
| Per-CPU private interrupt | LAPIC LVT (timer, thermal) | PPI | CLINT timer |
| Inter-processor interrupt | IPI via APIC_ICR | SGI via ICC_SGI1R_EL1 | CLINT software interrupt |
| MSI mechanism | MSI write → LAPIC vector | LPI via the ITS | AIA IMSIC/APLIC |
| Scaling fix for many CPUs | x2APIC (32-bit ID, MSR access) | GICv3 affinity routing | AIA |
The IPI rows are the subject of Inter-Processor Interrupts; the MSI rows of Message-Signaled Interrupts MSI and MSI-X.
Failure Modes and Gotchas
Spurious interrupts. Both families can deliver a “spurious” interrupt when an interrupt is withdrawn after the CPU has begun acknowledging it. x86 reserves SPURIOUS_APIC_VECTOR for this; a storm of spurious interrupts usually points at a level-triggered line whose source was not properly cleared. Misconfiguring an RTE’s is_level/active_low against the actual hardware wiring is a classic cause — the line never deasserts and re-fires forever (an interrupt storm).
RTE write ordering. The I/O APIC “high word first” rule exists because the mask bit lives in the low word; writing low-then-high can momentarily unmask a half-programmed entry and deliver an interrupt with a garbage vector. Drivers must respect it; this is why Linux centralizes RTE writes rather than letting drivers poke the table.
x2APIC not enabled. On a >255-CPU machine booted without x2APIC (firmware setting, or nox2apic on the kernel command line), the kernel cannot address the high CPUs and silently restricts routing — interrupts that “should” land on CPU 300 cannot. The fix is enabling x2APIC and (often) interrupt remapping in firmware.
GIC SRE bits unset. If firmware fails to set the ICC_SRE_* system-register-enable bits, a GICv3 cannot be driven in native mode and the kernel either falls back to slow GICv2 compatibility or fails to bring up secondary CPUs — exactly the boot requirement spelled out in booting.rst.
See Also
- Message-Signaled Interrupts MSI and MSI-X — how PCIe devices raise interrupts as memory writes the controller decodes (the device-facing side of the ITS and the I/O-APIC “MSI from pins” comment)
- Inter-Processor Interrupts — IPIs (x86) / SGIs (ARM), the CPU-pokes-CPU path through these controllers
- irq_chip and Flow Handlers — the Linux
irq_chipabstraction that wraps every controller in this note - The Generic IRQ Subsystem — the in-kernel model that hides controllers from drivers
- IRQ Domains and Interrupt Mapping — how a controller’s hardware IRQ numbers become Linux IRQ numbers
- Platform-Level Interrupt Controller — RISC-V’s external-interrupt aggregator (the PLIC), the architectural analogue of the I/O APIC / Distributor
- Core Local Interruptor — RISC-V’s per-hart timer and software-interrupt block (the CLINT)
- Interrupt Vectors and the IDT — what a “vector” is on x86 and how the CPU traps on it
- Linux Interrupts and Deferred Work MOC — the parent map