Recently, I’ve been spending my spare time on a systems programming project. Before long I ran into a problem: using the latest kernel features isn’t exactly easy. Most distributions lag behind the latest kernel releases, and I’m not bold enough to risk upgrading the kernel on my only Linux machine (using the installkernel command). So I needed a way to set up a development environment with a newer kernel while keeping my system safe.
Containers can’t help with this — virtual machines are the right choice.
I once wrote a brief QEMU introduction. But honestly, my QEMU knowledge didn’t go beyond that article. Figuring out the content for this article took me some time. In the process, I also found that most online resources either contained poorly-explained scripts or had too many redundant options. I think it’s worthwhile to document this process here in a simple, well-explained way.
Start by Compiling the Kernel, Of Course
If all you need is a kernel that runs inside a VM and you don’t need deep customization, compiling the kernel is actually quite simple. There’s a brief tutorial, but what we’re about to do is even simpler.
First, download the kernel source. If you don’t need to modify the source, downloading from kernel.org is the easiest. Here I’ve downloaded the stable version at the time of writing (6.17.9).
wget https://cdn.kernel.org/pub/linux/kernel/v6.x/linux-6.17.9.tar.xz
tar -xf linux-6.17.9.tar.xz
cd ./linux-6.17.9
Next, configure the build options. This creates the build configuration file .config.
make defconfig
make kvm_guest.config
The first command selects the default build configuration. The second adds build options on top of the defaults so the compiled image can run as a KVM guest. If you have other requirements, you can continue configuring from here.
Then simply compile. Since this only includes the minimal configuration for running as a VM and no drivers need to be compiled, this process should be quick. It took just one minute on my laptop.
make -j $(nproc)
The compiled kernel image is at arch/x86/boot/bzImage. For the x86 architecture, of course.
file arch/x86/boot/bzImage
# arch/x86/boot/bzImage: Linux kernel x86 boot executable bzImage, version 6.17.9 (wokron@wokron-navi) #2 SMP PREEMPT_DYNAMIC Sun Nov 30 12:34:56 CST 2025, RO-rootFS, swap_dev 0XD, Normal VGA
Preparing the Root Filesystem
A Simple Root Filesystem
This is similar to what I covered in a previous article about containers. If we don’t care about practical usability, we can already boot a kernel right now.
The following only demonstrates the principle with a simple approach. For the actual steps, skip ahead to here.
As an aside, if you skip this section’s method and instead use your current Linux system’s own initrd (rather than a distribution’s filesystem), running the kernel is even simpler. Try this:
qemu-system-x86_64 \ -kernel /path/to/your/bzImage \ -m 2G \ -append "console=ttyS0" \ -initrd /boot/initrd.img \ -nographic
We just need to download a distribution’s root filesystem and make it into a filesystem image. Let’s use Ubuntu 22.04 as an example.
truncate -s 512M ubuntu-22.04.img
mkfs.ext4 -F ubuntu-22.04.img
wget https://cdimage.ubuntu.com/ubuntu-base/releases/22.04/release/ubuntu-base-22.04-base-amd64.tar.gz
mkdir ubuntu_root
sudo mount -o loop ./ubuntu-22.04.img ./ubuntu_root/
sudo tar -xzvf ubuntu-base-22.04-base-amd64.tar.gz -C ./ubuntu_root/
sudo umount ./ubuntu_root/
rmdir ./ubuntu_root/
This creates an image.
file ubuntu-22.04.img
# ubuntu-22.04.img: Linux rev 1.0 ext4 filesystem data, UUID=40550120-faa4-46a8-ab59-67c9cdf887e3 (extents) (64bit) (large files) (huge files)
The key here is that we use mkfs.ext4 -F to format a regular file ubuntu-22.04.img as an ext4 filesystem, then copy the Ubuntu root filesystem contents into this file.
Then use QEMU to run the following command to boot the VM with our freshly compiled kernel:
qemu-system-x86_64 \
-kernel /path/to/your/bzImage \
-m 2G \
-append "console=ttyS0 root=/dev/sda" \
-drive format=raw,file=./ubuntu-22.04.img \
-nographic
-kernel specifies the kernel image to run; -m provides 2G of memory; -append sets kernel boot parameters: use serial console, and the root filesystem is on device /dev/sda. -drive provides the root filesystem image we just built, i.e., device /dev/sda. -nographic disables graphical output so QEMU only outputs text.
Wait for the logs to scroll for a while, and you should see a shell prompt. You’ve succeeded — try running various commands.
...
# ls
bin dev home lib32 libx32 media opt root sbin sys usr
boot etc lib lib64 lost+found mnt proc run srv tmp var
To exit QEMU, press
Ctrl+AthenC.
But this environment is practically unusable. The VM is missing many tools and configurations, and we can’t access the network to install needed packages.
We need a more complete environment. Let’s prepare the root filesystem from scratch.
A Complete Root Filesystem
The following is inspired by syzkaller’s create-image.sh script, but significantly simplified. Our goal isn’t fuzz testing, after all.
We’ll use a tool called debootstrap. This command can build a complete Debian filesystem (Ubuntu works too) within a directory. It’s just a script with no dependencies.
sudo apt-get install debootstrap
Then use it to build a complete Ubuntu filesystem. Use --include to specify packages to install — we really only need systemd. But for convenience, I’ve also included openssh-server. This command builds a root filesystem for the $RELEASE distribution at $ROOT_DIR. You can optionally specify a $MIRROR at the end; leaving it empty uses the default mirror.
MIRROR=...
RELEASE=noble
ROOT_DIR=noble
sudo debootstrap --include systemd,openssh-server $RELEASE $ROOT_DIR $MIRROR
After installation, modify the root user’s password. Since this is just a development environment, we’ll simply disable the password (i.e., remove the x from the password field in /etc/passwd).
set -u
sudo sed -i '/^root/ { s/:x:/::/ }' $ROOT_DIR/etc/passwd
Be careful! Make sure
ROOT_DIRis set before running this command. We check viaset -uhere.
Next, configure networking. Run the following to create /etc/netplan/01-netcfg.yaml. This configuration lets the eth0 device obtain an IP address automatically via DHCP.
cat << EOF | sudo tee -a $ROOT_DIR/etc/netplan/01-netcfg.yaml
network:
version: 2
renderer: networkd
ethernets:
eth0:
dhcp4: yes
EOF
Ubuntu uses netplan for network configuration; Debian uses a different method. See syzkaller’s script.
Then modify /etc/fstab to mount /dev/root as the root filesystem. This is likely to control subsequent mount behavior — without this entry, the boot will fail.
echo '/dev/root / ext4 defaults 0 0' | sudo tee -a $ROOT_DIR/etc/fstab
Since we installed SSH, let’s create an SSH key for later access.
ssh-keygen -f $RELEASE.id_rsa -t rsa -N ''
sudo mkdir -p $ROOT_DIR/root/.ssh
cat $RELEASE.id_rsa.pub | sudo tee -a $ROOT_DIR/root/.ssh/authorized_keys
Finally, create the root filesystem image as before.
MOUNT_DIR=${RELEASE}_rootfs
truncate -s 4G $RELEASE.img
mkfs.ext4 -F $RELEASE.img
mkdir $MOUNT_DIR
sudo mount -o loop $RELEASE.img $MOUNT_DIR
sudo cp -a $ROOT_DIR/. $MOUNT_DIR/
sudo umount $MOUNT_DIR
rmdir $MOUNT_DIR
Running the VM
Finally, use QEMU to run our kernel.
qemu-system-x86_64 \
-enable-kvm \
-m 8G \
-smp 4 \
-kernel /path/to/your/bzImage \
-append "console=ttyS0 root=/dev/vda net.ifnames=0" \
-drive file=/path/to/your.img,format=raw,if=virtio \
-device virtio-net-pci,netdev=net0 \
-netdev user,id=net0,hostfwd=tcp:127.0.0.1:10022-:22 \
-nographic
Where:
-enable-kvmenables KVM acceleration;-msets 8G of memory;-smpsets 4 CPU cores;-kernelspecifies the kernel image;-appendsets kernel options:console=ttyS0for serial console,root=/dev/vdafor the root filesystem device,net.ifnames=0disables interface renaming so oureth0configuration works correctly.-drivespecifies the root filesystem image, withif=virtiofor the virtio interface type, corresponding to device/dev/vda.-deviceadds a network card, typevirtio-net-pci(virtual NIC), namedeth0(matching the configuration).-netdev user,id=net0sets up “user networking” between the host and guest onnet0, where the VM’s IP is provided by QEMU’s DHCP service.hostfwd=tcp:127.0.0.1:10022-:22forwards the VM’s port 22 (SSH) to the host’s port 10022. Now the host can SSH into the VM by connecting to its own port 10022.-nographicdisables graphics, using serial console.
Run the above command to start the VM. After a short while, a login prompt appears. Enter root as the username and log in directly (no password).
Ubuntu 24.04 LTS wokron-navi ttyS0
wokron-navi login: root
Welcome to Ubuntu 24.04 LTS (GNU/Linux 6.17.9 x86_64)
* Documentation: https://help.ubuntu.com
* Management: https://landscape.canonical.com
* Support: https://ubuntu.com/pro
root@wokron-navi:~#
Check the kernel version — it should match what we compiled.
uname -r
# 6.17.9
Check network device information. We can see our configured NIC eth0. Its address 10.0.2.15 was automatically obtained from QEMU via DHCP.
ip addr
# 1: lo: <LOOPBACK,UP,LOWER_UP> mtu 65536 qdisc noqueue state UNKNOWN group default qlen 1000
# link/loopback 00:00:00:00:00:00 brd 00:00:00:00:00:00
# inet 127.0.0.1/8 scope host lo
# valid_lft forever preferred_lft forever
# inet6 ::1/128 scope host noprefixroute
# valid_lft forever preferred_lft forever
# 2: eth0: <BROADCAST,MULTICAST,UP,LOWER_UP> mtu 1500 qdisc pfifo_fast state UP group default qlen 100
# link/ether 52:54:00:12:34:56 brd ff:ff:ff:ff:ff:ff
# altname enp0s3
# inet 10.0.2.15/24 metric 100 brd 10.0.2.255 scope global dynamic eth0
# valid_lft 86244sec preferred_lft 86244sec
# inet6 fec0::5054:ff:fe12:3456/64 scope site dynamic mngtmpaddr noprefixroute
# valid_lft 86247sec preferred_lft 14247sec
# inet6 fe80::5054:ff:fe12:3456/64 scope link
# valid_lft forever preferred_lft forever
# 3: sit0@NONE: <NOARP> mtu 1480 qdisc noop state DOWN group default qlen 1000
# link/sit 0.0.0.0 brd 0.0.0.0
The VM should now have internet access. You can install packages via apt.
apt-get install vim
On the host, use the previously generated SSH private key to log into the VM:
ssh -i /your/path/to.id_rsa -p 10022 root@localhost