1. 项目概述与整体思路
1.1 为什么要自己做根文件系统
先说个背景。我在学习和调试Linux内核时,经常需要验证一些内核模块、驱动或者系统调用的行为。直接用发行版(比如Ubuntu)启动,虽然方便,但很多细节被封装得看不清楚。而且每次修改内核配置、重新编译,一套流程走下来效率很低。后来我开始用QEMU模拟一台“裸机”,自己定制内核、自己制作根文件系统,把启动过程完全掌握在自己手里。这篇文章就是这个系列第三篇,重点讲根文件系统的制作,以及怎么和编译好的内核一起在QEMU里跑起来。
所谓根文件系统,简单说就是内核启动后挂载的第一个文件系统,它包含/bin、/sbin、/etc、/lib、/dev、/proc、/sys这些基本目录,以及初始化进程(通常是init)和必要的动态库。内核启动时会根据启动参数找到根文件系统,挂载它,然后执行其中的init程序。没有根文件系统,内核启动到最后会直接panic,屏幕显示“VFS: Unable to mount root fs”,什么都做不了。
注意:根文件系统不是某种特定的文件系统格式,而是一个“目录结构+内容”的集合。它可以是ext4、ext2、squashfs、ramfs、initramfs等形式。只要内核能识别并挂载,就能作为根文件系统使用。
这篇文章面向的是两类读者:一类是像我一样想深入理解内核启动机制的爱好者,另一类是做嵌入式开发、需要定制最小系统的工程师。整个过程不依赖复杂的构建工具,用一台装了Linux的电脑(虚拟机也行)加上BusyBox就能完成。
1.2 QEMU方案选型的理由
做这个实验,业界常见的有三条路线:用真实的开发板(比如树莓派、全志、瑞芯微)、用VMware/VirtualBox装个完整发行版、用QEMU模拟。
开发板的问题是成本高、调试不便,改内核参数要反复烧写,没有串口日志很难定位问题。VMware一类的方案是把整个系统虚拟化,内核里很多东西已经被发行版定制过了,不适合做内核实验。QEMU的方案有两个优势:一是它模拟的是完整机器(CPU、内存、外设都是虚拟的),内核从零开始引导,和真实硬件的行为非常接近;二是QEMU支持-kernel参数直接加载内核镜像,支持-initrd加载initramfs,还能通过-append传内核启动参数,整个过程就是一条命令的事,改起来非常快。
我实测下来,从编译内核到在QEMU里启动,最快可以做到三分钟内跑通一个最小系统(前提是编译过一次,后续增量编译)。这个效率是开发板远远比不上的。所以做内核早期启动、驱动调试、文件系统实验,QEMU是最合适的平台,没有之一。
2. 核心细节解析与实操要点
2.1 一张图看懂内核、根文件系统、init进程的关系
先把概念理清,后面操作才有方向。一台Linux机器从开机到进入命令行,走的是这么一条链路:
- BootLoader(QEMU内置的,或者直接用
-kernel参数跳过)加载内核镜像到内存; - 内核解压、初始化各个子系统(内存管理、调度器、中断、驱动等);
- 内核根据启动参数中的
root=找到根文件系统所在设备,执行挂载; - 挂载成功后,内核在根文件系统中寻找
init程序(路径由init=参数指定,默认是/sbin/init),并把控制权交给它; init程序根据配置文件(通常是/etc/inittab)完成系统初始化,包括挂载/proc、/sys、配置网络、启动shell等。
这里有几个容易混淆的概念需要区分清楚:
| 概念 | 作用 | 常见的实现方式 |
|---|---|---|
| 内核镜像 | 操作系统的核心,负责管理硬件和提供系统调用 | vmlinuz、Image、zImage |
| 根文件系统 | 内核挂载的第一个文件系统,提供目录结构和用户态程序 | ext4镜像、initramfs、squashfs |
| init程序 | 用户态的第一个进程,PID为1,是所有进程的祖先 | BusyBox init、systemd |
| 启动参数 | 内核启动时的命令行参数,告诉内核如何挂载根文件系统 | root=、init=、console= |
理解这个链路后你会发现,内核和根文件系统其实是“分工合作”的关系:内核负责硬件和管理,根文件系统负责提供用户态工具和初始化逻辑。两者通过启动参数建立了联系。
2.2 BusyBox、init、inittab三件套怎么配合
在最小系统中,最常用的用户态工具集是BusyBox。它最大的特点是“一个二进制文件,集成几百个命令”,通过busybox ls、busybox sh这种方式调用,也可以创建符号链接来模拟传统命令。这意味着根文件系统不需要为每个命令单独拷贝一个可执行文件,极大减小体积。
BusyBox的init程序遵循经典的SysV init流程,读取/etc/inittab文件决定启动哪些服务。inittab的格式是id:runlevels:action:process,但在BusyBox里,runlevels字段被忽略,而action字段常见的有:
sysinit:系统初始化时要执行的任务,比如挂载/proc、/sys;respawn:当进程退出后自动重新启动,一般用于getty(登录终端);askfirst:在终端上提示“Please press Enter to activate this console”,按回车后启动shell;restart:init重启时执行;once:只执行一次,不等待;shutdown:关机时执行的脚本。
我们做实验时,最常用的inittab配置就三行:一行sysinit执行/etc/init.d/rcS,一行askfirst在串口控制台上启动shell,一行shutdown干净的卸载文件系统。看起来简单,但这里面有个坑:console配置必须和内核的console=参数一致,否则你会在QEMU的窗口里看不到任何输出。
2.3 initramfs vs ext4镜像,到底选哪个
制作根文件系统,核心问题是用什么形式让内核访问到它。两种主要方案:initramfs和磁盘镜像。
initramfs本质是一个cpio格式的压缩包(通常是gzip压缩),被内核直接加载到内存中作为临时根文件系统。它不需要真实的块设备支持,启动流程是:内核把initramfs解压到内存(tmpfs),然后将其作为根文件系统挂载。这种方式的优点是制作简单、不依赖磁盘控制器驱动、启动极快,非常适合做内核实验和嵌入式系统的早期启动。缺点是所有内容都在内存里,掉电即消失,不适合做持久化存储。
磁盘镜像则是一个真实的分区镜像文件,比如rootfs.ext4。内核需要对应的文件系统和磁盘驱动(virtio_blk或IDE)才能挂载它。优点是数据可以持久化保存,缺点是镜像的制作过程稍微复杂一些,需要用到dd、mkfs.ext4和mount循环设备。
我的建议是:做实验优先用initramfs。原因很简单——内核配置里只要开启CONFIG_BLK_DEV_INITRD和CONFIG_RD_GZIP,然后在启动参数里加initrd=即可。省去了分区、格式化的步骤,修改根文件系统的内容只需要重新打包cpio,非常灵活。这篇文章的实操部分采用initramfs方案。
不过,如果你想模拟更真实的启动场景(比如做内核模块的rootfs挂载实验),磁盘镜像会更有价值。我在文末也会简单提一下磁盘镜像怎么转换。
3. 实操过程与核心环节实现
3.1 环境准备:内核源码、BusyBox、QEMU
先列一下我实验用的环境,你不需要完全一样,但版本不能太旧。
- 宿主机:Ubuntu 22.04 x86_64,8核CPU,16GB内存(编译内核建议至少4GB可用内存);
- 内核源码:Linux 5.15.x(版本差异不影响本文步骤);
- BusyBox:1.36.x;
- QEMU:7.0以上版本(通过
apt install qemu-system-x86安装)。
我假设你已经把内核源码准备好了,并且至少成功编译过一次。如果内核还没编译过,建议先跟着编译出bzImage,再回来看这篇文章。内核源码目录我用$KERNEL表示。
BusyBox的下载和配置可以直接命令完成:
wget https://busybox.net/downloads/busybox-1.36.1.tar.bz2 tar xjf busybox-1.36.1.tar.bz2 cd busybox-1.36.1 make defconfig make menuconfig在menuconfig里,进入Settings -> Build Options,把Build static binary (no shared libs)选上。这个选项很重要——静态编译的BusyBox不依赖任何动态库,拷贝到根文件系统里就能跑,省去了拷贝/lib的麻烦。如果你选择动态编译,还需要手动拷贝glibc或musl的.so到根文件系统里,步骤多但能压体积。对刚开始做实验的人来说,静态编译是最省心的选择。
3.2 根文件系统的目录骨架和BusyBox安装
编译安装BusyBox之前,先把根文件系统的目录骨架建好。我会在一个临时目录里操作,比如~/rootfs。
mkdir -p ~/rootfs/{bin,sbin,etc/init.d,proc,sys,dev,lib,usr/{bin,sbin},root,tmp,var}目录作用对照:
| 目录 | 作用 |
|---|---|
| bin | 用户命令,如ls、cat、sh |
| sbin | 系统命令,如init、ifconfig |
| etc | 配置文件,如inittab、fstab、rcS |
| proc、sys | 虚拟文件系统挂载点 |
| dev | 设备文件节点所在目录 |
| lib | 动态库(静态编译时可不拷贝) |
| root、tmp | root用户家目录和临时目录 |
目录建好后,开始安装BusyBox:
make -j$(nproc) make CONFIG_PREFIX=~/rootfs installCONFIG_PREFIX指定了安装目录,BusyBox会自动在~/rootfs下创建bin/busybox,并在bin、sbin等目录里生成一大批符号链接,指向BusyBox二进制文件。安装完成后,~/rootfs就是一个能看能用的最小用户态环境了。
提示:
make install不会覆盖你手工创建的目录,但会把bin、sbin等目录补齐。如果你有自定义的脚本文件放在里面,不要担心,它会原样保留。
3.3 写inittab和rcS:让系统“活”起来
BusyBox安装完成只是把“工具”放进去了,系统要真正启动,还差两个关键文件:/etc/inittab和/etc/init.d/rcS。
先创建~/rootfs/etc/inittab,内容如下:
::sysinit:/etc/init.d/rcS ::askfirst:-/bin/sh ::restart:/sbin/init ::ctrlaltdel:/sbin/reboot ::shutdown:/bin/umount -a -r逐行解释:
- 第一行
sysinit,系统启动时第一个执行的任务,运行初始化脚本rcS; - 第二行
askfirst,在第一个终端上提示用户按回车,然后启动一个shell。这里的-前缀表示login shell,会读取/etc/profile; - 第三行
restart,init进程重启时调用/sbin/init; - 第四行
ctrlaltdel,按下Ctrl+Alt+Del时执行重启(QEMU窗口里可能触发不了,但配置上没坏处); - 第五行
shutdown,关机时先卸载所有文件系统,-r参数表示失败也继续执行。
然后是~/rootfs/etc/init.d/rcS,这个脚本负责挂载虚拟文件系统和设置主机名:
#!/bin/sh mount -t proc none /proc mount -t sysfs none /sys mount -t devtmpfs devtmpfs /dev echo "Hello from initramfs!"写完后记得给脚本加执行权限:
chmod +x ~/rootfs/etc/init.d/rcS之所以在rcS里挂载devtmpfs,是为了让/dev目录下自动出现ttyS0、null、zero等基本设备节点。如果没有这一步,你在shell里执行echo > /dev/null会报“No such file or directory”。实际使用中发现devtmpfs比手动mknod方便太多了,强烈推荐。
3.4 打包initramfs:cpio和gzip的细节
根文件系统目录就绪后,用cpio把它打包成一个内核能识别的initramfs镜像。注意打包时必须从~/rootfs目录内部执行,否则cpio会把绝对路径打进镜像,内核解压时会出问题。
cd ~/rootfs find . | cpio -H newc -o --owner root:root > ../initramfs.cpio gzip -9 ../initramfs.cpio解释一下参数:
find .列出当前目录下所有文件(包括目录);cpio -H newc使用newc格式,这是内核initramfs唯一支持的cpio变体;-o表示输出归档;--owner root:root把所有文件的属主设为root,避免你宿主机用户ID混入镜像;gzip -9压缩,-9是最大压缩比,体积能压掉70%以上。
两条命令可以连起来写成一行:
cd ~/rootfs && find . | cpio -H newc -o --owner root:root | gzip -9 > ~/initramfs.img生成的~/initramfs.img就是内核启动时要加载的initramfs镜像。
注意:这里有个容易踩的坑。cpio打包时,
find .会输出一个./开头的路径,cpio会把它原样记录在归档里。内核解压时,路径开头的./会被忽略,所以一般没问题。但如果你用了find *或者find | sort之类的变体,产生的归档可能在内核解压时出现“Empty file”之类的错误。用find .是最稳妥的,不需要画蛇添足。
3.5 QEMU启动:第一个完整的Kernel+Rootfs
内核镜像和initramfs都准备好了,下面就是见证“奇迹”的时刻。假设你的内核编译产物在$KERNEL/arch/x86/boot/bzImage,对应的启动命令是:
qemu-system-x86_64 \ -kernel $KERNEL/arch/x86/boot/bzImage \ -initrd ~/initramfs.img \ -append "console=ttyS0 root=/dev/ram rdinit=/sbin/init" \ -nographic逐项参数说明:
-kernel:指定内核镜像;-initrd:指定initramfs镜像;-append:传给内核的命令行参数。console=ttyS0是把内核日志输出到串口,配合-nographic使用;root=/dev/ram告诉内核根文件系统在内存中的ramfs(即initramfs);rdinit=/sbin/init指定initramfs中第一个执行的程序;-nographic:不用图形窗口,输入输出直接在当前终端进行。实验环境没有显示器的场景下特别有用。
如果顺利,你会看到一串内核日志滚动,最后停在BusyBox的shell提示符,类似:
Please press Enter to activate this console.按回车,进入命令行:
/ # ls bin etc proc root sbin sys tmp usr var / #到这里,一个最小的、完全由自己定制的Linux系统就跑起来了。你可以试试ls /bin,会看到BusyBox自动创建的那些符号链接;执行cat /proc/meminfo,能看到虚拟机的内存信息。
这个最小系统有一个特点:干净,干净得连ifconfig都配不上ip。这也是为什么后续做网络实验时,需要额外编译网络工具和配置脚本。但作为内核启动验证,这一步已经足够了。
3.6 ext4磁盘镜像制作(扩展方案)
initramfs适合做启动验证和临时系统,但如果你需要持久化存储(比如往/root里写文件,重启后保留),就需要一个磁盘镜像。
制作一个空白的ext4磁盘镜像:
dd if=/dev/zero of=~/rootfs.img bs=1M count=64 mkfs.ext4 ~/rootfs.img然后借助loop设备把镜像里的文件系统替换成我们的rootfs目录内容:
mkdir -p /mnt/rootfs sudo mount ~/rootfs.img /mnt/rootfs sudo cp -a ~/rootfs/* /mnt/rootfs/ sync sudo umount /mnt/rootfs启动命令相应变为:
qemu-system-x86_64 \ -kernel $KERNEL/arch/x86/boot/bzImage \ -drive format=raw,file=~/rootfs.img \ -append "console=ttyS0 root=/dev/sda rw" \ -nographic对比initramfs启动,不同的地方在于:
-initrd换成-drive,把磁盘镜像挂成虚拟硬盘;root=/dev/ram换成root=/dev/sda,告诉内核根文件系统在第一个虚拟磁盘上;- 加了
rw,让根文件系统以读写方式挂载。
这种方案做出来的系统,在QEMU里完全可以当一台小服务器用,不夸张地说,几十兆的磁盘空间就能做很多实验了。
4. 常见问题与排查技巧实录
做内核启动实验,最大的挑战不是“编译不过”,而是“启动不起来后不知道从哪里排查”。下面这几个问题是我实际踩过的坑,整理成速查表方便你对照。
| 现象 | 可能原因 | 排查命令/方法 |
|---|---|---|
启动后停在Kernel panic - not syncing: VFS: Unable to mount root fs | 内核没有initramfs支持,或initramfs路径不对,或cpio格式错误 | 检查内核配置CONFIG_BLK_DEV_INITRD=y;确认-initrd指向的镜像存在;重新打包cpio |
| 启动了但看不到shell提示,只有一个黑屏/空白终端 | console=参数和内核对不上,或无askfirst配置 | 确认-append里有console=ttyS0,且inittab里askfirst对应的终端是sh,不是tty1 |
执行ls等命令提示not found | BusyBox没有正确安装,或动态编译缺少库 | 确认make CONFIG_PREFIX=~/rootfs install执行过;用file ~/rootfs/bin/busybox检查是否是静态编译 |
| 文件系统目录是空的,符号链接没生成 | make install可能覆盖了路径,或者CONFIG_PREFIX没有生效 | 手动执行~/rootfs/bin/busybox --install -s重建符号链接 |
挂载proc返回No such device | 内核没有开启CONFIG_PROC_FS | make menuconfig里搜索PROC_FS,确认已开启 |
| initramfs内看到的是宿主机目录,不是rootfs | cpio打包时目录进入方式不对 | 确保在~/rootfs目录内执行find .,不要加绝对路径 |
4.1 内核日志最后一行的“魔咒”
Kernel panic - not syncing: VFS: Unable to mount root fs大概是内核实验里最常见的panic信息了。很多人一看到这个就慌,其实它想表达的意思很简单:内核找不到合适的根文件系统。
排查顺序:
- 确认
-initrd路径正确,文件存在且非空; - 确认内核配置里
CONFIG_BLK_DEV_INITRD=y和CONFIG_RD_GZIP=y(如果initramfs是gzip压缩的); - 确认cpio打包时用了
-H newc,而不是默认的odc或bin格式; - 确认
-append里有root=/dev/ram或rdinit=/sbin/init。
这四步走完,90%的panic都能解决。剩下10%是内核根本没起来(还没到挂载根文件的阶段),那就要看更早的日志了。
4.2 为什么我的shell不出现
内核日志一切正常,但QEMU窗口里只有一个“Please press Enter to activate this console”,按回车没反应。这个问题我折腾过半小时,最后发现是console=参数不对。
如果你用-nographic模式,内核日志默认输出到第一个串口(ttyS0),但inittab里askfirst对应的终端默认是tty1。两者不一致,就会出现内核日志刷屏但shell无响应的怪现象。
解决方案:inittab里把askfirst那一行改成::askfirst:-/bin/sh,不指定具体tty,或者明确指定console与串口一致。我建议用后者,更干净:
::askfirst:-/bin/shBusyBox init会自动把它分配到当前活动的控制台设备上。这一行是最不容易踩坑的写法。
4.3 静态编译的BusyBox还缺依赖?
理论上静态编译的BusyBox不依赖任何动态库,但有一个场景会“破功”:你调用了BusyBox不支持的某个命令,或者系统缺少必要的设备节点。
比如,在initramfs里执行free,它需要/proc/meminfo。如果rcS脚本里没挂载/proc,free就会报错。这个错误不是BusyBox的问题,而是系统环境不完整。排查口诀:遇到“No such file or directory”,先看/proc、/sys、/dev挂没挂;遇到“No such device”,先看内核配置有没有开对应的驱动。
4.4 在QEMU窗口里怎么退出
刚开始做实验时,最尴尬的是不知道QEMU的-nographic模式怎么退出。快捷键是Ctrl+A然后按X,即先按Ctrl+A,松开,再按一次X。如果你当前在BusyBox的shell里,也可以直接执行poweroff -f或reboot -f优雅退出。
如果你忘了这个快捷键,还有一个粗暴的方式:另开一个终端执行pkill qemu-system。不过不推荐,因为非正常退出可能会让磁盘镜像出现未同步的数据。
5. 内核与根文件系统联调的进阶技巧
5.1 只用一条命令完成内核编译与initramfs更新
做内核实验,最烦的就是每改一次代码都要重复编译、打包、启动。我后来写了一个小脚本,放在$KERNEL目录下,名字叫build-and-run.sh:
#!/bin/bash set -e KERNEL_DIR=$(pwd) ROOTFS_DIR=~/rootfs OUTPUT=/tmp/kernel-boot make -j$(nproc) bzImage cd $ROOTFS_DIR find . | cpio -H newc -o --owner root:root | gzip -9 > $OUTPUT/initramfs.img cd $KERNEL_DIR qemu-system-x86_64 \ -kernel arch/x86/boot/bzImage \ -initrd $OUTPUT/initramfs.img \ -append "console=ttyS0 root=/dev/ram rdinit=/sbin/init" \ -nographic这个脚本的逻辑是:先编译内核,再重新打包initramfs,最后启动QEMU。每次改完代码,只需要跑这个脚本,最多一两分钟就能看到效果。实测下来,比手动一条条敲命令效率高了非常多。
5.2 用9p共享目录实现宿主机与VM文件互传
QEMU有一个9p virtio功能,可以把宿主机的一个目录直接共享给虚拟机,类似VMware的共享文件夹。在initramfs实验里,这个功能特别好用——你不需要手动修改initramfs,直接在宿主机编辑文件,虚拟机能立即看到最新内容。
宿主机侧启动命令:
qemu-system-x86_64 \ -kernel $KERNEL/arch/x86/boot/bzImage \ -initrd ~/initramfs.img \ -append "console=ttyS0 root=/dev/ram rdinit=/sbin/init" \ -virtfs local,path=/home/user/shared,mount_tag=host0,security_model=none,id=host0 \ -nographic虚拟机制作挂载点并加载9p驱动:
mkdir -p /mnt/shared mount -t 9p -o trans=virtio,version=9p2000.L host0 /mnt/shared这样就可以在/mnt/shared里看到宿主机/home/user/shared下的文件了。注意:security_model=none意味着VM内所有文件都以宿主机当前用户权限访问,仅限实验环境使用,不要在生产环境配这个。
5.3 完整的实验日志怎么抓
调试内核时,光靠肉眼盯着QEMU窗口是不行的。把内核日志完整保存下来,才能反复分析。两种方式:
- 启动命令里加
-serial file:/tmp/kernel.log,内核的串口输出会同时写到文件里; - 在宿主机终端用
tee命令把QEMU输出落盘:qemu-system-x86_64 ... 2>&1 | tee /tmp/qemu.log。
我个人习惯用第一种,因为-serial file:是QEMU原生支持的,不会影响终端的交互输入。
5.4 initramfs体积优化的思路
initramfs做大了,启动时解压和拷贝都会变慢。实测下来,单纯BusyBox静态编译的最小系统,initramfs压缩后大约1~2MB。如果你的initramfs体积明显偏大,检查这几个地方:
- 有没有拷贝宿主机的
/lib动态库进去; - 有没有多余的内核模块(
.ko文件)被make modules_install INSTALL_MOD_PATH=...装进去; - 有没有包含文档、man page之类的非必要文件。
体积优化的核心思路是“只留能跑通的最小闭环”:一个可用shell、一个init进程、必要的挂载点和配置就足够。等你需要某个功能时,再按需加入,而不是一开始就塞满所有东西。
6. 一些个人体会
折腾内核和根文件系统这件事,看起来是个纯技术的活儿,多做几次就会发现,它其实是在帮你建立“从零到一”的完整视角。以前用发行版,你对系统启动的理解只停留在“开机转圈圈”这个层面。亲手做完这套流程后,你再看到/etc/inittab、/init、VFS挂载这些名词,脑子里会浮现出一台QEMU机器从无到有、从内核到shell的画面。这个画面感挺值钱的,尤其在做驱动调试、系统裁减、嵌入式开发的时候,能帮你快速定位问题到底出在哪个环节。
再分享一个很小但实用的技巧:我在调试时经常临时改rcS脚本,但initramfs是内存文件系统,改完要重新打包才能生效。后来我发现,直接在QEMU里用mount -t 9p共享目录后,把脚本放在共享目录里,再在rcS里加一行source /mnt/shared/init_extra.sh,这样改脚本只需要保存宿主机文件,不用重新打包,调试效率提高了非常多。如果你也经常改初始化脚本,这个思路可以试试。
最后说一下后续扩展方向。这篇文章做的是x86_64平台的最小系统,但同样的思路完全可以移植到ARM64模拟上,QEMU的qemu-system-aarch64加上-machine virt,核心步骤几乎一致。另外,如果你想把rootfs从initramfs换成NFS挂载(适合做内核NFS根文件系统调试),原理也相通,只是启动参数从root=/dev/ram变成root=/dev/nfs nfsroot=...。内核实验这条路,越往里走越有意思,希望这篇文章能帮你跨过第一道门槛。