内核编译出来只是前半程,后半程是今天要聊的这件事:根文件系统。如果你手里刚有一个编译好的 bzImage,兴冲冲地用 QEMU 启动,多半会卡在这样一行日志上:Kernel panic - not syncing: VFS: Unable to mount root fs。别急,这不是内核坏了,而是内核找不到它的用户态世界。这篇文章就把在 QEMU 虚拟机里制作一个根文件系统、和内核一起跑起来的过程完整过一遍。适合刚编译完内核、还没见过登录提示符的人,也适合后面准备做内核移植和驱动调试的人。整个过程完全可以跟着敲,不需要服务器,一台普通 Linux 宿主机就够。
我会按自己的实际调试顺序来讲:先解释内核启动时到底在找什么,再对比 initramfs 和 ext4 磁盘镜像两条路线,然后从 BusyBox 搭最小根目录,到打包、启动、排错,最后落到持久化镜像。全程用 x86_64 的 QEMU 串口控制台演示,ARM64 的差异我会单独标注出来。
1. 启动链条里的那一环:内核编译完为什么还要做根文件系统
很多刚编译完内核的人都有个错觉:有了 vmlinuz/bzImage,系统就能自己跑起来。实际上内核只是启动链路的一半,它需要一个“用户态世界”才能完成启动。这个用户态世界,就是根文件系统(root filesystem,简称 rootfs)加上里面存着的 init 程序和应用工具。
1.1 内核态到用户态的那一跳
Linux 的启动过程,大致是这样一个顺序:
- QEMU 模拟固件/引导加载器加载内核映像,比如
-kernel bzImage。 - 内核解压、初始化内存管理、调度器、中断、各种驱动子系统。
- 内核挂载一个初始根文件系统。
- 内核在根文件系统里寻找并执行 init 进程,也就是 PID 1。
- init 进程读取配置、挂载其余文件系统,然后拉起 shell 或业务进程。
前两步属于内核态,后面的步骤需要有一个能访问的文件系统。也就是说,内核要“交出控制权”,必须有一个它可以挂着、并且里面放好了用户态程序的目录树。
可以把内核理解成发动机,rootfs 是底盘、方向盘和驾驶室。发动机再好,不接上底盘,车也动不了。QEMU 在这里扮演的是车间里的测试台,它只负责模拟硬件,并不负责替内核准备“驾驶室”。
1.2 VFS panic 到底在说什么
如果 QEMU 启动时只给了-kernel bzImage,没有给根文件系统,你大概率看到类似日志:
[ 0.000000] Linux version ... ... [ 1.234567] VFS: Cannot open root device "" or unknown-block(0,0) [ 1.234567] Please append a correct "root=" boot option [ 1.234567] Kernel panic - not syncing: VFS: Unable to mount root fs on unknown-block(0,0)这里的VFS是 Linux 虚拟文件系统层。它负责把各种文件系统(ext4、proc、sysfs、ramfs 等)统一成/下的目录视图。内核启动后期会调用 VFS 去挂载根设备,如果设备号不存在、驱动没编进内核、文件系统类型没启用,它就只能 panic。
所以“制作根文件系统”本质上要做两件事:准备一份目录树和用户态程序,然后用 QEMU 能识别的方式交给内核。目录树本身并不神秘,核心是这几个目录:
/bin /sbin /etc /dev /proc /sys /tmp /root这里面/bin、/sbin放可执行程序,/etc放配置,/dev放设备节点,/proc和/sys是内核暴露信息的虚拟文件系统挂载点。最小系统不需要很大,几 MB 就能跑起来。
2. 两条路线:initramfs 和 ext4 磁盘镜像怎么选
给内核准备 rootfs,常见有两种做法:initramfs 和块设备镜像。这两条路线的原理和适用场景差别很大,我在项目里都试过,先说结论:新手调试先走 initramfs,等启动逻辑稳定后再迁到 ext4 镜像验证持久化。
2.1 initramfs:常驻内存的临时根
initramfs 的本质是一个 cpio 格式的归档文件,里面打包了完整的目录树。内核启动时读入这个归档,解压到内存中的 rootfs(基于 ramfs/tmpfs),然后在这个内存文件系统里执行 init。
它的特点:
- 不需要真实块设备驱动,只要内核支持 initramfs 和 gzip 解压就能跑。
- 不占磁盘、启动快,非常适合做内核早期启动流程验证。
- 内容在内存里,掉电/重启后全部丢失。
我在做内核驱动调试时,最喜欢用 initramfs。因为驱动还没写好的时候,少一个存储控制器驱动,磁盘根本认不出来,而 initramfs 绕过了整个存储链路,问题是少一层就少一半。
2.2 ext4 镜像:持久化才是真实需求
块设备镜像是用dd建一个文件,格式化成 ext4,再把目录树放进去。QEMU 把这个文件模拟成一块硬盘,内核通过root=/dev/vda这样的参数去挂载它。
它的特点:
- 是真实的块设备,能验证存储栈、驱动、文件系统。
- 数据可以持久化,重启后仍在。
- 启动依赖更多内核配置,比如 virtio 驱动、ext4 文件系统支持。
做产品、做开发板系统,最后都会落到这种方案。它更接近真实机器,但也意味着任何一个环节没配对都会启动失败。
2.3 先内后外的调试顺序
我自己的习惯是“先内后外”。第一遍先用 initramfs 把 init 流程和 BusyBox 跑通,确认 shell 能起来;然后再把同一份目录树灌进 ext4 镜像,从内存根切换到磁盘根。这样如果 ext4 启动失败,基本可以断定问题出在存储驱动、root 参数或文件系统配置上,而不是 init 脚本写错。两步隔离,排查效率高很多。
下面这张表可以帮你快速判断该走哪条路:
| 对比项 | initramfs | ext4 镜像 |
|---|---|---|
| 存放位置 | 内存 rootfs | 虚拟块设备 |
| 持久化 | 否 | 是 |
| 驱动要求 | 低 | 高,需存储/总线驱动 |
| 启动速度 | 快 | 稍慢 |
| 适合场景 | 早期启动、驱动调试 | 系统开发、接近真实环境 |
3. 用 BusyBox 搭最小用户态和根目录骨架
目录树里不能只有 shell 一个程序,否则连ls、mount、cat都得自己用 C 语言重新实现。常规做法是引入 BusyBox,它把几百个常用命令集成到一个可执行文件里,通过符号链接暴露命令名。
3.1 静态编译 BusyBox,一次编译到处跑
BusyBox 的编译本身不复杂,真正容易踩坑的是动态链接。默认配置下 BusyBox 会动态链接到 glibc,生成的文件很小,但在一个空 rootfs 里没有 glibc 动态库,启动时就会报can't load library 'libm.so.6',非常难查。
所以我的建议是:最小系统里无条件开启静态编译。
wget https://busybox.net/downloads/busybox-1.36.1.tar.bz2 tar jxf busybox-1.36.1.tar.bz2 cd busybox-1.36.1 make defconfig make menuconfig在 menuconfig 里进入:
Busybox Settings ---> Build Options ---> [*] Build static binary (no shared libs)选中之后保存退出,然后编译:
make -j$(nproc) make install末尾的make install会把结果装到_install目录。如果宿主机器架构和目标架构一致,比如都是 x86_64,上面命令就够了。假如你要给 ARM64 做,需要在make前指定工具链:
make ARCH=arm64 CROSS_COMPILE=aarch64-linux-gnu- defconfig make ARCH=arm64 CROSS_COMPILE=aarch64-linux-gnu- -j$(nproc) make ARCH=arm64 CROSS_COMPILE=aarch64-linux-gnu- install默认make install目标目录是_install,也可以显式改成自己想要的路径:
make CONFIG_PREFIX=$HOME/labs/rootfs install静态编译的代价是文件体积大一些,但换来的好处是“一个文件就是一个用户态”,移动到哪里都能跑,不用跟着拷贝一堆.so。
3.2 目录骨架与设备节点:少了它们起不来
拿到_install之后,开始组装根目录。我通常会建一个独立的工程目录,比如~/labs/rootfs,后续所有改动都基于这个目录,而不是直接改 initramfs 归档。这样可以随时重新打包,也能 diff 看改了什么。
ROOTFS=~/labs/rootfs mkdir -p ${ROOTFS}/{bin,sbin,etc,dev,proc,sys,tmp,root} cp -a busybox-1.36.1/_install/* ${ROOTFS}/ mknod -m 622 ${ROOTFS}/dev/console c 5 1 mknod -m 666 ${ROOTFS}/dev/null c 1 3这里最容易被忽略的是设备节点。/dev/console是内核控制台对应的字符设备,主设备号 5,次设备号 1;/dev/null是主设备号 1、次设备号 3。如果不提前建这两个节点,早期的输出可能直接消失,shell 也可能报can't access tty。
为什么需要手动建?因为这时候 rootfs 里还没有任何 udev/devtmpfs 机制,内核不会自动帮你把设备节点变出来。后面我会在 init 脚本里挂 devtmpfs,到那时/dev下的其他设备节点会自动生成,但 console 和 null 这两尽量在打包时就放在根目录里。
etc目录下还可以补两个最简单的基础文件,有些程序会读:
echo "root:x:0:0:root:/root:/bin/sh" > ${ROOTFS}/etc/passwd echo "root:x:0:" > ${ROOTFS}/etc/group不写也能进 shell,但写上更稳,后面加 telnetd、login 之类功能时不会忽然踩到缺文件。
3.3 初始化脚本:/init 与 /etc/inittab 的分工
根目录里有程序,还得让内核知道启动后执行谁。initramfs 有一个约定:内核默认去找根目录下的/init可执行文件;如果找不到,才会退而尝试/sbin/init、/bin/sh这些路径。
我习惯在 rootfs 顶部放一个/init脚本,用它完成早期挂载,然后交给 BusyBox 的 init:
cat > ${ROOTFS}/init << 'EOF' #!/bin/sh mount -t proc none /proc mount -t sysfs none /sys mount -t devtmpfs devtmpfs /dev exec /sbin/init EOF chmod +x ${ROOTFS}/init这里把/proc、/sys、/dev都挂上。/proc和/sys挂载后,你才能看到/proc/cmdline、/sys/class这些内核信息;devtmpfs 挂载后,块设备、tty 设备节点会自动出现。
然后在etc/inittab里写 BusyBox init 的配置:
cat > ${ROOTFS}/etc/inittab << 'EOF' ::askfirst:-/bin/sh ::ctrlaltdel:/sbin/reboot ::shutdown:/bin/umount -a -r EOFaskfirst的含义是:当内核有可用控制台时,先确认一次,再弹出一个 shell。对调试来说这个行为很方便,按一下回车就进入交互界面。-前缀表示这是一个登录 shell,会读取/etc/profile之类的配置。
如果你习惯 sysvinit 风格,也可以不写/init,而是把挂载命令放进/etc/init.d/rcS,然后让 inittab 调用:
::sysinit:/etc/init.d/rcS两种写法都能跑,区别只是挂载发生在 init 之前还是之后。我选择/init做挂载,是因为它更贴合 initramfs 的设计本意,逻辑更清晰:先让系统目录可见,再切换到正式的 init 管理用户态进程。
4. 打包 initramfs,用 QEMU 把系统真正拉起来
目录树就绪后,下一步是打成 initramfs 归档。这里有一个高频错误:打包时把顶层目录也带进去了。内核解包 initramfs 时,它是从归档根开始解析的,如果里面第一层是rootfs/,那内核在根目录找不到/init,启动依然会失败。
4.1 cpio 打包:格式和权限都是细节
打包命令:
cd ${ROOTFS} find . -print0 | cpio --null -ov --format=newc | gzip -9 > ~/labs/initramfs.cpio.gz注意几个点:
--format=newc必须指定,Linux 内核只认 newc 格式的 cpio。- 必须在
ROOTFS目录内部执行,确保归档里第一层是.也就是根目录。 find不加rootfs/前缀,否则打包结果就整体多了一层。- 权限必须保留,
/init和后续脚本如果没有执行权限,内核会报No working init found。
打完包可以确认一下内容:
cd ~/labs zcat initramfs.cpio.gz | cpio -tv | head这条命令会把归档内容列出来,重点看有没有/init,权限是不是-rwxr-xr-x。
4.2 QEMU 启动参数逐个拆解
现在可以启动虚拟机了。x86_64 平台的 QEMU 命令:
qemu-system-x86_64 \ -kernel bzImage \ -initrd initramfs.cpio.gz \ -append "console=ttyS0 rdinit=/init" \ -nographic参数逐一说明:
-kernel bzImage:指定内核映像,一般编译完的内核在arch/x86/boot/bzImage。-initrd initramfs.cpio.gz:让 QEMU 把归档作为 initramfs 交给内核。-append:向内核传递启动参数。console=ttyS0指定串口控制台,rdinit=/init显式告诉内核在 initramfs 里执行/init。-nographic:禁用图形窗口,把串口重定向到当前终端,这样日志能直接打到屏幕。
如果你在 ARM64 上做,QEMU 命令稍有不同:
qemu-system-aarch64 \ -M virt -cpu cortex-a53 \ -kernel Image.gz \ -initrd initramfs.cpio.gz \ -append "console=ttyAMA0 rdinit=/init" \ -nographic差别主要在机器模型-M virt、CPU 型号,以及串口名ttyAMA0。ARM 的 defconfig 通常会启用 virt 平台的 pl011 串口,但如果你是从零裁剪的内核,一定要确认CONFIG_SERIAL_AMBA_PL011_CONSOLE开着,否则看不到任何输出。
4.3 第一次进入 shell:成功标志与退出方法
如果一切顺利,你会看到内核日志刷到末尾,出现这样一段:
Please press Enter to activate this console.按下回车,进入#提示符。这时候可以验证系统状态:
# ls / /bin /dev /etc /init /proc /root /sbin /sys /tmp /usr # cat /proc/cmdline console=ttyS0 rdinit=/init # ps PID USER VSZ STAT COMMAND 1 root 0 S init能敲命令,能看内核信息,说明根文件系统和内核已经配合起来了。退出 QEMU 有两种方式:在 shell 里执行poweroff -f,或直接按Ctrl+A X强制退出 QEMU。Ctrl+A X是 QEMU 的 nographic 退出快捷键,别按成Ctrl+C,那只会发中断信号。
5. 启动失败排查:从日志反推到底哪里没配对
这个项目里真正耗时间的其实不是“搭成功”,而是“搭失败后怎么定位”。下面按我遇到的频率,把典型故障和排查思路列一版。
5.1 VFS: Unable to mount root fs
日志关键词:
Kernel panic - not syncing: VFS: Unable to mount root fs on unknown-block(0,0)这个错误最直接的原因就是内核没找到根。对应到 initramfs 场景,通常有三种情况:
- 内核没开启 initramfs 支持,
CONFIG_BLK_DEV_INITRD没打开。 -initrd参数没传,或路径写错。- initramfs 归档格式不对,比如 cpio 不是 newc,或压缩格式内核不支持。
先检查内核配置:
grep CONFIG_BLK_DEV_INITRD .config grep CONFIG_RD_GZIP .config如果CONFIG_BLK_DEV_INITRD不是y,回到make menuconfig打开:
General setup ---> [*] Initial RAM filesystem and RAM disk (initramfs/initrd) support如果确认配置没问题,再检查启动日志里有没有这一行:
[ 0.000000] RAMDISK: [mem ...] [ 0.000000] Unpacking initramfs...有Unpacking initramfs才说明内核读到了归档。
5.2 No working init found
日志关键词:
Kernel panic - not syncing: No working init found. Try passing init= option to kernel.这说明内核找到了 rootfs,但执行 init 失败。常见原因按发生频率从高到低:
/init文件没有执行权限。/init是动态链接的 ELF,缺少动态库。/init脚本的 shebang 写成/bin/bash,但 BusyBox 里没有 bash。- 打包时多包了一层目录,内核在根目录根本找不到
/init。
排查手段是解包看内容:
zcat initramfs.cpio.gz | cpio -tv | grep init确认权限后,用file检查二进制类型。静态 BusyBox 会输出statically linked,动态的会写dynamically linked。
5.3 can't access tty 与串口控制台问题
shell 能起来,但敲回车后提示:
/bin/sh: can't access tty; job control turned off这句话看起来吓人,其实不是致命错误。它说明 shell 找不到合适的前台控制终端,通常是因为/dev/console设备节点不存在,或者 inittab 的 askfirst 没指定 tty。解决办法是回到 rootfs,检查/dev/console节点:
ls -l ${ROOTFS}/dev/console如果完全没有任何输出,优先怀疑两个方向。一个是console=ttyS0对应的串口驱动没编进内核,x86 上要确认CONFIG_SERIAL_8250_CONSOLE=y;另一个是 QEMU 显示模式问题,确认启动参数里有-nographic,并且没有同时开着图形窗口抢走焦点。
5.4 内核编译选项速查表
排错到最后,很多问题会汇总到内核 config。我把这个项目涉及到的关键配置整理成一张表:
| 功能 | 需要开启的内核配置 |
|---|---|
| initramfs 支持 | CONFIG_BLK_DEV_INITRD=y |
| gzip 压缩 initramfs | CONFIG_RD_GZIP=y |
| devtmpfs 自动挂载 | CONFIG_DEVTMPFS=y、CONFIG_DEVTMPFS_MOUNT=y |
| proc 文件系统 | CONFIG_PROC_FS=y |
| sysfs 文件系统 | CONFIG_SYSFS=y |
| ext4 文件系统 | CONFIG_EXT4_FS=y |
| virtio 块设备 | CONFIG_VIRTIO=y、CONFIG_VIRTIO_PCI=y、CONFIG_VIRTIO_BLK=y |
| x86 串口 console | CONFIG_SERIAL_8250=y、CONFIG_SERIAL_8250_CONSOLE=y |
| ARM64 pl011 console | CONFIG_SERIAL_AMBA_PL011=y、CONFIG_SERIAL_AMBA_PL011_CONSOLE=y |
一个容易忽略的细节是:这些选项如果编成=m,模块不会自动出现在 initramfs 里。在根文件系统场景下,跟启动强相关的配置一律建议=y,否则内核还是找不到。
6. 进阶:把根文件系统迁到 ext4 镜像,验证持久化
initramfs 跑通之后,趁热打铁把它迁到 ext4 磁盘镜像。这一步做完,你就拥有一个能在 QEMU 里反复启动、数据不丢的“小系统”了。
6.1 生成 ext4 镜像并灌入用户态
先在宿主机上创建一块“虚拟硬盘”:
dd if=/dev/zero of=rootfs.ext4 bs=1M count=512 mkfs.ext4 -L mylinux rootfs.ext4dd生成一个 512MB 的空白文件,mkfs.ext4把它格式化成 ext4 文件系统。镜像大小按需调整,512M 对于最小系统来说很宽裕。
然后通过 loop 设备挂载镜像,把之前做好的 rootfs 目录树复制进去:
sudo mkdir -p /mnt/rootfs sudo mount -o loop rootfs.ext4 /mnt/rootfs sudo cp -a ${ROOTFS}/* /mnt/rootfs/ sudo umount /mnt/rootfs这里mount -o loop会把rootfs.ext4这个普通文件当作块设备挂载,属于开发机上常用的手法。复制完成后卸载,镜像里已经有了完整的根目录。
6.2 更换 QEMU 存储后端
这次启动不再用-initrd,而是把rootfs.ext4作为虚拟硬盘挂给 QEMU:
qemu-system-x86_64 \ -kernel bzImage \ -drive file=rootfs.ext4,format=raw,if=virtio \ -append "root=/dev/vda rw console=ttyS0" \ -nographic-drive参数里的if=virtio让 QEMU 模拟一块 virtio 磁盘,Linux 内核会把它识别为/dev/vda。因此root=/dev/vda表示根文件系统在这块虚拟盘上。rw表示以读写方式挂载根,这样系统内才能写文件。
如果你用的内核没有编 virtio 驱动,可以把if=virtio换成if=ide,同时root=/dev/sda。两种方式都能跑,virtio 更接近现代虚拟化环境,ide 兼容性更老派稳妥。
注意,这次启动不需要-initrd,因为根文件系统不再是内存里的 cpio,而是磁盘镜像。内核日志里也不会再出现Unpacking initramfs,取而代之的是 ext4 挂载日志:
EXT4-fs (vda): mounted filesystem with ordered data mode.vda 这个名字取决于你选的总线。如果看到VFS: Cannot open root device "vda",那就是内核里对应驱动没编进去,回上一章速查表逐项检查。
6.3 验证持久化:write、sync、重启
ext4 根最核心的价值就是持久化。进去之后做个简单验证:
# echo "hello from ext4" > /marker.txt # sync # cat /marker.txt hello from ext4sync这步非常关键。Linux 文件写入先进入页缓存,不会立刻落盘,如果你不执行sync就直接强杀 QEMU,镜像数据可能只停留在宿主机页缓存里。热词里把 sync 和 vfs 放在一起,其实背后就是这么一回事:VFS 缓存要刷回块设备,靠的就是 sync。
然后退出 QEMU,再次用同一条命令启动,进去后查看:
# cat /marker.txt hello from ext4文件还在,说明系统已经具备真实持久化能力了。往后在这个 rootfs 里装程序、改配置,都能保留在rootfs.ext4里。
7. 把这份最小系统变成后续开发模板
到此,一套最小根文件系统已经在 QEMU 里稳定运行了。后面它还能怎么用,我觉得比启动本身更有价值。
7.1 从“能进 shell”到“能跑自己的程序”
把你自己写的静态编译程序扔进 rootfs 的/bin,重新打包 initramfs 或复制进 ext4 镜像,启动后就能直接运行。比如写一个/bin/hello,在 shell 里执行,就能验证程序在目标内核上能否正确运行。
如果要做驱动调试,initramfs 是个非常方便的载体。驱动编译成.ko后,你可以在/init脚本里加一行insmod /xxx.ko,或者提前放进/lib/modules/$(uname -r)。由于 initramfs 不依赖块设备,驱动还没适配存储控制器之前也能完成加载测试,把变量控制在最小范围。
后续想加网络功能,可以在etc/inittab里加上启动脚本,用udhcpc自动获取 IP,再配 dropbear 做 SSH。最小系统很快就能转成可联网的开发环境。
7.2 我保留的几个操作习惯
踩过几轮坑之后,我总结出这几个固定习惯,能节省大量时间:
- rootfs 源目录才是工程,initramfs.cpio.gz 只是构建产物。每次改文件都改源目录,重新打包。这样遇到问题能 diff,而不是解压归档到处翻。
- 打包前检查权限。脚本类文件统一
chmod +x,再打 cpio。很多No working init found问题其实出在init没有x位。 - 改内核 config 后,先用 grep 验证再编译。比如
grep CONFIG_EXT4_FS .config,避免menuconfig里选项没保存、实际没生效的尴尬。 - 进 QEMU 之前先想清楚退出方式。用
-nographic时Ctrl+A X是保住手速的关键。没有图形化环境也能串口调试,习惯之后比开图形窗口高效得多。 - 改完 rootfs 马上测试,避免积累多个变量。一次只改一处,比如只改 init 脚本、只加一个驱动,跑一把看结果,再继续下一步。
这套流程我在 x86 和 ARM64 上各完整跑过一遍。第一次在 ARM64 上因为串口参数写错,黑屏了很久,后来发现只是ttyS0和ttyAMA0的差异。当你把这些细节都理顺之后,再回头看“内核启动找不到根”的 panic,就会发现它其实只是启动链路上一个非常明确的提示:你的用户态世界还没有准备好。现在,它准备好了。