4 — PID 1
Requirements: chapters 1 and 3, qemu-system-x86_64, and a kernel. There’s an ordering catch: this chapter boots a kernel, and chapter 5 builds yours. Either copy a readable distro kernel to /lo/out/bzImage, if it has the serial driver built in, or do chapter 5 first and come back.
When the kernel finishes booting, it executes a single program as process 1. Everything else, every daemon, every shell, every container, is a descendant of whatever that program decided to do. systemd is one answer to “what should PID 1 do?”, at about 2.06 million lines in its repository at the end of 2024 (Phoronix). This chapter writes a very small answer, not because systemd is wrong, but because you will never understand what an init is until you’ve been one.
What PID 1 must do
- Mount the basics:
/proc,/sys,/dev(devtmpfs). - Start something. With an initramfs, the kernel runs
/initfirst. Without one, it triesinit=, then/sbin/init,/etc/init,/bin/initand/bin/sh, and otherwise panics with “No working init found” (init/main.c). - Reap. When a process dies and its parent is gone, it gets a new parent: first another thread in its group, then a subreaper, then init (exit.c). If init doesn’t
wait(), these stay as zombies. This is the one job it can’t skip. - Handle signals. The kernel only delivers signals to PID 1 that it has handlers for (signal.c).
- Shut down cleanly, using
syncandreboot(2). - Never exit. If PID 1 exits, the kernel panics with “Attempted to kill init!”
The kernel protects PID 1 from signals, but not from itself.
Hands-on: write and boot it
Step 1: write the init
Create /lo/out/init.c. Two details matter. reboot(RB_DISABLE_CAD) is needed because, by default, Ctrl-Alt-Del reboots at once rather than sending SIGINT (reboot(2)). And children must restore the signal mask, or they’d inherit it with everything blocked.
#define _GNU_SOURCE
#include <signal.h>
#include <stdio.h>
#include <sys/mount.h>
#include <sys/reboot.h>
#include <sys/wait.h>
#include <unistd.h>
int main(void) {
sigset_t all, old;
if (getpid() != 1) { fprintf(stderr, "not PID 1\n"); return 1; }
mount("proc", "/proc", "proc", 0, 0);
mount("devtmpfs", "/dev", "devtmpfs", 0, 0);
reboot(RB_DISABLE_CAD); /* CAD now sends SIGINT */
sigfillset(&all);
sigprocmask(SIG_BLOCK, &all, &old);
if (fork() == 0) { /* child restores the mask */
sigprocmask(SIG_SETMASK, &old, 0);
setsid();
execl("/bin/sh", "sh", (char *)0);
_exit(127);
}
for (;;) {
int sig;
sigwait(&all, &sig);
if (sig == SIGCHLD) {
int st; pid_t p;
while ((p = waitpid(-1, &st, WNOHANG)) > 0)
printf("reaped %d\n", (int)p);
} else if (sig == SIGTERM || sig == SIGINT) {
sync();
reboot(RB_POWER_OFF);
}
}
}
Step 2: build an initramfs
cd /lo/out
$TGT-gcc -static -Os init.c -o init
rm -rf ramfs && mkdir -p ramfs/bin ramfs/proc ramfs/dev ramfs/sys
cp init ramfs/init
cp /lo/rootfs/bin/busybox ramfs/bin/busybox
ln -s busybox ramfs/bin/sh
( cd ramfs && find . | cpio -o -H newc ) | gzip > initramfs.cpio.gz
Expected: gzip -dc initramfs.cpio.gz | cpio -it | grep -x init prints init.
Step 3: boot it
qemu-system-x86_64 -m 512M -nographic -no-reboot \
-kernel /lo/out/bzImage -initrd /lo/out/initramfs.cpio.gz \
-append "console=ttyS0 panic=-1"
Expected: a shell prompt appears. Exit QEMU with Ctrl-A then X.
Step 4: record the first number
Note how long boot-to-shell took. For a cheap first measurement, add a printf using CLOCK_BOOTTIME to your init. Bpftrace inside the guest comes later and would disturb the boot anyway.
Step 5: prove reaping works
Inside the guest, make a real orphan:
(/bin/busybox sleep 1 &) ; /bin/busybox sleep 3
The subshell exits at once, so the first sleep is reparented to init. Expected: a reaped line should appear about a second later.
Step 6: make a zombie on purpose
- Comment out the
waitpidloop, then rebuild and boot. - Repeat Step 5, then run
/bin/busybox ps. - Expected: the orphan stays in state
Z, forever. This is the one job PID 1 can’t delegate. - Restore the loop.
Step 7: try to kill init
kill -9 1
kill -TERM 1
Expected: the first does nothing. The second powers off, because your program waits for it.
Step 8: let init exit
- Change the loop to
return 0;, then rebuild and boot. - Expected: the console shows “Attempted to kill init!” with an exit code.
- Restore the loop.
Trying it without a VM
A chroot won’t work, because it doesn’t change your PID, and this init refuses to run unless it’s PID 1. Use a PID namespace:
sudo unshare -pf --mount-proc ./init
It differs in that reboot and signals behave differently, and exiting kills only the namespace (pid_namespaces(7)).
If it fails
| What you see | Cause | Fix |
|---|---|---|
| Blank console | No console=ttyS0, or no serial driver | Add it, or use a kernel with SERIAL_8250_CONSOLE=y |
| “No working init found” | /init missing or not executable | Run the cpio -it check |
Failed to execute /init (error -2) | Dynamic binary with no interpreter | Rebuild with -static |
| “unable to open an initial console” | No /dev/console before init runs | Add sudo mknod ramfs/dev/console c 5 1 |
| No job control in the shell | The shell is on /dev/console | Expected. Open /dev/ttyS0 as an extension |
Principle: the first process is the contract
PID 1 defines what “the system is up” means: which filesystems exist, which services run, what happens when they die, what happens on shutdown. Every process on the machine inherits those decisions. That is why init design is architecture, not plumbing, and why the init you choose determines the operational model of everything above it: supervision (does a dead service come back?), ordering (what can assume what?), and the failure story (what does “half-up” look like?).
The general form: in any system, the component that starts everything else is the one whose assumptions are inherited by all of them. The orchestrator, the bootstrap script, the first service in the dependency graph: decide those carefully, because they’re the decisions nobody downstream gets to revisit.