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+A then C.

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_DIR is set before running this command. We check via set -u here.

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-kvm enables KVM acceleration;
  • -m sets 8G of memory;
  • -smp sets 4 CPU cores;
  • -kernel specifies the kernel image;
  • -append sets kernel options: console=ttyS0 for serial console, root=/dev/vda for the root filesystem device, net.ifnames=0 disables interface renaming so our eth0 configuration works correctly.
  • -drive specifies the root filesystem image, with if=virtio for the virtio interface type, corresponding to device /dev/vda.
  • -device adds a network card, type virtio-net-pci (virtual NIC), named eth0 (matching the configuration).
  • -netdev user,id=net0 sets up “user networking” between the host and guest on net0, where the VM’s IP is provided by QEMU’s DHCP service. hostfwd=tcp:127.0.0.1:10022-:22 forwards 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.
  • -nographic disables 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