I/O Subsystems & Device Management
The I/O subsystem is the kernel's great equalizer — the layered architecture that makes a keyboard, an NVMe SSD, and a GPU all look like file descriptors to your program.
Overview
Every program you have ever written eventually needs to talk to the outside world — reading a file, printing to a terminal, sending a packet, spinning a motor. That conversation happens through the I/O subsystem, one of the most carefully engineered parts of any operating system. It is the layer that hides the wild diversity of physical hardware behind clean, uniform abstractions.
Think about what an OS must tame: a spinning magnetic disk that moves a read head to a precise micrometer position in milliseconds, a USB keyboard that fires an electrical pulse whenever you press a key, a GPU that pumps billions of pixels per second through a PCIe bus, a serial port that trickles data at 9600 bits per second. These devices differ in speed by many orders of magnitude, in their communication style (push vs. pull), and in the granularity of data they handle (bytes vs. fixed-size blocks). Yet from a C program, they all look like file descriptors you can read() and write().
That uniformity is not an accident. It is the product of a carefully layered architecture: hardware controllers speak low-level protocols, device drivers translate those into OS primitives, and kernel I/O subsystems provide buffering, caching, scheduling, and the UNIX device-file abstraction. Understanding this layering is not just academic — it is the conceptual prerequisite for writing a device driver, diagnosing a performance bottleneck, or reasoning about why your disk-heavy program stalls while your memory-only benchmark flies.
This module builds that understanding from the hardware up. We start at the physical signaling layer (polling, interrupts, DMA), work up through driver architecture and the block/character device distinction, and finish with the full software stack that sits between a read() syscall and electrons moving on a bus. Every concept is grounded in real kernel code or hardware behavior you can observe on a Linux system today.
Device drivers represent the single largest category of code in the Linux kernel — more than half of the source tree by line count. Every hardware vulnerability, every kernel panic, every mysterious system freeze is disproportionately likely to live in this layer. If you want to contribute to the kernel, the most accessible entry points (staging drivers, simple character devices, platform drivers for embedded boards) all live here. You cannot meaningfully audit, fix, or write driver code without understanding how the I/O stack is structured.
Beyond kernel contribution, the I/O subsystem shapes the performance of almost every real-world workload. Database engineers tune their I/O scheduler. Systems programmers choose between O_DIRECT and buffered I/O based on their caching needs. Embedded engineers choose between polling and interrupt-driven I/O based on latency and power budgets. Network engineers reason about DMA ring buffers and NAPI polling. Even cloud engineers tuning virtual machine I/O performance are manipulating abstractions that trace directly back to the concepts in this module. This is the layer where theory meets reality with the highest fidelity.
I/O Request Journey: From syscall to hardware and back
A page-cache miss traces the full path through VFS, block layer, driver, and DMA. A cache hit short-circuits directly to copy_to_user. The interrupt-driven completion path (bottom) runs asynchronously after the CPU has moved on to other work.
The concepts
01The Device Taxonomy: Block vs. Character Devices
The Linux kernel divides devices into two fundamental categories based on how data flows through them, not what they physically are.
Character devices transfer data as a stream of bytes with no inherent structure. You read one byte, then the next, sequentially. You cannot seek to byte 5000 without reading bytes 1–4999 first (in general). Examples: terminals (/dev/tty), serial ports (/dev/ttyS0), keyboards (/dev/input/event0), mice, audio devices. The defining property is sequential, unbuffered access — data is consumed as it arrives or produced as it is sent.
Block devices transfer data in fixed-size chunks called blocks, typically 512 bytes to 4096 bytes. Crucially, every block has an address, and you can seek to any block in O(1) time — the OS can ask for block 1,000,000 just as easily as block 0. Examples: hard drives, SSDs, USB flash drives, CD-ROMs. The kernel maintains a block cache (the page cache) for these devices, so repeated reads to the same block are served from RAM.
There is a third, less discussed category: network devices. These are not exposed as files in /dev at all. They appear as eth0, wlan0, etc. and are accessed through the socket API. They have their own packet-oriented abstraction in the kernel.
You can see all your devices with ls -la /dev. The first character of the permissions string tells you the type: c for character, b for block. The two numbers after the permissions (e.g., 8, 0) are the major and minor device numbers. The major number identifies which driver handles this device; the minor number is a parameter passed to that driver (e.g., which partition of which disk).
This taxonomy is not just organizational — it drives which kernel subsystem handles your read() call. A read() on a character device goes through the cdev interface directly to the driver's read function. A read() on a block device goes through the VFS, then the page cache, then the block layer, and only reaches the driver if the block is not already in cache.
# See all block devices and their major:minor numbers
ls -la /dev/sd* /dev/nvme* 2>/dev/null
# See character devices (e.g., TTYs, input devices)
ls -la /dev/tty* /dev/input/event* 2>/dev/null
# lsblk gives a tree of block devices with sizes
lsblk
# The major number 8 means 'SCSI disk driver'
# /proc/devices lists all registered major numbers
cat /proc/devices
02Polling: The Busy-Wait Strategy
Imagine you order food at a counter and stand there staring at the kitchen, not moving, until the cook calls your number. That is polling.
In polling (also called programmed I/O or busy-wait I/O), the CPU continuously checks a device status register in a tight loop, waiting until the device signals readiness. No interrupts, no sleeping — the CPU burns every cycle checking.
while (device.status != READY) { /* spin */ }
data = device.data_register;
Polling is the simplest possible I/O strategy, and on embedded microcontrollers with no OS, it is extremely common. It has real advantages:
- Zero latency: the CPU notices readiness on the very next iteration of the loop, which can be nanoseconds after the event.
- No interrupt overhead: interrupts require saving/restoring registers, switching context, running an ISR. For very high-frequency events (e.g., a 10GbE NIC receiving millions of packets per second), the interrupt overhead alone would saturate the CPU.
- Predictable timing: important in real-time systems.
But the cost is severe for most workloads: the CPU is 100% occupied doing nothing useful. On a laptop waiting for a keypress that arrives every second, polling wastes ~10^9 cycles per keypress. This is why most OS I/O is interrupt-driven, with polling reserved for specific high-throughput scenarios.
An important modern example: Linux's NAPI (New API) for networking uses interrupt-to-poll mode switching. When packets arrive, an interrupt fires to wake the system. The kernel then disables that interrupt and switches to polling the NIC until the queue drains, then re-enables interrupts. This hybrid avoids both the overhead of per-packet interrupts at high load and the waste of continuous polling at low load.
On the hardware side, polling usually reads from a memory-mapped I/O (MMIO) register — a physical address that is wired to the device's status register rather than to RAM. Reading address 0xFED00000 might actually query the device's current state.
/* Minimal UART transmit via polling - typical in bare-metal embedded */
#define UART_BASE 0x09000000UL /* QEMU virt UART */
#define UART_DR (*(volatile uint32_t *)(UART_BASE + 0x00)) /* data register */
#define UART_FR (*(volatile uint32_t *)(UART_BASE + 0x18)) /* flag register */
#define UART_FR_TXFF (1 << 5) /* TX FIFO full */
void uart_putc(char c) {
/* Spin until the transmit FIFO has space */
while (UART_FR & UART_FR_TXFF)
; /* busy-wait */
UART_DR = c;
}
03Interrupts: The Event-Driven Alternative
The interrupt model inverts the polling model entirely. Instead of the CPU asking the device 'are you ready yet?', the device signals the CPU when it has something to report. The CPU is free to do other work in the meantime.
Here is the sequence when, say, you press a key:
- Your keystroke generates an electrical signal at the keyboard controller.
- The keyboard controller asserts an IRQ line (Interrupt Request) — a dedicated hardware wire connected to the CPU's interrupt controller (e.g., the x86 APIC).
- The interrupt controller evaluates priority and, if higher than the current CPU priority mask, signals the CPU.
- The CPU finishes its current instruction, then saves its full register state (the interrupted context) onto the kernel stack.
- The CPU jumps to the Interrupt Service Routine (ISR) — a handler function registered by the keyboard driver.
- The ISR reads the scancode from the hardware register, potentially queues it for a bottom half (more on this later), and executes
return_from_interrupt. - The CPU restores the saved registers and resumes the interrupted task exactly where it left off.
From the perspective of the interrupted program, nothing happened — it never saw the interrupt. This is the magic of hardware-assisted context save/restore.
Top halves and bottom halves: ISRs must be fast. While an ISR runs, that interrupt line (and often related lines) is masked — you can miss events. So Linux splits interrupt handling:
- Top half (ISR): runs immediately, does the minimum — acknowledges the hardware, reads critical data, queues a work item.
- Bottom half: runs later in a softer context. Can sleep, take locks, do complex processing. Implemented as softirqs, tasklets, or workqueues in Linux.
For example, a network driver's top half copies the packet from the DMA buffer and queues it. The bottom half (a softirq) runs the TCP/IP stack on that packet.
In Linux, drivers register interrupt handlers with request_irq() and free them with free_irq(). The handler prototype is irqreturn_t handler(int irq, void *dev_id).
/* Simplified Linux interrupt handler for a hypothetical device */
#include <linux/interrupt.h>
#define MY_IRQ 17
static irqreturn_t my_irq_handler(int irq, void *dev_id)
{
struct my_device *dev = dev_id;
/* Top half: read status, ACK the device, wake a waiter */
u32 status = ioread32(dev->base + STATUS_REG);
if (!(status & MY_DEVICE_INT_FLAG))
return IRQ_NONE; /* Not our interrupt (shared IRQ lines exist) */
iowrite32(status, dev->base + STATUS_REG); /* clear interrupt flag */
dev->last_status = status;
wake_up(&dev->wait_queue); /* wake sleeping reader */
return IRQ_HANDLED;
}
/* Called during driver probe */
int setup_irq(struct my_device *dev)
{
return request_irq(MY_IRQ, my_irq_handler,
IRQF_SHARED, "my_device", dev);
}
04DMA: Direct Memory Access
Both polling and interrupts, as described so far, have the CPU doing the actual data transfer: reading bytes from a device register and writing them to memory (or vice versa), one word at a time. For a 1 MB disk read, that is ~250,000 32-bit reads. The CPU is a terrible data-mover — it has to burn precious pipeline cycles shuttling bytes that require zero computation.
DMA (Direct Memory Access) solves this with a dedicated hardware unit — the DMA controller — that can transfer data between a device and main memory without CPU involvement. The CPU just programs the DMA controller with:
- Source address (device FIFO or physical memory)
- Destination address (physical memory buffer)
- Transfer size
- Direction (device→memory or memory→device)
Then the CPU can go do something useful. When the DMA transfer completes, the DMA controller fires an interrupt to notify the CPU.
On modern systems, there is no separate DMA controller chip — DMA functionality is built into each device's hardware. An NVMe SSD, a PCIe NIC, a USB host controller — they all have DMA engines that write directly into host RAM over the PCIe bus.
The cache coherency problem: DMA is tricky because it bypasses the CPU cache hierarchy. If the CPU has a dirty cache line at address X, and the DMA engine writes to physical address X from a device, the CPU's cache is now stale. Conversely, if the CPU writes a buffer to be sent by DMA, those writes might still be sitting in cache and the DMA engine reads old data from RAM.
Linux solves this with DMA API functions (dma_map_single, dma_sync_for_device, dma_sync_for_cpu) that insert the correct cache flush/invalidate operations and, on architectures with IOMMU (like x86's VT-d or ARM's SMMU), map physical addresses into an I/O virtual address space that protects the rest of memory from rogue device writes.
Scatter-Gather DMA: Modern devices support scatter-gather lists — instead of transferring a single contiguous buffer, the DMA engine walks a list of (address, length) pairs. This lets the kernel feed physically fragmented memory to a device without copying everything into one contiguous buffer, a major performance win.
/* Simplified DMA buffer setup in a Linux driver */
#include <linux/dma-mapping.h>
struct my_driver {
void *cpu_addr; /* virtual address for CPU access */
dma_addr_t dma_addr; /* bus address for device access */
};
int alloc_dma_buffer(struct device *dev, struct my_driver *drv, size_t size)
{
/*
* dma_alloc_coherent: allocates physically contiguous memory,
* marks it non-cacheable (or manages coherency in hardware),
* returns both the CPU virtual address and the DMA bus address.
*/
drv->cpu_addr = dma_alloc_coherent(dev, size, &drv->dma_addr,
GFP_KERNEL);
if (!drv->cpu_addr)
return -ENOMEM;
/* Program the device with drv->dma_addr — the bus address */
/* iowrite32(drv->dma_addr, dev_base + DMA_ADDR_REG); */
return 0;
}
void free_dma_buffer(struct device *dev, struct my_driver *drv, size_t size)
{
dma_free_coherent(dev, size, drv->cpu_addr, drv->dma_addr);
}
05Buffering, Caching, and Spooling
These three techniques address a fundamental problem: devices operate at wildly different speeds, and processes should not block waiting for slow hardware when they could be doing something else.
Buffering is about temporarily storing data in transit between two entities operating at different speeds or with different transfer granularities. The kernel maintains a buffer between a program writing bytes and a disk that wants 4096-byte sectors. The program writes small amounts; the buffer accumulates; the disk gets a full block write. Buffering also enables double buffering: while the device is processing buffer A, the CPU fills buffer B. When the device finishes, they swap. This overlaps computation and I/O, keeping both the CPU and the device busy.
Caching is about keeping copies of recently or frequently used data in a faster tier of the memory hierarchy to avoid re-fetching from the slow tier. The Linux page cache (formerly 'buffer cache') stores copies of disk blocks in RAM. When you read() a file, the kernel checks if the relevant pages are in the page cache. On a hit, data is served from RAM (nanoseconds). On a miss, the kernel issues a disk read, fills the cache, and serves the data. Subsequent reads to the same file go to cache.
The page cache also enables read-ahead: the kernel speculatively reads pages beyond what the program asked for, betting on sequential access patterns. If you read block 5, the kernel might pre-fetch blocks 6–13 in the background. This hides disk latency behind computation time.
Write-back caching: When you write() to a file, Linux does not immediately write to disk. It marks the page as dirty in the page cache and returns immediately. A kernel thread (pdflush / writeback) periodically flushes dirty pages to disk. This makes writes appear instant to applications, at the cost of potential data loss if the system crashes before flush. fsync() forces an immediate flush of dirty pages for a specific file.
Spooling (Simultaneous Peripheral Operations Online) is a specialized buffering technique for devices that cannot multiplex multiple jobs — classically, a printer. Instead of letting multiple processes write to the printer simultaneously (which would intermix output), a spooler daemon accepts print jobs from all processes, stores them in a queue (the spool directory), and feeds them to the printer one at a time. Modern print systems like CUPS use this architecture. The same idea applies to batch job queues on mainframes.
/* Demonstrate write-back caching: this write returns INSTANTLY
* even though the disk hasn't been touched yet.
* Use strace to verify no actual disk I/O happens synchronously. */
#include <fcntl.h>
#include <unistd.h>
#include <string.h>
int main(void) {
int fd = open("test.txt", O_WRONLY | O_CREAT | O_TRUNC, 0644);
const char *msg = "hello, page cache\n";
write(fd, msg, strlen(msg)); /* Goes to page cache; returns fast */
/* At this point, data may NOT be on disk yet */
fsync(fd); /* Forces dirty page flush to disk — now it's durable */
close(fd);
return 0;
}
/* Compare timings:
* Without fsync: nearly zero wall time
* With fsync on HDD: ~10ms (one disk rotation)
* With fsync on SSD: ~100 microseconds
*/
06The I/O Software Stack: Layers from Syscall to Hardware
The journey from read(fd, buf, n) to electrons moving on a bus passes through about five conceptual layers. Understanding each layer's job makes the whole system comprehensible.
Layer 1 — User space: Your program calls read(). This is a C library wrapper that issues a software interrupt / syscall instruction, transitioning to kernel mode.
Layer 2 — Virtual File System (VFS): The kernel's sys_read() looks up the file descriptor, finds the associated struct file, and calls file->f_op->read_iter(). The VFS is an abstraction layer that makes all filesystems (ext4, btrfs, tmpfs, procfs) look identical to the layers above. It also handles the generic inode/dentry cache.
Layer 3 — Page Cache: For block-backed files, the VFS checks whether the requested pages are in the page cache. On a hit, data is copied to user space and the call returns. On a miss, the page cache issues a bio (Block I/O request) to the block layer.
Layer 4 — Block Layer: The block layer receives struct bio objects (which describe a set of physical page segments to read/write from a block device at a given sector offset). The I/O scheduler (now called the block multiqueue, blk-mq) batches, reorders, and merges these requests to improve throughput. Different schedulers (none, mq-deadline, bfq) make different tradeoffs between latency and throughput.
Layer 5 — Device Driver: The block layer eventually calls the driver's request handler. For an NVMe SSD, the driver formats an NVMe command queue entry (a specific 64-byte structure defined by the NVMe specification) and writes it to the device's submission queue register. For a SATA drive, the driver issues an ATA command. The driver then typically returns; the device does DMA, and fires an interrupt when done.
Layer 6 — Hardware: The storage controller executes the command, DMA-copies data into the buffer the driver specified, and fires an interrupt. The driver's ISR runs, marks the bio complete, and the data is now in the page cache, ready to copy to user space.
For character devices, layers 3 and 4 are absent — there is no page cache or block scheduling for a serial port. The VFS calls the driver's read() function directly (or blocks the process in a wait queue until data arrives).
/* Trace the path with strace. On a Linux system:
*
* strace -e trace=read,openat,lseek cat /etc/hostname
*
* You'll see:
* openat(AT_FDCWD, "/etc/hostname", O_RDONLY) = 3
* read(3, "mymachine\n", 131072) = 10
*
* The kernel path for that read():
* sys_read -> vfs_read -> ext4_file_read_iter ->
* -> filemap_read (page cache check) ->
* [miss] -> ext4_readahead -> submit_bio ->
* -> blk_mq_submit_bio -> nvme_queue_rq ->
* -> NVMe submission queue write ->
* -> [interrupt] nvme_irq ->
* -> bio completion -> page cache filled ->
* -> copy_to_user -> return 10
*
* You can watch block I/O live with:
*/
// sudo blktrace -d /dev/nvme0n1 -o - | blkparse -i -
// or the simpler:
// sudo biolatency-bpfcc (from bpfcc-tools)
07Device Drivers: The OS-to-Hardware Translator
A device driver is a kernel module that implements a specific interface (character device, block device, network device, platform device, etc.) and translates generic OS operations into device-specific hardware commands.
Think of it as a contract: the kernel promises to call specific functions (probe, open, read, write, ioctl, release, remove) at the right times, using well-defined data structures. The driver author promises those functions will correctly program the hardware and use kernel APIs safely.
Driver lifecycle:
- Registration: The driver registers with the kernel's bus subsystem (PCI, USB, I2C, platform, etc.) by providing a table of device IDs it supports and a
probefunction. - Probe: When the kernel finds a device matching the driver's ID table (at boot or when USB is plugged in), it calls
probe(). The driver claims resources (IRQs, MMIO regions, DMA channels), initializes hardware, and registers its device interface. - Operation: User space calls arrive through the VFS → driver
fopstable. The driver reads/writes hardware registers. - Remove: On hotplug-remove or shutdown,
remove()undoes everythingprobe()did.
MMIO vs. Port I/O: On x86, there are two ways to talk to hardware registers:
- Memory-Mapped I/O (MMIO): Device registers appear at specific physical memory addresses. Accessed with normal load/store instructions (but through
ioread32/iowrite32in kernel code to ensure correct ordering and portability). - Port I/O (PIO): x86-specific
IN/OUTinstructions to a separate 16-bit I/O address space. Legacy only; modern devices use MMIO.
The ioctl escape hatch: read() and write() handle data transfer, but what about device-specific commands? Format a disk? Set baud rate? Eject a CD? These go through ioctl() (I/O control). Each device defines its own set of ioctl command numbers. This is powerful but notoriously hard to use safely — a major source of kernel vulnerabilities historically.
Kernel modules: Linux drivers are typically loadable kernel modules (.ko files). insmod/modprobe loads them; rmmod unloads. The kernel's module system handles symbol exports so modules can use kernel functions. All modules run in kernel space with full hardware access — a buggy driver can corrupt any memory and crash the system. This is why driver quality matters enormously.
/* Minimal Linux character device driver skeleton */
#include <linux/module.h>
#include <linux/fs.h>
#include <linux/cdev.h>
#include <linux/uaccess.h>
static dev_t dev_num; /* major + minor */
static struct cdev my_cdev;
static const char msg[] = "hello from kernel\n";
static ssize_t my_read(struct file *f, char __user *buf,
size_t count, loff_t *off)
{
size_t len = min(count, sizeof(msg) - (size_t)*off);
if (len == 0) return 0;
if (copy_to_user(buf, msg + *off, len)) /* safe user-space copy */
return -EFAULT;
*off += len;
return len;
}
static const struct file_operations my_fops = {
.owner = THIS_MODULE,
.read = my_read,
};
static int __init my_init(void)
{
alloc_chrdev_region(&dev_num, 0, 1, "mydev"); /* get major number */
cdev_init(&my_cdev, &my_fops);
cdev_add(&my_cdev, dev_num, 1);
pr_info("mydev: registered major=%d\n", MAJOR(dev_num));
return 0;
}
static void __exit my_exit(void)
{
cdev_del(&my_cdev);
unregister_chrdev_region(dev_num, 1);
}
module_init(my_init);
module_exit(my_exit);
MODULE_LICENSE("GPL");
08I/O Scheduling: Ordering Requests for Efficiency
A spinning hard disk (HDD) has a physical read/write head that must mechanically seek to the right track. This seek takes 3–10 milliseconds — an eternity in CPU time. If 20 processes each issue random reads to different tracks, naive first-come-first-served scheduling means the head jumps across the disk 20 times. Smart scheduling can dramatically reduce total seek distance.
SSTF (Shortest Seek Time First): Serve the request whose track is closest to the current head position. Minimizes seeks but causes starvation — requests far from the current head position may wait forever if new nearby requests keep arriving.
SCAN (Elevator algorithm): The head sweeps from one end of the disk to the other, serving all requests in its path, then reverses. Like an elevator serving all floors in one direction before returning. Fair, but requests at the far end still wait the most.
C-SCAN (Circular SCAN): Only serves requests in one direction. When reaching the end, jumps back to the start without serving requests on the return. More uniform wait times.
Modern SSDs have no moving parts — seek time is ~50 microseconds and is nearly independent of location. For SSDs, reordering requests by address is pointless, but other concerns emerge:
- Write amplification: SSDs erase in large blocks (128KB+) but write in pages (4KB). Writing one page may require erasing a block and rewriting it. Coalescing writes reduces this.
- Parallelism: An NVMe SSD has multiple queues and channels; software should fill multiple queues to keep all channels busy.
Linux's modern block layer uses blk-mq (block multiqueue), designed for NVMe's architecture. Each CPU core has its own submission queue, eliminating lock contention. Pluggable schedulers sit above blk-mq:
- none: No reordering. Best for NVMe SSDs.
- mq-deadline: Adds deadline guarantees to prevent starvation. Good for SATA SSDs.
- bfq (Budget Fair Queuing): Provides fairness between processes and good latency. Best for HDDs and mixed workloads on desktops.
# Check current I/O scheduler for each block device
for dev in /sys/block/*/queue/scheduler; do
echo "$dev: $(cat $dev)"
done
# Change scheduler (as root) - e.g., switch NVMe to 'none'
echo none | sudo tee /sys/block/nvme0n1/queue/scheduler
# Watch I/O statistics live (iostat from sysstat package)
iostat -x 1
# See queue depth and other parameters
cat /sys/block/nvme0n1/queue/nr_requests
cat /sys/block/nvme0n1/queue/max_sectors_kb
# blktrace + blkparse gives per-request timing (C=complete, D=dispatched)
# sudo blktrace -d /dev/sda -o trace
# blkparse trace
09Memory-Mapped I/O vs. Port-Mapped I/O
How does software actually talk to hardware? The answer depends on the CPU architecture, but there are two fundamental mechanisms.
Port-Mapped I/O (PMIO) — x86 specific: The x86 architecture has a separate 16-bit I/O address space, accessed with special instructions IN and OUT. This is completely separate from the memory address space. For example, the PC's standard PIC (Programmable Interrupt Controller) has been at I/O port 0x20 since the original IBM PC in 1981. In Linux kernel code, inb(0x20) reads a byte from port 0x20; outb(0x20, value) writes one. Modern devices almost never use PMIO — it is a legacy from the 8086 era.
Memory-Mapped I/O (MMIO) — universal: Device registers are mapped to physical memory addresses. The CPU uses normal load/store instructions to access them. The hardware decodes the physical address and routes the access to the device instead of RAM. This is how every modern device works: PCIe devices enumerate their register spaces in BARs (Base Address Registers) in the PCI config space; the OS maps those physical addresses into virtual address space with ioremap() in Linux.
A key subtlety: volatile and memory barriers. Because the compiler and CPU cache do not know that a memory address corresponds to a device register (not RAM), they might reorder or cache reads/writes. You must tell them not to. In kernel C, ioread32() / iowrite32() handle this — they compile to the correct barrier instructions. In user space with /dev/mem or UIO drivers, you need volatile and __sync_synchronize(). This is a common source of subtle driver bugs: code that works on one architecture (x86 with strong memory ordering) silently breaks on ARM or RISC-V (weaker ordering).
Physical vs. virtual addresses: ioremap(phys_addr, size) maps a physical MMIO region into the kernel's virtual address space, returning a virtual pointer you can use with ioread/iowrite. Never cast a physical address to a pointer and dereference it directly — the kernel uses virtual addressing everywhere.
/* MMIO example: reading a PCI device's BAR0 register space in a driver */
#include <linux/pci.h>
#include <linux/io.h>
struct my_pci_dev {
void __iomem *bar0; /* __iomem annotation: 'this is MMIO, not RAM' */
};
int my_pci_probe(struct pci_dev *pdev, const struct pci_device_id *id)
{
struct my_pci_dev *priv;
int err;
err = pci_enable_device(pdev);
if (err) return err;
err = pci_request_regions(pdev, "my_driver");
if (err) goto disable;
priv = devm_kzalloc(&pdev->dev, sizeof(*priv), GFP_KERNEL);
priv->bar0 = pci_iomap(pdev, 0, 0); /* map BAR0 into virtual space */
if (!priv->bar0) { err = -ENOMEM; goto release; }
/* Read device ID register at offset 0 */
u32 chip_id = ioread32(priv->bar0 + 0x00);
dev_info(&pdev->dev, "chip id: 0x%08x\n", chip_id);
/* Write a value to a control register at offset 0x10 */
iowrite32(0x1, priv->bar0 + 0x10);
return 0;
release:
pci_release_regions(pdev);
disable:
pci_disable_device(pdev);
return err;
}
10Userspace I/O (UIO) and the Move Toward Userspace Drivers
Traditional device drivers live in kernel space. A bug crashes the whole system. Reloading a driver requires root. Testing requires rebooting after a kernel panic. For many device classes — especially niche industrial hardware, network interface cards used for high-performance packet processing, and FPGAs — this is a painful development model.
UIO (Userspace I/O) is a kernel framework that exposes device memory and interrupts to user space. A minimal kernel shim registers the device and exposes:
- The device's MMIO regions via
mmap()on/dev/uioN - Interrupts as
read()on/dev/uioN(returns the interrupt count)
The actual driver logic — register initialization, state machines, protocol handling — runs in a user-space process. A crash kills the process, not the kernel. The process can be restarted. No root-module-signing headaches.
VFIO (Virtual Function I/O) takes this further. It uses the IOMMU to safely pass a PCIe device directly to a virtual machine or user-space process, with hardware protection against DMA attacks. VFIO is the basis for PCIe passthrough in KVM/QEMU and is also used by DPDK (Data Plane Development Kit), which is how 100Gbps packet processing is done in user space — no kernel network stack, no context switches, just user-space code polling NIC DMA rings at tens of millions of packets per second.
The trade-off: UIO/VFIO drivers sacrifice kernel integration (no sysfs, no ethtool, no standard device class interfaces) for speed, safety, and development convenience. They are not appropriate for general-purpose devices (keyboard, disk) but are excellent for specialized, high-performance hardware.
eBPF is a related trend: instead of writing kernel code, you write small verified programs that the kernel JIT-compiles and runs at specific hook points (including network device XDP hooks for near-line-rate packet processing). eBPF lets you extend kernel I/O behavior safely from user space, without loading a traditional kernel module.
/* User-space UIO driver: read device interrupts and map registers */
#include <stdio.h>
#include <fcntl.h>
#include <unistd.h>
#include <sys/mman.h>
#include <stdint.h>
#define UIO_DEV "/dev/uio0"
#define MAP_SIZE 0x1000 /* Map 4KB of device registers */
int main(void)
{
int fd = open(UIO_DEV, O_RDWR);
if (fd < 0) { perror("open"); return 1; }
/* Map the device's register space into our virtual address space */
volatile uint32_t *regs = mmap(NULL, MAP_SIZE,
PROT_READ | PROT_WRITE,
MAP_SHARED, fd, 0);
if (regs == MAP_FAILED) { perror("mmap"); return 1; }
/* Read a device register (e.g., hardware version at offset 0) */
printf("Device version: 0x%08x\n", regs[0]);
/* Wait for interrupt: read() blocks until IRQ fires */
uint32_t irq_count;
/* First, re-enable the interrupt (write 1 to the UIO fd) */
uint32_t enable = 1;
write(fd, &enable, sizeof(enable));
printf("Waiting for interrupt...\n");
ssize_t n = read(fd, &irq_count, sizeof(irq_count));
if (n == sizeof(irq_count))
printf("Interrupt #%u received\n", irq_count);
munmap((void *)regs, MAP_SIZE);
close(fd);
return 0;
}
Key insights
- ✦The block/character device distinction is not about the hardware itself but about the *access model*: addressable blocks with random access versus a sequential byte stream. The same physical hardware (e.g., a tape drive) can sometimes be exposed both ways.
- ✦DMA does not make data transfers free — it shifts the bottleneck from CPU cycles to bus bandwidth and introduces the cache coherency problem that the DMA API exists to solve.
- ✦The page cache is the single biggest performance lever in Linux I/O. Understanding dirty pages, write-back, and fsync explains nearly every puzzling I/O performance anomaly you will ever encounter.
- ✦Interrupts solve the polling waste problem but introduce their own costs: context save/restore, ISR latency, cache pressure. The NAPI hybrid (interrupt-to-poll switching) shows that the right answer is often 'use both, adaptively'.
- ✦The I/O software stack's layering means you can change behavior at any layer independently: swap the I/O scheduler without touching the driver, add a filesystem without changing the block layer, or bypass the page cache with O_DIRECT.
- ✦Memory ordering is the silent killer in driver code: code that appears correct on x86 (which has a strong memory model) silently corrupts data on ARM or RISC-V. Always use ioread/iowrite, never raw pointer dereference on MMIO.
- ✦Modern kernel development is trending toward user-space drivers (UIO, VFIO, eBPF XDP) for safety and performance. Understanding the classical in-kernel driver model makes these modern alternatives much clearer.
Common pitfalls
- ⚠Confusing physical addresses and virtual addresses: you cannot dereference a physical address directly in kernel code. You must use ioremap() to get a virtual address, and use ioread/iowrite to access it safely.
- ⚠Forgetting to call fsync()/fdatasync() and assuming write() means 'data is on disk': write() fills the page cache and returns. The data is safe only after fsync() or after the writeback daemon flushes it. Databases that skip fsync (like early versions of PostgreSQL under certain configs) corrupt their files on crash.
- ⚠Writing interrupt handlers that sleep or take blocking locks: top-half ISRs run with local interrupts disabled on that CPU. Sleeping (or taking a mutex that might sleep) deadlocks the system. Use spinlocks in ISRs, defer everything else to a bottom half.
- ⚠Accessing user-space pointers directly in kernel code: never dereference a pointer passed from user space with a raw `*ptr`. Always use copy_from_user() / copy_to_user(). Direct dereference can crash the kernel if the pointer is invalid or causes a security vulnerability via TOCTOU races.
- ⚠Assuming I/O scheduler reordering is always beneficial: for NVMe SSDs, disabling the scheduler ('none') often gives better throughput because the device is faster than any software reordering can compensate for. Always benchmark; don't assume HDD-era wisdom applies to SSDs.
- ⚠Forgetting to handle shared IRQs: on PCI, multiple devices can share an IRQ line. Your ISR will be called for interrupts from OTHER devices. Always check your device's status register and return IRQ_NONE if the interrupt was not yours.
- ⚠Memory leaks in driver probe/remove: resources allocated in probe() (IRQs, MMIO regions, DMA buffers) must be freed in remove(). Using the managed device resource API (devm_ prefixed functions) ties resource lifetime to the device, automatically freeing on remove/error, and prevents most leaks.
Practice
- 01medium
Write a minimal Linux kernel module that registers a character device at /dev/hellodev. When any process reads from it, return the string 'Hello from the kernel!' followed by a newline. When /dev/hellodev does not exist after insmod, use mknod to create it using the major number printed in dmesg. Test with 'cat /dev/hellodev'.
- 02medium
Write a C program that opens a file with O_DIRECT (bypassing the page cache), reads 4096 bytes from it, and measures the latency. Then read the same file normally (with page cache) on the second call. Compare the latencies and explain the difference you observe.
- 03easy
Use the /proc and /sys virtual filesystems to answer these questions about your own machine without any additional tools: (1) What I/O scheduler is your root disk using? (2) How many block I/O requests are queued right now? (3) What is the read-ahead size in KB? Write a shell script that prints all three values with labels.
- 04medium
Write a program that demonstrates the cost of NOT using fsync(). Create a file and write 1000 records of 256 bytes each, measuring total time WITHOUT fsync. Then repeat the experiment calling fsync() after every 10 writes. Finally, call fsync() only once at the end. Plot or print the three times and explain what you learned about the write-back cache.
- 05hard
Use strace, /proc/interrupts, and /proc/diskstats to build a 'mini iostat' shell script that, over a 1-second interval, reports: (1) number of read/write I/O operations completed on /dev/sda (or nvme0n1), (2) sectors read and written, (3) how many times interrupt line 0 fired. Run it while 'dd if=/dev/zero of=testfile bs=1M count=100' is running and compare to idle.
Resources
The canonical free OS textbook. Chapters 36-37 cover I/O devices, polling vs interrupts vs DMA, and disk scheduling with beautiful clarity and humor.
The definitive reference for Linux driver development. Free online. Chapters 3 (char drivers), 13 (block drivers), 10 (interrupt handling) are directly relevant to this module.
Official kernel docs covering blk-mq, I/O schedulers, and the bio/request data structures. Ground truth.
LWN's foundational article explaining why blk-mq was built and how it differs from the legacy single-queue block layer. Essential context for modern kernel I/O.
The authoritative guide to the Linux DMA API, including coherent vs streaming mappings and IOMMU interaction.
Free slides and labs from Bootlin's world-class embedded Linux training. Covers platform drivers, I2C, SPI, character devices with hands-on hardware examples.
Chapter 13 (file I/O buffering) and related chapters give unparalleled depth on the user-visible I/O behavior — page cache, fsync, O_DIRECT, and kernel buffering visible from user space.
Surprisingly readable. Understanding NVMe's queue model directly explains why blk-mq was designed the way it is. Read the intro sections.
Good starting point for finding approachable driver-related kernel projects and understanding the contributor workflow.
Checkpoints — you can now…
- ✓You can explain to someone with no OS background why a disk and a keyboard require different I/O abstractions, using the block/character distinction.
- ✓You can describe the exact sequence of hardware and software events — from IRQ assertion to copy_to_user — that happens when a process reads a file that is not in the page cache.
- ✓You can write a minimal compilable Linux character device driver that handles read() calls from user space, returning a fixed string.
- ✓You can explain when you would use polling vs. interrupts vs. DMA for a given device and workload, with concrete trade-offs.
- ✓You can use /sys/block, /proc/interrupts, and /proc/diskstats to observe live I/O activity on a Linux system and explain what each field means.
- ✓You can explain what happens to data after write() returns but before fsync() is called, and under what circumstances that data is lost.
- ✓You can describe the role of the I/O scheduler (blk-mq) and choose an appropriate scheduler for HDD vs. NVMe SSD workloads, justifying your choice.