Post

Interrupt_handling_in_hardware

Interrupt_handling_in_hardware

Interrupt Handling in Hardware

A modern processor does not simply execute instructions in a straight line from power-on to shutdown. At any moment, a disk controller may finish a read, a network card may receive a packet, or the CPU itself may stumble on an illegal opcode. The processor needs a way to drop what it is doing, attend to the event, and then resume its original work without corruption. That mechanism is the interrupt. Interrupts are the fundamental bridge between asynchronous hardware events and the deterministic flow of software. Without them, the kernel would have to continuously poll every device to see if it needs attention — wasting enormous amounts of CPU time doing nothing useful. Interrupts let the hardware tap the processor on the shoulder, so the CPU can spend its cycles on real work and respond to events only when they actually occur.

This document explores how interrupts work at the hardware level — what they are, how signals propagate from devices through interrupt controllers to the CPU, how the processor locates and dispatches the right handler, and what the CPU does, step by step, when an interrupt arrives. The focus is on general principles that apply across architectures, with specific references to the x86 and AArch64 implementations in the Linux kernel source tree.


What Is an Interrupt

An interrupt is an electrical signal, generated by hardware or triggered by software, that causes the processor to suspend its current execution and transfer control to a predefined handler routine. The key idea is simple: instead of the CPU asking “has anything happened?” in a loop, the device says “something happened” by asserting a signal, and the CPU reacts.

From the processor’s perspective, an interrupt is an event that alters the normal sequential flow of instruction execution. The CPU finishes (or aborts) its current instruction, saves enough state to resume later, and jumps to a handler. When the handler completes, the CPU restores the saved state and picks up where it left off. This entire mechanism is transparent to the interrupted code — it never knows it was paused.

The term “interrupt” is often used loosely to cover two related but distinct concepts: interrupts proper (asynchronous, caused by external hardware) and exceptions (synchronous, caused by the CPU itself while executing an instruction). The Linux kernel treats both through the same general dispatch framework, but the distinction matters when reasoning about timing, reproducibility, and handler design.


The Role of Interrupt Signals

Interrupt signals serve three essential roles in a running system:

First, they enable asynchronous I/O. A disk controller, network interface, or USB host controller can work independently of the CPU. When the device completes an operation — a DMA transfer finishes, a packet arrives, a key is pressed — it asserts an interrupt. The CPU handles the event and moves on. Without this, the processor would have to sit in a tight polling loop waiting for each device, unable to run user programs or service other devices in the meantime.

Second, they enforce hardware protection. When a user-space program tries to access a page that is not mapped, or executes a privileged instruction, or divides by zero, the CPU raises a synchronous exception. This forces a trap into kernel mode, where the kernel decides whether to fix the problem (e.g., demand-page the missing page), deliver a signal to the process, or kill it. The interrupt mechanism is the hardware-enforced boundary between what user code is allowed to do and what it is not.

Third, they coordinate multiprocessor systems. On SMP machines, one CPU can send an Inter-Processor Interrupt (IPI) to another CPU to request a TLB flush, a reschedule, or a function call. On x86, this is done through the Local APIC; on AArch64, through Software Generated Interrupts (SGI) in the GIC.


Types of Interrupts

Interrupts can be classified along two axes: by their origin (hardware vs. software) and by their maskability (whether the CPU can temporarily ignore them).

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
                            Interrupts & Exceptions
                            ========================
                                      |
                 +--------------------+--------------------+
                 |                                         |
          Hardware Interrupts                     Software Exceptions
           (Asynchronous)                           (Synchronous)
                 |                                         |
        +--------+--------+                    +-----------+-----------+
        |                 |                    |           |           |
    Maskable        Nonmaskable             Faults      Traps      Aborts
   (I/O IRQs)     (NMI, MCE)           (Page Fault) (Breakpoint) (Double
        |                                                          Fault)
   +----+----+
   |         |
Level     Edge
Triggered Triggered
          |
     +----+----+
     |         |
  Rising    Falling
   Edge      Edge

Maskable and Nonmaskable Interrupts

A maskable interrupt is one that the CPU can choose to ignore. The processor maintains an interrupt-enable flag (the IF flag in x86’s EFLAGS register, the I bit in AArch64’s PSTATE). When this flag is cleared, the CPU will not respond to maskable interrupt requests. The kernel uses this extensively — for example, disabling interrupts while manipulating a per-CPU data structure to prevent the handler from corrupting it mid-update.

All normal I/O device interrupts — disk, network, keyboard, timer — are maskable. They are important, but the CPU can briefly defer them when it is doing something that must not be interrupted.

Examples of maskable interrupts:

  • Keyboard controller — generates an IRQ each time a key is pressed or released (typically IRQ 1 on x86).
  • Network interface card — raises an interrupt when a packet arrives or a transmit completes.
  • Disk controller — signals completion of a DMA read/write operation.
  • APIC timer / ARM generic timer — periodic or one-shot timer tick for scheduling, timekeeping, and watchdogs.
  • USB host controller — notifies the CPU of device attach/detach or transfer completion.

A nonmaskable interrupt (NMI) cannot be disabled through normal software means. Only a few critical events generate NMIs — hardware failures, watchdog timeouts, memory parity errors. Because they cannot be masked, NMI handlers must be written with extreme care: they cannot acquire most locks (the interrupted code might already hold them), and they run on a special stack. On x86, NMI uses vector 2 and is routed through a dedicated Interrupt Stack Table (IST) entry to avoid stack corruption if the NMI arrives while the kernel is switching stacks.

Examples of nonmaskable interrupts:

  • Hardware watchdog timeout — an external watchdog chip (or the APIC NMI watchdog) fires when the system appears hung, allowing a diagnostic handler to capture a stack trace or trigger a panic.
  • Memory parity / ECC uncorrectable error — the memory controller detects data corruption it cannot fix and signals the CPU via NMI so the kernel can log the error and decide whether to continue or halt.
  • Machine-check exception (MCE) — the CPU itself detects an internal hardware fault (bad cache line, bus error, thermal overrun) and delivers it as a non-maskable event.
  • External NMI button — some server motherboards have a physical NMI button that injects a diagnostic interrupt, used by administrators to capture a crash dump of an otherwise unresponsive system.

Hardware Interrupts (Asynchronous)

Hardware interrupts are asynchronous — they bear no timing relationship to the instruction the CPU is executing when the signal arrives. A network packet can arrive at any point during any instruction. The CPU notices the signal at the boundary between instructions (or at certain checkpoints within long instructions) and takes the interrupt.

Hardware interrupts are generated by peripheral devices and delivered to the CPU through an interrupt controller. The electrical signaling between the device and the controller uses one of two schemes:

Level-Triggered Interrupts

In a level-triggered scheme, the device asserts (holds active) its interrupt line as long as it needs service. The interrupt controller sees the active level and forwards the request to the CPU. After the handler services the device and clears the condition, the device de-asserts the line.

1
2
3
4
5
6
7
8
Level-Triggered Signal:
                           device needs service
                     ┌─────────────────────────────┐
          HIGH ──────┘                             └────── HIGH (de-asserted)
                     ▲                             ▲
                     │                             │
               device asserts               handler clears
               interrupt line              device condition

The critical property of level-triggered interrupts is that they are self-reminding. If the handler fails to clear the source, the line stays asserted and the CPU will be interrupted again immediately upon re-enabling interrupts. This makes them robust — you cannot silently lose an interrupt — but it also means a misconfigured handler that does not acknowledge the device will cause an interrupt storm, with the CPU trapped in an infinite loop of entering and exiting the handler.

Level-triggered signaling is common on shared interrupt lines, where multiple devices are wired to the same physical signal. The handler must poll each device on the shared line to determine which one actually needs service.

Examples of level-triggered interrupts:

  • PCI conventional interrupts (INTA#–INTD#) — PCI devices hold their interrupt line asserted (active-low) until the driver reads the device’s status register and clears the interrupt source. Multiple devices can share one PCI interrupt line precisely because level-triggering is self-reminding.
  • ARM GIC SPIs configured as level-sensitive — many SoC peripherals (UART, SPI controller, I2C controller) use level-triggered signaling by default. The device holds its IRQ output active until the driver acknowledges the event by reading a data register or writing to a status-clear register.
  • Legacy 8259 PIC devices — traditional ISA devices (serial port, parallel port) use level-triggered mode on systems configured for it.

Edge-Triggered Interrupts

In an edge-triggered scheme, the interrupt is signaled by a transition on the line, not by its steady-state level. There are two variants:

  • Rising edge: the interrupt fires on the transition from low to high.
  • Falling edge: the interrupt fires on the transition from high to low.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
Rising-Edge Triggered (fires on LOW → HIGH transition):

                     ┌──────────┐          ┌──────────
          HIGH       │          │          │
                     │          │          │
          LOW  ──────┘          └──────────┘
                     ▲                     ▲
                     │                     │
                 IRQ fires             IRQ fires
                  (LOW→HIGH)            (LOW→HIGH)

                 (HIGH→LOW transitions are ignored)


Falling-Edge Triggered (fires on HIGH → LOW transition):

          HIGH ──────┐          ┌──────────┐
                     │          │          │
          LOW        └──────────┘          └──────────
                     ▲                     ▲
                     │                     │
                 IRQ fires             IRQ fires
                  (HIGH→LOW)            (HIGH→LOW)

                 (LOW→HIGH transitions are ignored)

The interrupt controller latches the transition and forwards a single event to the CPU. The key difference from level-triggered is that the signal is a momentary event, not a sustained condition.

Edge-triggered interrupts have a subtle hazard: if a second interrupt arrives while the first is still being handled, and the handler does not re-check the device status, the second event can be lost. The line transitioned, was latched once, and the second transition happened while the latch was already set. For this reason, well-written edge-triggered handlers typically re-check the device in a loop before returning.

1
2
3
4
5
6
7
8
9
10
11
12
Edge-Triggered Lost Interrupt Problem:

         1st IRQ          2nd IRQ (LOST — latch already set)
             ▲                ▲
             │                │
  HIGH       │   ┌────┐      │   ┌────────
             │   │    │      │   │
  LOW  ──────┘   │    └──────┘   │
                 │               │
          handler runs...   handler still running,
          latch set         latch was never cleared,
                            2nd edge goes unnoticed

Edge-triggered is common in modern message-signaled interrupt (MSI) schemes, where the “edge” is actually a write to a memory-mapped register rather than a physical wire transition.

Examples of edge-triggered interrupts:

  • MSI / MSI-X (Message Signaled Interrupts) — used by modern PCIe devices (NVMe SSDs, GPUs, high-speed NICs). Instead of driving a physical wire, the device writes a small message to a special memory address. Each write is a single “edge” event. MSI-X supports up to 2048 independent interrupt vectors per device, eliminating sharing entirely.
  • GPIO interrupts — many SoC GPIO pins can be configured for rising-edge, falling-edge, or both-edge detection. A button press, for example, generates a falling-edge interrupt when pressed and a rising-edge when released.
  • ARM GIC SGIs (Software Generated Interrupts) — always edge-triggered, since they are one-shot signals sent by software from one core to another.

Software Interrupts (Synchronous Exceptions)

A synchronous exception is generated by the CPU because of the instruction it is currently executing. Unlike hardware interrupts, exceptions are reproducible — execute the same instruction in the same state, and you get the same exception every time. The processor detects the anomalous condition, and instead of completing the instruction normally, diverts execution to the exception handler.

Exceptions are divided into three categories based on what happens to the instruction pointer when the exception is taken:

Faults

A fault reports a condition that the handler may be able to correct. When the handler finishes, the processor restarts the faulting instruction. The saved instruction pointer (the value pushed onto the kernel stack) points to the instruction that caused the fault.

1
2
3
4
5
6
7
8
9
Fault Flow:

  Instruction N (faults)     Exception Handler      Instruction N (retried)
  ─────────┬──────────────►  ───────────────────►   ─────────────────────►
           │                 fixes the condition           │
           │                 (e.g., maps a page)           │
           │                                               │
    saved EIP/ELR ──────────────────────────────────► re-executed
    points HERE

The most important example is the page fault. When user code accesses a virtual address that has no physical mapping, the CPU raises a page fault (vector 14 on x86, a data/instruction abort on AArch64). The kernel’s page fault handler checks whether the access is legitimate — maybe the page was swapped out, or the memory region is valid but the page table entry hasn’t been populated yet. If so, the handler allocates a physical page, updates the page table, and returns. The CPU re-executes the original instruction, which now succeeds. The user process never knows anything happened.

Examples of faults:

  • Page fault (x86 vector 14 / AArch64 data/instruction abort) — the most common fault in normal operation. Triggered when accessing an unmapped virtual address. The kernel either demand-pages the memory, loads it from swap, or performs copy-on-write, then returns so the CPU retries the instruction.
  • General protection fault (#GP, x86 vector 13) — raised when a program violates segment limits, attempts a privileged instruction from user mode, or writes to a read-only segment. In most cases the kernel delivers SIGSEGV to the process.
  • Alignment fault (AArch64, or x86 with AC flag set) — triggered when an unaligned memory access is attempted on a CPU configured to enforce alignment. The kernel may either fix up the access or kill the process.
  • Divide error (x86 vector 0) — integer division by zero or a quotient overflow. The kernel delivers SIGFPE to the process.
  • Floating-point exception (#MF, x86 vector 16) — a pending x87 FPU error (overflow, underflow, invalid operation) detected on the next FPU instruction.

Traps

A trap is reported after the trapping instruction has completed. The saved instruction pointer points to the next instruction — the one that would have executed if the trap hadn’t occurred. When the handler returns, execution resumes from that next instruction, not from the trapping instruction.

1
2
3
4
5
6
7
8
9
Trap Flow:

  Instruction N (traps)      Exception Handler      Instruction N+1
  ────────────────────┬──►   ───────────────────►   ─────────────────►
     completes first  │      (e.g., notifies           │
                      │       the debugger)             │
                      │                                 │
               saved EIP/ELR ────────────────────► resumes HERE
               points to N+1

The defining characteristic of a trap is that the triggering instruction has already done its work. There is no need (and no desire) to re-execute it. The classic example is the breakpoint (int3 on x86, BRK on AArch64). A debugger replaces an instruction byte with the breakpoint opcode. When the CPU hits it, it traps into the kernel, which notifies the debugger. The debugger inspects the program state, and when the user says “continue”, execution resumes at the instruction after the breakpoint.

System calls on x86 historically used int 0x80 (a software-initiated trap), though modern kernels use the faster SYSCALL/SYSENTER instructions.

Examples of traps:

  • Breakpoint (int3 / x86 vector 3, BRK on AArch64) — used by debuggers like GDB. The debugger replaces an instruction byte with the breakpoint opcode. When the CPU executes it, the trap fires, the kernel sends SIGTRAP to the debugged process, and the debugger takes control.
  • Overflow (into / x86 vector 4) — triggered by the INTO instruction when the overflow flag (OF) is set after an arithmetic operation.
  • Debug trap (x86 vector 1 in trap mode, AArch64 software step exception) — used for single-stepping. After each instruction, the CPU traps into the debugger so it can inspect registers and memory.
  • System call via int 0x80 (x86 vector 128) — the legacy Linux system call entry point. The instruction completes (places the syscall number in eax), then traps into the kernel to service the call.

Aborts

An abort signals a severe, unrecoverable error. The CPU may not be able to reliably report the exact instruction that caused the problem, so the saved instruction pointer is undefined or approximate. The handler cannot fix the condition and typically has no choice but to terminate the affected process (or panic the entire system if the error occurred in kernel mode).

Examples of aborts:

  • Double fault (x86 vector 8) — occurs when the CPU encounters an exception while trying to handle a prior exception (e.g., a page fault during delivery of a general protection fault). The CPU cannot make forward progress, so it takes the double fault handler, which typically panics the system. On x86, this runs on its own dedicated IST stack because the original kernel stack may be the reason the double fault occurred in the first place.
  • Machine-check exception (MCE) (x86 vector 18) — the CPU’s internal error-detection circuitry has found an uncorrectable problem: a failing CPU cache line, a poisoned memory location, a bus timeout. The handler logs as much diagnostic information as possible. If the error is contained to a single user process, the kernel may kill just that process; otherwise, it panics.
  • Triple fault — not a real exception vector, but a condition where a fault occurs during the double fault handler. The CPU has no further fallback — it performs a hardware reset (reboot). This is why double fault handlers are kept as simple as possible and use a dedicated stack.
  • Coprocessor segment overrun (x86 vector 9) — a legacy abort from early x87 FPU implementations, largely obsolete on modern CPUs but still reserved in the IDT.

Interrupt Controllers: x86 APIC vs. ARM GIC

A peripheral device does not talk directly to the CPU core. Between the device’s interrupt output pin and the processor’s interrupt input sits an interrupt controller — a piece of hardware whose job is to collect interrupt requests from multiple devices, prioritize them, and deliver them to the appropriate CPU core.

The interrupt controller exists because processors have very few interrupt input pins (often just one for normal interrupts and one for NMI), but a system may have dozens or hundreds of interrupt sources. The controller multiplexes them.

x86: The APIC System

Modern x86 systems use the Advanced Programmable Interrupt Controller (APIC) architecture, which has two components:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
                    x86 APIC Architecture
  ┌─────────────────────────────────────────────────────┐
  │                                                     │
  │   ┌──────────┐  ┌──────────┐      ┌──────────┐     │
  │   │ Device A │  │ Device B │ ...  │ Device N │     │
  │   └────┬─────┘  └────┬─────┘      └────┬─────┘     │
  │        │              │                 │           │
  │        ▼              ▼                 ▼           │
  │   ┌────────────────────────────────────────────┐    │
  │   │              I/O APIC                      │    │
  │   │    (Redirection Table: pin → vector+CPU)   │    │
  │   └──────────────────┬─────────────────────────┘    │
  │                      │ system bus messages           │
  │          ┌───────────┼───────────┐                  │
  │          ▼           ▼           ▼                  │
  │   ┌────────────┐ ┌────────────┐ ┌────────────┐     │
  │   │ Local APIC │ │ Local APIC │ │ Local APIC │     │
  │   │   (CPU 0)  │ │   (CPU 1)  │ │   (CPU N)  │     │
  │   └─────┬──────┘ └─────┬──────┘ └─────┬──────┘     │
  │         ▼               ▼               ▼           │
  │      ┌─────┐         ┌─────┐         ┌─────┐       │
  │      │ CPU │         │ CPU │         │ CPU │       │
  │      │  0  │         │  1  │         │  N  │       │
  │      └─────┘         └─────┘         └─────┘       │
  └─────────────────────────────────────────────────────┘

The I/O APIC sits on the system bus (one or more per system) and collects interrupt lines from I/O devices. It contains a redirection table — a configurable mapping from each input pin to a destination CPU and vector number. The I/O APIC can route interrupts to specific CPUs, distribute them across CPUs, or broadcast them. The I/O APIC redirection table is configured by arch/x86/kernel/apic/io_apic.c.

The Local APIC is integrated into each CPU core. It receives interrupts from the I/O APIC (via system bus messages), from local sources (the APIC timer, performance monitoring counters, thermal sensors), and from other CPUs (IPIs). The Local APIC prioritizes pending interrupts and delivers them to its core one at a time.

The APIC delivers interrupts to the CPU as vector numbers — an integer from 0 to 255. The vector space is partitioned as follows, defined in arch/x86/include/asm/irq_vectors.h:

Vector RangeConstantPurpose
0–31CPU exceptions (hardcoded by architecture)
32 (FIRST_EXTERNAL_VECTOR)–127Device interrupts
128Legacy int 0x80 syscall
129–234More device interrupts
235 (FIRST_SYSTEM_VECTOR)–255System vectors: APIC timer, reschedule IPI, spurious, etc.

AArch64: The GIC (Generic Interrupt Controller)

ARM systems use the Generic Interrupt Controller (GIC), with GICv3 being the current generation. The GIC has a similar goal to the APIC but a different architecture, reflecting ARM’s focus on scalability and SoC-style designs.

The GIC has three main components:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
                    ARM GICv3 Architecture
  ┌────────────────────────────────────────────────────────┐
  │                                                        │
  │   ┌──────────┐  ┌──────────┐       ┌──────────┐       │
  │   │ Device A │  │ Device B │  ...  │ Device N │       │
  │   │ (SPI)    │  │ (SPI)    │       │ (SPI)    │       │
  │   └────┬─────┘  └────┬─────┘       └────┬─────┘       │
  │        │              │                  │             │
  │        ▼              ▼                  ▼             │
  │   ┌──────────────────────────────────────────────┐     │
  │   │              Distributor (GICD)               │     │
  │   │  (enable, priority, routing for all SPIs)     │     │
  │   └──────────────────┬───────────────────────────┘     │
  │                      │                                 │
  │          ┌───────────┼───────────┐                     │
  │          ▼           ▼           ▼                     │
  │   ┌─────────────┐ ┌─────────────┐ ┌─────────────┐     │
  │   │Redistributor│ │Redistributor│ │Redistributor│     │
  │   │  (GICR)     │ │  (GICR)     │ │  (GICR)     │     │
  │   │  CPU 0      │ │  CPU 1      │ │  CPU N      │     │
  │   │ SGI+PPI     │ │ SGI+PPI     │ │ SGI+PPI     │     │
  │   └──────┬──────┘ └──────┬──────┘ └──────┬──────┘     │
  │          ▼               ▼               ▼            │
  │   ┌─────────────┐ ┌─────────────┐ ┌─────────────┐     │
  │   │CPU Interface│ │CPU Interface│ │CPU Interface│     │
  │   │ (ICC_* sys  │ │ (ICC_* sys  │ │ (ICC_* sys  │     │
  │   │  registers) │ │  registers) │ │  registers) │     │
  │   └──────┬──────┘ └──────┬──────┘ └──────┬──────┘     │
  │          ▼               ▼               ▼            │
  │       ┌─────┐         ┌─────┐         ┌─────┐        │
  │       │ CPU │         │ CPU │         │ CPU │        │
  │       │  0  │         │  1  │         │  N  │        │
  │       └─────┘         └─────┘         └─────┘        │
  └────────────────────────────────────────────────────────┘

The Distributor is a single global unit that receives all interrupt sources and routes them to the appropriate CPU. It holds the enable, priority, and target configuration for each interrupt. Its registers are mapped at a base address provided by the device tree or ACPI. The distributor is initialized by gic_dist_init().

The Redistributor exists one per CPU core. It handles per-CPU interrupt state — particularly PPIs and SGIs — and serves as the interface between the Distributor and each CPU’s interrupt interface. Per-CPU setup is handled by gic_cpu_init().

The CPU Interface is the component closest to the core. On GICv3, this is accessed through system registers (ICC_* registers) rather than memory-mapped I/O, which is faster than GICv2’s memory-mapped approach.

GICv3 defines four categories of interrupt, identified by their hardware interrupt ID (INTID) ranges, encoded in the gic_intid_range enum:

TypeINTID RangeScopePurpose
SGI (Software Generated Interrupt)0–15Per-CPUIPIs between cores — TLB flush, reschedule
PPI (Private Peripheral Interrupt)16–31Per-CPUCPU-local devices: timer, watchdog, PMU
SPI (Shared Peripheral Interrupt)32–1019GlobalShared I/O devices: UART, ethernet, USB
LPI (Locality-specific Peripheral Interrupt)8192+GlobalMessage-based, managed by the ITS (Interrupt Translation Service)

The GICv3 driver lives at drivers/irqchip/irq-gic-v3.c. Its central data structure is gic_data, of type struct gic_chip_data. During initialization (gic_init_bases()), it registers gic_handle_irq() as the system’s top-level interrupt handler via set_handle_irq(). The driver is bound to the device tree with IRQCHIP_DECLARE(gic_v3, ...).

Why Both Exist

The APIC and GIC solve the same fundamental problem — multiplexing many interrupt sources onto a small number of CPU inputs — but they evolved in very different ecosystems. The x86 APIC grew out of the legacy 8259 PIC, progressively adding SMP support, MSI, and per-CPU routing. The ARM GIC was designed from scratch for the mobile and embedded SoC world, where hundreds of cores and thousands of interrupt sources are common, and power efficiency demands fine-grained control over which core handles which interrupt. The GIC’s clear separation into Distributor/Redistributor/CPU Interface, and its message-based LPI mechanism, reflect these scalability requirements.

Despite architectural differences, both controllers present interrupts to the CPU through the same fundamental model: device asserts interrupt → controller resolves priority and target → CPU receives the interrupt and indexes into a table (the IDT on x86, the vector table on AArch64) to find the handler.


Interrupt Request (IRQ)

An Interrupt Request (IRQ) is the logical name given to an interrupt line — a numbered channel over which a device can signal the CPU. When we say “the network card is on IRQ 11,” we mean that the network card’s interrupt output is wired (physically or logically) to input 11 of the interrupt controller, and the kernel has associated a handler with that number.

In the Linux kernel, IRQ numbers go through a translation layer. Hardware has its own numbering (the hwirq — the pin number on the interrupt controller or the INTID in the GIC). Linux assigns its own virtual IRQ number (virq) through the IRQ domain system (struct irq_domain / struct irq_domain_ops). The domain maps between hwirq and virq, allowing multiple interrupt controllers (each with their own numbering scheme) to coexist without number collisions.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
         IRQ Number Translation
  ┌──────────────────────────────────────────────────┐
  │                                                  │
  │  Hardware World              Linux Kernel        │
  │  ─────────────              ────────────         │
  │                                                  │
  │  I/O APIC pin 3 ─┐                              │
  │                   │    ┌──────────────┐          │
  │  GIC SPI #47   ───┼──►│  irq_domain  │──► virq  │
  │                   │    │  (hwirq →    │   (e.g.  │
  │  GPIO chip #12 ───┘    │    virq map) │    42)   │
  │                        └──────┬───────┘          │
  │                               │                  │
  │                               ▼                  │
  │                        ┌──────────────┐          │
  │                        │  irq_desc    │          │
  │                        │  ├─irq_data  │          │
  │                        │  ├─flow_hdlr │          │
  │                        │  └─action ───┼──► handler chain
  │                        └──────────────┘          │
  └──────────────────────────────────────────────────┘

Each virtual IRQ number is backed by a struct irq_desc, the central per-interrupt data structure in the kernel. It holds:


Interrupt Handler (Interrupt Service Routine)

An Interrupt Handler, also called an Interrupt Service Routine (ISR), is the function the kernel executes in response to a specific interrupt. It is the code that actually does the work — reading a character from a UART, acknowledging a completed DMA transfer, or scheduling further processing for a received network packet.

Drivers register their interrupt handlers using request_irq() (or request_threaded_irq() for threaded handlers). This function allocates a struct irqaction, fills in the handler function pointer, flags, device name, and a device-identification cookie (dev_id), and chains it onto the irq_desc’s action list via __setup_irq().

When the interrupt fires, the generic layer calls __handle_irq_event_percpu(), which walks the action list and invokes each registered handler:

1
2
3
4
for_each_action_of_desc(desc, action) {
    res = action->handler(irq, action->dev_id);
    ...
}

Source: for_each_action_of_desc macro definition

Each handler returns one of:

  • IRQ_NONE — this device did not generate the interrupt (important for shared lines).
  • IRQ_HANDLED — the interrupt was from this device and has been serviced.
  • IRQ_WAKE_THREAD — the hard IRQ portion is done; wake the threaded handler to do the rest.

On a shared interrupt line, multiple devices are wired to the same IRQ. All registered handlers are called in sequence. Each must check its own hardware to determine if its device actually raised the interrupt. If not, it returns IRQ_NONE and the next handler in the chain is tried.

1
2
3
4
5
6
7
8
9
10
11
  Shared IRQ Handler Chain:

  IRQ fires ──► handler_A(irq, dev_id_A)  ──► IRQ_NONE  (not my device)
                        │
                        ▼
                handler_B(irq, dev_id_B)  ──► IRQ_HANDLED  (it was me!)
                        │
                        ▼
                handler_C(irq, dev_id_C)  ──► IRQ_NONE  (not mine either)

  All handlers are always called; the chain is walked completely.

The Interrupt Descriptor Table (IDT) and Vector Table

The processor needs a way to find the right handler for each interrupt or exception. It uses a hardware-defined lookup table, indexed by a number that identifies the interrupt source. On x86, this table is the Interrupt Descriptor Table (IDT). On AArch64, it is the exception vector table.

x86: The IDT

The IDT is an array of 256 gate descriptors, one for each possible interrupt vector. Each gate descriptor contains:

  • The address of the handler function (the entry point in kernel code).
  • The code segment selector to load (always the kernel code segment).
  • The privilege level (DPL) — whether user-space code is allowed to trigger this vector via a software int instruction.
  • The gate type — interrupt gate (clears IF, disabling further interrupts) or trap gate (leaves IF unchanged).
  • Optionally, an IST index — telling the CPU to switch to a dedicated stack for this handler (used for NMI, double fault, debug, and machine-check exceptions).

The IDT is stored in memory as a plain array of gate_desc structures, declared in arch/x86/kernel/idt.c:

1
static gate_desc idt_table[IDT_ENTRIES] __page_aligned_bss;

Gate entries are constructed via macros defined in the same file: INTG (kernel-only interrupt gate, DPL0), SYSG (user-accessible, DPL3 — used for int 0x80 and int3), and ISTG (uses an IST stack). Each macro fills an idt_data structure that is converted to a hardware gate_desc via pack_gate().

The CPU locates the IDT through the IDTR register, a special hardware register loaded by the lidt instruction during boot. The IDTR holds the base address and size of the IDT. When an interrupt with vector N arrives, the CPU reads IDT[N], extracts the handler address and segment, and transfers control there.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
  x86 IDT Lookup:

  Interrupt (vector N) ──► CPU reads IDTR
                                │
                                ▼
                          ┌───────────────────────────────────┐
                          │           IDT (256 entries)       │
                          ├───────────────────────────────────┤
                          │ [0]  Divide Error                 │
                          │ [1]  Debug (#DB)                  │
                          │ [2]  NMI                          │
                          │ [3]  Breakpoint (#BP)             │
                          │ ...                               │
                          │ [13] General Protection (#GP)     │
                          │ [14] Page Fault (#PF)             │
                          │ ...                               │
                          │ [32] ── first device IRQ ──────── │─► irq_entries_start
                          │ ...                               │    stub[N-32]
                          │ [128] int 0x80 syscall            │
                          │ ...                               │
                          │ [236] APIC timer                  │
                          │ ...                               │
                          │ [253] reschedule IPI              │
                          │ [255] spurious APIC               │
                          └───────────────────────────────────┘
                                │
                                ▼
                          handler address + segment
                          from gate descriptor

When Is the IDT Initialized?

The IDT is set up progressively during the Linux kernel boot process, in stages that reflect what hardware is available at each point:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
  IDT Initialization Sequence (x86 boot):

  ┌───────────────────────────────────────────────────────────────────────────┐
  │ Stage 1: idt_setup_early_handler()                                       │
  │   Fill all 256 entries with early_idt_handler_array[] stubs              │
  │   (minimal: print error + halt)                                          │
  ├───────────────────────────────────────────────────────────────────────────┤
  │ Stage 2: idt_setup_early_traps()                                         │
  │   Install #DB (vector 1) and #BP (vector 3)                              │
  │   Needed early for KASAN/debugging infrastructure                        │
  ├───────────────────────────────────────────────────────────────────────────┤
  │ Stage 3: idt_setup_traps()                                               │
  │   Install full exception handlers from def_idts[] table:                 │
  │   divide error, NMI, GPF, page fault, double fault, ...                  │
  │   Some use dedicated IST stacks                                          │
  ├───────────────────────────────────────────────────────────────────────────┤
  │ Stage 4: idt_setup_apic_and_irq_gates()                                  │
  │   Install APIC vectors from apic_idts[]                                  │
  │   Fill device IRQ vectors (0x20–0xEA) with irq_entries_start stubs       │
  │   Map IDT read-only into CPU entry area (security hardening)             │
  └───────────────────────────────────────────────────────────────────────────┘
  1. idt_setup_early_handler() — the very first stage. Fills all 256 entries with minimal early handlers from early_idt_handler_array[]. These do little more than print an error and halt — they exist only to catch faults during early boot before the real handlers are ready.

  2. idt_setup_early_traps() — installs handlers for #DB (debug, vector 1) and #BP (breakpoint, vector 3) from the early_idts[] table. These are needed early because KASAN and other debugging infrastructure may trigger them during boot.

  3. idt_setup_traps() — installs the full set of exception handlers from the def_idts[] table: divide error (vector 0), NMI (vector 2), general protection fault (vector 13), page fault (vector 14), double fault (vector 8), and others. Some use dedicated IST stacks.

  4. idt_setup_apic_and_irq_gates() — the final stage. Installs handlers for APIC vectors (timer, IPI, thermal, etc.) from the apic_idts[] table, then fills device interrupt vectors (0x20–0xEA) with auto-generated stubs from irq_entries_start. After this, the IDT is mapped read-only into the CPU entry area as a security hardening measure.

AArch64: The Exception Vector Table

ARM64 does not use a numbered vector table like x86. Instead, it uses a fixed-layout exception vector table with 16 entries, organized by the combination of two factors:

  • Where the exception came from: EL1 using SP_EL0, EL1 using SP_EL1 (normal kernel mode), EL0 in AArch64, or EL0 in AArch32 (compat mode).
  • What type of exception it is: Synchronous, IRQ, FIQ, or SError.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
  AArch64 Exception Vector Table Layout (16 entries × 128 bytes each):

  ┌───────────────────┬──────────────────────────────────────────────────┐
  │   Source Level     │        Exception Type                           │
  │                    │   Sync        IRQ         FIQ        SError     │
  ├───────────────────┼──────────────────────────────────────────────────┤
  │ EL1t (SP_EL0)     │  +0x000     +0x080      +0x100     +0x180      │
  │ EL1h (SP_EL1)     │  +0x200     +0x280      +0x300     +0x380      │
  │ EL0  (AArch64)    │  +0x400     +0x480      +0x500     +0x580      │
  │ EL0  (AArch32)    │  +0x600     +0x680      +0x700     +0x780      │
  └───────────────────┴──────────────────────────────────────────────────┘

  Each entry = 128 bytes (32 instructions max)
  Table base = VBAR_EL1 register

The table is defined in arch/arm64/kernel/entry.S at SYM_CODE_START(vectors), aligned to a 2048-byte boundary (as required by hardware). Each entry uses the kernel_ventry macro, which subtracts PT_REGS_SIZE from SP, checks for stack overflow, then branches to the full handler stub.

The CPU locates this table through the VBAR_EL1 register (Vector Base Address Register), set during boot. When an exception occurs, the CPU computes an offset into the table based on the exception type and source level, and jumps to that offset.

The fundamental difference from x86 is that ARM64 uses the exception type and source level to index the table, not a per-interrupt vector number. The actual interrupt source identification (which device fired?) happens later, when the handler reads the GIC’s IAR (Interrupt Acknowledge Register) to obtain the hardware interrupt ID.


How Does Hardware Find the Exact Interrupt Handler?

The complete path from “device asserts interrupt” to “handler function runs” involves a chain of hardware and software lookups. Here is the general flow, applicable to both architectures:

Step 1: Device Signals the Interrupt Controller

The device asserts its interrupt output — either by driving a physical wire (level or edge) or by writing to a message-signaled interrupt address. The interrupt controller (I/O APIC or GIC Distributor) receives this signal.

Step 2: Controller Resolves Priority and Target CPU

The interrupt controller checks whether this interrupt is enabled, compares its priority against any currently-being-serviced interrupt, and determines which CPU core should handle it (based on affinity configuration, or the lowest-priority-core heuristic). It then forwards the interrupt to the target CPU’s local interrupt interface (Local APIC or GIC CPU Interface).

Step 3: CPU Receives the Interrupt and Indexes the Table

On x86: The Local APIC delivers a vector number (0–255) to the CPU core. The CPU uses this vector as an index into the IDT: it reads IDT[vector], extracts the handler’s code segment and offset, and jumps there. The IDT entry directly gives the address of the specific handler stub.

On AArch64: The CPU takes a generic IRQ exception. It computes a fixed offset into the exception vector table based on the current execution level and exception type (IRQ from EL1 → offset 0x280), and jumps there. This lands in a common IRQ entry stub — not a device-specific handler. The stub then calls into C code, which reads the GIC’s IAR register to learn which interrupt fired, and uses the IRQ domain to dispatch to the right handler.

Step 4: Architecture Code Enters the Generic IRQ Layer

Regardless of how the hardware vector was resolved, both architectures converge into the same generic IRQ subsystem:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
  x86 Path:

  irq_entries_start stub ──► pushes vector number
          │
          ▼
  asm_common_interrupt ──► saves registers
          │
          ▼
  common_interrupt()  ──► looks up irq_desc via per-CPU vector_irq[]
          │
          ▼
  handle_irq(desc, regs)
          │
          ▼
  ┌───────────────────────────────┐
  │     Generic IRQ Subsystem     │
  │  (architecture-independent)   │
  └───────────────────────────────┘


  AArch64 Path:

  vectors (entry.S) ──► kernel_ventry (offset 0x280 for EL1h IRQ)
          │
          ▼
  entry_handler macro ──► kernel_entry (saves all registers)
          │
          ▼
  el1h_64_irq_handler()
          │
          ▼
  el1_interrupt() ──► do_interrupt_handler() ──► handle_arch_irq
          │
          ▼
  gic_handle_irq() ──► reads IAR register (gets INTID)
          │
          ▼
  generic_handle_domain_irq(domain, hwirq) ──► hwirq → virq translation
          │
          ▼
  ┌───────────────────────────────┐
  │     Generic IRQ Subsystem     │
  │  (architecture-independent)   │
  └───────────────────────────────┘

On x86, the auto-generated stub (irq_entries_start) pushes the vector number and jumps to asm_common_interrupt, which saves registers and calls common_interrupt() (defined via the DEFINE_IDTENTRY_IRQ macro). This function looks up the irq_desc via the per-CPU vector_irq[] mapping through call_irq_handler() and calls handle_irq(desc, regs).

On AArch64, the vector stub (via entry_handler macro) saves registers with kernel_entry and calls el1h_64_irq_handler(), which calls el1_interrupt()do_interrupt_handler()handle_arch_irq (a function pointer set to gic_handle_irq() during GIC init). The GIC handler reads the IAR register to get the hardware INTID in __gic_handle_irq(), then calls generic_handle_domain_irq(), which translates the hwirq to a Linux virq and invokes the registered irq_desc handler.

Step 5: The Flow Handler and Driver Handler Run

The irq_desc’s flow handler (handle_level_irq, handle_edge_irq, handle_fasteoi_irq, etc.) performs the correct acknowledge/mask/EOI sequence for the interrupt’s trigger type, then calls handle_irq_event(). This function walks the irqaction chain and calls each registered driver handler. The driver handler does the actual device-specific work.


What Happens When There Is an Interrupt (General Sequence)

When an interrupt arrives, the processor performs a carefully choreographed sequence. The details vary between x86 and AArch64, but the general shape is the same across all architectures:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
  Complete Interrupt Lifecycle:

  ┌─────────────────────────────────────────────────────────────────────┐
  │ 1. FINISH CURRENT INSTRUCTION                                      │
  │    CPU completes (or aborts) instruction in the pipeline            │
  ├─────────────────────────────────────────────────────────────────────┤
  │ 2. SAVE CRITICAL STATE (hardware-automatic)                        │
  │    x86:    push SS, RSP, RFLAGS, CS, RIP → kernel stack            │
  │    AArch64: PSTATE → SPSR_EL1, return addr → ELR_EL1              │
  ├─────────────────────────────────────────────────────────────────────┤
  │ 3. SWITCH TO KERNEL MODE + KERNEL STACK                            │
  │    x86:    ring 3 → ring 0, load RSP from TSS                     │
  │    AArch64: EL0 → EL1, SP_EL1 already points to kernel stack      │
  ├─────────────────────────────────────────────────────────────────────┤
  │ 4. DISABLE INTERRUPTS                                              │
  │    x86:    interrupt gate clears IF automatically                   │
  │    AArch64: sets PSTATE.I (masks IRQs) and PSTATE.A (masks SError) │
  ├─────────────────────────────────────────────────────────────────────┤
  │ 5. JUMP TO HANDLER                                                 │
  │    x86:    IDT[vector] → handler stub address                      │
  │    AArch64: VBAR_EL1 + offset → vector entry stub                  │
  ├─────────────────────────────────────────────────────────────────────┤
  │ 6. SAVE FULL REGISTER STATE (software, in assembly stub)           │
  │    x86:    error_entry() in entry_64.S → pt_regs on stack          │
  │    AArch64: kernel_entry macro → all 31 GPRs + SP/PC/PSTATE        │
  ├─────────────────────────────────────────────────────────────────────┤
  │ 7. SWITCH TO IRQ STACK (if needed)                                 │
  │    x86:    run_irq_on_irqstack_cond()                              │
  │    AArch64: call_on_irq_stack()                                    │
  ├─────────────────────────────────────────────────────────────────────┤
  │ 8. IDENTIFY SOURCE + DISPATCH                                      │
  │    x86:    vector → vector_irq[] → irq_desc → handler chain        │
  │    AArch64: read GIC IAR → INTID → irq_domain → irq_desc          │
  ├─────────────────────────────────────────────────────────────────────┤
  │ 9. ACKNOWLEDGE / END-OF-INTERRUPT                                  │
  │    x86:    apic_eoi() → write Local APIC EOI register              │
  │    AArch64: write ICC_EOIR1_EL1 system register                    │
  ├─────────────────────────────────────────────────────────────────────┤
  │ 10. CHECK PENDING WORK (before return)                             │
  │     TIF_NEED_RESCHED? → call scheduler                             │
  │     Pending signals? → deliver them                                │
  ├─────────────────────────────────────────────────────────────────────┤
  │ 11. RESTORE STATE + RETURN                                         │
  │     x86:    iret → atomically restores RIP, CS, RFLAGS, RSP, SS   │
  │     AArch64: eret → restores PC from ELR_EL1, PSTATE from SPSR   │
  └─────────────────────────────────────────────────────────────────────┘

1. Finish (or Abort) the Current Instruction

The CPU does not stop mid-instruction. It completes the instruction currently in the execution pipeline (for interrupts) or aborts it and records what went wrong (for faults). This ensures the architectural state is always consistent.

2. Save Critical State

The CPU hardware automatically saves the minimum state needed to resume later. On x86, this means pushing the current SS, RSP, RFLAGS, CS, and RIP onto the kernel stack (or onto the IST stack if specified). On AArch64, the CPU saves PSTATE into SPSR_EL1, saves the return address into ELR_EL1, and records the exception cause in ESR_EL1.

3. Switch to Kernel Mode and Kernel Stack

If the interrupt arrived while executing user-space code, the CPU switches to kernel privilege level (ring 0 on x86, EL1 on AArch64) and loads the kernel stack pointer. On x86, the kernel stack is found through the Task State Segment (TSS). On AArch64, SP_EL1 is already pointing to the kernel stack.

4. Disable Interrupts (Conditionally)

On x86, entering through an interrupt gate automatically clears the IF flag, preventing further maskable interrupts until the handler explicitly re-enables them. Trap gates leave IF unchanged. On AArch64, taking an IRQ exception automatically sets the PSTATE.I bit (masking IRQs) and PSTATE.A bit (masking SErrors).

This is critical: the handler needs to save the rest of the register state and set up a consistent environment before it can safely handle another interrupt.

5. Jump to the Handler

The CPU looks up the handler address (from the IDT on x86, from the vector table on AArch64) and begins executing it.

6. Handler Saves Full Register State

The hardware only saves a few registers automatically. The assembly entry stub saves the rest — all general-purpose registers, and any other state needed to fully reconstruct the interrupted context. On x86, this is done by error_entry() in entry_64.S (called from the idtentry_body macro, dispatched by the idtentry macro). On AArch64, the kernel_entry macro saves all 31 general-purpose registers plus SP, PC, and PSTATE into a pt_regs structure on the stack.

7. Switch to the IRQ Stack (If Needed)

Both architectures optionally switch to a dedicated per-CPU IRQ stack for interrupt processing, separate from the kernel thread stack. This prevents deep interrupt nesting from overflowing the relatively small kernel thread stacks (typically 8–16 KB). On x86, run_irq_on_irqstack_cond() handles this. On AArch64, call_on_irq_stack() does the equivalent.

8. Identify the Source and Dispatch

The handler identifies which device actually raised the interrupt (by reading the vector number on x86, or the IAR register on ARM) and dispatches to the appropriate driver handler through the generic IRQ subsystem. On both architectures, this enters generic_handle_irq_desc(), which simply calls desc->handle_irq(desc) — the flow handler.

9. Acknowledge / End-of-Interrupt

After servicing the interrupt, the handler signals the interrupt controller that it is done. On x86, this means writing to the Local APIC’s EOI register (apic_eoi()). On AArch64/GICv3, this means writing to the ICC_EOIR1_EL1 system register. This allows the controller to deliver the next pending interrupt.

10. Restore State and Return

The handler restores the full register state from the saved pt_regs. On x86, the entry returns via the iret instruction, which atomically restores RIP, CS, RFLAGS, RSP, and SS. On AArch64, the eret instruction restores PC from ELR_EL1 and PSTATE from SPSR_EL1. The interrupted code resumes execution, completely unaware that it was ever interrupted.

Before Returning: Checking for Pending Work

Before the handler actually returns to the interrupted context, the kernel checks whether there is pending work to do. If the interrupt arrived from user space, the kernel checks for pending signals, rescheduling requests, and other deferred work. If a reschedule is needed (TIF_NEED_RESCHED is set), the kernel calls the scheduler. This is one of the primary places where preemption happens — the interrupt provided a natural transition point from user code into the kernel, and the kernel takes the opportunity to switch to a higher-priority task if one is ready.

The transition in and out of interrupt context is marked by irq_enter_rcu()/irq_exit_rcu() (or their wrappers irq_enter()/irq_exit()), which update preempt counts, account time, and — on exit — process pending softirqs.


Summary

The interrupt handling pipeline, from electrical signal to handler execution and back, is a collaboration between hardware and software:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
  End-to-End Interrupt Flow:

  ┌──────────┐     ┌─────────────────┐     ┌────────────┐     ┌──────────────┐
  │  Device   │────►│   Interrupt     │────►│    CPU      │────►│  Assembly    │
  │  asserts  │     │   Controller    │     │  Hardware   │     │  Entry Stub  │
  │  IRQ      │     │  (APIC / GIC)   │     │ (IDT/VBAR) │     │ (save regs)  │
  └──────────┘     │  priority +     │     │ save state  │     └──────┬───────┘
                    │  route to CPU   │     │ switch mode │            │
                    └─────────────────┘     └────────────┘            ▼
                                                              ┌──────────────┐
  ┌──────────┐     ┌─────────────────┐     ┌────────────┐     │  Generic IRQ │
  │  Resume   │◄────│  Return Path    │◄────│   Driver    │◄────│  Subsystem   │
  │  (iret/   │     │  check pending  │     │  Handler   │     │  (irq_desc,  │
  │   eret)   │     │  work, restore  │     │ (ISR runs) │     │   dispatch)  │
  └──────────┘     │  state          │     └────────────┘     └──────────────┘
                    └─────────────────┘
  • Devices raise interrupt signals.
  • Interrupt controllers (APIC, GIC) prioritize, route, and deliver them to the right CPU.
  • CPU hardware saves minimal state, switches to kernel mode, and indexes a lookup table (IDT / vector table) to find the handler.
  • Architecture-specific assembly stubs save full register state and switch to the IRQ stack.
  • The generic IRQ subsystem translates hardware interrupt numbers to kernel descriptors and dispatches to registered driver handlers.
  • Driver handlers do the device-specific work.
  • The return path restores state, checks for pending work, and resumes the interrupted code.

This entire sequence — from signal to handler to resume — typically completes in a few microseconds. It is one of the most performance-critical paths in the kernel, executed millions of times per second on a busy system, and its design reflects decades of optimization across both hardware and software.

This post is licensed under CC BY 4.0 by the author.