The kernel is just a program
It is easy, after Chapter 1, to think of the kernel as some abstract authority, a governing law of the machine. The Bridge at the close of Part I showed otherwise: the hardware supplies a contract (the privilege bit, the trap, the MMU, the timer, and MMIO with DMA), and on the kernel side of it is a program: instructions stored in memory and executed by the same CPU as everything else. The Linux kernel, as of 2025, is roughly 30 million lines of code, nearly all of it C. What makes it the kernel is not what it is made of but where it sits: in Ring 0, with hardware-enforced privileges no other program has.
When you press the power button, the CPU starts executing instructions from a fixed address in the motherboard's built-in firmware, historically called the BIOS and now usually UEFI. The firmware initialises hardware, finds the boot device, and loads a small program called a bootloader (GRUB on Linux, Windows Boot Manager, iBoot on Apple devices). The bootloader's only job is to find the kernel image on disk, load it into memory, and jump to its entry point. From that moment the kernel stays in memory, in charge of the machine, until it shuts down.
Monolithic vs microkernel: the great schism
Two philosophies of kernel design have been arguing since the late 1980s. A monolithic kernel puts everything in Ring 0: scheduling, memory management, filesystems, network code, and the device drivers that operate each piece of hardware. Its parts call each other directly, as functions in one program, so it is fast. The cost is safety: a bug in any part can corrupt the whole machine, and one flawed driver can hand it to an attacker. Linux and most kernels in production use are monolithic.
A microkernel puts only the absolute minimum in Ring 0, typically just message passing, basic memory protection, and minimal scheduling. Filesystems, drivers and network code run as separate programs in Ring 3 (the kernel maps just a driver's own device registers into it and passes on its device's interrupts as messages), which ask each other for work through messages the kernel carries. A bug in a driver then crashes only the driver. The cost is speed: every request between those parts crosses into the kernel and out again. Andrew Tanenbaum's MINIX is a microkernel; so is seL4, which is formally verified, meaning mathematically proven to have no kernel bugs of certain classes.
In 1992, Tanenbaum criticised the then-new Linux on a Usenet newsgroup, calling its monolithic design "obsolete" and "a giant step back into the 1970s." Linus Torvalds, its author, answered bluntly and at length. Tanenbaum was right that a microkernel contains a bug's damage; Torvalds, that a monolithic kernel was simpler and faster, and Linux ran on the machines people had. Neither design has displaced the other.
The thread itself: Tanenbaum's opening post of 29 January 1992 and the reply from Torvalds, then twenty-two. Below, where each design runs today: microkernels in safety-critical systems, Linux in most servers, phones and supercomputers.
How it works
- Tanenbaum's case. Theory: a microkernel keeps drivers and services out of the kernel, so one bug cannot take the machine down. He also predicted Linux, tied to the 386, would not last.
- Torvalds's case. Running code: a monolithic kernel is simpler and faster, and Linux was free and worked on the machines people had.
- The outcome. Microkernels such as seL4 (formally verified) and QNX are used where one kernel bug is unacceptable; Linux runs servers, phones and supercomputers.
macOS sits in the middle. Its kernel, XNU, is a hybrid: a Mach microkernel core and a BSD UNIX layer sharing Ring 0, so its parts can pass messages like a microkernel's and call each other directly. iOS uses the same kernel. Android uses Linux. Windows NT began microkernel-influenced and has drifted toward monolithic design for speed.
A monolithic kernel runs everything in Ring 0, so a file read crosses the boundary twice; a microkernel runs filesystems and drivers as separate programs, so the same read crosses eight times. The price buys safety: a driver bug takes down one process, not the machine.
How it works
- Monolithic. Scheduler, memory, filesystems, drivers and network stack share one address space in Ring 0 and call each other directly.
- Microkernel. Ring 0 keeps only message passing, basic memory protection and scheduling; everything else is a user process that sends its requests as messages through the kernel.
- The trade. Every crossing costs time, so microkernels are slower; a bug in any component outside Ring 0 cannot corrupt the kernel, so microkernels are safer.
Kernel modules: a third path
Linux softens its monolithic stance with loadable kernel modules (LKMs): drivers and filesystems compiled separately from the main kernel and loaded or unloaded at runtime with commands like insmod and rmmod. A machine can then load only the drivers its hardware needs, though a module still runs in Ring 0, where a buggy one can crash the kernel. Most graphics drivers, filesystem drivers, and hardware support on Linux ship as modules; lsmod on a running system lists hundreds of them.
The command insmod loads a separately built module into the running kernel, and rmmod takes it out again. The module cannot know where the kernel's functions sit, so each one it calls is looked up by name in the symbol table, which pairs names with addresses; then its init function runs inside Ring 0.
How it works
- Load. The kernel allocates memory for the module, copies its code in, and finds the address of every kernel function it calls in the symbol table, by name.
- Start. It calls the module's module_init(); a driver registers itself with the subsystem it serves.
- Still Ring 0. A loaded module has the kernel's full power, so a buggy one can crash the machine (Fig 4.2).
- Unload. rmmod calls module_exit(), frees the memory and forgets the module. lsmod lists what is loaded.
Linux also limits the damage risky code can do: a few kinds of driver run as ordinary programs in Ring 3, where a crash takes down only that program, and the small eBPF programs of Section 06 run in the kernel only after a check proves them safe.
The art of taking turns
A modern laptop runs hundreds of processes; a server may run thousands. Your machine has at most a few dozen CPU cores, so most processes cannot be running at any given instant. The kernel creates the illusion that they all are by switching between them many times per second, too fast for you to perceive the gaps. The component that decides which process gets the CPU next is the scheduler, one of the most-studied parts of any kernel.
Cooperative vs preemptive: who interrupts whom
Many early personal-computer systems were cooperative, the model the Bridge described: each running program yielded the CPU back to the scheduler when it chose, so one that never yielded froze the entire machine. Mac OS through version 9 worked this way, as did 16-bit Windows.
Modern systems are preemptive: the kernel takes the CPU back from a running process at regular intervals, ready or not. The mechanism is the hardware timer interrupt: the Bridge's timer in each core, which Linux sets to fire 100–1000 times a second. Each interrupt forces the CPU into Ring 0, where the scheduler runs and either resumes the current process or switches to a different one.
What "scheduling" actually has to decide
Picking a process to run sounds trivial, but each classical algorithm makes a different trade-off:
| Algorithm | How it works | Trade-off |
|---|---|---|
| FCFS | First-come, first-served. Run processes in arrival order until they finish. | Simple. Terrible response time: one long process blocks all the short ones (the "convoy effect"). |
| SJF | Shortest Job First. When the CPU is free, pick the waiting job with the least work and run it to the end. | Provably optimal for average wait when every job is ready at the start. Requires knowing job length in advance, which you usually don't. |
| Round Robin | Each process gets a fixed time slice ("quantum"). When it expires, move to the next. | Fair. Responsive. Doesn't optimise for total throughput. |
| Priority | Each process has a priority number. Higher priority runs first. | Lets the system favor critical work. Risk: low-priority processes can be starved indefinitely. |
| MLFQ | Multilevel Feedback Queue. Multiple priority levels; processes that use a lot of CPU drift to lower priorities, processes that wait often rise. | Approximates SJF without knowing job length, when short jobs mix with long ones. Windows and classic UNIX schedulers descend from it. |
| CFS | Completely Fair Scheduler. Linux's default 2007–2023. Tracks how much CPU each process has used; always runs whichever has used the least. | Approximates "everyone runs at exactly 1/N speed on N processes." A red-black tree makes each decision take one more step each time the number of processes doubles, written O(log N). |
Linux's CFS keeps every runnable process in a tree sorted by virtual runtime, a measure of how much CPU time the process has had, weighted by its priority: a high-priority process's virtual runtime grows more slowly, so it is picked more often. Smaller values sit to the left, and the leftmost process, furthest behind, runs next. After its turn it goes back in further right; one that stops to wait leaves the tree until it is ready. It is a red-black tree: each step down from the top rules out about half the processes still in play, so even thousands are reached in a dozen or so steps, and after each change the tree rearranges a few nodes so that no branch grows much longer than the rest. Every process thus tends toward its fair share. Since kernel 6.6 (2023), Linux's default is CFS's successor EEVDF (Earliest Eligible Virtual Deadline First), which keeps the same bookkeeping but gives every task a virtual deadline set by the slice it asks for, so a task that asks for short slices gets the CPU sooner without taking more than its share.
The leftmost, 3.2, has had the least CPU, so it runs until its virtual runtime is a little past the others, at 8.9. It goes back in after 8.6, as a new leaf; now 8.1 is the leftmost.
How it works
- Virtual runtime. CPU time received, weighted by priority. Whoever has had least is owed the CPU.
- The tree. A red-black tree, kept balanced, with the leftmost cached: removing it and inserting a process each take a few dozen steps at most, even with thousands runnable.
- Since 6.6. Linux's default is now EEVDF, which keeps the tree and the virtual runtime but picks the eligible task with the earliest virtual deadline, so tasks that ask for short slices are served sooner, though without the guarantee a real-time kernel gives.
The same four jobs under four schedulers. Shortest-job-first gives the lowest average wait; round robin and MLFQ give up average wait to start every job almost at once, which is what an interactive machine needs.
How it works
- The jobs. "A 0+8" means A arrives at time 0 and needs 8 units of CPU. A job's wait is all the time it is ready but not running, gaps between its turns included; "first run after" counts only up to its first turn.
- FCFS. First come, first served: A's eight units hold up everyone behind it, the convoy effect.
- SJF. Shortest job first: the lowest average wait of the four here, and provably optimal when every job is ready at the start, but it needs to know job lengths in advance.
- Round robin. Each job runs for a quantum of 2, then goes to the back of the queue: responsive, at the cost of many switches.
- MLFQ. Multi-level feedback queue: a job that uses up its whole slice drops to a lower queue with a longer slice, so short jobs stay on top. Here every job uses up its slices, so none stays on top and MLFQ behaves much like round robin.
The mathematics underlying scheduling is queueing theory, developed in the early 20th century by Agner Krarup Erlang for telephone networks. Its best-known result, proved by John Little in 1961, is Little's Law:
L = λ · W
The average number of requests in the system (L), waiting or being served, equals their arrival rate (λ) times the average time each spends there (W): 10 requests a second, each inside for 2 seconds, keeps 20 inside on average. It holds for any stable queue, one that keeps up with its arrivals, whatever their pattern. W depends on spare capacity: a server finishes μ requests a second, and only the difference μ − λ absorbs a burst of arrivals; as λ nears μ, each burst takes longer to clear than the quiet spell before the next, and backlog piles up. For the simplest queue, one server with random arrivals and random service times, W is exactly 1/(μ − λ). Divided by the service time, 1/μ, that becomes 1/(1 − ρ), where ρ = λ/μ is the utilisation, the share of capacity in use: 20 service times at 95%, 100 at 99%, five times as long for four points more load.
Little's Law ties a queue's length to its arrival rate and wait: L = λW, for any stable queue. As utilisation nears 100% the time in the system grows like 1/(1 − ρ): five times the bare service time at 80% load, twenty times at 95%.
How it works
- The law. Average number in the system = arrival rate × average time in it. It needs no assumption about how arrivals or service times are distributed.
- The curve. For the simplest queue (random arrivals and service times, one server, called M/M/1) the time in the system is the service time divided by 1 − ρ.
- The cliff. From 50% to 60% load the time in the system grows by a quarter; from 90% to 95% it doubles; at 100% (the dashed line) the queue never drains. That is why operators worry once a server stays above about 80%.
Real-time scheduling: when missing a deadline kills
General-purpose schedulers like CFS and EEVDF optimise for average performance and guarantee nothing about the worst case. That is fine for most software and catastrophic for an anti-lock braking system or a pacemaker. These systems run on real-time kernels: variants of Linux (PREEMPT_RT) or specialised OSes (VxWorks, QNX, FreeRTOS) that guarantee a task will run within a bounded time after it becomes ready, even under load. Liu and Layland analysed the two classic rules in 1973: rate-monotonic scheduling, in which the task that repeats most often gets the highest priority, and earliest-deadline-first.
A real-time task must finish each job inside its period: here every 10 ms. Three jobs fit with room to spare; the fourth runs 12 ms and is still going at its deadline, which in a brake controller is a failure, not a stutter.
How it works
- The guarantee. Rate-monotonic scheduling meets every deadline if total utilisation stays under about 69%; earliest-deadline-first, up to 100% (Liu and Layland, 1973).
- The overrun. A job that takes longer than its budget breaks the arithmetic, which is why real-time code is written to bounded, measured worst cases.
The lie of unlimited memory
Chapter 1 introduced virtual memory, the private address space every process believes it owns, and the Bridge showed the silicon that enforces it: the MMU walking the page tables, the TLB remembering its answers. The kernel's side is the tables themselves, which it builds and keeps current, and what happens when the MMU finds no usable entry and calls on the kernel.
The translation problem
Every load and store uses a virtual address, which the MMU of Fig BR.4 translates to a physical address through the page table the kernel builds and keeps current.
Memory is divided into fixed-size blocks called pages, typically 4 KiB: virtual pages in the address space and physical pages in RAM. The page table maps one to the other. When a virtual page has no physical page in RAM (it has never been used, was moved to disk to free RAM, or belongs to a file mapped into memory but not yet read), its entry is marked invalid, and the MMU raises a hardware exception called a page fault. The kernel's page-fault handler decides what to do.
Why the page table cannot be flat
A flat page table would hold one entry for every possible virtual page. On 64-bit x86 a virtual address has 48 usable bits, enough for 248 bytes (256 TiB); split into 4 KiB (212-byte) pages, that is 236 pages. At 8 bytes an entry, the table alone would need 512 GiB per process.
The solution, as the Bridge showed, is a multi-level page table: a tree of small tables, each one 4 KiB page of 512 entries. An address's last 12 bits are the page offset, the byte's place inside its page; the 36 bits above form four 9-bit indexes (29 = 512), and each picks an entry on one level that points to the table below, the last to the physical page. Most of a process's 256 TiB is unused, and an empty entry near the top means no tables exist beneath it, so a real process needs a few megabytes of tables, not 512 GiB.
The CPU splits a 48-bit address into four 9-bit indexes and a 12-bit offset, then follows CR3 down a tree of four tables, one index per level. Four table reads come before the data itself: five memory reads for one load, unless the TLB already holds the answer.
How it works
- Why 9 bits. A table is one 4 KiB page of 8-byte entries, so it holds 512 of them, and 29 = 512. The last 12 bits address a byte inside a 4 KiB page.
- The pointers. CR3 holds the physical address of the top table; each entry holds the physical address of the next table down, plus permission bits.
- Sparse by design. Tables exist only for regions the process uses, so a few megabytes of tables map a whole program. Newer CPUs can add a fifth level for 57-bit addresses.
The TLB: a cache for translations
Walking all four levels on every access would turn each load into five memory reads, so the Bridge's Translation Lookaside Buffer (TLB) answers first, and a hit skips the walk. The cost the kernel has to manage is the miss: up to four extra memory reads, after which the MMU stores the result in the TLB.
A switch to another process drops the old translations unless each entry is tagged with its address space (PCID on x86), and even tagged, the processes compete for the same few entries.
Page faults: the productive kind
A page fault sounds like an error, but most are not: they are how the kernel implements many of its most useful features. Faults fall into three rough categories:
A minor page fault happens when no disk read is needed: the data is already in RAM but this process's page table has no entry for it yet (a page of a shared library another program has already loaded, say), or the page has never been used and the kernel simply hands over a fresh page of zeros. The kernel just adds the entry and returns. Cost: microseconds.
A major page fault happens when the data is not in RAM and must be fetched from disk: the page was moved to disk to free RAM, or the program is first reading part of a mapped file. The kernel issues a disk read, suspends the process, and resumes it when the data arrives. Cost: milliseconds, roughly a thousand times slower than a minor fault.
An invalid page fault happens when the access truly is illegal: writing to a read-only page, using an unmapped address such as 0 (NULL), executing data marked non-executable. The kernel sends the offending process a SIGSEGV signal (Section 05 covers signals), which by default ends the program with the famous "segmentation fault" message.
Most page faults are not errors. The kernel asks two questions, and three accesses take the three exits: a cached page is mapped in microseconds, a first read of a mapped file waits milliseconds for the disk, and a write through NULL ends in SIGSEGV.
How it works
- Minor. The data is already in RAM (a page another process has loaded, the page cache, or a fresh zero-filled page), so the kernel only writes the page-table entry.
- Major. The kernel starts the disk read, runs other processes while it waits, then maps the page and resumes: about a thousand times slower than a minor fault.
- Invalid. The address is unmapped or the access breaks the mapping's permissions; unless the process catches SIGSEGV, it dies with "segmentation fault".
Two beautiful uses of page faults
Memory-mapped files. When you call mmap() on a file, the kernel doesn't read the file. It sets up page table entries marking the relevant virtual addresses as backed by that file, but invalid (not yet present). The first time you touch a page, you take a page fault, and the kernel reads just that one page from disk. Reading a 100 GB file as if it were one unbroken stretch of memory becomes trivial, because only the pages you touch are loaded. This is how some databases (LMDB) and search engines (Lucene) handle large data, and how every shared library loads on Linux.
Copy-on-write fork. When a UNIX process calls fork() to create a child process, the child receives a copy of the parent's entire address space, which done naively would mean duplicating gigabytes of memory. Instead the kernel marks all of the parent's pages read-only in the page tables, keeps its own note that they are really writable, and shares them with the child. Only when one of them tries to write does a page fault occur, and because of that note the kernel copies that one page instead of ending the process. Most pages are never written: a new program is launched by a fork followed at once by exec, which swaps in the new program's memory, so almost nothing is copied.
After fork() the two page tables point at the same pages, all read-only, and nothing is copied. When the child writes page B, the fault makes the kernel copy that one page and give the child its own; A and C stay shared.
How it works
- The trap. Marking shared pages read-only turns the first write into a page fault, so the kernel learns exactly which page needs copying, when.
- The parent's side. If the parent writes B later, the kernel sees that nobody else maps it any more and simply makes it writable again, without a copy.
- Fork, then exec. A child that calls
execve()straight away replaces its address space before writing anything, so launching a program copies almost no pages.
Dirty COW (CVE-2016-5195). For at least nine years a race in Linux's copy-on-write code let any user write to read-only files such as /etc/passwd, the machine's list of accounts, and so make any account the administrator. The attacker mapped the file privately, so writes should go to a private copy, then ran two loops at once: one wrote, the other kept telling the kernel to discard the copy. The writes went through /proc/self/mem, a file through which the kernel writes into a process's own memory for it, and a discard that fell between the copy and the write made it look the page up again and find the file's own page, so the write went into the file.
Dirty COW broke copy-on-write's one promise, that a write lands in a private copy and never in the file. It shipped in Linux for at least nine years, was patched within hours of being seen in use in October 2016, and a related flaw followed in the huge-page code a year later.
How it works
- The fix. The kernel now remembers that it made the private copy and allows the write only if the page-table entry is still that private copy, instead of retrying the look-up as a plain read.
- The class. A check, then an action that relies on it, with a moment between them in which something else can change: time-of-check to time-of-use, closed by making the two one step or checking again.
- Getting the patch out. A fix protects only machines that install it; distributions shipped it within days, Android in its December 2016 security bulletin.
A tree of names on a flat array of blocks
A disk, at its lowest level, is a long row of numbered, fixed-size blocks: a spinning hard drive presents itself to the OS as billions of 512-byte sectors, and an SSD presents the same picture over entirely different hardware; the filesystem groups them into blocks, typically 4 KiB. From these blocks the filesystem builds the structure you actually use: named files in directories nested into a tree, each file recording its owner, permissions and timestamps, none of which the disk itself knows about.
The inode: where a file actually lives
In UNIX-derived filesystems, the central data structure is the inode, short for "index node." Every file has exactly one inode, and the inode contains everything about the file except its name and its data:
Notice what's not there: the filename. In UNIX, a filename is a property
of the directory it lives in, not the file. A directory is itself a special kind
of file whose contents are a list of (name, inode-number) pairs. To open
/home/yki/notes.txt, the kernel looks up the inode for /,
reads its directory contents to find the entry for home, follows
that to the next directory's inode, and so on, until it reaches
notes.txt's inode. Then it uses the inode's block list to read the
actual data.
Because the name lives in the directory, a single file can have several names: multiple directory entries pointing to the same inode, called hard links. The file is only deleted when its link count drops to zero. For the same reason, renaming a huge file is instant: only the directory entry changes. The inode and its data don't move.
The name lives in the directory and the file lives in the inode: notes.txt and backup.txt hold the same inode number, so they are one file with two names. The inode lists the file's blocks, which can sit far apart on the disk.
How it works
- Lookup. Opening
/home/yki/notes.txtreads one directory per path component, from/down, until the last name gives the inode number. - Hard links. The link count says how many names point at the inode. Deleting a name lowers it; the data is freed only when it reaches zero.
- Runs of blocks. Modern filesystems (ext4, XFS, APFS) record runs such as 102–104 instead of every block number, and try to keep a file's blocks together.
The everything-is-a-file principle
Chapter 1's creed, everything is a file, rests on the inode idea: UNIX gave devices and the kernel's own state names in the same tree. Most kernel-managed resources are reached this way:
- Your keyboard is one of the
/dev/input/event*files. - Random numbers come from
/dev/urandom. - The state of every running process lives under
/proc/<pid>. - Hardware sensors expose temperature and voltage as files in
/sys.
None of these has data blocks on the disk: a read or write on one runs kernel code (a driver, for a device) that supplies or accepts the bytes. The same four system calls work on all of them: open, read, write, and close. This uniformity is one of the reasons UNIX-derived systems became so dominant: tools written for files work unchanged on devices and process state. The command that counts the lines of a text file, wc -l, counts them just as well in a sensor's readings under /sys or a process's records under /proc.
Journaling: the database technique that saved filesystems
A filesystem that simply writes data and metadata (the inodes, directories and
free-block lists that keep track of it) to disk wherever convenient has
a fatal weakness: power loss. If the machine crashes between writing data and
updating its corresponding metadata (or vice versa), the filesystem becomes
inconsistent: files exist whose blocks are still listed as free, or directory
entries point at non-existent inodes. The classic UNIX response was
fsck, a program that scans the entire disk after a crash to find
and fix inconsistencies. On a multi-terabyte disk this could take hours.
The modern solution, borrowed from database theory, is the journal, a small, dedicated region of the disk where every change to the filesystem's own bookkeeping (inodes, the free list, directories) is written first, then applied to the main filesystem. Each change in the journal ends with a commit record, written last; after a crash, the kernel replays the changes that have one and discards those that do not. By default ext4 writes file contents to their place before the commit, so a committed change never points at missing data. Each change lands whole or not at all, and recovery takes seconds instead of hours. ext4 (Linux), NTFS (Windows), and HFS+ (older macOS) all journal. The technique is called write-ahead logging; we'll meet it again in Chapter 13.
One change is committed and replayed after the power cut, taking the filesystem from version 1 to 2; the next loses power before its commit record and is discarded, so the filesystem stays at version 2.
How it works
- The atomic step. The commit record is one small block, written after the rest of the entry has reached the disk. Whether it exists decides the outcome.
- What is logged. By default ext4 and NTFS journal metadata only (inodes, the free list, directories); ext4's
data=journalmode also logs file contents, writing them twice.
The newest generation goes further: copy-on-write filesystems like ZFS, btrfs and (for its metadata) Apple's APFS never overwrite existing data. Their blocks hang in a tree from one root, each block holding the addresses of the blocks below it, so a change goes to new blocks, with a new parent for each (the parent holds the address that changed), up to a new root; one final pointer switch to that root makes it current, so a crash leaves either the old version or the new one whole. Keeping the old blocks makes snapshots, saved views of the whole filesystem at one moment, nearly free.
A copy-on-write filesystem never overwrites a block: changing 103 writes a new 103′, a new parent and a new root, and one pointer switch makes the change live. The old root still describes the old file, so a snapshot costs nothing until the versions diverge.
How it works
- Crash safety. Before the switch the old tree is whole; after it the new one is. A crash before the switch loses only the change, never the filesystem.
- Checksums. Every block can carry a checksum checked on each read, so silent disk corruption is caught. ZFS and btrfs cover data too; APFS covers metadata.
- The cost. One changed byte rewrites a block at every level up to the root: write amplification. NetApp's WAFL made this trade in the early 1990s; ZFS, btrfs and APFS make it now.
How processes talk
Processes are isolated by design: virtual memory and privilege separation exist so that one process cannot read or modify another's memory. But isolation taken to its extreme produces a useless system: programs that share nothing cannot pass each other data, or even say when a job is done. The kernel therefore exposes a controlled set of mechanisms for processes to communicate, collectively called inter-process communication, or IPC.
The kernel offers eight mechanisms:
| Mechanism | How it works | Best for |
|---|---|---|
| Pipe | A unidirectional byte stream between two related processes. The shell | operator creates one. | Chaining commands together. The classic UNIX pipeline. |
| Named pipe (FIFO) | A pipe with a filesystem name. Any process with permission can connect. | Letting unrelated processes communicate via a known path. |
| Signal | A small numeric notification sent to a process. SIGTERM, SIGKILL, SIGINT (Ctrl+C). | Asynchronous control: "stop," "reload config," "we're shutting down." |
| Shared memory | Two processes map the same physical pages into both their address spaces. | The fastest IPC. No copying. Used by databases and high-performance systems. |
| Message queue | A kernel-managed queue of typed messages between processes. | Structured communication; less common today. |
| Semaphore | A counter that processes change in one indivisible step, used to coordinate access to a shared resource. | Synchronising without sharing data: "is it my turn yet?" |
| UNIX socket | Like a network socket, but local: kernel-mediated, with proper authentication. | Modern desktop IPC. Used by Docker, systemd, X11, Wayland. |
| Network socket | TCP or UDP connection, possibly to another machine. | Distributed systems. We'll cover these in depth in Part III. |
The pipe: UNIX's most beautiful idea
The pipe reached UNIX in 1973: Doug McIlroy had been urging the idea for years, and Ken Thompson finally built it in an evening. A pipe is a kernel-allocated ring buffer, a fixed block of memory filled and emptied in a circle, with two file descriptors attached: one process writes to one end, another reads from the other. When the buffer is full the kernel makes the writer wait, and when it is empty the reader; the two programs share nothing but the stream of bytes.
cat writes at one pointer and grep reads at the other; the ring fills when grep falls behind and empties when it catches up, and the kernel makes whichever side is ahead wait.
How it works
- Two descriptors.
pipe()returns one file descriptor for each end; the shell connectscat's output to one andgrep's input to the other. - The size. A Linux pipe holds 64 KB by default, so a fast writer runs ahead of a slow reader by at most that much.
In the shell, a pipe is written as a single character:
# Count how many unique lines start with "ERROR" in a log: cat server.log | grep "^ERROR" | sort | uniq | wc -l # Five separate processes, each doing one small thing. # The shell creates pipes between them. Output of one # becomes input of the next, streamed byte-by-byte.
Each program knows nothing about the others: it reads its standard input and writes its standard output; these are two file descriptors every program starts with, normally the keyboard and the screen, and the shell points them at the ends of pipes. All five run at once, each streaming into the next as fast as both can go. That is one reason UNIX produced a culture of small composable tools rather than monolithic applications.
Five small programs joined by four pipes run at once, each reading standard input and writing standard output with no idea what is on the other end. Lines stream through as they arrive, except at sort, which must see the last line before it can print the first.
How it works
- The wiring. The shell calls
pipe()four times andfork()five, then points each program's standard output at the next one's standard input. - Streaming.
grepanduniqwork a line at a time;sortandwcmust reach the end of their input before they can answer.
"This is the Unix philosophy: write programs that do one thing and do it well. Write programs to work together. Write programs to handle text streams, because that is a universal interface."
Doug McIlroy, inventor of the UNIX pipeSignals: a doorbell to a process
Pipes carry data. Signals carry no data, only the
bare fact that something happened. A signal is a small, numbered notification
delivered asynchronously to a process: number 2 (SIGINT) is what the
kernel sends when you press Ctrl+C; number 9 (SIGKILL) is "stop now";
number 15 (SIGTERM) is the polite "please stop." A process can
handle most signals (it registers a function; on arrival the
kernel hijacks the process, runs the handler, then resumes), ignore
them, or take the default action. Two signals,
SIGKILL and SIGSTOP, can be neither handled nor ignored:
the first always kills, the second always suspends.
Ctrl+C makes the kernel mark SIGINT pending; the program runs on until its next return from the kernel (the end of its next system call or timer interrupt), where it is diverted into its handler and then resumed where it stopped. SIGKILL never reaches the program: at that same point the kernel ends the process instead, and no handler runs.
How it works
- Three reactions. A process can handle a signal with its own function, ignore it, or take the default action: terminate (sometimes with a core dump), stop, continue, or nothing.
- Uncatchable.
SIGKILLandSIGSTOPcan be neither handled nor ignored, so an administrator can always stop a process that handles or ignores every other signal. - Crude by design. A signal is just a number. A second signal of the same kind arriving while the first is still pending merges with it.
Shared memory and semaphores: the no-copy IPC
Pipes are convenient, but every byte through one is copied twice, from the writer's memory into the kernel's buffer and then out into the reader's memory. For two processes on one machine that exchange large amounts of data, shared memory is the fastest mechanism, because it skips both copies: the two call into the kernel to map the same physical pages into both their virtual address spaces. After that, the region is ordinary memory, and no access involves the kernel.
The price of sharing is coordination: if two processes each read a shared count of 5, add 1 and write back 6, one addition is lost. Semaphores prevent this. A semaphore is a counter changed atomically, in one step that nothing can interrupt. A process that wants in decrements the counter, or sleeps if it is 0; one that leaves increments it, waking a sleeper. Initialised to 1, a semaphore admits one process at a time, a mutex; initialised to N, it is a pool of N. The idea is Edsger Dijkstra's, set out in his 1965 note "Cooperating Sequential Processes".
Both processes map the same physical page at different virtual addresses, so what one stores the other can load, with no copy and no system call. A semaphore holding 1 lets one in at a time: A takes the 1, B finds 0 and sleeps, and A's sem_post() wakes it.
How it works
- The mapping.
shm_open()andmmap()put the same physical pages into both address spaces; after that the region is ordinary memory. - The fast path. On Linux,
sem_wait()andsem_post()use atomic instructions on a counter in shared memory; the kernel is called only when someone must sleep or be woken. - Counting. A semaphore started at N admits N at once, a pool rather than a lock. The idea is Dijkstra's, set out in 1965.
Sockets: when the other process is on another machine
A socket is a generalisation of the pipe: a connection either to another process on the same machine (a "UNIX domain socket") or to a process on a different machine entirely (a "network socket," using TCP or UDP). The same system calls apply: read, write, and close. The network drivers, the networking code in the kernel and the wire itself are all hidden beneath those few calls.
That is why a web browser, a database client, and a remote login (SSH) all use roughly the same code shape: open a socket, write a request, read a reply, close. Part III opens up what happens between write and read: ARPANET, packet switching, TCP, DNS, TLS, HTTP.
When the kernel breaks
Everything we've built in this chapter (the privilege boundary, the address-space isolation, the file permission checks, the IPC mediation) rests on one assumption: the kernel is correct. A bug in the kernel is therefore not an ordinary bug. A user-space program with a buffer overflow can be exploited to take over that program. A kernel with the same kind of bug can be exploited to take over everything: the kernel itself, every other process, every file, every keystroke.
Privilege escalation, more carefully
A privilege escalation attack is one where a process gains more privilege than it was supposed to have. The two important versions:
Vertical escalation: moving up the privilege ladder. A normal user process exploits a bug to become root (the administrator account), or root, on a system that walls even root off from the kernel (Secure Boot's lockdown mode, say), exploits a bug to enter Ring 0 (kernel mode).
Horizontal escalation: moving sideways at the same privilege level into another process's data. Reading another user's files, hijacking another user's session.
Kernel privilege-escalation bugs of the last decade include Dirty COW (the copy-on-write race condition mentioned earlier, 2016), DirtyPipe (a flaw in pipe-buffer initialisation that allowed writes to read-only files, 2022), and dozens of bugs in device drivers. Driver code is vast, often less audited than the core kernel, and runs in Ring 0. Most modern kernel exploits chain together multiple bugs: an information leak to find where the kernel sits in memory, defeating KASLR (the kernel's version of ASLR), then a memory-corruption bug to overwrite whatever grants privilege.
Containers: not virtual machines
Containers, the units Docker and Kubernetes run, are often described as "lightweight VMs," which is misleading. A virtual machine imitates an entire computer, kernel and all, on a hypervisor, a thin layer of software that lets one machine run several. A container is a normal Linux process that runs on the one shared kernel: a process the kernel has lied to, using two Linux features:
Namespaces partition kernel resources so that
different processes see different views. There are namespaces for process IDs
(a containerised process believes it is PID 1, even though to the host it might
be PID 8472), for the network (the container has its own virtual network interface),
for mounts (its own tree of files from / down, unpacked from a container image, not the host's), for user IDs, for hostnames, and more.
Inside its namespaces, the container looks like a complete isolated system.
Cgroups (control groups) limit how much of each resource a process group can use: CPU time, memory, disk I/O, the number of processes. Together with namespaces, this gives Docker its model: cheap, fast isolation that doesn't require a hypervisor.
A virtual machine runs its own guest kernel on a hypervisor; a container is an ordinary process whose system calls go to the one host kernel, fenced by namespaces (what it can see) and cgroups (what it can use).
How it works
- Hypervisors. KVM turns Linux into one; VMware ESXi, Xen and Hyper-V are others. CPU extensions (Intel VT-x, AMD-V) let guest kernels run directly on the processor.
- The fences. Namespaces and cgroups are ordinary kernel features; the next two figures show each.
- The middle ground. MicroVMs such as AWS Firecracker boot a minimal guest kernel in about 125 ms, giving each container its own kernel at nearly container cost.
Each VM has its own guest kernel, so a kernel bug exposes only that VM unless the hypervisor has a flaw too; containers share one, so a kernel privilege-escalation bug can let a malicious container take over the host and every container beside it. Cloud providers that run strangers' code side by side therefore use VMs, or containers each wrapped in a minimal VM (AWS's Firecracker); machines that run one owner's code use plain containers, which are far cheaper.
Ask the same five questions from inside container A, inside B and on the host, and one kernel gives three sets of answers: each container sees its own PID 1, hostname, network and root filesystem. A PID namespace is a translation table, not a copy: to the host, A's PID 1 is just process 8472.
How it works
- Eight kinds. Mount, UTS (hostname), IPC, PID, network, user, cgroup and, since Linux 5.6, time. Each can be set separately.
- Making one.
clone()with a flag such asCLONE_NEWPIDstarts a new process in a fresh namespace;unshare()moves the caller itself into one (for PID, only its next children). Children inherit the namespace. - The network. A container's network namespace gets its own virtual network card, joined by a virtual cable to the host, which passes its traffic on. Part III opens up networking.
Namespaces decide what a process can see; cgroups decide what it can use, with a CPU and a memory limit per group that the kernel enforces. A runaway batch job wanting 12 CPUs is held to its 4, and when it outgrows its 16 GB the kernel kills it, while web and db never notice.
How it works
- The interface. Cgroup v2 is a filesystem at
/sys/fs/cgroup. Writing400000 100000tocpu.maxallows 4 CPUs' worth of time in every 100 ms period. - Memory. Past
memory.maxthe kernel first reclaims the group's own pages; if that fails, the OOM killer picks a process inside the group. - Who writes them. Docker's
--cpusand--memory, Kubernetes resource limits and systemd's services all write these files;io.maxandpids.maxare further files, for disk and process counts.
eBPF: safe code inside the kernel
A more recent Linux feature is eBPF, "extended Berkeley Packet Filter." It allows user programs to upload small pieces of code into the running kernel, where they are attached to specific events (network packets, system calls, function entries) and run inside Ring 0.
The obvious worry is that this lets user code into the kernel, historically the worst possible security outcome. eBPF answers with a verifier: a checker in the kernel that reads every uploaded program and refuses to load it unless it can prove the program will always terminate, never read out-of-bounds memory, and never crash. Turing showed in Chapter 1 that no checker can decide whether every program ends, so the verifier settles for less: it accepts only programs simple enough to prove, such as loops with a known bound, and refuses everything else, even some programs that would have ended. Programs that pass are then translated into the CPU's own machine code and run essentially as fast as compiled kernel code. (Belt and braces: modern kernels also restrict who may load programs at all.)
Program A, a packet counter, is proven to end and stay in bounds, so it is compiled to native code and runs on every packet; program B, an endless loop, is refused and never runs.
How it works
- The toolchain.
clangcompiles restricted C to eBPF bytecode, and thebpf()system call hands it to the kernel. - The proof. The verifier walks every path through the program, tracking the range of every register. Loops pass only when it can bound them (allowed since Linux 5.3), and its effort is capped.
eBPF now underlies much of Linux's tracing (bpftrace), networking (Cilium) and security monitoring (Falco). The same pattern recurs elsewhere: untrusted code runs inside a privileged place under constraints the machine enforces. eBPF proves the code harmless before it runs; the browser of Chapter 12 fences a web page's JavaScript in while it runs.
The question every layer answers. What is less-trusted code allowed to do here, and what stops it? Every mechanism in this chapter (the kernel itself, the privilege boundary, virtual memory, filesystem permissions, container isolation, eBPF verification) is a different answer to that one question, and each restricts rather than trusts. Any one of them can have a bug, so the walls are layered: a container that takes over its kernel inside a Firecracker VM still has the VM's walls around it.
What you now understand
The kernel is a program that owns the hardware and mediates everything every other program does. Written in C and loaded by a bootloader, it stays resident in Ring 0. It comes in two main shapes: monolithic (everything in Ring 0, fast, fragile) and microkernel (minimal Ring 0, message-passing, safer and slower), with most real systems sitting somewhere on that spectrum. Its scheduler, anchored mathematically in queueing theory, decides which process gets the CPU next, hundreds of times a second. Linux's CFS, and EEVDF after it, does this in O(log N) steps using a red-black tree of virtual runtimes. Its virtual memory subsystem maintains a four-level page table per process, accelerated by a translation cache (TLB), and uses page faults productively to implement memory-mapped files and copy-on-write fork. Its filesystem turns a flat array of disk blocks into a tree of inodes and names, made crash-safe by journaling or copy-on-write. Its IPC mechanisms (pipes, signals, shared memory, sockets) are the controlled boundary across which isolated processes can still cooperate. And its security depends entirely on its own correctness, which is why kernel bugs are so dangerous, and why each new mechanism (containers, eBPF) is layered behind further verification.
With this, the kernel is in view from both sides. The Bridge at the close of Part I showed it as silicon: privilege rings as a CPU bit, the trap as one instruction into an entry point the kernel chose, the MMU as a hardware unit, the timer as the one circuit that makes preemption possible, MMIO and DMA as the kernel's line to the devices. This chapter showed it as code. Same object, two different perspectives, and the kernel becomes legible only when both are visible at once: thirty million lines of C running in Ring 0, sustained by a hardware contract that lets it own the machine.
The next chapter climbs out of the kernel and into the language it was written in. C (Ritchie at Bell Labs, 1972) is still the language of Linux and most production kernels. Pointers turn out to be the addresses we just walked through: the source of C's power, and of every memory bug we met in Chapter 3.