Skip to main content

TECH VEDA

Embedded Linux on Edge-AI 23rd Sept 2026 enrollingLinux kernel & Device drivers starts on 24th Oct 2026 enrollingCorporate on-site training - Submit proposal Pick your modulesSharpen your kernel skills: deep dives, drivers, Yocto, CVEs, careers — updated daily. Read the blog →Embedded Linux fast track starts 30th sept 2026 enrollingEmbedded Linux Mastery track starts 30th sept 2026 enrollingLinux systems engineering starts 30th sept 2026 enrolling
Tutorials

Boot a Custom Linux Kernel in QEMU with a Minimal Initramfs

Boot a custom Linux kernel in QEMU with a minimal BusyBox initramfs: build the cpio, wire the console, and insmod a module to test drivers without hardware.

Boot a Custom Linux Kernel in QEMU with a Minimal Initramfs

To boot a custom Linux kernel in QEMU you need three things: the kernel image you built, a small root filesystem packed as a compressed cpio archive (the initramfs), and a matching serial console on the kernel command line. This tutorial builds a static BusyBox initramfs, packs it, and boots it under QEMU on x86_64 and arm64, then loads a kernel module inside the guest so you can test driver code without touching hardware.

Testing kernel and driver changes on real boards is slow: you cross-compile, copy an image, reflash, and reboot for every edit. For a large amount of kernel work you do not need the board at all. You can boot your freshly built kernel in QEMU with a tiny root filesystem in a second or two, get a shell, load a module, read dmesg, and iterate. This tutorial shows the complete, reproducible path to run a Linux kernel in QEMU with a minimal initramfs, and then to load an out-of-tree module inside that guest for driver testing.

What you need

  • A built Linux kernel tree. For x86_64 that produces arch/x86/boot/bzImage; for arm64 it produces arch/arm64/boot/Image. A plain make defconfig build is enough to start.
  • QEMU installed: the qemu-system-x86_64 and qemu-system-aarch64 binaries.
  • Build tools (gcc, make) and, for the arm64 path, an aarch64-linux-gnu- cross toolchain.
  • The BusyBox source, to build a single static binary that provides the shell and core utilities.

Three kernel options must be set (they are on by default in defconfig): CONFIG_BLK_DEV_INITRD for initramfs support, CONFIG_DEVTMPFS so /dev can be populated at runtime, and the serial console driver for your target — CONFIG_SERIAL_8250_CONSOLE on x86_64 (device ttyS0) and CONFIG_SERIAL_AMBA_PL011 on the arm64 virt board (device ttyAMA0). To load modules you also want CONFIG_MODULES and CONFIG_MODULE_UNLOAD.

Build a static BusyBox initramfs

The initramfs is just a directory tree that the kernel unpacks into a RAM filesystem and runs. Build BusyBox as a single statically linked binary so the root filesystem needs no shared libraries.

raghu@techveda.org:~$ wget https://busybox.net/downloads/busybox-1.36.1.tar.bz2
raghu@techveda.org:~$ tar xf busybox-1.36.1.tar.bz2
raghu@techveda.org:~$ cd busybox-1.36.1
raghu@techveda.org:~$ make defconfig
raghu@techveda.org:~$ sed -i 's/# CONFIG_STATIC is not set/CONFIG_STATIC=y/' .config
raghu@techveda.org:~$ make -j$(nproc)

Now create the initramfs directory and install BusyBox into it. The install step creates bin, sbin, and usr with symlinks such as sh, mount, and insmod all pointing at the one binary.

raghu@techveda.org:~$ mkdir -p ~/initramfs
raghu@techveda.org:~$ make CONFIG_PREFIX=~/initramfs install
raghu@techveda.org:~$ cd ~/initramfs
raghu@techveda.org:~$ mkdir -p proc sys dev tmp

Create the init script and device nodes

When the kernel finishes unpacking the initramfs it runs /init as PID 1. If that file is missing or not executable, the kernel stops with “No working init found”. Create a small init script that mounts the pseudo-filesystems and drops you into a shell.

raghu@techveda.org:~$ cat > ~/initramfs/init << 'EOF'
#!/bin/sh
mount -t proc     none /proc
mount -t sysfs    none /sys
mount -t devtmpfs none /dev
echo
echo "Booted into the custom initramfs. Kernel: $(uname -r)"
echo
exec /bin/sh
EOF
raghu@techveda.org:~$ chmod +x ~/initramfs/init

Before it mounts devtmpfs, the kernel needs /dev/console to exist so it can attach init’s standard input and output. Create that node (and /dev/null) as static device files. This step needs root because it creates device nodes.

raghu@techveda.org:~$ sudo mknod -m 622 ~/initramfs/dev/console c 5 1
raghu@techveda.org:~$ sudo mknod -m 666 ~/initramfs/dev/null    c 1 3

Pack the initramfs into a cpio archive

The kernel reads the initramfs as a newc-format cpio archive, optionally gzip-compressed. Building the archive does not need root — reading the device-node metadata created above is enough.

raghu@techveda.org:~$ cd ~/initramfs
raghu@techveda.org:~$ find . -print0 | cpio --null --create --format=newc 2>/dev/null | gzip > ~/initramfs.cpio.gz
raghu@techveda.org:~$ ls -l ~/initramfs.cpio.gz
-rw-r--r-- 1 raghu raghu 812431 Jul 28 09:40 /home/raghu/initramfs.cpio.gz

The exact size will differ with your BusyBox configuration; a static default build lands around 700 KB to 1 MB compressed. This archive is the second of the three inputs a Linux kernel in QEMU needs, after the kernel image itself.

Boot the Linux kernel in QEMU

Point QEMU at the kernel image and the cpio archive, and tell the kernel to use the emulated serial port as its console. On x86_64 that port is ttyS0.

raghu@techveda.org:~$ qemu-system-x86_64 \
    -kernel ~/linux/arch/x86/boot/bzImage \
    -initrd ~/initramfs.cpio.gz \
    -append "console=ttyS0" \
    -nographic

With -nographic the guest serial console is connected to your terminal, so you see the boot messages and then the shell. The tail of the boot looks like this:

[    1.842190] Run /init as init process

Booted into the custom initramfs. Kernel: 6.12.0

/ # uname -a
Linux (none) 6.12.0 #1 SMP PREEMPT_DYNAMIC x86_64 GNU/Linux
/ # cat /proc/cmdline
console=ttyS0

The / # prompt is the BusyBox shell running inside the guest. To leave QEMU when using -nographic, press Ctrl-A then x.

Boot the same setup on arm64

The same initramfs works on the arm64 virt board; only the QEMU binary, the kernel image path, and the console device change. The rest of the Linux kernel in QEMU workflow is unchanged. The virt board exposes a PL011 UART, which the kernel names ttyAMA0.

raghu@techveda.org:~$ qemu-system-aarch64 \
    -M virt -cpu cortex-a57 -m 512M \
    -kernel ~/linux/arch/arm64/boot/Image \
    -initrd ~/initramfs.cpio.gz \
    -append "console=ttyAMA0" \
    -nographic

If you built BusyBox for x86_64, rebuild it for arm64 with the cross toolchain (make ARCH=arm64 CROSS_COMPILE=aarch64-linux-gnu- ...) before packing the archive, because the static binary is architecture-specific. The kernel image must also be an arm64 build.

Load a kernel module for driver testing

This is where the setup earns its place. Build a small out-of-tree module against the same kernel tree, place it in the initramfs, repack, and load it inside the guest. First the module source and its Makefile:

/* hello.c */
#include <linux/module.h>
#include <linux/init.h>

static int __init hello_init(void)
{
        pr_info("hello: module loaded\n");
        return 0;
}

static void __exit hello_exit(void)
{
        pr_info("hello: module unloaded\n");
}

module_init(hello_init);
module_exit(hello_exit);
MODULE_LICENSE("GPL");
MODULE_DESCRIPTION("Minimal module for QEMU testing");
# Makefile
obj-m := hello.o
KDIR  := $(HOME)/linux

all:
	$(MAKE) -C $(KDIR) M=$(PWD) modules
clean:
	$(MAKE) -C $(KDIR) M=$(PWD) clean

Build the module, copy the resulting hello.ko into the initramfs, and repack the cpio archive.

raghu@techveda.org:~$ make
raghu@techveda.org:~$ cp hello.ko ~/initramfs/
raghu@techveda.org:~$ cd ~/initramfs
raghu@techveda.org:~$ find . -print0 | cpio --null --create --format=newc 2>/dev/null | gzip > ~/initramfs.cpio.gz

Boot the guest again with the same QEMU command, then load the module from inside the guest shell. At the / # prompt:

/ # insmod /hello.ko
/ # dmesg | tail -n 2
[   12.004431] hello: module loaded
/ # rmmod hello
/ # dmesg | tail -n 1
[   20.118902] hello: module unloaded

Running a Linux kernel in QEMU this way gives you a full edit-build-test loop that never leaves your workstation: change the module or the kernel, rebuild, repack, and boot again. If rmmod reports that the operation is not permitted, rebuild the kernel with CONFIG_MODULE_UNLOAD enabled. For a deeper treatment of writing and structuring drivers that you test this way, see TECH VEDA’s Linux device drivers course.

Key takeaways

  • Booting a Linux kernel in QEMU needs only three inputs: the kernel image, a cpio-packed initramfs, and a console= line matching the emulated UART.
  • A static BusyBox binary plus a short /init script is a complete, minimal root filesystem.
  • Create /dev/console as a static node so the kernel can open init’s console before devtmpfs is mounted.
  • Use ttyS0 on x86_64 and ttyAMA0 on the arm64 virt board; the initramfs itself is portable, but the BusyBox binary and kernel image are architecture-specific.
  • Copying a .ko into the initramfs gives you a fast, hardware-free loop for testing driver code with insmod, dmesg, and rmmod.
Was this worth your time?

Frequently asked questions

What is the difference between an initrd and an initramfs?
An initramfs is a cpio archive that the kernel unpacks directly into a RAM-based filesystem and runs as the root, while a classic initrd is a block-device image mounted through an extra layer. This tutorial uses an initramfs, passed with QEMU’s -initrd option and packed in newc cpio format.

Why does the kernel stop with “No working init found”?
The kernel runs /init from the initramfs as PID 1. If that file is missing, not executable, or the archive was packed incorrectly, the kernel has nothing to run and stops. Make sure /init exists at the top of the archive and has the executable bit set.

Do I need a cross toolchain to boot the arm64 example?
Yes. The arm64 kernel image and the BusyBox binary must both be built for arm64 with an aarch64-linux-gnu- toolchain. The init script and the cpio packing steps are the same across architectures.

How do I exit QEMU when it is running with -nographic?
Press Ctrl-A, release, then press x. This is the QEMU serial-console escape sequence that terminates the emulator.

Further reading

RB
Raghu Bharadwaj

Founder, TECH VEDA — 20+ years teaching the Linux kernel, device drivers and embedded systems.

Follow on LinkedIn

Get new posts by email

Kernel, embedded Linux and AI-era engineering — a few sharp reads a month. No spam.

We email occasionally and never share your address.