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
- Why the operating system makes devices uniform
- The structure of the I/O subsystem
- Classification: block, character and network
- "Everything is a file" and the
/devdirectory - Major and minor numbers
- udev and the dynamic creation of devices
- Buses and enumeration:
lspci,lsusb,lsblk - The three ways of talking to a device
- I/O ports and memory-mapped I/O
- Subsystem techniques: buffering, caching, spooling and reservation
- Error handling and retries
- 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:
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:
- A single permission model. The fact that
/dev/nvme0n1belongs to thediskgroup with permissionsrw-rw----is what stops themeteorauser reading the raw disk and bypassing the file system permissions. Without the file model you would need a separate permission system just for devices. - Tool reuse.
cat,dd,cp,grepwork on devices without ever having been programmed to. - Composition. You can redirect, pipe and chain devices like anything else.
An example that exploits all three at once:
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:
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 itttyUSB0orttyUSB3.OWNER/GROUP/MODE: make the device accessible to themeteorauser 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: igbEvery 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, 12Mlsusb -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 CPUA 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:
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.
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 theread()gets-1witherrno = 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
- Classify each entry by type and explain how you know.
- Why do
/dev/sdaand/dev/sda1share the major but not the minor? - If the user
meteora(groupmeteora) runsdd if=/dev/sda of=/tmp/copy bs=1M count=10, does it work? And on/dev/meteora/north-station? - What does the
7in the last line represent, where the others have two numbers? - Write a udev rule that gives the
meteoragroup 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:
- Write the sequence of commands you would run, in order, and what you are looking for in each one.
- For each of these four possible outcomes, say what you conclude and what you would do:
- a)
lspcishows no new card. - b)
lspcishows it but with noKernel driver in useline. - c)
lspcishows it with a driver,ip linkshows it asenp5s0in stateDOWN. - d)
dmesgshowsigb 0000:05:00.0: Failed to initialize MSI-X interrupts.
- a)
- Once it is working, write a udev rule that gives it the stable name
stations0and explain whyip link set nameis 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.
- Owner
root, withrw.meteorais not root. - Group
disk, withrw. You would have to check whethermeteorabelongs todisk:
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:
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:
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:
-
It bypasses the file system permissions completely. Reading the raw device gives access to the bytes of every file on that disk, including
/etc/shadowand the private TLS keys. File permissions are enforced through the file system; device access goes around them. -
It violates the principle of least privilege. If the
aggregatorneeds 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. -
It hugely widens the attack surface. If someone compromises a process running as
meteora, they get complete read access to the disk. -
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:
- Scalability. If Meteora grows to 500,000 readings/s, without DMA the copying alone would consume a whole core.
- It is not optional. Modern cards only work through DMA: the
igbdriver 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:
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/:
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.
# 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:
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 useNAME, notSYMLINK, 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
- Basic Concepts of Operating Systems
- History and Evolution of Operating Systems
- Types of Operating Systems
- Main Functions of an Operating System
- Kernel Architecture: Monolithic, Microkernel and Hybrid
- User Mode, Kernel Mode and System Calls
Module 2: Resource Management
- Process Management
- CPU Scheduling
- Memory Management
- Virtual Memory and Paging
- Storage Management
- Device Management
- Drivers, Interrupts and I/O Operations
Module 3: Concurrency
- Concurrency Concepts
- Threads and Processes
- Inter-Process Communication (IPC)
- Synchronization and Mutual Exclusion
- Classic Concurrency Problems
- Deadlocks: Prevention, Detection and Recovery
Module 4: File Structures
- File Systems
- Directory Structures
- Partitions, Mounting and the Virtual File System
- File Management
- Space Allocation, Journaling and Integrity
- File Security and Permissions
Module 5: System Protection and Security
- Protection Principles and Access Control
- Users, Authentication and Privilege Escalation
- Common Threats and System Hardening
- Auditing, Logging and Incident Response
Module 6: Virtualization and Containers
- Virtualization: Hypervisors and Virtual Machines
- Containers: Namespaces and cgroups
- The Operating System in the Cloud
- Mobile and Real-Time Operating Systems
