Read

Build a Linux that boots in under a second

A kernel from make tinyconfig, a static busybox and a five-line /init, packed into a cpio archive — plus the command-line flags that remove what is left of the boot time.

A Linux system does not need a distribution, a bootloader, a filesystem or a disk. It needs two files: a kernel, and an archive containing one executable called /init. The kernel unpacks the archive into memory, runs that program as PID 1, and considers its work finished. Everything else — udev, systemd, a package manager, /usr — is convention layered on top of that arrangement, and none of it is required to reach a shell.

This is an afternoon's work and it produces the fastest Linux you will ever start, because almost everything that makes a normal boot slow is something you are about to leave out. It is also the clearest way to find out which parts of a distribution are load-bearing, since you have to add each one back by hand before anything works.

The contract between kernel and userspace

The kernel's own documentation states the rule without ceremony: after extracting the cpio archive into rootfs, «the kernel checks to see if rootfs contains a file init, and if so it executes it as PID 1». If there is no /init, it falls through to the older behaviour — find a root partition, mount it, run some variant of /sbin/init — and if that also fails you get the panic everyone has seen at least once: No working init found.

There is one further rule, and it catches everybody the first time: /init must never return. Unlike the old initrd program, which did some setup and handed control back to the kernel, an initramfs /init is expected to run forever or to replace itself. Exit and the kernel panics with Attempted to kill init!. The fix is one word — exec — and it appears in the script below.

Why an initramfs is the fastest possible root

An initramfs is a gzipped cpio archive that the kernel unpacks into rootfs, an instance of ramfs (or tmpfs) that exists before any driver has loaded. Compare that with the ordinary path — probe the bus, find a controller, load a block driver, read a partition table, mount a filesystem, replay its journal — and the savings are structural rather than incremental.

  • No block device. Nothing enumerates IDE or SATA, nothing waits for a disk to announce itself, nothing times out on one that never will.
  • No filesystem driver and no mount. There is no root= to resolve, no UUID to look up, no fsck, no journal.
  • The extractor is disposable. The kernel's cpio unpacker is __init text and data, discarded once the boot is over. It costs nothing at runtime.
  • cpio, not tar. The format was chosen because it is simpler to parse; the kernel's extraction code is small enough to live inside the kernel without embarrassment.

A kernel with almost nothing in it

make tinyconfig is allnoconfig with KCONFIG_ALLCONFIG=kernel/configs/tiny-base.config — which contains exactly one line, CONFIG_EXPERT=y — followed by a merge of kernel/configs/tiny.config, which adds size optimisation, XZ compression, CONFIG_SLUB_TINY and dead-code elimination. The result does not boot. It has no console, no ELF loader and no initramfs support. Your job is to put back the smallest set of options that produces a working machine.

tar xf linux-6.12.tar.xz
cd linux-6.12

# start from nothing
make ARCH=i386 tinyconfig

# turn the handful of options below back on
make ARCH=i386 menuconfig

make ARCH=i386 -j"$(nproc)" bzImage
# -> arch/x86/boot/bzImage

ARCH=i386 already gives you a 32-bit kernel without touching anything: arch/x86/Kconfig declares config 64BIT with default "$(ARCH)" != "i386", so the symbol comes out unset. On a 64-bit host you will also need a 32-bit-capable toolchain, which every distribution packages under some variation of gcc-multilib.

OptionWhat it isWhy you need it
CONFIG_64BIT=nBuild a 32-bit kernelSet for you by ARCH=i386. The emulator is a 32-bit machine.
CONFIG_PRINTKEnable support for printkWithout it the kernel boots in complete silence. You cannot debug what you cannot see.
CONFIG_TTYEnable TTYHidden unless CONFIG_EXPERT is on. Every console depends on it.
CONFIG_SERIAL_82508250/16550 serial driverMust be y, not m — the console option depends on it being built in.
CONFIG_SERIAL_8250_CONSOLEConsole on a serial portLets console=ttyS0 work. Cheaper than a VGA text console and easy to capture.
CONFIG_BLK_DEV_INITRDinitramfs/initrd supportThe whole point. Without it your archive is ignored entirely.
CONFIG_BINFMT_ELFKernel support for ELF binariesWithout it exec of busybox fails and PID 1 never starts.
CONFIG_BINFMT_SCRIPTSupport for scripts starting with #!Needed if /init is a shell script rather than a binary. It usually is.
CONFIG_DEVTMPFSA devtmpfs filesystem to mount at /devIts Kconfig help is explicit: with an initramfs, devtmpfs «always needs to be mounted manually».
CONFIG_PROC_FS, CONFIG_SYSFSThe two pseudo-filesystemsNot strictly required to reach a shell, but almost every tool assumes them.
CONFIG_KERNEL_GZIPCompress the kernel with gzipReplaces the XZ that tinyconfig picked: a bigger file that decompresses far faster.

A userspace of exactly one program

Busybox is several hundred Unix utilities compiled into a single binary that decides what to do by looking at argv[0]. Build it static — CONFIG_STATIC=y, described in its own Kconfig as «Build static binary (no shared libs)» — and there is no dynamic loader to run, no libc to ship and no /lib directory to create. One file, a few hundred symlinks pointing at it, and your userspace is done.

tar xf busybox-1.37.0.tar.bz2
cd busybox-1.37.0

make defconfig
sed -i 's/# CONFIG_STATIC is not set/CONFIG_STATIC=y/' .config
make -j"$(nproc)"

# populates bin/ sbin/ usr/bin/ usr/sbin/ with symlinks to busybox
make CONFIG_PREFIX=../initramfs install

cd ../initramfs
mkdir -p proc sys dev
file bin/busybox
# ELF 32-bit LSB executable, Intel 80386, statically linked

The init script

Five lines. Mount the pseudo-filesystems the kernel will not mount for you, print something so you can see it worked, and replace yourself with a shell so PID 1 becomes interactive instead of exiting.

#!/bin/sh
mount -t proc     none /proc
mount -t sysfs    none /sys
mount -t devtmpfs none /dev
echo "userspace at $(cut -d' ' -f1 /proc/uptime)s"
exec /bin/sh

Save it as initramfs/init and chmod +x it — a non-executable /init is silently skipped, and the kernel then panics looking for a root device that is not there. The cut on /proc/uptime gives you the kernel's own measurement of how long it took to hand over, in seconds with two decimals, which is the only boot-time number worth trusting.

Packing the archive

cd initramfs
find . | cpio -H newc -o | gzip -9 > ../initramfs.cpio.gz

# check it: the first entry should be ".", then directories, then files
zcat ../initramfs.cpio.gz | cpio -itv | head

newc is the SVR4 portable format with ASCII headers, and it is the only format the kernel's extractor understands. Two traps, both documented in the kernel tree. First: do not add -depth to find, whatever the cpio man page advises — the kernel's extractor will not create a file inside a directory that does not exist yet, so directory entries have to come first, which is precisely what plain find gives you. Second: run find from inside the directory, as find ., so every path in the archive is relative. Absolute paths produce an archive that unpacks into the wrong place or not at all.

There is a second route if you would rather ship one file instead of two: point CONFIG_INITRAMFS_SOURCE at your directory and the archive is built into the bzImage at compile time, using the kernel's own usr/gen_init_cpio rather than the cpio on your machine. One artefact, nothing to attach separately, and no chance of the two drifting apart.

Where the boot time actually goes

Add initcall_debug to the command line, boot once, and read dmesg. The timestamps are an itemised bill. On a machine this small the total divides into four categories, in roughly this order of cost.

  • Decompressing the kernel. Megabytes of arithmetic before a single line of the kernel proper runs, and inside an emulator that is arithmetic with no I/O to hide behind. This is where the XZ that tinyconfig chose costs you.
  • Delay loop calibration. The kernel times a busy loop against the timer to compute loops_per_jiffy. The documentation puts the cost at up to 250 ms per CPU, and it is pure waste on a machine whose timing is fictional anyway.
  • Clocksource verification. The kernel watches the TSC against another clock to decide whether it can be trusted. Inside an emulator the question is meaningless and the checking is not free.
  • Probing and waiting. Enumerating buses, poking config space, and timing out on hardware that will never answer. Every one of those port accesses crosses into the emulator, and the cure is not a command-line flag — it is not compiling the driver in the first place.

The kernel command line

OptionWhat it doesWhat it buys
console=ttyS0Sends kernel and userspace output to the first serial portAvoids the VGA text console: no scrolling through emulated video memory.
quietDisables most log messagesEvery printk to a console is I/O. On a serial line the log itself is measurable.
lpj=<n>Presets loops_per_jiffy instead of measuring itUp to 250 ms per CPU. Boot once without it, read the value from dmesg, hard-code it.
tsc=reliableMarks the TSC trustworthy, disabling runtime verification and boot-time stability checksRemoves a set of checks that mean nothing in a virtualised environment.
noapicDo not use any IOAPIC present in the systemSkips IOAPIC setup on a machine that is happy with the legacy PIC.
no_timer_checkDisables the code that tests for a broken IO-APIC timerOne probe fewer, and one class of emulation quirk avoided.
nosmpTells an SMP kernel to act as a uniprocessor kernelNo secondary CPUs to bring up. It disables the IO-APIC as a side effect.
rdinit=/bin/shRuns this instead of /init from the ramdiskThe one that overrides an initramfs. init= applies only after switching to a real root.
initcall_debugTraces initcalls as they are executedCosts time, buys you the list of what is costing time. Remove it afterwards.

The Buildroot machine in this site's catalog boots with tsc=reliable mitigations=off random.trust_cpu=on. The last two are worth knowing: mitigations=off skips the speculative-execution workarounds, which protect against attacks that an interpreted CPU cannot perform, and random.trust_cpu=on lets the kernel credit the hardware random instruction so it does not sit waiting for the entropy pool to fill — a wait that on a machine with no disks and no interrupts can be a very long one.

Buildroot, once you are past the exercise

Do the above once, by hand, because it is the only way to learn which pieces are load-bearing. Then stop doing it by hand. Buildroot builds a cross-toolchain, a kernel and a busybox root filesystem from a single .config, reproducibly, and it has already made every mistake you are about to make.

make qemu_x86_defconfig
make menuconfig
#   Filesystem images -> cpio the root filesystem  (BR2_TARGET_ROOTFS_CPIO=y)
#   Compression method -> gzip                     (BR2_TARGET_ROOTFS_CPIO_GZIP=y)
make

ls output/images/
# bzImage  rootfs.cpio.gz

The catalog here carries the results of exactly this approach. Buildroot is a 5 MB bzImage with an editable command line and its console on the serial port. Tiny Core is the same idea grown into a distribution with a package system. ELKS is the same idea taken in the other direction, down to 16-bit 8086 hardware. If you want to see where each of them sits, the distribution guide lines them up side by side.

Booting the result here

Two routes. On /new you attach bzImage in the kernel slot and initramfs.cpio.gz in the initrd slot, type a command line, and start the machine — no bootloader involved, because the emulator loads the kernel the way a bootloader would and jumps straight to it (what a bootloader normally does). Or you write the machine down as a spec on /json, which is the same thing in a form you can keep in a file:

{
  "name": "tiny",
  "memoryMB": 64,
  "vgaMemoryMB": 4,
  "fastboot": true,
  "cmdline": "console=ttyS0 quiet tsc=reliable nosmp noapic",
  "media": [
    { "kind": "bzimage", "url": "https://example.org/bzImage" },
    { "kind": "initrd",  "url": "https://example.org/initramfs.cpio.gz" }
  ]
}

One honest warning about hosting the files yourself: the browser fetches them, not a server, so whatever serves them must send Access-Control-Allow-Origin. Without it the request fails before the machine has started, and the error you see will be a CORS message rather than anything to do with Linux. A file you attach directly in the browser never leaves your machine and never has this problem.

What you end up with is about four megabytes that reaches a shell faster than a normal system finishes decompressing its kernel, and a fairly precise answer to the question of what Linux actually is. The glossary has one-line definitions for the pieces — bzImage, initramfs, cpio, PID 1 — if any of them are still doing more work than you would like.