EthicsComputerCourses
Join training +91 90532 10052

I/O, Interrupts & DMA

Operating Systems · Lesson 27 of 32 Advanced 8 min read Updated

By the end of this lesson you will be able to compare polling, interrupt-driven I/O and DMA, list the steps the CPU takes when an interrupt arrives, and explain buffering, spooling and the main kinds of I/O calls programs use.

Keyboards, disks, network cards, cameras and printers all work at very different speeds, and all of them are far slower than the CPU. The OS's I/O subsystem lets the fast CPU work with these slow devices without wasting its time, and hides their differences behind a simple interface.

Devices, controllers and drivers

Each device is attached through a device controller: a small piece of hardware with its own registers, such as a status register ("busy" or "ready"), a command register and a data register. The CPU talks to the controller, not to the device itself.

The CPU reaches controller registers in one of two ways:

  • Port-mapped I/O: special CPU instructions (such as in and out on x86) read and write I/O ports.
  • Memory-mapped I/O: the registers appear at ordinary memory addresses, so normal load and store instructions reach them. Most modern devices use this.

A device driver is the kernel code that knows one kind of controller's registers and commands. The rest of the kernel calls the driver through a standard interface. That is why one OS can support thousands of devices: each vendor writes a driver, and every program still just calls read() and write().

Think of a hotel concierge (the kernel) who speaks to guests in one language. For each local service, the taxi stand, the laundry, the restaurant, there is a contact who speaks that service's language (the driver).

Three ways to do I/O

1. Programmed I/O with polling

The CPU repeatedly reads the device's status register: "Ready yet? Ready yet?" This is polling, a form of busy waiting. It is simple and fast when the device will be ready in a moment, but wasteful if the device is slow.

2. Interrupt-driven I/O

The CPU starts the operation and goes off to do other work. When the device is done, its controller sends an electrical signal called an interrupt. The CPU pauses what it is doing and runs a small piece of kernel code, the interrupt handler (or interrupt service routine, ISR).

A pressure cooker whistle is a perfect picture. You do not stand and stare at the cooker (polling). You chop vegetables, and the whistle tells you when to act (interrupt).

Step 1: compare the CPU cost of polling and interrupts for a keyboard.

PythonRun it yourself
# A keyboard: you type about 5 keys per second.
cpu_hz = 3_000_000_000           # 3 GHz CPU
poll_cost = 200                  # cycles to check the device once
polls_per_sec = 1_000            # poll every millisecond so typing feels instant
irq_cost = 3_000                 # cycles to handle one interrupt (save state, handler, return)
keys_per_sec = 5

polling = poll_cost * polls_per_sec
interrupts = irq_cost * keys_per_sec
print(f"polling:    {polling:>9,} cycles/s = {polling / cpu_hz:.5%} of the CPU")
print(f"interrupts: {interrupts:>9,} cycles/s = {interrupts / cpu_hz:.5%} of the CPU")
Output
polling:      200,000 cycles/s = 0.00667% of the CPU
interrupts:    15,000 cycles/s = 0.00050% of the CPU

Step 2: compare. Both are small for one keyboard, but interrupts cost about 13 times less, because the CPU only works when a key is actually pressed. Multiply by dozens of devices and the difference matters. The numbers here are rough examples, not measurements of a specific CPU.

The opposite can also be true. For very fast devices that are almost always ready, an interrupt per event costs more than a quick check. Linux network drivers use a method called NAPI that switches from interrupts to polling when packets arrive in a flood, and back again when traffic is light.

3. Direct Memory Access (DMA)

Even with interrupts, if the CPU must copy every word of a disk block itself, it stays busy. A DMA controller copies data directly between the device and RAM. The CPU only says "move this block to this memory address", and the DMA controller interrupts once at the end.

PythonRun it yourself
block = 64 * 1024                      # copy a 64 KB block from disk to RAM
word = 4                               # programmed/interrupt I/O moves 4 bytes per interrupt

per_word_interrupts = block // word
dma_interrupts = 1                     # DMA controller interrupts once, when done
print("interrupt per word:", f"{per_word_interrupts:,}", "interrupts")
print("DMA:               ", dma_interrupts, "interrupt")
Output
interrupt per word: 16,384 interrupts
DMA:                1 interrupt

Disks, SSDs, network cards and GPUs all use DMA. One side effect: the DMA controller and the CPU share the memory bus, so the CPU occasionally waits for a bus cycle. This is sometimes called cycle stealing.

What happens when an interrupt arrives

  1. A device controller raises an interrupt request (IRQ) on its interrupt line.
  2. The CPU finishes the current instruction, then checks for pending interrupts.
  3. The CPU saves the current program counter and some registers, and switches to kernel mode.
  4. It uses the interrupt number to look up the handler's address in the interrupt vector table (on x86, the Interrupt Descriptor Table).
  5. The handler runs: it reads the device status, acknowledges the interrupt, and records the work that needs doing.
  6. The CPU restores the saved state and continues the interrupted program (or the scheduler picks another process if the interrupt made something ready).

Interrupts have priorities: the timer and disk are usually more urgent than the keyboard. The kernel can also mask (temporarily disable) some interrupts during critical work. A non-maskable interrupt (NMI) cannot be disabled; it is reserved for serious events such as hardware errors.

Step 3: run this model of an interrupt controller with priorities and masking.

PythonRun it yourself
import heapq

handlers = {0: "timer", 1: "keyboard", 14: "disk", 11: "network"}
priority = {0: 0, 1: 2, 14: 1, 11: 1}          # smaller = more urgent
pending, masked = [], {11}                      # network interrupts masked for now

def raise_irq(irq, t):
    print(f"t={t}: device raises IRQ {irq} ({handlers[irq]})")
    heapq.heappush(pending, (priority[irq], t, irq))

def cpu_check(t):
    deferred = []
    while pending:
        pr, when, irq = heapq.heappop(pending)
        if irq in masked:
            deferred.append((pr, when, irq)); continue
        print(f"t={t}: CPU saves state, runs {handlers[irq]} handler, returns")
    for item in deferred:                       # masked ones stay pending
        heapq.heappush(pending, item)

raise_irq(1, 1); raise_irq(11, 1); raise_irq(14, 2); raise_irq(0, 2)
cpu_check(3)
masked.clear()
print("t=4: kernel unmasks the network IRQ")
cpu_check(4)
Output
t=1: device raises IRQ 1 (keyboard)
t=1: device raises IRQ 11 (network)
t=2: device raises IRQ 14 (disk)
t=2: device raises IRQ 0 (timer)
t=3: CPU saves state, runs timer handler, returns
t=3: CPU saves state, runs disk handler, returns
t=3: CPU saves state, runs keyboard handler, returns
t=4: kernel unmasks the network IRQ
t=4: CPU saves state, runs network handler, returns

The timer is handled first even though it arrived last, and the masked network interrupt waits until it is unmasked. It is not lost.

Top half and bottom half

Interrupt handlers must be short, because while one runs, other work (sometimes other interrupts) waits. So kernels split the work. The top half runs immediately: acknowledge the device, grab the data. The bottom half runs a little later with interrupts enabled: process the packet, wake up the waiting process. Linux uses softirqs, tasklets and workqueues for bottom halves; Windows uses Deferred Procedure Calls (DPCs). On Linux you can see interrupt counts per CPU in /proc/interrupts.

Buffering, caching and spooling

  • Buffering: keep data in memory while it moves between a fast and a slow side. With double buffering, a device fills one buffer while the program empties the other, so neither waits.
  • Caching: keep a copy of frequently used data in faster memory, such as recently read file blocks in the page cache.
  • Spooling: for devices that can serve only one job at a time, such as a printer, collect each job in a disk queue and feed them to the device one by one. The print queue you see in Windows Settings is a spooler.

Blocking, non-blocking and asynchronous I/O

From a program's point of view, there are three styles of I/O call:

StyleWhat the call doesExample
BlockingWaits until the data is ready; the process sleepsA normal read() on a file or socket
Non-blockingReturns at once with whatever is available, maybe nothingA socket opened with O_NONBLOCK; often used with poll() or epoll()
AsynchronousStarts the I/O and notifies the program later when it is doneLinux io_uring, Windows I/O completion ports

Blocking calls are easiest to write. High-performance servers that juggle thousands of connections use non-blocking or asynchronous I/O so one thread can serve many clients.

Common mistakes

  • Thinking interrupts are always better than polling. For very fast, busy devices, polling can be cheaper.
  • Saying DMA needs no CPU at all. The CPU sets up the transfer and handles the final interrupt.
  • Doing heavy work inside an interrupt handler. Long handlers delay everything else; defer the work to a bottom half.

Key points

  • Device controllers have registers; drivers are kernel code that know how to drive each controller.
  • Polling repeatedly checks the device; interrupts let the device signal the CPU when it is done.
  • DMA copies whole blocks between device and memory and interrupts only once at the end.
  • On an interrupt, the CPU saves state, looks up the handler in the vector table, runs it, then resumes.
  • Interrupts have priorities and can be masked, except non-maskable interrupts.
  • Buffering, caching and spooling smooth out speed differences; I/O calls can be blocking, non-blocking or asynchronous.

Exercise

In the interrupt model, raise IRQ 11 (network) at t=5 while it is not masked, together with IRQ 1 (keyboard), then call cpu_check(6). Which handler runs first, and why? Then mask the keyboard (IRQ 1) and repeat.

Show solution
Python
raise_irq(1, 5); raise_irq(11, 5)
cpu_check(6)
masked.add(1)
raise_irq(1, 7); raise_irq(11, 7)
cpu_check(8)
Output
t=5: device raises IRQ 1 (keyboard)
t=5: device raises IRQ 11 (network)
t=6: CPU saves state, runs network handler, returns
t=6: CPU saves state, runs keyboard handler, returns
t=7: device raises IRQ 1 (keyboard)
t=7: device raises IRQ 11 (network)
t=8: CPU saves state, runs network handler, returns

Run it after the interrupt model. The network has priority 1 and the keyboard 2, so the network runs first. With the keyboard masked, its interrupt stays pending and only the network handler runs.

Quick Quiz

1. What is the main advantage of interrupt-driven I/O over polling for a slow device?

2. What does a DMA controller do?

3. Where does the CPU find the address of the right interrupt handler?

4. What is spooling?

Learn Operating Systems with a mentor

Free lessons get you started. Our Computer Science Fundamentals training adds real projects and code reviews from working developers.

  • Project-based learning
  • Mentor code reviews
  • Free counselling call