Ashok G

Linux, Observed · Linux 7.3 · Draft

6 — Booting: firmware to first prompt

Requirements: chapters 3, 4 and 5, with EXT4_FS, VIRTIO_BLK, VIRTIO_PCI and DEVTMPFS confirmed in your config.

The hand-offs

A PC boot is a series of hand-offs:

  1. Firmware loads something. With CONFIG_EFI_STUB, the kernel poses as an EFI program, so firmware can run it directly (EFI stub).
  2. The kernel starts, and unpacks the initramfs, a cpio archive in newc format (format).
  3. /init in the initramfs finds the real root and switches to it.
  4. The real init starts everything else.

-kernel in QEMU skips firmware entirely. And dmesg starts when kernel timekeeping starts, so it can’t tell you how long firmware took.

Decision: an initramfs always, even when the root is simple. It’s the only place to put logic that must run before the root exists, such as verity, encryption or a network root.

A Unified Kernel Image bundles a stub, the kernel, an initramfs and a command line in one EFI file you can sign as a unit (UKI spec). ukify builds them (ukify(1)).

Version note: since Linux 7.0, a new empty filesystem called nullfs sits underneath rootfs, and the docs prefer pivot_root for leaving the initramfs (kernel docs). Older explanations of why pivot_root can’t work from an initramfs are out of date on 7.0 and later. This walkthrough uses switch_root, which still works everywhere.

Hands-on

Step 1: build a root disk

mkfs.ext4 -d fills the filesystem without needing root:

cd /lo/out
mkdir -p /lo/rootfs/proc /lo/rootfs/sys /lo/rootfs/dev /lo/rootfs/sbin
cp init /lo/rootfs/sbin/init
truncate -s 512M root.ext4
mkfs.ext4 -d /lo/rootfs root.ext4

Expected: dumpe2fs -h root.ext4 | grep -i 'filesystem state' reports clean.

Step 2: write the initramfs /init

rootwait won’t help here, because it only applies to the kernel’s own root mounting. In your own /init, you write the retry loop yourself, which is the better lesson:

cat > ramfs/init <<'E'
#!/bin/sh
export PATH=/bin
mount -t proc proc /proc
mount -t sysfs sys /sys
mount -t devtmpfs dev /dev
n=0
while [ ! -b /dev/vda ] && [ $n -lt 50 ]; do sleep 0.1; n=$((n+1)); done
if ! mount -o ro /dev/vda /mnt; then
    echo "root not found after $n tries; rescue shell"
    exec sh
fi
mount --move /dev /mnt/dev
umount /proc /sys
exec switch_root /mnt /sbin/init
E
chmod +x ramfs/init
mkdir -p ramfs/mnt
for a in mount umount sleep switch_root; do ln -sf busybox ramfs/bin/$a; done
( cd ramfs && find . | cpio -o -H newc ) | gzip > initramfs.cpio.gz

exec matters, because switch_root must run as PID 1. Devtmpfs creates the device nodes, so if you’re waiting, it’s for the device to appear, not for a missing node.

Step 3: boot it

qemu-system-x86_64 -m 512M -nographic -no-reboot \
  -kernel bzImage -initrd initramfs.cpio.gz \
  -drive file=root.ext4,format=raw,if=virtio \
  -append "console=ttyS0 panic=-1"

Expected: your chapter 4 init runs from disk, and cat /proc/mounts shows the virtio device as /.

Step 4: make it fail on purpose

Boot again without the -drive line.

Expected: after about five seconds, you get the rescue message and a shell, with no panic. That’s the failure path working.

Step 5: draw the timeline

Add a line to each stage that prints the time from /proc/uptime. Then draw three bars: kernel to /init, /init to real init, and real init to shell. These are your numbers.

Step 6: try the 7.0+ method

Replace switch_root following the three calls in the kernel docs:

chdir(new_root);
pivot_root(".", ".");
umount2(".", MNT_DETACH);

This only applies on Linux 7.0 and later.

If it fails

  • switch_root can’t exec: the init path in /mnt is wrong, or the binary isn’t static.
  • No /dev/vda: VIRTIO_BLK or VIRTIO_PCI is missing, or you left out if=virtio.
  • Panic right after /init exits: a script path fell through. Always end in exec.
  • switch_root prints usage: it isn’t PID 1, so you forgot exec.
  • sleep 0.1 fails: BusyBox was built without fractional sleep. Use sleep 1 and a smaller limit.

Principle: startup is a dependency graph, not a script

Boot looks like a sequence because that’s how it’s written down. It is actually a graph: root needs the block device, which needs the driver, which needs the bus probe; the console needs the tty driver; a network root needs the NIC and DHCP and a clock. Writing it as a script hides the edges, and hidden edges are where boot hangs live. systemd’s units, Kubernetes’ readiness probes and your deployment pipeline are all attempts to make the graph explicit.