Chapter 29: Initramfs from scratch

What: two small early user-space designs: a statically linked program in a cpio archive that prints “hello” and reboots, and a BusyBox-based initramfs with a shell.

Why: the standard rootfs path (root=/dev/mmcblk0p2) hides a lot. An initramfs exposes the kernel-to-user-space handoff, the requirements on /init, and how a cpio archive becomes a populated filesystem. Buildroot and Ubuntu-base in Part V expand the runtime environment with more pieces. MCU bridge: Think of the rootfs as the firmware image’s file-backed runtime environment. On an MCU you link everything into flash. On Linux, programs and config live in this mounted tree. rootfs: root filesystem, the directory tree mounted at / that contains /bin, /etc, /dev, and libraries. Buildroot: a configuration-driven build system that produces a complete root filesystem and related images.

Focus: the cpio archive as a filesystem image that the kernel unpacks into the initial tmpfs. Once that model is clear, the rest is just commands.

29.1 What an initramfs is

An initramfs is a small filesystem image that the kernel loads into RAM before any “real” disk filesystem is mounted. The kernel mounts the initramfs as /, runs /init, and from there /init can do whatever it wants, usually pivot to a real rootfs on disk, but for embedded systems the initramfs is the rootfs.

The image format is cpio (the venerable Unix archive format), optionally compressed with gzip/bzip2/xz/zstd. The kernel has a built-in cpio extractor that runs very early in boot.

Two ways to get the cpio archive into kernel memory:

  1. Built into the kernel image. The kernel’s usr/initramfs_data.cpio.gz gets linked into vmlinux (and therefore into zImage). The kernel knows the archive’s location. On boot it extracts it.

  2. Loaded separately by the bootloader. U-Boot reads initramfs.cpio.gz into RAM at some address, passes that address via the DT’s /chosen/linux,initrd-start and linux,initrd-end properties (or the legacy ATAGS), and the kernel extracts from there.

MCU bridge: Think of U-Boot like a much larger boot stub plus debug monitor: it initializes hardware, loads the next image, and gives you commands before Linux starts. U-Boot: the bootloader that initializes enough hardware to load and start the Linux kernel.

Option 1 is simpler for tiny images. Option 2 allows changes to the rootfs without rebuilding the kernel. The examples compare both packaging choices.

29.2 The smallest possible initramfs

A single binary that prints “hello”, waits a moment, and reboots:

hello.c:

#include <stdio.h>
#include <unistd.h>
#include <sys/reboot.h>
#include <sys/syscall.h>
#include <linux/reboot.h>

int main(void)
{
    /* Open the kernel console so printf goes somewhere visible. */
    /* (kernel_init opens /dev/console as fd 0,1,2 before running /init.) */

    puts("\n*** hello from a one-binary initramfs ***\n");

    for (int i = 5; i > 0; i--) {
        printf("rebooting in %d...\n", i);
        sleep(1);
    }

    /* Use the raw syscall — we don't depend on libc init having done anything. */
    syscall(__NR_reboot,
            LINUX_REBOOT_MAGIC1, LINUX_REBOOT_MAGIC2,
            LINUX_REBOOT_CMD_RESTART, NULL);

    return 0;   /* unreachable on a successful reboot */
}

Build statically so we have no library dependencies on the target:

$ arm-none-linux-gnueabihf-gcc -static -Os -o init hello.c
$ arm-none-linux-gnueabihf-strip init
$ ls -lh init
-rwxr-xr-x 1 you you 480K Jan 22 init

480 KB statically linked against glibc. That’s ten times the size of the program itself. Glibc is fat. With musl instead:

$ musl-gcc -static -Os -o init hello.c   # or use arm-linux-musleabihf-gcc
$ arm-none-linux-gnueabihf-strip init
$ ls -lh init
-rwxr-xr-x 1 you you  30K Jan 22 init

30 KB. That’s roughly the minimum a statically-linked C program reaches.

Build the cpio archive:

$ mkdir -p initramfs
$ cp init initramfs/init                 # MUST be named "init" at the root
$ cd initramfs
$ find . | cpio -o -H newc | gzip > ../initramfs.cpio.gz
$ ls -lh ../initramfs.cpio.gz
-rw-r--r-- 1 you you 14K Jan 22 ../initramfs.cpio.gz

-H newc selects the SVR4 / portable ASCII cpio format the kernel expects. The result is a 14 KB compressed image containing one file (/init, executable). The kernel will extract this into a tmpfs, execute /init, and we’ll see the hello message.

29.3 Booting it from U-Boot

Drop initramfs.cpio.gz into your TFTP server, then:

TFTP: Trivial File Transfer Protocol, a simple network protocol U-Boot commonly uses to fetch kernels from the host.

=> tftp 0x82000000 zImage
=> tftp 0x83000000 imx6ull.dtb
=> tftp 0x84000000 initramfs.cpio.gz
=> setenv initrd_size ${filesize}
=> setenv bootargs 'console=ttymxc0,115200 earlycon rdinit=/init'
=> bootz 0x82000000 0x84000000:${initrd_size} 0x83000000

Notice the new things:

  • tftp 0x84000000 initramfs.cpio.gz: load the initramfs at a third DRAM address.

  • rdinit=/init: tells kernel_init to run /init from the initramfs (instead of /sbin/init from a disk rootfs).

  • setenv initrd_size ${filesize}: after tftp, U-Boot stores the loaded byte count in filesize. Raw initramfs images need both address and size.

  • bootz 0x82000000 0x84000000:${initrd_size} 0x83000000: the second argument is now initrd_address:initrd_size (no longer -). U-Boot writes both linux,initrd-start and linux,initrd-end into the DT.

You should see:

[   2.087xxx] Freeing unused kernel image (initmem) memory: 1024K
[   2.110xxx] Run /init as init process

*** hello from a one-binary initramfs ***

rebooting in 5...
rebooting in 4...
rebooting in 3...
rebooting in 2...
rebooting in 1...
[   7.214xxx] reboot: Restarting system

That is the smallest user space that boots Linux on this board: 30 KB compiled.

29.4 A BusyBox-based initramfs (real shell)

hello is just a demo. A practical initramfs has a shell and some utilities, plus an init system. BusyBox packs all of these into one statically-linked binary of about 600 KB. It exposes hundreds of applets, most of the common Unix utilities.

$ cd ~/imx6ull/src
$ wget https://busybox.net/downloads/busybox-1.36.1.tar.bz2
$ tar xf busybox-1.36.1.tar.bz2
$ cd busybox-1.36.1
$ make ARCH=arm CROSS_COMPILE=arm-none-linux-gnueabihf- defconfig

# Enable static linking
$ make ARCH=arm CROSS_COMPILE=arm-none-linux-gnueabihf- menuconfig
# Settings → Build static binary (no shared libs) → [*]
# Save and exit.

$ make ARCH=arm CROSS_COMPILE=arm-none-linux-gnueabihf- -j$(nproc)
$ ls -lh busybox
-rwxr-xr-x 1 you you 1.2M Jan 22 busybox
$ arm-none-linux-gnueabihf-strip busybox
$ ls -lh busybox
-rwxr-xr-x 1 you you 580K Jan 22 busybox

580 KB statically linked against glibc. With musl, ~450 KB.

Now build a rootfs around it:

$ cd ~/imx6ull
$ rm -rf initramfs
$ mkdir -p initramfs/{etc/init.d,proc,sys,dev,tmp,var,root}

# Install BusyBox and all applet symlinks.
# This uses the BusyBox build system, so the host never tries to execute
# the ARM busybox binary.
$ make -C ~/imx6ull/src/busybox-1.36.1 \
      ARCH=arm CROSS_COMPILE=arm-none-linux-gnueabihf- \
      CONFIG_PREFIX=$PWD/initramfs install

# If your BusyBox config did not install /sbin/init, add it.
$ test -e initramfs/sbin/init || ln -s /bin/busybox initramfs/sbin/init

# A minimal inittab for busybox's init
$ cat > initramfs/etc/inittab <<'EOF'
::sysinit:/etc/init.d/rcS
::respawn:-/bin/sh
::ctrlaltdel:/sbin/reboot
::shutdown:/bin/umount -a -r
EOF

# A minimal startup script
$ mkdir -p initramfs/etc/init.d
$ cat > initramfs/etc/init.d/rcS <<'EOF'
#!/bin/sh
mount -t proc none /proc
mount -t sysfs none /sys
mount -t devtmpfs none /dev
echo "*** BusyBox initramfs is up ***"
EOF
$ chmod +x initramfs/etc/init.d/rcS

# Repackage
$ cd initramfs
$ find . | cpio -o -H newc | gzip > ../initramfs.cpio.gz
$ ls -lh ../initramfs.cpio.gz
-rw-r--r-- 1 you you 250K Jan 22 ../initramfs.cpio.gz

250 KB compressed. Load and boot as before, but drop rdinit=/init from bootargs, busybox-init wants to run as /sbin/init, which is the default search order:

=> setenv bootargs 'console=ttymxc0,115200 earlycon'
=> tftp 0x84000000 initramfs.cpio.gz
=> setenv initrd_size ${filesize}
=> bootz 0x82000000 0x84000000:${initrd_size} 0x83000000

After kernel boots, you should see:

[   2.298xxx] Run /sbin/init as init process
*** BusyBox initramfs is up ***

Please press Enter to activate this console.

# ls /
bin   dev  etc   lib   proc  root  sbin  sys  tmp  usr  var
# cat /proc/cpuinfo
processor  : 0
model name : ARMv7 Processor rev 5 (v7l)
...

In this example, BusyBox supplies a shell and the enabled applets. Their availability depends on its configuration, not on the presence of a full desktop distribution.

29.5 Embedded vs separate

Two architectures for shipping the initramfs:

Embedded in the kernel

# In kernel's .config:
CONFIG_INITRAMFS_SOURCE="/home/you/imx6ull/initramfs.cpio.gz"

$ make ARCH=arm zImage     # zImage now includes initramfs internally

The zImage grows by the initramfs size. No separate bootloader step. Convenient for a fixed initramfs that’s part of the kernel’s release artifact. Used by single-purpose appliances and by recovery kernels.

Separate file loaded by bootloader

In the separate-file example above, the kernel is one file (zImage) and the initramfs is another (initramfs.cpio.gz). The bootloader loads both. The advantage is changing the rootfs doesn’t require rebuilding the kernel.

Choose based on how often the rootfs changes vs the kernel. For early development, embedded is faster (one artefact). For production, separate is more flexible.

29.6 Init systems compared

Once you have user space running, what runs as PID 1? Three common choices for embedded:

Init

Footprint

What you get

BusyBox init

< 1 KB on top of busybox

One inittab; classic Unix respawn / sysinit semantics. Sufficient for 95% of embedded

sysvinit

~80 KB

The traditional System V /etc/rc.d/init.d/* scripts. Familiar to anyone from before 2010

systemd

~5 MB + dependencies

Service unit files, sockets, journal, timers. Powerful, big, dependency-heavy

For embedded, BusyBox init is the default. We use it in this book through Chapter 31 and switch to discussing systemd in Chapter 33.

29.7 Key Notes

  • An initramfs supplies files before the normal storage-backed root is available. The kernel unpacks a cpio archive, then early user space owns the next boot or recovery decision.

  • /init must be executable for the target architecture, with its interpreter and libraries present when needed. Static linking removes shared-library dependencies, not the requirements for a console or filesystem mounts.

  • A separately loaded raw archive needs its address and byte count in the bootloader handoff. The saved size must describe the initramfs transfer, not another image loaded afterward.

  • Embedding the archive ties its contents to the kernel artifact. Loading it separately permits independent replacement but adds another image to the bootloader’s release and memory-layout contract.

  • BusyBox combines selected applets in one binary. Its shell and utilities still need startup policy, mount points and device-node availability.

  • procfs exposes kernel and process information, sysfs exposes device-model attributes, and devtmpfs supplies device nodes. Their mounts are distinct from the files stored in the cpio archive.

  • PID 1 is a system-lifetime process. The rebooting example is a demonstration of that boundary, not a service manager or a qualified recovery system.

29.8 Pitfalls

  • /init must exist at the root and be executable. Forget either and the kernel panics with “No filesystem could mount root, tried: ramfs”. Cpio archives don’t error on missing init.

  • /init linked dynamically. If /init depends on libc.so.6 and libc.so.6 isn’t in the cpio, exec fails silently. Either statically link (recommended) or include the needed .so files in /lib/ of the initramfs.

  • init= vs rdinit=. init=path tells the kernel to look on the root filesystem (the one specified by root=). rdinit=path tells it to look on the initramfs. For initramfs-only boots, use rdinit= or just rely on the default /init lookup.

  • Forgetting the initramfs size in bootz. For a raw initramfs.cpio.gz, use bootz kernel_addr initrd_addr:initrd_size fdt_addr. The size usually comes from U-Boot’s ${filesize} after tftp or load.

  • cpio archive built without -H newc. Default cpio format isn’t what the kernel expects. The unpacker reports an error and gives up. Always -H newc.

  • Trailing slash on find .. find . gives relative paths like ./init, which is what cpio wants. find /home/you/initramfs gives absolute paths, so the archive ends up with /home/you/initramfs/init and the kernel cannot find /init. Always cd into the rootfs first.

  • BusyBox not statically linked. Built dynamic by default. Forgetting to set static causes the binary to need glibc shared objects you don’t have in the initramfs. Symptom: Kernel panic - not syncing: Attempted to kill init! because exec fails.

  • No /dev/console before kernel_init opens it. Kernel handles this automatically via devtmpfs auto-mount, but if you disable that in .config, you’ll see Warning: unable to open an initial console and lose all stdio in your init. Keep CONFIG_DEVTMPFS=y and CONFIG_DEVTMPFS_MOUNT=y.

29.9 Going deeper

  • Documentation/filesystems/ramfs-rootfs-initramfs.rst: canonical kernel doc on the initramfs mechanism.

  • Documentation/admin-guide/initrd.rst: older initrd mechanism (predecessor to initramfs). Useful context.

  • BusyBox manual at busybox.net/about.html and the per-applet --help.

  • init/initramfs.c: the kernel’s cpio extractor. Short and readable.

  • klibc: an even smaller libc-replacement than musl, designed specifically for in-kernel-cpio-initramfs static binaries.

Next chapter: Chapter 29A: Initramfs as a recovery system. The same early user-space mechanism can host diagnostics and a controlled recovery path independently of the main rootfs.