
内核编译出来只是前半程后半程是今天要聊的这件事根文件系统。如果你手里刚有一个编译好的 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 脚本写错。两步隔离排查效率高很多。下面这张表可以帮你快速判断该走哪条路对比项initramfsext4 镜像存放位置内存 rootfs虚拟块设备持久化否是驱动要求低高需存储/总线驱动启动速度快稍慢适合场景早期启动、驱动调试系统开发、接近真实环境3. 用 BusyBox 搭最小用户态和根目录骨架目录树里不能只有 shell 一个程序否则连ls、mount、cat都得自己用 C 语言重新实现。常规做法是引入 BusyBox它把几百个常用命令集成到一个可执行文件里通过符号链接暴露命令名。3.1 静态编译 BusyBox一次编译到处跑BusyBox 的编译本身不复杂真正容易踩坑的是动态链接。默认配置下 BusyBox 会动态链接到 glibc生成的文件很小但在一个空 rootfs 里没有 glibc 动态库启动时就会报cant 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 ARCHarm64 CROSS_COMPILEaarch64-linux-gnu- defconfig make ARCHarm64 CROSS_COMPILEaarch64-linux-gnu- -j$(nproc) make ARCHarm64 CROSS_COMPILEaarch64-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 也可能报cant 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 的 initcat ${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 --formatnewc | gzip -9 ~/labs/initramfs.cpio.gz注意几个点--formatnewc必须指定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 consolettyS0 rdinit/init \ -nographic参数逐一说明-kernel bzImage指定内核映像一般编译完的内核在arch/x86/boot/bzImage。-initrd initramfs.cpio.gz让 QEMU 把归档作为 initramfs 交给内核。-append向内核传递启动参数。consolettyS0指定串口控制台rdinit/init显式告诉内核在 initramfs 里执行/init。-nographic禁用图形窗口把串口重定向到当前终端这样日志能直接打到屏幕。如果你在 ARM64 上做QEMU 命令稍有不同qemu-system-aarch64 \ -M virt -cpu cortex-a53 \ -kernel Image.gz \ -initrd initramfs.cpio.gz \ -append consolettyAMA0 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 consolettyS0 rdinit/init # ps PID USER VSZ STAT COMMAND 1 root 0 S init能敲命令能看内核信息说明根文件系统和内核已经配合起来了。退出 QEMU 有两种方式在 shell 里执行poweroff -f或直接按CtrlA X强制退出 QEMU。CtrlA X是 QEMU 的 nographic 退出快捷键别按成CtrlC那只会发中断信号。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 cant access tty 与串口控制台问题shell 能起来但敲回车后提示/bin/sh: cant access tty; job control turned off这句话看起来吓人其实不是致命错误。它说明 shell 找不到合适的前台控制终端通常是因为/dev/console设备节点不存在或者 inittab 的 askfirst 没指定 tty。解决办法是回到 rootfs检查/dev/console节点ls -l ${ROOTFS}/dev/console如果完全没有任何输出优先怀疑两个方向。一个是consolettyS0对应的串口驱动没编进内核x86 上要确认CONFIG_SERIAL_8250_CONSOLEy另一个是 QEMU 显示模式问题确认启动参数里有-nographic并且没有同时开着图形窗口抢走焦点。5.4 内核编译选项速查表排错到最后很多问题会汇总到内核 config。我把这个项目涉及到的关键配置整理成一张表功能需要开启的内核配置initramfs 支持CONFIG_BLK_DEV_INITRDygzip 压缩 initramfsCONFIG_RD_GZIPydevtmpfs 自动挂载CONFIG_DEVTMPFSy、CONFIG_DEVTMPFS_MOUNTyproc 文件系统CONFIG_PROC_FSysysfs 文件系统CONFIG_SYSFSyext4 文件系统CONFIG_EXT4_FSyvirtio 块设备CONFIG_VIRTIOy、CONFIG_VIRTIO_PCIy、CONFIG_VIRTIO_BLKyx86 串口 consoleCONFIG_SERIAL_8250y、CONFIG_SERIAL_8250_CONSOLEyARM64 pl011 consoleCONFIG_SERIAL_AMBA_PL011y、CONFIG_SERIAL_AMBA_PL011_CONSOLEy一个容易忽略的细节是这些选项如果编成m模块不会自动出现在 initramfs 里。在根文件系统场景下跟启动强相关的配置一律建议y否则内核还是找不到。6. 进阶把根文件系统迁到 ext4 镜像验证持久化initramfs 跑通之后趁热打铁把它迁到 ext4 磁盘镜像。这一步做完你就拥有一个能在 QEMU 里反复启动、数据不丢的“小系统”了。6.1 生成 ext4 镜像并灌入用户态先在宿主机上创建一块“虚拟硬盘”dd if/dev/zero ofrootfs.ext4 bs1M count512 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作为虚拟硬盘挂给 QEMUqemu-system-x86_64 \ -kernel bzImage \ -drive filerootfs.ext4,formatraw,ifvirtio \ -append root/dev/vda rw consolettyS0 \ -nographic-drive参数里的ifvirtio让 QEMU 模拟一块 virtio 磁盘Linux 内核会把它识别为/dev/vda。因此root/dev/vda表示根文件系统在这块虚拟盘上。rw表示以读写方式挂载根这样系统内才能写文件。如果你用的内核没有编 virtio 驱动可以把ifvirtio换成ifide同时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时CtrlA X是保住手速的关键。没有图形化环境也能串口调试习惯之后比开图形窗口高效得多。改完 rootfs 马上测试避免积累多个变量。一次只改一处比如只改 init 脚本、只加一个驱动跑一把看结果再继续下一步。这套流程我在 x86 和 ARM64 上各完整跑过一遍。第一次在 ARM64 上因为串口参数写错黑屏了很久后来发现只是ttyS0和ttyAMA0的差异。当你把这些细节都理顺之后再回头看“内核启动找不到根”的 panic就会发现它其实只是启动链路上一个非常明确的提示你的用户态世界还没有准备好。现在它准备好了。