Before you start#
Read Navigate Git and the Linux source tree, ELF and the ABI, and CPU, memory and I/O. This lab builds an x86-64 kernel and static userspace on an x86-64 Linux host, then boots them with QEMU. It writes only to a new lab directory and uses no host disks or network device in the guest. Commands labeled host run in your normal terminal; commands labeled guest run only after the QEMU boot. The source-only route is to follow the source walkthrough and inspect the commands without executing them.
Verification status: the author inspected the v6.12 init code and verified the BusyBox archive digest, but did not complete this build or boot. Expected messages below are expectations, not captured logs. Package names and compiler behavior can differ by distribution; investigate failures before continuing.
Learning goals#
Build an explicitly configured kernel; assemble a minimal initramfs containing a static PID 1; boot with a serial console and TCG; and distinguish kernel, archive, interpreter and console failures. Record exact source and tool versions rather than assuming a reproducible binary from a tag alone.
What boots, and why#
QEMU emulates a machine. The host kernel runs QEMU as an ordinary process; the guest kernel and its userspace run inside the emulated machine. This lab supplies the kernel with -kernel and a compressed initramfs with -initrd. The initial root filesystem lives in guest memory, so there is no guest block device, persistent root disk, virtio driver requirement, or host filesystem mount. Exiting QEMU discards guest changes. This does not create a security boundary suitable for running untrusted code; use it for your own learning artifacts.
The Linux v6.12 tag is a historical source baseline, not a recommended production kernel. The x86_64_defconfig is a broad starting configuration rather than a universal minimum; explicit settings below request required features as built-ins. The later verification loop checks the resulting .config, the effective configuration used by Kbuild. A different compiler, architecture, configuration or QEMU version changes the result. BusyBox 1.36.1 is pinned independently and distributed under GPL-2.0-only; inspect its LICENSE file and corresponding source. Its static binary avoids needing a dynamic loader and shared libraries in the tiny root. It is not the kernel's init implementation: the educational /init script is userspace PID 1 until it replaces itself with the shell.
Source walkthrough#
- Host QEMUGuest kernel
load bzImage and external archive
- Guest kernelRootfs
unpack initramfs
- Guest kernelGuest init
execute /init
- Guest initRootfs
mount proc, sysfs and devtmpfs
- Guest initGuest init
exec shell as PID 1
Time runs downward. Dashed arrows show returns.
Read the text version
- Host QEMU → Guest kernel: load bzImage and external archive
- Guest kernel → Rootfs: unpack initramfs
- Guest kernel → Guest init: execute /init
- Guest init → Rootfs: mount proc, sysfs and devtmpfs
- Guest init → Guest init: exec shell as PID 1
In v6.12 init/initramfs.c, do_populate_rootfs includes (SPDX GPL-2.0; intervening logic omitted):
char *err = unpack_to_rootfs(__initramfs_start, __initramfs_size);
err = unpack_to_rootfs((char *)initrd_start, initrd_end - initrd_start);
The first call unpacks the built-in archive; when a supplied external initramfs is available and not suppressed by configuration, the second processes that supplied data. The surrounding function checks errors and configuration, so these lines do not mean every boot has an external archive. The archive supplies names, permissions and file data to rootfs. Its format and supported compression depend on configuration; see initramfs documentation.
Later, v6.12 init/main.c tries the early userspace command (GPL-2.0-only; surrounding branches omitted):
ret = run_init_process(ramdisk_execute_command);
if (!ret)
return 0;
ramdisk_execute_command normally names /init when usable. Successful execution replaces the kernel-created init task's userspace image; failure is logged and later fallback paths can be tried. The same source eventually panics if no usable init can be found. console_on_rootfs opens /dev/console for the initial standard descriptors, so a valid console device node matters before the script can print. A script also needs an executable interpreter. An executable bit alone does not create /bin/sh or a missing ELF dynamic loader. Read the init failure guide when diagnosing failures.
In v6.12 init/main.c, SPDX GPL-2.0-only, console_on_rootfs is defined as follows (its caller in kernel_init_freeable is omitted):
/* Open /dev/console, for stdin/stdout/stderr, this should never fail */
void __init console_on_rootfs(void)
{
struct file *file = filp_open("/dev/console", O_RDWR, 0);
if (IS_ERR(file)) {
pr_err("Warning: unable to open an initial console.\n");
return;
}
init_dup(file);
init_dup(file);
init_dup(file);
fput(file);
}
The function opens /dev/console and duplicates the descriptor to the three standard streams. If the open fails it logs a warning and returns, so the kernel can continue toward init with no standard descriptors. The excerpt does not show whether the console is serial or virtual, or whether any output reaches your terminal; the kernel command line and QEMU arguments decide that.
Practice#
Host preparation
These instructions assume Debian or Ubuntu on x86-64, a working network connection and permission to install packages through your normal administrator. Use a dedicated Bash session: commands that say exit 1 intentionally close that session on failure. Package installation is a host change; use an already prepared host if you do not want it. On Debian-family systems the following package names provide the tools:
sudo apt-get update
sudo apt-get install build-essential bc bison flex libssl-dev libelf-dev \
git curl ca-certificates bzip2 cpio gzip file qemu-system-x86
Check tools and resources on the host:
uname -m
for tool in gcc make bc bison flex git curl sha256sum cpio gzip file readelf qemu-system-x86_64; do command -v "$tool" || exit 1; done
gcc --version | head -1
make --version | head -1
qemu-system-x86_64 --version | head -1
df -h "$HOME"
Use an x86-64 host for these exact commands. Reserve at least 15 GiB free disk and 4 GiB RAM; 20–90 minutes or more and several gigabytes of downloads/build output are plausible depending on machine and network, not guarantees. Limit build parallelism if memory is constrained. Record versions and free space. If a tool is missing, install it or stop; do not continue with an unexamined substitute. sudo above is only for your package manager, never for the build, guest, archive creation or QEMU.
Build kernel and userspace on the host
Create a fresh lab directory with no spaces in its pathname; the initramfs manifest format below uses whitespace-separated fields. Preserve its path and never substitute an existing project directory.
LAB=$(mktemp -d "$HOME/linux-kernel-lab.XXXXXX") || exit 1
printf 'Lab directory: %s\n' "$LAB"
cd "$LAB" || exit 1
git clone --depth 1 --branch v6.12 https://github.com/torvalds/linux.git linux || exit 1
git -C linux rev-parse 'v6.12^{commit}'
git -C linux status --short
mkdir "$LAB/build"
make -C "$LAB/linux" O="$LAB/build" x86_64_defconfig || exit 1
"$LAB/linux/scripts/config" --file "$LAB/build/.config" \
-e BLK_DEV_INITRD -e RD_GZIP -e BINFMT_ELF -e BINFMT_SCRIPT \
-e DEVTMPFS -e DEVTMPFS_MOUNT -e PROC_FS -e SYSFS -e TMPFS \
-e TTY -e SERIAL_8250 -e SERIAL_8250_CONSOLE -e PRINTK
make -C "$LAB/linux" O="$LAB/build" olddefconfig || exit 1
for option in BLK_DEV_INITRD RD_GZIP BINFMT_ELF BINFMT_SCRIPT DEVTMPFS PROC_FS SYSFS TMPFS TTY SERIAL_8250 SERIAL_8250_CONSOLE PRINTK; do
grep -qx "CONFIG_${option}=y" "$LAB/build/.config" || { printf 'Missing built-in: %s\n' "$option"; exit 1; }
done
make -C "$LAB/linux" O="$LAB/build" -j4 bzImage || exit 1
Do not proceed after a failed command or missing configuration symbol. olddefconfig resolves dependencies and can change requested values; the loop verifies the resulting built-in settings, rather than assuming scripts/config succeeded. Save the full commit ID and .config. bzImage is the x86 boot image, not an initramfs. The -j4 limit can be reduced to -j1 if memory is tight. This is not a reproducible-build attestation: compiler, environment and timestamps also affect output.
Build the independently pinned BusyBox release. The hash below identifies the archive inspected for this lesson; compare it before extracting. This detects changed bytes relative to this published value but does not independently authenticate the value itself.
cd "$LAB" || exit 1
curl -fL https://busybox.net/downloads/busybox-1.36.1.tar.bz2 -o busybox-1.36.1.tar.bz2
printf '%s %s\n' b8cc24c9574d809e7279c3be349795c5d5ceb6fdf19ca709f80cde50e47de314 busybox-1.36.1.tar.bz2 | sha256sum -c - || exit 1
tar -xjf busybox-1.36.1.tar.bz2
make -C busybox-1.36.1 defconfig || exit 1
sed -i 's/^# CONFIG_STATIC is not set$/CONFIG_STATIC=y/' busybox-1.36.1/.config
sed -i 's/^CONFIG_TC=y$/# CONFIG_TC is not set/' busybox-1.36.1/.config
grep -qx 'CONFIG_STATIC=y' busybox-1.36.1/.config || exit 1
make -C busybox-1.36.1 -j4 || exit 1
file busybox-1.36.1/busybox || exit 1
readelf -l busybox-1.36.1/busybox > busybox.phdr || exit 1
if grep -q INTERP busybox.phdr; then printf 'Unexpected dynamic interpreter; stop.\n'; exit 1; fi
printf 'No ELF interpreter segment found.\n'
The file output should identify an x86-64 static executable; readelf should show no INTERP segment. If static linking fails, install the distribution's static C development libraries or use a separately pinned static toolchain; do not copy a dynamic host binary into the archive and assume it will work. BusyBox applets and library support can vary with its configuration. The unused tc applet is disabled because this release can fail to compile it against newer kernel headers; inspect any further compiler error instead of assuming the build completed. Preserve .config and LICENSE with your notes.
Assemble the archive
The kernel's gen_init_cpio accepts a whitespace-delimited manifest and encodes device nodes inside the archive without calling mknod on the host; inspect the v6.12 parser when adapting the format. Build the host utility and create the script and manifest. The single quotes around INIT preserve guest variables and commands for the guest.
cd "$LAB" || exit 1
make -C "$LAB/linux" O="$LAB/build" usr/gen_init_cpio || exit 1
set -o pipefail
cat > "$LAB/init" <<'INIT'
#!/bin/sh
mount -t devtmpfs devtmpfs /dev
mount -t proc proc /proc
mount -t sysfs sysfs /sys
mount -t tmpfs tmpfs /tmp
printf '\nGuest init: shell will be PID 1.\n'
exec /bin/sh
INIT
chmod 755 "$LAB/init"
cat > "$LAB/initramfs.list" <<EOF_LIST
dir /bin 0755 0 0
dir /dev 0755 0 0
dir /proc 0755 0 0
dir /sys 0755 0 0
dir /tmp 1777 0 0
nod /dev/console 0600 0 0 c 5 1
file /bin/busybox $LAB/busybox-1.36.1/busybox 0755 0 0
slink /bin/sh /bin/busybox 0777 0 0
slink /bin/mount /bin/busybox 0777 0 0
slink /bin/cat /bin/busybox 0777 0 0
slink /bin/uname /bin/busybox 0777 0 0
slink /bin/poweroff /bin/busybox 0777 0 0
file /init $LAB/init 0755 0 0
EOF_LIST
"$LAB/build/usr/gen_init_cpio" "$LAB/initramfs.list" | gzip -9 > "$LAB/initramfs.cpio.gz" || exit 1
gzip -t "$LAB/initramfs.cpio.gz"
gzip -dc "$LAB/initramfs.cpio.gz" | cpio -it
Check the list includes init, bin/sh, bin/busybox and dev/console; cpio's display may prefix names with . or omit leading slashes. The manifest supplies root ownership inside the archive; no host files become root-owned. A missing executable, wrong mode or malformed archive is a stop condition. The mount commands run only in the guest. devtmpfs supplies device nodes; proc and sysfs expose guest kernel information; tmpfs gives temporary writable space. Mount failure does not automatically stop this simple teaching script, so examine its output before trusting later observations.
Boot and inspect the guest
Run on the host, from the same shell that has LAB set:
qemu-system-x86_64 -machine pc,accel=tcg -m 512M -smp 2 \
-kernel "$LAB/build/arch/x86/boot/bzImage" \
-initrd "$LAB/initramfs.cpio.gz" \
-append 'console=ttyS0 rdinit=/init' \
-nographic -monitor none -serial stdio -no-reboot -net none
TCG works without KVM, though it is slower. The serial console is directed to your terminal; no host disk image, host network configuration or KVM device is used. The guest should print kernel boot messages, the Guest init marker and a shell prompt. Exact messages, timing and prompt depend on configuration and BusyBox. In the guest shell, run:
printf 'guest shell PID=%s\n' "$$"
uname -srmo
cat /proc/cmdline
cat /proc/1/status
The shell should report PID 1 because /init used exec, and /proc/cmdline should contain the supplied console and rdinit arguments. uname describes the guest, not the host. If proc was not mounted, its files fail to open; check the preceding mount diagnostics. Record the commit, configs, tool versions, QEMU command and observations. Do not call this a verified boot if you only read the instructions.
To stop, run poweroff -f inside the guest. If it hangs, identify and terminate the QEMU process from another host terminal. This invocation disables the monitor and connects serial directly, so it does not enable the usual Ctrl-a multiplexer escape. Do not run guest poweroff in the host shell. Leaving the PID 1 shell with exit can panic the guest; terminate QEMU and restart from the same clean archive. All guest filesystem changes disappear at shutdown.
Failure diagnosis and recovery
On a host with a newer GCC, a v6.12 build may fail before boot because warnings are treated as errors. In one private trial with GCC 16.2.1, an ACPI header produced -Werror=unterminated-string-initialization. Preserve the original build log and configuration first. If you choose to continue that isolated teaching build, "$LAB/linux/scripts/config" --file "$LAB/build/.config" -d WERROR followed by make -C "$LAB/linux" O="$LAB/build" olddefconfig and the chapter's bzImage build retries with warnings no longer globally promoted to errors. Record the changed configuration and all remaining warnings; this does not prove that any warning is harmless, or make an unsupported toolchain a production recommendation. A compatible, pinned toolchain is another option.
The same private GCC 16.2.1 trial encountered a second, distinct failure after CONFIG_WERROR was disabled: EFI-stub files were compiled in a C23 mode in which the v6.12 kernel's false enumeration constant and bool typedef were rejected. That retry stopped before bzImage or QEMU; disabling warnings-as-errors did not fix this compiler-language incompatibility. Use a compatible compiler/toolchain for this historical baseline and record its exact version. For example, Ubuntu 24.04 with its GCC 13 toolchain is a plausible historical build environment, but this particular combination has not been boot-tested here. Only if an appropriate gcc-13 is already installed, choose a fresh output directory and consistently pass the compiler for configuration and the build:
command -v gcc-13 || { printf 'gcc-13 is unavailable; choose a compatible installed toolchain.\n'; exit 1; }
gcc-13 --version | head -1
ALT_BUILD="$LAB/build-gcc13"
mkdir "$ALT_BUILD" || exit 1
cp "$LAB/build/.config" "$ALT_BUILD/.config"
make -C "$LAB/linux" O="$ALT_BUILD" CC=gcc-13 HOSTCC=gcc-13 olddefconfig || exit 1
make -C "$LAB/linux" O="$ALT_BUILD" CC=gcc-13 HOSTCC=gcc-13 -j4 bzImage || exit 1
make -C "$LAB/linux" O="$ALT_BUILD" CC=gcc-13 HOSTCC=gcc-13 usr/gen_init_cpio || exit 1
This is an untested alternative, not a promise that every GCC 13 installation or configuration will succeed. Verify the required built-in configuration options again in "$ALT_BUILD/.config"; use "$ALT_BUILD/usr/gen_init_cpio" to make the archive and "$ALT_BUILD/arch/x86/boot/bzImage" in QEMU if the build succeeds. Preserve the old .config and errors. If no suitable compiler is available, stop or use the source-only route rather than changing host packages without your administrator's approval.
A subsequent private direct-QEMU trial disabled both EFI and the EFI stub in the lab configuration, verified the effective EFI setting after olddefconfig, and kept CONFIG_WERROR disabled. The build still failed before producing bzImage: GCC 16 rejected false and bool in arch/x86/boot/compressed/string.o under C23 defaults. The failing EFI-stub verbose command from the earlier trial had no explicit -std flag, even though other kernel build flags specify GNU C11. This observation is specific to that toolchain and build path. Disabling EFI neither corrected the remaining compressed-boot compilation nor constitutes a general firmware or production fix. The private logs and configurations were retained; no QEMU boot or PID 1 observation resulted.
No output can mean the wrong bzImage, serial configuration or QEMU terminal setup; verify SERIAL_8250_CONSOLE=y, console=ttyS0, the command and terminal first. A long silent build or boot under TCG may simply need time, but preserve any log before terminating it. An early panic mentioning init may mean missing /init, a missing /bin/sh, a bad executable mode, wrong architecture or a missing dynamic interpreter. Inspect the archive listing and file/readelf output on the host. An exec-format error can indicate a mismatched architecture or invalid script interpreter. An existing dynamically linked executable can still fail with ENOENT when its PT_INTERP loader is absent. A decompression error suggests a corrupted archive or missing gzip support. A shell without proc output can indicate the mount failed, not that PID 1 is absent. Change one cause at a time and record the retry.
- Host tools
- A missing or mismatched compiler, make or bc stops the build before any image exists.
- Kernel image
- Configuration or toolchain errors, or a wrong bzImage, fail before QEMU can boot it.
- Archive
- A missing /init, a wrong mode or a corrupt archive can fail early in boot.
- Interpreter
- A missing /bin/sh or ELF loader can fail exec even when /init is executable.
- Console
- Wrong serial configuration or console arguments can leave no visible output.
To clean up, stop QEMU first. Inspect printf '%s\n' "$LAB" and ensure it is the fresh linux-kernel-lab.* directory you created and contains no retained work. Back up your notes and configs elsewhere if desired; then delete that directory with your file manager, or rm -rf -- "$LAB" only after that verification. Never substitute /, $HOME, a block device or another project. No guest image needs repair because no persistent guest disk was attached.
Knowledge check#
- Why does the archive include
/dev/consolebefore/initmounts devtmpfs? - Why can
/initexist and be executable yet fail to start? - Why does the guest shell become PID 1, and what happens if that shell exits?
- Which artifacts must accompany a boot report, and why is the tag alone insufficient?
Answers#
- The kernel opens the console on rootfs for initial standard streams before the script executes. The archive can provide the device node without creating one on the host; devtmpfs is mounted later by the script.
- Its shebang interpreter may be missing or non-executable, an ELF interpreter may be absent, or the executable may have the wrong architecture. Inspect both the script and its interpreter.
/initis the early userspace init task andexecreplaces its image without creating a new PID. If PID 1 exits, the guest can panic; this does not halt the host kernel.- Record resolved kernel commit, kernel and BusyBox configs, tool and QEMU versions, archive and boot commands, and actual output. Configuration and environment alter behavior even at one source tag.
Checkpoint#
The artifact is a reproducibility record plus the guest PID and command-line observations, or a source-only explanation of how the supplied archive reaches /init. Pass the runtime route only when the recorded boot reaches the guest shell and its PID and proc observations match; if it fails, preserve the error, use the diagnosis section and retry from the saved configuration. Source-only completion is not a claimed boot. Neither route certifies kernel expertise.
Sources#
- v6.12 init/initramfs.c: rootfs unpacking.
- v6.12 init/main.c: init execution and console.
- Initramfs documentation.
- Init failure guide.
- v6.12 gen_init_cpio source: manifest parsing and archive entry types.
- QEMU invocation documentation: machine accelerator and command options.
- BusyBox 1.36.1 release source.