自己动手从零构建Linux操作系统_第2章
自己动手从零构建Linux操作系统
写给有 Linux 基础,但对底层硬件不太熟悉的朋友
这本书从「原理」和「实践」两个维度,手把手带你从零开始, 编译内核、构建用户空间、制作启动磁盘,最终得到一个可以真正运行的最小 Linux 系统。

第 2 章:从磁盘启动 —— UEFI、GRUB 与持久存储
学习目标:把第 1 章构建的内存系统升级为可以从磁盘镜像启动、 支持持久存储的完整系统。引入 UEFI 启动、GPT 分区表、GRUB 引导器和 ext4 文件系统。
完成后你会拥有:一个 256MB 的磁盘镜像文件,可以直接写入 U 盘或硬盘, 在支持 UEFI 的虚拟机上启动运行。
2.1 为什么需要磁盘镜像?
第 1 章的系统虽然能运行,但有一个本质问题:一切都在内存里,重启断电就没了。
如果你用 vi 写了一个文件、用 touch 创建了数据,重启后就彻底消失。
因为没有磁盘设备,没有文件系统能持久保存数据。
从这章开始,我们将引入磁盘镜像、UEFI 启动、GPT 分区表、GRUB 引导器和 ext4 文件系统。
2.2 理解 UEFI 启动流程
2.2.1 UEFI 是什么?
UEFI(Unified Extensible Firmware Interface,统一可扩展固件接口)是现代计算机的 「开机程序」,替代了老旧的 BIOS(Basic Input/Output System)。
UEFI 相比 BIOS 的最大进步:
| 对比维度 | BIOS(传统) | UEFI(现代) |
|---|---|---|
| 启动方式 | 只能读硬盘最开头 512 字节(MBR) | 能直接读懂 FAT32 文件系统 |
| 分区表 | MBR(最多 4 个主分区,最大 2TB) | GPT(128 个分区,无大小限制) |
| 启动文件 | 藏在扇区里,不直观 | 放在 FAT32 分区里,像普通文件 |
对于 UEFI,启动一个操作系统就像在 FAT32 格式的分区里放一个特定路径的文件:
|
|
ESP(EFI System Partition,EFI 系统分区)就是一个特殊标记的 FAT32 分区。
UEFI 固件开机后会扫描所有磁盘的 ESP,找到 /EFI/BOOT/BOOTX64.EFI,然后运行它。
💡 类比:如果把 UEFI 看成一个「自动点唱机」,ESP 就是「唱片架」,
BOOTX64.EFI就是默认会播放的那张唱片。
2.2.2 EFI 可执行文件格式与 GRUB 的角色
EFI 可执行格式:PE/COFF
一个值得注意的细节是:EFI 可执行文件使用的不是 Linux 原生的 ELF 格式,
而是 PE/COFF(Portable Executable / Common Object File Format)——
也就是 Windows 上 .exe 和 .dll 文件使用的格式。
这意味着 BOOTX64.EFI 虽然在 Linux 环境中由 grub-mkimage 生成,
但它本质上是一个 PE/COFF 格式的二进制文件。
为什么 UEFI 选择 PE/COFF 而不是 ELF?
| 格式 | 主要使用场景 | UEFI 的选择 |
|---|---|---|
| ELF | Linux/Unix | 功能强大,但规范复杂,且与 Unix 系统强绑定 |
| PE/COFF | Windows | 格式简洁、自描述性好,独立于操作系统 |
UEFI 的设计目标之一是与操作系统无关——固件不应该偏向任何特定系统。 PE/COFF 格式在 Windows 生态中经过了数十年的验证,规范清晰、稳定, 因此被 UEFI 论坛选为固件级的可执行格式。
PE/COFF 文件的基本结构:
|
|
关键的字段:
- Machine(COFF Header):对于 x86_64 UEFI 是
0x8664,告诉固件需要 64 位模式 - Subsystem(Optional Header):UEFI 应用固定为
EFI_APPLICATION (10) - Entry Point:固件加载后从这里开始执行——相当于 ELF 的
_start - .reloc 段:EFI 加载器根据实际加载地址修正代码中的绝对地址引用
可以用 file 命令验证:
|
|
💡 关键认知:UEFI 固件是一个「PE/COFF 加载器」。当它找到
BOOTX64.EFI后, 会按照 PE/COFF 规范解析文件、分配内存、处理重定位,然后跳转到入口点执行。 整个流程和 Windows 加载.exe的过程几乎一样——只是运行在固件层面。
GRUB 的定位
BOOTX64.EFI 可以是任何 EFI 可执行文件——它可以是 GRUB 引导器,
也可以是 Linux 内核本身(开启了 EFI Stub 的内核)。
GRUB(Grand Unified Bootloader)是一个「启动菜单程序」。它做的事很简单:
- 被 UEFI 加载运行
- 读取配置文件(
grub.cfg),显示一个菜单 - 根据选择,加载 Linux 内核和 initramfs
- 把控制权交给内核
完整启动链:
|
|
2.2.3 我们的方案:嵌入配置
标准做法是 GRUB 启动后去磁盘上搜索 grub.cfg 文件。但这有两个问题:
- 路径问题:GRUB 可能找不到正确的设备——尤其在手动构建的系统中
- 依赖外部文件:配置文件丢了?GRUB 就傻眼了
我们的解决方案:把启动指令直接「烧」进 GRUB 内部。这样 BOOTX64.EFI
就是一个完全自包含的启动器——不依赖任何外部配置文件。
2.3 GPT 分区表与磁盘布局
2.3.1 GPT vs MBR
传统硬盘用 MBR(Master Boot Record)分区表,只能支持 4 个主分区和最大 2TB 的磁盘。 现代系统用 GPT(GUID Partition Table),没有这些限制,而且 UEFI 要求使用 GPT。
2.3.2 我们的磁盘布局
|
|
- 分区 1(ESP):只放 UEFI 启动引导文件,FAT32 格式,UEFI 固件能直接读。
除
BOOTX64.EFI之外不再放任何其他文件。 - 分区 2(root):根文件系统,ext4 格式。存放内核镜像、initramfs 以及完整的
用户空间文件。系统启动后由 initramfs 中的 init 脚本将其切换为真正的根目录(
/), 所有文件修改都会持久保存。
ESP 从 1MB 偏移处开始(GPT 分区表需要这个空间),大小为 100MB。 根分区占据剩余空间。
2.4 扩大内核配置:块设备与文件系统
第 1 章的内核配置只支持在内存中运行。要让内核能访问磁盘、读写文件系统, 需要开启新的功能模块。
2.4.1 块设备子系统(非常重要)
CONFIG_BLOCK 是整个 Linux 块设备子系统的总开关。包括硬盘、SSD、U 盘、
虚拟磁盘在内的一切存储设备都依赖它。
tinyconfig 默认关闭了 CONFIG_BLOCK,如果没打开配置,导致内核编译出来没有任何磁盘设备节点——
/dev/sda、/dev/vda 全部不存在。
|
|
🎯 关键:用 tinyconfig 白名单策略时,必须显式开启
CONFIG_BLOCK和CONFIG_SCSI。这两个选项在完整发行版内核中默认开启,很容易被忽略。⚠️ Linux 7.1 额外注意:7.1 引入了
menuconfig VIRTIO_MENU作为所有 virtio 驱动选项的门控。必须先开启CONFIG_VIRTIO_MENU,再开启CONFIG_VIRTIO_PCI、CONFIG_VIRTIO_BLK等子选项,否则make olddefconfig会把它们静默清除(表现为scripts/config --state返回undef)。
2.4.2 关于 CONFIG_EFI 的一个坑
CONFIG_EFI 依赖 CONFIG_ACPI。如果在 tinyconfig 中直接开启 CONFIG_EFI,
olddefconfig 会因为依赖不满足而自动把它清除。
正确的顺序:
|
|
2.5 GRUB 引导器:嵌入配置
2.5.1 编译 GRUB EFI 镜像
我们需要创建一个能直接被 UEFI 执行的 EFI 文件,并且把启动指令嵌入其中:
|
|
grub-mkimage 做了两件事:
- 把 GRUB 核心和指定的模块(
part_gpt、fat、linux等)打包成一个 EFI 文件 - 把
embedded.cfg的内容内嵌在 GRUB 镜像里
2.5.2 嵌入配置详解
| 指令 | 作用 |
|---|---|
insmod part_gpt |
加载 GPT 分区表识别模块 |
insmod fat |
加载 FAT 文件系统读取模块 |
insmod ext2 |
加载 ext2/3/4 读取模块 |
insmod linux |
加载 Linux 内核启动协议模块 |
insmod boot |
加载启动执行模块 |
insmod echo |
加载屏幕输出模块 |
search --no-floppy --set=root --file /boot/bzImage |
在所有分区搜索并定位 /boot/bzImage |
linux /boot/bzImage ... |
加载内核,传入启动参数 |
initrd /boot/rootfs.cpio.gz |
加载 initramfs |
boot |
开始执行! |
2.6 完整的 init 脚本(含 switch_root)
第 1 章的 init 脚本不涉及磁盘。本章的 initramfs init 只负责一件事:
找到 ext4 根分区,把控制权交给它。真正的根文件系统上的 /sbin/init 才是
系统启动后运行的 init。
2.6.1 initramfs 的 init(引导阶段)
这个 init 在 initramfs 中运行。它的任务很明确——找到分区 2、挂载它、
然后 switch_root 把控制权交给真正的根文件系统。
|
|
⚠️
switch_root从哪里来?
switch_root不是 BusyBox/Toybox 默认包含的命令,需要在编译 Toybox 时 手动开启CONFIG_SWITCH_ROOT=y。具体的 Toybox 编译流程 请参考第 1 章第 1.5.2 节「获取和编译」:
1 2 3 4cd /workspace/toybox echo "CONFIG_SWITCH_ROOT=y" >> .config make olddefconfig LDFLAGS="--static" make -j$(nproc)编译完成后,
toybox二进制中会包含switch_root命令。 rootfs 中创建switch_root -> toybox符号链接即可使用:
1ln -sf toybox /workspace/rootfs/sbin/switch_root如果缺少此步骤,
switch_root执行时会报not found, 系统将无法切换到 ext4 根文件系统,只能停留在 initramfs 的 Shell 中。
switch_root 原理分析:如何把根切换到 ext4 分区
switch_root 是 initramfs 引导流程中最关键的步骤。它是一套精巧的文件系统根替换机制。
switch_root 的核心任务可以概括为四步:
|
|
第一步:move —— 移动挂载点
|
|
mount --move 是 Linux 内核提供的一个特殊操作:它不触发任何 I/O,
只是在内核的挂载树中修改一个挂载点的位置。原挂载点 /mnt/root 消失,
ext4 分区直接出现在 / 上。
|
|
注意:/proc、/sys、/dev 这些从 initramfs 中挂载的虚拟文件系统
会「跟随」移动到新根下,不需要重新挂载(这也是为什么 2.6.2 的 /sbin/init
会先 umount /sys 再重新挂载——这些旧挂载点可能状态不对)。
第二步:pivot —— 把旧根挪走
switch_root 的底层使用的是 pivot_root 系统调用(而非 chroot)。
两者的关键区别:
chroot |
pivot_root |
|
|---|---|---|
| 新根 | 只是一个目录 | 必须是一个挂载点 |
| 旧根 | 仍然可访问(可逃逸) | 被移到子目录,可以彻底卸载 |
| 内核视角 | 仅改变进程的根目录 | 替换整个系统的根文件系统 |
| initramfs | 无法释放内存 | 删除后可以释放内存 |
pivot_root 的真正威力在于:它把 initramfs 的 tmpfs 从 / 上卸下来,
把 ext4 分区顶上去。 操作完成后,内核层面的「根文件系统」已经变成了
ext4 分区。旧根(initramfs)被移动到 put_old 目录下,随后被 switch_root
递归删除。
|
|
第三步:delete —— 释放 initramfs
|
|
initramfs 本质是 tmpfs(内存文件系统),删除其中的文件会立即释放内存。 这一步很重要,否则 initramfs 中解压出来的文件(Toybox 二进制、内核模块等) 会一直占用几十 MB 内存,直到关机。
第四步:exec —— 启动新 init
|
|
exec 是关键——它不创建新进程,而是在当前进程(PID 1)的上下文中
用新 init 程序替换自身。这样 /sbin/init 继续以 PID 1 的身份运行,
保持了 init 进程的特殊地位(PID 1 不能被 kill、负责收养孤儿进程等)。
完整流程可视化
|
|
为什么不用 chroot?
可能读者会问:直接在 initramfs 的 init 里 chroot /mnt/root /sbin/init
不行吗?使用 chroot 会存在几个问题:
- 内存无法释放:已经说明
- PID 1 跑在旧根上:chroot 只改变「当前进程看到的根目录」, 但 PID 1 的进程根文件系统仍然是 initramfs
- /proc 混乱:
/proc/1/root会指向旧根(initramfs), 而不是 ext4 分区——各种工具可能产生混淆
2.6.2 分区 2 上的 /sbin/init(系统阶段)
switch_root 之后,分区 2 上的 /sbin/init 开始运行。
它负责启动后的初始化,然后交出交互式 Shell:
|
|
💡 为什么要有两个 init?
Linux 内核启动后,先加载 initramfs 并在其中运行
/init(PID 1)。 initramfs 的 init 负责挂载真正的根文件系统,然后用switch_root命令 把当前的文件系统根切换到 ext4 分区上,并执行新的/sbin/init。 这是现代 Linux 发行版的标准做法——initramfs 只做引导,不做系统管理。
1 2内核 → initramfs:/init → 挂载分区 2 → switch_root → /sbin/init → Shell (PID 1) (临时挂载点) (根目录变成分区2) (新 PID 1)切换完成后,分区 2 就是
/。创建的任何文件都直接写入 ext4 分区, 重启后仍然存在,不需要再手动 mount 到/root。
重新打包 rootfs
如果修改了 init 脚本或 rootfs 内容,需要更新 initramfs 和分区 2:
|
|
💡 分区 2 的更新需要在宿主机上用 loop 挂载后操作(见 2.7.2)。
2.7 制作 GPT 分区磁盘镜像
2.7.1 loop 设备:处理 ESP 和 ext4 分区的文件系统操作
磁盘镜像有两个分区需要操作:
- 分区 1(ESP):FAT32 格式,需要创建
/EFI/BOOT/目录并放入BOOTX64.EFI - 分区 2(root):ext4 格式,需要放入完整的根文件系统目录树 +
/boot/存放内核和 initramfs
loop 设备可以同时处理这两种文件系统——用 losetup -P 扫描分区表后,
两个分区都会以独立的设备节点出现(loop0p1 和 loop0p2),
分别 mount 后像普通目录一样操作即可。
⚠️ 重要提醒:loop 设备依赖内核支持。Docker 容器内的 Alpine 系统无法 使用 loop 设备(没有
/dev/loop*权限,也不支持loopsetup的分区扫描)。 因此为了简单起见所有分区格式化和文件写入操作推荐在宿主机上完成, 同时也给出了在Docker 容器内的操作方法。 内核编译和 GRUB 镜像生成仍可在 Docker 容器内进行。
loop 设备是什么?
loop 设备(回环设备)是 Linux 内核提供的一种抽象机制:把一个普通文件「伪装」成块设备。
程序可以通过 /dev/loop0、/dev/loop1 等设备节点像操作真实硬盘一样读写普通文件。
它的工作原理很简单——内核的 loop 驱动拦截所有发往 /dev/loopN 的块 I/O 请求,
将其转换为对关联文件(backing file)的读写操作:
|
|
💡 类比:loop 设备就像一个「翻译官」——你说「读硬盘第 100 个扇区」, 它翻译成「读 disk.img 的第 51200 个字节」。对上层程序来说,
/dev/loop0和/dev/sda没有任何区别。
常用操作方法
方法一:基本关联
|
|
方法二:分区扫描(-P 参数)
|
|
-P 参数让内核根据 GPT/MBR 分区表自动为每个分区创建对应的设备节点。
这是最推荐的方式——不需要手动计算偏移量,两个分区一目了然。
方法三:带偏移量的基本关联
如果没有 -P 支持,可以手动指定分区偏移量:
|
|
方法四:mount -o loop(一步挂载)
|
|
使用限制:并非所有环境都支持 loop
loop 设备虽然方便,但它的工作依赖于内核的 loop 模块(CONFIG_BLK_DEV_LOOP)。
在以下环境中,loop 的功能可能受限甚至完全不可用。
虚拟化环境(KVM / 云 VPS)
虚拟化平台的内核通常为了安全和精简,只编译了最小化的驱动集合。常见问题:
loop模块未编译(CONFIG_BLK_DEV_LOOP未设置)loop模块不支持分区扫描/dev/loop-control不存在,无法动态创建 loop 设备
表现为 losetup 命令报告 “no such device”,或执行成功但 /dev/loop0p1 等分区节点不出现。
不同 KVM / 云 VPS 版本和内核版本表现不一致,建议在实际环境中先测试。
Docker 容器
默认情况下,Docker 容器没有访问 /dev/loop* 设备节点的权限。即使宿主机内核支持 loop,
容器内也无法使用 losetup。可以通过以下方式解决:
|
|
🎯 本书推荐的策略:Docker 容器内完成内核编译和 GRUB 镜像生成。 磁盘镜像的分区、格式化和文件写入统一在宿主机上完成—— 把
disk.img从容器复制出来,在宿主机上用losetup -P一次性完成 ESP 和 ext4 root 的所有操作,完成后再拷回容器继续 QEMU 测试。 为了收敛在Docker容器内的操作,也同时给出不支持losetup -P的方案。 这需要手动计算偏移量,关联分区,下一节(2.7.2)会给出完整的操作步骤。
WSL(Windows Subsystem for Linux)
WSL 2 运行的是真实的 Linux 内核,但 Microsoft 提供的内核配置非常精简。 要启用 loop 支持,可能需要自行下载 WSL 2 内核源码,开启相关配置,重新编译并替换。
内核配置检查
如果你不确定当前环境是否支持 loop,可以用以下命令检查:
|
|
各环境方案对比
| 环境 | loop 支持 |
|---|---|
| 物理 Linux | ✅ losetup -P + mount |
| KVM | ⚠️ 取决于内核 |
| Docker(默认) | ⚠️ → 特权模式启动,手动计算偏移量,关联分区 |
| WSL 1 | ❌ → 不支持 |
| WSL 2 | ❌ → 可能需要自定义编译内核 |
2.7.2 实际操作:格式化分区并写入文件
以下操作在宿主机上完成(需要 root 权限和 loop 设备支持)。
|
|
⚠️ 如果
losetup -P不可用(如 BusyBox/Alpine 环境),改用偏移量dd方式。 重要:不能直接用losetup -o+mkfs.ext4,因为 loop 设备会从偏移量 覆盖到文件末尾,导致 ext4 超出实际分区边界,内核挂载时报bad geometry: block count NNN exceeds size of device (MMM blocks)。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24ESP_START=$((2048 * 512)) # ESP 偏移 = 1MB ROOT_START=$((196608 * 512)) # root 偏移 = 196608扇区×512 ROOT_SECTORS=325632 # root 分区扇区数 (196608→522239) # === ESP:直接格式化并写入 === losetup -o $ESP_START /dev/loop0 disk.img mkfs.fat -F32 /dev/loop0 mount /dev/loop0 /mnt/esp mkdir -p /mnt/esp/EFI/BOOT cp BOOTX64.EFI /mnt/esp/EFI/BOOT/ umount /mnt/esp losetup -d /dev/loop0 # === root:先创建精确大小的 ext4 镜像再 dd 写入 === truncate -s $((ROOT_SECTORS * 512)) /tmp/ext4_root.img mkfs.ext4 -q /tmp/ext4_root.img mount /tmp/ext4_root.img /mnt/root cp -a /workspace/rootfs/* /mnt/root/ mkdir -p /mnt/root/boot cp /workspace/linux-7.1/arch/x86/boot/bzImage /mnt/root/boot/ cp /workspace/rootfs.cpio.gz /mnt/root/boot/ umount /mnt/root dd if=/tmp/ext4_root.img of=disk.img bs=512 seek=196608 conv=notrunc rm -f /tmp/ext4_root.img
💡 如果
disk.img是用docker cp从容器里拷出来的, 完成以上操作后再docker cp拷回容器,继续后面的 initramfs 打包和 QEMU 测试。 如果宿主机的/workspace和容器是共享映射的,则不需要来回复制。
最终验证
|
|
🎯 检查清单:
- ESP(FAT32):只包含
EFI/BOOT/BOOTX64.EFI- 分区 2(ext4):包含完整的根文件系统目录(
bin/,sbin/,dev/,etc/,tmp/,init)+boot/bzImage+boot/rootfs.cpio.gz
2.8 QEMU 验证
2.8.1 OVMF UEFI 固件
QEMU 需要 OVMF(Open Virtual Machine Firmware)来模拟 UEFI 环境:
|
|
OVMF 有两个文件:
OVMF_CODE.fd:UEFI 固件代码(只读)OVMF_VARS.fd:NVRAM 变量存储(读写)
⚠️ OVMF NVRAM 缓存问题:修改了
BOOTX64.EFI后,QEMU 可能仍然加载旧的。 这是因为 OVMF 在 NVRAM 中缓存了启动项信息。每次测试使用全新拷贝:
1cp /usr/share/OVMF/OVMF_VARS.fd /tmp/ovmf_clean.fd
2.8.2 启动命令
|
|
参数说明:
-drive if=pflash:将 OVMF 固件加载到 flash 设备unit=0,readonly=on:代码只读unit=1:NVRAM 可读写-drive file=disk.img,if=virtio:磁盘镜像用 virtio 接口(→/dev/vda)
💡 如果 QEMU 不支持 pflash,可以用
-bios简化方式:
1 2 3qemu-system-x86_64 -bios /usr/share/OVMF/OVMF.fd \ -drive file=disk.img,format=raw,if=virtio \ -nographic -m 256M
2.8.3 预期启动输出
|
|
🔍 如果看到 “Looking for root filesystem…” 但最终 “FAILED to mount root filesystem!”, 说明块设备未正确识别。检查:
- 内核是否启用了
CONFIG_BLOCK(用zcat /proc/config.gz | grep BLOCK确认)- QEMU 磁盘参数(
if=virtio→/dev/vda,if=ide→/dev/sda) 与 init 脚本中尝试的设备名是否匹配- 分区 2 是否用
mkfs.ext4正确格式化,且 rootfs 文件已写入
2.9 持久存储写入验证
现在我们已经有了一个完整的、从磁盘镜像启动的最小 Linux 系统,
ext4 根分区被 switch_root 切换为 /。这一节我们来实际验证:
在系统里写入的文件,重启后是否真的还在?
2.9.1 第一次启动:写入测试文件
系统启动后会显示欢迎信息并进入 Shell。此时 / 就是 ext4 根分区上的内容:
|
|
💡 这个
/reboot-test.txt是直接写入 ext4 分区的。 所有数据都存在磁盘镜像的 ext4 文件系统上。
2.9.2 关机
对于我们的最小系统,使用 poweroff -f 强制关机:
|
|
⚠️ 如果 Toybox 编译时没有开启
CONFIG_POWEROFF,需要确认配置:
1 2 3 4cd /workspace/toybox echo "CONFIG_POWEROFF=y" >> .config make olddefconfig LDFLAGS="--static" make -j$(nproc)
2.9.3 第二次启动:验证文件还在
重启完全相同的 QEMU 命令
|
|
进入 Shell 后,检查上次写入的文件:
|
|
🎯 关键验证点:第一次启动写入
/reboot-test.txt→ 关机 → 第二次启动 文件还在。我们的数据再不会因为重启而丢失了。
2.9.4 验证在宿主机直接查看
除了在 QEMU 内启动验证,还可以在宿主机上用 loop 挂载直接查看:
|
|
这种方式不需要启动 QEMU 就能确认数据确实写入了 ext4 分区—— 和访问普通 ext4 文件系统完全一样。
🎯 至此,我们已经构建了一个真正具备持久存储能力的最小 Linux 系统—— 用户创建的文件不再随重启消失,从临时内存系统到「可用操作系统」 我们走出了关键的一步。
2.10 踩坑记录
坑 1:CONFIG_EFI 被静默清除
现象:明明在 .config 里写了 CONFIG_EFI=y,但 make olddefconfig 后消失了。
原因:CONFIG_EFI 依赖于 CONFIG_ACPI。在 tinyconfig 中 CONFIG_ACPI 是关闭的,
olddefconfig 会把依赖不满足的选项自动关闭。
修复:先开启 CONFIG_ACPI,再开启 CONFIG_EFI。
坑 2:tinyconfig 关闭了 BLOCK —— 没有任何磁盘设备
现象:
fdisk -l输出为空ls /dev/sd*/ls /dev/vd*无结果mount -t ext4 /dev/vda2 /mnt/root报 “No such device”- initramfs init 报 “FAILED to mount root filesystem!",回退到 initramfs 内的 Shell
原因:tinyconfig 把 CONFIG_BLOCK 关掉了。CONFIG_BLOCK 是所有块设备的父开关,
关闭后整个存储子系统(SCSI、virtio-blk、ext4 挂载)全部失效。
修复:显式开启 CONFIG_BLOCK、CONFIG_SCSI、CONFIG_BLK_DEV_SD、CONFIG_VIRTIO_BLK。
🎯 这是整个项目踩过最深的坑。用 tinyconfig 白名单策略时,
CONFIG_BLOCK和CONFIG_SCSI必须加入开启清单。它们在发行版内核中默认开启, 极容易被忽略。
坑 3:GRUB 嵌入配置中的 # 注释
现象:GRUB 启动后报 “Unknown command ‘#’"。
原因:GRUB 的 --config 嵌入配置是逐行被 GRUB 解释器执行的,
不支持 # 注释语法。# 被当作命令名执行。
修复:不要在嵌入配置中用注释,或者用 echo 替代。
坑 4:ext4 分区格式化不正确
现象:switch_root 执行时报 “Invalid argument” 或 initramfs init 无法挂载分区 2。
原因:用 mkfs.ext4 --offset 方式直接对 raw 镜像偏移位置格式化,
容易因偏移计算错误或字节对齐问题导致 ext4 超级块损坏。
修复:使用 losetup -P 创建分区设备节点后格式化:
|
|
坑 5:OVMF NVRAM 缓存
现象:修改了 BOOTX64.EFI,但 QEMU 仍然加载旧的。
原因:OVMF 固件会在 NVRAM 中缓存启动项信息。即使磁盘文件变了, 它还是会尝试加载之前记录的启动项。
修复:每次测试使用全新拷贝的 OVMF_VARS.fd:
|
|
坑 6:mdev -s 触发 toybox 通用保护异常 (GPF)
现象:内核启动后进入 shell,dmesg 显示:
|
|
同时 ext4 分区可能挂载失败,进入 “FAILED to mount root filesystem!” 回退。
原因:toybox(GCC 编译的静态二进制)在 x86_64 上默认使用 SSE2 指令
做内存操作(movsd、movaps、comisd 等)。当 mdev -s 扫描 /sys
创建大量设备节点时,密集的字符串处理和内存操作触发 SSE 寄存器使用。
在 tinyconfig 编译的内核中,FPU/XSAVE 状态管理虽然基础可用,但某些
代码路径可能导致 #GP 异常(错误码 0,非段相关)。
修复:
-
推荐方案:去掉
mdev -s。CONFIG_DEVTMPFS已开启时,内核会自动 在/dev中创建所有块设备节点(/dev/vda、/dev/vda2等), 完全不需要手动mdev -s。 -
备选方案:如果需要 mdev(如未启用 devtmpfs),可尝试用
LDFLAGS="--static" CFLAGS="-march=x86-64 -mtune=generic"重新编译 toybox,避免使用高级 SIMD 指令。
🎯 核心经验:
devtmpfs与mdev -s在很多场景下是冗余的。 只要CONFIG_DEVTMPFS=y,块设备节点不需要手动创建。
坑 7:ext4 分区大小超过设备边界
现象:initramfs 能找到 /dev/vda2,但 mount 报 Invalid argument,
dmesg 显示:
|
|
原因:用 losetup -o ROOT_OFFSET /dev/loopN disk.img 关联 loop 设备时,
loop 设备从偏移量位置覆盖到文件末尾,而非到 GPT 分区的结束扇区。
因此 mkfs.ext4 会创建一个比实际分区大的文件系统。
示例:root 分区结束于 sector 522239(即 267386368 字节处), 但 loop 设备覆盖从 196608 扇区到文件末尾(268435456 字节处), 多出约 1MB,导致 ext4 的 block count 超过分区实际容量。
修复:
- 首选:使用
losetup -P扫描分区表(推荐)。 - 备用:先创建精确大小的镜像文件(
truncate -s + mkfs.ext4), 再用dd写入到分区的精确扇区偏移位置。
|
|
💡 这个坑在 BusyBox/Alpine 的
losetup(不支持--sizelimit)中 尤为常见。losetup -P是正确做法,但如果不可用,dd是可靠的替代方案。
2.11 本章小结
你在本章完成了:
- ✅ 理解了 UEFI 启动流程(固件 → ESP → BOOTX64.EFI)
- ✅ 学会了 GPT 分区表与磁盘布局
- ✅ 采用了 ESP + ext4 根分区的双分区方案(ESP 只放 EFI,根分区放全部系统文件)
- ✅ 用 loop 设备(
losetup -P或dd偏移量方式)完成了 ESP 和 ext4 根分区的格式化和文件写入 - ✅ 扩大了内核配置,新增块设备、文件系统、UEFI 支持
- ✅ 用 GRUB + 嵌入配置实现了完全自包含的启动
- ✅ 编写了 initramfs init + switch_root 的双 init 启动方案,修复了 mdev GPF 和 ext4 分区大小问题
- ✅ 在 QEMU 中成功启动,ext4 根文件系统持久化验证通过
此时你的系统特点:
- 从 UEFI 磁盘镜像启动,支持真实硬件
- ESP 分区只含一个
BOOTX64.EFI(604KB),干净简洁 - ext4 根分区被 switch_root 切换为
/,所有文件修改自动持久保存 - 完整的启动链:UEFI → GRUB → Linux 内核 → initramfs → switch_root → ext4 root → Shell
- 仍然没有网络(将在后续章节补充)
附录 A:关键命令速查
内核编译
| 操作 | 命令 |
|---|---|
| 生成最小配置 | make tinyconfig |
| 查看某个配置项 | grep CONFIG_XXX .config |
| 开启配置 | scripts/config --enable CONFIG_XXX |
| 设置字符串配置 | scripts/config --set-str CONFIG_XXX "value" |
| 补齐依赖 | make olddefconfig |
| 编译内核 | make -j$(nproc) bzImage |
Toybox
| 操作 | 命令 |
|---|---|
| 生成默认配置 | make defconfig |
| 开启 Shell | echo "CONFIG_SH=y" >> .config |
| 开启 mdev | echo "CONFIG_MDEV=y" >> .config |
| 开启静态链接 | echo "CONFIG_STATIC=y" >> .config |
| 编译 | LDFLAGS="--static" make -j$(nproc) |
| 安装到目录 | make install PREFIX=/path/to/rootfs |
| 验证静态链接 | ldd toybox(应为 “not a dynamic executable”) |
Rootfs
| 操作 | 命令 |
|---|---|
| 创建 console 节点 | mknod -m 622 dev/console c 5 1 |
| 创建 ttyS0 节点 | mknod -m 666 dev/ttyS0 c 4 64 |
| 打包 initramfs | find . | cpio -o -H newc | gzip > rootfs.cpio.gz |
| 嵌入 initramfs | scripts/config --set-str CONFIG_INITRAMFS_SOURCE "/path/to/rootfs" |
磁盘镜像
| 操作 | 命令 |
|---|---|
| 创建空镜像 | qemu-img create -f raw disk.img 256M |
| 创建 GPT 分区表 | parted -s disk.img mklabel gpt |
| 创建 ESP 分区 | parted -s disk.img mkpart ESP fat32 1M 101M |
| 创建 root 分区 | parted -s disk.img mkpart root ext4 101M 100% |
| 设置 ESP 标志 | parted -s disk.img set 1 esp on |
| 格式化 FAT32 | mkfs.fat -F32 /dev/loop0p1 |
| 格式化 ext4 | mkfs.ext4 /dev/loop0p2 |
| loop 挂载ESP | losetup -Pf disk.img && mount /dev/loop0p1 /mnt/esp |
| loop 挂载 root | losetup -Pf disk.img && mount /dev/loop0p2 /mnt/root |
| switch_root | exec switch_root /mnt/root /sbin/init |
GRUB
| 操作 | 命令 |
|---|---|
| 生成 EFI 镜像 | grub-mkimage --format=x86_64-efi --config=cfg ... |
QEMU 启动
| 场景 | 命令 |
|---|---|
| 直接内核启动 | qemu-system-x86_64 -kernel bzImage -append "console=ttyS0" -nographic -m 256M |
| 内核+initrd | qemu-system-x86_64 -kernel bzImage -initrd rootfs.cpio.gz -append "console=ttyS0" -nographic -m 256M |
| UEFI 磁盘启动 | 见 2.8.2 节完整命令 |
附录 B:最终文件清单
完成第 2 章后,你的工作目录应该包含:
|
|
编写于 2026-06-24 · 环境:Alpine Linux 3.22.4 · Docker 容器 · Linux 7.1 + Toybox 0.8.13 后续章节待补充:网络配置、显卡与图形输出、安全加固等