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
/ # poweroffThe 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: doneThe 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 LinuxA 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=hd0The 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 /bootStop 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=yThe 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 0One 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.scrThe 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
| Symptom | Cause |
|---|---|
VFS: Cannot open root device "vda1" or unknown-block(0,0), then Please append a correct "root=" boot option and a partition list | The 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.sfdiskpartitions a regular file, andconv=notruncis what stops theddthat fills the partition destroying the table.- A virtio block device on the virt machine is
-drive ... if=none,id=Xplus-device virtio-blk-device,drive=X, in one of eight virtio-mmio slots. virtio scanmust run first, and0:1rather than0addresses the partition instead of the raw disk.rootwaitturns 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_NOWHEREthe environment does not survive a reset. A legacyboot.scrcannot 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.
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.




