The CPU executes instructions. It does not watch for events. While it is running your program, it has no mechanism to notice that a key was pressed, a network packet arrived, or a disk read completed — unless something forces it to stop and look.
That something is a hardware interrupt.
Polling vs Interrupts
The alternative to interrupts is polling: the CPU periodically stops what it is doing and checks whether any device needs attention. A tight polling loop wastes CPU cycles checking devices that have nothing to report. A slow polling loop introduces latency — events wait until the next check cycle before being handled.
Interrupts invert this. Instead of the CPU asking devices if they need attention, devices signal the CPU when they do. The CPU handles the event immediately, then returns to whatever it was doing. Between events, no CPU time is spent on devices that are idle.
This model scales. A machine with dozens of devices — keyboard, network card, storage controller, USB bus, timers — can handle all of them efficiently because only the devices with something to report consume CPU time.
The Interrupt Descriptor Table
The kernel needs to know what to do when an interrupt fires. On x86-64, the answer is in the Interrupt Descriptor Table — IDT.
The IDT is an array of 256 entries, each called a gate descriptor. Each entry maps an interrupt vector number (0–255) to a kernel function — the interrupt handler for that vector. When an interrupt fires, the CPU looks up the vector number in the IDT and jumps to the corresponding handler.
The IDT is set up by the kernel at boot time. The address of the IDT is stored in the IDTR register, loaded with the lidt instruction — a privileged operation that only ring 0 code can perform. User processes cannot modify the IDT or redirect interrupt handling.
Vectors 0–31 are reserved by the CPU architecture for exceptions — faults and traps generated by the CPU itself rather than external hardware. Page faults, general protection faults, divide-by-zero errors all have fixed vector numbers defined by Intel. Vectors 32–255 are available for hardware interrupts and software-generated interrupts.
IRQ Lines, the APIC, and MSI
Historically, hardware devices signalled the CPU by asserting a physical wire — an Interrupt Request line, or IRQ. The original IBM PC had 8 IRQ lines, later expanded to 16 via a cascaded pair of Programmable Interrupt Controller chips (PIC). Each device was assigned an IRQ number. When a device asserted its line, the PIC forwarded the signal to the CPU and told it which IRQ had fired.
Modern x86 systems use the Advanced Programmable Interrupt Controller — APIC. Every CPU core has a Local APIC that receives interrupts and presents them to its core. A separate I/O APIC (typically on the motherboard) collects interrupts from devices and routes them to Local APICs. The I/O APIC supports far more interrupt vectors than the original PIC and can distribute interrupts across CPU cores — a network card can be configured to deliver its interrupts to different cores on successive packets, spreading the load.
On modern systems, most PCIe devices do not use physical IRQ lines at all. They use Message Signaled Interrupts — MSI or its extended form MSI-X. Instead of asserting a wire, the device writes a small packet of data to a special memory address that the Local APIC monitors. The write is the interrupt signal. MSI-X allows a single device to have up to 2048 independent interrupt vectors — a multi-queue network card can assign one interrupt vector per receive queue, each routable to a different CPU core, eliminating contention between cores handling the same device.
You can see which interrupt mechanism each device uses:
cat /proc/interrupts
Devices listed with a numeric IRQ number in the leftmost column may be using legacy PIC routing or APIC-routed IRQs. Devices listed as PCI-MSI or PCI-MSIX are using message-signaled interrupts. Most modern hardware will show MSI or MSI-X.
What Happens When an Interrupt Fires
When an interrupt signal reaches the CPU — whether from a physical IRQ line, the APIC, or an MSI write — the CPU completes the current instruction, then stops.
Before jumping to the interrupt handler, the CPU saves the state needed to resume the interrupted code: the instruction pointer, the stack pointer, the flags register, and the current privilege level. These are pushed onto a special per-CPU interrupt stack — a stack maintained by the kernel separately from any process stack, so the handler does not corrupt the interrupted process's state.
The CPU then looks up the interrupt vector in the IDT, switches to ring 0, and jumps to the handler. The handler runs in the context of whatever process happened to be running when the interrupt fired — but it is not that process's code. The interrupted process is paused, unaware anything happened.
When the handler finishes, the CPU executes iret — interrupt return — which pops the saved state from the interrupt stack, restores the instruction pointer and flags, and returns to whatever was running before. The interrupted process resumes from exactly the instruction it was about to execute.
Top Half and Bottom Half
Interrupt handlers run with interrupts disabled on the current CPU core. While a handler is executing, no other interrupt on that core can be processed. This keeps interrupt handling simple — handlers do not need to worry about being interrupted themselves — but it means handlers must be short. A handler that takes too long blocks all other interrupts on that core, increasing latency for every other device.
The kernel splits interrupt handling into two halves.
The top half runs immediately when the interrupt fires, with interrupts disabled. It does only what cannot wait: acknowledge the interrupt to the hardware (so the device knows it was received and can fire again), copy any time-sensitive data from device registers into kernel memory, and schedule the bottom half for later execution.
The bottom half runs after the top half completes and interrupts are re-enabled. It does the heavier work: processing the data the top half collected, updating kernel data structures, waking processes that were waiting for the event. Linux implements bottom halves through several mechanisms — softirqs for fixed, high-frequency work like network packet processing; tasklets built on top of softirqs for driver-level deferral; and workqueues for work that can sleep, scheduled to run in a kernel thread.
The split is why network performance does not collapse under load. The top half for a received packet copies the packet data from the NIC's DMA buffer and schedules a softirq. The actual protocol processing — TCP/IP stack, socket buffers, waking the application — happens in the softirq, which runs with interrupts enabled and can be preempted if something more urgent arrives.
The Timer Interrupt
IRQ 0 is the timer interrupt. It fires at a regular interval — on most Linux systems, the kernel is configured for 250Hz, meaning the timer fires 250 times per second, every 4 milliseconds. Some configurations use 100Hz or 1000Hz depending on the workload profile.
Every timer interrupt invokes the scheduler's tick function. The scheduler updates the current process's accounting — how much CPU time it has consumed — and checks whether it has run long enough. If a higher-priority process has become runnable, or the current process has exhausted its time slice, the scheduler marks the current process for preemption.
Preemption is the mechanism that makes a single CPU core appear to run many programs simultaneously. When the timer interrupt fires and the scheduler decides to switch processes, the current process does not get to finish what it was doing. It is suspended at whatever instruction it was about to execute — mid-loop, mid-function, anywhere — and the scheduler picks the next process to run. The suspended process resumes later from exactly the point it was interrupted.
Without the timer interrupt, a process that never called the kernel voluntarily could hold the CPU indefinitely. The timer interrupt guarantees the scheduler gets control at regular intervals regardless of what user code is doing.
Inter-Processor Interrupts
On a multi-core system, the kernel sometimes needs to signal a specific CPU core directly — not a device, not a timer, but one core telling another to do something immediately.
This is done through Inter-Processor Interrupts — IPIs. The Local APIC on each core can send an IPI to any other core's Local APIC by writing to a register. The target core receives it as a normal interrupt and jumps to the IPI handler.
Linux uses IPIs for several purposes. TLB shootdowns are the most common: when the kernel modifies a page table entry — unmapping a page, for example — other cores may have the old mapping cached in their TLBs. The kernel sends a TLB shootdown IPI to every core that might have a stale entry, forcing each one to flush the relevant TLB entry before the kernel proceeds. Without this, a core could continue using a mapping that no longer exists, corrupting memory.
IPIs are also used to deliver signals to processes running on other cores, to wake a sleeping CPU core when work arrives, and to coordinate kernel operations that must complete atomically across all cores. IPIs are not free — the target core must stop its current work to handle them. In high-concurrency applications that frequently remap memory, the cost of TLB shootdown IPIs becomes measurable and is a known performance bottleneck.
Interrupt Storms
An interrupt storm occurs when a device fires interrupts faster than the CPU can process them.
A malfunctioning network card, a buggy driver, or a hardware fault can cause a device to assert its interrupt line continuously — or to fire thousands of MSI writes per second. Each interrupt invokes the top half handler. If the handler cannot clear the interrupt condition before the device fires again, the CPU spends all its time in interrupt context, never returning to process-level code.
The visible symptom is a CPU core pegged at near 100% in the hi (hardware interrupt) category in top or htop. The core is alive — it is executing interrupt handlers — but no user processes or kernel threads are making progress on it. If the bottleneck shifts to the bottom half — the softirq processing the data the top half collected — the same core will show high si (software interrupt) instead. High si under network load typically means the protocol stack cannot keep up with packet rate, not that the hardware itself is misbehaving. From the user's perspective, either symptom means the system appears frozen or severely degraded.
Linux has a mitigation called NAPI (New API) for network devices. Under heavy load, instead of handling every packet arrival as a separate interrupt, the kernel disables the interrupt and polls the device directly in a softirq until the queue is drained, then re-enables the interrupt. This converts the high-frequency interrupt pattern into a controlled polling loop, preventing storm conditions on busy network cards.
For non-network devices, a stuck interrupt can trigger the kernel's spurious interrupt handler, which logs the event and in extreme cases disables the IRQ line entirely to protect the rest of the system.
You can observe interrupt rates per CPU in real time:
watch -n1 "cat /proc/interrupts"
A device whose counter is incrementing extremely rapidly — thousands per second — while the system is otherwise idle is worth investigating. Each interrupt entry also shows which CPU cores are handling it. If all interrupts from a device are being handled by CPU 0, that core bears the full cost. On servers, irqbalance runs as a daemon and automatically redistributes interrupt affinity across cores as load changes. On systems without it, interrupt affinity can be adjusted manually by writing a CPU bitmask to /proc/irq/N/smp_affinity, where N is the IRQ number — distributing interrupts across cores for high-throughput devices.
A Keypress, End to End
When you press a key, here is the full path from hardware to your terminal:
1. The keyboard controller detects the keypress. The key's physical switch closes a circuit. The keyboard controller scans the matrix, identifies which key, and produces a scan code.
2. The keyboard controller asserts IRQ 1. On modern systems this is routed through the I/O APIC to a Local APIC on a CPU core.
3. The CPU receives the interrupt. It completes its current instruction, saves state to the interrupt stack, and jumps to the IRQ 1 handler.
4. The top half runs. The handler reads the scan code from the keyboard controller's I/O port, acknowledges the interrupt, and queues the scan code for the bottom half.
5. The bottom half runs. The input subsystem converts the scan code to a key code, then to a character. It places the character in the input event queue for the device the terminal is reading from.
6. The terminal process wakes. The terminal was sleeping in a read() system call, waiting for input. The kernel wakes it, the read() returns with the character, and the terminal processes it — displaying it, acting on it, or passing it to the shell.
The entire path — from key contact to character appearing — takes single-digit milliseconds on a healthy system. Most of that time is the display update, not the interrupt handling.
References
- Linux man page: proc(5) —
/proc/interruptsand/proc/irq/documentation - Intel 64 and IA-32 Architectures Software Developer's Manual, Volume 3 — Chapter 6 covers interrupt and exception handling; Chapter 10 covers the APIC
- Linux kernel source: arch/x86/kernel/irq.c — x86 interrupt handling entry points
- Linux kernel source: kernel/softirq.c — softirq and tasklet implementation