How Do You Boot an ARM64 Linux System in QEMU on WSL?

Booting a full ARM64 Linux system on an x86 machine takes about ten minutes once you have the toolchain. You cross-compile two things: a minimal Linux kernel configured for QEMU's virt machine, and a BusyBox-based root filesystem packed into a cpio initramfs. Then one qemu-system-aarch64 command loads both and drops you into a shell. This is the same bring-up workflow kernel developers use to validate a new SoC before silicon arrives, and it runs identically on WSL2, a bare-metal Linux box, or a cloud VM.
In this guide you will build a bootable ARM64 system from scratch: compile BusyBox into a static root filesystem, cross-compile an aarch64 kernel with initramfs support, and launch the whole thing under QEMU. Every step includes the "why" behind the configuration choices, so you can adapt the workflow to your own kernel or rootfs later.
Key Takeaways
- A bootable ARM64 system needs only two artifacts: a cross-compiled kernel and a BusyBox initramfs. QEMU's
virtmachine provides the rest (UART, memory, CPU).- Start the kernel from
defconfig, notallnoconfig. The aarch64 defconfig already enables initramfs, devtmpfs, PL011 serial console, and printk, so you only toggle what your use case actually needs.- The initramfs is a
newc-format cpio archive, gzip-compressed. It contains a single statically-linked BusyBox binary, a set of symlinked applets, and a custominitscript that becomes PID 1.- QEMU's
-nographicflag routes the PL011 UART to your terminal, so the kernel console and the BusyBox shell both appear in the same window.- Over 350 billion ARM-based chips have shipped to date, with the last 100 billion arriving in under three years (ARM Holdings, 2025), making local ARM emulation a practical skill for kernel and embedded work.
What Are We Building?
The goal is a minimal but functional ARM64 Linux system that boots to a shell inside QEMU. Here is the end-to-end workflow:
┌─────────────────────────────────────────────────────────────┐
│ BUILD PHASE │
│ │
│ BusyBox source │
│ │ │
│ ▼ │
│ cross-compile (aarch64-linux-gnu-) ──► _install/ │
│ │ │ │
│ │ add init script, mkdir etc/proc/sys│ │
│ │ ▼ │
│ │ cpio + gzip │
│ │ │ │
│ │ ▼ │
│ │ initramfs.cpio.gz │
│ │ │
│ Linux kernel source │
│ │ │
│ ▼ │
│ defconfig + menuconfig (initramfs, PL011, devtmpfs) │
│ │ │
│ ▼ │
│ cross-compile (aarch64-linux-gnu-) ──► arch/arm64/boot/ │
│ Image │
└─────────────────────────────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────┐
│ BOOT PHASE │
│ │
│ qemu-system-aarch64 │
│ -machine virt -cpu cortex-a53 │
│ -kernel Image │
│ -initrd initramfs.cpio.gz │
│ -append "console=ttyAMA0" │
│ -nographic │
│ │ │
│ ▼ │
│ kernel boots ──► mounts initramfs ──► runs /init │
│ │ │
│ ▼ │
│ BusyBox shell (PID 2+) │
└─────────────────────────────────────────────────────────────┘
You end up with a running shell where standard Unix tools (ls, cat, mount, echo) all work, backed by a real Linux kernel running on an emulated Cortex-A53.
What Do You Need?
This workflow runs on any aarch64-capable Linux environment. WSL2 works because it provides a real Linux kernel with full qemu-system-aarch64 system emulation support.
Host requirements:
- A Linux environment with
apt(Ubuntu 22.04/24.04, Debian, or WSL2) - ~2 GB free disk space for sources and build output
- Internet access to download BusyBox and kernel sources
Install the build toolchain:
sudo apt-get update
sudo apt-get install -y bison flex libssl-dev \
gcc-aarch64-linux-gnu g++-aarch64-linux-gnu \
qemu-system-arm qemu-utils make cpio gzipbisonandflexare needed by the kernel's Kconfig parser and BusyBox's build system.gcc-aarch64-linux-gnuis the cross-compiler toolchain. Everymakecommand below passesCROSS_COMPILE=aarch64-linux-gnu-so the build emits aarch64 binaries even on an x86 host.libssl-devprovides the crypto headers the kernel needs for signature verification and KASLR.qemu-system-armprovidesqemu-system-aarch64(full 64-bit ARM system emulation).
Download the sources:
wget https://busybox.net/downloads/busybox-1.38.0.tar.bz2
wget https://cdn.kernel.org/pub/linux/kernel/v6.x/linux-6.12.tar.xzBusyBox 1.38.0 is the current stable release (BusyBox downloads). Linux 6.12 is a long-term support kernel with mature aarch64 and QEMU virt support.
How Do You Build the BusyBox Root Filesystem?
BusyBox gives you a complete userspace in a single binary. Cross-compile it for aarch64, install it into a staging directory, add an init script and the required mount points, then pack it all into a cpio ramdisk.
Configure BusyBox for a Static Build
tar jxvf busybox-1.38.0.tar.bz2
cd busybox-1.38.0
mkdir build
make O=build ARCH=arm64 defconfig
make O=build ARCH=arm64 menuconfigTwo config changes matter for a bootable rootfs:
- Build static binary (no shared libs): under Build Options. Static linking produces one self-contained binary with no
.sodependencies. Since the rootfs has no dynamic linker, a dynamically-linked BusyBox would fail to run. - Don't use /usr: under Build Options. This keeps the install layout flat (
bin/,sbin/) instead of the FHS/usrmerge, which simplifies the initramfs. - Cross compiler prefix: set to
aarch64-linux-gnu-under Build Options. This is equivalent to passingCROSS_COMPILEon the command line.
The O=build flag keeps all output in a separate directory, so you can rebuild from a clean tree without make clean.
Install and Assemble the Rootfs
make O=build ARCH=arm64 -j$(nproc)
make O=build ARCH=arm64 install
cd build/_installThe _install directory now contains the BusyBox binary and symlinked applets. Add the directories the kernel and init script expect:
mkdir -pv etc proc sys usr/bin usr/sbinetc/: placeholder for config files (fstab, inittab). Empty here.proc/andsys/: mount points for procfs and sysfs.usr/bin/andusr/sbin/: empty FHS-compliant directories some tools look for.
Write the init Script
The init script is the first userspace process the kernel runs (PID 1). Create it in the _install root:
cat > init << 'EOF'
#!/bin/sh
mount -t proc none /proc
mount -t sysfs none /sys
echo -e "\nBoot took $(cut -d' ' -f1 /proc/uptime) seconds\n"
exec /bin/sh
EOF
chmod +x initThis script mounts procfs and sysfs (so /proc/uptime and ps work), prints the boot time, then execs into a shell. Without a working init, the kernel panics with "No working init found" and halts.
Pack the initramfs
find . -print0 | cpio --null -ov --format=newc 2>/dev/null | gzip > ../initramfs.cpio.gzThe newc cpio format is what the kernel's initramfs loader expects. --null and -print0 handle filenames with spaces safely. The result is initramfs.cpio.gz one level above _install.
What Is Inside the initramfs?
The initramfs is the root filesystem the kernel mounts at boot. Understanding its contents explains why each build step exists.
initramfs.cpio.gz (newc cpio archive, gzip-compressed)
│
└── root/
├── init ← PID 1: mounts proc/sysfs, prints boot time, exec /bin/sh
│
├── bin/
│ ├── busybox ← single statically-linked aarch64 binary
│ ├── sh -> ../bin/busybox
│ ├── mount -> ../bin/busybox
│ ├── ls -> ../bin/busybox
│ ├── cat -> ../bin/busybox
│ ├── echo -> ../bin/busybox
│ ├── cut -> ../bin/busybox
│ └── ... ← one symlink per applet (100+ tools)
│
├── sbin/ ← sbin applets, also symlinks to busybox
├── usr/bin/ ← empty (FHS compliance)
├── usr/sbin/ ← empty (FHS compliance)
├── etc/ ← empty placeholder for configs
├── proc/ ← mount point for procfs
└── sys/ ← mount point for sysfs
Why one binary with many symlinks? BusyBox reads argv[0] (the name it was invoked as) to decide which applet to run. sh, mount, and ls are all the same binary behaving differently. Static linking means no shared libraries are needed in the rootfs.
Why cpio and not ext4? The kernel's initramfs loader expects a cpio archive. It decompresses directly into an initial tmpfs, with no block device or filesystem driver required. This keeps the build simple and the boot fast. (For a persistent rootfs you would use an ext4 image and -drive file=..., but cpio is the right choice for a bring-up workflow.)
How Do You Cross-Compile the aarch64 Kernel?
The kernel needs to know how to talk to QEMU's virt machine: which UART to use for the console, how to mount the root filesystem, and how to run the init process.
Start from defconfig, Not allnoconfig
Older tutorials start from allnoconfig and toggle dozens of options by hand. The modern aarch64 defconfig already enables almost everything a virt machine needs:
defconfig already provides:
CONFIG_BLK_DEV_INITRD=y initramfs/initrd support
CONFIG_DEVTMPFS=y automatic /dev management
CONFIG_DEVTMPFS_MOUNT=y mount devtmpfs at /dev after rootfs
CONFIG_SERIAL_AMBA_PL011=y PL011 UART driver
CONFIG_SERIAL_AMBA_PL011_CONSOLE=y PL011 as kernel console
CONFIG_PRINTK_TIME=y timestamped kernel messages
CONFIG_BINFMT_ELF=y ELF binary support (default on MMU)
CONFIG_PROC_FS=y /proc filesystem
CONFIG_SYSFS=y /sys filesystem
This means menuconfig only needs to verify or adjust a few options, not enable them from scratch.
Configure the Kernel
cd linux-6.12
mkdir build
make O=build ARCH=arm64 defconfig
make O=build ARCH=arm64 menuconfigVerify these options are set (they should be on by default from defconfig):
-> General setup
[*] Initial RAM filesystem and RAM disk (initramfs/initrd) support
-> General setup
-> Configure standard kernel features
[*] Enable support for printk
-> Executable file formats / Emulations
[*] Kernel support for ELF binaries
[*] Kernel support for scripts starting with #!
-> Device Drivers
-> Generic Driver Options
[*] Maintain a devtmpfs filesystem to mount at /dev
[*] Automount devtmpfs at /dev, after the kernel mounted the rootfs
-> Device Drivers
-> Character devices
[*] Enable TTY
-> Device Drivers
-> Character devices
-> Serial drivers
[*] ARM AMBA PL011 serial port support
[*] Support for console on AMBA serial port
-> File systems
-> Pseudo filesystems
[*] /proc file system support
[*] sysfs file system support
The PL011 (not PL010) is the UART QEMU's virt machine exposes. The kernel console attaches to it via the console=ttyAMA0 boot argument.
Build the Kernel
make O=build ARCH=arm64 CROSS_COMPILE=aarch64-linux-gnu- -j$(nproc)The output is build/arch/arm64/boot/Image (the uncompressed kernel image). QEMU loads it directly with -kernel.
How Do You Boot the System in QEMU?
One command ties the kernel and the initramfs together:
qemu-system-aarch64 \
-machine virt -cpu cortex-a53 -smp 1 -m 2G \
-kernel linux-6.12/build/arch/arm64/boot/Image \
-append "console=ttyAMA0" \
-initrd busybox-1.38.0/build/initramfs.cpio.gz \
-nographicWhat each flag does:
-machine virt: QEMU's generic ARM Virtual Platform. It provides a Cortex-A53 CPU, PL011 UART, GIC interrupt controller, and 2 GB of RAM. This is the standard target for aarch64 kernel development.-cpu cortex-a53: the CPU model.cortex-a53is in-order, energy-efficient, and well-supported. You can switch tomaxto expose all available features.-smp 1 -m 2G: one CPU core, 2 GB RAM. Increase-smpfor multi-core testing.-kernel: loads the aarch64 kernel image directly into guest memory, bypassing a bootloader. For a bring-up workflow this is simpler than using U-Boot or EFI.-append "console=ttyAMA0": tells the kernel to use the PL011 UART (ttyAMA0) as the console. This is the serial port QEMU'svirtmachine exposes.-initrd: loads the BusyBox initramfs as the initial ramdisk. The kernel mounts it as the root filesystem.-nographic: disables QEMU's graphical window and routes the UART to your terminal. The kernel boot log and the BusyBox shell both appear in the same window.
You should see the kernel boot messages scroll by, followed by the "Boot took X seconds" line from the init script, and then a # prompt. You are now running an ARM64 Linux shell on an emulated Cortex-A53.
To exit: press Ctrl+A then X (QEMU's escape sequence when using -nographic).
What Happens During Boot?
Understanding the boot sequence explains why each artifact is built the way it is.
QEMU starts
│
▼
kernel Image loaded at reset vector
│
▼
start_kernel() arch/arm64/kernel/head.S → init/main.c
│ set up MMU, page tables, exception vectors
│ identify CPU, initialize scheduler
▼
vfs_caches_init() mount initial tmpfs
│
▼
populate_rootfs() decompress initramfs.cpio.gz into tmpfs
│ (the cpio archive becomes the root directory)
▼
kernel_try_to_run_init_process("/init")
│
▼
/init (PID 1) BusyBox shell interprets the script
│ mount -t proc none /proc
│ mount -t sysfs none /sys
│ echo "Boot took $(cut -d' ' -f1 /proc/uptime) seconds"
│
▼
exec /bin/sh BusyBox sh applet (PID 2)
│
▼
# interactive shell rootfs is the initramfs, tools are BusyBox applets
The kernel's only hard requirement for booting is a working init process. Everything else (block drivers, network, graphical console) is optional. That is why this minimal system works: the kernel mounts the initramfs, runs /init, and the script hands control to a shell.
FAQ
Why not use a full distribution like Ubuntu instead of BusyBox?
A full distribution assumes a block device, a bootloader, and a hardware-specific device tree. BusyBox in an initramfs strips all of that away: the root filesystem lives in RAM, the kernel is loaded directly, and QEMU's virt machine provides a fixed device layout. For kernel bring-up and embedded development, this minimal environment is faster to build and easier to debug. Once the kernel boots here, you can layer on a real rootfs.
Can I use this to debug the kernel with GDB?
Yes. Add -s -S to the QEMU command (-s opens a GDB server on port 1234, -S pauses CPU at startup), then connect from another terminal with gdb-multiarch build/arch/arm64/boot/Image and target remote :1234. This is the standard QEMU + GDB kernel debugging workflow. The same setup is described in the ARM assembly environment guide for user-mode debugging.
Why does the guide use defconfig when the original used allnoconfig?
allnoconfig produces a kernel with almost nothing enabled. It is useful for finding config-dependent compile bugs, but it forces you to manually enable every feature a bootable system needs. defconfig is the distribution-default config: it enables initramfs, devtmpfs, the PL011 console, printk, and the common filesystems. For a bring-up workflow, defconfig gets you to a shell faster and you only toggle the options your specific use case requires.
Does this work on WSL1?
No. WSL1 is a translation layer, not a real Linux kernel, and it cannot run qemu-system-aarch64 system emulation. You need WSL2, which runs a real Linux kernel in a lightweight VM. Install WSL2 with wsl --install -d Ubuntu, then run the steps above inside the WSL2 terminal.
How do I add a real filesystem instead of an initramfs?
Replace -initrd with -drive file=rootfs.ext4,format=raw,if=virtio and add root=/dev/vda to -append. You will also need to enable CONFIG_VIRTIO_BLK and CONFIG_EXT4_FS in the kernel. The initramfs approach in this guide is simpler for initial bring-up; switch to a persistent drive once you need to test filesystem behavior.
Conclusion
You now have a complete, bootable ARM64 Linux system running in QEMU: a cross-compiled kernel and a BusyBox initramfs that boots to a shell in seconds. The same workflow scales to real hardware (swap the virt machine for your SoC's device tree) and to automated testing (drive QEMU headlessly from a CI script).
From here, the usual next steps are: enable networking with -netdev user -device virtio-net-device to test the network stack, load a kernel module to exercise your driver code, or attach GDB and step through early boot. The minimal environment in this guide is the foundation all of those build on.
Sources
- ARM Holdings, "Company Overview" (shipment and market-share data), 2025, https://www.arm.com/company
- BusyBox, "Downloads" (current stable release 1.38.0), https://busybox.net/downloads/
- The Linux Kernel Archives, "Linux kernel 6.12" (LTS), https://cdn.kernel.org/pub/linux/kernel/v6.x/
- QEMU, "Documentation/Platforms/ARM" (virt machine and CPU documentation), https://www.qemu.org/docs/master/system/arm/virt.html
- learn-arm-assembly-language (related post: ARM assembly + QEMU + GDB on WSL), https://findns.cc/posts/learn-arm-assembly-language/