Device Drivers
A device driver is the translation layer between a uniform kernel interface and one specific piece of hardware. The kernel calls read; the driver knows that means writing a command register and waiting for an interrupt.
The Translation Layer
A device driver in operating system terms exists so that the rest of the kernel does not need to know how any particular hardware works. Device drivers in operating system design sit between the kernel and the hardware. The VFS layer calls read() on a file descriptor; whether that descriptor is a SATA SSD, an NVMe drive, a USB stick, or a network block device, the calling code is identical. The driver is what makes that possible.
The mechanism is a structure of function pointers. A Linux character driver fills in a file_operations struct with its own implementations of open, read, write, and ioctl, and registers it. The kernel then dispatches through that table. This is polymorphism implemented in C, and it is the reason a driver can be written and loaded years after the kernel it plugs into.
Below that interface the driver speaks to the device through memory-mapped I/O — physical addresses mapped into the kernel's address space where a write does not store a value but commands hardware — or on x86 through port I/O instructions. Writing 0x1 to an offset in a BAR region might start a DMA transfer. The driver's job is knowing which offsets mean what, which is what the datasheet is for.
- A driver implements the interface the kernel calls
- Function-pointer tables make the dispatch polymorphic
- MMIO writes command hardware, not memory
- The datasheet defines what each register offset means
Character, Block, and Network
Types of device drivers divide by access model, and the division is structural rather than cosmetic. A character device driver handles a byte stream with no meaningful notion of position — keyboards, serial ports, /dev/random. Reads and writes pass through largely unbuffered, and seeking usually means nothing.
A block device driver handles fixed-size addressable blocks that can be read in any order — disks, SSDs, USB storage. Because random access is possible and slow, the kernel inserts a substantial layer above these: a buffer cache, request merging so adjacent blocks become one operation, and an I/O scheduler that reorders requests. A block driver receives already-optimised requests, not raw application calls.
Network drivers fit neither model and have their own interface. There is no device file in /dev for an ethernet card; the driver registers a net_device and exchanges packet buffers with the network stack asynchronously in both directions. Data arrives unsolicited, which no file abstraction handles naturally.
- Character — a byte stream with no position
- Block — fixed-size blocks, cached and scheduled
- Network — asynchronous packets, no device file
- The class determines what sits above the driver
Interrupts, DMA, and Why Both Exist
The naive way to wait for hardware is polling — reading a status register in a loop until the ready bit sets. It is simple and it burns an entire CPU core doing nothing. It survives only where the wait is measured in nanoseconds, or in early boot before interrupt handling exists.
Interrupts invert the relationship: the driver issues the command and the process sleeps, freeing the CPU entirely, until the device raises an interrupt line and the CPU jumps to the driver's handler. Because that handler runs with interrupts disabled and blocks everything else, the work is split — a top half that acknowledges the device and does the minimum, and a bottom half (softirq, tasklet, or workqueue in Linux) that does the real processing later with interrupts enabled.
DMA solves the other half. Without it, moving 4 KB from a disk controller means the CPU executing thousands of load-store pairs. With DMA the driver programs a source, destination, and length into the DMA controller, and the device writes directly into physical memory while the CPU runs other work, raising one interrupt at completion. This is why the driver must handle cache coherency explicitly: the CPU's cache may hold stale data for memory that hardware just overwrote.
- Polling burns a core waiting for hardware
- Interrupts free the CPU until completion
- Top half acknowledges; bottom half does the work
- DMA moves bulk data without CPU involvement
One read() traced from application to hardware and back
# What a driver actually implements: a table of function pointers the
# kernel dispatches through. It never knows what hardware is underneath.
class NvmeDriver:
BLOCK = 512
def read(self, offset, count):
lba, blocks = offset // self.BLOCK, count // self.BLOCK
cmd = self.build_read_cmd(lba, blocks) # generic -> device command
self.ring_doorbell(cmd) # an MMIO write IS the command
self.wait_for_irq() # sleep; the CPU runs other work
return self.dma_bytes(count) # hardware wrote memory directly
def build_read_cmd(self, lba, blocks):
return {"opcode": "read", "lba": lba, "blocks": blocks}
def ring_doorbell(self, cmd):
self.last = cmd # a store to a mapped register
def wait_for_irq(self):
pass # top half acks, bottom half works
def dma_bytes(self, count):
return bytes(count) # no CPU cycles spent copying
d = NvmeDriver()
data = d.read(offset=0, count=4096)
print("command issued:", d.last)
print("bytes returned:", len(data))
print("1 interrupt, 0 CPU copies -- DMA moved every byte")
// A block driver in outline. Real kernel headers cannot compile in
// userspace, so the same structure is shown with the kernel types stubbed:
// what matters is the SHAPE -- a table of operations the kernel calls.
#include <iostream>
#include <cstdint>
#include <cstddef>
#include <vector>
struct Command {
const char* opcode;
std::uint64_t lba;
int blocks;
};
class NvmeDriver {
static constexpr int BLOCK = 512;
Command last {
};
std::vector<char> dma; // memory the device writes
public:
// The kernel calls this through a function-pointer table. It never
// knows whether the device is NVMe, SATA, or a USB stick.
std::size_t read(std::uint64_t offset, std::size_t count) {
last = {"read", offset / BLOCK, int(count / BLOCK)};
ringDoorbell(); // an MMIO store IS the command
waitForIrq(); // sleep; the CPU runs elsewhere
dma.assign(count, 0); // hardware wrote this, not us
return dma.size();
}
const Command& issued() const {
return last;
}
private:
void ringDoorbell() {
}
// a store to a mapped register, not to RAM
void waitForIrq() {
}
// top half acks; bottom half does the work
};
int main() {
NvmeDriver d;
std::size_t n = d.read(0, 4096);
std::cout << "lba " << d.issued().lba << " blocks " << d.issued().blocks
<< " -> " << n << " bytes, 1 interrupt, 0 CPU copies\n";
}// Drivers are not written in Java, but the dispatch pattern is the point:
// the kernel holds an interface reference and never knows the concrete type.
interface BlockDevice {
int read(byte[] buf, long offset, int count);
void open();
void close();
}
class NvmeDevice implements BlockDevice {
private final Mmio regs; // memory-mapped registers
private final DmaPool pool; // memory the controller can reach
public int read(byte[] buf, long offset, int count) {
long lba = offset >> 9; // bytes -> logical block
DmaBuffer dma = pool.alloc(count); // device-visible memory
Command cmd = Command.read(lba, count >> 9, dma.busAddress());
submissionQueue.push(cmd);
regs.write(DOORBELL, submissionQueue.tail()); // MMIO write starts it
completion.await(); // thread sleeps here
dma.syncForCpu(); // invalidate stale cache
dma.copyTo(buf, count);
return count;
}
}
// The VFS layer holds BlockDevice, not NvmeDevice -- swap the hardware,
// swap the implementation, the caller is unchanged.Step through it
Running on fd = open('/dev/nvme0n1'); read(fd, buf, 4096)
Read all 14 Steps
- the application calls read() and knows nothing else A program calls read(fd, buf, 4096). It does not know whether the descriptor is an NVMe SSD, a USB stick, a network block device, or a file on a RAM disk. That indifference is the entire purpose of the driver layer — the same three arguments work for all of them.
- the syscall crosses into kernel mode The syscall instruction switches the CPU to kernel mode and enters the system call handler, which looks up read in the syscall table. This is the only privilege transition the application makes; everything from here to the hardware happens inside the kernel.
- VFS dispatches through a table of function pointers The virtual filesystem layer takes the file descriptor, finds its struct file, and calls file->f_op->read. That f_op is the driver's own function table, registered when the module loaded. This is polymorphism implemented in C, and it is why a driver written years after the kernel still plugs in cleanly.
- the block layer merges and schedules the request Because this is a block device rather than a character device, the request does not go straight to the driver. The block layer checks the page cache first, and on a miss builds a request that it may merge with adjacent pending ones, then hands it to the I/O scheduler for ordering. Character devices skip all of this — that is the structural difference between the two classes.
- the driver translates generic request into device command Now the driver does its actual job: turning a byte offset and length into what this specific hardware understands. Offset 0 and 4096 bytes become logical block address 0 and a block count of 8, packed into an NVMe submission queue entry with the opcode for read.
- DMA memory is allocated for the transfer The driver allocates a buffer the device can write into directly — physically contiguous, with a bus address the controller can address. The command carries that address, so the controller knows exactly where to deposit the data without involving the CPU.
- an MMIO write to the doorbell starts the hardware The driver writes the new queue tail to the doorbell register. This looks like an ordinary memory store but the address is mapped to the device rather than RAM, so the write is a command: the controller sees its doorbell change and begins fetching the queue entry. Memory-mapped I/O is how nearly all modern device control works.
- the calling process sleeps and the CPU goes elsewhere The driver puts the process to sleep and returns to the scheduler, which runs something else entirely. This is the whole reason interrupts exist: polling a status register in a loop would burn a full core for the microseconds or milliseconds the device needs. The CPU does useful work instead.
- the device DMAs data straight into memory The controller reads from flash and writes 4096 bytes directly into the DMA buffer over PCIe. The CPU is not involved in a single byte of this transfer — without DMA the driver would execute thousands of load-store pairs to move the same data. This is why DMA matters more than any other single mechanism in I/O performance.
- the device raises an interrupt — top half runs The controller signals completion with an MSI-X interrupt. The CPU jumps to the driver's handler, which runs with interrupts disabled and therefore blocks everything else on that core. It does the absolute minimum: acknowledge the device so it stops asserting, and record that the command finished.
- the bottom half is deferred to run with interrupts on Anything substantial is pushed to a softirq or workqueue and the handler returns immediately. This split is not an optimisation — a slow interrupt handler delays every other interrupt on the system, including the timer tick, so keeping the top half short is a correctness requirement.
- cache coherency is handled explicitly Before the CPU reads the buffer, the driver calls dma_sync_for_cpu. Hardware wrote to physical memory behind the CPU's back, so any cached lines covering that region are stale and must be invalidated. Getting this wrong produces the worst class of driver bug: data that is correct in memory and wrong when read, intermittently, depending on cache state.
- the sleeping process is woken and the data copied out The bottom half marks the request complete and wakes the blocked process. The block layer copies from the DMA buffer into the user's buffer — crossing the kernel-user boundary with copy_to_user, which validates the address rather than trusting it — and read() returns 4096.
- why this layering is worth its cost Every layer in that path exists to keep the one above it ignorant of the one below. The application knew nothing about NVMe; VFS knew nothing about block addressing; the block layer knew nothing about doorbell registers. Swap the SSD for a SATA disk and only the bottom two frames change. The cost is that drivers hold full kernel privilege, which is why defect studies consistently find driver code several times buggier than core kernel code — written against undocumented hardware, tested on few configurations, and reviewed by very few people. That is the pressure behind FUSE and VFIO moving drivers into userspace, behind DPDK and SPDK bypassing the stack entirely for polling at ten million packets a second, and behind Rust being merged into the kernel specifically for new driver work.
Where Drivers Break, and Userspace Alternatives
Driver code is where kernel bugs concentrate. Analyses of kernel defect distributions have repeatedly found error rates in drivers several times higher than in core kernel code, and the reasons are structural: drivers are written against undocumented hardware, tested on few configurations, reviewed by fewer people, and comprise the majority of kernel source by volume.
Because a driver runs with full kernel privilege, those bugs are not contained. This motivated moving drivers out of the kernel where the performance cost is acceptable. Linux FUSE puts file systems in userspace, which is how sshfs and NTFS-3G work. UIO and VFIO expose device memory to userspace processes safely, with VFIO using the IOMMU so a device cannot DMA outside its permitted memory.
The high-performance case inverted the argument entirely: DPDK and SPDK bypass the kernel driver stack completely, mapping the NIC or NVMe device into a userspace process that polls it directly. Polling is wasteful in general and optimal here — at ten million packets per second, interrupt overhead exceeds the work itself. Rust support in the Linux kernel, merged for driver development, attacks the same problem from the other direction by making memory-safety bugs harder to write in the first place.
- Driver defect rates exceed core kernel rates
- FUSE moves file systems into userspace
- VFIO exposes devices safely through the IOMMU
- DPDK and SPDK bypass the kernel stack entirely