We closed the previous lesson with three unopened boxes and a problem stated in numbers. The boxes: what exactly the CPU does when an interrupt arrives, how a driver registers with the kernel and what contract it fulfils, and how DMA works on the inside. The problem: at 500,000 readings per second, a whole core would be devoted solely to servicing interrupts, and that cannot be the final answer.

This lesson opens the three boxes and solves the problem. You are going to see the complete journey of an interrupt from the IRQ line to the handler, understand why a handler's code cannot sleep or block, and why that restriction forces the work to be split into two halves. You will learn to read /proc/interrupts line by line, which is one of the most informative outputs in the system. And you will follow the complete path of a packet carrying station readings, this time with no layer left unexplained.

It is the last lesson of module 2, so at the end we will recap all seven and link up with module 3.

Contents

  1. What a device driver is
  2. The contract with the kernel: the file operations interface
  3. Driver registration and loadable modules
  4. Kernel space versus user space for drivers
  5. Interrupts: IRQ line, vector and vector table
  6. The interrupt controller: from the PIC to the APIC
  7. What the CPU does when an interrupt arrives
  8. Interrupt context and its rules
  9. Masking and shared interrupts
  10. Top half and bottom half
  11. /proc/interrupts interpreted line by line
  12. MSI and MSI-X
  13. DMA in detail: descriptors, coherence and the IOMMU
  14. The complete path of a packet carrying readings
  15. Interrupt latency and its impact
  16. Closing module 2

What a device driver is

A device driver is the code that translates the kernel's generic operations into the concrete operations of one hardware model.

It is worth separating two things that are constantly confused, and which in some languages even share a name:

Device driver Device controller
What it is Software inside the kernel Hardware: the chip on the card
Where it lives In RAM, as part of the kernel On the card itself
Example The igb module The Intel I210 chip

In this lesson, when we say driver we mean the software; when we say device controller or chip, we mean the hardware.

The driver is the only piece of the system that knows the dirty details: which register has to be written to start a transfer, which bit carries the "ready" signal, how many microseconds you have to wait after a reset, what silicon erratum revision B2 of the chip has.

$ lsmod | head -8
Module                  Size  Used by
igb                   270336  0
nvme                   49152  4
nvme_core             143360  5 nvme
raid1                  49152  1
md_mod                176128  2 raid1
ext4                  942080  2
xhci_pci               24576  0

And their sheer size in the Linux kernel is revealing:

$ du -sh /lib/modules/$(uname -r)/kernel/drivers/
198M    /lib/modules/6.1.0-13-amd64/kernel/drivers/
$ du -sh /lib/modules/$(uname -r)/kernel/
312M    /lib/modules/6.1.0-13-amd64/kernel/

Drivers are 63 % of the kernel's code. And in the Linux source tree the proportion is similar: more than half of the millions of lines are device drivers. The kernel proper — scheduler, memory, file systems, network — is the small part.

The contract with the kernel: the file operations interface

A driver cannot do as it pleases: it has to fulfil a contract with the kernel. That contract is a structure of function pointers. For a character device:

#include <linux/fs.h>

static const struct file_operations meteora_fops = {
    .owner          = THIS_MODULE,
    .open           = meteora_open,
    .release        = meteora_release,
    .read           = meteora_read,
    .write          = meteora_write,
    .unlocked_ioctl = meteora_ioctl,
    .poll           = meteora_poll,
    .llseek         = no_llseek,
};

How the mechanism works, which is the same table dispatch we saw in 01-06 with system calls:

  • When a process calls read() on /dev/meteora/north-station, the kernel walks the path you already know: syscall → call table → sys_read → the VFS layer.
  • The VFS layer looks at the file's major number, finds the registered driver and calls the .read pointer in its file_operations.
  • That pointer points at meteora_read, code specific to this device.

It is polymorphism implemented with function pointers in C: the same read() call ends up running different code depending on the device, without the calling code knowing anything about it.

The usual methods in the contract:

Method When the kernel calls it What it must do
.open When the device file is opened Reserve resources, check availability
.release When the last descriptor is closed Release resources
.read On a read() Copy data to user space
.write On a write() Copy data from user space
.unlocked_ioctl On an ioctl() Operations that do not fit read/write
.poll On select/poll/epoll Report whether data is available
.mmap On an mmap() Map device memory into the process

ioctl deserves a comment. It is the escape hatch from the "everything is a file" model: it allows operations that are neither reading nor writing. Ejecting a CD, setting the speed of a serial port, querying a card's statistics. The fact that it exists demonstrates the limits of the file abstraction, exactly as sockets demonstrated the limits from another direction. ioctl is the pragmatic acknowledgement that not everything fits into read/write.

A simplified driver skeleton:

/* meteora_drv.c — illustrative skeleton, not compilable as it stands */
#include <linux/module.h>
#include <linux/fs.h>
#include <linux/uaccess.h>

#define METEORA_MAJOR 0        /* 0 = let the kernel assign a free one */

static int assigned_major;

static ssize_t meteora_read(struct file *f, char __user *buf,
                            size_t count, loff_t *pos)
{
    char data[24];
    int nread;

    /* 1. Read from the hardware (MMIO registers) */
    nread = read_from_device(data, sizeof data);
    if (nread < 0)
        return -EIO;                    /* will become errno = EIO */

    if (count > (size_t)nread)
        count = nread;

    /* 2. Copy to user space: NEVER with memcpy */
    if (copy_to_user(buf, data, count))
        return -EFAULT;

    return count;                        /* bytes read */
}

static int __init meteora_init(void)
{
    assigned_major = register_chrdev(METEORA_MAJOR, "meteora", &meteora_fops);
    if (assigned_major < 0) {
        pr_err("meteora: could not register the driver\n");
        return assigned_major;
    }
    pr_info("meteora: registered with major %d\n", assigned_major);
    return 0;
}

static void __exit meteora_exit(void)
{
    unregister_chrdev(assigned_major, "meteora");
    pr_info("meteora: unloaded\n");
}

module_init(meteora_init);
module_exit(meteora_exit);
MODULE_LICENSE("GPL");
MODULE_DESCRIPTION("Example driver for Meteora stations");

Points to understand about this code:

  • copy_to_user() instead of memcpy(). It is mandatory and it is not a formality. The buf pointer comes from user space and is not to be trusted: it could point to kernel memory, to an unmapped address or to another process's memory. copy_to_user validates the range, handles the page fault if the page is not present, and returns the number of bytes it could not copy. A plain memcpy would be a textbook privilege escalation vulnerability. The __user annotation in the prototype also lets static analysis tools catch the mistake.
  • Returning -EIO and -EFAULT, negative. That is exactly the convention we traced in 01-06: the kernel returns the error as a small negative value, and the C library turns it into -1 plus errno.
  • register_chrdev with major 0 asks the kernel to assign a free number. Fixing a major by hand only works for the officially reserved numbers.
  • module_init and module_exit define the module's entry and exit points, which is what makes it possible to load and unload it at run time.

Driver registration and loadable modules

Here we pick up what we saw in 01-05: loadable modules let you add code to a running kernel.

$ modinfo igb
filename:       /lib/modules/6.1.0-13-amd64/kernel/drivers/net/ethernet/intel/igb/igb.ko
version:        5.6.0-k
license:        GPL v2
description:    Intel(R) Gigabit Ethernet Network Driver
alias:          pci:v00008086d00001521sv*sd*bc*sc*i*
depends:        i2c-algo-bit,dca
parm:           max_vfs:Maximum number of virtual functions (uint)

The alias line is the key to automatic loading: it says that this module manages the PCI device with vendor 8086 (Intel) and device 1521 (I350). When the PCI bus enumerates a card with those identifiers, udev looks for a module with the matching alias and loads it with no human intervention.

$ lspci -nn | grep -i ethernet
03:00.0 Ethernet controller [0200]: Intel Corporation I210 [8086:1533] (rev 03)

$ sudo modprobe -c | grep 8086.*1533
alias pci:v00008086d00001533sv*sd*bc*sc*i* igb

There is the complete chain: the hardware identifier leads to the module name.

# Manual loading and unloading
$ sudo modprobe igb
$ sudo modprobe -r igb        # unload (fails if it is in use)
$ lsmod | grep igb
igb                   270336  0     ← the 0 is the use count

# See the configurable parameters
$ ls /sys/module/igb/parameters/
max_vfs

Advantages of modules, picking up the kernel architecture discussion from 01-05:

Advantage Detail
A small kernel Only what is actually present gets loaded
Updating without rebooting Swapping out a faulty driver on a running system
Agile development Compile and test without rebooting the machine
Dynamic hardware Hot-plugged USB

And one important limitation: a module runs with all the kernel's privileges. There is no isolation. A buggy module can corrupt any structure in the system.

Kernel space versus user space for drivers

Here we pick up and settle the architecture debate from 01-05, now with concrete data.

Driver in the kernel Driver in user space
Privileges Ring 0, full access Ring 3, restricted
A bug causes A kernel panic or corruption One process dies
Performance Maximum, no context switches Context switches and IPC
Debugging Hard (printk, kgdb, dumps) With ordinary gdb
Examples Almost all of Linux's FUSE, CUPS, SPDK, DPDK

Why a faulty driver is dangerous. A driver runs in ring 0, so:

  • It can write to any physical memory address, including the kernel's structures and any process's memory.
  • It can execute privileged instructions: disabling interrupts, changing the page table, reprogramming the MMU.
  • There is no MMU to protect it from itself: the protection mechanisms we studied in 02-03 and 02-04 protect processes from each other, but the kernel sits above them.

A null pointer in a driver:

$ sudo dmesg -T | tail -12
[Sun Aug 31 11:02:44 2026] BUG: kernel NULL pointer dereference, address: 0000000000000018
[Sun Aug 31 11:02:44 2026] #PF: supervisor read access in kernel mode
[Sun Aug 31 11:02:44 2026] Oops: 0000 [#1] PREEMPT SMP NOPTI
[Sun Aug 31 11:02:44 2026] CPU: 2 PID: 0 Comm: swapper/2 Tainted: G  O  6.1.0-13-amd64
[Sun Aug 31 11:02:44 2026] RIP: 0010:meteora_interrupt_handler+0x2a/0x120 [meteora_drv]
[Sun Aug 31 11:02:44 2026] Call Trace:
[Sun Aug 31 11:02:44 2026]  __handle_irq_event_percpu+0x46/0x180
[Sun Aug 31 11:02:44 2026]  handle_irq_event+0x38/0x80
[Sun Aug 31 11:02:44 2026] Kernel panic - not syncing: Fatal exception in interrupt

How to read this trace:

  • supervisor read access in kernel mode: the fault happened in ring 0. Had it been in user space, it would be a simple SIGSEGV.
  • RIP: meteora_interrupt_handler+0x2a [meteora_drv]: the exact address where it failed, with the guilty module named in brackets.
  • Tainted: G O: the kernel is "tainted" by an out-of-tree module. Kernel developers use this mark to know that the problem may not be theirs.
  • Fatal exception in interrupt: and here is the serious part. The fault happened inside an interrupt handler, where the kernel cannot simply kill the process responsible (there is no process: Comm: swapper/2 is the idle thread). No recovery is possible: kernel panic.

Compare it with the same bug in meteo-api: one process dies, systemd restarts it, a few requests are lost. Here the whole machine dies.

Drivers in user space. The alternative exists and is used in specific cases:

Technology What for Why in user space
FUSE File systems (sshfs, s3fs) A bug does not bring the system down
CUPS Printing Enormous complexity, performance irrelevant
libusb Specific USB devices No need for a module per gadget
DPDK / SPDK Extremely high performance networking and storage It bypasses the kernel entirely

The DPDK case is paradoxical and very instructive: it moves the driver into user space not for safety, but for performance. It maps the card's registers straight into the process and polls continuously, eliminating interrupts and system calls. It manages to process millions of packets per second at the cost of dedicating whole cores to spinning in a loop. It is exactly the busy waiting of the previous lesson, chosen deliberately because at those rates it is cheaper than interrupting.

Interrupts: IRQ line, vector and vector table

An interrupt is a hardware signal that makes the CPU suspend what it is doing and run a predetermined piece of code.

The vocabulary, which you need to be clear about:

Term What it is
IRQ line The physical (or logical) connection the device signals over
Vector The number that identifies which handler to run (0-255 on x86)
IDT Interrupt Descriptor Table: the 256-entry table with the handlers' addresses
ISR Interrupt Service Routine: the handler's code

On x86-64 there are 256 vectors, allocated like this:

Vectors Use
0-31 CPU exceptions (divide by zero, page fault, etc.)
32-47 Legacy IRQs inherited from the PIC (keyboard, timer, disk)
48-238 Device interrupts, MSI/MSI-X
239-255 Inter-processor interrupts (IPI), APIC local timer

Some exception vectors you already know from earlier lessons:

Vector Exception Where it appeared
0 Divide by zero
6 Invalid instruction
13 General protection fault 01-06, the cli instruction in user space
14 Page fault 02-04, the whole virtual memory mechanism

And here it is worth pinning down a classification that is often muddled:

Type Origin Synchronous Example
Interrupt External hardware No A network packet arrives
Exception / trap The CPU itself while executing Yes Page fault, divide by zero
System call A deliberate syscall instruction Yes The ingestor's read()

The key difference is synchrony. An exception always happens at the same point if you rerun the program with the same data: it is a consequence of the instruction being executed. An interrupt arrives whenever the outside world feels like it and can land between any two instructions. That asynchrony is the source of all the difficulty in interrupt code.

The IDT lives in memory and its address is in the IDTR register, loaded with the privileged lidt instruction we saw in the table in 01-06. Each entry holds the handler's address, the segment and the permissions.

The interrupt controller: from the PIC to the APIC

The CPU has very few interrupt pins, so an intermediary chip is needed to multiplex the lines from all the devices.

PIC 8259A (1976): two chained chips, 15 usable IRQ lines.

IRQ Classic device
0 System timer
1 Keyboard
3 COM2
4 COM1
8 Real-time clock
14 Primary IDE disk

That 1981 assignment is still recognizable today in /proc/interrupts.

The APIC (Advanced Programmable Interrupt Controller) replaced it, and its improvements are what make a multiprocessor server viable:

PIC 8259A APIC
Lines 15 24 per I/O APIC, several per system
Multiprocessor No Yes: it directs the interrupt to a specific CPU
Priorities Fixed by number Programmable
Load spreading No Yes, across CPUs
MSI No Yes

The APIC has two parts:

  • Local APIC: one per core, integrated into the CPU. It receives the interrupts directed at that core and manages the local timer and the inter-processor interrupts.
  • I/O APIC: in the chipset. It receives the devices' lines and routes them to the Local APIC of the appropriate core.

The fact that the APIC can direct an interrupt at a particular core is what enables the smp_affinity we will see when we get to /proc/interrupts, and what makes it possible for a card with several queues to spread its work across cores.

What the CPU does when an interrupt arrives

This is the exact sequence, and it is worth comparing it mentally with the system call sequence from 01-06:

sequenceDiagram
    participant D as Device
    participant A as APIC
    participant C as CPU
    participant K as Kernel
    D->>A: asserts its IRQ line
    A->>A: prioritizes and picks the target core
    A->>C: interrupt signal + vector
    Note over C: finishes the instruction in progress
    C->>C: saves RIP, RSP, RFLAGS and CS on the kernel stack
    C->>C: switches to ring 0 and to the kernel stack
    C->>C: disables interrupts (depending on the IDT gate)
    C->>C: looks up IDT[vector]
    C->>K: jumps to the handler
    K->>K: saves the remaining registers
    K->>K: runs the driver's handler
    K->>A: sends EOI (end of interrupt)
    K->>K: restores registers
    K->>C: iret
    Note over C: carries on with the next instruction

The details that matter:

  1. The instruction in progress is finished. The CPU does not interrupt halfway through an instruction (with a few exceptions for very long instructions, which are restartable). This guarantees a consistent state.
  2. The minimum state is saved automatically: instruction pointer, stack pointer, flags register and segment selector. It is done by the hardware, not by software.
  3. It switches to the kernel stack, exactly as in a syscall. The handler never uses the interrupted process's stack.
  4. Interrupts are disabled if the IDT entry is an interrupt gate (the usual case). This stops another interrupt from nesting immediately.
  5. The EOI is mandatory. If the handler does not send the end-of-interrupt signal to the APIC, that device will never interrupt again. It is one of the classic mistakes when writing drivers, and its symptom is that the device works exactly once and then goes silent.

A comparison with what you already know:

System call (01-06) Interrupt
Who initiates it The process, with syscall The hardware
When it happens At a deterministic point At any moment
Switches process No No (but it can cause a switch afterwards)
Switches stack Yes, to the kernel's Yes, to the kernel's
Context That of the calling process That of nobody in particular
Cost 50-500 ns 1-5 µs

The crucial row is the second to last: a system call runs on behalf of a particular process, whereas an interrupt runs in the context of whoever happened to be running, which could be anyone. Every restriction in the next section comes out of that.

Interrupt context and its rules

An interrupt handler runs in interrupt context, also called atomic context. It is an environment with very strict rules:

Rule Why
It cannot sleep or block There is no process to hand the CPU back to: there is no task_struct of its own to put in state S
It cannot call functions that might sleep kmalloc(GFP_KERNEL), mutex_lock, copy_to_user
It cannot touch user space It could trigger a page fault, which would mean sleeping
It must be very fast With interrupts disabled, everything else waits
It must use special locks spin_lock_irqsave, never a mutex

Understanding the reason behind the first rule is the key to everything else. When the ingestor calls read() and there is no data, the kernel puts it in state S and schedules another process: there is a task_struct to park. But an interrupt handler does not belong to any process. If it went to sleep, who would be put in state S? The process that happened to be running, which has nothing to do with it? And who would wake it up?

Remember the kernel panic trace: Comm: swapper/2. The handler ran "on top of" the idle thread, which was what happened to be on that core. It was not its interrupt and not its work.

/* WRONG: this hangs the system */
static irqreturn_t bad_handler(int irq, void *dev)
{
    char *buf = kmalloc(4096, GFP_KERNEL);   /* it can sleep! */
    mutex_lock(&my_mutex);                    /* it can sleep! */
    copy_to_user(dest, data, 24);             /* the page can fault! */
    msleep(10);                               /* it sleeps outright! */
    return IRQ_HANDLED;
}

/* RIGHT */
static irqreturn_t good_handler(int irq, void *dev)
{
    struct meteora_dev *d = dev;
    u32 status;
    unsigned long flags;

    /* 1. Is this interrupt mine? (shared line) */
    status = readl(d->regs + REG_STATUS);
    if (!(status & INT_PENDING))
        return IRQ_NONE;                      /* not mine, let someone else see it */

    /* 2. Acknowledge it in the hardware: essential */
    writel(status, d->regs + REG_STATUS);

    /* 3. Spinlock that disables interrupts */
    spin_lock_irqsave(&d->lock, flags);
    d->pending_packets++;
    spin_unlock_irqrestore(&d->lock, flags);

    /* 4. Delegate the heavy work to the bottom half */
    napi_schedule(&d->napi);

    return IRQ_HANDLED;
}

The four decisions in the correct handler:

  1. Check whether the interrupt is its own before anything else, because the line may be shared. Returning IRQ_NONE lets the kernel try the next registered driver.
  2. Acknowledge it in the hardware by writing to the status register. Without this the device would keep asserting the line and an endless interrupt storm would follow.
  3. spin_lock_irqsave instead of mutex_lock. A mutex sleeps if it is taken; a spinlock spins waiting, which is the only thing you can do in atomic context. The irqsave variant also saves the interrupt state and disables interrupts, avoiding a deadlock against yourself if the same interrupt were to fire again.
  4. Delegate all the real work to the bottom half. All it has done is bump a counter and schedule deferred work.

Masking and shared interrupts

Masking means temporarily disabling an interrupt, and there are three levels:

Level How Scope
Globally on the CPU cli / sti All maskable interrupts
Per line, in the APIC Mask register One particular line
With state saving spin_lock_irqsave Global, restoring the previous state

cli is the privileged instruction we tried in 01-06 and which caused a SIGSEGV in user mode. Now you understand why it has to be so emphatically privileged: a process that could disable interrupts would stop the timer from taking the CPU away from it, monopolizing the CPU forever and completely breaking the preemptive scheduling of 02-02.

NMIs (Non-Maskable Interrupts) cannot be masked under any circumstances. They are reserved for catastrophic failures: a memory parity error, the watchdog timer, hardware failure signals. If the system stops responding entirely, the watchdog NMI is what forces a diagnostic dump.

Shared interrupts. With few IRQ lines and many devices, several can share a line:

$ cat /proc/interrupts | grep -E '^ *(16|17|18):'
 16:      1204      0      0      0  IO-APIC  16-fasteoi   ehci_hcd:usb1, i801_smbus
 17:     28841      0      0      0  IO-APIC  17-fasteoi   snd_hda_intel

IRQ 16 is shared by the USB controller and the SMBus. When it fires, the kernel does not know which of the two it was, so it calls the handlers of every driver registered on that line, in order, until one returns IRQ_HANDLED.

Hence the importance of the check in the previous section: a driver that returned IRQ_HANDLED without checking whether the interrupt was its own would steal the other device's interrupts, and that device would stop working with a symptom that is impossible to diagnose.

/* Register a shared handler */
ret = request_irq(irq, good_handler,
                  IRQF_SHARED,          /* the line can be shared */
                  "meteora",            /* name in /proc/interrupts */
                  dev);                 /* passed to the handler to identify it */

The last argument is essential on shared lines: it is what lets the handler know which device instance the call belongs to.

Top half and bottom half

Here is the central idea in Linux's interrupt design, and it resolves a real tension.

The tension: an interrupt handler has to be extremely fast, because it runs with interrupts disabled and blocks everything else. But the work an interrupt generates can be considerable: processing a network packet means walking the IP stack, finding the socket, updating statistics, waking processes.

The solution: split it in two.

Top half Bottom half
When Immediately, in interrupt context Deferred, shortly afterwards
Interrupts Disabled (at least its own) Enabled
Can it sleep No Depends on the mechanism
Duration Microseconds Can be long
What it does Acknowledge the hardware, save data, schedule the bottom half The real processing

The three bottom-half mechanisms in Linux:

Mechanism Context Can it sleep? Concurrency Used for
softirq Atomic No In parallel on several CPUs Network, block, timers
tasklet Atomic No Only one instance at a time Simple drivers
workqueue Process Yes Kernel threads Work that needs to sleep
  • softirq: the fastest and the only one that scales. There is a fixed number defined at compile time, so only the kernel's main subsystems use it. The same softirq can run simultaneously on several CPUs, which requires its code to be reentrant.
  • tasklet: built on top of softirqs, easier to use. A given tasklet never runs on two CPUs at once, which simplifies synchronization at the cost of scalability.
  • workqueue: runs in a kernel thread, that is, in process context. It is the only one that can sleep, so it is the option when you need to allocate memory with GFP_KERNEL, wait on a mutex or do I/O.
$ cat /proc/softirqs
                    CPU0       CPU1       CPU2       CPU3
          HI:          0          0          0          0
       TIMER:     284102     271884     269110     265882
      NET_TX:      12841       1102        884        791
      NET_RX:    1284102      42881      38104      36992
       BLOCK:     102841      98221      94102      91884
     TASKLET:       2841       1102        884        702
       SCHED:     284102     198221     194102     191884
     RCU:          98221      94102      91884      89102

Reading this output:

  • NET_RX is 30 times higher on CPU0 (1,284,102 against ~40,000). All the incoming network traffic is processed on a single core. With a single-queue card that is what you would expect; with a multi-queue one, it means the interrupt affinity is not spread properly.
  • TIMER is spread evenly, as it should be: every core has its own APIC local timer.
  • BLOCK are the completed block device interrupts, correlated with the RAID activity we saw in 02-05.

The network card example

/* TOP HALF: microseconds, in interrupt context */
static irqreturn_t igb_msix_ring(int irq, void *data)
{
    struct igb_q_vector *q_vector = data;

    igb_write_itr(q_vector);          /* adjust the moderation */
    napi_schedule(&q_vector->napi);   /* schedule the bottom half */

    return IRQ_HANDLED;
}

/* BOTTOM HALF: NET_RX softirq, with interrupts enabled */
static int igb_poll(struct napi_struct *napi, int budget)
{
    /* Process up to 'budget' packets (typically 64) */
    clean_complete = igb_clean_rx_irq(q_vector, budget);

    if (!clean_complete)
        return budget;                /* more left: I will stay in polling */

    napi_complete_done(napi, work_done);
    igb_ring_irq_enable(q_vector);    /* re-enable interrupts */
    return work_done;
}

The split is clear: the top half takes less than 1 µs and only schedules work. The bottom half processes up to 64 packets walking the whole network stack, and can take tens of microseconds, but with interrupts enabled, so it blocks nothing.

NAPI: the answer to the interrupt storm

Here we solve the problem we left open. The key is in igb_ring_irq_enable: while there are packets to process, that queue's interrupts are disabled and the kernel polls actively.

Low load (800 packets/s):
  A packet arrives → interrupt → NAPI processes 1 → there are no more
  → re-enables interrupts → back to interrupt mode
  Result: ~800 interrupts/s. Minimum latency.

High load (500,000 packets/s):
  A packet arrives → interrupt → NAPI disables interrupts
  → processes 64 → there are still more → processes 64 more → ...
  → when the queue empties, it re-enables interrupts
  Result: a few thousand interrupts/s instead of 500,000

NAPI is an adaptive hybrid: interrupts at low load (minimum latency) and polling at high load (maximum throughput). It switches mode on its own, with no configuration.

The numbers for Meteora:

Current situation (800 readings/s):
  Interrupt mode, ~800 interrupts/s
  800 × 3 µs = 2.4 ms/s = 0.24 % of a core

At 500,000 readings/s WITHOUT NAPI:
  500,000 × 3 µs = 1.5 s of CPU per second → more than a whole core

At 500,000 readings/s WITH NAPI:
  It processes batches of 64 in polling
  ~7,800 polling cycles/s × 3 µs ≈ 23 ms/s = 2.3 % of a core
  Reduction: 65×

It is one of the most elegant solutions in the Linux kernel: recognizing that neither of the two techniques from the previous lesson is always better, and switching between them according to the load.

/proc/interrupts interpreted line by line

$ cat /proc/interrupts
            CPU0       CPU1       CPU2       CPU3
   0:         41          0          0          0   IO-APIC    2-edge      timer
   1:       1204          0          0          0   IO-APIC    1-edge      i8042
   8:          1          0          0          0   IO-APIC    8-edge      rtc0
   9:          0          0          0          0   IO-APIC    9-fasteoi   acpi
  16:      28841          0          0          0   IO-APIC   16-fasteoi   ehci_hcd:usb1, i801_smbus
 128:          0          0          0          0   PCI-MSI 1572864-edge   enp3s0
 129:    1284102          0          0          0   PCI-MSI 1572865-edge   enp3s0-rx-0
 130:          0     284102          0          0   PCI-MSI 1572866-edge   enp3s0-tx-0
 131:      98221      94102      91884      89102   PCI-MSI 524288-edge    nvme0q0
 132:     284102     271884     269110     265882   PCI-MSI 524289-edge    nvme0q1
 NMI:          0          0          0          0   Non-maskable interrupts
 LOC:    2841022    2718840    2691100    2658820   Local timer interrupts
 RES:      28410      27188      26911      26588   Rescheduling interrupts
 CAL:       1204       1102        884        791   Function call interrupts
 TLB:      12841      11022       8840       7910   TLB shootdowns

The structure of a line:

 129:    1284102          0          0          0   PCI-MSI 1572865-edge   enp3s0-rx-0
 └┬─┘    └────────── count per CPU ─────────┘       └─ type ─┘ └trigger┘   └── driver ──┘
IRQ

A line-by-line analysis of what this system is saying:

Line What it reveals
0: timer Only 41 firings. The legacy timer is barely used: Linux uses the APIC local timer (the LOC line)
1: i8042 1,204 keystrokes. On a server with no monitor, most likely from the management console
16: ehci_hcd:usb1, i801_smbus A shared interrupt: two drivers on the same line
128-130: enp3s0 Three MSI-X vectors for a single card: one control, one receive, one transmit
129: enp3s0-rx-0 1,284,102 interrupts, all on CPU0. All network reception on one core
131-132: nvme0q0, nvme0q1 Spread evenly across the 4 cores. This is NVMe working as it should: one queue per core
LOC The timer that drives scheduling. Even, as it should be
RES One core asking another to reschedule. Tied to the load balancing of 02-02
TLB TLB invalidations between CPUs: when one core changes a page table, it tells the others to invalidate their entries. Directly related to the context switch cost of 02-01 and to the TLB of 02-04

The diagnosis that jumps out: enp3s0-rx-0 concentrates 1.28 million interrupts on CPU0 while the other three cores are at zero. At 800 readings/s it is not a problem, but it is the bottleneck that would show up as things grew.

$ cat /proc/irq/129/smp_affinity
1
$ cat /proc/irq/129/smp_affinity_list
0

The mask 1 (binary 0001) means "CPU0 only". To spread it out:

# Allow any core to take it
$ echo f | sudo tee /proc/irq/129/smp_affinity

# Or pin it to a specific core other than the one the aggregator uses
$ echo 2 | sudo tee /proc/irq/129/smp_affinity    # CPU1 only

And there an interesting decision appears that links back to 02-02: in the scheduling lesson we confined the aggregator to cores 2 and 3 with taskset. Directing the network interrupts to CPU0 and CPU1 completes that partition: packet processing and the computation of averages stop competing both for CPU and for cache.

The irqbalance daemon does this spreading automatically, but on systems with strict latency requirements it is usually disabled so that affinity can be pinned by hand.

Watching the activity in real time:

$ watch -n1 'grep -E "enp3s0|nvme" /proc/interrupts'

It is the quickest way to check whether a device is generating interrupts at all. If a device is not responding and its counter is not increasing, the problem is in the hardware or in the interrupt routing, not in the software above.

MSI and MSI-X

Classic interrupts use physical lines: a dedicated pin from the device to the APIC. That has three problems:

  1. Scarcity: there are few lines, hence the shared ones.
  2. Race conditions: the device can assert the line before the data it has written by DMA has reached memory. The handler would read incomplete data.
  3. One vector per device: you cannot tell "I have received a packet" from "I have finished transmitting".

MSI (Message Signaled Interrupts) does away with the lines: the device writes a value to a special memory address, and that write is the interrupt.

IRQ line MSI MSI-X
Mechanism Physical pin Memory write Memory write
Vectors per device 1 Up to 32 Up to 2,048
Shared Yes No No
Race with DMA Possible No No
Target per vector Fixed One for all One per vector

The two decisive advantages:

The race with DMA disappears. Because the interrupt is a write over the same PCIe bus as the data, the bus ordering guarantees that the data has already arrived when the interrupt does. The physical line took a different path and could overtake it.

MSI-X allows one vector per queue and per core. That is what makes possible the perfect spread of the NVMe device we saw above:

 131:      98221      94102      91884      89102   PCI-MSI 524288-edge   nvme0q0
 132:     284102     271884     269110     265882   PCI-MSI 524289-edge   nvme0q1

Remember from 02-05 that NVMe has up to 65,535 queues and that its advantage is parallelism. MSI-X is the piece that completes it: each queue has its own interrupt vector directed at its own core, so there is neither a shared lock nor a CPU centralizing the work.

$ sudo lspci -v -s 03:00.0 | grep -A2 MSI-X
        Capabilities: [70] MSI-X: Enable+ Count=5 Masked-
                Vector table: BAR=3 offset=00000000

Enable+ confirms it is active, and Count=5 that the card has 5 vectors: control, two receive queues and two transmit queues.

DMA in detail: descriptors, coherence and the IOMMU

DMA transfers data between the device and memory without the CPU. Here is how it works on the inside.

Descriptors

Modern cards do not receive one command per transfer: they work with descriptor rings, structures in memory that the CPU fills in and the device consumes.

/* Simplified receive descriptor, Intel style */
struct rx_descriptor {
    u64 buffer_address;      /* PHYSICAL address to write to */
    u16 length;              /* bytes received (filled in by the card) */
    u16 checksum;
    u8  status;              /* DD bit: Descriptor Done */
    u8  errors;
    u16 vlan;
};

The life cycle:

 Ring of 256 descriptors in RAM

 ┌──────┬──────┬──────┬──────┬──────┬──────┐
 │ D0   │ D1   │ D2   │ D3   │ ...  │ D255 │
 └──────┴──────┴──────┴──────┴──────┴──────┘
    ↑                    ↑
   HEAD                 TAIL
 (the card)           (the driver)

1. The driver allocates 256 buffers and fills in the physical addresses
2. It writes TAIL to a card register: "there are 256 free"
3. A packet arrives: the card writes by DMA into the HEAD buffer,
   fills in the length and sets the DD bit, and advances HEAD
4. The card raises an interrupt (MSI-X)
5. The driver walks from its position looking for descriptors with DD=1
6. It processes the packets, reallocates fresh buffers and advances TAIL

This ring design is what makes reception at full speed possible: the card can write several packets with no CPU involvement at all, and the driver collects them in a batch when its turn comes. It is the same idea as the double buffer in 02-06, generalized to 256 slots.

Cache coherence

Here there is a subtle and very real problem. The CPU has caches; DMA writes straight into RAM, bypassing them.

Problem on reading (DMA → memory):
  1. The CPU had read the buffer before: it has a copy in its L1 cache
  2. DMA writes new data into RAM
  3. The CPU reads the buffer → it gets the OLD copy from the cache
  → It reads stale data

Problem on writing (memory → DMA):
  1. The CPU writes into the buffer → it stays in the cache (write-back)
  2. DMA reads from RAM → it gets the OLD data
  → It sends rubbish

The solutions, in order of preference:

Solution How Cost
Hardware coherence The bus invalidates the affected cache lines None, the chipset does it
Non-cacheable memory Marking the pages with PCD Very slow CPU accesses
Explicit flushing dma_sync_single_for_cpu/device Extra instructions

On x86 the hardware is coherent and the problem does not arise. On ARM and other architectures you have to synchronize explicitly, which is why Linux's DMA API is portable:

/* Allocate coherent memory: the kernel picks the right
   strategy for the architecture */
desc = dma_alloc_coherent(&pdev->dev, size, &dma_handle, GFP_KERNEL);

/* For ordinary buffers, mark the transfer of ownership */
dma_sync_single_for_cpu(&pdev->dev, dma_handle, len, DMA_FROM_DEVICE);
/* ... the CPU reads the data ... */
dma_sync_single_for_device(&pdev->dev, dma_handle, len, DMA_FROM_DEVICE);

Notice the concept these calls express: ownership of the buffer is handed over between the CPU and the device. While it belongs to the device, the CPU must not touch it, and vice versa. It is a pattern that will reappear under another name in module 3.

The IOMMU

A first-order security problem: DMA uses physical addresses and bypasses the MMU. A compromised card — or one with malicious firmware, or a Thunderbolt device plugged in by an attacker — could write to any physical address, the kernel's memory included. That is the DMA attack, and it has been exploited in practice.

The IOMMU (Intel VT-d, AMD-Vi) is an MMU for devices:

flowchart LR
    DEV["Device<br/>DMA address<br/>0x1000"] --> IOMMU
    IOMMU{"IOMMU<br/>does this device<br/>have permission?"}
    IOMMU -->|yes| RAM["RAM<br/>physical address<br/>0x7A34000"]
    IOMMU -->|no| FAULT["DMA fault<br/>→ logged and blocked"]

It is exactly the same mechanism as the MMU in 02-04 — translation tables, permission checks, a fault if it does not add up — but applied to devices instead of processes. Each device has its own DMA address space and can only reach what has been assigned to it.

$ dmesg | grep -i -E 'dmar|iommu' | head -4
[    0.000000] DMAR: IOMMU enabled
[    0.212841] DMAR: Intel(R) Virtualization Technology for Directed I/O
[    0.213102] iommu: Default domain type: Translated

$ ls /sys/class/iommu/
dmar0  dmar1

Besides security, the IOMMU enables device passthrough to virtual machines: you can hand a physical card to a VM with the guarantee that its driver, even if compromised, cannot touch the host's memory or that of the other VMs. It is an essential piece of virtualization, and it will come back in Virtualization: Hypervisors and Virtual Machines.

The complete path of a packet carrying readings

Now, at last, the complete journey with no black boxes. A station sends a 24-byte reading over UDP and the ingestor receives it.

sequenceDiagram
    participant E as Station
    participant N as I210 NIC
    participant M as RAM
    participant C as CPU
    participant S as NET_RX softirq
    participant K as Network stack
    participant I as ingestor

    E->>N: UDP packet (66 bytes on the wire)
    N->>N: validates the Ethernet frame CRC
    N->>M: DMA writes into the HEAD descriptor's buffer
    N->>M: sets DD=1 and the length in the descriptor
    N->>C: MSI-X: a write that generates vector 129
    C->>C: saves state, jumps to IDT[129]
    C->>C: TOP HALF igb_msix_ring (< 1 µs)
    C->>S: napi_schedule() and disables this IRQ
    Note over C: iret: the CPU carries on with what it was doing
    S->>S: BOTTOM HALF: NET_RX softirq
    S->>M: walks the descriptors with DD=1
    S->>K: hands the packet to the network stack
    K->>K: Ethernet → IP: checks destination and checksum
    K->>K: IP → UDP: checks port 9010
    K->>K: finds the socket listening on 9010
    K->>K: enqueues the packet in the socket buffer
    K->>I: marks the ingestor as runnable (S → R)
    S->>N: if there are no more packets, re-enables the IRQ
    Note over I: the scheduler picks the ingestor (02-02)
    I->>I: read() returns with 24 bytes in the buffer

Step by step, with the cost and the reference lesson:

# Step Who Cost Lesson
1 The frame arrives, the CRC is checked NIC hardware ~0.5 µs
2 DMA into the descriptor's buffer DMA engine 0 CPU 02-07
3 DD=1 is set in the descriptor NIC 0 CPU 02-07
4 MSI-X generates vector 129 NIC → APIC 02-07
5 The CPU saves state and jumps to the IDT CPU hardware ~0.5 µs 02-07
6 Top half: schedules NAPI igb driver < 1 µs 02-07
7 Bottom half: NET_RX softirq Kernel ~5 µs 02-07
8 IP and UDP stack, socket lookup Kernel ~3 µs
9 Enqueued in the socket buffer Kernel ~0.5 µs 02-06 (buffering)
10 The ingestor goes from S to R Kernel ~0.3 µs 02-01 (states)
11 The scheduler picks it CFS/EEVDF variable 02-02
12 read() copies to user space System call ~1 µs 01-06
Total latency from the wire to the ingestor: ~12 µs
plus the scheduler wait (0 to several ms depending on load)

Two observations that sum up the whole module:

The CPU spends 12 µs on a packet that took 0.5 µs to arrive. The cost is not in moving 66 bytes, but in crossing layers: the interrupt, the interrupt context switch, the network stack, waking the process, the system call. It is the same lesson as the cost of the syscall in 01-06 and of the context switch in 02-01, applied to I/O.

Step 11 is the one that varies most. Steps 1 to 10 are deterministic and add up to ~12 µs. Step 11 depends on the load and on the ingestor's nice value, and can be zero or several milliseconds. The real latency is dominated by scheduling, not by the hardware, which is why in 02-02 we gave the ingestor a raised priority.

Interrupt latency and its impact

Interrupt latency is the time from the device raising the interrupt to the handler starting to run.

Its components:

Component Typical cost What it depends on
Propagation through the APIC 0.1-0.3 µs Hardware
Finishing the instruction in progress 0-0.1 µs It can be long (rep movsb)
Waiting for interrupts to be re-enabled 0 - many µs Kernel code with cli
Saving state and jumping to the IDT 0.3-0.5 µs Hardware
Entering the handler with cold caches 0.5-2 µs Cache pressure

The critical component is the third: if some other kernel code has interrupts disabled, the interrupt waits. That is the worst case, and it determines how predictable the system is.

$ sudo cyclictest -p 80 -t 4 -n -D 60
T: 0 ( 4102) P:80 I:1000 C:  60000 Min:      2 Act:    3 Avg:    4 Max:      87
T: 1 ( 4103) P:80 I:1500 C:  40000 Min:      2 Act:    3 Avg:    4 Max:      64

An average latency of 4 µs and a maximum of 87 µs. For Meteora that is excellent. For industrial control with 50 µs deadlines, that 87 µs maximum would be a missed deadline.

Kernel Typical maximum latency Suitable for
Standard (CONFIG_PREEMPT_NONE) milliseconds Throughput servers
Voluntary (PREEMPT_VOLUNTARY) hundreds of µs Desktop
Preemptive (PREEMPT) tens of µs Multimedia, low latency
PREEMPT_RT < 10 µs Hard real time

PREEMPT_RT achieves that guarantee by turning almost all interrupt handlers into schedulable kernel threads and replacing spinlocks with preemptible mutexes. It reduces peak throughput in exchange for predictability, which is exactly the real-time trade-off we set out in 01-03: predictable, not fast. The detail belongs to Mobile and Real-Time Operating Systems.

Diagnostic tools:

# Interrupts in real time
$ watch -n1 'grep -E "enp3s0|nvme" /proc/interrupts'

# Kernel messages with timestamps
$ sudo dmesg -T | grep -iE 'irq|dma|error' | tail -20

# Detailed card statistics
$ sudo ethtool -S enp3s0 | grep -E 'rx_packets|rx_dropped|rx_missed|rx_no_buffer'
     rx_packets: 1284102
     rx_dropped: 0
     rx_missed_errors: 0
     rx_no_buffer_count: 0

# Interrupt moderation
$ sudo ethtool -c enp3s0
rx-usecs: 3
rx-frames: 0

rx_missed_errors and rx_no_buffer_count are the critical metrics for Meteora. If they are non-zero, readings are being lost: the card received packets but there were no free descriptors to write them into, because the driver did not recycle them in time. Since lost readings are unrecoverable, these two figures should be monitored with an alert.

# Grow the descriptor ring if losses appear
$ sudo ethtool -g enp3s0
Ring parameters for enp3s0:
Pre-set maximums:
RX:             4096
Current hardware settings:
RX:             256

$ sudo ethtool -G enp3s0 rx 2048

Going from 256 to 2,048 descriptors gives the system eight times more headroom to absorb bursts before dropping packets. The cost is memory (2,048 buffers of 2 KB = 4 MB) and slightly more latency in the worst case. For a system where losing data is irreversible, it is a clearly favorable trade.

Common Mistakes and Tips

Doing heavy work in the top half. It is the number one design mistake in drivers. With interrupts disabled, the entire system waits. A handler that takes 500 µs makes the network card drop packets and causes xruns in audio. The rule: acknowledge the hardware, save the bare minimum, schedule the bottom half, get out.

Using mutex_lock in interrupt context. A mutex sleeps, and in atomic context sleeping hangs the system. Use spin_lock_irqsave. And do not forget the irqsave: without it, if the same interrupt fires again while you hold the lock, you deadlock against yourself.

Forgetting the EOI or the hardware acknowledgement. The symptom is unmistakable: the device works exactly once and then goes silent, or it generates an endless interrupt storm that freezes the machine.

Returning IRQ_HANDLED without checking whether the interrupt is yours. On a shared line you steal the other device's interrupts, and it stops working with a symptom impossible to connect to your driver.

Using memcpy instead of copy_to_user. It is a privilege escalation vulnerability, not a matter of style. The pointer comes from user space and is not to be trusted.

Ignoring rx_missed_errors. It is the metric that tells you whether you are losing data at the card. At Meteora, where lost readings are unrecoverable, it should have an alert configured.

Disabling irqbalance without pinning affinity by hand. You end up with every interrupt on CPU0, which is the worst of both worlds: no automatic spreading and no manual spreading.

Diagnostic tip: faced with a device that does not respond, the order is: watch -n1 cat /proc/interrupts (is it generating interrupts?), dmesg -T | tail -50 (are there driver errors?), ethtool -S or the equivalent tool (are there losses?), cat /proc/irq/N/smp_affinity (are they all on one core?). If the interrupt counter does not increase, the problem is below the driver: interrupt routing, misconfigured MSI or hardware. If it increases but no data arrives, the problem is above.

Exercises

Exercise 1: analyzing /proc/interrupts

This is the state of meteo-01 during the 8:00 peak:

            CPU0       CPU1       CPU2       CPU3
   1:       1204          0          0          0   IO-APIC    1-edge      i8042
  16:      28841          0          0          0   IO-APIC   16-fasteoi   ehci_hcd:usb1, i801_smbus
 128:          2          0          0          0   PCI-MSI 1572864-edge   enp3s0
 129:    8421022          0          0          0   PCI-MSI 1572865-edge   enp3s0-rx-0
 130:     284102          0          0          0   PCI-MSI 1572866-edge   enp3s0-tx-0
 131:      12841      11022       8840       7910   PCI-MSI 524288-edge    nvme0q0
 132:     284102     271884     269110     265882   PCI-MSI 524289-edge    nvme0q1
 LOC:    9841022    2718840    2691100    2658820   Local timer interrupts
 RES:     284102      27188      26911      26588   Rescheduling interrupts
 TLB:     128410      11022       8840       7910   TLB shootdowns

And in parallel:

$ mpstat -P ALL 1 1
CPU  %usr %nice %sys %iowait %irq %soft %idle
all  18.2   4.1  12.4     1.2  0.8  14.1   49.2
  0   4.1   0.0  28.2     0.4  3.2  56.4    7.7
  1  24.1   0.0   6.8     1.6  0.0   0.2   67.3
  2  21.8  16.4   7.1     1.4  0.0   0.1   53.2
  3  22.8   0.0   7.5     1.4  0.0   0.1   68.2
  1. Identify the main problem and justify it with at least three pieces of data from the two outputs.
  2. Why is LOC 3.6 times higher on CPU0 than on the others?
  3. What does it tell you that nvme0q1 is spread out and enp3s0-rx-0 is not?
  4. Propose a concrete solution with the exact commands, and explain what each one would improve.
  5. How would you check whether readings are being lost, and what would you do if they were?

Exercise 2: designing the split of a driver

You are writing the driver for a device that collects data from a group of stations connected over a proprietary bus. When a batch of readings arrives, the driver has to:

  • (a) Read the device's status register. Takes 0.5 µs.
  • (b) Acknowledge the interrupt by writing to a register. Takes 0.3 µs.
  • (c) Copy 64 readings of 24 bytes from the DMA buffer. Takes 8 µs.
  • (d) Validate the checksums of the 64 readings. Takes 45 µs.
  • (e) Allocate memory to store them. It may need to sleep.
  • (f) Write them to a file in /var/lib/meteora/readings/. Takes milliseconds.
  • (g) Wake the process waiting in read(). Takes 0.3 µs.

Answer:

  1. Which operations go in the top half and which in the bottom half? Justify each one.
  2. Which bottom-half mechanism would you use for each group: softirq, tasklet or workqueue? Why?
  3. Calculate the time spent with interrupts disabled in your design and compare it with doing everything in the top half.
  4. If the device generates 2,000 interrupts per second, calculate the CPU percentage in both designs.
  5. What would happen if you put (e) or (f) in the top half?

Exercise 3: diagnosing lost readings

The Meteora team reports that readings are missing: the day's data has gaps. You investigate and find:

$ sudo ethtool -S enp3s0 | grep -E 'rx_packets|dropped|missed|no_buffer|fifo'
     rx_packets: 68420112
     rx_dropped: 0
     rx_missed_errors: 284102
     rx_no_buffer_count: 128410
     rx_fifo_errors: 284102

$ sudo ethtool -g enp3s0
Pre-set maximums:
RX:             4096
Current hardware settings:
RX:             256

$ cat /proc/net/softnet_stat | head -2
0102a4c1 00000000 00028f41 00000000 00000000 00000000 00000000 00000000 00000000 00000000
00004a12 00000000 00000000 00000000 00000000 00000000 00000000 00000000 00000000 00000000

$ ss -uln
State   Recv-Q  Send-Q  Local Address:Port
UNCONN  212992  0             0.0.0.0:9010

$ cat /proc/sys/net/core/rmem_max
212992

$ ps -eo pid,ni,stat,comm -u meteora
    PID  NI STAT COMMAND
   1842   0 Ssl  ingestor
   1877  -5 Ssl  aggregator
  1. Locate where the packets are being lost. There are at least two distinct loss points: identify them and explain how they differ.
  2. Calculate the loss percentage and what it means in readings lost per day.
  3. Propose a complete solution for each loss point, with concrete commands.
  4. There is an obvious configuration mistake in the ps output. Identify it and correct it, relating it to what we saw in 02-02.
  5. Design the monitoring that would have caught this before the team noticed.

Solutions

Solution 1

1. The main problem: CPU0 saturated by network processing.

The evidence:

Evidence Figure What it means
enp3s0-rx-0 8,421,022 interrupts, all on CPU0 All reception on one core
%soft on CPU0 56.4 % against ~0.1 % on the others More than half a core in softirqs
%idle on CPU0 7.7 % against 53-68 % CPU0 saturated, the others half idle
%sys on CPU0 28.2 % against ~7 % Kernel work is concentrated there too
RES on CPU0 284,102 against ~27,000 Ten times more reschedulings

The picture is unambiguous: CPU0 is 92 % busy while the other three are less than half loaded. The system has capacity to spare in aggregate (49.2 % global %idle) but one core is the bottleneck.

And the figure that makes it serious: if CPU0 saturates completely, the NET_RX softirq will not be able to drain the descriptor ring in time and readings will start to be lost, which at Meteora is irreversible.

2. Why LOC is 3.6 times higher on CPU0.

LOC is the APIC local timer interrupts, which drive scheduling. On a system with NO_HZ (tickless), the timer stops on idle cores to save power and only fires when there is work.

CPU0 has 7.7 % %idle: it is working almost all the time, so its timer practically never stops. The other three, at 53-68 % idle, spend long periods without ticks.

A high LOC is not the cause of the problem: it is one more symptom that CPU0 never rests.

3. nvme0q1 spread out against enp3s0-rx-0 concentrated.

The difference lies in the queue architecture, and it is exactly what we saw in 02-05 and in the MSI-X section:

NVMe: the standard defines one submission and completion queue per core. Each queue has its MSI-X vector directed at its core. When a process on CPU2 does I/O, it uses CPU2's queue and its interrupt lands on CPU2. No shared locks and no concentration.

 132:     284102     271884     269110     265882   nvme0q1

The near-perfect spread confirms it is working as it should.

The I210 card: it only has one receive queue (rx-0, confirmed by the MSI-X Count=5: control, 2 RX and 2 TX at best, and here only one is used). All the incoming traffic goes through it, and its vector is directed at CPU0.

It is a combined hardware and configuration limitation, which is why modern server cards have multiple RX queues with RSS (Receive Side Scaling), which spreads the flows across queues by hashing the headers.

4. A concrete solution.

a) Check how many queues the card supports:

$ sudo ethtool -l enp3s0
Channel parameters for enp3s0:
Pre-set maximums:
RX:             4
Combined:       4
Current hardware settings:
RX:             1
Combined:       1

If it supports 4, enable them:

$ sudo ethtool -L enp3s0 combined 4

What it improves: the card now has 4 receive queues, each with its own MSI-X vector. With RSS, packets from different flows are spread across the four by hash, and each queue interrupts a different core. The %soft work is divided by four.

b) Spread the interrupt affinity:

# See the current vectors
$ grep enp3s0 /proc/interrupts

# Assign each queue to a core
$ echo 1 | sudo tee /proc/irq/129/smp_affinity   # rx-0 → CPU0
$ echo 2 | sudo tee /proc/irq/133/smp_affinity   # rx-1 → CPU1
$ echo 4 | sudo tee /proc/irq/134/smp_affinity   # rx-2 → CPU2
$ echo 8 | sudo tee /proc/irq/135/smp_affinity   # rx-3 → CPU3

What it improves: it guarantees the spread instead of leaving it to chance.

c) If the card supports only one queue, enable RPS in software:

$ echo e | sudo tee /sys/class/net/enp3s0/queues/rx-0/rps_cpus

e is 1110 in binary: CPU1, CPU2 and CPU3. What it improves: RPS (Receive Packet Steering) does in software what RSS does in hardware: the top half still runs on CPU0, but the network stack processing is distributed to the other three cores. CPU0 is deliberately excluded to unload it.

d) Tune the interrupt moderation:

$ sudo ethtool -C enp3s0 rx-usecs 50

What it improves: the card waits up to 50 µs before interrupting, grouping several packets per interrupt. With 8.4 million interrupts, grouping them ten at a time brings them down to 840,000. The cost is up to 50 µs more latency per packet, perfectly acceptable at Meteora, where not losing readings matters far more than delivering them 50 µs sooner.

e) Coordinate with the process affinity from 02-02:

# The aggregator on CPU2 and CPU3 only (we already did this in 02-02)
$ sudo taskset -cp 2,3 1877

# The ingestor on CPU0 and CPU1, where the network interrupts land
$ sudo taskset -cp 0,1 1842

What it improves: having the ingestor run on the same core where its packets are processed exploits cache locality: the packet's data is already in that core's L1/L2. It is the processor affinity of 02-02 applied to I/O.

5. Checking for lost readings.

$ sudo ethtool -S enp3s0 | grep -E 'missed|no_buffer|dropped|fifo'
$ ss -uln | grep 9010          # socket queue
$ cat /proc/net/softnet_stat   # 2nd column = dropped packets

How to read each loss point:

Metric Where it is lost Cause
rx_missed_errors At the card No free descriptors
rx_no_buffer_count At the card The driver does not recycle buffers in time
Column 2 of softnet_stat In the backlog queue The kernel does not process in time
Recv-Q full in ss At the socket The ingestor does not read in time

If there were losses, the actions in order:

# 1. Grow the descriptor ring
$ sudo ethtool -G enp3s0 rx 2048

# 2. Grow the socket buffer
$ sudo sysctl -w net.core.rmem_max=16777216
$ sudo sysctl -w net.core.rmem_default=16777216

# 3. Grow the backlog queue
$ sudo sysctl -w net.core.netdev_max_backlog=5000

# 4. Apply the interrupt spreading from point 4

Solution 2

1. Splitting between halves.

Op Description Half Justification
(a) Read the status, 0.5 µs Top You have to know whether the interrupt is ours and what happened. Essential before anything else
(b) Acknowledge the interrupt, 0.3 µs Top Mandatory: without it the device keeps asserting the line → endless storm
(c) Copy 64 readings, 8 µs Top, with a caveat See the discussion below
(d) Validate checksums, 45 µs Bottom 45 µs with interrupts disabled is unacceptable, and it is not urgent
(e) Allocate memory Bottom (workqueue) It can sleep: forbidden in atomic context
(f) Write to a file, ms Bottom (workqueue) Disk I/O: it blocks, and milliseconds are an eternity
(g) Wake the process, 0.3 µs Bottom It has to happen after (d) and (e): there is no valid data before then

A discussion of (c), which is the interesting decision: the 8 µs copy could go in either half. The arguments:

  • In favor of the top half: if the DMA buffer is a small ring, it has to be emptied soon so that the device can carry on writing. Delaying it risks data loss.
  • In favor of the bottom half: 8 µs is 16 times the rest of the top half, and with interrupts disabled that is a lot.

The professional answer is not to copy at all. The correct design uses a descriptor ring like the network card's: the top half only notes which descriptors are ready (advancing an index, ~0.2 µs) and the bottom half processes the data directly in the DMA buffer, without copying it. That is exactly what igb_clean_rx_irq does.

The final design:

TOP HALF (interrupt context, ~1 µs):
  (a) read the status
  (b) acknowledge the interrupt
  (c') note the ready descriptors and advance the index
       schedule the bottom half

BOTTOM HALF — tasklet or softirq (atomic context, ~45 µs):
  (d) validate the checksums

BOTTOM HALF — workqueue (process context, ms):
  (e) allocate memory
  (f) write to the file
  (g) wake the waiting process

2. The mechanism for each group.

For (d), validating checksums: a tasklet.

  • It does not need to sleep: it is pure computation over data already in memory.
  • It has to run soon: the data is occupying the DMA buffer.
  • A softirq is not needed: softirqs are a scarce resource, with a fixed number compiled into the kernel, reserved for the main subsystems (network, block, timers). A specific driver uses a tasklet, which is built on softirqs and is the interface designed for drivers.
  • An extra advantage: a given tasklet never runs on two CPUs at once, which enormously simplifies synchronization.

For (e), (f) and (g): a workqueue, necessarily.

  • (e) can sleep. kmalloc(GFP_KERNEL) blocks if there is no immediately free memory, waiting for the kernel to reclaim pages (the whole mechanism of 02-04). In atomic context that hangs the system. The workqueue is the only one of the three that can sleep, because it runs in a kernel thread, that is, in process context with its own task_struct.
  • (f) does disk I/O. Milliseconds of blocking. Unthinkable outside process context.
  • (g) has to come afterwards, so it goes with the previous two.
/* Top half */
static irqreturn_t meteora_irq(int irq, void *dev)
{
    struct meteora_dev *d = dev;
    u32 status = readl(d->regs + REG_STATUS);      /* (a) */

    if (!(status & INT_BATCH_READY))
        return IRQ_NONE;

    writel(status, d->regs + REG_STATUS);          /* (b) */
    d->ready_idx = readl(d->regs + REG_HEAD);      /* (c') */

    tasklet_schedule(&d->validate_tasklet);        /* → (d) */
    return IRQ_HANDLED;
}

/* Atomic bottom half */
static void meteora_validate(unsigned long data)
{
    struct meteora_dev *d = (struct meteora_dev *)data;
    validate_checksums(d);                         /* (d) 45 µs */
    queue_work(d->wq, &d->store_work);             /* → (e)(f)(g) */
}

/* Bottom half with process context */
static void meteora_store(struct work_struct *w)
{
    struct meteora_dev *d = container_of(w, struct meteora_dev, store_work);
    void *buf = kmalloc(BATCH_SIZE, GFP_KERNEL);   /* (e) can sleep */
    if (!buf) return;
    copy_and_write(d, buf);                        /* (f) I/O: blocks */
    kfree(buf);
    wake_up_interruptible(&d->wait_queue);         /* (g) */
}

3. Time with interrupts disabled.

The two-half design:

(a) 0.5 µs + (b) 0.3 µs + (c') 0.2 µs + scheduling 0.1 µs = 1.1 µs

Everything in the top half:

(a) 0.5 + (b) 0.3 + (c) 8 + (d) 45 + (g) 0.3 = 54.1 µs
(and (e) and (f) would hang the system, so it is not even possible)
Reduction: 54.1 / 1.1 = 49× less time with interrupts disabled

4. CPU percentage at 2,000 interrupts/s.

The two-half design:

Phase Time Interrupts
Top half 1.1 µs Disabled
Tasklet (d) 45 µs Enabled
Workqueue (e)(f) ~2,000 µs Enabled, and it can sleep
Total CPU:  2,000 × (1.1 + 45) µs = 92.2 ms/s = 9.2 % of a core
CPU with interrupts disabled:
            2,000 × 1.1 µs = 2.2 ms/s = 0.22 %

(The workqueue does I/O: its time is mostly waiting on the disk, not CPU.)

Everything in the top half:

Total CPU: 2,000 × 54.1 µs = 108.2 ms/s = 10.8 %
CPU with interrupts disabled: 108.2 ms/s = 10.8 %
Design Total CPU With interrupts disabled
Two halves 9.2 % 0.22 %
Everything on top 10.8 % 10.8 %

The metric that matters is the second column. Total consumption hardly changes: the work has to be done either way. What changes radically is how long the rest of the system is blind.

With interrupts disabled 10.8 % of the time, the impact on the rest of meteo-01 would be:

One packet every 1.25 ms (800 readings/s)
Blind windows of 54.1 µs, 2,000 times a second
Probability that a packet arrives during a window: 10.8 %

→ 10.8 % of readings would suffer extra latency
→ With bursts, losses in rx_missed_errors

5. If (e) or (f) went into the top half.

With (e), kmalloc(GFP_KERNEL):

[ 1284.221] BUG: sleeping function called from invalid context at mm/page_alloc.c
[ 1284.221] in_atomic(): 1, irqs_disabled(): 1, pid: 0, name: swapper/2
[ 1284.221] Call Trace: __might_sleep → __alloc_pages → meteora_irq

If there is immediately free memory, kmalloc returns without sleeping and it appears to work. When the system is under memory pressure — precisely when it matters most — it will try to sleep in atomic context and the system will hang or panic. It is the worst kind of bug: it works in testing and fails in production under load.

The correct alternative if you really had to allocate in atomic context is GFP_ATOMIC, which never sleeps but can fail more often and draws on an emergency reserve. Even so, here the design solution (a workqueue) is better.

With (f), writing to a file:

Worse still. Writing involves the file system, the page cache, the I/O scheduler from 02-05 and waiting on the disk: milliseconds of blocking, with multiple points where the code sleeps.

The chain of consequences:

5 ms of interrupts disabled, 2,000 times a second
= 10 seconds of blindness per second → impossible, the system collapses

And before that:
- The timer loses ticks → the system clock falls behind
- The card drops packets → Meteora readings are lost
- The watchdog fires an NMI → kernel panic

The general conclusion of the exercise: the split into two halves does not exist to share out work, but to minimize the window in which the system cannot react to the outside world. Anything that does not absolutely have to be done right now must be moved out of the top half.

Solution 3

1. The two loss points.

Point 1: at the network card (hardware).

rx_missed_errors: 284102
rx_no_buffer_count: 128410
rx_fifo_errors: 284102

The card received the packets correctly off the wire but had nowhere to put them: the 256-descriptor ring was full because the NET_RX softirq had not drained it in time. The packets stayed in the card's internal FIFO until it overflowed.

Those packets never reached RAM. The kernel never saw them.

Point 2: in the kernel's backlog queue.

$ cat /proc/net/softnet_stat | head -2
0102a4c1 00000000 00028f41 ...
└─ col 1 ─┘└─ col 2 ─┘└─ col 3 ─┘
Column Meaning CPU0 value
1 Packets processed 0x0102a4c1 = 16,950,977
2 Packets dropped because the backlog was full 0
3 time_squeeze: times NAPI exhausted its budget 0x00028f41 = 167,745

Column 2 is zero, so nothing was dropped in the backlog. But column 3 is the revealing one: 167,745 times the NET_RX softirq exhausted its time or packet budget and had to yield before draining the ring.

And there is the causal connection between the two points: a high time_squeeze is the cause of rx_missed_errors. The softirq cannot keep up, the ring is not drained, and the card drops packets.

Besides that, the second line of softnet_stat (CPU1) shows 18,962 packets processed against 16.9 million on CPU0: all the processing is on one core, the same problem as in exercise 1.

The difference between the two points:

Loss at the card Loss in the backlog
Where The hardware FIFO The kernel's queue in RAM
Cause No free descriptors The softirq does not process in time
Seen in ethtool -S softnet_stat col. 2
Fixed with A bigger ring, more queues More CPU, RPS, a bigger backlog

The socket queue (ss) shows Recv-Q: 0, so the ingestor is reading in time: there is no third loss point. The problem is upstream of the application.

2. The loss percentage.

Packets received:  68,420,112
Packets lost:         284,102 (rx_missed_errors)
Total offered:     68,704,214

Loss = 284,102 / 68,704,214 = 0.4135 %

In daily readings:

At 800 readings/s: 800 × 86,400 = 69,120,000 readings/day
Lost: 69,120,000 × 0.004135 = 285,811 readings/day

285,811 readings lost per day, unrecoverable. Put in context:

Complete data for the day:  69,120,000 × 24 B = 1.66 GB
                            (note: far above the 17 MB reference figure,
                             which suggests the 8:00 peak is not
                             representative of the daily average)
Gaps: 1 in every 242 readings

0.41 % sounds small, but for hourly averages per station it means systematic gaps in the series, and in the worst case a particular station could lose entire runs if its traffic coincides with the moments of overflow.

3. A solution for each loss point.

For the loss at the card:

# a) Grow the descriptor ring from 256 to 2048
$ sudo ethtool -G enp3s0 rx 2048

# b) Enable multiple queues if the hardware allows it
$ sudo ethtool -l enp3s0
$ sudo ethtool -L enp3s0 combined 4

# c) Interrupt moderation: group into batches
$ sudo ethtool -C enp3s0 rx-usecs 50 rx-frames 32

Why each one works:

  • (a) Gives eight times more headroom to absorb bursts. With 2,048 descriptors at 800 packets/s, the system has 2.5 seconds of cushion instead of 0.3. It costs 4 MB of RAM.
  • (b) Spreads the work across four cores, attacking the root cause.
  • (c) Reduces interrupts by batching packets, leaving more CPU for the real processing.

For the time_squeeze:

# d) Raise NAPI's budget per cycle
$ sudo sysctl -w net.core.netdev_budget=600          # default 300
$ sudo sysctl -w net.core.netdev_budget_usecs=8000   # default 2000

# e) Grow the backlog queue
$ sudo sysctl -w net.core.netdev_max_backlog=5000    # default 1000

# f) Spread the processing with RPS if there is only one queue
$ echo e | sudo tee /sys/class/net/enp3s0/queues/rx-0/rps_cpus
  • (d) netdev_budget is how many packets the softirq processes before yielding. Raising it from 300 to 600 halves the time_squeeze count. The risk is that the softirq hogs more CPU, so it is best raised in moderation.
  • (f) Distributes the network stack processing to CPU1-3 even though the interrupt still lands on CPU0.

Making it persistent:

# /etc/sysctl.d/99-meteora-net.conf
net.core.netdev_budget = 600
net.core.netdev_budget_usecs = 8000
net.core.netdev_max_backlog = 5000
net.core.rmem_max = 16777216

# /etc/udev/rules.d/70-meteora-nic.rules
ACTION=="add", SUBSYSTEM=="net", NAME=="enp3s0", \
  RUN+="/sbin/ethtool -G enp3s0 rx 2048", \
  RUN+="/sbin/ethtool -C enp3s0 rx-usecs 50"

4. The configuration mistake in ps.

    PID  NI STAT COMMAND
   1842   0 Ssl  ingestor      ← nice 0
   1877  -5 Ssl  aggregator    ← nice -5

The priorities are inverted. The aggregator has a raised priority (−5) and the ingestor a normal one (0). It is exactly the opposite of what it should be, and it contradicts what we established in 02-02:

Process Type Cost of delaying it Correct nice Current nice
ingestor I/O, tiny bursts Irreversible: data lost −10 0
aggregator CPU, long bursts Recoverable: it just takes longer +10 −5

And this directly aggravates the loss problem. The full causal chain:

  1. The aggregator at nice −5 gets ~75 % of the CPU under contention (the weight table in 02-02).
  2. It is a CPU-bound process: when it runs, it holds the CPU for whole milliseconds.
  3. The NET_RX softirqs compete with it for CPU0.
  4. Delaying the softirq means not draining the ring → time_squeezerx_missed_errors.
  5. The badly prioritized aggregator is causing readings to be lost.

The correction:

$ sudo renice -n -10 -p 1842      # ingestor: high priority
$ sudo renice -n  10 -p 1877      # aggregator: low priority
$ sudo taskset -cp 2,3 1877       # and confine it away from CPU0

Made persistent in systemd:

# /etc/systemd/system/meteora-ingestor.service.d/priority.conf
[Service]
Nice=-10
CPUAffinity=0 1

# /etc/systemd/system/meteora-aggregator.service.d/priority.conf
[Service]
Nice=10
CPUAffinity=2 3

This ties the module's three pieces together: CPU priority (02-02), processor affinity (02-02) and interrupt affinity (02-07), coordinated so that the path of the readings — card → softirq → socket → ingestor — does not compete with the computation of averages at any point.

5. Monitoring that would have caught this earlier.

The process failure here is as serious as the technical one: the problem was spotted by the data team seeing gaps, not by the system. By the time somebody looks at the series, unrecoverable readings have been lost for days.

Metrics and thresholds:

Metric Source Warning threshold Critical threshold
rx_missed_errors ethtool -S > 0 (delta) > 100/min
rx_no_buffer_count ethtool -S > 0 (delta) > 100/min
time_squeeze softnet_stat col. 3 > 100/min > 1,000/min
Backlog drops softnet_stat col. 2 > 0 > 10/min
Recv-Q of socket 9010 ss -uln > 50 % of rmem_max > 90 %
%soft per CPU mpstat -P ALL > 30 % > 50 %
Readings written/min The day's file Deviation > 2 % from expected > 5 %

The key criterion: for rx_missed_errors the warning threshold is any non-zero increment, because each unit is a reading lost forever. It is not a performance metric, it is a data-loss metric.

Collection script:

#!/bin/bash
# /usr/local/bin/meteora-net-metrics.sh — run every minute
IFACE=enp3s0
STATE=/var/lib/meteora/.net-metrics

read_stat() { ethtool -S $IFACE | awk -v k="$1:" '$1==k {print $2}'; }

MISSED=$(read_stat rx_missed_errors)
NOBUF=$(read_stat rx_no_buffer_count)
SQUEEZE=$(awk 'NR==1 {print strtonum("0x" $3)}' /proc/net/softnet_stat)

if [ -f "$STATE" ]; then
    read -r PM PN PS < "$STATE"
    D_MISSED=$((MISSED - PM))
    D_NOBUF=$((NOBUF - PN))
    D_SQUEEZE=$((SQUEEZE - PS))

    if [ "$D_MISSED" -gt 0 ] || [ "$D_NOBUF" -gt 0 ]; then
        logger -p daemon.crit \
          "METEORA: READING LOSS missed=$D_MISSED nobuf=$D_NOBUF"
    fi
    if [ "$D_SQUEEZE" -gt 100 ]; then
        logger -p daemon.warning \
          "METEORA: softirq saturated, time_squeeze=$D_SQUEEZE/min"
    fi
fi

echo "$MISSED $NOBUF $SQUEEZE" > "$STATE"

The end-to-end check, the most valuable of all:

# Count the readings actually stored and compare them with the expected figure
EXPECTED=$((800 * 60))
ACTUAL=$(( ($(stat -c %s /var/lib/meteora/readings/$(date +%F).dat) - PREV_SIZE) / 24 ))
LOSS=$(echo "scale=3; (1 - $ACTUAL/$EXPECTED) * 100" | bc)

This last one is what really matters: it measures the outcome, not the intermediate indicators. There may be losses at a point you are not watching — the switch, the cable, the station itself — and only the end-to-end check catches them all.

They compare like this:

Type of metric Example Detects
Cause time_squeeze, %soft The problem before it causes loss
Symptom rx_missed_errors The loss at the moment it happens
Outcome Readings written/min Any loss, wherever it comes from

A decent monitoring system has all three: the cause metrics give you time to act, the symptom metrics pinpoint the exact place, and the outcome metric guarantees that nothing slips through. We will pick this approach up again in Performance Monitoring and Troubleshooting.

Conclusion

A device driver is the software that translates the kernel's generic operations into the concrete operations of one hardware model, and drivers are 63 % of the Linux kernel's code. They fulfil a contract — a structure of function pointers such as file_operations — that implements polymorphism in C: the same read() ends up in different code depending on the device's major number. They are loaded as modules whose alias connects the hardware's PCI identifier with the module's name, which is what makes automatic loading work. And they live in ring 0 with no safety net: the Fatal exception in interrupt trace we read does not end in a SIGSEGV, it ends in a kernel panic.

An interrupt arrives over an IRQ line, is translated into a vector, and the CPU jumps to the corresponding entry in the IDT, saving state and switching to the kernel stack just as in a system call. The decisive difference is that an interrupt belongs to no process: it runs on top of whoever happened to be there, which forbids sleeping, blocking, allocating memory with GFP_KERNEL or touching user space. Out of that prohibition comes the split into a top half — microseconds, acknowledge the hardware and schedule work — and a bottom half — softirq, tasklet or workqueue, with interrupts enabled. In the exercise we measured the difference: 1.1 µs against 54.1 µs of system blindness, a factor of 49.

NAPI solves the problem we left open in the previous lesson: it alternates between interrupts at low load and polling at high load, cutting the cost 65-fold at 500,000 packets/s. MSI-X does away with physical lines, with races against DMA and with the one-vector-per-device limit, allowing one interrupt queue per core — which is what makes nvme0q1 perfectly spread and enp3s0-rx-0 not. DMA works with descriptor rings that let the card write several packets without the CPU, requires cache coherence to be coordinated, and needs the IOMMU so that a compromised device cannot write to any physical address it likes. And /proc/interrupts tells this whole story line by line: who is interrupting, how much and on which core.

With this you close module 2, and the whole journey is worth looking at. You started by opening up the process abstraction (02-01): its memory image, the task_struct, the states with their real codes, fork with copy-on-write, execve, zombies and the cost of a context switch. Then you saw how the CPU is shared between them (02-02): bursts, conflicting criteria, the classic algorithms worked out by hand and CFS's fair virtual time with nice. Then the second great resource, memory (02-03): logical and physical addresses, the MMU, fragmentation with numbers and why paging wins; and its full development (02-04): translation, multilevel tables, the TLB, page faults, replacement, thrashing, mmap and the OOM killer. Then storage (02-05): the physics of the disk and the SSD, request scheduling and RAID. And finally devices (02-06): making them uniform, /dev, udev and the three ways of talking to the hardware, which this lesson has developed to the end.

The seven lessons answer the same question from different angles: how a scarce resource is shared among many that want it. CPU, memory, disk, devices. And in all of them the same pattern has appeared: an abstraction that hides the complexity, a hardware mechanism that enforces the rules, and an operating system policy that decides.

But we have taken something big for granted. Every time two processes shared a page with MAP_SHARED, every time a softirq and a process touched the same queue, every time two meteo-api workers wrote into /dev/shm/meteora-cache, we have said "this requires synchronization" and moved on. The time has come to pay that debt. What exactly happens when two flows of execution touch the same piece of data at once? Why can counter++ lose increments? And how do they coordinate without wrecking the performance that has cost so much to achieve?

That is Module 3: Concurrency, and it starts in Concurrency Concepts.

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