🐧 Build Linux from Scratch Guide

A hands-on manual in small steps, each one booted and tested: a kernel with nothing to run, a first init written in C, a BusyBox shell, networking, bash, and finally a real disk with a boot loader. All of it runs in QEMU, so nothing can touch your real system.

Video: Build Linux from Scratch Guide (Build video)
Prefer to watch? This short video runs this page's main commands in a real lab, with on-screen captions and a synthetic voiceover (an AI voice trained on the author's own recording). Click to play, or watch it on YouTube.
make -j$(nproc) bzImage                 # the Linux kernel, built by you
./configure --enable-static-link && make   # bash, built by you
qemu-system-x86_64 -nographic -drive file=disk.img,if=virtio,format=raw   # your own Linux boots

A Linux system is a small number of parts: a kernel, one program that the kernel starts first (init), and the tools that you use after that. A distribution such as Ubuntu is a very large collection of those parts. This guide builds the small version yourself, one step at a time, and boots the result after every step, so that you see what each part adds.

You will download the latest stable kernel and check it, configure and build the smallest kernel that boots, watch it fail without an init, write an init in C, build BusyBox for a shell and about 400 tools (ls, grep, sed, awk, vi and more), add networking, build bash from the GNU source, and put the whole system on a real disk image with a partition, an ext4 file system and a boot loader.

The "hardware" is QEMU, and the build runs in a throwaway virtual machine, so nothing here can harm your workstation. Every example ran for real, as an ordinary user with sudo, on Ubuntu 24.04 (gcc 13.3, QEMU 8.2) with Linux 7.3.1, BusyBox 1.38.0 and bash 5.3.

What we are building, and the plan

Every Linux system, from a phone to a supercomputer, starts the same way: a boot loader loads the kernel, the kernel starts one program (called init), and that program starts everything else. A "distribution" such as Ubuntu or Rocky Linux is a very large collection of programs built around those two pieces.

In this guide you build the pieces yourself, one small step at a time, and you boot the result after every stage so you can see exactly what each piece adds. You need no earlier Linux-building experience: every command is shown with the output it really produced.

The finished system has:

  the latest stable Linux kernel (7.3.1 when this guide was written), configured and built by you
  BusyBox, one program that provides a shell and about 400 basic tools, among them ls, cp, grep, sed, awk, vi, ps, mount, ip and wget
  bash, built from the GNU source, as the login shell
  networking: an address by DHCP, DNS and web access
  a real disk image, with a partition, an ext4 file system and a boot loader, that boots like a small server

The "hardware" is QEMU, a program that pretends to be a computer, so nothing here can touch your real disks or your real system. The build itself runs in a throwaway Ubuntu 24.04 virtual machine, so even the compiler and the temporary disk images stay away from your workstation: when you have finished, delete the virtual machine and nothing is left behind.

The stages, in order:

  1. get the kernel source and check it
  2. configure and build the smallest kernel that boots
  3. boot it, and watch it fail because it has no init
  4. write a first init program in C and boot that
  5. build BusyBox and make a root file system for a shell
  6. use ls, grep, sed, awk and vi on your own system
  7. add networking support to the kernel, then networking to the system
  8. build bash and make it the login shell
  9. make a real disk with a boot loader, and boot from it

The lab: a throwaway virtual machine

You need a Linux machine to build on. We used a fresh Ubuntu 24.04 virtual machine with 6 CPUs, 10 GB of memory and a 40 GB disk. The build works with fewer CPUs, only slower, and 20 GB of disk is enough. Any other distribution works too if you install the same tools. Make the virtual machine disposable: it is much safer to compile, mount disk images and run experiments in a machine that you can delete afterwards.

All commands from here on run as an ordinary user with sudo. This is the machine:

$ grep PRETTY_NAME /etc/os-release
PRETTY_NAME="Ubuntu 24.04.5 LTS"
$ nproc
6
$ free -g | sed -n 2p
Mem:               9           0           8           0           0           9

Install the build tools. build-essential gives the C compiler and make. flex and bison are code generators that the kernel build needs, libelf-dev and libssl-dev are libraries that the kernel build uses, bc does arithmetic for the build scripts, and cpio and xz-utils pack and unpack archives. qemu-system-x86 is the hardware simulator. e2fsprogs, fdisk and extlinux (from the syslinux packages) make the disk image at the end.

$ sudo apt-get update -qq
$ sudo env DEBIAN_FRONTEND=noninteractive apt-get install -y -qq build-essential flex bison libelf-dev libssl-dev bc cpio xz-utils wget qemu-system-x86 e2fsprogs fdisk syslinux extlinux > /dev/null 2>&1
$ gcc --version | head -1
gcc (Ubuntu 13.3.0-6ubuntu2~24.04.1) 13.3.0
$ flex --version
flex 2.6.4
$ bison --version | head -1
bison (GNU Bison) 3.8.2
$ qemu-system-x86_64 --version | head -1
QEMU emulator version 8.2.2 (Debian 1:8.2.2+ds-0ubuntu1.18)

QEMU can use the processor's virtualization support (KVM) to run the guest at nearly full speed, but only for members of the kvm group. Add yourself to it. The new group applies to your next login, so open a new terminal or ssh session (or log out and in) before you go on:

$ sudo usermod -aG kvm ubuntu
$ id -nG | tr ' ' '\n' | grep kvm
kvm
$ ls -l /dev/kvm
crw-rw---- 1 root kvm 10, 232 Nov  2 13:11 /dev/kvm

If /dev/kvm does not exist, QEMU still works without -enable-kvm, but the boot is much slower. When the build machine is itself a virtual machine, nested virtualization has to be switched on in the host.

Get the kernel source and check it

The kernel is developed at kernel.org. The home page lists the current versions, and so does this command:

$ curl -s https://www.kernel.org/finger_banner | head -2
The latest stable version of the Linux kernel is:             7.3.1
The latest mainline version of the Linux kernel is:           7.4-rc1

The "stable" line is the one to use. This guide uses 7.3.1. If a newer stable version has been released when you read this, use that one and change the version number in every command; the steps do not change.

Make a working folder for everything, then download the source and the list of official checksums:

$ cd ~/os
$ wget -q https://cdn.kernel.org/pub/linux/kernel/v7.x/linux-7.3.1.tar.xz
$ wget -q https://cdn.kernel.org/pub/linux/kernel/v7.x/sha256sums.asc
$ ls -lh linux-7.3.1.tar.xz
-rw-rw-r-- 1 ubuntu ubuntu 155M Oct 30 12:40 linux-7.3.1.tar.xz

Check that the download is intact and is the file that kernel.org published. The checksum list contains one line per file, and sha256sum -c compares it with the file you have:

$ grep linux-7.3.1.tar.xz sha256sums.asc | sha256sum -c -
linux-7.3.1.tar.xz: OK

OK means the file matches. Unpack it (this takes a few seconds, and makes about 90,000 files) and look inside:

$ tar xf linux-7.3.1.tar.xz
$ cd ~/os/linux-7.3.1
$ ls | head -12
COPYING
CREDITS
Documentation
Kbuild
Kconfig
LICENSES
MAINTAINERS
Makefile
README
arch
block
certs
$ ls | wc -l
31

arch holds the code for each processor family (arch/x86 is what we build), drivers holds the device drivers, fs the file systems and net the network stack. You will not read any of it today, but this is where it lives.

Configure the smallest kernel that boots

The kernel has thousands of options: processor types, drivers, file systems, network protocols, debugging features. The file .config records the choice for each one. A distribution kernel switches on nearly everything, so that it runs on any machine, and that makes it large and slow to build. We build the opposite: start with everything off and switch on only what we need.

The command make tinyconfig writes a .config with the bare minimum:

$ make tinyconfig 2>&1 | tail -1
#
$ grep -c '=y' .config
365

That kernel would boot, but it could not do anything useful: it has no serial console to talk to us on, no way to run programs, no /proc. The script scripts/config switches options on (-e) or off (-d) in the .config file. Switch on the first group of options. The table below the commands says what each one is for:

$ for o in 64BIT PRINTK TTY SERIAL_8250 SERIAL_8250_CONSOLE BLK_DEV_INITRD RD_GZIP BINFMT_ELF BINFMT_SCRIPT PROC_FS SYSFS TMPFS DEVTMPFS DEVTMPFS_MOUNT UNIX SMP; do scripts/config -e $o; done
$ make olddefconfig 2>&1 | tail -1
#

  64BIT                       a 64-bit kernel, for 64-bit programs
  PRINTK                      lets the kernel print messages (without it a failed boot is silent)
  TTY, SERIAL_8250(_CONSOLE)  terminals, and the serial port that QEMU connects to your terminal
  BLK_DEV_INITRD, RD_GZIP     the initial RAM file system (initramfs): a gzip archive that the kernel unpacks into memory and uses as the root file system
  BINFMT_ELF, BINFMT_SCRIPT   the ability to run compiled programs (ELF files) and scripts that start with #!
  PROC_FS, SYSFS, TMPFS       the /proc and /sys virtual file systems, and RAM-based storage
  DEVTMPFS(_MOUNT)            the kernel creates the /dev device files by itself
  UNIX                        unix sockets, which many programs use
  SMP                         use all processors, not just one

make olddefconfig then fills in every option that the new choices depend on, with its default. Check that the choices are in the file:

$ for o in SERIAL_8250_CONSOLE BLK_DEV_INITRD BINFMT_ELF DEVTMPFS; do echo "$o=$(scripts/config -s $o)"; done
SERIAL_8250_CONSOLE=y
BLK_DEV_INITRD=y
BINFMT_ELF=y
DEVTMPFS=y
$ grep -c '=y' .config
563

Build the kernel

Now compile. make -j runs one compile job per processor core, and bzImage is the name of the compressed kernel file that a boot loader can load. The output is long, so show only the last lines. The time keyword reports how long it took:

$ time make -j$(nproc) bzImage 2>&1 | tail -3
  OBJCOPY arch/x86/boot/setup.bin
  BUILD   arch/x86/boot/bzImage
Kernel: arch/x86/boot/bzImage is ready  (#1)

real	1m44.691s
user	8m26.943s
sys	1m0.675s
$ ls -lh arch/x86/boot/bzImage
-rw-rw-r-- 1 ubuntu ubuntu 1.1M Nov  2 13:14 arch/x86/boot/bzImage
$ file arch/x86/boot/bzImage | cut -c1-120
arch/x86/boot/bzImage: Linux kernel x86 boot executable bzImage, version 7.3.1 (ubuntu@ubuntu-lab) #1 SMP PREEMPT Mon No

The whole Linux kernel, compiled and compressed into about one megabyte. The tinyconfig start makes this fast; a distribution kernel takes much longer and is many times larger.

First boot: a kernel with nothing to run

Boot the kernel in QEMU. These are the options:

  -nographic              no window: the guest's serial console is your terminal
  -enable-kvm -m 256      use hardware virtualization, and give the guest 256 MB of memory
  -kernel FILE            load the kernel straight from a file, so no boot loader is needed yet
  -append "..."           the kernel command line: console=ttyS0 makes the kernel print on the serial port, panic=-1 reboots immediately after a panic, and quiet hides most boot messages
  -no-reboot              quit QEMU instead of rebooting, so a panic ends the run

$ qemu-system-x86_64 -nographic -enable-kvm -m 256 -kernel linux-7.3.1/arch/x86/boot/bzImage -append "console=ttyS0 panic=-1" -no-reboot
Linux version 7.3.1 (ubuntu@ubuntu-lab) (gcc (Ubuntu 13.3.0-6ubuntu2~24.04.1) 13.3.0, GNU ld (GNU Binutils for Ubuntu) 2.42) #1 SMP PREEMPT Mon Nov  2 13:14:21 UTC 2026
   ... (140 lines left out)
Run /bin/sh as init process
  with arguments:
    /bin/sh
  with environment:
    HOME=/
    TERM=linux
Kernel panic - not syncing: No working init found.  Try passing init= option to kernel. See Linux Documentation/admin-guide/init.rst for guidance.
Kernel Offset: disabled

That is the expected result, and it is a good result. The kernel started, found no root file system, and looked for a program to run: /sbin/init, /etc/init, /bin/init, /bin/sh. There is none, so it stops with "Kernel panic - not syncing: No working init found". A kernel on its own does nothing; it needs one program to hand control to.

Because of panic=-1 and -no-reboot, QEMU quit by itself. If you ever need to leave a QEMU session that does not quit, press Ctrl-A and then X.

Your first init: a program in C

The first program the kernel starts is called init, and it has the process number 1. It can be anything. To prove it, write the smallest possible one: print a message, then wait (an init that exits makes the kernel panic).

$ cd ~/os/hello
$ cat init.c
#include <stdio.h>
#include <unistd.h>

int main(void) {
    printf("Hello from my own Linux!\n");
    sleep(1000000);
    return 0;
}

Compile it as a static program (-static). A normal program loads shared libraries at run time, and our system has none yet. A static program carries everything it needs inside the file:

$ gcc -static -o init init.c
$ ls -lh init
-rwxrwxr-x 1 ubuntu ubuntu 768K Nov  2 13:14 init
$ file init | cut -c1-110
init: ELF 64-bit LSB executable, x86-64, version 1 (GNU/Linux), statically linked, BuildID[sha1]=cd07544a28b30

The initramfs is a cpio archive of files, compressed with gzip. The kernel unpacks it into memory and looks for /init. Pack the one file we have:

$ echo init | cpio -o -H newc | gzip > ../hello.cpio.gz
1535 blocks
$ ls -lh ../hello.cpio.gz
-rw-rw-r-- 1 ubuntu ubuntu 333K Nov  2 13:14 ../hello.cpio.gz

Boot again, this time giving QEMU the archive with -initrd:

$ cd ~/os
$ qemu-system-x86_64 -nographic -enable-kvm -m 256 -kernel linux-7.3.1/arch/x86/boot/bzImage -initrd hello.cpio.gz -append "console=ttyS0 panic=-1" -no-reboot
Freeing unused kernel image (text/rodata gap) memory: 236K
Freeing unused kernel image (rodata/data gap) memory: 1656K
Run /init as init process
Hello from my own Linux!
input: ImExPS/2 Generic Explorer Mouse as /devices/platform/i8042/serio1/input/input2

"Run /init as init process", and then our own message: Hello from my own Linux! That is the whole idea of an operating system start-up: the kernel runs one program, and you chose it. QEMU does not quit this time, because our init is still running (sleeping); press Ctrl-A and then X to leave it. In this guide the run stops by itself after a few seconds.

Build BusyBox: a shell and 400 tools in one file

A system needs more than one program. Writing a shell, ls, cp, grep, awk, vi and the rest would take years, and linking each from the GNU sources is a big job. BusyBox is the shortcut: it is one program that contains simplified versions of about 400 standard tools. The program looks at the name it was started with and behaves as that tool, so a link called ls to the busybox file becomes ls. It is used in routers, in Alpine Linux and in almost every embedded system.

$ wget -q https://busybox.net/downloads/busybox-1.38.0.tar.bz2
$ wget -q https://busybox.net/downloads/busybox-1.38.0.tar.bz2.sha256
$ sha256sum -c busybox-1.38.0.tar.bz2.sha256
busybox-1.38.0.tar.bz2: OK
$ tar xf busybox-1.38.0.tar.bz2
$ cd ~/os/busybox-1.38.0

BusyBox has a configuration file too. make defconfig writes a sensible default with nearly every tool switched on. Then make two changes. Switch on CONFIG_STATIC: like our init, BusyBox must be one static file, because the system has no shared libraries. And switch off CONFIG_TC, the traffic control tool, which does not compile against the newest kernel headers (the build stops with "TCA_CBQ_MAX undeclared") and that we do not need.

$ make defconfig > /dev/null
$ sed -i 's/^# CONFIG_STATIC is not set/CONFIG_STATIC=y/' .config
$ sed -i 's/^CONFIG_TC=y/# CONFIG_TC is not set/' .config
$ grep -E '^CONFIG_STATIC=|^# CONFIG_TC ' .config
CONFIG_STATIC=y
# CONFIG_TC is not set
$ time make -j$(nproc) 2>&1 | tail -2
 Library resolv is needed, can't exclude it (yet)
Final link with: m resolv

real	0m28.591s
user	1m43.719s
sys	0m22.594s

Look at what you built:

$ ls -lh busybox
-rwxrwxr-x 1 ubuntu ubuntu 2.4M Nov  2 13:15 busybox
$ file busybox | cut -c1-110
busybox: ELF 64-bit LSB executable, x86-64, version 1 (GNU/Linux), statically linked, BuildID[sha1]=0d21ab9645
$ ./busybox --list | wc -l
407
$ ./busybox --list | grep -E '^(sh|ash|vi|grep|awk|sed|ls|ps|mount|ip|ping|udhcpc|wget)$' | tr '\n' ' '
ash awk grep ip ls mount ping ps sed sh udhcpc vi wget

One 2.5 MB file, 407 tools, no dependencies.

The root file system, and the init script

The root file system is the tree of folders and files that the system sees as /. Make the folders that the kernel and the tools expect. proc and sys are mount points for the virtual file systems, dev is for the device files, and tmp and root are the temporary folder and the home folder of the superuser:

$ mkdir -p rootfs/{proc,sys,dev,tmp,root,etc}

BusyBox can install itself: make install creates /bin, /sbin and /usr with one link to busybox for every tool. CONFIG_PREFIX says where:

$ cd ~/os/busybox-1.38.0
$ make CONFIG_PREFIX=$HOME/os/rootfs install 2>&1 | tail -1
$ cd ~/os
$ ls rootfs
bin
dev
etc
linuxrc
proc
root
sbin
sys
tmp
usr
$ ls -l rootfs/bin | head -6
total 2440
lrwxrwxrwx 1 ubuntu ubuntu       7 Nov  2 13:15 arch -> busybox
lrwxrwxrwx 1 ubuntu ubuntu       7 Nov  2 13:15 ash -> busybox
lrwxrwxrwx 1 ubuntu ubuntu       7 Nov  2 13:15 base32 -> busybox
lrwxrwxrwx 1 ubuntu ubuntu       7 Nov  2 13:15 base64 -> busybox
-rwxr-xr-x 1 ubuntu ubuntu 2497624 Nov  2 13:15 busybox
$ ls rootfs/bin | wc -l
94

Now replace our C program with an init script. A shell script is enough. It mounts the three virtual file systems (without them there is no ps, no /dev), prints a greeting, and starts a shell. exec replaces the script by the shell, and setsid cttyhack gives that shell a controlling terminal so that Ctrl-C and job control work:

$ cat rootfs/init
#!/bin/sh
mount -t proc none /proc
mount -t sysfs none /sys
mount -t devtmpfs none /dev
echo
echo "Welcome to my own Linux, built from scratch."
echo
exec setsid cttyhack /bin/sh
$ chmod +x rootfs/init

Pack the whole tree into an initramfs. The -R 0:0 makes every file belong to root, whoever you are:

$ cd ~/os/rootfs
$ find . | cpio -o -H newc -R 0:0 | gzip > ../busybox.cpio.gz
4995 blocks
$ cd ~/os
$ ls -lh busybox.cpio.gz
-rw-rw-r-- 1 ubuntu ubuntu 1.3M Nov  2 13:15 busybox.cpio.gz

Boot to a shell, and use the tools

Boot with the new initramfs. QEMU runs inside your terminal and the guest's console is the same terminal, so you type to the new system directly. The commands after the boot are typed at the guest prompt (a # sign); they are what you type, in this order:

$ qemu-system-x86_64 -nographic -enable-kvm -m 256 -kernel linux-7.3.1/arch/x86/boot/bzImage -initrd busybox.cpio.gz -append "console=ttyS0 panic=-1 quiet" -no-reboot
Welcome to my own Linux, built from scratch.

~ # uname -a
Linux (none) 7.3.1 #1 SMP PREEMPT Mon Nov  2 13:14:21 UTC 2026 x86_64 GNU/Linux
~ # ls /
bin      etc      linuxrc  root     sys      usr
dev      init     proc     sbin     tmp
~ # mount
rootfs on / type rootfs (rw)
none on /proc type proc (rw,relatime)
none on /sys type sysfs (rw,relatime)
none on /dev type devtmpfs (rw,relatime)
~ # ps | head -5
PID   USER     TIME  COMMAND
    1 0         0:00 /bin/sh
    2 0         0:00 [kthreadd]
    3 0         0:00 [pool_workqueue_]
    4 0         0:00 [kworker/R-rcu_g]
~ # ps | wc -l
29
~ # cat /proc/version
Linux version 7.3.1 (ubuntu@ubuntu-lab) (gcc (Ubuntu 13.3.0-6ubuntu2~24.04.1) 13.3.0, GNU ld (GNU Binutils for Ubuntu) 2.42) #1 SMP PREEMPT Mon Nov  2 13:14:21 UTC 2026
~ # poweroff -f
reboot: Power off not available: System halted instead

You are at a shell prompt inside a Linux that you built. uname shows the kernel version, 7.3.1. mount shows the three file systems that your init mounted, and ps shows init and the shell, and the kernel threads (the names in brackets). The last command asks the kernel to power off; with this small kernel the answer is "System halted", because the power button support (ACPI) is not built in yet. In the next stages you add it.

If the shell does not appear, or output stops, press Ctrl-A and then X to leave QEMU, and compare your init script and your kernel options with this guide.

Try grep, sed, awk and vi

The point of the exercise was a usable system, so try the tools that the guide promised. Type these at the guest prompt. First a text pipeline with echo, sed and awk. sed replaces text and awk handles columns and calculations:

$ qemu-system-x86_64 -nographic -enable-kvm -m 256 -kernel linux-7.3.1/arch/x86/boot/bzImage -initrd busybox.cpio.gz -append "console=ttyS0 panic=-1 quiet" -no-reboot
Welcome to my own Linux, built from scratch.

~ # echo hello world | sed 's/world/linux/' | awk '{print toupper($0)}'
HELLO LINUX
~ # printf 'a 1\nb 2\nc 3\n' > /tmp/t.txt
~ # awk '{sum += $2} END {print "sum:", sum}' /tmp/t.txt
sum: 6
~ # grep -n b /tmp/t.txt
2:b 2
~ # grep -m1 'model name' /proc/cpuinfo
model name	: QEMU Virtual CPU version 2.5+
~ # vi /tmp/note.txt
~ # cat /tmp/note.txt
hello from vi
~ # poweroff -f
reboot: Power off not available: System halted instead

The file /tmp/note.txt was made with vi. The keys, if you type them yourself: start with vi /tmp/note.txt and Enter; press i to start inserting text; type hello from vi; press Esc to stop inserting; type :wq and Enter to write the file and quit. The BusyBox vi is small but it is real vi: the same keys, h j k l to move, dd to delete a line, / to search, and the :commands.

(The screen of vi itself is not repeated in this transcript. In the video you can see it.)

Nothing is saved: /tmp is memory, and the whole system is in memory (the initramfs). When QEMU ends, everything you did is gone. Making the system permanent is the last stage of this guide.

Kernel round two: everything else the system needs

Our minimal kernel has no networking, no disk support and several features that ordinary programs quietly expect. We found these by running into them, and the guide lists the symptoms at the end so that you can recognise them. Switch on the second group of options, all together:

$ for o in NET INET PACKET NETDEVICES PCI VIRTIO_MENU VIRTIO VIRTIO_PCI VIRTIO_NET ACPI POSIX_TIMERS HIGH_RES_TIMERS NO_HZ_IDLE FUTEX EPOLL EVENTFD SIGNALFD TIMERFD FILE_LOCKING ADVISE_SYSCALLS MULTIUSER BLOCK BLK_DEV VIRTIO_BLK EXT4_FS PARTITION_ADVANCED MSDOS_PARTITION; do scripts/config -e $o; done
$ make olddefconfig 2>&1 | tail -1
#

  NET, INET, PACKET           the network stack, IP (TCP/UDP), and raw packet sockets, which the DHCP client needs
  NETDEVICES, PCI, VIRTIO...  network cards: QEMU provides a virtio network card, a paravirtual device made for virtual machines, on the PCI bus
  ACPI                        power management: the kernel can switch the virtual machine off
  POSIX_TIMERS, HIGH_RES_TIMERS, NO_HZ_IDLE   timers that sleep, alarm and ping use (without POSIX_TIMERS, ping prints one reply and then waits for ever)
  FUTEX, EPOLL, EVENTFD, SIGNALFD, TIMERFD, FILE_LOCKING, ADVISE_SYSCALLS   system calls that C libraries use for threads, waiting and locking (without FUTEX, name lookups abort with "The futex facility returned an unexpected error code")
  MULTIUSER                   user and group IDs, for whoami, chown and file owners
  BLOCK, BLK_DEV, VIRTIO_BLK, EXT4_FS, PARTITION_ADVANCED, MSDOS_PARTITION   disks: a virtio disk, the ext4 file system and the classic partition table, for the last stage

The kernel is rebuilt, and only the files that depend on the changed options are compiled again, but it is still the longest wait of this guide:

$ time make -j$(nproc) bzImage 2>&1 | tail -2
  BUILD   arch/x86/boot/bzImage
Kernel: arch/x86/boot/bzImage is ready  (#2)

real	3m49.213s
user	19m45.426s
sys	2m2.665s
$ ls -lh arch/x86/boot/bzImage
-rw-rw-r-- 1 ubuntu ubuntu 2.4M Nov  2 13:21 arch/x86/boot/bzImage

About twice as large as before, and still tiny: a distribution kernel and its modules take more than 100 MB.

Networking: an address, a route and DNS

The kernel can talk to a network card now. The configuration is the job of user space: bring the card up, ask a DHCP server for an address, and apply the answer. BusyBox has the DHCP client, udhcpc. When it gets an answer it runs a script to apply it, and BusyBox ships a ready example script. Install it where udhcpc looks for it:

$ mkdir -p rootfs/usr/share/udhcpc
$ cp busybox-1.38.0/examples/udhcp/simple.script rootfs/usr/share/udhcpc/default.script
$ chmod +x rootfs/usr/share/udhcpc/default.script
$ grep -nE 'ip addr add|nameserver' rootfs/usr/share/udhcpc/default.script
31:			ip addr add $ip$NETMASK $BROADCAST dev $interface
69:			echo "nameserver $i" >> "$tmpfile"

The script sets the address with ip and writes /etc/resolv.conf. Now add three lines to the init script: the loopback interface, the network card, and one DHCP request (-q quit when done, -n give up if there is no answer):

$ cat rootfs/init
#!/bin/sh
mount -t proc none /proc
mount -t sysfs none /sys
mount -t devtmpfs none /dev
ip link set lo up
ip link set eth0 up
udhcpc -i eth0 -q -n > /dev/null 2>&1
echo
echo "Welcome to my own Linux, built from scratch."
echo
exec setsid cttyhack /bin/sh
$ cd ~/os/rootfs
$ find . | cpio -o -H newc -R 0:0 | gzip > ../net.cpio.gz
5000 blocks
$ cd ~/os

Boot with a network card. QEMU's "user" network gives the guest a private network behind a built-in router: the guest is 10.0.2.15, the router 10.0.2.2, the DNS 10.0.2.3, and the guest can reach the internet through the host without any set-up on the host:

$ qemu-system-x86_64 -nographic -enable-kvm -m 256 -kernel linux-7.3.1/arch/x86/boot/bzImage -initrd net.cpio.gz -nic user,model=virtio-net-pci -append "console=ttyS0 panic=-1 quiet" -no-reboot
Welcome to my own Linux, built from scratch.

~ # ip addr show eth0 | grep 'inet '
    inet 10.0.2.15/24 brd 10.0.2.255 scope global eth0
~ # ip route
default via 10.0.2.2 dev eth0
10.0.2.0/24 dev eth0 scope link  src 10.0.2.15
~ # cat /etc/resolv.conf
nameserver 10.0.2.3
~ # ping -c 2 10.0.2.2
PING 10.0.2.2 (10.0.2.2): 56 data bytes
64 bytes from 10.0.2.2: seq=0 ttl=255 time=0.848 ms
64 bytes from 10.0.2.2: seq=1 ttl=255 time=0.554 ms

--- 10.0.2.2 ping statistics ---
2 packets transmitted, 2 packets received, 0% packet loss
round-trip min/avg/max = 0.554/0.701/0.848 ms
~ # nslookup techcommune.org 10.0.2.3 | tail -3
Name:	techcommune.org
Address: 47.204.6.48

~ # wget -qO- http://example.com | grep -o '<title>.*</title>'
<title>Example Domain</title>
~ # ping -c 2 -W 2 1.1.1.1
PING 1.1.1.1 (1.1.1.1): 56 data bytes

--- 1.1.1.1 ping statistics ---
2 packets transmitted, 0 packets received, 100% packet loss
~ # poweroff -f
reboot: Power down

Read the results from the top. The address 10.0.2.15 came from DHCP. The default route and the DNS server are the QEMU router. The router answers ping, the name techcommune.org turns into an address, and wget fetched a web page from the real internet: the system is online. The last ping, to an address on the internet, got no answer. That is not a fault in your system: QEMU's user network does not pass ping (ICMP) through to the outside, but it does pass TCP and UDP, which is what web access and DNS use. And the poweroff now works: "Power down", and QEMU quit by itself.

Build bash from the GNU source

BusyBox's own shell (ash) is good, but bash is the shell that most Linux administrators and most scripts expect, so build the real one. bash is a normal GNU program with a configure script: ./configure checks the machine and writes a Makefile, then make builds it.

$ wget -q https://ftp.gnu.org/gnu/bash/bash-5.3.tar.gz
$ sha256sum bash-5.3.tar.gz
0d5cd86965f869a26cf64f4b71be7b96f90a3ba8b3d74e27e8e9d9d5550f31ba  bash-5.3.tar.gz
$ tar xf bash-5.3.tar.gz
$ cd ~/os/bash-5.3

The options: --enable-static-link builds one static file, as with BusyBox; --without-bash-malloc uses the C library's memory allocator; --without-curses uses bash's own small terminal code instead of the ncurses library, which our system does not have; --disable-nls leaves out translations.

$ ./configure --enable-static-link --without-bash-malloc --without-curses --disable-nls --prefix=/usr 2>&1 | tail -2
config.status: creating po/Makefile
config.status: executing stamp-h commands
$ time make -j$(nproc) 2>&1 | tail -3
size bash
   text	   data	    bss	    dec	    hex	filename
2320348	  73096	  73912	2467356	 25a61c	bash

real	0m12.711s
user	1m4.253s
sys	0m6.079s
$ ls -lh bash
-rwxrwxr-x 1 ubuntu ubuntu 5.8M Nov  2 13:23 bash
$ ./bash --version | head -1
GNU bash, version 5.3.0(1)-release (x86_64-pc-linux-gnu)

Copy it into the root file system, stripped of its debugging information (strip makes the file less than half the size):

$ cd ~/os
$ strip -o rootfs/bin/bash bash-5.3/bash
$ ls -lh rootfs/bin/bash
-rwxrwxr-x 1 ubuntu ubuntu 2.3M Nov  2 13:23 rootfs/bin/bash

Make bash the login shell. These small files tell the system who root is (/etc/passwd), what group it belongs to, what the computer is called, and set up the shell: where programs are searched for (PATH), the home folder, and a prompt that shows user, host and folder. The init script now also sets the host name and starts bash instead of ash:

$ cat rootfs/etc/passwd
root:x:0:0:root:/root:/bin/bash
$ cat rootfs/etc/group
root:x:0:
$ cat rootfs/etc/hostname
tclinux
$ cat rootfs/etc/profile
export HOME=/root
export PATH=/bin:/sbin:/usr/bin:/usr/sbin
export PS1="\u@\h:\w\$ "
alias ll="ls -l"
cd
$ cat rootfs/init
#!/bin/sh
mount -t proc none /proc
mount -t sysfs none /sys
mount -t devtmpfs none /dev
hostname -F /etc/hostname
ip link set lo up
ip link set eth0 up
udhcpc -i eth0 -q -n > /dev/null 2>&1
echo
echo "Welcome to my own Linux, built from scratch."
echo
exec setsid cttyhack /bin/bash --login
$ cd ~/os/rootfs
$ find . | cpio -o -H newc -R 0:0 | gzip > ../bash.cpio.gz
9707 blocks
$ cd ~/os

Boot it, and try a few things that only bash does (arrays, loops, a prompt with the host name):

$ qemu-system-x86_64 -nographic -enable-kvm -m 256 -kernel linux-7.3.1/arch/x86/boot/bzImage -initrd bash.cpio.gz -nic user,model=virtio-net-pci -append "console=ttyS0 panic=-1 quiet" -no-reboot
Welcome to my own Linux, built from scratch.

root@tclinux:~$ echo $BASH_VERSION
5.3.0(1)-release
root@tclinux:~$ arr=(one two three); echo ${arr[1]} ${#arr[@]}
two 3
root@tclinux:~$ for i in 1 2 3; do echo n$i; done
n1
n2
n3
root@tclinux:~$ hostname; whoami
tclinux
root
root@tclinux:~$ ll /bin/bash
-rwxrwxr-x    1 root     root       2409032 Nov  2 13:23 /bin/bash
root@tclinux:~$ poweroff -f
reboot: Power down

The prompt shows root@tclinux, and the shell is bash 5.3. The system works, but all of it still lives in memory and disappears at every power-off.

A real disk: partition, ext4 and a boot loader

A real system lives on a disk, starts from a boot loader, and keeps its files between boots. QEMU can show a plain file to the guest as a disk, so the disk is an image file. Build it in four steps. This is the stage where the throwaway build machine pays off: it uses loop devices and mounts, and runs as root, and nothing outside the virtual machine is at risk.

Step 1: a 512 MB empty file, and a partition table with one bootable Linux partition.

$ truncate -s 512M disk.img
$ echo "type=83, bootable" | sfdisk -q disk.img
$ sfdisk -l disk.img | tail -2
Device     Boot Start     End Sectors  Size Id Type
disk.img1  *     2048 1048575 1046528  511M 83 Linux

Step 2: attach the file as a loop device, so Linux treats it as a disk, then make an ext4 file system on the partition and mount it. The -P option makes the kernel create the partition devices (loop0p1).

$ sudo losetup -Pf --show disk.img
/dev/loop0
$ sudo mkfs.ext4 -q -L tcroot /dev/loop0p1
$ sudo mkdir -p /mnt/tc && sudo mount /dev/loop0p1 /mnt/tc
$ df -h /mnt/tc | tail -1
/dev/loop0p1    463M   24K  427M   1% /mnt/tc

Step 3: copy the system onto it. This disk starts with the BusyBox init program instead of our script, the way a real system does: init reads /etc/inittab, which says what to run at start (a script), what to keep running (a login shell on the console), and what to do at shutdown. Make that file, the start script, and the link /sbin/init that the kernel looks for, then copy the tree:

$ cat rootfs/etc/inittab
::sysinit:/etc/init.d/rcS
ttyS0::respawn:-/bin/bash
::ctrlaltdel:/sbin/reboot
::shutdown:/bin/umount -a -r
$ cat rootfs/etc/init.d/rcS
#!/bin/sh
mount -t proc none /proc
mount -t sysfs none /sys
mount -o remount,rw /
hostname -F /etc/hostname
ip link set lo up
ip link set eth0 up
udhcpc -i eth0 -q -n > /dev/null 2>&1
echo
echo "Welcome to my own Linux, built from scratch."
echo
$ chmod +x rootfs/etc/init.d/rcS
$ ln -sf ../bin/busybox rootfs/sbin/init
$ sudo cp -a rootfs/. /mnt/tc/
$ sudo chown -R 0:0 /mnt/tc
$ ls /mnt/tc
bin
dev
etc
init
linuxrc
lost+found
proc
root
sbin
sys
tmp
usr

Step 4: the boot loader. extlinux is a small loader for ext file systems: it is installed into the disk, finds a configuration file, and loads the kernel from the disk. The configuration names the kernel and the kernel command line, which now says root=/dev/vda1 (the first partition of the first virtio disk). The classic boot sector (mbr.bin) goes into the first 440 bytes of the disk, and starts the loader:

$ sudo mkdir -p /mnt/tc/boot/extlinux
$ sudo cp linux-7.3.1/arch/x86/boot/bzImage /mnt/tc/boot/
$ cat ~/os/extlinux.conf
DEFAULT tc
PROMPT 0
TIMEOUT 10
LABEL tc
  LINUX /boot/bzImage
  APPEND root=/dev/vda1 rw console=ttyS0 quiet
$ sudo cp extlinux.conf /mnt/tc/boot/extlinux/extlinux.conf
$ sudo extlinux --install /mnt/tc/boot/extlinux
/mnt/tc/boot/extlinux is device /dev/loop0p1
Warning: unable to obtain device geometry (defaulting to 64 heads, 32 sectors)
         (on hard disks, this is usually harmless.)
$ sudo dd if=/usr/lib/syslinux/mbr/mbr.bin of=/dev/loop0 bs=440 count=1 conv=notrunc 2>&1 | tail -1
440 bytes copied, 0.000114114 s, 3.9 MB/s

Unmount and detach, so that all the data is written to the image file:

$ sudo umount /mnt/tc
$ sudo losetup -d /dev/loop0
$ ls -lh disk.img
-rw-rw-r-- 1 ubuntu ubuntu 512M Nov  2 13:23 disk.img

Boot from the disk

This is the full system. QEMU gets only the disk image and the network card: no -kernel, no -initrd. The BIOS starts the boot sector, the boot sector starts extlinux, extlinux loads the kernel from the ext4 partition, the kernel mounts that partition as /, and BusyBox init takes over:

$ qemu-system-x86_64 -nographic -enable-kvm -m 256 -drive file=disk.img,if=virtio,format=raw -nic user,model=virtio-net-pci -no-reboot
SYSLINUX 6.04 EDD 20240408 Copyright (C) 1994-2015 H. Peter Anvin et al

Welcome to my own Linux, built from scratch.

root@tclinux:~$ uname -r; echo $BASH_VERSION
7.3.1
5.3.0(1)-release
root@tclinux:~$ df -h / | tail -2
Filesystem                Size      Used Available Use% Mounted on
/dev/root               462.3M      7.3M    419.2M   2% /
root@tclinux:~$ mount | grep ' / '
/dev/root on / type ext4 (rw,relatime)
root@tclinux:~$ ip addr show eth0 | grep 'inet '
    inet 10.0.2.15/24 brd 10.0.2.255 scope global eth0
root@tclinux:~$ echo it stays > /root/note.txt; sync; cat /root/note.txt
it stays
root@tclinux:~$ poweroff
root@tclinux:~$ umount: devtmpfs busy - remounted read-only
The system is going down NOW!
Sent SIGTERM to all processes
Sent SIGKILL to all processes
Requesting system poweroff
reboot: Power down

The first line, SYSLINUX 6.04, is the boot loader. The root file system is now /dev/root, an ext4 disk of 462 MB, the network is up, and a normal poweroff shuts down the system step by step: BusyBox init sends the signals, unmounts the disks, and asks the kernel to power down (the little message that devtmpfs is busy is normal here).

Now the proof that this system keeps its data. Boot it a second time and read the file that you wrote:

$ qemu-system-x86_64 -nographic -enable-kvm -m 256 -drive file=disk.img,if=virtio,format=raw -nic user,model=virtio-net-pci -no-reboot
root@tclinux:~$ cat /root/note.txt
it stays
root@tclinux:~$ ls /root
note.txt
root@tclinux:~$ poweroff
root@tclinux:~$ umount: devtmpfs busy - remounted read-only
The system is going down NOW!
Sent SIGTERM to all processes
Sent SIGKILL to all processes
Requesting system poweroff
reboot: Power down

The file is still there. You have a Linux system that you built from three source tarballs (the kernel, BusyBox and bash), in a disk image of 512 MB that starts in a few seconds. The programs that make it up are small: the kernel is 2.4 MB, BusyBox 2.5 MB and bash 2.3 MB.

When something goes wrong, and where to go from here

The first four symptoms below (the panic, the BusyBox tc error, the ping that hangs and the futex message) were met while building this guide. The others are the usual causes of the same kinds of failure.

  Kernel panic: No working init found       the kernel found no /init (initramfs) or /sbin/init (disk). Check the file exists, is executable (chmod +x) and is in the archive (the path in cpio is relative: use find . from inside the folder)
  BusyBox build stops at networking/tc.c    switch off CONFIG_TC, as in the BusyBox section
  ping prints one line and hangs            POSIX_TIMERS and HIGH_RES_TIMERS are off in the kernel
  "The futex facility returned an unexpected error code"    FUTEX is off in the kernel
  Kernel panic: ... VFS: Unable to mount root fs    the disk driver or the file system is not in the kernel (VIRTIO_BLK, EXT4_FS), or root= is wrong
  No messages at all after the BIOS         console=ttyS0 is missing from the kernel command line, or SERIAL_8250_CONSOLE is off
  The program runs but prints nothing       there is no console: with an initramfs the kernel opens /dev/console itself, but check that PRINTK and TTY are on
  /bin/sh: not found, but the file exists   the program is dynamically linked and the libraries are missing. Build it static (-static, CONFIG_STATIC=y)
  udhcpc: no lease, no eth0                 NET, INET, PACKET, PCI, VIRTIO_PCI and VIRTIO_NET, and the network option of QEMU (-nic)
  poweroff says "System halted"             ACPI is off in the kernel; poweroff -f works with it

Where to go from here. You now know the parts of every Linux system, so you can add to it. Add more of the GNU tools in the same way as bash (download, configure, make, copy into the tree). Add a second console, a user with a password, or a service that starts at boot, with a line in /etc/inittab. Add a package of your own, built as in the earlier guides of this series. Replace BusyBox init with systemd or OpenRC, and the static programs with a shared C library (musl or glibc), and you have started to write a distribution. The standard book on the subject is Linux From Scratch, which builds a full desktop-capable system with the GNU tools, and which this guide is a small first step towards.

When you have finished, delete the build machine, and nothing is left on your workstation.