QEMU+BusyBox定制最小Linux根文件系统从零到启动
2026/9/17 4:32:32 网站建设 项目流程

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等。

这里有几个容易混淆的概念需要区分清楚:

概念作用常见的实现方式
内核镜像操作系统的核心,负责管理硬件和提供系统调用vmlinuzImagezImage
根文件系统内核挂载的第一个文件系统,提供目录结构和用户态程序ext4镜像、initramfs、squashfs
init程序用户态的第一个进程,PID为1,是所有进程的祖先BusyBox init、systemd
启动参数内核启动时的命令行参数,告诉内核如何挂载根文件系统root=init=console=

理解这个链路后你会发现,内核和根文件系统其实是“分工合作”的关系:内核负责硬件和管理,根文件系统负责提供用户态工具和初始化逻辑。两者通过启动参数建立了联系。

2.2 BusyBox、init、inittab三件套怎么配合

在最小系统中,最常用的用户态工具集是BusyBox。它最大的特点是“一个二进制文件,集成几百个命令”,通过busybox lsbusybox 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)才能挂载它。优点是数据可以持久化保存,缺点是镜像的制作过程稍微复杂一些,需要用到ddmkfs.ext4mount循环设备。

我的建议是:做实验优先用initramfs。原因很简单——内核配置里只要开启CONFIG_BLK_DEV_INITRDCONFIG_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、tmproot用户家目录和临时目录

目录建好后,开始安装BusyBox:

make -j$(nproc) make CONFIG_PREFIX=~/rootfs install

CONFIG_PREFIX指定了安装目录,BusyBox会自动在~/rootfs下创建bin/busybox,并在binsbin等目录里生成一大批符号链接,指向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
  • 第三行restartinit进程重启时调用/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目录下自动出现ttyS0nullzero等基本设备节点。如果没有这一步,你在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,且inittabaskfirst对应的终端是sh,不是tty1
执行ls等命令提示not foundBusyBox没有正确安装,或动态编译缺少库确认make CONFIG_PREFIX=~/rootfs install执行过;用file ~/rootfs/bin/busybox检查是否是静态编译
文件系统目录是空的,符号链接没生成make install可能覆盖了路径,或者CONFIG_PREFIX没有生效手动执行~/rootfs/bin/busybox --install -s重建符号链接
挂载proc返回No such device内核没有开启CONFIG_PROC_FSmake menuconfig里搜索PROC_FS,确认已开启
initramfs内看到的是宿主机目录,不是rootfscpio打包时目录进入方式不对确保在~/rootfs目录内执行find .,不要加绝对路径

4.1 内核日志最后一行的“魔咒”

Kernel panic - not syncing: VFS: Unable to mount root fs大概是内核实验里最常见的panic信息了。很多人一看到这个就慌,其实它想表达的意思很简单:内核找不到合适的根文件系统。

排查顺序:

  1. 确认-initrd路径正确,文件存在且非空;
  2. 确认内核配置里CONFIG_BLK_DEV_INITRD=yCONFIG_RD_GZIP=y(如果initramfs是gzip压缩的);
  3. 确认cpio打包时用了-H newc,而不是默认的odcbin格式;
  4. 确认-append里有root=/dev/ramrdinit=/sbin/init

这四步走完,90%的panic都能解决。剩下10%是内核根本没起来(还没到挂载根文件的阶段),那就要看更早的日志了。

4.2 为什么我的shell不出现

内核日志一切正常,但QEMU窗口里只有一个“Please press Enter to activate this console”,按回车没反应。这个问题我折腾过半小时,最后发现是console=参数不对。

如果你用-nographic模式,内核日志默认输出到第一个串口(ttyS0),但inittabaskfirst对应的终端默认是tty1。两者不一致,就会出现内核日志刷屏但shell无响应的怪现象。

解决方案:inittab里把askfirst那一行改成::askfirst:-/bin/sh,不指定具体tty,或者明确指定console与串口一致。我建议用后者,更干净:

::askfirst:-/bin/sh

BusyBox init会自动把它分配到当前活动的控制台设备上。这一行是最不容易踩坑的写法。

4.3 静态编译的BusyBox还缺依赖?

理论上静态编译的BusyBox不依赖任何动态库,但有一个场景会“破功”:你调用了BusyBox不支持的某个命令,或者系统缺少必要的设备节点。

比如,在initramfs里执行free,它需要/proc/meminfo。如果rcS脚本里没挂载/procfree就会报错。这个错误不是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 -freboot -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窗口是不行的。把内核日志完整保存下来,才能反复分析。两种方式:

  1. 启动命令里加-serial file:/tmp/kernel.log,内核的串口输出会同时写到文件里;
  2. 在宿主机终端用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=...。内核实验这条路,越往里走越有意思,希望这篇文章能帮你跨过第一道门槛。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询