Operating Systems

Hardware Interrupts and ISRs

How a device grabs the CPU in microseconds — without polling

A hardware interrupt is an asynchronous electrical signal — raised by a device on an IRQ line — that forces the CPU to suspend the instruction it is running, save just enough context to resume, and vector through the interrupt vector table to a small piece of code called an interrupt service routine (ISR). It is the mechanism that lets a keyboard press, a disk finishing a read, a timer tick, or a network packet reach the operating system in hundreds of nanoseconds without the CPU wasting a single cycle asking "anything yet?" Invented on early machines like the 1954 DYSEAC and universal since, interrupts are the reason a modern CPU can stay busy while a thousand slow devices are served on demand.

  • Trigger typeAsynchronous (external device)
  • Dispatch costO(1) vector-table lookup
  • Typical latency~0.1–2 μs to reach the ISR
  • x86 vector tableIDT — 256 gate descriptors
  • Two flavorsMaskable (IRQ) & non-maskable (NMI)
  • Handler splitTop half (fast) / bottom half (deferred)

Interactive visualization

Press play, or step through manually. The visualization is yours to drive — try it before reading on.

Open visualization fullscreen ↗

Watch the 60-second explainer

A condensed visual walkthrough — narrated, captioned, under a minute.

Why interrupts matter

Consider the alternative. Without interrupts, the only way for software to learn that a device is ready is to poll it — read its status register over and over in a loop. A CPU polling a keyboard would spend billions of cycles per keystroke doing nothing but asking. Scale that to a disk, a network card, a mouse, a timer, and a dozen sensors, and the machine grinds to a halt purely on bookkeeping. Interrupts invert the relationship: the CPU runs useful work, and the device raises its hand when it has something to say.

This one idea underpins essentially everything an operating system does. Preemptive multitasking is a timer interrupt firing every few milliseconds so the scheduler can switch tasks. Disk and network I/O completion is signaled by interrupts so a thread can block instead of spin. Even keyboard echo, USB, GPUs, and the wall clock are interrupt-driven. Interrupts are the seam where asynchronous hardware meets synchronous software.

How an interrupt is handled, step by step

The full journey from a device raising a line to the interrupted program resuming looks like this:

  1. Assertion. A device asserts its IRQ (Interrupt Request) line, or sends a Message Signaled Interrupt (MSI) — a memory write to a special address, which modern PCIe devices prefer because it needs no dedicated wire and avoids sharing.
  2. Arbitration. An interrupt controller — the legacy 8259 PIC, or today the APIC (Local APIC + I/O APIC) on x86, or the GIC on ARM — prioritizes pending requests, applies masks, and delivers a single vector number to the CPU.
  3. Acknowledge & check. The CPU finishes the in-flight instruction, then checks: are interrupts enabled and is this vector's priority high enough? If masked, it stays pending.
  4. Automatic context save. The hardware pushes the minimum needed to resume — the program counter and the flags/status register — onto the (kernel) stack, switching privilege level and stack if it was in user mode. It disables same-or-lower priority interrupts.
  5. Vector dispatch. The CPU indexes the interrupt vector table (the IDT on x86) by the vector number and jumps to the handler's address. This lookup is O(1) — the hardware does it.
  6. The ISR runs. The interrupt service routine saves any additional registers it will clobber (the software half of context save), does the urgent work, and acknowledges the device / interrupt controller so the line can be de-asserted.
  7. Return. The ISR restores the registers it saved and executes an interrupt-return instruction — IRET on x86, ERET on ARM — which atomically restores the saved PC and flags and re-enables interrupts. The interrupted program resumes as if nothing happened.

The word to hold onto is asynchronous. The interrupted instruction has nothing to do with the interrupt; the program was just unlucky enough to be executing when the device raised its line. That is exactly what distinguishes an interrupt from an exception, which is caused by the current instruction.

Context save: the part everyone underestimates

"Save context" sounds trivial, but it is the whole reason interrupts are transparent. The processor guarantees the bare minimum automatically — enough to IRET back — but the ISR is responsible for anything else it touches. If your handler modifies a general-purpose register that the interrupted code was using, and you forget to save and restore it, you have silently corrupted an unrelated program. This is why compilers tag interrupt handlers specially (GCC's __attribute__((interrupt))) so they push and pop every register they clobber.

Context save also has a cost, and that cost is the reason interrupts are not free. Saving and restoring state, the pipeline flush, the possible privilege switch, and the cache pollution from running handler code all add up to hundreds of nanoseconds per interrupt. At low rates this is invisible; at millions per second it becomes the dominant cost — the fact that motivates coalescing and polling hybrids below.

Maskable vs non-maskable interrupts

Not all interrupts are equal. Most device IRQs are maskable: the CPU can temporarily refuse them by clearing the interrupt-enable flag (CLI on x86, clearing PSTATE.I on ARM) or by masking the specific line in the interrupt controller. This is essential for correctness — a kernel critical section that manipulates a data structure the ISR also touches must be able to run atomically, so it disables interrupts, does its work, and re-enables them.

A non-maskable interrupt (NMI), by contrast, cannot be blocked by the ordinary enable flag. It always gets through. NMIs are reserved for events you must never miss: hardware failures, memory parity/ECC errors, watchdog timeouts (used to detect a hung kernel), and profiling. On x86 the NMI arrives on a dedicated pin and always uses vector 2. Because an NMI can interrupt even code that has disabled interrupts, NMI handlers must be exquisitely careful about re-entrancy.

Top half and bottom half

There is a tension: while an ISR runs with interrupts disabled, no other interrupt of equal or lower priority can be serviced. A slow handler therefore raises interrupt latency for the whole system and can cause dropped events. The classic solution, formalized in Linux, is to split handling into two parts.

  • Top half — the real ISR. It runs in interrupt context, often with interrupts disabled, and does only the urgent minimum: acknowledge the device, copy the freshly arrived data out of the hardware buffer, clear the interrupt condition, and schedule the rest. It must be fast and must never sleep or take a blocking lock.
  • Bottom half — the deferred work. It runs later, with interrupts re-enabled, doing the heavier processing: parsing a packet, waking a waiting thread, updating kernel data structures. Linux offers several bottom-half mechanisms — softirqs (highest performance, statically defined), tasklets (built on softirqs, serialized per-tasklet), threaded IRQs, and workqueues (which run in kernel threads and can sleep).

The rule of thumb: acknowledge fast in the top half, do the real work in the bottom half. A network driver's top half might just move descriptors and raise a softirq; the softirq then runs the protocol stack.

Polling vs interrupts — and why the answer flips under load

Interrupts are the obvious winner for idle or low-rate devices: zero cost when nothing happens. But at very high event rates the fixed per-interrupt overhead dominates, and a machine can spend so long entering and leaving handlers that it makes no forward progress — a pathology called receive livelock or an interrupt storm. Here, tight polling actually wins: read a batch of ready events in one pass with no per-event context save.

Modern systems don't pick one — they blend. Linux's NAPI takes the first packet by interrupt, then disables that interrupt and polls the NIC until the queue drains, re-enabling the interrupt only when traffic subsides. DPDK goes further and busy-polls in user space, bypassing interrupts entirely for maximum throughput. The choice is fundamentally load-dependent.

InterruptsPollingHybrid (NAPI)
Idle CPU costZeroHigh (busy loop)Zero (interrupt-armed)
Latency at low rateLow (~1 μs)Bounded by poll intervalLow
Throughput at high ratePoor (per-event overhead)ExcellentExcellent
Livelock riskYes (interrupt storm)NoNo
Best forSlow / bursty devicesSustained high-rate I/OVariable-rate NICs

Interrupt coalescing: batching at the hardware level

Before you even reach polling, the device itself can help by coalescing interrupts — batching several events into one. A 10 GbE NIC receiving 1,000,000 packets per second would otherwise fire a million interrupts; with each one costing a few hundred nanoseconds, that alone saturates a core. By waiting until it has accumulated N packets, or a small time budget elapses (say 50 μs), the NIC raises a single interrupt for the whole batch. The fixed dispatch cost is amortized across many packets.

The knob is a direct latency/throughput trade: more coalescing means higher throughput and lower CPU load, but each packet waits a little longer. On Linux you tune it with ethtool -C eth0 rx-usecs 50 rx-frames 64. Low-latency trading systems dial coalescing down; bulk-transfer servers dial it up.

Interrupts vs exceptions vs system calls

All three ride the same vector/IDT machinery, so it is easy to conflate them, but they differ in origin and reproducibility:

Hardware interruptException (fault/trap)System call
OriginExternal deviceCurrent instructionUser code (deliberate)
TimingAsynchronousSynchronousSynchronous
Reproducible?NoYesYes
ExamplesTimer, disk, NIC, keyboardPage fault, divide-by-zero, breakpointSYSCALL / SVC / INT 0x80
Maskable?Usually (except NMI)No (faults are involuntary)N/A (voluntary)

Implementation: a minimal ISR skeleton

Here is the shape of a bare-metal top-half ISR in C, the way you would write one for a microcontroller or a hobby kernel. Note the register save/restore, the device acknowledge, and the deferral of real work.

// Interrupt vector table (x86-style IDT gate for illustration).
// Each entry maps a vector number -> handler address.
struct idt_gate idt[256];

void install_handler(int vector, void (*handler)(void)) {
    idt[vector].offset_low  = (uint32_t)handler & 0xFFFF;
    idt[vector].offset_high = ((uint32_t)handler >> 16) & 0xFFFF;
    idt[vector].selector    = KERNEL_CODE_SEG;
    idt[vector].type_attr   = 0x8E;   // present, ring 0, 32-bit interrupt gate
}

// Shared flag written by the top half, read by the bottom half.
volatile int rx_pending = 0;

// TOP HALF — runs in interrupt context, interrupts disabled.
// The 'interrupt' attribute makes the compiler save/restore ALL
// clobbered registers and emit IRET on return (the context save).
__attribute__((interrupt))
void nic_isr(struct interrupt_frame *frame) {
    uint8_t status = mmio_read(NIC_STATUS);   // read device status
    if (status & NIC_RX_READY) {
        drain_rx_ring_into_kernel_buffer();   // urgent: pull data out of HW
        rx_pending = 1;                       // schedule the bottom half
    }
    mmio_write(NIC_ACK, status);              // acknowledge -> de-assert IRQ
    lapic_eoi();                              // tell the APIC we're done
    // IRET is emitted automatically -> restores PC + flags, re-enables IRQs
}

// BOTTOM HALF — runs later, interrupts ENABLED, may take locks / sleep.
void nic_bottom_half(void) {
    while (rx_pending) {
        rx_pending = 0;
        process_received_packets();           // heavy work: protocol stack
    }
}

The volatile qualifier on rx_pending is not optional: because the flag is written asynchronously by the ISR and read elsewhere, the compiler must not cache it in a register or reorder the access. On a multiprocessor you would additionally need a memory barrier and would deliver the interrupt to a specific core via the APIC's affinity settings.

A short history

Interrupts predate almost everything else in this article. The 1954 DYSEAC and the UNIVAC 1103A are commonly cited as the first machines with a hardware interrupt facility. By the 1960s, interrupt-driven I/O was standard on mainframes; the IBM System/360 formalized a program-status-word swap on interrupt that is recognizably the "context save" we still do. The x86 line evolved from the 8259 PIC (with its famous IRQ 0–15 assignments — IRQ 0 the timer, IRQ 1 the keyboard) to the APIC and today's MSI/MSI-X, which scale to hundreds of vectors and deliver interrupts as memory writes rather than dedicated wires. The concept is over sixty years old and utterly load-bearing.

Common misconceptions and pitfalls

  • "An ISR is just a function." It runs in a constrained context — you cannot sleep, cannot call blocking APIs, and cannot take locks that a non-interrupt path holds without risking deadlock. Keep it short.
  • Forgetting to acknowledge the device. If the ISR never clears the interrupt condition at the device (and the controller's EOI), the line stays asserted and the same interrupt fires immediately again — an infinite interrupt loop that hangs the machine.
  • Shared IRQ lines. With legacy level-triggered lines, several devices can share one IRQ. Every handler on that line runs and must check "was it me?" and return a "not handled" indication if not. MSI avoids sharing entirely.
  • Missing volatile / barriers on shared flags. Data touched by both the ISR and normal code needs volatile and, on SMP, memory barriers — otherwise the compiler or CPU can reorder around it.
  • Doing heavy work in the top half. Long ISRs inflate interrupt latency system-wide and cause dropped events. Defer to a bottom half.
  • Assuming interrupts always beat polling. Under a packet flood, per-interrupt overhead causes livelock; NAPI-style polling or coalescing is required.

Frequently asked questions

What is the difference between an interrupt and polling?

With polling, the CPU repeatedly reads a device's status register in a loop, burning cycles even when nothing has happened — latency is bounded by the poll interval and throughput scales poorly with many idle devices. With interrupts, the device asserts an IRQ line asynchronously and the CPU is notified only when work is actually ready, so idle cost is zero. The trade-off flips under extreme load: at millions of events per second, per-interrupt overhead (context save, vector dispatch, return) dominates, and busy-polling (as in Linux NAPI or DPDK) becomes faster. The right answer is load-dependent, which is why modern NIC drivers switch between the two.

What is the interrupt vector table?

The interrupt vector table (IVT) is an array in memory that maps each interrupt number to the address of its handler. When an interrupt with vector number n fires, the CPU indexes entry n and jumps there. On x86 real mode it lived at physical address 0x0000 as 256 four-byte far pointers; in protected and long mode it is replaced by the Interrupt Descriptor Table (IDT) of 256 gate descriptors, located wherever the IDTR register points. ARM uses a vector table whose base is set by VBAR. The table is what makes dispatch O(1): the hardware does the lookup, not software.

What is the difference between maskable and non-maskable interrupts?

A maskable interrupt can be temporarily disabled by clearing the interrupt-enable flag (CLI on x86, clearing PSTATE.I on ARM) or by masking its line in the interrupt controller — the CPU defers it until interrupts are re-enabled. Most device IRQs are maskable so a critical section can run atomically. A non-maskable interrupt (NMI) cannot be blocked by the normal enable flag; it always reaches the CPU. NMIs are reserved for events that must never be ignored — hardware failures, watchdog timeouts, memory parity errors, and profiling. On x86 the NMI arrives on a dedicated pin and uses vector 2.

What is the difference between the top half and bottom half of an interrupt handler?

To keep interrupts disabled for as short a time as possible, Linux splits handling into two parts. The top half is the actual ISR: it runs with interrupts (often) disabled in interrupt context, does the minimum urgent work — acknowledge the device, grab the data, clear the interrupt condition — and schedules the rest. The bottom half runs the deferred, longer work later with interrupts enabled, via softirqs, tasklets, threaded IRQs, or workqueues. The rule of thumb: acknowledge fast in the top half, do real processing in the bottom half, because you cannot sleep or hold blocking locks in the top half.

What is interrupt coalescing and why does it help?

Interrupt coalescing lets a device batch several events into a single interrupt instead of raising one per event. A NIC receiving 1,000,000 packets per second would otherwise generate a million interrupts, and the per-interrupt overhead (a few hundred nanoseconds each) would saturate a core — a condition called an interrupt storm or receive livelock. By waiting for N packets or a small time budget (say 50 microseconds) before interrupting, the device amortizes the fixed cost across many packets, raising throughput at the price of a little added latency. It is the hardware version of batching, and the latency/throughput knob is exposed via ethtool -C.

What happens to the CPU state when an interrupt fires?

The CPU finishes the current instruction (or aborts it, for a fault), then automatically saves enough state to resume: at minimum the program counter and the processor status/flags register, pushed onto the stack (the kernel stack, after any privilege-level switch). It disables further interrupts of equal or lower priority, looks up the handler in the vector table, and jumps there. The ISR must preserve any additional registers it clobbers — that is the software half of context save. On return, an interrupt-return instruction (IRET on x86, ERET on ARM) atomically restores the saved PC and flags and re-enables interrupts, resuming the interrupted program as if nothing happened.

How do interrupts differ from exceptions and system calls?

All three transfer control through the same vector/IDT mechanism, but they differ in origin and timing. A hardware interrupt is asynchronous — it comes from an external device and is unrelated to the instruction currently executing. An exception (fault, trap, or abort) is synchronous — it is caused by the current instruction, such as a page fault, divide-by-zero, or breakpoint, and re-running the instruction is often meaningful. A system call is a deliberate software-triggered trap (INT 0x80, SYSCALL, or SVC) used by user code to request a kernel service. Interrupts are external and unpredictable; exceptions and syscalls are internal and reproducible.