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
- What a device driver is
- The contract with the kernel: the file operations interface
- Driver registration and loadable modules
- Kernel space versus user space for drivers
- Interrupts: IRQ line, vector and vector table
- The interrupt controller: from the PIC to the APIC
- What the CPU does when an interrupt arrives
- Interrupt context and its rules
- Masking and shared interrupts
- Top half and bottom half
/proc/interruptsinterpreted line by line- MSI and MSI-X
- DMA in detail: descriptors, coherence and the IOMMU
- The complete path of a packet carrying readings
- Interrupt latency and its impact
- 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
.readpointer in itsfile_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 ofmemcpy(). It is mandatory and it is not a formality. Thebufpointer 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_uservalidates the range, handles the page fault if the page is not present, and returns the number of bytes it could not copy. A plainmemcpywould be a textbook privilege escalation vulnerability. The__userannotation in the prototype also lets static analysis tools catch the mistake.- Returning
-EIOand-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-1pluserrno. register_chrdevwith major 0 asks the kernel to assign a free number. Fixing a major by hand only works for the officially reserved numbers.module_initandmodule_exitdefine 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 simpleSIGSEGV.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/2is 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:
- 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.
- The minimum state is saved automatically: instruction pointer, stack pointer, flags register and segment selector. It is done by the hardware, not by software.
- It switches to the kernel stack, exactly as in a
syscall. The handler never uses the interrupted process's stack. - Interrupts are disabled if the IDT entry is an interrupt gate (the usual case). This stops another interrupt from nesting immediately.
- 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:
- Check whether the interrupt is its own before anything else, because the line may be shared. Returning
IRQ_NONElets the kernel try the next registered driver. - 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.
spin_lock_irqsaveinstead ofmutex_lock. A mutex sleeps if it is taken; a spinlock spins waiting, which is the only thing you can do in atomic context. Theirqsavevariant also saves the interrupt state and disables interrupts, avoiding a deadlock against yourself if the same interrupt were to fire again.- 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 89102Reading this output:
NET_RXis 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.TIMERis spread evenly, as it should be: every core has its own APIC local timer.BLOCKare 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 shootdownsThe 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.
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:
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:
- Scarcity: there are few lines, hence the shared ones.
- 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.
- 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=00000000Enable+ 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 TAILThis 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: 0rx_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
- Identify the main problem and justify it with at least three pieces of data from the two outputs.
- Why is
LOC3.6 times higher on CPU0 than on the others? - What does it tell you that
nvme0q1is spread out andenp3s0-rx-0is not? - Propose a concrete solution with the exact commands, and explain what each one would improve.
- 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:
- Which operations go in the top half and which in the bottom half? Justify each one.
- Which bottom-half mechanism would you use for each group: softirq, tasklet or workqueue? Why?
- Calculate the time spent with interrupts disabled in your design and compare it with doing everything in the top half.
- If the device generates 2,000 interrupts per second, calculate the CPU percentage in both designs.
- 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- Locate where the packets are being lost. There are at least two distinct loss points: identify them and explain how they differ.
- Calculate the loss percentage and what it means in readings lost per day.
- Propose a complete solution for each loss point, with concrete commands.
- There is an obvious configuration mistake in the
psoutput. Identify it and correct it, relating it to what we saw in 02-02. - 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.
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:
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:
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:
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 process2. 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 owntask_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:
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)
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:
| 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).
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 readings0.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_budgetis how many packets the softirq processes before yielding. Raising it from 300 to 600 halves thetime_squeezecount. 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.
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:
- The
aggregatoratnice −5gets ~75 % of the CPU under contention (the weight table in 02-02). - It is a CPU-bound process: when it runs, it holds the CPU for whole milliseconds.
- The NET_RX softirqs compete with it for CPU0.
- Delaying the softirq means not draining the ring →
time_squeeze→rx_missed_errors. - The badly prioritized
aggregatoris 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
- 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
