目录

自己动手从零构建Linux操作系统_第2章


目录

自己动手从零构建Linux操作系统

写给有 Linux 基础,但对底层硬件不太熟悉的朋友

这本书从「原理」和「实践」两个维度,手把手带你从零开始, 编译内核、构建用户空间、制作启动磁盘,最终得到一个可以真正运行的最小 Linux 系统。


https://oser.space/%E8%87%AA%E5%B7%B1%E5%8A%A8%E6%89%8B%E4%BB%8E%E9%9B%B6%E6%9E%84%E5%BB%BAlinux%E6%93%8D%E4%BD%9C%E7%B3%BB%E7%BB%9F_%E7%AC%AC2%E7%AB%A0/2.png

第 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 格式的分区里放一个特定路径的文件:

1
2
3
4
ESP(EFI 系统分区)
└── EFI
    └── BOOT
        └── BOOTX64.EFI    ← UEFI 固件会直接找这个文件执行

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 文件的基本结构:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
┌────────────────────────────┐
│  DOS Header (MZ 签名)       │  ← 传统 DOS 存根,确保「这不是 DOS 程序」
├────────────────────────────┤
│  PE Signature ("PE\0\0")   │
├────────────────────────────┤
│  COFF Header               │  ← 目标机器类型(如 x86_64)
├────────────────────────────┤
│  Optional Header           │  ← 入口点地址、镜像基址、子系统类型
├────────────────────────────┤
│  Section Headers           │  ← 各段的名称、大小、属性
├────────────────────────────┤
│  .text (代码段)             │
├────────────────────────────┤
│  .data (数据段)             │
├────────────────────────────┤
│  .reloc (重定位表)          │  ← EFI 加载器据此修正地址引用
└────────────────────────────┘

关键的字段:

  • Machine(COFF Header):对于 x86_64 UEFI 是 0x8664,告诉固件需要 64 位模式
  • Subsystem(Optional Header):UEFI 应用固定为 EFI_APPLICATION (10)
  • Entry Point:固件加载后从这里开始执行——相当于 ELF 的 _start
  • .reloc 段:EFI 加载器根据实际加载地址修正代码中的绝对地址引用

可以用 file 命令验证:

1
2
file BOOTX64.EFI
# 输出:BOOTX64.EFI: PE32+ executable (EFI application) x86-64, for MS Windows, ...

💡 关键认知:UEFI 固件是一个「PE/COFF 加载器」。当它找到 BOOTX64.EFI 后, 会按照 PE/COFF 规范解析文件、分配内存、处理重定位,然后跳转到入口点执行。 整个流程和 Windows 加载 .exe 的过程几乎一样——只是运行在固件层面。

GRUB 的定位

BOOTX64.EFI 可以是任何 EFI 可执行文件——它可以是 GRUB 引导器, 也可以是 Linux 内核本身(开启了 EFI Stub 的内核)。

GRUB(Grand Unified Bootloader)是一个「启动菜单程序」。它做的事很简单:

  1. 被 UEFI 加载运行
  2. 读取配置文件(grub.cfg),显示一个菜单
  3. 根据选择,加载 Linux 内核和 initramfs
  4. 把控制权交给内核

完整启动链:

1
按下电源 → UEFI 固件 → 加载 BOOTX64.EFI (GRUB) → 读配置 → 加载内核 + initramfs → 内核启动

2.2.3 我们的方案:嵌入配置

标准做法是 GRUB 启动后去磁盘上搜索 grub.cfg 文件。但这有两个问题:

  1. 路径问题:GRUB 可能找不到正确的设备——尤其在手动构建的系统中
  2. 依赖外部文件:配置文件丢了?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
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
┌────────────────────────────────────────┐
│  disk.img (256 MB)                     │
│                                        │
│  ┌──────────────┬──────────────────┐   │
│  │ 分区1: ESP   │ 分区2: root      │   │
│  │ FAT32 100MB  │ ext4  ~156MB     │   │
│  │              │                  │   │
│  │ EFI/BOOT/    │ boot/            │   │
│  │   BOOTX64.EFI│   bzImage        │   │
│  │              │   rootfs.cpio.gz │   │
│  │              │                  │   │
│  │              │ bin/  sbin/      │   │
│  │              │ dev/  etc/  tmp/ │   │
│  │              │ init             │   │
│  │              │ (根文件系统)      │   │
│  └──────────────┴──────────────────┘   │
└────────────────────────────────────────┘
  • 分区 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 全部不存在。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
cd /workspace/linux-7.1

# ═══════════════════════════════════════════
# 在第一章配置基础上,新增以下选项:
# ═══════════════════════════════════════════

# 块设备子系统
scripts/config --enable CONFIG_BLOCK
scripts/config --enable CONFIG_SCSI
scripts/config --enable CONFIG_BLK_DEV_SD

# 分区表支持
scripts/config --enable CONFIG_EFI_PARTITION     # GPT 分区表
scripts/config --enable CONFIG_MSDOS_PARTITION   # MBR 分区表

# 文件系统(用于持久存储)
scripts/config --enable CONFIG_EXT4_FS
scripts/config --enable CONFIG_FAT_FS
scripts/config --enable CONFIG_VFAT_FS

# QEMU 虚拟设备驱动
scripts/config --enable CONFIG_PCI
scripts/config --enable CONFIG_VIRTIO
scripts/config --enable CONFIG_VIRTIO_MENU        # ⚠️ Linux 7.1+ 新增,门控所有 virtio 驱动
scripts/config --enable CONFIG_VIRTIO_PCI
scripts/config --enable CONFIG_VIRTIO_PCI_LEGACY
scripts/config --enable CONFIG_VIRTIO_BLK        # virtio 块设备 → /dev/vda

# UEFI 启动支持
scripts/config --enable CONFIG_ACPI              # EFI 的硬依赖
scripts/config --enable CONFIG_EFI
scripts/config --enable CONFIG_EFI_STUB          # 允许内核直接被 UEFI 加载

# 补齐依赖
make olddefconfig
make -j$(nproc) bzImage

🎯 关键:用 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 会因为依赖不满足而自动把它清除。

正确的顺序:

1
2
3
4
scripts/config --enable CONFIG_ACPI    # 先开依赖
scripts/config --enable CONFIG_EFI     # 再开本身
scripts/config --enable CONFIG_EFI_STUB
make olddefconfig                      # 这样 EFI 就不会被清除了

2.5 GRUB 引导器:嵌入配置

2.5.1 编译 GRUB EFI 镜像

我们需要创建一个能直接被 UEFI 执行的 EFI 文件,并且把启动指令嵌入其中:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
# 创建嵌入配置(⚠️ 不支持 # 注释!)
cat > embedded.cfg << 'EOF'
insmod part_gpt
insmod fat
insmod ext2
insmod normal
insmod linux
insmod boot
insmod echo
insmod all_video
search --no-floppy --set=root --file /boot/bzImage
echo Loading Minimal Linux 7.1...
linux /boot/bzImage console=ttyS0 quiet
initrd /boot/rootfs.cpio.gz
boot
EOF

# 生成 EFI 文件,嵌入配置
grub-mkimage \
    --format=x86_64-efi \
    --output=BOOTX64.EFI \
    --prefix='' \
    --config=embedded.cfg \
    part_gpt fat ext2 normal boot linux echo search \
    search_fs_file all_video efi_gop

grub-mkimage 做了两件事:

  1. 把 GRUB 核心和指定的模块(part_gpt、fat、linux 等)打包成一个 EFI 文件
  2. 把 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 把控制权交给真正的根文件系统。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
cat > /workspace/rootfs/init << 'INITEOF'
#!/bin/sh

# =============================================
#  initramfs bootstrap — 找到根分区并切换
# =============================================

# 挂载内核虚拟文件系统
mount -t proc proc /proc
mount -t sysfs sys /sys
mount -t devtmpfs dev /dev

# devtmpfs 已在内核挂载时自动创建了所有块设备节点(如 /dev/vda2)
# 不需要 mdev -s —— 在 tinyconfig 内核上 mdev -s 会因 SSE 指令
# 缺少 FPU/XSAVE 状态管理而触发 general protection fault (GPF)
# 如果未启用 CONFIG_DEVTMPFS,取消下面注释以手动创建设备节点:
# mdev -s
sleep 0.3

# 查找并挂载根文件系统
# virtio-blk → /dev/vda, SATA/SCSI → /dev/sda
echo "Looking for root filesystem..."
mkdir -p /mnt/root

for dev in sda2 vda2 sdb2 vdb2; do
    if [ -e "/dev/$dev" ]; then
        echo "Found /dev/$dev, mounting..."
        mount -t ext4 "/dev/$dev" /mnt/root 2>/dev/null
        if [ $? -eq 0 ]; then
            echo "Switching root to ext4 partition..."
            exec switch_root /mnt/root /sbin/init
        fi
    fi
done

# 失败回退
echo "FAILED to mount root filesystem! Dropping to shell."
exec /bin/sh
INITEOF

chmod +x /workspace/rootfs/init

⚠️ switch_root 从哪里来?

switch_root 不是 BusyBox/Toybox 默认包含的命令,需要在编译 Toybox 时 手动开启 CONFIG_SWITCH_ROOT=y。具体的 Toybox 编译流程 请参考第 1 章第 1.5.2 节「获取和编译」:

1
2
3
4
cd /workspace/toybox
echo "CONFIG_SWITCH_ROOT=y" >> .config
make olddefconfig
LDFLAGS="--static" make -j$(nproc)

编译完成后,toybox 二进制中会包含 switch_root 命令。 rootfs 中创建 switch_root -> toybox 符号链接即可使用:

1
ln -sf toybox /workspace/rootfs/sbin/switch_root

如果缺少此步骤,switch_root 执行时会报 not found, 系统将无法切换到 ext4 根文件系统,只能停留在 initramfs 的 Shell 中。

switch_root 原理分析:如何把根切换到 ext4 分区

switch_root 是 initramfs 引导流程中最关键的步骤。它是一套精巧的文件系统根替换机制。

switch_root 的核心任务可以概括为四步:

1
2
3
4
1. move   → 把新根文件系统的挂载点从临时位置「移」到 /
2. pivot  → 把旧根(initramfs)「转」出去
3. delete → 删除旧根上的所有文件,释放内存
4. exec   → 在新的 / 上启动真正的 /sbin/init

第一步:move —— 移动挂载点

1
2
# switch_root 内部等价于:
mount --move /mnt/root /

mount --move 是 Linux 内核提供的一个特殊操作:它不触发任何 I/O, 只是在内核的挂载树中修改一个挂载点的位置。原挂载点 /mnt/root 消失, ext4 分区直接出现在 / 上。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
操作前:                       操作后:
/ (tmpfs, initramfs)          / (ext4, 分区 2)
├── mnt/                      ├── bin/
│   └── root/ (ext4)          ├── sbin/
│       ├── bin/              │   └── init  ← 真正的 init
│       ├── sbin/             ├── boot/
│       │   └── init          │   ├── bzImage
│       └── boot/             │   └── rootfs.cpio.gz
├── proc/                     ├── proc/
├── sys/                      ├── sys/
└── dev/                      └── dev/

注意:/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 递归删除。

1
2
3
4
5
6
7
pivot_root 前的状态:              pivot_root 后的状态:
                                  put_old/
/ (tmpfs)                         ├── mnt/     ← 旧 initramfs 的残骸
├── mnt/                          │   └── root/ ← 已空(mount --move 移走了)
│   └── root/ (ext4) ← new_root   /
└── ...                           └── ... (ext4, 新根)
                                  / (ext4)

第三步:delete —— 释放 initramfs

1
2
# switch_root 递归删除旧根:
rm -rf /put_old

initramfs 本质是 tmpfs(内存文件系统),删除其中的文件会立即释放内存。 这一步很重要,否则 initramfs 中解压出来的文件(Toybox 二进制、内核模块等) 会一直占用几十 MB 内存,直到关机。

第四步:exec —— 启动新 init

1
exec /sbin/init

exec 是关键——它不创建新进程,而是在当前进程(PID 1)的上下文中 用新 init 程序替换自身。这样 /sbin/init 继续以 PID 1 的身份运行, 保持了 init 进程的特殊地位(PID 1 不能被 kill、负责收养孤儿进程等)。

完整流程可视化

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
时间线:

[内核启动]
    │
    ├─→ 挂载 initramfs (tmpfs) 为 /
    ├─→ 执行 /init (PID 1)
    │     │
    │     ├─→ mount -t proc /proc
    │     ├─→ mount -t sysfs /sys
    │     ├─→ mount -t devtmpfs /dev
    │     ├─→ mount -t ext4 /dev/vda2 /mnt/root    ← ext4 分区就位
    │     │
    │     ├─→ exec switch_root /mnt/root /sbin/init
    │     │     │
    │     │     ├─→ mount --move /mnt/root /        ← ext4 成为新根
    │     │     ├─→ pivot_root( . , put_old )        ← 内核级切换
    │     │     ├─→ rm -rf /put_old                  ← 释放 initramfs 内存
    │     │     └─→ exec /sbin/init                  ← PID 1 重新上路
    │     │
    │     └─→ (switch_root 不会返回)
    │
    └─→ /sbin/init (PID 1, 新程序, ext4 根)
          │
          ├─→ 重新挂载 proc, sysfs
          ├─→ 显示欢迎信息
          └─→ exec /bin/sh

为什么不用 chroot?

可能读者会问:直接在 initramfs 的 init 里 chroot /mnt/root /sbin/init 不行吗?使用 chroot 会存在几个问题:

  1. 内存无法释放:已经说明
  2. PID 1 跑在旧根上:chroot 只改变「当前进程看到的根目录」, 但 PID 1 的进程根文件系统仍然是 initramfs
  3. /proc 混乱:/proc/1/root 会指向旧根(initramfs), 而不是 ext4 分区——各种工具可能产生混淆

2.6.2 分区 2 上的 /sbin/init(系统阶段)

switch_root 之后,分区 2 上的 /sbin/init 开始运行。 它负责启动后的初始化,然后交出交互式 Shell:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
cat > /workspace/rootfs/sbin/init << 'INITEOF'
#!/bin/sh

# =============================================
#  系统 init — 运行在 ext4 根文件系统上
# =============================================

# 重新挂载 proc 和 sysfs(switch_root 后需要)
# 注意:sysfs 可能已从 initramfs 挂载,先卸载避免 "Resource busy" 报错
mount -t proc proc /proc 2>/dev/null
umount /sys 2>/dev/null
mount -t sysfs sys /sys 2>/dev/null

# 基本设置
hostname minimal

# =============================================
#  显示欢迎信息
# =============================================
echo ""
echo "  ╔══════════════════════════════════════════╗"
echo "  ║   Welcome to Minimal Linux!             ║"
echo "  ║   Kernel: Linux 7.1                     ║"
echo "  ║   Root on ext4 (persistent).            ║"
echo "  ║   All files on / are saved to disk.     ║"
echo "  ╚══════════════════════════════════════════╝"
echo ""
echo "  Type 'toybox' to see all commands."
echo "  Use 'fdisk -l' to see disk partitions."
echo ""

# 启动交互式 Shell
exec /bin/sh
INITEOF

chmod +x /workspace/rootfs/sbin/init

💡 为什么要有两个 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:

1
2
3
4
5
6
7
8
cd /workspace/rootfs

# 1)重新打包 initramfs(供 GRUB 加载)
find . | cpio -o -H newc | gzip > ../rootfs.cpio.gz

# 2)如果已经格式化并挂载了分区 2,更新文件:
#    cp -a . /mnt/root/
#    cp ../rootfs.cpio.gz /mnt/root/boot/

💡 分区 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)的读写操作:

1
2
3
4
5
程序 → read(/dev/loop0p1, offset=4096, len=512)
         ↓
      loop 驱动:把 offset=4096 翻译成 backing file 的对应位置
         ↓
      read(disk.img, offset=4096, len=512)

💡 类比:loop 设备就像一个「翻译官」——你说「读硬盘第 100 个扇区」, 它翻译成「读 disk.img 的第 51200 个字节」。对上层程序来说,/dev/loop0 和 /dev/sda 没有任何区别。

常用操作方法

方法一:基本关联

1
2
3
4
5
6
7
# 将 disk.img 关联到第一个可用的 loop 设备
LOOP=$(losetup -f)            # 获取空闲 loop 设备,如 /dev/loop0
losetup $LOOP disk.img        # 关联

# 现在 /dev/loop0 就代表整个 disk.img
fdisk -l /dev/loop0            # 查看分区信息
losetup -d $LOOP               # 解除关联

方法二:分区扫描(-P 参数)

1
2
3
4
5
6
7
8
losetup -Pf disk.img           # -P: 扫描分区表,自动创建 /dev/loop0p1、/dev/loop0p2

# 此时两个分区以独立设备节点出现,可以直接操作:
mkfs.fat -F32 /dev/loop0p1     # 格式化 ESP(FAT32)
mkfs.ext4 /dev/loop0p2         # 格式化 root(ext4)

mount /dev/loop0p1 /mnt/esp    # 挂载 ESP
mount /dev/loop0p2 /mnt/root   # 挂载 root 分区

-P 参数让内核根据 GPT/MBR 分区表自动为每个分区创建对应的设备节点。 这是最推荐的方式——不需要手动计算偏移量,两个分区一目了然。

方法三:带偏移量的基本关联

如果没有 -P 支持,可以手动指定分区偏移量:

1
2
3
4
5
6
7
8
9
# ESP 从第 2048 个扇区开始(1MB = 2048 × 512)
losetup -o $((2048 * 512)) /dev/loop0 disk.img
mkfs.fat -F32 /dev/loop0
mount /dev/loop0 /mnt/esp

# 分区 2(root ext4)从 101MB 偏移处开始
losetup -o $((101 * 1024 * 1024)) /dev/loop1 disk.img
mkfs.ext4 /dev/loop1
mount /dev/loop1 /mnt/root

方法四:mount -o loop(一步挂载)

1
2
3
4
5
6
7
# 自动创建临时 loop 设备并挂载
mount -o loop,offset=$((2048 * 512)) disk.img /mnt/esp    # ESP
mount -o loop,offset=$((101 * 1024 * 1024)) disk.img /mnt/root  # root

# 等价于:
#   losetup -o 1048576 /dev/loop0 disk.img
#   mount /dev/loop0 /mnt/esp

使用限制:并非所有环境都支持 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。可以通过以下方式解决:

1
2
3
4
5
# 方式一:特权模式
docker run --privileged ...

# 方式二:精确授权
docker run --device /dev/loop-control --device /dev/loop0 ...

🎯 本书推荐的策略: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,可以用以下命令检查:

1
2
3
4
5
# 检查内核配置(如果 /proc/config.gz 可用)
zcat /proc/config.gz | grep CONFIG_BLK_DEV_LOOP

# 检查 loop 设备是否存在
ls -la /dev/loop*

各环境方案对比

环境 loop 支持
物理 Linux ✅ losetup -P + mount
KVM ⚠️ 取决于内核
Docker(默认) ⚠️ → 特权模式启动,手动计算偏移量,关联分区
WSL 1 ❌ → 不支持
WSL 2 ❌ → 可能需要自定义编译内核

2.7.2 实际操作:格式化分区并写入文件

以下操作在宿主机上完成(需要 root 权限和 loop 设备支持)。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
cd /workspace

# ============================================
# 第一步:创建镜像并分区
# ============================================
qemu-img create -f raw disk.img 256M
parted -s disk.img mklabel gpt
parted -s disk.img mkpart ESP fat32 1M 101M
parted -s disk.img set 1 esp on
parted -s disk.img mkpart root ext4 101M 100%

# ============================================
# 第二步:关联 loop 设备并扫描分区
# ============================================
LOOP=$(losetup -f)              # 获取空闲 loop 设备
losetup -P $LOOP disk.img       # -P 扫描分区表,自动创建 ${LOOP}p1、${LOOP}p2

# ============================================
# 第三步:格式化两个分区
# ============================================
mkfs.fat -F32 ${LOOP}p1         # ESP → FAT32
mkfs.ext4 ${LOOP}p2             # root → ext4

# ============================================
# 第四步:写入 ESP(FAT32)
# ============================================
mkdir -p /mnt/esp
mount ${LOOP}p1 /mnt/esp
mkdir -p /mnt/esp/EFI/BOOT
cp BOOTX64.EFI /mnt/esp/EFI/BOOT/

# 验证
ls -la /mnt/esp/
ls -la /mnt/esp/EFI/BOOT/

umount /mnt/esp

# ============================================
# 第五步:写入分区 2(ext4 根文件系统)
# ============================================
mkdir -p /mnt/root
mount ${LOOP}p2 /mnt/root

# 复制整个 rootfs(这是根文件系统!)
cp -a /workspace/rootfs/* /mnt/root/

# 创建 /boot 目录,放入内核和 initramfs
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/

# 验证
ls -la /mnt/root/
ls -la /mnt/root/boot/

umount /mnt/root

# ============================================
# 第六步:解除关联
# ============================================
losetup -d $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
24
ESP_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 和容器是共享映射的,则不需要来回复制。

最终验证

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
# 重新关联,确认所有文件已正确写入
LOOP=$(losetup -f)
losetup -P $LOOP disk.img

# 验证 ESP
mount ${LOOP}p1 /mnt/esp
ls -la /mnt/esp/EFI/BOOT/
# 预期:BOOTX64.EFI(约 604KB)
umount /mnt/esp

# 验证分区 2
mount ${LOOP}p2 /mnt/root
ls /mnt/root/
# 预期:bin  dev  etc  sbin  tmp  init  boot/
ls /mnt/root/boot/
# 预期:bzImage  rootfs.cpio.gz
umount /mnt/root

losetup -d $LOOP

🎯 检查清单:

  • 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 环境:

1
apk add ovmf

OVMF 有两个文件:

  • OVMF_CODE.fd:UEFI 固件代码(只读)
  • OVMF_VARS.fd:NVRAM 变量存储(读写)

⚠️ OVMF NVRAM 缓存问题:修改了 BOOTX64.EFI 后,QEMU 可能仍然加载旧的。 这是因为 OVMF 在 NVRAM 中缓存了启动项信息。每次测试使用全新拷贝:

1
cp /usr/share/OVMF/OVMF_VARS.fd /tmp/ovmf_clean.fd

2.8.2 启动命令

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
# 准备干净的 NVRAM
cp /usr/share/OVMF/OVMF_VARS.fd /tmp/ovmf_clean.fd

# UEFI 磁盘启动
qemu-system-x86_64 \
    -drive if=pflash,format=raw,unit=0,file=/usr/share/OVMF/OVMF_CODE.fd,readonly=on \
    -drive if=pflash,format=raw,unit=1,file=/tmp/ovmf_clean.fd \
    -drive file=disk.img,format=raw,if=virtio \
    -nographic \
    -m 256M

参数说明:

  • -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
3
qemu-system-x86_64 -bios /usr/share/OVMF/OVMF.fd \
    -drive file=disk.img,format=raw,if=virtio \
    -nographic -m 256M

2.8.3 预期启动输出

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
BdsDxe: loading Boot0002 "UEFI Misc Device" from PciRoot(0x0)/Pci(0x4,0x0)
BdsDxe: starting Boot0002 "UEFI Misc Device" from PciRoot(0x0)/Pci(0x4,0x0)
Loading Minimal Linux 7.1...
Looking for root filesystem...
Found /dev/vda2, mounting...
Switching root to ext4 partition...

  ╔══════════════════════════════════════════╗
  ║   Welcome to Minimal Linux!             ║
  ║   Kernel: Linux 7.1                     ║
  ║   Root on ext4 (persistent).            ║
  ║   All files on / are saved to disk.     ║
  ╚══════════════════════════════════════════╝

  Type 'toybox' to see all commands.
  Use 'fdisk -l' to see disk partitions.

$ fdisk -l
Disk /dev/vda: 268 MB, 268435456 bytes
...
/dev/vda1      2048 206847    100M  EFI System
/dev/vda2    206848 524287  154.8M  Linux filesystem

$ echo "hello from ext4 root" > /hello.txt
$ cat /hello.txt
hello from ext4 root

$ ls /boot/
bzImage          rootfs.cpio.gz

🔍 如果看到 “Looking for root filesystem…” 但最终 “FAILED to mount root filesystem!”, 说明块设备未正确识别。检查:

  1. 内核是否启用了 CONFIG_BLOCK(用 zcat /proc/config.gz | grep BLOCK 确认)
  2. QEMU 磁盘参数(if=virtio → /dev/vda,if=ide → /dev/sda) 与 init 脚本中尝试的设备名是否匹配
  3. 分区 2 是否用 mkfs.ext4 正确格式化,且 rootfs 文件已写入

2.9 持久存储写入验证

现在我们已经有了一个完整的、从磁盘镜像启动的最小 Linux 系统, ext4 根分区被 switch_root 切换为 /。这一节我们来实际验证: 在系统里写入的文件,重启后是否真的还在?

2.9.1 第一次启动:写入测试文件

系统启动后会显示欢迎信息并进入 Shell。此时 / 就是 ext4 根分区上的内容:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
  ╔══════════════════════════════════════════╗
  ║   Welcome to Minimal Linux!             ║
  ║   Root on ext4 (persistent).            ║
  ╚══════════════════════════════════════════╝

$ # 确认当前根文件系统是 ext4
$ mount | grep ' / '
/dev/vda2 on / type ext4 (rw,relatime)

$ # 写入一个测试文件
$ echo "this file survived a reboot!" > /reboot-test.txt
$ cat /reboot-test.txt
this file survived a reboot!

$ # 看看文件属性
$ ls -la /reboot-test.txt
-rw-r--r-- 1 root root 29 ... /reboot-test.txt

💡 这个 /reboot-test.txt 是直接写入 ext4 分区的。 所有数据都存在磁盘镜像的 ext4 文件系统上。

2.9.2 关机

对于我们的最小系统,使用 poweroff -f 强制关机:

1
2
$ poweroff -f
[系统关机,QEMU 自动退出]

⚠️ 如果 Toybox 编译时没有开启 CONFIG_POWEROFF,需要确认配置:

1
2
3
4
cd /workspace/toybox
echo "CONFIG_POWEROFF=y" >> .config
make olddefconfig
LDFLAGS="--static" make -j$(nproc)

2.9.3 第二次启动:验证文件还在

重启完全相同的 QEMU 命令

1
2
3
4
5
6
7
8
cp /usr/share/OVMF/OVMF_VARS.fd /tmp/ovmf_vars.fd

qemu-system-x86_64 \
    -drive if=pflash,format=raw,unit=0,file=/usr/share/OVMF/OVMF_CODE.fd,readonly=on \
    -drive if=pflash,format=raw,unit=1,file=/tmp/ovmf_vars.fd \
    -drive file=disk.img,format=raw,if=virtio \
    -nographic \
    -m 256M

进入 Shell 后,检查上次写入的文件:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
  ╔══════════════════════════════════════════╗
  ║   Welcome to Minimal Linux!             ║
  ╚══════════════════════════════════════════╝

$ cat /reboot-test.txt
this file survived a reboot!

$ # 再来写一个
$ echo "second boot file" > /second-boot.txt
$ ls -la /reboot-test.txt /second-boot.txt
-rw-r--r-- 1 root root 29 ... /reboot-test.txt
-rw-r--r-- 1 root root 17 ... /second-boot.txt

$ # 关机
$ poweroff -f

🎯 关键验证点:第一次启动写入 /reboot-test.txt → 关机 → 第二次启动 文件还在。我们的数据再不会因为重启而丢失了。

2.9.4 验证在宿主机直接查看

除了在 QEMU 内启动验证,还可以在宿主机上用 loop 挂载直接查看:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
# 在宿主机上挂载分区 2
LOOP=$(losetup -f)
losetup -P $LOOP disk.img
mount ${LOOP}p2 /mnt/root

# 直接看我们写的文件
cat /mnt/root/reboot-test.txt
# → this file survived a reboot!

cat /mnt/root/second-boot.txt
# → second boot file

umount /mnt/root
losetup -d $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 创建分区设备节点后格式化:

1
2
3
4
LOOP=$(losetup -f)
losetup -P $LOOP disk.img
mkfs.ext4 ${LOOP}p2
losetup -d $LOOP

坑 5:OVMF NVRAM 缓存

现象:修改了 BOOTX64.EFI,但 QEMU 仍然加载旧的。

原因:OVMF 固件会在 NVRAM 中缓存启动项信息。即使磁盘文件变了, 它还是会尝试加载之前记录的启动项。

修复:每次测试使用全新拷贝的 OVMF_VARS.fd:

1
cp /usr/share/OVMF/OVMF_VARS.fd /tmp/ovmf_clean.fd

坑 6:mdev -s 触发 toybox 通用保护异常 (GPF)

现象:内核启动后进入 shell,dmesg 显示:

1
traps: init[35] general protection fault ip:XXXXXX sp:XXXXXX error:0 in toybox

同时 ext4 分区可能挂载失败,进入 “FAILED to mount root filesystem!” 回退。

原因:toybox(GCC 编译的静态二进制)在 x86_64 上默认使用 SSE2 指令 做内存操作(movsd、movaps、comisd 等)。当 mdev -s 扫描 /sys 创建大量设备节点时,密集的字符串处理和内存操作触发 SSE 寄存器使用。 在 tinyconfig 编译的内核中,FPU/XSAVE 状态管理虽然基础可用,但某些 代码路径可能导致 #GP 异常(错误码 0,非段相关)。

修复:

  1. 推荐方案:去掉 mdev -s。CONFIG_DEVTMPFS 已开启时,内核会自动 在 /dev 中创建所有块设备节点(/dev/vda、/dev/vda2 等), 完全不需要手动 mdev -s。

  2. 备选方案:如果需要 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 显示:

1
EXT4-fs (vda2): bad geometry: block count 163840 exceeds size of device (162816 blocks)

原因:用 losetup -o ROOT_OFFSET /dev/loopN disk.img 关联 loop 设备时, loop 设备从偏移量位置覆盖到文件末尾,而非到 GPT 分区的结束扇区。 因此 mkfs.ext4 会创建一个比实际分区大的文件系统。

示例:root 分区结束于 sector 522239(即 267386368 字节处), 但 loop 设备覆盖从 196608 扇区到文件末尾(268435456 字节处), 多出约 1MB,导致 ext4 的 block count 超过分区实际容量。

修复:

  1. 首选:使用 losetup -P 扫描分区表(推荐)。
  2. 备用:先创建精确大小的镜像文件(truncate -s + mkfs.ext4), 再用 dd 写入到分区的精确扇区偏移位置。
1
2
3
4
5
6
# 精确计算 root 分区大小并 dd 写入
ROOT_SECTORS=325632  # 从 fdisk/parted 获取:End_扇区 - Start_扇区 + 1
truncate -s $((ROOT_SECTORS * 512)) /tmp/ext4_root.img
mkfs.ext4 -q /tmp/ext4_root.img
# ... 写入 rootfs 到镜像 ...
dd if=/tmp/ext4_root.img of=disk.img bs=512 seek=196608 conv=notrunc

💡 这个坑在 BusyBox/Alpine 的 losetup(不支持 --sizelimit)中 尤为常见。losetup -P 是正确做法,但如果不可用,dd 是可靠的替代方案。

2.11 本章小结

你在本章完成了:

  1. ✅ 理解了 UEFI 启动流程(固件 → ESP → BOOTX64.EFI)
  2. ✅ 学会了 GPT 分区表与磁盘布局
  3. ✅ 采用了 ESP + ext4 根分区的双分区方案(ESP 只放 EFI,根分区放全部系统文件)
  4. ✅ 用 loop 设备(losetup -P 或 dd 偏移量方式)完成了 ESP 和 ext4 根分区的格式化和文件写入
  5. ✅ 扩大了内核配置,新增块设备、文件系统、UEFI 支持
  6. ✅ 用 GRUB + 嵌入配置实现了完全自包含的启动
  7. ✅ 编写了 initramfs init + switch_root 的双 init 启动方案,修复了 mdev GPF 和 ext4 分区大小问题
  8. ✅ 在 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 章后,你的工作目录应该包含:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
/workspace/
├── disk.img             256 MB   完整 UEFI 启动磁盘镜像
│   ├── 分区1 (ESP/FAT32): EFI/BOOT/BOOTX64.EFI
│   └── 分区2 (ext4):     完整的根文件系统 + boot/ 目录
├── bzImage              3.1 MB   内核镜像
├── BOOTX64.EFI          604 KB   GRUB EFI 启动器(嵌入配置)
├── rootfs.cpio.gz       656 KB   独立 initramfs(供 GRUB 加载)
├── embedded.cfg                    GRUB 嵌入配置(无注释版)
├── linux-7.1/                     内核源码目录
│   └── arch/x86/boot/bzImage      编译产物
├── toybox/                        Toybox 源码目录
│   └── toybox                     编译产物
└── rootfs/                        initramfs 源目录(也拷贝到分区2作为根文件系统)
    ├── bin/                        命令(toybox + 符号链接)
    ├── sbin/
    │   └── init                    分区 2 的 /sbin/init(switch_root 后执行)
    ├── dev/                        设备节点
    ├── etc/                        配置文件
    ├── tmp/
    └── init                        initramfs 的 /init(引导阶段,执行 switch_root)

编写于 2026-06-24 · 环境:Alpine Linux 3.22.4 · Docker 容器 · Linux 7.1 + Toybox 0.8.13 后续章节待补充:网络配置、显卡与图形输出、安全加固等