Skip to content

让最小 Linux 脱离 QEMU:GRUB 引导与磁盘镜像制作

前几篇我们的最小 Linux 一直靠 QEMU 的 -kernel-initrd 参数"喂"给虚拟机——QEMU 充当保姆,把内核和文件系统直接放进内存。这种方式快速方便,但系统完全依赖 QEMU:换个虚拟机就跑不起来,关机所有数据丢失,连引导过程都被跳过了。

这篇要做的事:让最小 Linux 学会自己从磁盘启动。制作一块包含引导器、内核和根文件系统的磁盘镜像——插到任何支持 BIOS 引导的虚拟机(甚至物理机)里,都能自己启动。

QEMU 保姆模式 vs 独立引导

先对比两种启动方式的本质差异:

QEMU 保姆模式(前几篇):
  qemu-system-x86_64 -kernel bzImage -initrd initramfs.cpio.gz -append "..."
  └── QEMU 看到 -kernel,直接把内核加载到内存地址 0x100000
  └── QEMU 看到 -initrd,直接把 initramfs 加载到内存
  └── QEMU 看到 -append,直接设置内核启动参数
  → 跳过了整个引导过程,没有 BIOS、没有 GRUB、没有磁盘读取

独立引导(本篇):
  qemu-system-x86_64 -drive file=minilinux.img
  └── QEMU 只知道有一块磁盘,不管里面是什么
  └── BIOS 读磁盘第一个扇区 → 找到 MBR → 加载 GRUB
  └── GRUB 读磁盘上的配置文件 → 找到内核 → 加载到内存
  └── 内核启动 → 读磁盘上的根文件系统 → 运行 init
  → 完整的引导链,和真实物理机一样

保姆模式的问题:

  • 只能在 QEMU 里跑(VMware、VirtualBox 不支持 -kernel 参数)
  • 根文件系统在 RAM 里,关机数据全丢
  • 开发者无法学到真实的引导过程

独立引导意味着:系统自给自足,不需要外部帮手。

从 RAM 到磁盘:根文件系统的变化

脱离 QEMU 意味着根文件系统也要从 RAM 搬到磁盘。这不只是存储位置的变化,init 的运行方式也完全不同:

initramfs 方案(RAM)磁盘方案
存储cpio 压缩包解压到 RAMext4 分区在磁盘上
持久性关机全丢关机保留
内核怎么找到它QEMU 的 -initrd 直接喂内核参数 root=/dev/sda1
PID 1 是谁我们自己写的 /init 脚本BusyBox 的 /sbin/init 程序
init 怎么工作顺序执行脚本里的命令读取 /etc/inittab 配置

BusyBox 的 /sbin/init(实际是 busybox 的符号链接)是一个真正的 init 程序,不是我们手写的脚本。它启动后读取 /etc/inittab,按配置管理系统生命周期——开机初始化、维持终端、处理关机。

引导器:GRUB

系统要自己启动,第一步需要一个引导器。BIOS 只会读磁盘第一个扇区(512 字节),里面必须有能找到内核的代码。

GRUB(GRand Unified Bootloader)就是干这个的。它分多个阶段工作:

BIOS 加载 MBR(512 字节)
  → MBR 的代码加载 GRUB core.img(~30 KB,在 MBR 后面)
    → core.img 内嵌文件系统驱动,能读磁盘上的文件
      → 加载 /boot/grub/ 下的模块和 grub.cfg 配置
        → 根据配置找到并加载内核
          → 把控制权交给内核

GRUB 本质上是一个运行在内核之前的微型系统——有自己的文件系统驱动、命令行、脚本语言和模块化插件(.mod 文件)。

从源码编译

bash
cd ~/build
wget https://ftp.gnu.org/gnu/grub/grub-2.14.tar.gz
tar -xzf grub-2.14.tar.gz
cd grub-2.14

sudo apt install -y build-essential bison flex autoconf automake \
    gettext libdevmapper-dev python3 pkg-config

./configure --target=i386 --with-platform=pc --prefix=/usr/local
make -j$(nproc)
sudo make install

--target=i386 --with-platform=pc 的意思:生成面向传统 BIOS + MBR 的 32 位引导代码。即使宿主机是 64 位的,BIOS 引导阶段运行在实模式/保护模式下,需要 32 位代码。

安装完成后 grub-install 命令可用,GRUB 模块库位于 /usr/local/lib/grub/i386-pc/

制作磁盘镜像

创建空白磁盘

bash
dd if=/dev/zero of=~/build/minilinux.img bs=1M count=128

128 MB 的全零文件——我们的虚拟硬盘。比 initramfs 大了 100 倍,但换来了持久化存储和完整的引导链。

分区

bash
sudo apt install -y parted
parted ~/build/minilinux.img mklabel msdos
parted ~/build/minilinux.img mkpart primary ext4 1MiB 100%

mklabel msdos 创建 MBR 分区表。mkpart 从 1 MiB 处开始创建分区——前 1 MiB 留给 MBR 和 GRUB 的 core.img。这 1 MiB 的间隙是 GRUB 安装的前提条件:core.img 要写在 MBR 之后、第一个分区之前。

关联 loop 设备

文件不能直接被 mkfs/mount 操作,需要用 losetup 把它伪装成块设备:

bash
sudo losetup --show -fP ~/build/minilinux.img
# 输出 /dev/loop0

-P 让内核自动扫描分区表,创建 /dev/loop0p1。之后 /dev/loop0 = 整块磁盘,/dev/loop0p1 = 第一个分区。

格式化并挂载

bash
sudo mkfs.ext4 /dev/loop0p1
mkdir -p ~/build/mnt
sudo mount /dev/loop0p1 ~/build/mnt

安装系统

BusyBox + 目录结构

bash
sudo cp -a ~/build/busybox-1.37.0/_install/* ~/build/mnt/
sudo mkdir -p ~/build/mnt/{proc,sys,dev,etc/init.d,boot}

cp -a 保留符号链接和权限。比 initramfs 多了 etc/init.d(init 配置)和 boot(内核 + GRUB)。

inittab

bash
cat <<'EOF' | sudo tee ~/build/mnt/etc/inittab > /dev/null
::sysinit:/etc/init.d/rcS
tty0::respawn:-/bin/sh
ttyS0::respawn:-/bin/sh
::ctrlaltdel:/sbin/reboot
::shutdown:/bin/umount -a -r
EOF

BusyBox inittab 格式:设备::动作:命令

关键设计:tty0(VGA)和 ttyS0(串口)上各开一个 shell。这让系统自动适配运行环境——图形界面看 tty0,串口终端看 ttyS0,不存在的设备自动忽略。不需要知道自己运行在 QEMU 还是 VMware 里。

-/bin/sh 前面的 - 表示 login shell,会读取 /etc/profilerespawn 表示 shell 退出后自动重启。

rcS 启动脚本

bash
cat <<'RCSEOF' | sudo tee ~/build/mnt/etc/init.d/rcS > /dev/null
#!/bin/sh
mount -t devtmpfs none /dev 2>/dev/null
mount -t proc none /proc
mount -t sysfs none /sys
dmesg -n 1
printf "\033c" > /dev/tty0 2>/dev/null
printf "\033c" > /dev/ttyS0 2>/dev/null

for tty in /dev/tty0 /dev/ttyS0; do
    [ -e "$tty" ] && {
        echo "" > "$tty"
        echo "=====================================" > "$tty"
        echo "  Mini Linux (BusyBox + Custom Kernel)" > "$tty"
        echo "  Kernel: $(uname -r)" > "$tty"
        echo "  Boot took $(cut -d' ' -f1 /proc/uptime) seconds" > "$tty"
        echo "=====================================" > "$tty"
        echo "" > "$tty"
    }
done
RCSEOF
sudo chmod +x ~/build/mnt/etc/init.d/rcS

和 initramfs 的 /init 相比,多了几个关键处理:

  • dmesg -n 1:把内核 console loglevel 设为 1(只输出紧急消息)。内核有些驱动是延迟初始化的,会在 init 开始运行后继续往屏幕打日志。不先堵住这个口,后面的清屏会被新日志覆盖
  • printf "\033c" > /dev/tty0> /dev/ttyS0:分别对 VGA 和串口终端执行 VT100 重置。这里不能只写 printf "\033c"——rcS 的标准输出走的是 /dev/console(即最后一个 console= 参数指定的设备),只会清一个终端。必须显式重定向到每个终端设备
  • banner 循环输出到两个终端:同理,欢迎信息也要分别写入 /dev/tty0/dev/ttyS0,这样不管从哪个终端看都能看到

执行顺序很重要:先 dmesg -n 1 堵住日志源,再清屏。反过来的话清屏和禁日志之间可能有新日志溜进来。

内核

bash
sudo cp ~/build/linux-6.18.19/arch/x86/boot/bzImage ~/build/mnt/boot/vmlinuz

安装 GRUB

bash
sudo grub-install \
    --boot-directory=~/build/mnt/boot \
    --target=i386-pc \
    /dev/loop0

这一步做了三件事:

  1. 把引导代码写入 MBR 的前 446 字节
  2. 把 core.img 写入 MBR 和第一个分区之间的间隙
  3. 把 GRUB 模块复制到 /boot/grub/i386-pc/

GRUB 配置

bash
cat <<'EOF' | sudo tee ~/build/mnt/boot/grub/grub.cfg > /dev/null
set timeout=3
set default=0

menuentry "Mini Linux (BusyBox + Custom Kernel 6.18.19)" {
    linux /boot/vmlinuz root=/dev/sda1 console=tty0 console=ttyS0
}
EOF

linux 命令告诉 GRUB 加载内核并传递启动参数:

  • root=/dev/sda1:根文件系统所在的分区
  • console=tty0 console=ttyS0:同时启用 VGA 和串口输出

收尾并测试

bash
sudo umount ~/build/mnt
sudo losetup -D

磁盘镜像准备完毕。启动命令变得极其简洁:

bash
qemu-system-x86_64 \
    -drive file=~/build/minilinux.img,format=raw \
    -m 128 \
    -nographic

没有 -kernel、没有 -initrd、没有 -append。QEMU 只提供硬件模拟,系统自己完成一切。

启动全过程

SeaBIOS → MBR → GRUB 2.14 →

  ┌────────────────────────────────────────────────────┐
  │ *Mini Linux (BusyBox + Custom Kernel 6.18.19)      │
  │                                                     │
  │  The highlighted entry will be executed in 3s.      │
  └────────────────────────────────────────────────────┘

→ 加载 /boot/vmlinuz → 内核启动 → 挂载 /dev/sda1
→ /sbin/init → 读 /etc/inittab → 执行 rcS → 清屏 →

=====================================
  Mini Linux (BusyBox + Custom Kernel)
  Kernel: 6.18.19
  Boot took 2.18 seconds
=====================================

~ #

从 BIOS 到 shell 提示符,和真实物理机的开机过程一模一样。退出 QEMU:Ctrl+A 然后 X

对比:保姆模式 vs 独立引导

保姆模式(前几篇)独立引导(本篇)
启动命令需要指定 -kernel-initrd-append只需要 -drive
引导器无(QEMU 直接加载)GRUB
rootfs 位置RAM磁盘分区
数据持久性关机丢失关机保留
init 方式自写脚本(/initBusyBox init(/etc/inittab
可移植性仅限 QEMU任何 BIOS 引导环境
到 shell 的时间~0.6 秒~2 秒 + 3 秒 GRUB 菜单

独立引导更慢,但那 5 秒多出来的时间里,系统经历了和你每天开机一样的完整引导链。

磁盘文件结构

minilinux.img (128 MB)
├── [MBR + GRUB core.img]      ← 分区之前的磁盘头部
└── 分区 1 (ext4)
    ├── boot/
    │   ├── vmlinuz             ← 内核 (14 MB)
    │   └── grub/
    │       ├── grub.cfg        ← GRUB 配置
    │       └── i386-pc/        ← GRUB 模块 (~5 MB)
    ├── bin/
    │   ├── busybox             ← BusyBox (2.4 MB)
    │   ├── sh → busybox
    │   └── ...
    ├── sbin/
    │   └── init → ../bin/busybox
    ├── etc/
    │   ├── inittab
    │   └── init.d/rcS
    ├── proc/  sys/  dev/       ← 虚拟文件系统挂载点
    └── [剩余空间可自由使用]     ← 这就是持久化的好处

注意最后一行——磁盘上还有大量可用空间。和 initramfs 方案不同,你在这个系统里创建的文件、修改的配置,关机后都还在。

下一步

磁盘镜像是 raw 格式——可以直接给 QEMU 用,但 VMware 和 VirtualBox 需要各自的格式(VMDK、VDI)。格式转换本身很简单(一行 qemu-img convert),真正的坑在驱动兼容性和终端配置。下一篇讲把这个磁盘搬到 VMware 的完整流程和踩过的坑。

最后更新于:

Hosted by GitHub Pages