Interrupts and DMA
Imagine copying a 10 GB file from disk to memory, one byte at a time. The CPU checks a status flag before every single byte. At even a generous one check per microsecond, that single copy would burn nearly three hours of pure CPU time. It does nothing but babysit a disk controller. Yet real systems move gigabytes per second while the CPU runs other programs. The gap between those two worlds is bridged by a handful of foundational ideas. These include how a device tells the CPU it needs attention. They also include how bulk data gets from device to memory without the CPU touching every byte. This article is about the mechanics of moving data across the device–memory boundary. It builds on the I/O Devices and the Device Driver model of registers, status flags, and drivers. Every I/O transfer must answer two independent questions. First, how does the CPU learn that a device is ready? Second, who actually copies the bytes: the CPU, one word at a time, or dedicated hardware? These two questions give rise to three transfer strategies. They are programmed I/O, interrupt-driven I/O, and direct memory access (DMA). These layer on top of each other rather than compete outright. The big picture is a story of delegation. Early systems made the CPU do everything: poll devices, and move every word. Interrupts freed the CPU from polling. DMA then freed the CPU from moving bytes altogether. It could merely orchestrate the transfer and react to a single completion signal. Modern devices push delegation even further. They batch thousands of completions into one interrupt to protect the CPU from interrupt storms. Understanding this progression is essential context for later topics. These include Disk Scheduling Algorithms and the very different I/O economics of Solid-State Drives (SSDs). By the end of this article you will be able to trace, cycle by cycle, how a single disk-read request travels through programmed I/O, interrupt-driven I/O, and DMA. You will be able to compute and compare the CPU-overhead cost of each approach. You will also recognize the concrete hardware components that make delegation possible. These include the interrupt controller, the DMA controller, and bus arbitration.
- Programmed I/O (PIO) makes the CPU poll a status register and move every data word itself — simple but wasteful.
- Interrupt-driven I/O lets the CPU do useful work while waiting; the device raises a hardware interrupt when ready, still one word at a time.
- DMA adds a dedicated controller that moves entire blocks between device and memory, interrupting the CPU only once per transfer, not once per word.
- CPU overhead drops roughly from O(n) status checks and copies (PIO), to O(n) interrupts (interrupt-driven I/O), to O(1) interrupts (DMA), for an n-word transfer.
- DMA introduces new problems: bus contention (cycle stealing), cache coherence with device-written memory, and the need for physically contiguous or scatter-gather buffer descriptors.
- These strategies are not mutually exclusive — real drivers use PIO for tiny control transfers, interrupts for event notification, and DMA for bulk data, often combined.
As data volume grows, the right strategy shifts from the CPU does the work (PIO) to the CPU is notified of the work (interrupts) to the CPU merely authorizes the work (DMA) — each step trading a little control for a lot of CPU time back.
The idea
An I/O transfer has two logically separate jobs: synchronization (knowing when a device is ready) and data movement (getting the actual bytes between the device and memory). Confusing these two is the most common source of confusion for students first encountering this material — a system can synchronize by interrupt while still moving data with the CPU, and this is in fact the second of our three strategies.
Programmed I/O (PIO) handles both jobs with the CPU alone. The CPU repeatedly reads a status register on the device controller — a technique called polling or busy-waiting — until a ready bit is set, and then the CPU itself executes a load/store instruction to move each word of data across the bus. PIO is simple and requires no extra hardware, but it wastes CPU cycles spinning, and it forces one CPU instruction per word transferred, making the CPU cost scale linearly with data size.
Interrupt-driven I/O decouples synchronization from busy-waiting. The CPU issues a request and then either blocks the calling process or continues running other processes entirely. When the device becomes ready, it asserts an interrupt request (IRQ) line, which is routed through an interrupt controller to the CPU. The CPU's interrupt-service routine (ISR) — the same mechanism described in I/O Devices and the Device Driver — then handles that single unit of readiness. Crucially, in the pure interrupt-driven model without DMA, the CPU is still the one physically copying each word once notified; interrupts only eliminate the busy-wait, not the per-word copy.
Direct Memory Access (DMA) eliminates the per-word CPU copy as well. A separate hardware unit, the DMA controller (DMAC), is programmed once with a source address, destination address, and transfer length (or a list of such descriptors for scatter-gather transfers). The DMAC then independently drives the memory bus, moving the entire block, and raises a single interrupt only when the whole transfer completes. The CPU's involvement collapses from "move every word" to "set up three registers and wait for one interrupt."
How it works
The clean way to see the difference between the three strategies is to walk through the same task — reading a block of data from a disk controller into memory — under each regime.
Programmed I/O, step by step: 1. CPU writes a command (e.g., "read block 42") into the device's command register. 2. CPU enters a tight loop, repeatedly reading the device's status register. 3. When the status register signals "data ready," the CPU reads one word from the device's data register into a CPU register. 4. CPU stores that word into the target memory location. 5. Repeat steps 2–4 for every word in the block. 6. Loop terminates when the word count is exhausted.
Interrupt-driven I/O, step by step: 1. CPU writes the command to the device, then the requesting process blocks (or the CPU schedules other work); no busy loop. 2. Device performs its work asynchronously (e.g., seeks and reads from the platter). 3. When one word (or a small buffer) is ready, the device asserts its interrupt line. 4. The interrupt controller signals the CPU; the CPU finishes its current instruction, saves context, and dispatches to the device's ISR. 5. ISR reads the ready word from the data register and stores it to memory, then acknowledges the interrupt. 6. CPU resumes the interrupted process — until the next word triggers another interrupt.
DMA-based I/O, step by step: 1. CPU (via the driver) programs the DMA controller: source address (device FIFO), destination address (a memory buffer), transfer length, and direction. 2. CPU tells the device to begin transferring, then returns to running other work — no polling, no per-word interrupts. 3. Device pushes data into the DMAC (or the DMAC pulls data from the device) directly onto the memory bus, using cycles the CPU is not using at that instant (cycle stealing), or during dedicated bus phases (burst mode). 4. DMAC decrements its word counter after every word transferred, entirely in hardware. 5. When the counter reaches zero, the DMAC raises a single interrupt to the CPU signaling transfer complete. 6. CPU's ISR does bookkeeping (wake the waiting process, check for errors) — it never touched the data itself. ```pseudocode // --- Programmed I/O --- function pio_read(device, buffer, n_words): device.command_register = READ_CMD for i in 0 .. n_words - 1: while device.status_register != READY: // busy-wait: wastes CPU cycles pass buffer[i] = device.data_register // CPU copies every word return SUCCESS // --- Interrupt-driven I/O --- // shared state between driver and ISR queue_index = 0 buffer = allocate(n_words) transfer_done = false function interrupt_read(device, n_words): device.command_register = READ_CMD block_current_process() // CPU free to run other work // control returns here only after all interrupts handled return SUCCESS function isr_on_word_ready(device): // fires once PER WORD buffer[queue_index] = device.data_register queue_index += 1 if queue_index == n_words: transfer_done = true wake_blocked_process() acknowledge_interrupt(device) // --- DMA --- function dma_read(device, buffer, n_words): dmac.source = device.fifo_address dmac.destination = address_of(buffer) dmac.count = n_words dmac.direction = DEVICE_TO_MEMORY dmac.start() // one-time setup, O(1) CPU work block_current_process() return SUCCESS function isr_on_dma_complete(): // fires ONCE for whole transfer wake_blocked_process() acknowledge_interrupt(dmac) ``` The pseudocode makes the qualitative difference concrete: `pio_read` has an explicit loop of length `n_words` executed by the CPU; `isr_on_word_ready` is invoked `n_words` times by hardware but each invocation still costs a full interrupt (context save/restore); `dma_read` has no loop at all from the CPU's perspective — the loop has moved entirely into the DMAC's hardware counter.
Complexity & correctness
Programmed I/O. For a transfer of `n` words, the CPU executes at least one status-register poll and one data-register load/store per word, plus an unbounded number of extra polls while waiting for the device to become ready. If the device produces a word every `d` cycles and a poll costs `p` cycles, the CPU spends roughly `n·(d + c)` cycles total, where `c` is the fixed per-word copy cost — but almost all of that time is spin-wait, cycles in which the CPU could have executed unrelated instructions. Time complexity in terms of useful CPU work is O(n) copies, but wasted CPU time is proportional to the full device latency, which can be enormous for slow mechanical devices. Correctness is straightforward: the status register acts as a hardware invariant — the CPU may read `data_register` only when `status_register = READY`, and the loop's postcondition (`buffer` fully populated) follows by induction on the number of completed iterations, since each iteration transfers exactly one word and the loop runs exactly `n` times.
Interrupt-driven I/O. The busy-wait disappears — the CPU can run other processes during the device latency — but the dispatch overhead now appears n times: each of the n words triggers a full interrupt (save CPU context, vector to ISR, execute a few instructions, restore context). If interrupt dispatch costs `k` cycles (typically hundreds of cycles due to pipeline flushes, register saves, and privilege-level switches), total CPU overhead is O(n·k), which for large n and slow devices with fine-grained per-word interrupts can actually exceed the wasted busy-wait time of PIO in absolute terms — though it is usefully overlapped with other processes' progress, so system throughput is far better. Correctness rests on the ISR invariant: the ISR must consume exactly one ready unit and update `queue_index` atomically with respect to further interrupts (interrupts are typically disabled or the relevant IRQ line masked during ISR execution), preventing a race where two interrupts overlap and corrupt `buffer`.
DMA. CPU overhead collapses to O(1) per transfer: a constant number of register writes to program the DMAC, plus one interrupt at completion. The actual data movement now costs bus cycles, not CPU cycles — the DMAC and CPU contend for the shared memory bus, and under cycle stealing the DMAC takes bus cycles the CPU is not using, while under burst mode the DMAC may hold the bus for the entire transfer, stalling the CPU's own memory accesses (this is the honest tradeoff: DMA frees CPU instruction time but not necessarily CPU memory-access time, since a CPU cache miss during a DMA burst can stall until the burst finishes). The correctness argument for DMA is more subtle than for the previous two: the invariant is that the DMAC and the CPU must never access the destination buffer concurrently in conflicting ways. This is why the driver must (a) pin the destination memory pages so they cannot be paged out or moved mid-transfer, (b) ensure the buffer's physical address range matches what was programmed into the DMAC (virtual-to-physical translation must happen before or during setup, not per-word), and (c) invalidate or flush CPU caches appropriately, since the DMAC bypasses the cache and writes memory directly — a CPU that reads a stale cached copy of the buffer before invalidation observes incorrect data. Systems solve this with cache-coherent DMA (bus-snooping hardware) or explicit `cache_invalidate` calls in the driver before the CPU touches DMA'd memory.
Comparative summary.
| Strategy | CPU instructions for data movement | Interrupts raised | Extra hardware | Wasted CPU time |
|---|---|---|---|---|
| PIO | O(n) | 0 | none | O(device latency), all spin |
| Interrupt-driven | O(n) | O(n) | interrupt controller | ~O(n·k) dispatch overhead, but overlapped |
| DMA | O(1) | O(1) | DMA controller | ~0 CPU time; bus contention instead |
Worked example
Consider transferring a 4 KB disk block, split into 1024 32-bit words, from a disk controller to memory. Assume: a status poll costs 5 cycles, an interrupt dispatch (save/restore context) costs 500 cycles, the device produces one word every 200 cycles, and DMA setup costs a fixed 60 cycles.
| Strategy | Approx. CPU cycles spent on this transfer | What the CPU could do meanwhile |
|---|---|---|
| PIO | ≈ 1024 × (200 spin + 5 poll + 2 copy) ≈ 212,000 cycles, all CPU-bound | Nothing — CPU is pinned in the polling loop |
| Interrupt-driven (1 IRQ/word) | ≈ 1024 × 500 (dispatch) + 1024 × 2 (copy) ≈ 514,000 cycles of dispatch overhead | Runs other processes during the 200-cycle device gaps between words, but pays 500-cycle tax per word |
| Interrupt-driven (batched, 1 IRQ per 64-word buffer) | ≈ 16 × 500 + 1024 × 2 ≈ 10,000 cycles | Runs other processes for almost the entire device latency |
| DMA | ≈ 60 (setup) + 500 (one completion interrupt) ≈ 560 cycles | Free to run other processes for virtually the whole 1024 × 200 ≈ 204,800-cycle device latency |
Two lessons emerge from this table. First, naive per-word interrupts (row 2) can be worse than PIO in raw cycle count, because interrupt dispatch is expensive — this is exactly why real hardware batches many words into a device FIFO and interrupts only when the FIFO fills (row 3), a middle ground between pure PIO and pure DMA. Second, DMA's O(1) CPU cost (row 4) is not "free": those ~204,800 device cycles still elapse in real time, and during that window the DMAC is competing with the CPU for memory-bus cycles, so a CPU running a memory-intensive workload concurrently will see some slowdown from bus contention — but it never spends instructions on the copy, which is the actual scarce resource being optimized. To see the full data-transfer state machine that a driver implements around DMA, trace the following states for a single request:
Common pitfalls & traps
| Pitfall | Why it happens | Consequence |
|---|---|---|
| Treating "interrupt-driven" as automatically fast | Students assume interrupts eliminate CPU copying entirely | Confuses interrupt-driven I/O with DMA; per-word interrupt dispatch can be slower than PIO for fast devices |
| Forgetting to mask/disable the IRQ line inside the ISR | Assuming ISRs are naturally atomic | A second interrupt fires mid-ISR, corrupting shared state (e.g., `queue_index`) — a classic race condition |
| Using a pageable (swappable) buffer as a DMA destination | Overlooking that DMA uses physical addresses set up in advance | OS pages out the buffer mid-transfer; DMAC writes to now-reused physical memory, corrupting an unrelated process |
| Ignoring cache coherence for DMA buffers | Assuming memory writes are automatically visible to the CPU | CPU reads stale cached data instead of the freshly DMA'd bytes, unless caches are invalidated or hardware snoops the bus |
| Assuming DMA transfer time is "free" CPU time | Focusing only on instruction count, not bus contention | Cycle-stealing DMA bursts can stall concurrent CPU memory accesses, hurting real-time or latency-sensitive workloads |
| Off-by-one in transfer length or descriptor count | Manually computing buffer length in bytes vs. words | DMAC either under-transfers (truncated data) or over-transfers (buffer overrun into adjacent memory) |
Pioneers & history
The evolution from programmed I/O to interrupts to DMA tracks the broader history of computers being asked to do more than one thing at a time, a theme also central to Interprocess Communication and scheduling.
Origins · 1 minJohn von Neumann and how this was discovered
Von Neumann's stored-program architecture, formalized in the 1945 EDVAC report, established the CPU-memory-bus model that all three I/O strategies operate within — without a shared, addressable memory bus, there is nothing for a DMA controller to arbitrate access to.
Where this matters
Every modern operating system kernel implements device drivers that choose among these three strategies based on device speed and data volume, exactly as described in I/O Devices and the Device Driver. Keyboards and mice, which generate tiny amounts of data at unpredictable times, use interrupt-driven I/O — a full DMA setup would be wasted overhead for a single keystroke. Disk controllers, network interface cards, and graphics cards, which move large contiguous blocks, rely on DMA (and its more advanced form, bus-mastering DMA, where the device itself initiates bus transactions without CPU involvement at all). High-speed NICs and modern NVMe SSDs go further with interrupt coalescing, batching thousands of completed operations into a single interrupt, directly generalizing the batching insight from the worked example above. Understanding these tradeoffs is essential background for Disk Scheduling Algorithms, where the cost model for reordering requests assumes DMA-driven transfers are already CPU-cheap, and for Solid-State Drives (SSDs), where NVMe's multi-queue DMA design pushes this delegation to its logical extreme — thousands of parallel DMA-backed queues with minimal per-request CPU involvement.
Ask the tutor: How does interrupt coalescing on a real NVMe SSD decide when to batch completions versus interrupting immediately?
Does DMA completely eliminate CPU involvement in I/O?
No. DMA eliminates the CPU's role in copying individual words, but the CPU still issues the initial request, programs the DMA controller's source, destination, and length registers, and services one completion interrupt per transfer (or per batch, with coalescing). The CPU also remains responsible for handling errors, managing buffer pinning, and enforcing cache coherence around the DMA'd region. In bus-mastering DMA the device can initiate transactions with even less CPU setup, but some CPU involvement — at minimum, request initiation and completion handling — is unavoidable in any practical system.
Why not just always use DMA, since it is clearly the most CPU-efficient option?
DMA setup has fixed overhead — programming several controller registers — that is wasted if the transfer is very small, such as a single keystroke or a one-byte status update. For tiny, latency-sensitive, or irregular transfers, interrupt-driven I/O (or even PIO for trivial control registers) is simpler and can actually be faster in wall-clock time because it avoids DMA controller setup latency. Real drivers typically use PIO or interrupts for small control operations and reserve DMA for bulk data movement, choosing the strategy based on transfer size and frequency rather than using one uniformly.
How does the operating system know which device raised an interrupt if several devices share one interrupt line?
Systems use either a vectored interrupt scheme, where each device supplies a unique identifying number that indexes directly into an interrupt-vector table pointing to its ISR, or a polling scheme at the software level, where the CPU's generic interrupt handler queries each device on a shared line to find which one is actually signaling. Most modern systems use vectored interrupts (via an interrupt controller such as an APIC) for speed, falling back to a short software poll only when interrupt lines are shared among multiple devices, since the interrupt controller alone cannot always disambiguate a shared line.
What is cycle stealing and why does it matter for DMA performance?
Cycle stealing is when the DMA controller takes control of the memory bus for a single cycle or a short burst, "stealing" that cycle from the CPU, which would otherwise be free to fetch instructions or data from memory during that cycle. It matters because DMA does not run for free in terms of overall system throughput: heavy DMA traffic can measurably slow down a CPU-bound process running concurrently, especially if that process is also memory-intensive, since the two hardware units genuinely compete for the same physical bus bandwidth even though the CPU's instruction count for the transfer is unaffected.
Can DMA be used for transfers that are not simple contiguous blocks, like scattered network packet fragments?
Yes — this is called scatter-gather DMA. Instead of programming the DMAC with a single source and destination address, the driver builds a list of descriptors, each specifying a separate (address, length) pair, and hands the DMAC the whole list. The DMAC works through the list autonomously, gathering data from multiple non-contiguous memory regions into one output stream (or scattering an incoming stream across multiple buffers) without further CPU intervention per fragment. This is essential for networking, where packet headers and payloads often live in separate memory regions, and for filesystems assembling non-contiguous disk blocks into one logical read.
How does this relate to context switching and process scheduling?
Interrupt-driven I/O and DMA both allow a process waiting on I/O to be moved off the CPU into a blocked state, letting the scheduler run a different ready process during the wait — this is precisely what makes multiprogramming efficient. When the device (or DMAC) later raises its completion interrupt, the ISR wakes the blocked process, moving it back to the ready queue, at which point the scheduler decides when it actually resumes running. Without interrupts, achieving overlap between I/O wait and useful CPU work would require the CPU itself to somehow "check in" on multiple pending PIO operations, which programmed I/O's busy-wait model cannot support.
Is polling always bad, then?
Not always — polling can outperform interrupts when events arrive extremely frequently, because each interrupt carries a fixed dispatch cost (context save/restore, privilege-level switch) that a tight polling loop avoids. High-performance networking and storage systems sometimes use hybrid polling, switching to interrupts under light load to save CPU cycles, and switching to polling under heavy, steady load where interrupt overhead per event would exceed the cost of simply checking a flag in a loop. The right choice depends on event rate, not on treating one mechanism as universally superior.