A Minimal Root Filesystem
A static BusyBox, a directory skeleton, and an /init packed into an initramfs — early user space made concrete instead of magical.
A kernel that boots with no root filesystem panics — it has a CPU, memory, and a scheduler, and
nothing to run. Every distribution hides how thin the contract actually is behind dracut, systemd,
and hundreds of megabytes of tooling, so it's easy to end up believing early user space is inherently
complicated. It isn't. Building the smallest possible userland by hand — one static binary, a handful
of empty directories, and a ten-line shell script — takes about twenty minutes and permanently removes
the magic: /init is just a program the kernel happens to run first, and PID 1 is just whichever
process that program becomes.
What the kernel needs
The contract is small enough to state in full. An initramfs is a cpio archive, optionally
gzip-compressed, that the kernel unpacks directly into an in-memory tmpfs mounted as / — before any
real block device, filesystem driver, or disk has been touched. Once unpacking finishes, the kernel
looks for one file, /init, and executes it as PID 1. init/initramfs.c:unpack_to_rootfs()
is the function that does the unpacking; init/main.c:kernel_init() is what runs
afterward and, through init/main.c:run_init_process(), execs whatever /init
turns out to be. That's the entire contract — no filesystem type detection, no hardware probing, no
service manager. Everything past this point is userland choosing to do more with it.
BusyBox, statically linked
A normal Linux binary is dynamically linked: it lists the shared libraries it needs, and the kernel's
ELF loader hands off to a dynamic linker (ld.so) to find and map them before main() runs. That
dynamic linker has to exist somewhere the new root filesystem can find it — which means installing a
working libc into an initramfs that currently has nothing. BusyBox built statically sidesteps the
entire problem: one self-contained ELF binary, every applet (sh, ls, mount, mkdir, cat, and
dozens more) linked directly into it, nothing to install, nothing for the loader to fail to find.
$ cd ~/kernel-lab
$ curl -LO https://busybox.net/downloads/busybox-1.36.1.tar.bz2
$ tar xf busybox-1.36.1.tar.bz2 && cd busybox-1.36.1
$ make defconfig
$ ./scripts/config --enable CONFIG_STATIC
$ make -j$(nproc)
$ make install CONFIG_PREFIX=~/kernel-lab/initramfs
make install populates ~/kernel-lab/initramfs/bin/busybox and symlinks every enabled applet
(~/kernel-lab/initramfs/bin/sh, .../ls, and so on) back to that one binary — BusyBox inspects
argv[0] at runtime to decide which applet it's being invoked as.
The directory skeleton
Nothing creates these for you — a bare initramfs is not a partial Linux install, it's an empty tmpfs
plus whatever you packed into the archive. The minimum skeleton a shell needs to do anything useful:
$ cd ~/kernel-lab/initramfs
$ mkdir -p bin sbin etc proc sys dev usr/bin usr/sbin
proc, sys, and dev matter for a specific reason: they must exist as empty directories in the
archive itself, because a mount point has to already exist before anything can be mounted onto it.
The kernel doesn't create mount points on demand — mount -t proc none /proc fails outright if /proc
isn't there first. bin, sbin, and the usr/ equivalents just need to exist for BusyBox's installed
symlinks to land somewhere.
Writing /init
/init is not a special file format or a kernel-recognized binary — it is any executable at that exact
path, and the simplest version is just a shell script that mounts the three pseudo-filesystems every
later lab in this section assumes are present, then hands control to an interactive shell:
#!/bin/sh
mount -t proc none /proc
mount -t sysfs none /sys
mount -t devtmpfs none /dev
echo "[initramfs] mounted proc, sysfs, devtmpfs — dropping to shell"
exec /bin/sh
Two details break this in ways that look nothing like their actual cause. First, /init must be
executable — chmod +x init — or the kernel's attempt to run it fails and produces a kernel panic
that reads like the kernel itself is broken, not like a file permission problem. Second, exec /bin/sh
at the end, not a plain /bin/sh: exec replaces PID 1 with the shell instead of spawning a child of
it, and PID 1 exiting is a fatal, unrecoverable event in Linux's process model — plain /bin/sh would
leave the script itself as PID 1, and the moment that script ends, so does the entire machine.
Packing it
$ cd ~/kernel-lab/initramfs
$ find . | cpio -H newc -o | gzip > ../initramfs.cpio.gz
Each piece does one job: find . lists every file and directory in the tree, in an order cpio can
walk; cpio -H newc -o reads that file list on stdin and writes a newc-format archive to stdout —
newc is the portable, kernel-supported cpio variant, as opposed to the older binary or POSIX ustar
formats the kernel's initramfs unpacker doesn't accept; and gzip compresses the result, which the
kernel decompresses transparently on unpack. The output — ~/kernel-lab/initramfs.cpio.gz — is the
exact artefact Booting Your Kernel in QEMU's -initrd flag takes by name.
What you have just built
This is not a simplified stand-in for what a real distribution does at boot — it is the same mechanism,
with the hardware-detection layer removed. dracut (Fedora/RHEL) and mkinitcpio (Arch) both produce a
cpio archive that a kernel unpacks into a tmpfs and executes /init from, exactly as above; the
difference is that their generated /init (often a small script that then hands off to systemd as
PID 1) spends most of its logic probing for the real root device, loading the right disk and filesystem
drivers, and only then switching over to it — none of which this lab's fixed, known VM needs. Folder
03's initramfs page covers that real-world version, including the root-device handoff this one skips
entirely.
From a cpio archive to a shell prompt, which is everything early user space does.
-
Build static BusyBox and stage it:
$ cd ~/kernel-lab$ curl -LO https://busybox.net/downloads/busybox-1.36.1.tar.bz2$ tar xf busybox-1.36.1.tar.bz2 && cd busybox-1.36.1$ make defconfig$ ./scripts/config --enable CONFIG_STATIC$ make -j$(nproc)$ make install CONFIG_PREFIX=~/kernel-lab/initramfs -
Build the directory skeleton:
$ cd ~/kernel-lab/initramfs$ mkdir -p proc sys dev -
Write
/init(the script from above) and make it executable:$ chmod +x init -
Pack it:
$ find . | cpio -H newc -o | gzip > ../initramfs.cpio.gz6474 blocksExpect a line like
NNNNN blockson stderr — the block countcpioreports for what it just wrote. The exact number depends on which BusyBox applets you enabled. -
Confirm the archive:
$ ls -lh ~/kernel-lab/initramfs.cpio.gz-rw-r--r-- 1 you you 1.8M ... initramfs.cpio.gzA few megabytes is normal for a
defconfig-built static BusyBox; an archive under a few hundred kilobytes usually meansCONFIG_STATICdidn't take and you packed a dynamically linked binary instead.
If it fails: a boot that reaches Run /init as init process and then immediately panics with
/init: not found, even though the file is clearly there, means BusyBox is dynamically linked — the
kernel can find /init, but /init itself can't find its linker, since this rootfs has none installed.
Rerun BusyBox's build with CONFIG_STATIC=y confirmed (./scripts/config --state CONFIG_STATIC) and
verify with file bin/busybox, which should say statically linked, not dynamically linked.
References
- Kernel documentation — ramfs, rootfs, and initramfs — the kernel's own explanation of the three layers, and the distinction ("rootfs is a ramfs mount that can never be unmounted") readers most often get wrong.
- BusyBox — downloads and applet list — what a single BusyBox binary can actually do; worth a skim before assuming an applet you want is (or isn't) included.
- Kernel documentation — early userspace support
— the
cpioformat contract the kernel's initramfs unpacker enforces, straight from the source.