all
Stage 09

BusyBox Root Filesystem

Build a minimal embedded Linux root filesystem using BusyBox — configure, cross-compile, install, write inittab and init scripts, mount filesystems, and boot to a working shell.

10 min read
50189 chars

What is BusyBox?

{:.gc-basic}

Basic

BusyBox is the “Swiss Army knife of embedded Linux”. It bundles roughly 400 Unix utilities — ls, mount, sh, init, vi, wget, and many more — into a single statically-linkable binary, typically under 1 MB. Every utility is implemented as an applet: a small function registered in a table. At runtime, BusyBox reads argv[0] to decide which applet to run.

The deployment pattern uses symlinks: the installer creates /bin/ls → /bin/busybox, /bin/sh → /bin/busybox, and so on. Calling any symlink causes BusyBox to dispatch to the matching applet.

Size comparison — full GNU utilities vs BusyBox:

Component Full GNU toolchain BusyBox equivalent
coreutils (ls, cp, mv …) ~8 MB included in busybox binary
util-linux (mount, fdisk …) ~4 MB included
procps (ps, top …) ~500 KB included
bash ~1.3 MB sh applet (~150 KB equivalent)
Total ~14 MB ~500 KB – 2 MB

A typical embedded rootfs with BusyBox, a cross-compiled libc, and a kernel is 2–5 MB — small enough to live in NOR flash or a 16 MB QSPI chip.

BusyBox’s init is also a fully functional PID 1, reading /etc/inittab and spawning getty/daemons, removing the need for any separate init system.


Building BusyBox

{:.gc-basic}

Basic

Getting the Source

wget https://busybox.net/downloads/busybox-1.36.1.tar.bz2
tar -xjf busybox-1.36.1.tar.bz2
cd busybox-1.36.1

Configure

# Start from a sane default — enables most applets
make defconfig

# Interactive TUI configurator (similar to kernel menuconfig)
make menuconfig

Key menuconfig paths:

  • Settings → Cross compiler prefix — set your toolchain prefix
  • Settings → Build static binary — enable for fully self-contained binary
  • Init Utilities — enable init, getty, halt, poweroff, reboot
  • Linux System Utilitiesmount, umount, fdisk, blkid
  • Networking Utilitiesifconfig, udhcpc, wget, telnetd

Cross-Compilation for ARM

# Set the cross-compiler prefix in the environment
export ARCH=arm
export CROSS_COMPILE=arm-linux-gnueabihf-

# Or put it in .config directly:
#   CONFIG_CROSS_COMPILER_PREFIX="arm-linux-gnueabihf-"

make -j$(nproc)

# Install into _install/ subdirectory
make install
# Result: _install/{bin,sbin,usr/bin,usr/sbin,linuxrc}

Relevant .config Snippet

# CONFIG file fragment — BusyBox 1.36
CONFIG_CROSS_COMPILER_PREFIX="arm-linux-gnueabihf-"
CONFIG_SYSROOT=""
CONFIG_EXTRA_CFLAGS="-march=armv7-a -mfpu=neon -mfloat-abi=hard"

# Static linking: embed libc inside the binary (no .so needed at runtime)
CONFIG_STATIC=y

# Init system
CONFIG_INIT=y
CONFIG_FEATURE_USE_INITTAB=y
CONFIG_GETTY=y
CONFIG_LOGIN=y

# Core shell
CONFIG_ASH=y
CONFIG_ASH_JOB_CONTROL=y
CONFIG_FEATURE_SH_STANDALONE=y

# Key utilities
CONFIG_MOUNT=y
CONFIG_UMOUNT=y
CONFIG_SYSLOGD=y
CONFIG_KLOGD=y
CONFIG_IFCONFIG=y
CONFIG_UDHCPC=y

Static vs Dynamic Linking

Criterion Static (CONFIG_STATIC=y) Dynamic (shared libc)
Binary size Larger (~1–2 MB) Smaller binary, but needs /lib
Runtime dependencies None Requires libc.so, ld-linux.so
Deployment simplicity Drop single binary anywhere Must copy libraries to rootfs
Security patching Rebuild to update libc Replace .so only
Recommended for Rescue images, initramfs Full production rootfs

For production, dynamic linking is preferred so security patches to libc propagate without rebuilding BusyBox.


Minimal RootFS Directory Structure

{:.gc-mid}

Intermediate

Create the Directory Skeleton

ROOTFS=$HOME/rootfs
mkdir -p $ROOTFS

# FHS-required directories
mkdir -p $ROOTFS/{bin,sbin,usr/bin,usr/sbin}
mkdir -p $ROOTFS/{etc,lib,lib64}
mkdir -p $ROOTFS/{dev,proc,sys,tmp,run}
mkdir -p $ROOTFS/{var/log,var/run,var/tmp}
mkdir -p $ROOTFS/home/root
mkdir -p $ROOTFS/mnt

# Correct permissions
chmod 1777 $ROOTFS/tmp
chmod 1777 $ROOTFS/var/tmp
chmod 700  $ROOTFS/home/root

Copy BusyBox

# Copy the binary and the applet symlinks from _install/
cp -a busybox-1.36.1/_install/* $ROOTFS/

Create Essential Device Nodes

These nodes must exist before the kernel hands off to init, because init opens /dev/console on its very first line.

# These need to be created as root (or with fakeroot for images)
sudo mknod -m 600 $ROOTFS/dev/console c 5 1
sudo mknod -m 666 $ROOTFS/dev/null    c 1 3
sudo mknod -m 666 $ROOTFS/dev/zero    c 1 5
sudo mknod -m 666 $ROOTFS/dev/random  c 1 8
sudo mknod -m 666 $ROOTFS/dev/urandom c 1 9
sudo mknod -m 666 $ROOTFS/dev/tty     c 5 0
sudo mknod -m 660 $ROOTFS/dev/tty1    c 4 1
# Serial console for embedded targets
sudo mknod -m 660 $ROOTFS/dev/ttyS0   c 4 64
sudo mknod -m 660 $ROOTFS/dev/ttyAMA0 c 204 64   # ARM PL011 UART

devtmpfs vs static /dev: On modern kernels, mounting devtmpfs on /dev at boot lets the kernel automatically populate device nodes. This is the preferred approach — add mount -t devtmpfs devtmpfs /dev to your rcS script. Static nodes are a fallback for kernels without CONFIG_DEVTMPFS.

Copy Cross-Compiled Libraries (Dynamic Linking)

SYSROOT=$(arm-linux-gnueabihf-gcc --print-sysroot)

# Dynamic linker / loader — absolute requirement
cp $SYSROOT/lib/ld-linux-armhf.so.3  $ROOTFS/lib/

# C library
cp $SYSROOT/lib/libc.so.6            $ROOTFS/lib/
cp $SYSROOT/lib/libm.so.6            $ROOTFS/lib/
cp $SYSROOT/lib/libpthread.so.0      $ROOTFS/lib/

# Resolve any additional .so dependencies
arm-linux-gnueabihf-readelf -d busybox-1.36.1/busybox | grep NEEDED
# NEEDED  libm.so.6
# NEEDED  libc.so.6

/etc/inittab and Init Scripts

{:.gc-mid}

Intermediate

BusyBox’s init is not systemd. It reads /etc/inittab at startup and processes each entry. There are no units, no D-Bus, no cgroups — just a simple table of “when to run what”.

inittab Field Format

id:runlevel:action:process
  • id — unique identifier (1–4 characters); for serial-console entries this is the tty device name
  • runlevel — ignored by BusyBox init (kept for compatibility); leave blank
  • action — one of the keywords below
  • process — full command to execute
Action Meaning
sysinit Run once at boot, before anything else; init waits for it to finish
wait Run once, init waits for completion
once Run once, init does NOT wait
respawn Restart the process whenever it exits (e.g., getty)
askfirst Like respawn, but prints “Please press Enter to activate” first
ctrlaltdel Run when Ctrl-Alt-Del is pressed
shutdown Run on shutdown/reboot
restart Run when init receives SIGHUP

Real /etc/inittab Example

# /etc/inittab — BusyBox init

# System initialization
::sysinit:/etc/init.d/rcS

# Start a shell on the serial console (ARM UART)
ttyAMA0::respawn:/sbin/getty -L ttyAMA0 115200 vt100

# Also start a shell on tty1 (framebuffer/VGA)
tty1::respawn:/sbin/getty -L tty1 0 vt100

# If running under QEMU with -nographic, use this instead:
# ::askfirst:/bin/sh

# Handle Ctrl-Alt-Del
::ctrlaltdel:/sbin/reboot

# Cleanup before reboot/halt
::shutdown:/bin/umount -a -r
::shutdown:/sbin/swapoff -a

The /etc/init.d/rcS System Init Script

This script runs as sysinit — BusyBox init blocks until it completes.

#!/bin/sh
# /etc/init.d/rcS — system initialization script

echo "--- Starting rcS ---"

# Mount the kernel virtual filesystems first — many tools need these
mount -t proc     proc     /proc
mount -t sysfs    sysfs    /sys
mount -t devtmpfs devtmpfs /dev

# /dev/pts for pseudo-terminals (needed by SSH, screen, etc.)
mkdir -p /dev/pts
mount -t devpts devpts /dev/pts -o gid=5,mode=620

# Shared memory
mkdir -p /dev/shm
mount -t tmpfs tmpfs /dev/shm -o size=32m

# Writable tmpfs for runtime data
mount -t tmpfs tmpfs /run   -o size=16m,mode=755
mount -t tmpfs tmpfs /tmp   -o size=64m,mode=1777

# Set hostname
hostname -F /etc/hostname

# Bring up loopback interface
ip link set lo up

# Seed the kernel RNG from a saved random seed (optional but good practice)
if [ -f /var/lib/urandom/random-seed ]; then
    cat /var/lib/urandom/random-seed > /dev/urandom
fi
dd if=/dev/urandom of=/var/lib/urandom/random-seed bs=512 count=1 2>/dev/null

# Start syslog
/sbin/syslogd -C512          # circular buffer, 512 KB
/sbin/klogd

# Network (static example — replace with udhcpc for DHCP)
# ip addr add 192.168.1.100/24 dev eth0
# ip link set eth0 up
# ip route add default via 192.168.1.1

echo "--- rcS complete ---"

Make it executable:

chmod +x $ROOTFS/etc/init.d/rcS

Additional Required Files

# /etc/hostname
echo "embeddex" > $ROOTFS/etc/hostname

# /etc/passwd — root with no password for development
echo "root::0:0:root:/root:/bin/sh" > $ROOTFS/etc/passwd

# /etc/group
echo "root:x:0:"   >  $ROOTFS/etc/group
echo "tty:x:5:"    >> $ROOTFS/etc/group

# /etc/profile — shell environment
cat > $ROOTFS/etc/profile << 'EOF'
export PATH=/bin:/sbin:/usr/bin:/usr/sbin
export PS1='\u@\h:\w\$ '
export HOME=/root
umask 022
EOF

Packaging into a Filesystem Image

{:.gc-adv}

Advanced

ext4 Block Image

ROOTFS=$HOME/rootfs
IMAGE=rootfs.ext4

# Allocate a 64 MB image file
dd if=/dev/zero of=$IMAGE bs=1M count=64

# Format as ext4 — small block size and inode ratio for embedded
mkfs.ext4 -b 1024 -i 4096 -L "rootfs" $IMAGE

# Mount loop and copy in our rootfs
sudo mount -o loop $IMAGE /mnt/target
sudo cp -a $ROOTFS/* /mnt/target/
sudo umount /mnt/target

# Verify and minimize
e2fsck -f $IMAGE
resize2fs -M $IMAGE     # shrink to minimum size

cpio Initramfs (in-memory rootfs)

cd $ROOTFS

# Build compressed cpio archive
find . | cpio -H newc -o | gzip -9 > ../initramfs.cpio.gz

# Check size
ls -lh ../initramfs.cpio.gz
# Should be around 1–3 MB for a BusyBox rootfs

SquashFS Read-Only Image

mksquashfs $ROOTFS rootfs.squashfs \
    -comp lz4 \
    -b 131072 \
    -noappend \
    -no-progress

Format Selection Guide

Format Writable Compression Use Case
ext4 Yes No SD card, eMMC, development
cpio/initramfs RAM only Yes (gzip/xz) Initramfs, tiny targets
SquashFS No (ro) Yes (lz4/zstd) Production read-only rootfs
UBIFS Yes Yes (LZO/zlib) Raw NAND flash

Testing with QEMU

{:.gc-adv}

Advanced

Boot with initramfs (cpio)

qemu-system-arm \
    -M vexpress-a9 \
    -cpu cortex-a9 \
    -m 256M \
    -kernel zImage \
    -initrd initramfs.cpio.gz \
    -append "console=ttyAMA0,115200 rdinit=/sbin/init" \
    -nographic

Boot with ext4 on Virtual SD Card

qemu-system-arm \
    -M vexpress-a9 \
    -cpu cortex-a9 \
    -m 256M \
    -kernel zImage \
    -drive file=rootfs.ext4,format=raw,if=sd \
    -append "console=ttyAMA0,115200 root=/dev/mmcblk0 rootfstype=ext4 rw init=/sbin/init" \
    -nographic

Common Kernel Panic: “No working init found”

This panic means the kernel mounted the rootfs but could not execute PID 1.

Kernel panic - not syncing: No working init found.
Try passing init= option to kernel. See Linux Documentation/admin-guide/init.rst

Debugging checklist:

Symptom Likely Cause Fix
No working init /sbin/init missing or wrong arch Verify BusyBox is at /sbin/init and compiled for correct arch
No working init Missing shared libraries Use CONFIG_STATIC=y or copy all .so files
No working init /dev/console missing mknod /dev/console c 5 1
VFS: Cannot open root device Wrong root= parameter Check device name: /dev/mmcblk0p2, /dev/sda1, etc.
Mounts ro, writes fail Kernel mounted rootfs read-only Add rw to kernel cmdline
# Verify binary architecture matches QEMU target
file $ROOTFS/bin/busybox
# bin/busybox: ELF 32-bit LSB executable, ARM, EABI5 version 1 (SYSV),
#              statically linked, stripped

Interview Q&A

{:.gc-iq}

Interview Q&A

Q1 — Why would you prefer static linking for a BusyBox-based initramfs?

An initramfs must work before the real rootfs is mounted — there is nowhere to load shared libraries from. Statically linking BusyBox embeds libc into the binary so it runs with zero external dependencies. For the final rootfs (where /lib exists), dynamic linking is preferable for security patch agility, but for the early-boot environment, static is the safe choice.

Q2 — What device nodes must exist in /dev for the system to boot to a BusyBox shell?

At minimum: /dev/console (c 5 1) so that init can open its controlling terminal, and /dev/null (c 1 3) so that redirections work. Without /dev/console, BusyBox init aborts with can't open /dev/console. The getty also needs the serial device node (e.g., /dev/ttyAMA0). Using devtmpfs automounts these from the kernel, removing the manual mknod requirement.

Q3 — What is the difference between BusyBox init and systemd?

BusyBox init is a small, sequential init system driven by a single /etc/inittab file. It has no dependency graph, no parallelism, no socket activation, no cgroups integration, and no journal. It is appropriate for systems booting in under a second where simplicity and small footprint matter. systemd is a full service manager with declarative unit files, parallel startup, service supervision, cgroup accounting, and journald logging — valuable on complex embedded or server systems but adds 3–10 MB to the rootfs and significant RAM overhead.

Q4 — In /etc/inittab, what is the difference between respawn and once?

respawn instructs BusyBox init to restart the process every time it exits — used for getty on a serial console so a new login prompt appears after a user logs out. once runs the process a single time and does not restart it if it exits. Use once for one-shot configuration tasks that run at boot and are expected to complete and exit.

Q5 — Why must /proc be mounted before many other init tasks?

/proc is required by tools like mount (reads /proc/mounts), ps, top, ifconfig, and udev/mdev. The mount -a command reads /etc/fstab and updates /proc/mounts. Attempting to start daemons or configure networking before /proc is mounted causes mysterious failures. Similarly, /sys must be mounted before any device enumeration.

Q6 — How would you add a custom applet to BusyBox?

Create mycmd.c in the BusyBox source tree, implementing int mycmd_main(int argc, char **argv). Register it in include/applets.h with the APPLET() macro, and add the corresponding Kconfig entry and Makefile rule. Run make menuconfig, enable the new applet under its menu category, rebuild, and the applet is available. The BusyBox wiki documents the full process under “Applets Development”.

Q7 — What kernel command-line parameters does BusyBox init respect?

BusyBox init reads init= (path to the init binary, default /sbin/init), rdinit= (path to init inside an initramfs), and per-applet parameters. It also respects single or 1 to enter single-user mode (runlevel 1), which spawns a single root shell without running rcS. The quiet parameter suppresses kernel messages but does not affect BusyBox init output.


References

{:.gc-ref}

References

Resource Link
BusyBox Official Site busybox.net
BusyBox FAQ busybox.net/FAQ.html
Buildroot Manual buildroot.org/downloads/manual/manual.html
Linux From Scratch — Creating a Boot Script linuxfromscratch.org/lfs
Bootlin Embedded Linux Training bootlin.com/training/embedded-linux
QEMU ARM vexpress-a9 qemu.org/docs/master/system/arm/vexpress.html
Linux kernel init documentation kernel.org/doc/html/latest/admin-guide/init.html
man 8 init (BusyBox) Run busybox init --help or man 8 init