---
title: Interrupts and the Kernel
module: Exceptions & I/O
moduleNumber: 8
lessonNumber: 2
order: 802
summary: >
  An I/O device signals completion by raising an interrupt, crossing the privilege
  boundary from user mode into the kernel. We fix that boundary, follow an
  interrupt from device through the interrupt controller to its vectored handler,
  and use the timer interrupt to drive preemptive scheduling and the context
  switch. Then the I/O mechanics: polling versus interrupt-driven I/O with a cycle
  count, device registers and memory-mapped I/O versus port I/O, DMA's full
  transfer walkthrough and its cache hazard, and a disk read traced end to end,
  from the read syscall to the completion interrupt.
topics: [Exceptions & I/O]
sources:
  - book: Bryant & O'Hallaron
    ref: "CS:APP — §6.1 Storage, §8 Exceptional Control Flow"
  - book: Bistriceanu
    ref: "Computer Architecture Notes — §6 Interrupts"
---

The [previous lesson](/computer-architecture/exceptions-and-io/exceptional-control-flow)
placed interrupts among the four exception classes: asynchronous events raised by
I/O devices. This lesson develops them in full. Interrupts are
how a processor that executes billions of instructions per second coexists with
devices that take milliseconds to respond, without sitting idle waiting on them.
The same mechanism, applied to a timer, is how the operating system takes the CPU
back from one process and gives it to another. Along the way we fix the
**user/kernel privilege boundary** that every interrupt crosses, meet the
**interrupt controller** that funnels many devices into one CPU pin, walk the
**context switch**, and work out the actual costs: what polling wastes, what an
interrupt costs, and how **DMA** moves bulk data while the CPU does something
better.

## User mode and kernel mode

A processor runs in one of two privilege levels, recorded in a control register.

> **Definition (User / kernel mode).** In **kernel mode** (supervisor mode) code
> may execute any instruction and touch any memory or device register. In **user
> mode** privileged instructions are forbidden and only the process's own pages
> are reachable; attempting a privileged action raises an exception. Application
> code runs in user mode; the OS kernel runs in kernel mode.

The boundary is enforced by hardware and is the foundation of protection: it is
what stops a buggy program from halting the CPU, reprogramming the MMU, or reading
another process's memory. The supervisor bit in each [PTE](/computer-architecture/virtual-memory/page-tables-and-page-faults)
is part of the same scheme. What counts as privileged is the set of
instructions that could subvert the system from below: `hlt` (stop the
processor), `cli`/`sti` (disable and enable interrupts), loads of the page-table
base or the exception-table base, and direct device access through `in`/`out`.
A user-mode program that executes any of them takes a general-protection fault,
and the kernel's `#GP` handler turns the attempt into a `SIGSEGV`.

User code reaches kernel services only by raising an exception, which switches
the mode for it: a **trap** (`syscall`) for a deliberate request, or an
**interrupt** for an asynchronous device event. Both vector through the
[exception table](/computer-architecture/exceptions-and-io/exceptional-control-flow)
into kernel mode, and `iret` drops back to user mode on the way out. There is no
third door. The mode bit cannot be written directly from user mode, and every
kernel-mode entry point is an address the kernel itself installed.

$$
% caption: The user/kernel boundary. User code crosses into the kernel only via a
% caption: syscall (trap) or an interrupt, which switch to kernel mode and vector
% caption: through the exception table. iret returns to user mode. Privileged
% caption: instructions are blocked on the user side of the line.
\begin{tikzpicture}[font=\footnotesize,>=stealth,
  box/.style={draw, minimum width=26mm, minimum height=11mm, inner sep=3pt, align=center}]
  \definecolor{acc}{HTML}{2348F2}
  % the privilege line
  \draw[acc, thick, dashed] (-2.2,0) -- (8.4,0);
  \node[anchor=west, text=acc, font=\scriptsize] at (6.6,0.32) {kernel mo\/de};
  \node[anchor=west, text=acc, font=\scriptsize] at (6.6,-0.32) {user mo\/de};
  % user side (below)
  \node[box] (user) at (0.5,-1.6) {user pro\/cess\\(unprivileged)};
  % kernel side (above)
  \node[box, fill=acc!8] (kern) at (0.5,1.6) {kernel\\(privileged)};
  % crossing up: syscall / interrupt
  \draw[->] (user.north west) -- (kern.south west)
    node[pos=0.3,left,align=right,font=\scriptsize] {syscall /\\in\/terrupt};
  % crossing down: iret
  \draw[->] (kern.south east) -- (user.north east)
    node[pos=0.72,right,font=\scriptsize] {iret};
\end{tikzpicture}
$$

## The interrupt controller

A machine has one CPU interrupt input (per core) and dozens of interrupt
sources: disks, network cards, keyboards, timers, other cores. The component in
between is the **interrupt controller**, and it has three jobs: collect request
lines from every device, decide which pending request the CPU should see first
(**prioritization**), and tell the CPU _which_ device it was by supplying the
**vector number** that indexes the exception table.

The classic part is the 8259A **PIC** (programmable interrupt controller): eight
input lines, cascadable to fifteen, each line mapped to a consecutive vector.
Modern x86 systems replace it with the **APIC** architecture: a **local APIC**
attached to each core, plus an **I/O APIC** that routes device lines to
whichever core the OS chooses, with an arbitrary vector per line; newer devices
skip wires entirely and signal by a special memory write (message-signaled
interrupts). The architecture changed to scale across cores, but the contract
did not: the controller presents one prioritized request at a time and hands the
CPU a vector number.

$$
% caption: Interrupt vectoring. Devices assert request lines into the interrupt
% caption: controller, which masks and prioritizes them and presents one vector
% caption: number to the CPU. The CPU uses the vector to index the exception
% caption: table and jumps to that device's handler.
\begin{tikzpicture}[font=\footnotesize,>=stealth,
  box/.style={draw, minimum width=19mm, minimum height=8mm, inner sep=2pt, align=center},
  lbl/.style={font=\scriptsize, text=black}]
  \definecolor{acc}{HTML}{2348F2}
  % devices
  \node[box] (d1) at (0,1.4) {disk};
  \node[box] (d2) at (0,0) {network card};
  \node[box] (d3) at (0,-1.4) {timer};
  % controller
  \node[box, fill=acc!8, minimum height=14mm, minimum width=24mm] (ic) at (3.9,0)
    {controller:\\mask, prioritize};
  \draw[->] (d1.east) -- (ic.160);
  \draw[->] (d2.east) -- (ic.west);
  \draw[->] (d3.east) -- (ic.200);
  % to CPU
  \node[box] (cpu) at (7.6,0) {CPU};
  \draw[->] (ic.east) -- (cpu.west) node[midway, above, font=\scriptsize] {vector 46};
  % table lookup
  \node[box, minimum width=26mm] (tab) at (7.6,-2.0) {exception table:\\entry 46 = handler};
  \draw[->] (cpu.south) -- (tab.north) node[midway, right, font=\scriptsize] {index};
\end{tikzpicture}
$$

Two controls matter to software. **Masking**: the kernel can block interrupts,
either globally at the CPU (the interrupt-enable flag, cleared by `cli` and set
by `sti`, both privileged) or per line at the controller. Handlers run critical
code that must not be re-entered carelessly, so the CPU automatically masks
interrupts on entry to a handler and unmasks on `iret`. **Nesting**: a kernel
may re-enable interrupts inside a long handler so that a _higher-priority_
request (say the timer) can preempt a lower one (a slow device); handlers then
nest on the kernel stack exactly as calls do. The practical discipline that
falls out is to keep handlers short: acknowledge the device, record what must be
done, defer the rest to ordinary kernel code running with interrupts enabled.

## An interrupt, end to end

A device — say a disk that has finished reading a block — does not get to run code
on the CPU; it gets to raise a _signal_. It asserts a line into the interrupt
controller, which prioritizes pending requests and, if interrupts are enabled,
raises the processor's interrupt input. The CPU finishes its current instruction,
then takes the interrupt: it saves state, switches to kernel mode, and jumps
through the exception table to that device's **interrupt handler**. The handler
services the device — copies or acknowledges the data, tells the controller the
interrupt is handled so the next one can be delivered, wakes any process waiting
on the I/O — and executes `iret`, resuming whatever the CPU had been running.

$$
% caption: Interrupt-driven I/O. The device asserts a request to the interrupt
% caption: controller, which signals the CPU. The CPU vectors to the handler,
% caption: which services the device and acknowledges it, then returns to the
% caption: interrupted code with iret.
\begin{tikzpicture}[font=\footnotesize,>=stealth,
  box/.style={draw, minimum width=20mm, minimum height=11mm, inner sep=3pt, align=center},
  num/.style={draw, circle, fill=acc!8, inner sep=1pt, minimum size=4.5mm, font=\scriptsize}]
  \definecolor{acc}{HTML}{2348F2}
  \node[box] (dev) at (0,0) {I/O\\device};
  \node[box, fill=acc!8] (ic) at (3.4,0) {in\/terrupt\\controller};
  \node[box] (cpu) at (7.0,0) {CPU};
  \node[box] (hdl) at (7.0,-2.2) {handler};
  % device -> controller (1)
  \draw[->] (dev.east) -- (ic.west) node[midway,above] {request};
  \node[num] at (1.9,0.55) {1};
  % controller -> CPU (2)
  \draw[->] (ic.east) -- (cpu.west) node[midway,above] {signal};
  \node[num] at (5.4,0.55) {2};
  % CPU -> handler (3)
  \draw[->] (cpu.south) -- (hdl.north) node[midway,right] {vector};
  \node[num] at (6.45,-1.1) {3};
  % handler -> device acknowledge (4)
  \draw[->] (hdl.west) -| (dev.south) node[pos=0.2,below] {service + ack};
  \node[num] at (0.5,-1.1) {4};
\end{tikzpicture}
$$

The timing has one subtlety worth naming: the CPU checks for pending interrupts
**between instructions**. An asynchronous signal never tears an instruction in
half; the interrupted instruction either completed or (for a fault) counts as
not-run. That is what makes the saved `%rip` a clean resumption point, and it is
the property [pipelined](/computer-architecture/pipelining/the-complete-pipe-processor)
implementations work hard to preserve: whatever the pipeline was doing, the
exception must appear to strike at an instruction boundary.

## The timer interrupt and preemptive scheduling

The interrupt mechanism does double duty. Besides devices reporting completions,
the kernel programs the **interval timer** to interrupt on a fixed period,
typically every 1 to 10 milliseconds. Each tick hands control to the kernel no
matter what is running. The timer handler updates time accounting, and the
scheduler decides whether the current process has had enough CPU. If it has, the
kernel does not `iret` back into it. It performs a **context switch** and returns
into a different process instead.

> **Definition (Context switch).** Saving the complete CPU state of the running
> process — its general-purpose registers, `%rip`, flags, stack pointer, and the
> [page-table base register](/computer-architecture/virtual-memory/the-tlb-and-multi-level-page-tables) —
> and restoring the previously-saved state of another, so execution resumes in the
> second process. Performed by the kernel, in kernel mode, on a timer interrupt or
> a blocking syscall.

The switch is mechanical but total: every piece of state that makes the CPU "be"
process A must be put aside and replaced with process B's. Switching the page-table
base changes the virtual-to-physical mapping, so the same virtual addresses now
name B's memory; the TLB must therefore be flushed (or its entries tagged by
process) so stale translations from A are not reused. The flush is the expensive
part. The register save and restore are a few dozen memory operations, but the
switched-to process starts with a cold TLB and a cache full of someone else's
data, so the true cost of a context switch is paid in the misses that follow it.
This is why time slices are milliseconds and not microseconds: the switch
overhead must stay small relative to the slice.

$$
% caption: A context switch on a timer interrupt. Process A is running; the kernel
% caption: saves A's registers and page-table base, restores B's, and returns into
% caption: B. The same virtual addresses now map to B's memory.
\begin{tikzpicture}[font=\footnotesize,>=stealth,
  ph/.style={draw, minimum width=22mm, minimum height=9mm, inner sep=2pt, align=center}]
  \definecolor{acc}{HTML}{2348F2}
  % timeline
  \node[ph] (a) at (0,0) {pro\/cess A\\running};
  \node[ph, fill=acc!8] (k) at (3.6,0) {kernel\\(switc\/h)};
  \node[ph] (b) at (7.2,0) {pro\/cess B\\running};
  \draw[->] (a.east) -- (k.west) node[midway,above,font=\scriptsize] {timer irq};
  \draw[->] (k.east) -- (b.west) node[midway,above,font=\scriptsize] {iret};
  % what the kernel does, below
  \node[anchor=north, align=center, font=\scriptsize] at (3.6,-0.75)
    {save A regs + PT base\\restore B regs + PT base};
\end{tikzpicture}
$$

There is a second trigger besides the timer. When a process makes a syscall that
cannot complete immediately — a `read` from a disk that will take milliseconds —
the kernel does not spin waiting inside the syscall. It marks the process
blocked, context-switches to a runnable one, and lets the eventual **completion
interrupt** mark the blocked process runnable again. Preemption and I/O overlap
are the same machinery viewed from two sides, and the disk-read walkthrough at
the end of this lesson uses both.

## Polling versus interrupts, with numbers

How does software learn an I/O operation is done? There are two strategies.

- **Polling** has the CPU _repeatedly read_ a device's status register in a loop
  until it reports ready. It is simple, and it notices readiness almost
  instantly. But every loop iteration is a cycle the CPU could have spent on
  something else.
- **Interrupt-driven I/O** lets the CPU issue the request and go do other
  work; the device raises an interrupt when finished, exactly as above.

The trade is worth quantifying, because it flips depending on the device. Take a
3 GHz processor and a disk read that completes in 4 ms. Polling spins for
$0.004 \times 3\times10^9 = 12{,}000{,}000$ cycles — twelve million cycles of
status reads, enough to execute tens of millions of instructions of useful work.
The interrupt path instead costs one handler round trip: saving and restoring
state, running the handler, and the cache and TLB disturbance it leaves behind —
call it on the order of $10{,}000$ cycles. For the disk, interrupts win by three
orders of magnitude.

Now replace the disk with a fast NVMe device that completes a read in 10
microseconds: the wait is $0.00001 \times 3\times10^9 = 30{,}000$ cycles, the
same order as the interrupt overhead itself. Sleeping and being woken can take
longer than the I/O. This is why high-performance storage and network stacks
have swung back to polling for their fastest devices: when the wait is shorter
than the cost of being interrupted, spinning _is_ the efficient choice. The rule
is not "interrupts good, polling bad" but a comparison of two costs: expected
wait time against handler round trip.

$$
% caption: Polling versus interrupt-driven I/O for a 4 ms disk read on a 3 GHz
% caption: CPU. Polling burns 12 million cycles reading the status register;
% caption: interrupt-driven I/O runs other work and pays one handler round trip
% caption: (on the order of ten thousand cycles) when the device signals.
\begin{tikzpicture}[font=\footnotesize,>=stealth,
  lbl/.style={font=\scriptsize, text=black}]
  \definecolor{acc}{HTML}{2348F2}
  % time axis
  \draw[->] (0,-3.1) -- (9.8,-3.1) node[anchor=west, font=\scriptsize] {time};
  \draw[black] (0.0,-2.95) -- (0.0,-3.25) node[lbl, below] {request};
  \draw[black] (7.6,-2.95) -- (7.6,-3.25) node[lbl, below] {device done (4 ms)};
  % polling lane
  \node[anchor=east] at (-0.3,-0.4) {polling};
  \draw[black, line width=2.2pt] (0,-0.4) -- (7.6,-0.4);
  \node[lbl, anchor=south] at (3.8,-0.28) {spin on status register: 12,000,000 cycles wasted};
  \draw[acc, line width=2.2pt] (7.6,-0.4) -- (9.0,-0.4);
  \node[lbl, anchor=north] at (8.3,-0.55) {use data};
  % interrupt lane
  \node[anchor=east] at (-0.3,-1.9) {in\/terrupt};
  \draw[acc, line width=2.2pt] (0,-1.9) -- (7.6,-1.9);
  \node[lbl, anchor=south] at (3.8,-1.78) {other useful work};
  \draw[black, line width=2.2pt] (7.6,-1.9) -- (8.1,-1.9);
  \node[lbl, anchor=north, align=center] at (8.15,-2.05) {handler\\(order of 10,000 cycles)};
  \draw[acc, line width=2.2pt] (8.1,-1.9) -- (9.0,-1.9);
\end{tikzpicture}
$$

## Device registers: how the CPU talks to hardware

Interrupts are the device-to-CPU direction. The CPU-to-device direction goes
through **device registers**: small storage locations inside the device
controller that the processor reads and writes like memory. A typical controller
exposes at least three:

- a **status register** the CPU reads (ready? busy? error?),
- a **command register** the CPU writes to start an operation, and
- **data/parameter registers** for operands: a block number, a byte count, a
  memory address.

Two addressing schemes give the CPU access to them. **Port I/O** gives devices a
separate, small address space (64 K ports on x86), reached only by the dedicated
`in` and `out` instructions, which are privileged. **Memory-mapped I/O** is the
modern default: a region of the _physical address space_ is reserved for a
device, and its registers appear at those addresses, so ordinary `mov`
instructions read and write them. x86-64 keeps both, port I/O surviving for
legacy devices; most controllers today are memory-mapped.

$$
% caption: Two ways to address device registers. Memory-mapped I/O reserves
% caption: regions of the physical address space, reached by ordinary loads and
% caption: stores. Port I/O is a separate small space reached only by the
% caption: privileged in/out instructions.
\begin{tikzpicture}[font=\footnotesize,>=stealth,
  seg/.style={draw, minimum width=30mm, inner sep=2pt, align=center, font=\footnotesize},
  lbl/.style={font=\scriptsize, text=black}]
  \definecolor{acc}{HTML}{2348F2}
  % physical address space column
  \node[lbl, font=\footnotesize] at (0,2.7) {physical address space};
  \node[seg, minimum height=13mm] (dram1) at (0,1.4) {DRAM};
  \node[seg, minimum height=7mm, fill=acc!8] (mmio1) at (0,0.4) {disk controller regs};
  \node[seg, minimum height=7mm, fill=acc!8] (mmio2) at (0,-0.3) {network card regs};
  \node[seg, minimum height=13mm] (dram2) at (0,-1.3) {DRAM};
  \node[lbl, anchor=north] at (0,-2.1) {reached by \texttt{mov}: memory-mapp\/ed I/O};
  % port space column
  \node[lbl, font=\footnotesize] at (6.6,2.7) {port space (64K)};
  \node[seg, minimum height=7mm, minimum width=24mm, fill=acc!8] (p1) at (6.6,1.7) {keyboard port};
  \node[seg, minimum height=7mm, minimum width=24mm, fill=acc!8] (p2) at (6.6,1.0) {serial port};
  \node[seg, minimum height=13mm, minimum width=24mm] (p3) at (6.6,-0.1) {...};
  \node[lbl, anchor=north, align=center] at (6.6,-1.0) {reached only by \texttt{in} / \texttt{out}\\(privileged)};
\end{tikzpicture}
$$

A device register is not memory, and the difference shows through the
[memory hierarchy](/computer-architecture/memory-hierarchy/set-associative-and-write-policies):
reading a status register has a _side effect_ in the device, and two reads may
return different values. Register regions are therefore marked uncacheable in
the page tables; a cached copy of "status: busy" would never be updated. This is
also why device access is kernel-only: the pages holding register regions are
mapped with the supervisor bit, so user code cannot poke hardware directly.

As a concrete miniature (following CS:APP's example), suppose a simple disk
controller is memory-mapped at address `0xa0`. The kernel initiates a read of one logical
block with three stores: first a command word saying "read, and interrupt me
when done", then the logical block number, then the destination address in main
memory. Command issued, the kernel moves on; everything after that is the
device's problem until the interrupt arrives. What fills the gap between "here
is a destination address" and "the data is there" is DMA.

## DMA: moving data without the CPU

Interrupts remove the _waiting_ cost of I/O. One cost remains: if the CPU itself
must copy every byte between the device and memory, a large transfer still
consumes the processor word by word — a 1 MB read at 8 bytes per load/store pair
is on the order of a quarter million instructions of pure copying. **Direct
memory access** removes that too.

> **Definition (DMA).** A transfer in which a device controller reads or writes
> main memory **directly over the bus**, without CPU involvement, the CPU having
> programmed the controller beforehand with a source, a destination, and a
> length. Completion is announced with a single interrupt.

The walkthrough has three steps:

1. **Setup.** The CPU (kernel code) writes the transfer parameters into the
   controller's registers: which blocks to read, how many bytes, and the
   physical address of the destination buffer. The buffer's pages must be
   **pinned** — the [VM system](/computer-architecture/virtual-memory/page-tables-and-page-faults)
   must not evict or move them mid-transfer, because the controller uses
   physical addresses and takes no page faults.
2. **Transfer.** The controller becomes a **bus master**: it drives read and
   write transactions on the memory bus itself, moving data device-to-DRAM (or
   the reverse) at device speed. The CPU is not merely idle during this, it is
   _elsewhere_, running other processes; it competes with the DMA engine only
   for memory-bus bandwidth.
3. **Completion.** The controller raises one interrupt. The handler checks
   status, unpins the buffer, and wakes whoever was waiting for the data.

$$
% caption: A DMA disk read. (1) The CPU programs the controller with block,
% caption: length, and destination. (2) The controller masters the bus and moves
% caption: the data into DRAM directly. (3) One completion interrupt tells the
% caption: CPU the buf\/fer is full.
\begin{tikzpicture}[font=\footnotesize,>=stealth,
  box/.style={draw, minimum width=22mm, minimum height=10mm, inner sep=2pt, align=center},
  num/.style={draw, circle, fill=acc!8, inner sep=1pt, minimum size=4.5mm, font=\scriptsize}]
  \definecolor{acc}{HTML}{2348F2}
  \node[box] (cpu) at (0,0) {CPU};
  \node[box, fill=acc!8] (ctl) at (4.4,0) {disk controller\\(DMA engine)};
  \node[box] (mem) at (8.8,0) {main\\memory};
  % 1: program
  \draw[->] (cpu.east) -- (ctl.west)
    node[midway, above, font=\scriptsize, align=center] {1: sector, length,\\destination};
  % 2: transfer
  \draw[->] (ctl.east) -- (mem.west)
    node[midway, above, font=\scriptsize] {2: DMA transfer};
  % 3: interrupt back
  \draw[->] (ctl.south) |- (0,-1.7) -- (cpu.south)
    node[pos=0.0, below, font=\scriptsize] {};
  \node[anchor=north, font=\scriptsize] at (4.4,-1.75) {3: completion in\/terrupt};
\end{tikzpicture}
$$

**The cache hazard.** DMA writes go to DRAM, but the CPU reads through its
[caches](/computer-architecture/memory-hierarchy/cache-memories-direct-mapped) —
and the cache may still hold lines from the buffer's _previous_ contents. Read
those addresses after the transfer and the cache serves stale data, with no way
to observe that DRAM changed underneath it. The mirror hazard exists on output: a
dirty line sitting in the cache means DRAM does not yet hold what the CPU wrote,
and a DMA read from DRAM ships the old bytes. Systems solve this in one of two
ways: hardware makes DMA **coherent** (the DMA engine's bus traffic snoops the
caches, invalidating or fetching lines as needed, as most x86 systems do), or
software must explicitly flush dirty lines before an outbound transfer and
invalidate the buffer's lines after an inbound one. Either way, the lesson
generalizes: once more than one agent can touch memory, the "memory" the CPU
sees through its cache and actual DRAM are different things that must be
actively kept in agreement — the same problem that returns at full scale with
[multiple cores](/computer-architecture/multithreading-and-multicore/cache-coherence).

## A disk read, end to end

All the pieces are now in place; here is the whole machine working. A process
calls `read(fd, buf, 8192)` for a file whose blocks are not cached in memory.

1. **Trap.** `syscall` enters the kernel; the syscall handler finds the file's
   blocks and a free kernel buffer.
2. **Command.** The kernel writes the disk controller's registers: read these
   blocks, this many bytes, DMA them to this physical address, interrupt when
   done.
3. **Block and switch.** The read cannot complete for milliseconds, so the
   kernel marks the process blocked and context-switches to another runnable
   process. The CPU now runs unrelated work.
4. **DMA.** The disk reads the blocks and its controller masters the bus,
   depositing the data in the kernel buffer. No CPU involvement.
5. **Interrupt.** The controller raises its completion interrupt. Whatever
   process is running is briefly suspended; the handler checks status, marks
   the blocked process runnable, acknowledges the controller, and returns.
6. **Resume.** When the scheduler next picks the original process, the kernel
   copies the data from its buffer into the user's `buf`, sets `%rax` to the
   byte count, and `sysret`s. The program sees `read` return, milliseconds
   older and none the wiser.

$$
% caption: A disk read from syscall to completion. Process A traps in (1) and is
% caption: blocked after the kernel programs the controller (2, 3); process B
% caption: runs while the DMA transfer f\/ills the kernel buf\/fer (4); the
% caption: completion in\/terrupt (5) marks A runnable, and A later resumes and
% caption: gets its data (6).
\begin{tikzpicture}[font=\footnotesize,>=stealth,
  lbl/.style={font=\scriptsize, text=black},
  num/.style={draw, circle, fill=acc!8, inner sep=1pt, minimum size=4.2mm, font=\scriptsize}]
  \definecolor{acc}{HTML}{2348F2}
  % lanes
  \node[anchor=east] at (-0.3,0) {pro\/cess A};
  \node[anchor=east] at (-0.3,-1.0) {kernel};
  \node[anchor=east] at (-0.3,-2.0) {pro\/cess B};
  \node[anchor=east] at (-0.3,-3.0) {disk};
  % A runs, traps, blocked (dashed), resumes
  \draw[acc, line width=1.8pt] (0,0) -- (1.4,0);
  \draw[black, dashed] (1.4,0) -- (8.0,0);
  \draw[acc, line width=1.8pt] (8.0,0) -- (9.6,0);
  % kernel activity bursts
  \draw[acc, line width=1.8pt] (1.4,-1.0) -- (2.6,-1.0);
  \draw[acc, line width=1.8pt] (6.2,-1.0) -- (6.9,-1.0);
  \draw[acc, line width=1.8pt] (7.6,-1.0) -- (8.0,-1.0);
  \draw[black, dashed] (0,-1.0) -- (1.4,-1.0);
  \draw[black, dashed] (2.6,-1.0) -- (6.2,-1.0);
  \draw[black, dashed] (6.9,-1.0) -- (7.6,-1.0);
  \draw[black, dashed] (8.0,-1.0) -- (9.6,-1.0);
  % B runs in the gap
  \draw[black, dashed] (0,-2.0) -- (2.6,-2.0);
  \draw[acc, line width=1.8pt] (2.6,-2.0) -- (6.2,-2.0);
  \draw[acc, line width=1.8pt] (6.9,-2.0) -- (7.6,-2.0);
  \draw[black, dashed] (6.2,-2.0) -- (6.9,-2.0);
  \draw[black, dashed] (7.6,-2.0) -- (9.6,-2.0);
  % disk busy + DMA
  \draw[black, dashed] (0,-3.0) -- (2.6,-3.0);
  \draw[acc, line width=1.8pt] (2.6,-3.0) -- (6.2,-3.0);
  \draw[black, dashed] (6.2,-3.0) -- (9.6,-3.0);
  % transitions
  \draw[->, black] (1.4,-0.15) -- (1.4,-0.85);
  \draw[->, black] (2.6,-1.15) -- (2.6,-1.85);
  \draw[->, black] (2.6,-1.15) -- (2.6,-2.85);
  \draw[->, black] (6.2,-2.85) -- (6.2,-1.15);
  \draw[->, black] (8.0,-0.85) -- (8.0,-0.15);
  % step numbers
  \node[num] at (1.4,0.45) {1};
  \node[num] at (2.0,-0.55) {2};
  \node[num] at (3.1,-1.55) {3};
  \node[num] at (4.4,-2.55) {4};
  \node[num] at (6.55,-0.55) {5};
  \node[num] at (8.4,0.45) {6};
  % annotations
  \node[lbl, anchor=north] at (4.4,-3.2) {DMA data lands in kernel memory};
  \node[lbl, anchor=south] at (4.4,0.35) {A waits: another pro\/cess gets the CPU};
\end{tikzpicture}
$$

Count what the mechanisms bought. During a 4 ms disk read, a 3 GHz CPU executes
roughly 12 million cycles of _other processes'_ work instead of spinning (the
interrupt bought that), and not one of those cycles is spent copying bytes (DMA
bought that). The process that asked for the data paid two context switches and
a buffer copy. That is the shape of all modern I/O: the CPU orchestrates and is
notified; the data moves on its own.

## When interrupts cost too much

The polling-versus-interrupts analysis earlier in this lesson assumed a slow
device, where an interrupt's round-trip cost is negligible against a
multi-millisecond wait. Modern high-speed devices broke that assumption, and the
system's software has spent the last two decades adapting — a live example of the
same two costs, expected-wait versus handler-overhead, tipping the other way.

The pressure came first from networking. A 10-gigabit link receiving small
packets can deliver well over a million packets per second, and one interrupt per
packet would spend the entire CPU just entering and leaving handlers — an
"interrupt storm" that starves the very work the packets are for. Two fixes are
now standard. **Interrupt coalescing** lets the device hold its interrupt until
several packets have arrived or a short timer expires, amortizing one handler
round trip over many packets. And Linux's **NAPI** switches a busy interface
_out_ of interrupt mode entirely: on the first packet it disables that device's
interrupts and has the kernel **poll** the device in a loop until the burst
drains, then re-enables interrupts.[^napi] The device that this lesson taught to
raise interrupts is told, under load, to stop.

$$
% caption: Adapting interrupt cost to device speed. A slow device (left) is best
% caption: served by one interrupt per event. A fast device under load (right)
% caption: coalesces many events into one interrupt, or is polled in a tight loop
% caption: with interrupts disabled, to avoid an interrupt storm.
\begin{tikzpicture}[font=\footnotesize,>=stealth,
  ev/.style={draw, minimum width=5mm, minimum height=5mm, inner sep=0pt},
  lbl/.style={font=\scriptsize, text=black}]
  \definecolor{acc}{HTML}{2348F2}
  % slow device: one irq each
  \node[lbl] at (0,1.5) {slow device};
  \foreach \i in {0,1,2} {
    \node[ev, fill=acc!8] at (\i*1.5,0.7) {};
    \draw[->, acc] (\i*1.5,0.4) -- (\i*1.5,-0.1);
    \node[lbl, anchor=north] at (\i*1.5,-0.15) {irq};
  }
  \node[lbl, anchor=north, align=center] at (1.5,-0.7) {one interrupt per event};
  % fast device: coalesced
  \node[lbl] at (7.5,1.5) {fast device (load)};
  \foreach \i in {0,1,2,3,4,5} \node[ev, fill=acc!18] at (6.0+\i*0.5,0.7) {};
  \draw[->, acc] (7.25,0.4) -- (7.25,-0.1);
  \node[lbl, anchor=north] at (7.25,-0.15) {one irq};
  \node[lbl, anchor=north, align=center] at (7.25,-0.7) {coalesce, or poll the burst};
\end{tikzpicture}
$$

Storage followed the same path. When flash and then NVMe drives cut latency from
milliseconds to microseconds, the interrupt's round trip — the very cost the
disk-read walkthrough treated as free — became comparable to the wait itself, the
crossover this lesson's NVMe example flagged. Two responses now ship in production
kernels. **Hybrid polling** lets a thread issuing a fast I/O spin briefly instead
of sleeping, betting the device finishes before a context switch would have paid
for itself. And user-space I/O frameworks — **DPDK** for networking, **SPDK** and
`io_uring`'s polled mode for storage — bypass the interrupt path altogether for
the hottest devices, dedicating a core to poll rings of completions.[^kernelbypass]
The unifying rule is the one this lesson stated and these systems rediscovered
under load: interrupts win when waiting is expensive relative to being
interrupted, and polling wins when it is not — and on today's fastest hardware,
it often is not.

## The kernel's three doors

Everything in this module enters the kernel one of three ways, and it is worth
seeing them side by side.

| entry | trigger | timing | vector from | returns to |
|---|---|---|---|---|
| interrupt | device signal | async, between instructions | controller (32–255) | next instruction |
| exception (fault/abort) | instruction's side effect | sync | architecture (0–31) | same instruction, or never |
| syscall (trap) | `syscall` instruction | sync, deliberate | fixed entry + `%rax` | next instruction |

One table, three rows, and every kernel entry in the system is one of them. The
[exception table](/computer-architecture/exceptions-and-io/exceptional-control-flow)
serves the first two; the third has its own registered entry point but the same
shape: save state, run kernel code, restore, drop privilege.

> **Takeaway.** Devices reach the CPU through the **interrupt controller**,
> which masks, prioritizes, and supplies the **vector number** that indexes the
> exception table. The **timer interrupt** gives the kernel the CPU back every
> few milliseconds, driving **preemptive scheduling** via the **context
> switch** — registers plus page-table base out, another process's in, TLB
> flushed. **Polling** costs the expected wait (12 million cycles for a 4 ms
> disk on a 3 GHz CPU); an **interrupt** costs a handler round trip (order
> $10^4$ cycles); the cheaper one depends on the device. The CPU commands
> devices through **status/command/data registers**, memory-mapped (uncacheable,
> kernel-only) or in the legacy port space; **DMA** moves the bulk data with the
> controller as bus master and one completion interrupt, at the price of keeping
> caches and DRAM in agreement. A disk read uses every piece: trap, command,
> block, switch, DMA, interrupt, resume.

This closes the loop opened back in [processor design](/computer-architecture/processor-design/the-fetch-decode-execute-cycle):
the fetch-decode-execute cycle is the normal flow, exceptions and interrupts are
the diversions, and together with [virtual memory](/computer-architecture/virtual-memory/address-spaces-and-translation)
they are what turns a raw datapath into a machine that can safely run many
programs at once.

[^napi]: **NAPI** ("New API") is the interrupt-mitigation scheme in the Linux network stack, introduced around kernel 2.4/2.6: on load it disables a device's receive interrupt and polls the device in the kernel's soft-interrupt context until the ring drains, then re-arms the interrupt. Salim, Olsson, and Kuznetsov's "Beyond Softnet" (USENIX/ALS, 2001) describes the design and the interrupt-storm problem it solves; hardware interrupt coalescing is documented in the datasheets of every high-speed NIC.
[^kernelbypass]: **DPDK** (Data Plane Development Kit) and **SPDK** (Storage Performance Development Kit) are the widely used user-space, poll-mode I/O frameworks that bypass the kernel interrupt path for the fastest devices; Linux's `io_uring` (Axboe, 2019) adds a polled submission/completion mode for storage, and "hybrid" adaptive polling for NVMe was added to the block layer around kernel 4.10. The common rationale — that at microsecond device latencies the interrupt round trip is no longer negligible — is the crossover this lesson's NVMe example identifies.
