← All chapters
Chapter 26· 17 min read · illustrated

I/O Hardware & Software Layers

How one read() call reaches a disk, a keyboard or a network card — and stays the same shape for all of them

So far this course has been mostly about the CPU and memory: how a process runs, how threads are scheduled, how a virtual address becomes a physical frame. But a computer that could only compute would be useless. It has to talk to the outside world — read files, draw pixels, send packets, listen to a keyboard, spin a disk. That is input/output, and it is where the tidy world of registers and page tables meets a chaotic zoo of physical devices, each with its own quirks, speeds and failure modes.

This chapter opens Part E, on storage, I/O and file systems. Before we can talk about disks and file systems in the chapters ahead, we need the machinery that moves bytes between a program and any device at all. We will build it from the bottom up: the device controllers and registers the hardware exposes, the three ways the CPU can wait for a device (polling, interrupts and DMA), and then the layered stack of OS software that turns all of that into a single, uniform read() and write() your code can call without caring what is on the other end.

The payoff is one of the most quietly powerful ideas in systems: the same handful of calls work whether the bytes come from an SSD, a pipe, a terminal or a socket. We lean on the hardware from Chapter 3 — interrupts, buses, DMA, memory-mapped registers — and now watch the OS put it to work. Throughout, the engineering angle: why buffered writes can vanish in a crash, why a syscall per byte is a performance sin, and why "everything is a file" is more than a slogan.

01

Why I/O is genuinely hard

Managing the CPU is a problem of one kind of thing shared many ways. I/O is the opposite problem: a bewildering variety of things, no two alike. A keyboard delivers a few bytes a second in bursts; a mouse trickles tiny position updates; a spinning disk moves data in large blocks with millisecond seek delays; an SSD is far faster but still block-oriented; a modern network card and a GPU move gigabytes per second. That is a span of roughly a billion to one in speed, sitting side by side in the same machine.

The devices do not just differ in speed. Some are read-only (a sensor), some write-only (a printer), some both. Some deliver a stream of bytes with no structure (a serial port), others addressable blocks you can jump around in (a disk). Each has its own command set, its own error conditions, its own timing. And the catalogue keeps growing — every new gadget is a device the OS has never seen. Writing custom code in every application for every device would be madness.

So the operating system takes on a hard bargain. Underneath, it copes with all this diversity, timing and failure. On top, it presents your program with one small, uniform interface — a handful of calls like open, read, write and close — that behave the same regardless of what is on the other end. Absorbing the mess so your code does not have to is the entire job of the I/O subsystem, and the rest of this chapter is how it pulls that off.

Device diversity
Devices differ enormously in speed, direction, structure, command set and error behaviour — often by factors of a billion.
Uniform interface
The OS goal of exposing one small, stable set of calls (open/read/write/close) that works for every device.
The I/O subsystem
The layers of OS code that hide device-specific mess behind that uniform interface.

The engineering bargain: Every abstraction has a cost, but this one pays for itself daily: because the OS absorbs device diversity, the read() you wrote for a file works unchanged on a socket, a pipe or a terminal.

Tap to enlarge
02

Device controllers & their registers

The CPU never wires directly into a disk motor or a network transceiver. Between the processor and each physical device sits a device controller — a small dedicated chip that understands one device’s messy electrical and timing details and presents it to the CPU as something far simpler: a handful of device registers. We met these in Chapter 3; here they are the foundation everything else rests on, so it is worth pinning down exactly what the OS sees.

A controller typically exposes three kinds of register. The status register reports the device’s current state — ready, busy, error, data-available — usually as individual bits the driver polls or checks. The command (or control) register is where the driver writes what it wants done — start a read, start a write, reset. The data register is the window through which the actual bytes pass, in or out. Drive a device and you are, at bottom, reading and writing these registers in the right order.

How does the CPU reach those registers? Two schemes, both from Chapter 3. With memory-mapped I/O, the registers are wired into ordinary physical memory addresses, so a normal load or store to a magic address talks to the device — convenient, because all the CPU’s existing memory instructions just work. With port-mapped I/O, devices live in a separate address space reached only by dedicated IN and OUT instructions. Modern hardware leans overwhelmingly on memory-mapped I/O.

Device controller
The chip that operates one physical device and presents it to the CPU as a small set of registers.
Status register
Reports device state — ready, busy, error, data-ready — usually bit by bit.
Command register
Where the driver writes the operation to perform: read, write, reset.
Data register
The port through which bytes actually move between controller and CPU.
Memory-mapped I/O
Registers appear at normal memory addresses; ordinary loads and stores drive the device.

Keep this picture: From here on, "the driver talks to the device" always means "the driver reads and writes these registers". The only open question is how it waits for the device to finish — which is the next three sections.

Tap to enlarge
03

Talking to a device: polling

The simplest way to coordinate with a device is to ask it, over and over, whether it is done. This is polling (or programmed I/O). The driver writes a command, then sits in a loop reading the status register: busy? busy? busy? — until at last the ready bit flips, at which point it reads or writes the data register and moves on. There is no cleverness here, and that is its charm: no interrupt setup, no handler, no surprises.

The problem is equally plain. While the CPU spins in that loop, it does nothing useful — it burns full-speed cycles just waiting. If the device is slow, say a disk that takes milliseconds, the CPU might waste millions of cycles it could have spent running another program. On a multitasking system that is intolerable: you would have one process freeze the whole machine every time it read a file.

Yet polling is not obsolete — it is genuinely the right choice for very fast devices. If a device responds in tens of nanoseconds, the cost of setting up an interrupt, taking it, saving state and returning can be greater than just spinning briefly and reading the result. This is exactly why high-performance network drivers (Linux NAPI) and modern NVMe SSD paths poll under heavy load: at those speeds, interrupts arrive so fast they would drown the CPU in overhead, so polling actually wins.

  • Polling is trivially simple: write a command, loop on the status bit, then move the data.
  • It wastes the CPU whenever the device is slower than the wait — the CPU spins doing nothing.
  • It gives the lowest possible latency, because the CPU reacts the instant the bit flips.
  • It is the right tool for very fast or busy devices, where interrupt overhead would cost more than the wait.

The real tradeoff: Polling trades CPU cycles for low latency and simplicity. It is not "the naive option" — it is the correct option precisely when the device is fast enough that waiting is cheaper than being interrupted.

Tap to enlarge
04

Interrupts: let the device tell you

The fix for wasted spinning is to invert the flow: instead of the CPU asking "are you done yet?", the device announces "I am done" when it finishes. That announcement is the interrupt from Chapter 3. The driver kicks off the operation and the CPU goes off to run other work. When the device controller completes, it raises an interrupt line; the CPU finishes its current instruction, saves a little state, and jumps to the handler for that device.

It finds the handler through the interrupt vector — the table, set up at boot, mapping each interrupt number to the address of its interrupt service routine (ISR). The ISR does the minimum needed to service the device — acknowledge the interrupt, read the ready data out of the data register, note that the operation is complete — and returns, and the CPU resumes exactly where it left off. No cycles wasted waiting; the machine stays responsive during a slow download because it was doing other work the whole time.

But an ISR must be fast. While it runs, it often blocks other interrupts, so spending long there hurts the whole system. The standard answer is to split the work in two. The top half (the ISR proper) runs immediately and does only the urgent, minimal part. Anything heavier — copying buffers, waking the waiting process, deciding what to do next — is deferred to a bottom half that the kernel runs a moment later, with interrupts enabled. Linux calls these deferred pieces softirqs, tasklets and workqueues; the principle is universal.

Interrupt
An asynchronous signal from a device saying it needs attention — typically "operation complete".
Interrupt service routine (ISR)
The handler the CPU jumps to; it services the device quickly and returns.
Interrupt vector
The boot-time table mapping each interrupt number to its handler’s address.
Top half / bottom half
The fast, urgent part of handling (top) versus the heavier work deferred to run later (bottom).
Every interrupt the machine has taken, counted per CPU and per device — read straight from the kernel.bash
$ cat /proc/interrupts
           CPU0       CPU1
  1:         9          0   IO-APIC   1-edge      i8042   # keyboard
 16:    284517          0   IO-APIC  16-fasteoi   nvme0q0 # SSD
 24:         0    1902244   PCI-MSI  ...           eth0    # network card
LOC:   4821330    4790112   Local timer interrupts

Callback to Chapter 3: This is the same "stop, save, jump to handler" machinery that also serves system calls (deliberate traps) and page faults (faults). Interrupt-driven I/O is just that mechanism aimed at devices — and /proc/interrupts lets you watch it happen.

Tap to enlarge
05

DMA: moving bulk data without the CPU

Interrupts solve the waiting problem, but not the copying problem. Suppose a disk read brings in 64 KB. If the CPU has to move every one of those bytes from the controller’s data register into RAM itself — load a word, store a word, thousands of times — it is pinned doing dumb copying for the whole transfer, interrupt or not. For the large, frequent transfers that disks and network cards do, that is a huge waste of the most valuable chip in the machine.

The answer, again from Chapter 3, is Direct Memory Access. The CPU writes a small job description to a DMA controller — source, destination in RAM, and length — and then walks away to run other code. The DMA engine performs the entire bulk transfer over the bus by itself, moving data directly between the device and memory without the CPU touching a single byte. Only when the whole block has landed does the DMA controller raise one completion interrupt: "done".

Combine the two ideas and you get the pattern every fast device uses. The CPU sets up a DMA transfer and goes off to do useful work (no polling); the hardware moves the data on its own (no CPU copying); and a single interrupt fires at the end (no per-byte interruptions). That is how an SSD or a NIC can run at multiple gigabytes per second while your programs keep executing. It is also why disk and network throughput scale with the bus and the device, not with how fast your CPU can copy memory.

DMA controller
A hardware engine that transfers whole blocks between a device and RAM without the CPU copying each byte.
DMA descriptor
The small setup the CPU writes — source, destination, length — telling the engine what to move.
Completion interrupt
The single interrupt raised when the entire transfer has finished, instead of one per byte.

Why engineers care: DMA is why "zero-copy" I/O matters: techniques like sendfile() let the kernel DMA data from disk to a socket without ever bouncing it through your program’s memory, saving both CPU copies and cache pollution on high-throughput servers.

Tap to enlarge
06

The I/O software layers

All the hardware mechanics so far — registers, polling, interrupts, DMA — need to be organised into software, and the OS does it as a stack of layers, each with one clear job. This layering is the structural heart of the whole chapter: it is what turns a chaotic pile of devices into that single uniform interface. Read it from the bottom up, following what happens after a device finishes, or top down, following a read() call inward. Either way, four layers.

Interrupt handlers (bottom)
The lowest layer: they field the completion interrupt from the device, do the urgent work, and wake whatever was waiting. Closest to the metal.
Device drivers
Device-specific code that knows one device’s registers and command sequence, and translates generic requests ("read block 42") into concrete register writes.
Device-independent OS I/O software
The large shared layer above the drivers: naming (/dev), buffering, caching, blocking, access control, and a uniform interface every driver plugs into.
User-level I/O libraries
Code linked into your program (stdio, fwrite, printf) that adds convenience and buffering on top of the raw system calls.

Trace a call through them. Your program calls fwrite (user-level library), which buffers and eventually makes a write system call; that crosses into the device-independent layer, which checks permissions, looks up the right driver, and may buffer or cache; the driver turns the request into register writes and starts the device, often via DMA; the process sleeps until, much later, the completion interrupt fires, the interrupt handler runs, the driver finishes up, and the waiting process is woken with its result. Four layers, one round trip.

The power of the design is separation of concerns. The vast device-independent layer is written once and shared by every device. Each driver is small and worries only about its own hardware. Adding a brand-new device means writing one new driver that honours the standard interface — and nothing above it has to change. That is how an OS supports thousands of devices it was never specifically built for.

The one idea to keep: Each layer talks only to the layers directly above and below it, through a fixed contract. That is why device makers can ship a driver and slot it into an OS they have never seen the source of — the interface between layers is the whole agreement.

Tap to enlarge
07

Device drivers & the uniform interface

A device driver is the layer where the abstract meets the specific. Above it, the rest of the OS issues generic requests through a fixed set of entry points — essentially open, read, write, close, and ioctl for anything that does not fit the others. Below it, the driver knows the exact registers and command sequence of one device. Its whole reason to exist is to hide device specifics behind that standard interface, so the layers above can treat every device the same way.

The OS groups devices into two broad families that shape the interface. Block devices — disks, SSDs — store data in fixed-size, numbered blocks you can read and write in any order; they support random access and sit behind a buffer cache. Character devices — keyboards, mice, serial ports, terminals — deliver or accept a stream of bytes, one at a time, with no notion of seeking to block 42. A few devices (like network interfaces) fit neither mould and get their own interface.

Unix took this uniformity to its famous conclusion: everything is a file. Devices appear as special files under /dev — /dev/sda for a disk, /dev/tty for a terminal, /dev/null for the byte void. You open them, read and write them, and close them with the very same system calls you use on a text file. The kernel routes those calls to the right driver based on the file. This is why a shell pipeline can redirect between a file, a device and another program without any of them knowing the difference.

Block vs character devices in /dev — note the leading b or c in the permissions column.bash
$ ls -l /dev/sda /dev/tty /dev/null
brw-rw----  1 root disk    8,  0  sda    # b = block device (the SSD/disk)
crw-rw-rw-  1 root tty     5,  0  tty    # c = character device (terminal)
crw-rw-rw-  1 root root    1,  3  null   # c = character device (discards writes)

$ lsblk                                   # the block devices, as a tree
NAME   MAJ:MIN  SIZE TYPE MOUNTPOINT
sda      8:0    512G disk
└─sda1   8:1    512G part /

The abstraction that pays off: Because a driver honours the same open/read/write/close contract as a file, the uniform interface reaches all the way up to your code. The read() you learned for files is quite literally the same call you use on a socket, a pipe or a device node.

Tap to enlarge
08

Buffering, caching & why engineers care

Sitting inside the device-independent layer are two ideas that quietly shape the performance and the correctness of every program you write: buffering and caching. A buffer is a staging area in memory that smooths the mismatch between how a program produces or consumes data and how a device moves it. Rather than trap into the kernel for every byte, data piles up in a buffer and moves in efficient chunks — the same reason stdio buffers, seen back in the system-calls chapter.

Buffering comes in a few shapes. Single buffering fills one buffer, then hands it on — simple, but the producer stalls while it drains. Double buffering uses two, so the device can drain one while the program fills the other, hiding the wait. A circular (ring) buffer is a fixed ring where a producer and consumer chase each other around — the standard structure for streaming keyboard input, audio, and network packets. Distinct from buffering is the buffer cache: a pool of recently used disk blocks kept in RAM, so a repeated read is answered from memory and never touches the slow disk at all.

Related is spooling: for a device that cannot be shared mid-job — classically a printer — the OS collects each job into a queue (a spool) and feeds them out one at a time, so many programs can "print at once" without interleaving garbage. Buffering, caching and spooling are all the same instinct: put a smart layer of memory between fast software and slow, awkward or unshareable hardware.

The crash that eats your data: Here is the trap every backend engineer must internalise. When write() returns, your data is usually only in the OS buffer cache, not on the disk. If the power fails before the kernel flushes it, that data is gone even though write() said it succeeded. Durability requires explicitly forcing it down with fsync() — which is exactly why databases and journaling file systems call fsync at the right moments, and why "the write returned OK" is not the same as "the data is safe".

Buffer
A memory staging area that batches data and smooths the speed mismatch between program and device.
Double / circular buffering
Two alternating buffers, or a ring with a producer and consumer, to overlap filling and draining and stream continuously.
Buffer cache
A RAM pool of recently used disk blocks, so repeat reads (and pending writes) stay in memory.
Blocking vs non-blocking I/O
A blocking read waits until data is ready; a non-blocking one returns immediately (empty-handed if nothing is ready) so a program can serve many streams at once.

One last teaser before we move on. Everything here assumed the calling thread simply waits — blocking I/O. But a server juggling ten thousand connections cannot afford a thread parked on every one. That is where non-blocking I/O and event notification (epoll, kqueue, io_uring) come in, letting one thread watch many streams and act only on the ready ones. We will return to it; for now, keep the shape in mind.

Handing off to storage: You now have the whole I/O machine: controllers and registers at the bottom, polling, interrupts and DMA to drive them, four software layers to organise it, drivers behind a uniform file-like interface, and buffering and caching for speed and safety. The most important block device of all is the disk — so next we open it up: how disks are laid out, how the OS schedules their arms and channels, and why storage is where durability is won or lost.

Tap to enlarge