Ashok G

Linux From the Metal Up

Part 5 — Principles and a learning path

5.1 The stack as a set of design principles

Separation of mechanism and policy. The kernel provides mechanisms, and userspace decides policy. cgroups give the knobs (memory.max) while systemd or Kubernetes decides the values. The kernel emits uevents and udev decides device names. The LSM framework provides hooks while SELinux or AppArmor policies decide what is allowed. sched_ext takes this as far as it goes: even scheduling policy can be a loadable BPF program. When you design a system, ask which part is mechanism and must be fast and general, and which is policy and must be changeable without redeploying the core.

Everything is a file, more precisely everything is a file descriptor. The VFS’s four objects let one API (open/read/write/poll/close) cover disks, pipes, sockets, devices, kernel state (/proc, /sys) and even events (eventfd, signalfd, timerfd, pidfd). The modern kernel extends this to handles: pidfd avoids PID-reuse races, the fd-based mount API, openat2(RESOLVE_*), and now fchroot(). File descriptors are unforgeable capabilities. That is why they are the direction of travel for safe sandboxing.

Layering and abstraction with explicit contracts. The stack is firmware → bootloader → kernel → libc → init → services. Each boundary has a written contract: UEFI spec, boot protocol (Documentation/arch/x86/boot.rst), syscall ABI and UAPI headers, ELF and psABI, D-Bus interfaces. Inside the kernel, operations tables (file_operations, net_device_ops) are interfaces in all but name. LFS works as a teaching tool precisely because it makes you cross every one of those boundaries by hand.

Least privilege, applied repeatedly. UID separation, then capabilities splitting root, then namespaces limiting visibility, then seccomp limiting syscalls, then LSMs limiting objects, then Landlock letting a process restrict itself without privilege, then lockdown restricting even root. Each layer exists because the one before it was too coarse.

The Unix philosophy. Small composable tools; text streams; one job per program. BusyBox and the quickstart in 4.1 are the extreme case. systemd is the counter-argument: integration sometimes beats composition, for example in dependency-ordered parallel boot, cgroup-based supervision and socket activation. Understanding both, and when each wins, is a system-design skill.

“We don’t break userspace.” Linus’s rule is that a kernel change which breaks a working userspace program is a regression and gets reverted, no matter how “correct” the change is. Syscall numbers, UAPI structures, /proc formats that programs parse, and behaviours that programs depend on are all frozen. The other half of the bargain is no stable internal API. In-tree code can be refactored freely: SLAB removal, folio conversion, the move to EEVDF. The lesson for API design is to stabilise the narrow external contract and keep everything behind it free to change. Hyrum’s law applies: anything observable becomes the contract.

Monolithic vs. microkernel. The tradeoff is real. Microkernels (seL4, QNX, Fuchsia’s Zircon) isolate drivers and services in user mode. That gives fault containment, a smaller TCB, and with seL4 even formal verification, at the price of IPC on hot paths. Linux chose shared-address-space performance and pays for it with every driver bug being a kernel bug. Then it claws back isolation piece by piece: modules signed and verified, BPF verified before it runs, drivers moving to Rust for memory safety, FUSE and userspace drivers (UIO/VFIO) where performance allows, and the hypervisor (KVM) as a coarse-grained isolation layer. That is the pragmatic answer: start monolithic for performance, then add verified extension points instead of rewriting.

5.2 A learning path and reading order

Stage 1: observe (1–2 weeks). Work through every command block in Part 2 on a VM you can break. Learn strace, perf, bpftrace and /proc fluently.

Stage 2: boot and build (1–2 weeks). Do the quickstart in 4.1. Build a kernel from tinyconfig until it boots. Write and load the module from 2.10.

Stage 3: read the source, in this order. Use Elixir (elixir.bootlin.com) for cross-referenced browsing. Click any identifier to see its definition and every reference, in any version.

  1. init/main.c: start_kernel(), rest_init(), kernel_init(). The kernel’s main.
  2. arch/x86/entry/entry_64.S and arch/x86/entry/syscall_64.c, then kernel/entry/common.c: the syscall boundary.
  3. fs/read_write.c (ksys_read, vfs_read), then fs/open.c, then fs/namei.c: follow one syscall all the way down.
  4. kernel/fork.c (kernel_clone, copy_process) and fs/exec.c (bprm_execve).
  5. kernel/sched/core.c (__schedule, try_to_wake_up), then kernel/sched/fair.c (pick_eevdf).
  6. mm/memory.c (handle_mm_fault), then mm/page_alloc.c and mm/slub.c.
  7. drivers/base/core.c and drivers/base/dd.c (really_probe), then one simple driver, e.g. drivers/net/dummy.c or drivers/char/mem.c.
  8. kernel/module/main.c (load_module).
  9. include/linux/sched.h (struct task_struct), include/linux/fs.h and include/linux/mm_types.h. Read these structures slowly.

Stage 4: QEMU + GDB kernel debugging.

# Kernel config for debugging
scripts/config -e DEBUG_INFO_DWARF5 -e GDB_SCRIPTS -e FRAME_POINTER -d RANDOMIZE_BASE
make olddefconfig && make -j"$(nproc)"

# Boot halted, with a GDB stub on :1234
qemu-system-x86_64 -m 1G -nographic -s -S \
  -kernel arch/x86/boot/bzImage -initrd ~/minilinux/initramfs.cpio.gz \
  -append "console=ttyS0 nokaslr rdinit=/init"

# In another terminal, from the build directory
echo "add-auto-load-safe-path $(pwd)" >> ~/.gdbinit
gdb vmlinux
(gdb) target remote :1234
(gdb) lx-symbols                 # load symbols for modules too (from scripts/gdb)
(gdb) hbreak start_kernel
(gdb) continue
(gdb) bt
(gdb) break do_sys_openat2
(gdb) continue                   # now run a command in the guest
(gdb) p *current                 # helper macros: lx-ps, lx-dmesg, lx-lsmod, $lx_current()

nokaslr keeps symbol addresses matching vmlinux. Use hardware breakpoints (hbreak) for early boot code that runs before the kernel’s page tables are final.

Stage 5: an ftrace walkthrough of a real path. Combine the function_graph recipe in 2.12 with set_graph_function=__x64_sys_openat and read the tree it produces next to the Elixir source. Then do the same for handle_mm_fault, a page fault, and __schedule, a context switch. Seeing the real call tree with timings makes the code far easier to follow than reading it cold.

Stage 6: LFS, using the kernel knowledge to understand why each step is there.

Stage 7: contribute. Run tools/testing/selftests, fix a checkpatch warning or a documentation error, review patches on a subsystem list, or reproduce a syzbot report.

5.3 How to keep understanding it: exercises

  1. Trace a byte. Run echo hi > /tmp/x and use ftrace or bpftrace to find every kernel function from write() to the folio becoming dirty. Then force writeback with sync and find the function that submits the bio.
  2. Break PID 1. Make your BusyBox /init exit instead of exec’ing a shell. Read the panic. Then fix it with exec.
  3. Shrink the kernel. Starting from tinyconfig, boot to a shell with the fewest options possible. Record each option you add and the error that forced it.
  4. Build a container by hand. Use unshare, cgroup v2 files (cpu.max, memory.max), pivot_root and a BusyBox rootfs. No Docker.
  5. Watch the scheduler. Run two CPU-bound loops at nice 0 and nice 10 under perf sched and compute the observed CPU share. Then compare it with the weight table (sched_prio_to_weight) in kernel/sched/core.c.
  6. Find the OOM decision. In a cgroup with memory.max=100M, allocate until killed. Read dmesg and memory.events, and trace oom_kill_process.
  7. Write a Landlock sandbox. Write a 50-line C program that restricts itself to read-only /usr and test what open() returns outside it.
  8. Decode a real oops. Add a NULL dereference to your hello module, load it in QEMU, and decode the trace with faddr2line.
  9. Follow a release. Read LWN’s merge-window summaries for 7.4 (Part 1 and Part 2) and pick one feature. Find its merge commit and read the patch series.
  10. Rebuild LFS with musl, or with Clang. Changing a single foundational component exposes every hidden assumption in the stack.