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, nofsck, no journal. - The extractor is disposable. The kernel's cpio unpacker is
__inittext 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.
| Option | What it is | Why you need it |
|---|---|---|
CONFIG_64BIT=n | Build a 32-bit kernel | Set for you by ARCH=i386. The emulator is a 32-bit machine. |
CONFIG_PRINTK | Enable support for printk | Without it the kernel boots in complete silence. You cannot debug what you cannot see. |
CONFIG_TTY | Enable TTY | Hidden unless CONFIG_EXPERT is on. Every console depends on it. |
CONFIG_SERIAL_8250 | 8250/16550 serial driver | Must be y, not m — the console option depends on it being built in. |
CONFIG_SERIAL_8250_CONSOLE | Console on a serial port | Lets console=ttyS0 work. Cheaper than a VGA text console and easy to capture. |
CONFIG_BLK_DEV_INITRD | initramfs/initrd support | The whole point. Without it your archive is ignored entirely. |
CONFIG_BINFMT_ELF | Kernel support for ELF binaries | Without it exec of busybox fails and PID 1 never starts. |
CONFIG_BINFMT_SCRIPT | Support for scripts starting with #! | Needed if /init is a shell script rather than a binary. It usually is. |
CONFIG_DEVTMPFS | A devtmpfs filesystem to mount at /dev | Its Kconfig help is explicit: with an initramfs, devtmpfs «always needs to be mounted manually». |
CONFIG_PROC_FS, CONFIG_SYSFS | The two pseudo-filesystems | Not strictly required to reach a shell, but almost every tool assumes them. |
CONFIG_KERNEL_GZIP | Compress the kernel with gzip | Replaces 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
tinyconfigchose 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
| Option | What it does | What it buys |
|---|---|---|
console=ttyS0 | Sends kernel and userspace output to the first serial port | Avoids the VGA text console: no scrolling through emulated video memory. |
quiet | Disables most log messages | Every 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 it | Up to 250 ms per CPU. Boot once without it, read the value from dmesg, hard-code it. |
tsc=reliable | Marks the TSC trustworthy, disabling runtime verification and boot-time stability checks | Removes a set of checks that mean nothing in a virtualised environment. |
noapic | Do not use any IOAPIC present in the system | Skips IOAPIC setup on a machine that is happy with the legacy PIC. |
no_timer_check | Disables the code that tests for a broken IO-APIC timer | One probe fewer, and one class of emulation quirk avoided. |
nosmp | Tells an SMP kernel to act as a uniprocessor kernel | No secondary CPUs to bring up. It disables the IO-APIC as a side effect. |
rdinit=/bin/sh | Runs this instead of /init from the ramdisk | The one that overrides an initramfs. init= applies only after switching to a real root. |
initcall_debug | Traces initcalls as they are executed | Costs 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.