In the previous lesson we treated the disk as if it were the device in the system. But meteo-01 has a great deal more: two NVMe SSDs, a mechanical disk, a network card, a real-time clock, a serial terminal, a random number generator and several USB controllers. And the weather stations that send it data have sensors, clocks and radios. None of these devices resembles the others: they differ in speed by a factor of a million, in transfer unit, in whether they can be read, written or both, and in whether they respond in microseconds or in minutes.

The operating system has to offer a coherent interface for all of them without knowing the details of any. This lesson is about how it manages that: what classification it uses, why in UNIX "everything is a file", how the files in /dev come into being, and what the three ways the CPU has of talking to a device are. We will finish by following the path of a station reading from the network card to the ingestor's buffer, leaving the internal mechanism of interrupts for the next lesson, which develops it in detail.

Contents

  1. Why the operating system makes devices uniform
  2. The structure of the I/O subsystem
  3. Classification: block, character and network
  4. "Everything is a file" and the /dev directory
  5. Major and minor numbers
  6. udev and the dynamic creation of devices
  7. Buses and enumeration: lspci, lsusb, lsblk
  8. The three ways of talking to a device
  9. I/O ports and memory-mapped I/O
  10. Subsystem techniques: buffering, caching, spooling and reservation
  11. Error handling and retries
  12. The path of a station reading

Why the operating system makes devices uniform

Look at the real variety meteo-01's kernel faces:

Device Speed Unit Operations Latency
Keyboard 100 B/s Character Read Human
Mouse 500 B/s Character Read Human
Real-time clock Register Read/write ns
Serial terminal 11 KB/s Character Both ms
1 Gb/s network 125 MB/s Packet Both µs
Hard disk 150 MB/s Block Both ms
NVMe SSD 3,500 MB/s Block Both µs
GPU 500 GB/s Command Both µs

Between the keyboard and the GPU there are nine orders of magnitude of difference in speed. And yet, the programmer of the ingestor writes:

read(fd, buffer, 1024);

and that same line works whether fd is a network socket, a file on the SSD, a terminal or the random generator. That uniformity is not a convenience: it is what makes writing software possible at all.

Without it, every program would have to know the exact model of every device. Changing the network card would force you to recompile every application. It is exactly the problem the operating system as an extended machine solves, the one we set out in 01-01.

The I/O subsystem has five concrete goals:

Goal What it means
Device independence The program does not know what hardware lies underneath
Uniform naming A name is a name, whatever the device
Error handling Errors are dealt with as far down as possible
Synchronous and asynchronous transfer The program chooses whether to block or not
Sharing and dedication A disk is shared; a printer is not

The structure of the I/O subsystem

The subsystem is organized in layers, each one adding abstraction on top of the previous one:

flowchart TD
    A["Application<br/>ingestor: read(fd, buf, 1024)"] --> B
    B["C library<br/>translates into the system call"] --> C
    C["System call interface<br/>read / write / ioctl"] --> D
    D["Device-independent I/O software<br/>naming, buffering, cache, permissions, errors"] --> E
    E["Device drivers<br/>specific to each model"] --> F
    F["Interrupt handlers"] --> G
    G["Hardware<br/>device controller + device"]

How the responsibilities are shared out:

Layer What it does Example
Application Asks for data using logical names read(fd, buf, 1024)
Device-independent Everything common to all devices Buffering, cache, permission checks
Driver Whatever is specific to one model Writing the registers of the e1000e card
Interrupt handler Reacts to the hardware's signal Marking the transfer as complete

The key point is the device-independent layer: it is the one that does all the common work once, so that each driver only has to implement what is genuinely specific. A network driver does not need to know anything about permissions, or naming, or buffering: only how to talk to its chip.

And here it connects with lesson 01-05: drivers live inside the kernel in a monolithic system like Linux, loaded as modules. That is why a faulty e1000e can bring the whole machine down, whereas in a microkernel it would live in user space at the cost of more IPC.

Classification: block, character and network

Linux classifies devices into three broad families, and that classification determines the interface each one offers:

Block device Character device Network device
Access unit Fixed-size blocks (512 B - 4 KB) Byte stream Packets
Random access Yes, you can jump to any block No, sequential Not applicable
Intermediate storage Kernel page cache Direct or minimal Socket queues
Can be mounted Yes No No
Interface File in /dev File in /dev Socket, no file
Examples /dev/sda, /dev/nvme0n1 /dev/tty0, /dev/random, /dev/null eth0, lo

Block devices. The previous lesson covered their management in full: an array of blocks numbered by LBA, request scheduling, cache. The defining characteristic is random access: you can read block 5,000 without having read the previous 4,999.

Character devices. They are a stream: the bytes arrive and you cannot go back. A keyboard, a serial port, a random generator. It makes no sense to "seek to position 500" on a keyboard.

$ ls -l /dev/random /dev/null /dev/zero /dev/tty0
crw-rw-rw- 1 root root  1,   8 Aug 31 09:14 /dev/random
crw-rw-rw- 1 root root  1,   3 Aug 31 09:14 /dev/null
crw-rw-rw- 1 root root  1,   5 Aug 31 09:14 /dev/zero
crw--w---- 1 root tty   4,   0 Aug 31 09:14 /dev/tty0

The leading c marks a character device. Notice that /dev/null and /dev/zero do not correspond to any hardware at all: they are virtual devices implemented entirely in software. /dev/null discards everything written to it; /dev/zero produces an infinite supply of zeros. The fact that they exist shows that the device abstraction is general enough to wrap things that are not devices.

Network devices. These are the interesting exception: they have no file in /dev.

$ ls /dev | grep -i eth
(nothing)

$ ip link show
1: lo: <LOOPBACK,UP,LOWER_UP> mtu 65536 state UNKNOWN
2: enp3s0: <BROADCAST,MULTICAST,UP,LOWER_UP> mtu 1500 state UP

The reason is fundamental: a file implies an ordered, lossless byte stream, and the network is not that. Packets can be lost, duplicated or arrive out of order, and each one carries metadata (addresses, ports, protocol) that does not fit into read()/write(). That is why UNIX invented a different abstraction — the socket, with socket(), bind(), sendto(), recvfrom() — for what would not fit into the file one.

It is a valuable design lesson: a good abstraction has limits, and forcing it beyond them produces worse interfaces. The BSD authors recognized this and created a second abstraction instead of distorting the first.

Even so, once it is open, a socket is handled through a file descriptor and does accept read() and write(). The file abstraction covers the usage; only creation and configuration need an interface of their own.

"Everything is a file" and the /dev directory

The principle that defines UNIX: devices are presented as special files in the file system.

The consequences are enormous and very practical:

# The same permissions as an ordinary file
$ ls -l /dev/nvme0n1
brw-rw---- 1 root disk 259, 0 Aug 31 09:14 /dev/nvme0n1

# The same tools
$ sudo dd if=/dev/nvme0n1 of=/backup/mbr.img bs=512 count=1
$ head -c 32 /dev/urandom | base64

# The same redirection
$ /opt/meteora/bin/aggregator --verbose > /dev/null 2>&1

# The same system calls
$ strace -e open,read,write cat /dev/zero 2>&1 | head -3

Three concrete advantages of this:

  1. A single permission model. The fact that /dev/nvme0n1 belongs to the disk group with permissions rw-rw---- is what stops the meteora user reading the raw disk and bypassing the file system permissions. Without the file model you would need a separate permission system just for devices.
  2. Tool reuse. cat, dd, cp, grep work on devices without ever having been programmed to.
  3. Composition. You can redirect, pipe and chain devices like anything else.

An example that exploits all three at once:

$ sudo dd if=/dev/nvme0n1p2 bs=4M status=progress | gzip -9 > /backup/meteora.img.gz

It copies an entire block device, compresses it on the fly and stores it. dd knows nothing about NVMe, gzip knows nothing about devices, and neither of them has needed to.

And the shell's > operator exploits exactly what we saw in 02-01: the shell does a fork, in the child it opens the file as descriptor 1, and then execve. The new program writes to descriptor 1 without ever noticing.

Major and minor numbers

A device file contains no data: it contains a reference to a driver. That reference is two numbers.

$ ls -l /dev/sda /dev/tty0 /dev/nvme0n1 /dev/null
brw-rw---- 1 root disk    8,   0 Aug 31 09:14 /dev/sda
crw--w---- 1 root tty     4,   0 Aug 31 09:14 /dev/tty0
brw-rw---- 1 root disk  259,   0 Aug 31 09:14 /dev/nvme0n1
crw-rw-rw- 1 root root    1,   3 Aug 31 09:14 /dev/null

Let us take the /dev/sda line apart field by field:

b   rw-rw----  1  root  disk   8,   0   Aug 31 09:14  /dev/sda
│   └───┬───┘        └─┬─┘    └┬┘  └┬┘
│  permissions     owner and   │    └── MINOR number: which instance
│                    group     └─────── MAJOR number: which driver
└── type: b = block, c = character

Where an ordinary file would show its size in bytes, a device file shows two numbers separated by a comma. That is the detail that gives away that it is not an ordinary file.

Number What it identifies Who interprets it
Major The driver that manages the device The kernel, to pick the driver
Minor Which particular instance within that driver The driver itself

Some examples that clarify the mechanics:

$ ls -l /dev/sda /dev/sda1 /dev/sda2 /dev/sdb
brw-rw---- 1 root disk 8,  0 Aug 31 09:14 /dev/sda
brw-rw---- 1 root disk 8,  1 Aug 31 09:14 /dev/sda1
brw-rw---- 1 root disk 8,  2 Aug 31 09:14 /dev/sda2
brw-rw---- 1 root disk 8, 16 Aug 31 09:14 /dev/sdb

They all have major 8 (the SCSI/SATA disk driver), and the minor tells them apart: 0 is the whole sda disk, 1 and 2 its partitions, 16 is the next disk. The convention allocates 16 minors per disk, which allows 15 partitions each.

Majors are officially registered:

$ cat /proc/devices
Character devices:
  1 mem
  4 tty
  5 /dev/tty
 10 misc
 13 input
189 usb_device

Block devices:
  8 sd
  9 md
 11 sr
252 device-mapper
259 blkext

When you open /dev/sda, the kernel reads the major (8), looks up in this table which driver registered it, and hands it the operation together with the minor so that it knows which disk is meant. That is the entire I/O dispatch mechanism.

You can create device files by hand, although these days you hardly ever need to:

$ sudo mknod /dev/mydisk b 8 0
$ sudo mknod /dev/myconsole c 4 0

A conceptually revealing test: /dev/mydisk with major 8 and minor 0 is exactly /dev/sda. The name means nothing; the only thing that matters is the two numbers. Names are a human convention.

udev and the dynamic creation of devices

/dev used to be an ordinary directory with thousands of files created in advance just in case. It was a mess: you could not tell which ones corresponded to hardware that was actually present, and plugging in something new meant creating the file by hand.

Today /dev is a virtual file system (devtmpfs) that reflects the hardware genuinely present, managed by udev.

$ mount | grep devtmpfs
udev on /dev type devtmpfs (rw,nosuid,relatime,size=3980212k,nr_inodes=995053,mode=755)

When a device is plugged in, the sequence is:

sequenceDiagram
    participant HW as Hardware
    participant K as Kernel
    participant U as udevd
    participant FS as /dev
    HW->>K: a USB device is plugged in
    K->>K: detects it and loads the driver module
    K->>K: creates the basic node in devtmpfs
    K->>U: uevent over netlink
    U->>U: consults /lib/udev/rules.d and /etc/udev/rules.d
    U->>FS: applies permissions, ownership and symbolic links
    U->>U: runs the configured actions

Watching it live is the best way to understand it:

$ udevadm monitor --property --subsystem-match=usb
KERNEL[1284.221] add   /devices/pci0000:00/0000:00:14.0/usb2/2-1 (usb)
ACTION=add
DEVNAME=/dev/bus/usb/002/007
DEVTYPE=usb_device
ID_VENDOR=FTDI
ID_MODEL=FT232R_USB_UART
ID_SERIAL_SHORT=A50285BI
MAJOR=189
MINOR=134

And you can ask for everything udev knows about a particular device:

$ udevadm info --query=all --name=/dev/nvme0n1
P: /devices/pci0000:00/0000:00:1d.0/0000:04:00.0/nvme/nvme0/nvme0n1
N: nvme0n1
S: disk/by-id/nvme-Samsung_SSD_980_PRO_1TB_S5GXNX0T123456
S: disk/by-path/pci-0000:04:00.0-nvme-1
E: DEVTYPE=disk
E: ID_MODEL=Samsung SSD 980 PRO 1TB
E: ID_SERIAL_SHORT=S5GXNX0T123456

The S: lines are alternative symbolic links, and they solve a very real problem: the name nvme0n1 depends on the detection order, which can change between boots. Links based on the serial number never change.

$ ls -l /dev/disk/by-id/ | head -4
lrwxrwxrwx 1 root root 13 Aug 31 09:14 nvme-Samsung_SSD_980_PRO_1TB_S5GXNX0T123456 -> ../../nvme0n1
lrwxrwxrwx 1 root root 15 Aug 31 09:14 nvme-Samsung_SSD_980_PRO_1TB_S5GXNX0T123456-part1 -> ../../nvme0n1p1

This is why /etc/fstab must use UUIDs or /dev/disk/by-id/, never /dev/sda1. If you add a disk and the order changes, an fstab with direct names can mount the wrong volume. We will see this applied in Partitions, Mounting and the Virtual File System.

udev rules

You can define your own rules. A real case from Meteora: one of the stations connects to the server over USB-serial for diagnostics, and you want it always to appear under the same name and to be accessible to the meteora user.

# /etc/udev/rules.d/70-meteora.rules
SUBSYSTEM=="tty", ATTRS{idVendor}=="0403", ATTRS{idProduct}=="6001", \
  ATTRS{serial}=="A50285BI", \
  SYMLINK+="meteora/north-station", OWNER="meteora", GROUP="meteora", MODE="0660"

How each part works:

  • SUBSYSTEM=="tty": applies only to serial terminal devices.
  • ATTRS{idVendor}/ATTRS{idProduct}: identify the particular FTDI chip.
  • ATTRS{serial}: distinguishes this station from another with the same chip. Without it, two identical adapters would both match the rule.
  • SYMLINK+=: creates /dev/meteora/north-station, a stable name that does not depend on whether the kernel called it ttyUSB0 or ttyUSB3.
  • OWNER/GROUP/MODE: make the device accessible to the meteora user without needing root.

That last point matters from a security point of view: instead of running the diagnostic process as root so that it can open the serial port, it is given exact permissions on that one specific device. It is the principle of least privilege, which we will develop in Protection Principles and Access Control.

# Reload the rules and apply them without unplugging the device
$ sudo udevadm control --reload-rules
$ sudo udevadm trigger --subsystem-match=tty

$ ls -l /dev/meteora/
lrwxrwxrwx 1 root root 10 Aug 31 09:22 north-station -> ../ttyUSB0

Another useful rule: setting the I/O scheduler by device type, picking up where the previous lesson left off.

# /etc/udev/rules.d/60-scheduler.rules
ACTION=="add|change", KERNEL=="sd[a-z]", ATTR{queue/rotational}=="1", \
  ATTR{queue/scheduler}="mq-deadline"
ACTION=="add|change", KERNEL=="nvme[0-9]n[0-9]", \
  ATTR{queue/scheduler}="none"

That way the right scheduler is applied automatically at every boot and to every disk that gets connected, according to whether it is rotational or not.

Buses and enumeration: lspci, lsusb, lsblk

Devices connect through buses, and each bus has its own enumeration mechanism: the way the system discovers what is attached.

Bus Enumeration Tool Hot plugging
PCI / PCIe Standard configuration space lspci Limited
USB Descriptor query on connection lsusb Yes
SATA/SCSI Query to the controller lsblk, lsscsi Yes
I²C / SPI Declared in the device tree i2cdetect No
$ lspci
00:00.0 Host bridge: Intel Corporation 8th Gen Core Processor Host Bridge
00:14.0 USB controller: Intel Corporation 200 Series PCH USB 3.0 xHCI Controller
00:17.0 SATA controller: Intel Corporation 200 Series PCH SATA controller [AHCI mode]
00:1d.0 PCI bridge: Intel Corporation 200 Series PCH PCI Express Root Port #9
03:00.0 Ethernet controller: Intel Corporation I210 Gigabit Network Connection
04:00.0 Non-Volatile memory controller: Samsung Electronics NVMe SSD Controller

The identifier 03:00.0 follows the bus:device.function format, and it is the card's physical address in the PCI topology. The detailed version reveals how the kernel talks to it:

$ sudo lspci -v -s 03:00.0
03:00.0 Ethernet controller: Intel Corporation I210 Gigabit Network Connection (rev 03)
        Subsystem: Intel Corporation Device 0000
        Flags: bus master, fast devsel, latency 0, IRQ 128
        Memory at df200000 (32-bit, non-prefetchable) [size=128K]
        I/O ports at e000 [size=32]
        Memory at df280000 (32-bit, non-prefetchable) [size=16K]
        Capabilities: [70] MSI-X: Enable+ Count=5 Masked-
        Kernel driver in use: igb

Every line says something we will use:

  • bus master: the card can initiate DMA transfers on its own, without the CPU copying the data.
  • IRQ 128: the interrupt line assigned to it. It will show up in /proc/interrupts.
  • Memory at df200000 [size=128K]: its registers are mapped into memory at that physical address.
  • I/O ports at e000: it also has classic I/O ports.
  • MSI-X: Enable+ Count=5: it uses message-signalled interrupts, with 5 different vectors.
  • Kernel driver in use: igb: which module manages it.

That last line is the first thing to check when a device does not work: if it is missing, no driver is loaded and the hardware is present but inert. It is exactly the scenario we saw in 01-05 with lsmod and modinfo.

$ lsusb
Bus 002 Device 007: ID 0403:6001 Future Technology Devices International FT232 Serial (UART) IC
Bus 001 Device 002: ID 8087:0024 Intel Corp. Integrated Rate Matching Hub

$ lsusb -t
/:  Bus 02.Port 1: Dev 1, Class=root_hub, Driver=xhci_hcd/6p, 5000M
    |__ Port 1: Dev 7, If 0, Class=Vendor Specific Class, Driver=ftdi_sio, 12M

lsusb -t shows the tree with each node's driver (ftdi_sio for the station) and the negotiated speed (12 Mb/s, USB 1.1 full speed, plenty for a serial port).

And the complete map of block devices, which we saw in the previous lesson:

$ lsblk -o NAME,ROTA,SIZE,TYPE,MOUNTPOINT,MODEL
NAME        ROTA   SIZE TYPE  MOUNTPOINT         MODEL
nvme0n1        0 931.5G disk                     Samsung SSD 980 PRO 1TB
└─nvme0n1p2    0   931G part
  └─md0        0   931G raid1 /var/lib/meteora
sda            1   7.3T disk                     ST8000NM0055-1RM112
└─sda1         1   7.3T part  /backup

The three ways of talking to a device

When the CPU needs to transfer data with a device, there are three possible mechanisms. This section contrasts them; the internal mechanism of interrupts and DMA is the subject of the next lesson, here we only care about when each one is used and what it costs.

Programmed I/O with busy waiting

The CPU repeatedly polls a status register on the device until it reports being ready, and then transfers the data itself, byte by byte or word by word.

/* Conceptual sketch: NOT real kernel code */
while ((read_register(STATUS) & READY) == 0)
    ;                              /* busy wait: burning CPU */
write_register(DATA, byte);        /* transfer one byte */
  • Advantage: minimal latency and total simplicity. No interrupts, no synchronization.
  • Drawback: the CPU does nothing else while it waits.

With a slow device the waste is catastrophic:

Printer at 100 characters/second
Time per character: 10 ms
CPU cycles wasted per character (at 3 GHz): 30,000,000

Thirty million cycles per letter. Even so, busy waiting is still the right choice in three cases: when the device responds in less time than an interrupt would cost (a few microseconds), during boot — when the interrupt system is not yet configured — and in a panic handler, when nothing else can be trusted any more.

Interrupt-driven I/O

The CPU starts the operation and forgets about it: it puts the process to sleep and schedules another one. When the device finishes, it raises an interrupt and the kernel wakes the process up.

1. The ingestor calls read() on the socket
2. There is no data → the kernel puts it in TASK_INTERRUPTIBLE (state S)
3. The scheduler picks another process (02-02)
4. A packet arrives → the card raises an interrupt
5. The handler copies the data into the socket queue
6. It marks the ingestor as runnable (state R)
7. The scheduler runs it again and read() returns

You will recognize every step: they are exactly the transitions from the state diagram in 02-01. States S and D exist precisely because of this.

  • Advantage: the CPU is put to use during the wait.
  • Drawback: each interrupt costs between 1 and 5 µs, and the CPU still copies the data byte by byte.

And there is the problem that remains to be solved: with a 1 Gb/s network card receiving 1,500-byte packets:

Packets per second: 125,000,000 / 1,500 = 83,333 packets/s
With one interrupt per packet: 83,333 interrupts/s
At 3 µs each: 0.25 seconds of CPU per second = 25 % of a core

A quarter of a core just servicing interrupts, without counting the copying of the data.

DMA (direct memory access)

DMA is a controller that transfers data between the device and memory without the CPU intervening. The CPU only programs the operation (destination address, size) and receives a single interrupt at the end.

Without DMA, reading 4 KB from disk:
  4,096 one-byte transfers performed by the CPU
  ~4,096 × 2 instructions = 8,192 instructions

With DMA:
  1 descriptor setup (~20 instructions)
  1 completion interrupt (~3 µs)

CPU cost comparison table

Transferring 1 MB from a device:

Technique CPU involvement Interrupts CPU consumed When to use it
Busy waiting Copies every word + waits 0 ~100 % Extremely fast devices, boot, panic
Interrupt-driven Copies every word Thousands 30-60 % Slow devices with little volume
DMA Programs and collects 1 < 1 % Anything that moves volume

With concrete numbers for 1 MB from the SSD:

Busy waiting:     262,144 four-byte words × ~4 cycles = ~1,000,000 cycles
                  + a 300 µs wait blocking the CPU

Interrupt-driven: 262,144 copies + ~256 interrupts × 3 µs = 768 µs of CPU

DMA:              1 descriptor + 1 interrupt = ~5 µs of CPU

A factor of more than 150 against interrupt-driven I/O. That is why every device that moves volume uses DMA: disks, network, GPU, sound. The bus master we saw in lspci is precisely the ability to do it.

The decision rule in summary:

Situation Technique
Critical latency, device ready in < 5 µs Busy waiting
Slow device, little data (keyboard, mouse) Interrupts
Volume of data (disk, network, graphics) DMA
Very high interrupt rate DMA + adaptive polling (NAPI, in 02-07)

I/O ports and memory-mapped I/O

One question remains: how a device's registers are physically reached. There are two approaches.

A separate I/O space

x86 has a separate address space of 65,536 ports, with dedicated instructions:

in  al, 0x60      ; read a byte from port 0x60 (keyboard controller)
out 0x3F8, al     ; write a byte to port 0x3F8 (serial port COM1)

You will remember in and out from the table of privileged instructions in 01-06: they can only be executed in kernel mode. That is the mechanism that stops a user program from talking directly to the hardware.

$ sudo cat /proc/ioports | head -8
0000-0cf7 : PCI Bus 0000:00
  0000-001f : dma1
  0040-0043 : timer0
  0060-0060 : keyboard
  0064-0064 : keyboard
  0070-0077 : rtc0
  02f8-02ff : serial
  03f8-03ff : serial

There are the historic ports: 0x60 and 0x64 for the keyboard, 0x3F8 for COM1, 0x70 for the real-time clock. Numbers that have not changed since the IBM PC of 1981.

Memory-mapped I/O (MMIO)

The device's registers are assigned addresses in the physical memory space. Reading or writing them is accessing the device.

$ sudo cat /proc/iomem | grep -A2 'PCI Bus 0000:03'
df200000-df21ffff : 0000:03:00.0
  df200000-df21ffff : igb
df280000-df283fff : 0000:03:00.0

The 128 KB at 0xdf200000 are the registers of the network card we saw in lspci. The igb driver has reserved them and accesses them with ordinary memory instructions.

Comparing the two approaches:

I/O ports MMIO
Instructions in/out, privileged Ordinary movs
Address space Separate, 64 KB Shared with RAM
Transfer size Limited Any, including bursts
Protection By mode only (all or nothing) Per page, via the MMU
Cacheable No Must be disabled (PCD bit)
Architectures Almost only x86 Universal

MMIO has won, and for a reason you now understand perfectly: because it lives in the ordinary address space, protection is applied by the MMU with the page granularity we studied in 02-04. You can give a process access to the registers of one particular device without giving it access to anything else. With I/O ports, protection is all or nothing.

On top of that, MMIO lets a driver reach the registers with ordinary C code, without assembly, which makes the code portable across architectures.

One critical detail: MMIO pages must be marked as non-cacheable (the PCD bit in the page table, which we saw in 02-04). If the CPU cached a device's status register, it would read a stale value from the cache instead of the real hardware state, and the driver would hang waiting for a condition that has already been met. It is a perfect example of how two individually correct mechanisms — cache and MMIO — destroy each other if they are not coordinated.

Subsystem techniques: buffering, caching, spooling and reservation

The device-independent layer applies four general techniques. Each one solves a different mismatch.

Buffering

A buffer is an intermediate area of memory between the producer and the consumer. It solves three distinct problems:

Problem Example How the buffer solves it
Speed mismatch Network at 125 MB/s, disk at 150 MB/s Absorbs the bursts
Size mismatch 1,500 B packets, 4 KB blocks Accumulates until a block is complete
Copy semantics The process modifies the buffer after write() It is copied before being written

We already calculated its impact in 01-06: batching 170 readings before writing cut system calls by a factor of 167.

Why double buffering. With a single buffer a blocking problem appears:

With ONE buffer:
[fill buffer] → [drain buffer] → [fill buffer] → [drain buffer]
    device          process         device          process
   ← the device is STALLED →     ← the process is STALLED →

While the process works through the buffer, the device cannot write into it and has to wait. And vice versa. The two sides serialize.

With TWO buffers:
Buffer A: [fill]    [process] [fill]    [process]
Buffer B:           [fill]    [process] [fill]
          ← both work IN PARALLEL →

While the device fills buffer A, the process works on B. When they finish, they swap. Throughput goes from 1/(t_fill + t_process) to 1/max(t_fill, t_process).

The calculation for the ingestor:

Filling a buffer from the network: 8 ms
Processing the buffer:             5 ms

One buffer:  1 / (8 + 5) = 76.9 buffers/second
Two buffers: 1 / max(8, 5) = 1/8 = 125 buffers/second
Improvement: 62.5 %

The generalization is the circular buffer with N slots, which is what socket queues and the descriptor rings of modern network cards use.

Caching

A buffer and a cache are often confused, but they are different things:

Buffer Cache
What it holds The only copy of data in transit A copy of data that exists somewhere else
If it is lost The data is lost Only performance is lost
Purpose Matching speeds and sizes Avoiding slow accesses

Linux's page cache is the system's disk cache, and it is the one we saw as buff/cache in free -h:

$ free -h
               total        used        free      shared  buff/cache   available
Mem:           7.8Gi       3.1Gi       412Mi       528Mi       4.3Gi       3.9Gi

Those 4.3 GB are file content held in RAM. When the aggregator reads 2026-08-31.dat a second time, it does not touch the disk: the data is already there. It is the same cache that backs the mmap() pages we used in 02-04.

Checking it is simple and very instructive:

$ sync && echo 3 | sudo tee /proc/sys/vm/drop_caches   # empty the cache
$ time cat /var/lib/meteora/readings/2026-08-31.dat > /dev/null
real    0m0.118s

$ time cat /var/lib/meteora/readings/2026-08-31.dat > /dev/null
real    0m0.006s

20 times faster the second time, without having touched the disk. That factor of 20 is what the page cache contributes continuously and invisibly.

Spooling

Spool comes from Simultaneous Peripheral Operation On-Line. It is the technique for devices that cannot be shared by interleaving operations.

If two processes write simultaneously to a printer without coordination, the output comes out mixed line by line. Spooling solves it: each job is written in full into a queue directory, and a daemon prints them one at a time, in order.

$ ls -l /var/spool/cups/
$ ls /var/spool/cron/crontabs/
$ ls /var/spool/mail/

The interesting thing is that spooling is still very much alive even though printers no longer matter: cron, mail and message queues use the same pattern. The general idea — queue jobs in persistent storage and process them sequentially with a single consumer — is one of the most reused patterns in computing.

At Meteora, the nightly backup transfer to the remote server works this way: the files are dropped into an outgoing directory and a process transfers them one by one, retrying the ones that fail.

Device reservation

Some devices have to be used exclusively. The system offers mechanisms to reserve them:

/* Open a serial port exclusively */
int fd = open("/dev/meteora/north-station", O_RDWR | O_NOCTTY);
if (flock(fd, LOCK_EX | LOCK_NB) == -1) {
    fprintf(stderr, "The port is already in use by another process\n");
    return 1;
}

flock with LOCK_EX | LOCK_NB attempts an exclusive lock without waiting: if another process already holds the port, it fails immediately instead of hanging.

Exclusive reservation introduces the risk of deadlock: if process A holds the serial port and waits for the modem, and B holds the modem and waits for the serial port, neither makes progress. That problem has a lesson of its own: Deadlocks.

Error handling and retries

The general principle: errors are handled as close to the hardware as possible, and they only travel upwards if they cannot be resolved below.

Device level:              automatic retry by the hardware itself
Driver level:              retries, sector reallocation
Device-independent level:  translation into a standard error code
Application level:         decides what to do (retry, warn, abort)

A real example of the complete chain, seen from the kernel log:

$ sudo dmesg -T | tail -6
[Sun Aug 31 03:22:11 2026] ata3.00: exception Emask 0x0 SAct 0x0 SErr 0x0 action 0x0
[Sun Aug 31 03:22:11 2026] ata3.00: irq_stat 0x40000001
[Sun Aug 31 03:22:11 2026] ata3.00: failed command: READ DMA EXT
[Sun Aug 31 03:22:11 2026] ata3.00: status: { DRDY ERR }
[Sun Aug 31 03:22:11 2026] ata3.00: error: { UNC }
[Sun Aug 31 03:22:14 2026] sd 2:0:0:0: [sda] tag#20 Sense Key : Medium Error [current]

Reading the trace:

  • UNC (uncorrectable): the disk could not read a sector even with its internal error correction.
  • The driver retried several times (hence the 3 seconds between the first and last lines).
  • Once the retries were exhausted, it produced a Medium Error.
  • The file system receives it as EIO, and the process that issued the read() gets -1 with errno = EIO.

That errno is exactly the mechanism we traced back to its origin in 01-06: a negative value returned by the kernel that libc converts into -1 plus errno.

Error categories and what to do about each:

Error Meaning Retry?
EIO Physical I/O error Maybe once; if it persists, faulty hardware
EAGAIN No data right now (non-blocking) Yes, it is not a real error
EINTR Interrupted by a signal Yes, always
ENOSPC Out of space No: space has to be freed
ENODEV The device has disappeared No: it has been disconnected
EBUSY In use by someone else Perhaps, after waiting

EINTR deserves to be stressed because it is the most treacherous source of bugs. We already saw it in the buffering example in 01-06: if a signal arrives while the process is blocked in read() or write(), the call returns with EINTR without having done anything. It is not an error: you have to retry. Ignoring it produces sporadic, irreproducible data loss.

ssize_t robust_read(int fd, void *buf, size_t n) {
    ssize_t r;
    do {
        r = read(fd, buf, n);
    } while (r == -1 && errno == EINTR);
    return r;
}

Four lines that avoid a whole class of intermittent failures.

The path of a station reading

We close by applying everything to one concrete journey: a weather station sends a 24-byte reading and the ingestor receives it. Here we see which layers take part and what each one does; the internal mechanism of the interrupt and of DMA is the content of the next lesson.

flowchart TD
    A["Weather station<br/>sends 24 bytes over UDP"] --> B
    B["Physical network<br/>reaches the I210 card"] --> C
    C["Network card<br/>validates the Ethernet frame"] --> D
    D["DMA<br/>copies the packet into a RAM buffer<br/>with no CPU involvement"] --> E
    E["Interrupt<br/>the card signals the CPU: IRQ 128"] --> F
    F["igb driver<br/>acknowledges the interrupt"] --> G
    G["Network stack<br/>IP → UDP → finds the destination socket"] --> H
    H["Socket queue<br/>the packet is enqueued"] --> I
    I["The kernel marks the ingestor<br/>as runnable: S → R"] --> J
    J["Scheduler<br/>picks the ingestor (02-02)"] --> K
    K["read() returns<br/>the 24 bytes in the user buffer"]

The journey with the role of each layer:

Step Layer What happens Lesson
1-3 Hardware The card receives and validates the frame
4 DMA The packet reaches RAM without the CPU 02-07
5 Interrupt The card signals: there is work to do 02-07
6 Driver Code specific to the I210 card 02-07
7 Device-independent Network stack common to all cards
8 Buffering The socket queue absorbs the bursts This lesson
9 Process management The ingestor goes from S to R 02-01
10 Scheduling It competes for the CPU 02-02
11 System call Copy into user space 01-06

Notice the division of labor. Steps 1-6 are specific to this card: only the igb driver knows how to read its registers. From step 7 onwards the code is common to every network card in the world: the IP/UDP stack neither knows nor cares which hardware brought the packet in. That boundary is exactly the one we drew in the layer diagram in section 2, and it is what allows Linux to support hundreds of different cards with a single network stack.

And notice one more thing: the ingestor has taken part in none of this. It was asleep in state S from its read(). All the work was done by the hardware, DMA, the interrupt handler and the network stack. The process only wakes up when there is already data waiting for it.

A cost calculation that motivates the next lesson. At 800 readings/second:

With one interrupt per packet:
  800 interrupts/s × 3 µs = 2.4 ms/s = 0.24 % of a core
  Manageable at this load.

If Meteora grew to 500,000 readings/second:
  500,000 × 3 µs = 1.5 seconds of CPU per second
  More than a whole core just servicing interrupts.

That is the interrupt storm problem, and its solution — having the system stop using interrupts and start actively polling when the load is high — is one of the most elegant mechanisms in the Linux kernel. It is called NAPI and we will look at it in detail next.

Common Mistakes and Tips

Using /dev/sda1 in /etc/fstab. The name depends on the detection order and can change when you add hardware. Always use UUID= or /dev/disk/by-id/. The day you add a disk and the system will not boot, you will understand why.

Confusing buffer and cache. A buffer holds the only copy of data in transit; losing it loses data. A cache holds a copy of something that exists somewhere else; losing it only costs performance. drop_caches empties caches, never buffers with pending data.

Running as root to reach a device. Almost always the right answer is a udev rule that grants exact permissions on that one device to the user who needs it. Root to read a serial port is granting a thousand permissions in order to use one.

Ignoring EINTR. It is the most frequent cause of intermittent failures in I/O code. Any read, write or close on a blocking descriptor has to allow for it.

Believing that a network device ought to have a file in /dev. It does not, and that is a deliberate design decision: packets are not a reliable, ordered byte stream. The right abstraction is the socket.

Misreading lspci when something does not work. If the device appears but there is no Kernel driver in use line, the hardware is present and has no driver. If it does not appear at all, the problem is physical or a BIOS matter. Those are two completely different diagnoses.

Diagnostic tip: when a device does not work, the order that works is: lspci/lsusb (does the system see it?), lspci -v | grep -i driver (does it have a driver?), dmesg -T | tail -50 (what did the kernel say when it detected it?), ls -l /dev/... (does the node exist, and with what permissions?), and udevadm info --query=all --name=... (what does udev know?). Five checks that pin the problem down to the exact layer.

Exercises

Exercise 1: interpreting device files

Given this output:

$ ls -l /dev/sda /dev/sda1 /dev/nvme0n1 /dev/tty1 /dev/null /dev/meteora/north-station
brw-rw----  1 root disk    8,   0 Aug 31 09:14 /dev/sda
brw-rw----  1 root disk    8,   1 Aug 31 09:14 /dev/sda1
brw-rw----  1 root disk  259,   0 Aug 31 09:14 /dev/nvme0n1
crw--w----  1 root tty     4,   1 Aug 31 09:14 /dev/tty1
crw-rw-rw-  1 root root    1,   3 Aug 31 09:14 /dev/null
lrwxrwxrwx  1 root root         7 Aug 31 09:22 /dev/meteora/north-station -> ttyUSB0
  1. Classify each entry by type and explain how you know.
  2. Why do /dev/sda and /dev/sda1 share the major but not the minor?
  3. If the user meteora (group meteora) runs dd if=/dev/sda of=/tmp/copy bs=1M count=10, does it work? And on /dev/meteora/north-station?
  4. What does the 7 in the last line represent, where the others have two numbers?
  5. Write a udev rule that gives the meteora group read-only access to /dev/sda, and explain whether it is a good idea.

Exercise 2: choosing the I/O technique and calculating its cost

For each of these Meteora devices, decide which I/O technique is appropriate (busy waiting, interrupts or DMA) and justify it with a calculation:

  • A: An I²C sensor in a station, which returns 2 bytes 8 µs after the request. It is polled once a minute. The microcontroller runs at 80 MHz.
  • B: meteo-01's network card receiving 800 readings/second of 24 bytes in 66-byte UDP packets.
  • C: The NVMe SSD reading the 17 MB of 2026-08-31.dat.
  • D: A 9,600-baud USB-serial diagnostic adapter, which sends 200 characters every time it is plugged in.

For each one state the technique, the CPU cost calculation and what would happen if you chose wrongly.

Exercise 3: diagnosing a device that does not work

You have plugged a second network card into meteo-01 to separate the station traffic from the API traffic, but it does not show up as an interface. Design the complete diagnostic procedure:

  1. Write the sequence of commands you would run, in order, and what you are looking for in each one.
  2. For each of these four possible outcomes, say what you conclude and what you would do:
    • a) lspci shows no new card.
    • b) lspci shows it but with no Kernel driver in use line.
    • c) lspci shows it with a driver, ip link shows it as enp5s0 in state DOWN.
    • d) dmesg shows igb 0000:05:00.0: Failed to initialize MSI-X interrupts.
  3. Once it is working, write a udev rule that gives it the stable name stations0 and explain why ip link set name is not enough.

Solutions

Solution 1

1. Classification.

Entry Type How I know
/dev/sda Block First character b; shows two numbers (8, 0) instead of a size
/dev/sda1 Block The same, with minor 1
/dev/nvme0n1 Block b, major 259 (blkext)
/dev/tty1 Character First character c
/dev/null Character c, major 1 (mem), minor 3
/dev/meteora/north-station Symbolic link Leading l and the -> arrow

The most reliable signal is the column where an ordinary file would put its size: if there are two numbers separated by a comma, it is a device file.

2. Same major, different minor.

Major 8 identifies the driver (sd, SCSI/SATA disks). Both are managed by the same driver, so they share the major.

The minor identifies which particular instance within that driver:

minor 0  → the whole sda disk, from LBA 0 to the end
minor 1  → the sda1 partition, a range of LBAs within sda

A partition is not a separate device: it is a window onto a range of blocks on the same physical disk. The sd driver receives the minor, consults its partition table and applies the corresponding offset.

The convention allocates 16 minors per disk: minors 0-15 for sda and its 15 partitions, 16-31 for sdb, and so on. That is why /dev/sdb has minor 16.

3. Permissions for the meteora user.

On /dev/sda: it does NOT work.

brw-rw---- 1 root disk 8, 0 /dev/sda
  • Owner root, with rw. meteora is not root.
  • Group disk, with rw. You would have to check whether meteora belongs to disk:
$ groups meteora
meteora : meteora

It does not.

  • Others: ---, no permissions at all.

Result: dd: failed to open '/dev/sda': Permission denied.

And it is right that it should be so. Read access to the raw disk means being able to read any file in the system, completely bypassing the file system permissions: /etc/shadow included. It is equivalent to being root for reading.

On /dev/meteora/north-station: it DOES work, if the udev rule from the lesson is active.

The link points to ttyUSB0, and the permissions that count are the target's, not the link's (the lrwxrwxrwx of a symbolic link is always like that and means nothing). With the rule:

crw-rw---- 1 meteora meteora 188, 0 Aug 31 09:22 /dev/ttyUSB0

Owner meteora with rw: access granted. Although dd on a serial port would read the incoming stream, not structured content.

4. The number 7 on the symbolic link.

It is the size of the link in bytes, that is, the length of the string it contains:

"ttyUSB0" = 7 characters

A symbolic link is a real file: its content is the target path. That is why it shows a size where device files show major and minor. Real devices show no size because they do not contain anything: they only reference a driver.

Checking it:

$ readlink /dev/meteora/north-station
ttyUSB0
$ stat -c '%s bytes' /dev/meteora/north-station
7 bytes

5. A udev rule to give the meteora group read access to /dev/sda.

# /etc/udev/rules.d/71-meteora-disk.rules
SUBSYSTEM=="block", KERNEL=="sda", GROUP="meteora", MODE="0640"

Result: brw-r----- 1 root meteora 8, 0 /dev/sda.

Is it a good idea? Emphatically not.

The reasoning:

  1. It bypasses the file system permissions completely. Reading the raw device gives access to the bytes of every file on that disk, including /etc/shadow and the private TLS keys. File permissions are enforced through the file system; device access goes around them.

  2. It violates the principle of least privilege. If the aggregator needs to read data, it needs to read files in /var/lib/meteora/readings/, not the entire disk. The permission granted is several orders of magnitude larger than the need.

  3. It hugely widens the attack surface. If someone compromises a process running as meteora, they get complete read access to the disk.

  4. There is no legitimate case at Meteora that requires it. The real uses of the raw disk — cloning, forensics, recovery — are one-off administrative tasks, not the operations of a service.

The correct alternative, depending on what is actually needed:

Real need Correct solution
Reading the reading data Permissions on /var/lib/meteora/, which it already has
Checking free space df, which needs no special permissions
Seeing the disk's SMART status sudo with a specific entry for smartctl
Making a copy of the disk An administrative, one-off task, done as root
# If checking SMART without being root really were necessary:
# /etc/sudoers.d/meteora-smart
meteora ALL=(root) NOPASSWD: /usr/sbin/smartctl -a /dev/sda

This grants exactly one command with exactly one set of arguments, instead of full access to the disk. It is the difference between the key to one door and the master key to the building.

Solution 2

A: I²C sensor, 2 bytes in 8 µs, once a minute, microcontroller at 80 MHz.

Technique: busy waiting.

Cycles wasted waiting: 8 µs × 80 MHz = 640 cycles
Frequency: once a minute
CPU cost: 640 cycles / (60 s × 80,000,000 cycles/s) = 0.00000013 %

Justification:

  • 640 cycles is less than an interrupt would cost. Setting up the vector, saving the context, running the handler and restoring costs on the order of 200-500 cycles on a microcontroller, plus the complexity of the code. The interrupt would be more expensive than the wait.
  • It is a microcontroller running FreeRTOS, as we established in 01-03. There is no heavy multitasking competing for it: there is nothing better to do during those 8 µs.
  • Simplicity has value in its own right in embedded systems: less code, less state, fewer ways to fail.

If you chose wrongly (interrupts): it would work, but with more code, more complexity and more CPU consumption than the wait itself. A rare case where the "advanced" solution is objectively worse.

B: Network card, 800 readings/s in 66-byte UDP packets.

Technique: DMA with interrupts (and interrupt moderation).

Packets per second: 800
Data per second: 800 × 66 B = 52.8 KB/s

With busy waiting: the CPU could do nothing else. Ruled out.

With interrupts and no DMA:
  CPU copy: 66 bytes = ~17 four-byte words per packet
  800 × (17 copies × 4 cycles + 3 µs of interrupt)
  ≈ 800 × 3.02 µs = 2.4 ms/s = 0.24 % of a core

With DMA:
  800 interrupts/s × 3 µs = 2.4 ms/s = 0.24 %
  The copy is done by DMA: CPU cost ≈ 0

An honest observation: at only 800 packets/s, DMA and pure interrupts give an almost identical cost, because the cost is dominated by the interrupt, not by copying 66 bytes. DMA wins clearly when packets are large or plentiful.

But there are two solid reasons to use DMA anyway:

  1. Scalability. If Meteora grows to 500,000 readings/s, without DMA the copying alone would consume a whole core.
  2. It is not optional. Modern cards only work through DMA: the igb driver programs descriptors and the card writes straight into RAM. Programmed I/O no longer exists on this hardware.

If you chose wrongly (busy waiting): a whole core permanently polling the card's register in order to receive 52.8 KB/s. Absurd.

C: NVMe SSD reading 17 MB.

Technique: DMA, without a shadow of a doubt.

Data: 17,280,000 bytes = 4,320,000 four-byte words

Busy waiting or interrupts with a CPU copy:
  4,320,000 copies × ~4 cycles = 17,280,000 cycles
  At 3 GHz = 5.76 ms of pure CPU just copying

With DMA in 1 MB requests:
  17 descriptors + 17 interrupts × 3 µs = 51 µs of CPU

Improvement factor: 5,760 µs / 51 µs = 113×

And there is an even stronger qualitative argument:

SSD read time: 17 MB / 3,500 MB/s = 4.9 ms

With busy waiting, the CPU would be blocked for 4.9 ms doing nothing. At 3 GHz that is almost 15 million cycles lost, and it amounts to more than a full scheduler quantum (4 ms, according to 02-02). With DMA, another process makes use of those 4.9 ms.

If you chose wrongly: besides the waste, the process could not block in state S, breaking the whole scheduling model we studied in 02-02.

D: USB-serial at 9,600 baud, 200 characters on connection.

Technique: interrupts (which is what the ftdi_sio driver does).

9,600 baud with 8N1 = 8 data bits + 1 start + 1 stop = 10 bits per character
Characters per second: 9,600 / 10 = 960 c/s
Time per character: 1.04 ms
Total time for 200 characters: 208 ms

With busy waiting:
  208 ms of blocked CPU at 3 GHz = 624,000,000 cycles wasted
  And 208 ms is 52 quanta of 4 ms: 52 turns stolen from other processes

With interrupts (batched, as USB does):
  ~4 interrupts × 3 µs = 12 µs of CPU

Factor: 208,000 µs / 12 µs = 17,333×

Justification: 1.04 ms per character is an eternity for a CPU. It is exactly the scenario interrupts were invented for: an extremely slow device, scarce data, very long waits.

An interesting detail: USB does not raise one interrupt per byte. The USB controller groups transfers into packets and the FTDI chip has a buffer of its own, so 200 characters may arrive in 3 or 4 transfers. It is buffering applied in the hardware itself, for exactly the reasons in section 10.

If you chose wrongly (busy waiting): 208 ms of blocked CPU. On meteo-01, with the ingestor receiving 800 readings/s, those 208 ms would mean 166 unserviced readings. A diagnostic serial port would have degraded the production service.

Summary:

Case Technique CPU cost Deciding criterion
A: I²C sensor Busy waiting 0.0000001 % 640 cycles < the cost of an interrupt
B: network DMA 0.24 % Scalability and modern hardware
C: SSD DMA 0.001 % Volume of data: a 113× improvement
D: serial Interrupts 0.006 % Extremely slow device, scarce data

The rule that emerges: busy waiting when the wait is shorter than the interrupt; DMA when there is volume; interrupts for everything else.

Solution 3

1. The diagnostic sequence.

# Step 1: does the PCI bus see the card?
$ lspci | grep -i ethernet
# Looking for: a second Ethernet controller line

# Step 2: does it have a driver bound to it?
$ lspci -v -s 05:00.0 | grep -E 'Kernel driver|Kernel modules'
# Looking for: "Kernel driver in use: igb"

# Step 3: what did the kernel say when it detected it?
$ sudo dmesg -T | grep -iE 'eth|igb|e1000|link' | tail -30
# Looking for: initialization, firmware or interrupt errors

# Step 4: does the network interface exist?
$ ip link show
# Looking for: a second interface besides lo and enp3s0

# Step 5: is the module loaded?
$ lsmod | grep -E 'igb|e1000'
$ modinfo igb | head -5

# Step 6: what does udev know about the device?
$ udevadm info --query=all --path=/sys/class/net/enp5s0 2>/dev/null

# Step 7: is there an interrupt conflict?
$ cat /proc/interrupts | grep -i eth

The logic of the order is to work from the bottom up: hardware → driver → kernel → interface. There is no point investigating the network configuration if the PCI bus does not even see the card.

2. The four scenarios.

a) lspci shows no new card.

Conclusion: the problem is below the operating system. Linux cannot manage hardware that the PCI bus does not enumerate; it is not a driver or configuration problem.

Possible causes and what to do:

# Is it physically seated properly in the slot?
# → Power off, check the connector, reseat it

# Is the PCIe slot enabled in the BIOS?
# → Check the BIOS/UEFI configuration

# Does the slot work?
# → Try the card in another slot

# Does the card work?
# → Try it in another machine

# Force a PCI bus rescan without rebooting:
$ echo 1 | sudo tee /sys/bus/pci/rescan
$ lspci | grep -i ethernet

The rescan is useful because it rules out the case of a hot-inserted card that was not detected. If it still does not appear afterwards, the problem is physical or a BIOS matter.

b) It appears in lspci but with no Kernel driver in use.

Conclusion: the hardware is present and correctly enumerated, but there is no driver managing it. The card exists and is inert.

$ sudo lspci -v -s 05:00.0
05:00.0 Ethernet controller: Intel Corporation I350 Gigabit Network Connection
        Kernel modules: igb
        ("Kernel driver in use" does not appear)

If Kernel modules: igb appears, the kernel knows which module would manage it but has not loaded it.

# Load it by hand
$ sudo modprobe igb
$ dmesg -T | tail -20
$ lspci -v -s 05:00.0 | grep 'Kernel driver'

# If the module does not exist, identify the exact hardware
$ lspci -nn | grep -i ethernet
05:00.0 Ethernet controller [0200]: Intel Corporation I350 [8086:1521] (rev 01)

# Look for the driver by the 8086:1521 identifier
$ sudo apt install firmware-linux-nonfree   # if it needs firmware

If the module is blacklisted, it will show up in /etc/modprobe.d/:

$ grep -r igb /etc/modprobe.d/

This is the scenario of the faulty e1000e driver we saw in 01-05: hardware present, module absent or blocked.

c) It appears with a driver, ip link shows it as enp5s0 in state DOWN.

Conclusion: the whole device subsystem is working correctly. The driver is loaded, the kernel has created the interface, udev has named it. What is missing is network configuration or a physical link.

$ ip link show enp5s0
3: enp5s0: <BROADCAST,MULTICAST> mtu 1500 qdisc noop state DOWN
# Is a cable plugged in and a link negotiated?
$ sudo ethtool enp5s0 | grep -E 'Link detected|Speed'
Link detected: no

# If "Link detected: no" → a physical problem:
#   an unplugged or faulty cable, or a switch port that is down

# If "Link detected: yes" → it only needs bringing up:
$ sudo ip link set enp5s0 up
$ sudo ip addr add 10.20.0.5/24 dev enp5s0

Link detected is the decisive check in this scenario: it separates a cabling problem from a configuration one, which are two completely different investigations.

To make it persistent:

# /etc/systemd/network/20-stations.network
[Match]
Name=stations0

[Network]
Address=10.20.0.5/24

d) dmesg shows Failed to initialize MSI-X interrupts.

Conclusion: the driver has loaded but has not been able to configure the interrupts, which as we saw in lspci are MSI-X with 5 vectors. Without working interrupts, the card cannot signal that it has received packets.

$ sudo dmesg -T | grep -A5 'Failed to initialize MSI-X'
[Sun Aug 31 10:14:22 2026] igb 0000:05:00.0: Failed to initialize MSI-X interrupts. Falling back to MSI interrupts.

If it says Falling back to MSI interrupts, the driver has degraded to MSI and will probably work, though with less parallelism: instead of 5 vectors (one per queue), it will have a single one, which reduces throughput under heavy load.

# Check how many lines it really uses
$ cat /proc/interrupts | grep enp5s0
 129:  1204  0  0  0  PCI-MSI 2621440-edge  enp5s0

# A single line: it is on MSI, not MSI-X (which would show 5)

Causes and fixes:

# Have the system's interrupt vectors run out?
$ cat /proc/interrupts | wc -l

# Does the kernel have MSI disabled by some parameter?
$ cat /proc/cmdline | grep -o 'pci=[^ ]*'
# If pci=nomsi appears, that is the cause: remove it from GRUB

# Does the BIOS have the IOMMU or VT-d misconfigured?
# → Check in the BIOS

# Is there a pending firmware or BIOS update?
$ sudo dmidecode -s bios-version

If it works degraded to MSI, that is acceptable for Meteora at 800 readings/s — we already calculated that it is 2.4 ms/s of CPU — but it is worth fixing to leave room for growth. The detail of MSI, MSI-X and why several vectors matter is the content of the next lesson.

3. A udev rule for a stable name.

# /etc/udev/rules.d/70-meteora-net.rules
SUBSYSTEM=="net", ACTION=="add", DRIVERS=="igb", \
  ATTR{address}=="a0:36:9f:12:34:56", \
  ATTR{type}=="1", NAME="stations0"

An explanation of each condition:

  • SUBSYSTEM=="net": network interfaces only.
  • ACTION=="add": only when the device appears.
  • ATTR{address}: the MAC address, which is unique and immutable in the hardware. It is what guarantees that the rule identifies this card and not another.
  • ATTR{type}=="1": Ethernet type, so as not to match virtual interfaces.
  • NAME=: the final name. Careful: for network interfaces you use NAME, not SYMLINK, because the interface is renamed rather than given an alias.
$ sudo udevadm control --reload-rules
$ sudo udevadm trigger --subsystem-match=net
$ ip link show stations0

Why ip link set name is not enough:

ip link set enp5s0 name stations0 udev rule
Survives a reboot No Yes
Applied before the network configuration No Yes
Depends on the initial name Yes (if it changes, it fails) No, it uses the MAC
Works with hot plugging No Yes

The underlying problem is the same as with /dev/sda1 in /etc/fstab: the name assigned by the kernel depends on the enumeration of the PCI bus, which can change when you add or move hardware. The card that is enp5s0 today can be enp6s0 tomorrow if you insert another card in an earlier slot.

And there is an even more serious ordering problem: ip link set name is a command that runs after the system has brought the network up. By then, systemd-networkd will already have tried to configure enp5s0 with stations0's configuration, or the other way round, and the interface could end up with the wrong IP. With udev, the renaming happens at detection time, before anything else touches the interface.

At Meteora this matters especially: if stations0 (the station network) and the API interface swapped names after a reboot, the ingestor would be listening on the wrong network and the readings would be lost with no error message whatsoever. A silent, irreversible failure, exactly the kind you have to design out.

Conclusion

The I/O subsystem exists so that read(fd, buf, 1024) works the same on a 100 B/s keyboard and on a 3,500 MB/s NVMe drive, nine orders of magnitude apart. It achieves this with a layered architecture where the device-independent software does everything common — naming, permissions, buffering, cache, errors — and each driver only implements what is genuinely specific to its model.

The classification into block, character and network determines the interface: block devices allow random access and can be mounted; character devices are sequential streams; network devices have no file in /dev because a reliable byte stream does not describe packets that can be lost and reordered, which is why UNIX created a second abstraction, the socket, instead of distorting the first. The principle "everything is a file" unifies permissions, tools and composition, and rests on a surprisingly simple mechanism: the major and minor numbers, where the major picks the driver and the minor the instance. udev populates /dev dynamically and its rules solve two real problems: stable names that do not depend on the detection order, and exact permissions that avoid having to run as root.

There are three ways of talking to a device, and choosing well is a matter of arithmetic: busy waiting when the wait is shorter than an interrupt (or during boot and panic), interrupts for slow devices with little data, and DMA for anything that moves volume, with a CPU cost reduction of more than 150 times. Registers are reached through I/O ports or through MMIO, and MMIO has won because its protection is enforced by the MMU with page granularity, although it requires marking those pages as non-cacheable. Finally, the subsystem techniques — buffering (with double buffering contributing a 62.5 % improvement in the ingestor), caching (a factor of 20 on the second read of the daily file), spooling and reservation — each solve a specific mismatch, and error handling resolves errors as far down as possible, leaving only what requires a decision to reach the top.

We have followed the path of a station reading from the card to the ingestor's buffer and identified the layers, but we have deliberately left three boxes unopened: what exactly the CPU does when it receives an interrupt, how a driver registers with the kernel and what contract it fulfils, and how DMA works on the inside. A problem has also been posed with numbers: at 500,000 readings per second, a whole core would be devoted solely to servicing interrupts. The answer to that, and to everything above, is in Drivers, Interrupts and I/O Operations, which closes the module.

Operating Systems Fundamentals

Module 1: Introduction to Operating Systems

Module 2: Resource Management

Module 3: Concurrency

Module 4: File Structures

Module 5: System Protection and Security

Module 6: Virtualization and Containers

Module 7: Administration and Troubleshooting in Practice

© Copyright 2026. All rights reserved