Skip to main content

TECH VEDA

Linux 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

Building Linux for a RISC-V Board — Part 3: A Root Filesystem on a virtio Block Device

Put a real root filesystem on a virtio block device, let U-Boot find it, and boot it from a script instead of retyped addresses.

Building Linux for a RISC-V Board — Part 3: A Root Filesystem on a virtio Block Device

Three things must line up before Linux will mount a root filesystem from a virtio block device: the filesystem has to sit inside a partition at an offset U-Boot can find, U-Boot has to scan the virtio bus before it can read that partition, and the kernel has to carry the virtio-blk driver built in rather than as a module on the disk it is trying to reach. The image can be built without root privileges, because mke2fs populates a filesystem from a directory with -d. A boot script on that disk then replaces every address you have been typing by hand.

Part 2 finished with a kernel running on firmware and a bootloader you built yourself, but every byte it executed came from memory QEMU had filled before the machine started, at addresses you re-stated by hand on every boot. This part gives the board storage: a partition table, an ext4 root filesystem and the kernel image on a virtio block device, and a boot script on that disk so nothing has to be typed.

What you need before you start

The ~/rv directory as Parts 1 and 2 left it: linux-6.12.111 built, the initramfs tree with the static BusyBox 1.38.0 binary, opensbi at v1.9 and u-boot at v2026.07. No new packages, and nothing here needs sudo. Below, raghu@techveda.org:~$ is your host shell, => is U-Boot, and / # is the guest’s BusyBox shell.

Why the initramfs stops being enough

/ # mount
rootfs on / type rootfs (rw,...)
/ # echo saved > /saved
/ # cat /saved
saved
/ # poweroff

The write succeeds, and after a reboot the file is gone: the filesystem it went into was unpacked into RAM from a cpio archive and discarded with the RAM. That is the obvious limit. The one that drives product decisions is that an initramfs is a single blob the previous stage delivers in one piece, so changing one line of a start-up script means rebuilding, re-transferring and, on a signed system, re-signing the whole image. It is also decompressed into memory in full before init runs, so the RAM cost is the uncompressed size of everything you ship, read or not.

Build the root filesystem without root privileges

raghu@techveda.org:~/rv$ cp -a initramfs rootfs
raghu@techveda.org:~/rv$ mkdir -p rootfs/sbin rootfs/etc rootfs/root rootfs/boot
raghu@techveda.org:~/rv$ cp linux-6.12.111/arch/riscv/boot/Image rootfs/boot/

Part 1 passed rdinit=/init, a program inside the initramfs. On a disk root the parameter is init=, and with nothing passed the kernel works through a built-in list in init/main.c:

if (!try_to_run_init_process("/sbin/init") ||
    !try_to_run_init_process("/etc/init") ||
    !try_to_run_init_process("/bin/init") ||
    !try_to_run_init_process("/bin/sh"))
raghu@techveda.org:~/rv$ cat > rootfs/sbin/init <<'EOF'
#!/bin/sh
mount -t proc none /proc
mount -t sysfs none /sys
echo "TECH VEDA RISC-V root filesystem is up"
cat /proc/cmdline
exec /bin/sh
EOF
raghu@techveda.org:~/rv$ chmod +x rootfs/sbin/init

/sbin/init therefore runs with no kernel argument, and Part 1’s /init is ignored unless named. Compare the script with Part 1’s: the mount -t devtmpfs none /dev line is gone, because the kernel now does it. CONFIG_DEVTMPFS_MOUNT, which the RISC-V defconfig sets, automounts devtmpfs at /dev “directly after the kernel has mounted the root filesystem”, and the same drivers/base/Kconfig help text states the exception that explains Part 1: “This option does not affect initramfs based booting, here the devtmpfs filesystem always needs to be mounted manually.”

raghu@techveda.org:~/rv$ truncate -s 256M rootfs.ext4
raghu@techveda.org:~/rv$ mke2fs -t ext4 -L rootfs -d rootfs -F rootfs.ext4
mke2fs 1.47.0 (5-Feb-2023)
Creating filesystem with 65536 4k blocks and 65536 inodes
Filesystem UUID: 5062cc94-d422-4d66-9891-54bd058aee57
...
Copying files into the device: done
Writing superblocks and filesystem accounting information: done

The line that matters is Copying files into the device: done, and it is there because of -d, which the mke2fs manual describes as copying “the contents of the given directory or tarball into the root directory of the file system”. It writes through the ext2 library, so no loop device, no mount and no root privilege are involved. Ownership comes from your host: a file you own as a normal user lands in the image owned by your UID.

Lay out the disk image

raghu@techveda.org:~/rv$ truncate -s 300M disk.img
raghu@techveda.org:~/rv$ printf 'label: dos
start=1MiB, size=256MiB, type=83, bootable
' | sfdisk disk.img
...
Created a new DOS disklabel with disk identifier 0x155fbe7c.
disk.img1: Created a new partition 1 of type 'Linux' and of size 256 MiB.
...
Device     Boot Start    End Sectors  Size Id Type
disk.img1  *     2048 526335  524288  256M 83 Linux

A filesystem is not a disk, and both U-Boot and Linux want a partition table. sfdisk writes to a regular file as readily as to a block device. type=83 is the MBR code for a Linux filesystem, and leaving start= at its default puts the partition at 1 MiB, or 2048 sectors of 512 bytes, which is what makes the next step a plain dd.

raghu@techveda.org:~/rv$ dd if=rootfs.ext4 of=disk.img bs=1M seek=1 conv=notrunc
raghu@techveda.org:~/rv$ dd if=disk.img bs=512 count=1 status=none | file -
/dev/stdin: DOS/MBR boot sector
raghu@techveda.org:~/rv$ dd if=disk.img bs=1M skip=1 count=1 status=none | file -
/dev/stdin: Linux rev 1.0 ext4 filesystem data, UUID=5062cc94-..., volume name "rootfs"

seek=1 skips one output block, which at bs=1M is the offset the partition starts at; conv=notrunc stops dd truncating the 300 MiB file down to the 256 MiB it writes, which would take the partition table with it. The two file readbacks are the check on the virtio block device you are about to attach: a boot sector at offset zero, an ext4 superblock at 1 MiB, the same UUID mke2fs printed. Get the offset wrong and the symptom arrives much later, as U-Boot reporting a partition it cannot read.

Attach the virtio block device to QEMU

raghu@techveda.org:~/rv$ qemu-system-riscv64 -machine virt -cpu rv64 -smp 2 -m 512M -nographic -bios opensbi/build/platform/generic/firmware/fw_jump.bin -kernel u-boot/u-boot.bin -drive file=disk.img,format=raw,if=none,id=hd0 -device virtio-blk-device,drive=hd0

The virt machine has, in QEMU’s own words, eight virtio-mmio transport slots; hw/riscv/virt.c creates them in a loop from a base of 0x10001000 with a 0x1000 stride and gives each a device-tree node. The two new arguments put a virtio block device in one of them. -drive ... if=none names the backing file without attaching it to any bus, and -device virtio-blk-device,drive=hd0 attaches it; that split is what lets one disk be handed to a device of your choosing. virtio-blk-device is the real type name, TYPE_VIRTIO_BLK in QEMU’s virtio-blk.h.

Find the virtio block device from U-Boot

=> virtio scan
=> virtio info
Device 0: QEMU VirtIO Block Device
            Type: Hard Disk
            Capacity: <size> MB = <size> GB (<n> x 512)
=> virtio part 0
=> ls virtio 0:1 /boot

Stop the autoboot countdown, and the virtio block device is not there until scan has run. U-Boot’s documentation calls that step “initialize virtio bus”, and it is the one most easily left out of a boot command, because every later command then fails in a way that points at the disk rather than the bus. 0:1 is device 0, partition 1; the load documentation notes the partition number “defaults to 0 (whole device)”, so virtio 0 reads from sector zero and finds the partition table instead. That load and ls work on ext4 comes from CONFIG_CMD_FS_GENERIC, CONFIG_CMD_EXT4 and CONFIG_DOS_PARTITION, none of which appear in qemu-riscv64_defconfig: board/emulation/qemu-riscv/Kconfig implies them, with VIRTIO_MMIO and VIRTIO_BLK.

Hand the kernel its root filesystem

=> load virtio 0:1 ${kernel_addr_r} /boot/Image
=> setenv bootargs 'console=ttyS0 root=/dev/vda1 rootwait rw'
=> booti ${kernel_addr_r} - ${fdtcontroladdr}

The dash is not a placeholder for you to fill in: U-Boot’s booti documentation gives it as the way to boot “a kernel with a device tree but no initial RAM disk”.

/dev/vda1 is the first partition of the first virtio block device. The vd prefix comes from drivers/block/virtio_blk.c, which calls virtblk_name_format("vd", index, ...) and counts in letters, so index 0 is vda. The same file registers its major with register_blkdev(0, "virtblk"): the zero means the kernel allocates whichever major is free, so there is no fixed number to rely on. Name the root by path, never by device number.

rootwait stops an intermittent failure from ever appearing. In init/do_mounts.c it sets root_wait = -1, and wait_for_root() then loops on driver_probe_done() and a lookup of the named device, printing Waiting for root device /dev/vda1... and sleeping 5 ms at a time. Without it the kernel looks once, and whether that succeeds depends on timing you do not control.

raghu@techveda.org:~/rv$ grep -E 'VIRTIO_BLK|VIRTIO_MMIO|EXT4_FS=|DEVTMPFS' linux-6.12.111/.config
CONFIG_VIRTIO_BLK=y
CONFIG_VIRTIO_MMIO=y
CONFIG_EXT4_FS=y
CONFIG_DEVTMPFS=y
CONFIG_DEVTMPFS_MOUNT=y

The driver has to be inside the kernel image, because a module for it would be a file on the filesystem the kernel is mounting. The defconfig Part 1 used gets this right; an =m on any of those symbols gives a kernel that boots perfectly and cannot find its root.

Why the console works before /dev exists

dir /dev 0755 0 0
nod /dev/console 0600 0 0 c 5 1
dir /root 0700 0 0

One detail of that boot is widely misread. The kernel opens its initial console in console_on_rootfs() with filp_open("/dev/console", O_RDWR, 0) and, on failure, prints Warning: unable to open an initial console. In kernel_init_freeable() that call comes before prepare_namespace(), where your root filesystem is mounted, so the console is opened from a filesystem that is not yours. The three entries above are that filesystem: usr/default_cpio_list, the default initramfs the kernel links in when you supply none, and its one character device node is why a disk-root boot has a console at all. Your own /dev becomes live a moment later.

Replace the typed commands with a boot script

Everything above still depends on you typing four commands, and the obvious fix, putting them in bootcmd, does not survive a reset here. qemu-riscv64_defconfig sets no CONFIG_ENV_IS_IN_* backend, so U-Boot’s ENV_IS_DEFAULT rule selects CONFIG_ENV_IS_NOWHERE, whose help text says the environment “will still exist while U-Boot is running, but once U-Boot exits it may not be stored”. There is nowhere for saveenv to write, which is the argument for keeping the sequence on the disk.

raghu@techveda.org:~/rv$ cat > boot.cmd <<'EOF'
setenv bootargs 'console=ttyS0 root=/dev/vda1 rootwait rw'
load virtio 0:1 ${kernel_addr_r} /boot/Image
booti ${kernel_addr_r} - ${fdtcontroladdr}
EOF
raghu@techveda.org:~/rv$ u-boot/tools/mkimage -A riscv -T script -C none -d boot.cmd rootfs/boot/boot.scr

The script goes into the rootfs tree rather than beside it, so the order is mkimage first, then mke2fs and dd again.

=> virtio scan
=> load virtio 0:1 ${scriptaddr} /boot/boot.scr
=> source ${scriptaddr}
=> setenv bootcmd 'virtio scan; load virtio 0:1 ${scriptaddr} /boot/boot.scr; source ${scriptaddr}'

scriptaddr is a U-Boot variable that exists for this, and using it keeps you out of the address bookkeeping Part 2 described. source runs the script, and its documentation names one dependency worth checking first: CONFIG_LEGACY_IMAGE_FORMAT is “default y if !FIT_SIGNATURE && !TI_SECURE_DEVICE” in boot/Kconfig. On a signed system it is deliberately off and a legacy boot.scr will not run; the replacement is a FIT script, built with mkimage -f and loaded the same way, since source takes both formats. That is the right direction for anything shipping, because a legacy script cannot be signed and a FIT one can. On hardware with an environment in flash, saveenv makes that bootcmd permanent, and the bootloader holds one line that never changes while the sequence it runs lives on the disk.

What breaks, and what each failure looks like

SymptomCause
VFS: Cannot open root device "vda1" or unknown-block(0,0), then Please append a correct "root=" boot option and a partition listThe device was not there when the kernel looked. Read that list: vda without vda1 means the partition table is wrong; nothing at all means the driver or rootwait is missing.
No working init found. Try passing init= option to kernel.Root mounted, and none of the four paths was executable. Usually a missing chmod +x, or an interpreter line naming a shell the image does not have.
Bad Linux RISCV Image magic!The address given to booti holds no Image, normally after a load whose error line scrolled past.

One habit saves most of this: after every load, check printenv filesize. U-Boot stores the byte count of the last file it read there, and a zero or absent value says the problem is upstream.

Limits, and what was not verified

This layout is the simplest one that works, not one to ship. There is a single copy of everything, so an interrupted update leaves an unbootable device, which is what A/B layouts exist for. The root is mounted read-write with a journal, so an unclean power cut means journal recovery: use poweroff rather than killing QEMU. Naming the root by path is fine with one virtio block device and fragile with two, and root=PARTUUID= is a change to the script and nothing else.

Every host command above was run as shown, and the mke2fs, sfdisk, dd and file output is that real output with host-specific values trimmed. The U-Boot excerpts follow the convention of Parts 1 and 2, showing the fields to compare with host-varying values as placeholders, because no RISC-V run was captured this week. Quoted source and configuration symbols were read from the file that defines them.

Key takeaways

  • mke2fs -t ext4 -d <dir> -F <image> builds a populated filesystem image with no loop device, no mount and no root privilege.
  • sfdisk partitions a regular file, and conv=notrunc is what stops the dd that fills the partition destroying the table.
  • A virtio block device on the virt machine is -drive ... if=none,id=X plus -device virtio-blk-device,drive=X, in one of eight virtio-mmio slots.
  • virtio scan must run first, and 0:1 rather than 0 addresses the partition instead of the raw disk.
  • rootwait turns a timing-dependent root-mount failure into a non-failure, and a virtio block device has no fixed major number, so name the root by path.
  • With CONFIG_ENV_IS_NOWHERE the environment does not survive a reset. A legacy boot.scr cannot be signed; a FIT script can.

What is next in this series

Part 4 moves the boot sequence into a FIT image: the kernel, the device tree and the script in one signed container, with U-Boot refusing anything whose signature does not check out.

This material is also covered as a structured programme in TECH VEDA’s embedded Linux training.

Was this worth your time?

Frequently asked questions

Do I need root privileges to build a root filesystem image?
No. mke2fs -d copies a directory tree into the image through the ext2 library, so no loop device and no mount is involved, and sfdisk and dd both work on a regular file. File ownership in the image comes from your host user.

Why does my kernel panic on VFS: Cannot open root device when the disk is attached?
Most often the kernel looked before the virtio probe finished, and rootwait makes it wait instead of checking once. The other common cause is a virtio-blk or ext4 driver built as a module, which cannot be loaded from the filesystem it is needed to mount.

Why does virtio 0 fail where virtio 0:1 works?
The partition number defaults to 0, which U-Boot defines as the whole device. On a partitioned virtio block device that reads a filesystem from sector zero, where the partition table is. Partition 1 is where the filesystem is.

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.