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

第 1 章:内存中的 Linux —— 内核编译与直接启动
学习目标:编译一个最小化的 Linux 内核,配合手工构建的根文件系统, 在 QEMU 虚拟机中从内存直接启动。
完成后你会拥有:一个约 3MB 的系统,包含内核 + Shell + 常用工具集, 启动后进入交互式终端。
1.1 我们要做什么?
本章的目标很简单:让一个自己编译的 Linux 内核在 QEMU 里跑起来,并且能进入 Shell。
整个系统包含三个部分:
| 组件 | 体积 | 作用 |
|---|---|---|
| Linux 内核 | ~1.0 MB | 操作系统「大脑」,管理 CPU、内存 |
| Toybox 工具集 | ~1.0 MB | 提供上百个命令行工具(sh、vi、ls、mount 等) |
| init 启动脚本 | 几 KB | 系统启动后第一个执行的程序 |
加起来不到 3 MB。为什么这么小?普通 Ubuntu 安装要几个 GB,因为它包含了几千个软件包、 图形界面、字体、语言包等。我们只保留最核心的部分——一个内核 + 一套工具集——就像搭乐高 只拿最基础的几块积木。
🎯 本章不涉及:磁盘、分区、UEFI、GRUB、网络等。这些都是第 2 章及以后的内容。
1.2 先理解几个核心概念
1.2.1 内核是什么?
Linux 内核是操作系统的核心。它负责:
- 管理 CPU:决定哪个程序在什么时间运行
- 管理内存:分配和回收内存,保护进程之间互不干扰
- 管理设备:通过驱动程序与硬件通信
- 提供系统调用:让用户程序能请求内核服务(读文件、创建进程等)
但内核本身不提供任何用户能直接使用的程序。没有 Shell、没有 ls 命令、没有编辑器。
这些东西属于「用户空间」,需要另外构建。
|
|
1.2.2 initramfs:内核的「第一个文件系统」
内核启动后,内存里什么都没有——没有目录、没有文件、没有硬盘。此时内核需要一个
「初始根文件系统」来找到并运行第一个用户程序(通常是 /init)。
这就是 initramfs(Initial RAM Filesystem,初始内存文件系统)。
|
|
💡 类比:initramfs 就像搬家时先放进新房子的那个工具箱。你还没拆开家具箱子 (挂载硬盘),但工具箱(initramfs)里有螺丝刀、锤子(sh、mount 等命令), 让你先把基础设施搭起来。
1.2.3 静态链接 vs 动态链接,以及 musl vs glibc
普通 Linux 程序依赖很多 .so 动态库(比如 C 标准库 libc.so)。运行时,
操作系统在启动程序时会先加载这些库。如果 initramfs 里没有这些 .so 文件,
动态链接的程序根本无法运行。
静态链接把所有依赖「打包」进程序本身,启动时不需要任何外部文件。
|
|
C 标准库的选择:glibc vs musl
所有 C 程序都需要一个「C 标准库」来提供 printf、malloc、fopen 等基础函数。
Linux 世界有两个主流选择:
glibc(GNU C Library):
- 诞生于 1980 年代末,是 GNU 项目的核心组件,也是绝大多数 Linux 发行版(Ubuntu、Debian、Fedora、Arch)的默认 C 库
- 功能极其全面,支持几乎所有 POSIX 标准和大量 GNU 扩展
- 历史悠久,兼容性极好——几乎所有 Linux 软件都优先在 glibc 上测试
- 体积庞大:完整的 glibc 约 2-3MB,动态链接的
libc.so.6也有约 2MB - 不擅长静态链接:glibc 的部分功能(如 DNS 解析
nsswitch)在设计上依赖运行时动态加载.so插件,这使得完全静态链接非常困难甚至不可能 - 维护者众多,由 GNU 项目社区维护
musl libc:
- 诞生于 2011 年,由 Rich Felker 创建,目标是做一个「干净、正确、高效」的标准 C 库
- 代码量远小于 glibc:musl 源码约 10 万行,glibc 约 250 万行
- 体积小:静态链接的 musl 约 500-800KB,比 glibc 小 3-4 倍
- 静态链接友好:从头设计时就考虑了静态链接的需求,没有任何功能强制依赖动态加载
- 标准合规严格,对 POSIX 的遵循程度实际上超过了 glibc
- 性能适中:简单场景比 glibc 略快或持平,复杂场景(如多线程内存分配)可能略慢
- 是 Alpine Linux、Void Linux(musl 版)、OpenWrt 等发行版的默认 C 库
为什么我们选择 musl?
| 考量维度 | glibc | musl | 我们的选择 |
|---|---|---|---|
| 静态链接 | 困难(nsswitch 等设计问题) | 原生支持 | ✅ musl |
| 体积 | 大(~2MB 动态库) | 小(~500KB 静态) | ✅ musl |
| 兼容性 | 几乎所有软件都支持 | 部分软件需补丁 | 对于最小系统足够 |
| 发行版默认 | Ubuntu/Debian/Fedora | Alpine | Alpine 更轻量 |
对于我们的最小系统,musl 有很大的优势:「小体积」- initramfs 更小, 「静态链接友好」- 不需要往 initramfs 里塞动态库。
编译完成后可以用如下方法验证静态链接是否成功:
|
|
1.3 准备构建环境
在开始编译之前,我们需要一个一致的构建环境。本书使用 Docker + Alpine Linux。
1.3.1 为什么用 Docker?
编译内核和 Toybox 需要特定的工具链(gcc、make、各种开发库)。不同 Linux 发行版的 包名、版本、默认配置各不相同——在 Ubuntu 上能编译的内核,在 Alpine 上可能因为 musl 和 glibc 的差异而失败。
Docker 解决了两件事:
- 环境一致性:所有读者在完全相同的 Alpine Linux 环境中编译,避免「在我机器上能跑」的问题。
- 不污染宿主机:所有编译依赖都装在容器里,不需要在宿主机上安装任何东西,也不会因为误操作破坏了宿主机的环境。
1.3.2 安装 Docker
Ubuntu/Debian:
|
|
Fedora/RHEL:
|
|
macOS:建议官方下载并安装 Docker Desktop。
Windows:建议下载并安装 Docker Desktop,建议启用 WSL 2 后端。
1.3.3 创建 Dockerfile 和工作目录
在宿主机上创建工作目录:
|
|
创建 Dockerfile:
|
|
构建镜像:
|
|
1.3.4 启动容器
|
|
参数说明:
| 参数 | 作用 |
|---|---|
-it |
交互模式 + 分配 TTY |
--rm |
容器退出后自动删除(不留垃圾) |
-v $HOME/linux_build:/workspace |
将宿主机目录挂载到容器,双向同步文件 |
linux-build:alpine |
使用的镜像名 |
/bin/sh |
容器启动后执行的命令 |
💡 挂载目录的好处:在容器内编译的文件直接保存在宿主机
~/linux_build下, 容器退出后文件不会丢失。你也可以在宿主机上用喜欢的编辑器修改文件。
进入容器后,确认工具链正常:
|
|
1.4 编译 Linux 内核:tinyconfig 白名单策略
1.4.1 获取源码
|
|
1.4.2 理解 tinyconfig
Linux 内核有几千个配置选项。普通发行版内核约 50-100MB,因为它包含了成千上万个 硬件驱动、文件系统支持、安全模块等。
tinyconfig 是内核提供的一个特殊配置目标:全部关闭,只保留能让内核编译通过的最少选项。
然后我们像点菜一样,只打开真正需要的项。
这就是 「白名单」策略 ——从零开始添加,而非从完整配置删减。最终体积可以做到 2MB 以下。
|
|
1.4.3 本章需要的内核功能
本章只需要「内存中运行」,不需要磁盘、不需要 UEFI,因此需要的配置非常精简:
控制台输出(没有这个,内核启动时是哑巴):
CONFIG_PRINTK— 内核日志输出CONFIG_TTY— 终端支持CONFIG_SERIAL_8250+CONFIG_SERIAL_8250_CONSOLE— 串口控制台(QEMU 的输出通道)
架构(x86_64):
CONFIG_64BIT— 启用 64 位模式(x86_64 编译必须开启)
程序执行:
CONFIG_BINFMT_ELF— 运行 ELF 格式程序(Linux 标准程序格式)CONFIG_BINFMT_SCRIPT— 支持 shebang 脚本(如#!/bin/sh,init 脚本需要)
虚拟文件系统:
CONFIG_PROC_FS—/proc(进程信息,ps、top 需要)CONFIG_SYSFS—/sys(设备和驱动信息)CONFIG_DEVTMPFS—/dev自动管理设备节点
initramfs 支持:
CONFIG_BLK_DEV_INITRD— 启用 initramfs 支持CONFIG_INITRAMFS_SOURCE— 指定根文件系统目录路径(嵌入到内核中)
体积优化(可选):
CONFIG_KERNEL_XZ— 用 XZ 压缩内核镜像CONFIG_SLUB_TINY— 使用精简的内存分配器
1.4.4 配置并编译
|
|
编译产物:arch/x86/boot/bzImage,约 1.0 MB。
💡 bzImage 是什么格式? bzImage 是一个特殊的打包格式,包含三部分:
- setup 头部:引导信息(内核大小、入口地址、命令行等)
- PE/COFF 头部:让 UEFI 固件也能识别这是一个可执行文件
- 压缩的 vmlinux:内核本体,用 XZ 压缩
QEMU 的
-kernel参数可以直接加载 bzImage。
1.4.5 关于 CONFIG_INITRAMFS_SOURCE
这个选项可以在编译时把根文件系统目录嵌入内核。但现在我们先把内核编译出来,
根文件系统的嵌入等到 1.7 节一起处理。因此本节先不设置 CONFIG_INITRAMFS_SOURCE。
⚠️ 注意:如果用
CONFIG_INITRAMFS_SOURCE,内核构建系统会用宿主机的find命令 扫描 rootfs 目录生成 cpio 包。Alpine 默认的 BusyBoxfind不支持-printf选项, 会导致生成的 cpio 是空的(512 字节),内核启动时找不到/init而 panic。解决方法:安装 GNU findutils(已在 Dockerfile 中预装):
1apk add findutils
1.5 编译 Toybox:一个文件,上百个命令
1.5.1 Toybox 是什么?
Toybox 是 BusyBox 的精神续作,由原 BusyBox 维护者 Rob Landley 开发。 它的设计理念是「一个二进制文件,提供所有常用 Unix 命令」。
Toybox 利用 Linux 的符号链接机制:为每个命令创建一个指向 toybox 的符号链接。
当执行 ls 时,实际上运行的是 toybox ls。Toybox 根据自己被调用的名字
(argv[0])决定执行哪个功能。
|
|
1.5.2 获取和编译
|
|
编译产物是一个约 800 KB 的静态二进制文件 toybox。
1.5.3 验证静态链接
|
|
如果输出列出了一些 .so 文件路径,说明链接不是静态的,需要检查 CONFIG_STATIC=y
是否生效,以及是否安装了 musl 的静态库。
1.5.4 关于 Toybox 的现状
⚠️ Toybox 仍然处于活跃开发期,项目以 GPLv2 协议开源, 维护者 Rob Landley 和社区非常活跃(GitHub 上几乎每天都有提交)。
这意味着一方面 Toybox 在不断进步,另一方面部分功能还不够完善:
- Shell(
sh):位于pending目录,说明还不是「正式」功能。 在我们的测试中,基本的脚本执行、管道、重定向都正常工作, 但在复杂场景(如嵌套$()、here-document 的高级用法)下可能遇到边界问题。 对于我们的 init 脚本来说足够用,但如果后续想用它做复杂的交互式 Shell, 建议关注 Toybox 的更新。- 设备管理器(
mdev):同样位于pending目录,需要显式开启CONFIG_MDEV。 它是 init 脚本中mdev -s的执行者,负责扫描/sys并创建设备节点, 对于我们的最小系统是必须的。- 部分命令的功能是精简版:Toybox 的
vi、awk、sed等工具 实现了核心子集而非全部功能。例如vi不支持语法高亮、多窗口分割等高级特性。- 编译兼容性:不同版本的 GCC 和 musl 组合可能导致个别模块编译失败。 例如作者在 GCC 14 + musl 下遇到过
CONFIG_MAN(复合字面量 UB)和CONFIG_IP(musl 头文件类型不完整)的编译问题,遇到时临时禁用对应的 config 选项即可。对于本书的目标——构建最小 Linux 系统——Toybox 的现状已经足够。 它比 BusyBox 代码更现代、更清晰,值得投入时间了解。
1.6 构建根文件系统与 init 脚本
1.6.1 目录结构
|
|
|
|
1.6.2 安装 Toybox
|
|
1.6.3 使用mknod创建设备节点(关键的一步)
内核在运行 /init 之前,就需要一个地方输出错误信息——这就是 /dev/console。
如果 initramfs 里没有这个设备节点,内核会报:
|
|
然后杀掉 init,系统崩溃。
mknod 的使用方法
mknod(make node)是用来创建设备文件的命令。它的基本语法是:
|
|
- 名称:设备文件的路径(如
/dev/console) - 类型:
c表示字符设备(character device),b表示块设备(block device) - 主设备号(major):告诉内核「用哪个驱动程序来处理这个设备」
- 次设备号(minor):告诉驱动程序「处理的是哪个具体设备实例」
-m权限:和chmod一样的八进制权限位
💡 可以把主设备号理解成「电话号码的区号」——决定打给哪个城市(哪个驱动), 次设备号是「具体号码」——决定接通哪个人(哪个具体设备)。
设备文件与普通文件的相同点
在了解区别之前,先看看它们为什么「看起来一样」。这正是 Unix/Linux 设计哲学的精髓—— 一切皆文件(Everything is a file)。
| 相同点 | 说明 |
|---|---|
| 统一命名空间 | 设备文件和普通文件都在同一个目录树中,使用相同的路径表示(如 /home/user/note.txt 和 /dev/ttyS0) |
| 相同的系统调用接口 | 程序用 open()、read()、write()、close()、ioctl() 等同一套系统调用来操作两者 |
| 拥有权限位 | 都用 rwx 权限模型(如 chmod 666 /dev/null),用 chown 更改所有者 |
| 拥有所有者和属组 | ls -l 能看到 user 和 group,和普通文件完全一致 |
| 可以用标准工具操作 | ls、cp、rm、mv、stat 等命令对设备文件同样有效 |
| 有 inode | 在文件系统中,设备文件也有对应的 inode 结构,只是 inode 中存储的是设备号而非数据块指针 |
这种设计带来一个巨大的好处:程序不需要知道自己在跟文件还是设备打交道。
举个例子,一个日志程序只需要 write(fd, log_msg, len)——无论 fd 指向的是
/var/log/app.log(普通文件)还是 /dev/ttyS0(串口设备),代码完全不用改。
内核在系统调用层自动判断 inode 类型,决定是把数据写到磁盘,还是交给设备驱动。
💡 这就是 *nix 比 Windows 优雅的地方:在 Windows 中,写文件用
WriteFile, 但操作控制台要用WriteConsole, API 相对来说各管各的,没法统一抽象。
这就是Linux 的设计高明之处: 「接口统一,实现分离」——对上层暴露相同的面孔,对内部分发到不同的处理逻辑。
设备文件与普通文件的区别
虽然接口统一,但它们在内核内部的实现路径完全不同:
| 对比维度 | 普通文件 | 设备文件 |
|---|---|---|
| 存储内容 | 实际数据(文本、二进制等) | 不存储数据,只是一个「入口」 |
| 读写行为 | 读写磁盘上的数据 | 读写操作被转发给内核驱动 |
| 大小 | 真实占用磁盘空间 | 通常显示为 0(ls -l 看到的是设备号) |
| 创建方式 | touch、vi、echo > |
mknod(手动)或 udev/mdev(自动) |
| 删除影响 | 数据丢失 | 只删除入口,驱动和设备本身不受影响 |
举个例子:当你执行 echo "hello" > /dev/ttyS0 时:
- Shell 打开
/dev/ttyS0这个文件 - 内核发现它是一个字符设备(
c 4 64),不读写磁盘 - 内核把数据交给主设备号 4 对应的串口驱动
- 串口驱动把 “hello” 通过次设备号 64 对应的串口 0 发送出去
整个过程没有涉及任何磁盘 I/O。设备文件就是一个「代理」——把文件操作翻译成硬件操作。
现在创建我们需要的设备节点:
|
|
💡 主设备号和次设备号:Linux 用一对数字标识每个设备。
c 5 1表示字符设备(c),主设备号 5(tty 类驱动),次设备号 1(console 这个具体设备)。
这些设备节点的作用:
| 设备 | 作用 | 如果没有会怎样 |
|---|---|---|
/dev/console |
内核启动时的默认输出设备 | 内核无法输出错误信息 → panic |
/dev/ttyS0 |
第一个串口(QEMU -nographic 通过它通信) |
无法看到启动输出和交互 |
/dev/null |
「黑洞」设备,写入的数据被丢弃 | mdev -s 可能报错 |
/dev/tty |
当前进程的控制终端 | 某些程序可能报 “not a tty” |
1.6.4 创建 etc 配置文件:passwd 和 group
即使是最小系统,也需要 /etc/passwd 和 /etc/group。Linux 内核本身并不强制要求
这两个文件,但很多用户空间工具(包括 Toybox 的 id、chown、login 等)会读取它们。
/etc/passwd — 用户账户数据库
格式:每行一个用户,冒号分隔 7 个字段:
|
|
| 字段 | 含义 | 说明 |
|---|---|---|
| 用户名 | 登录名 | root、nobody 等 |
| 密码 | x 表示密码存在 /etc/shadow |
我们的系统不需要密码,填 x 即可 |
| UID | 用户 ID(数字) | 0 是 root,普通用户从 1000 起 |
| GID | 主组 ID(数字) | 对应用户的默认组 |
| 注释 | GECOS 字段 | 历史遗留,通常写全名 |
| 主目录 | $HOME |
root 通常是 /root |
| 登录 Shell | 登录后启动的程序 | 通常是 /bin/sh 或 /bin/bash |
为什么 root 的 UID 是 0?
在 Linux 内核中,权限检查的核心逻辑极简:如果当前进程的 UID 是 0,跳过所有权限检查。
UID 0 就是「超级用户」的硬编码标识——内核代码里直接写 if (uid == 0) return OK;。
这也是为什么黑客想要「提权到 root」——拿到 UID 0 就拿到了整个系统的钥匙。
|
|
这一行的含义:
- 用户
root,密码在 shadow 文件中 - UID=0(超级用户),GID=0(root 组)
- 全名 “root”,主目录
/root - 登录后启动
/bin/sh
/etc/group — 用户组数据库
格式:每行一个组,冒号分隔 4 个字段:
|
|
|
|
- 组
root,组密码在 shadow 文件中,GID=0,无额外成员
有了这两个文件,id 命令就能显示用户信息,chown 就能把文件归属到正确的用户/组。
1.6.5 编写 init 脚本(逐行详解)
/init 是整个系统的「开机第一把火」。内核启动后,把它当作第一个进程(PID 1)执行。
PID 1 在 Linux 中有特殊地位——如果它退出了,内核会直接 panic,认为系统无法继续运行。
|
|
逐行解析
#!/bin/sh
这是 shebang(释伴)。内核执行一个文件时,如果发现文件开头是 #!,
就会把后面的路径当作解释器,用解释器来执行这个文件。等价于执行 /bin/sh /init。
mount -t proc proc /proc
proc 是「进程文件系统」。它不存数据在磁盘上——当你 cat /proc/cpuinfo 时,
内核动态生成 CPU 信息。ps、top 等命令都依赖 /proc 来读取进程列表。
mount -t sysfs sys /sys
sysfs 暴露设备和驱动的层次结构。例如 /sys/block/ 下列出了所有块设备(磁盘),
/sys/class/net/ 下列出了所有网络接口。mdev 依赖 sysfs 来发现设备。
mount -t devtmpfs dev /dev
devtmpfs 是内核管理的设备文件系统。它自动在 /dev 下创建内核已知的设备节点
(如 /dev/tty、/dev/pts/ 等),作为我们手动创建的那几个设备节点的补充。
mdev -s
mdev 是 BusyBox/Toybox 提供的轻量级设备管理器(udev 的精简替代品)。
mdev -s:启动时扫描/sys目录,为内核已发现的所有设备创建/dev下的设备节点- 之后
mdev还可以作为热插拔 handler(不在本章讨论范围)
这就是为什么我们在 devtmpfs 之上还需要 mdev:devtmpfs 只创建内核「内置」知道的设备节点, 而 mdev 会为所有通过 sysfs 暴露的设备(包括 virtio 磁盘等)自动创建节点。
💡 在完整 Linux 发行版中,这个角色由 udev(systemd 的一部分)或 eudev 担任。 mdev 是它们在嵌入式/最小系统场景的替代品。
sleep 0.5
设备扫描和节点创建是异步的。sleep 0.5 给内核 0.5 秒的时间完成设备初始化。
没有这个等待,后续操作可能发现设备节点还不存在。
hostname minimal
设置系统主机名为 “minimal”。这会影响 Shell 提示符的显示。
chmod 1777 /tmp
/tmp 是所有用户共享的临时目录。1777 是「粘滞位 + 所有人可读写执行」的简写:
1(粘滞位):只有文件所有者和 root 能删除文件,防止用户互相删对方的临时文件777:所有人可读、可写、可进入
exec /bin/sh
这是最关键的一行。exec 是一个 Shell 内建命令,它用新程序替换当前进程,
而不是创建子进程。执行完这行后:
- PID 1 不再是
/bin/sh /init,变成了/bin/sh(交互式 Shell) - 原 init 脚本的 Shell 进程被「覆盖」,不占用额外内存
- Shell 退出时,PID 1 退出 → 内核 panic(符合预期,因为没有其他 init 系统)
如果不用 exec 而直接用 /bin/sh,会发生什么?
|
|
这样 PID 1 会一直处于「等待子进程」的状态,白白占用内存和 PID,而且如果子 Shell 退出,PID 1 不知道该做什么——init 脚本已经执行完了。
1.7 打包 initramfs 并启动
在把 rootfs 打包好之后,我们需要一个「虚拟计算机」来运行内核。 这就是 QEMU 的角色。
1.7.0 QEMU 简介
QEMU(Quick Emulator)是一个开源的虚拟机软件,可以在一台计算机上模拟出另一台 「虚拟计算机」的完整硬件环境——包括 CPU、内存、磁盘、网卡、串口等。
💡 类比:QEMU 就像一个「硬件模拟器」——你在宿主机上运行 QEMU, QEMU 在内存里构造一台假的计算机,然后把内核和 initramfs 装进去让它跑。 对于运行在 QEMU 里面的内核来说,它完全不知道自己活在虚拟机里。
为什么用 QEMU?
对于我们「从零构建 Linux」的场景,QEMU 有两个不可替代的优势:
- 不需要真实硬件:你不需要找一台额外的电脑、不需要烧录 U 盘、 不需要反复重启——在 QEMU 里启动一次内核只需几秒钟。
- 调试友好:QEMU 的
-nographic参数把虚拟机的串口输出 直接打印到你的终端窗口,所有内核日志一目了然。物理机上想看内核启动日志, 要么需要串口线,要么需要等显卡驱动加载后才能看到画面。
我们在本书中怎么用 QEMU?
本书中 QEMU 的使用集中在两个场景:
- 第 1 章:用
-kernel参数直接加载内核到内存,跳过固件(BIOS/UEFI), 从内核入口直接启动。这是最快的方式,适合开发和调试。 - 第 2 章及以后:用
-drive参数加载磁盘镜像,走完整的 UEFI → GRUB → 内核 → initramfs 启动链,模拟真实硬件的启动过程。
下面是第 1 章用到的核心参数概览(启动命令中会逐一说明):
| 参数 | 作用 |
|---|---|
-kernel bzImage |
直接加载内核文件,跳过 BIOS/UEFI |
-initrd rootfs.cpio.gz |
把 initramfs 加载到内存,作为初始根文件系统 |
-append "console=ttyS0 ..." |
向内核传递启动参数(通过串口输出日志) |
-nographic |
不使用图形窗口,把串口输出重定向到当前终端 |
-m 256M |
为虚拟机分配 256MB 内存 |
🎯 关于
-nographic:QEMU 默认会弹出一个图形窗口模拟显示器。 但我们的内核没有显卡驱动,图形窗口里什么也看不到。加上-nographic后, QEMU 会把虚拟机的串口(相当于物理机的 COM 口)接到你的终端上—— 内核通过串口输出的所有文字都会直接显示在你的终端里,你也可以在终端里输入 命令传给虚拟机里的 Shell。这样我们不需要任何图形界面就能完整操作虚拟机。
1.7.1 方式一:独立 initramfs 文件
把 rootfs 目录打包成 .cpio.gz 文件,启动时通过 QEMU 的 -initrd 参数传入:
|
|
cpio -o -H newc:把目录树打包成 newc 格式的 cpio archivegzip:压缩(减小体积,QEMU 和内核都能直接解压)newc:新版 cpio 格式(Linux 内核能识别的标准格式)
最终 rootfs.cpio.gz 约 656 KB。
QEMU 启动命令:
|
|
参数说明:
-kernel bzImage:直接加载内核(绕过 BIOS/UEFI)-initrd rootfs.cpio.gz:把 initramfs 加载到内存-append "console=ttyS0 quiet":内核启动参数——用串口做控制台、减少输出-nographic:不启动图形窗口,用终端做 I/O-m 256M:分配 256MB 内存
1.7.2 方式二:嵌入 initramfs 到内核(更好更推荐的方式)
把 rootfs 直接嵌入内核镜像,这样内核文件就自包含了一切,不需要额外的 initramfs 文件:
|
|
编译产物 arch/x86/boot/bzImage 现在包含了内核本体 + 嵌入的 rootfs。
|
|
⚠️ 再次提醒:确保安装了
findutils(GNU find), 否则内核构建系统生成的 cpio 是空的。
1.7.3 预期启动输出
|
|
🎉 恭喜!你已经从零构建了一个可以启动的 Linux 系统。
1.8 Linux 启动过程简述:从代码角度看
到目前为止,我们都是用 QEMU 的 -kernel 参数「一键启动」。在进入第 2 章(UEFI 启动)
之前,有必要从源代码的角度理解「内核到底是怎么跑起来的」。下面以 x86_64 架构为例。
1.8.1 整体阶段
|
|
第 1 章中 QEMU -kernel 参数做的事情:把 bzImage 加载到内存,设置好寄存器,
然后跳转到内核入口地址。这替代了真实硬件的引导程序。
1.8.2 第一阶段:进入内核(汇编)
内核的入口点在 arch/x86/boot/header.S(汇编文件)。它做了三件关键的事:
- 设置基础环境:初始化段寄存器、设置栈指针,让 C 代码有地方运行
- 检测硬件:调用
arch/x86/boot/main.c中的 C 函数,检测内存大小、显示模式等 - 跳转到保护模式/长模式:x86 CPU 启动时处于 16 位实模式(兼容 8086), 内核需要切换到 64 位长模式才能使用全部内存和现代 CPU 特性
1.8.3 第二阶段:解压内核
你编译出来的 bzImage 中的内核本体(vmlinux)是被压缩的。在能运行之前,
需要一个「解压引导程序」把它解开。
这个解压程序位于 arch/x86/boot/compressed/ 目录。它的流程是:
head_64.S(汇编入口):设置 64 位环境misc.c中的extract_kernel():把压缩的内核解压到内存的正确位置- 跳转到解压后的内核入口:
startup_64(arch/x86/kernel/head_64.S)
1.8.4 第三阶段:start_kernel() — 内核初始化
这是 Linux 内核中最重要的函数,位于 init/main.c。它所做的初始化按顺序大致如下:
|
|
💡 注意到
console_init()在比较靠后的位置吗?这意味着start_kernel()前半部分的printk()输出是「不可见」的——它们被缓存在__log_buf里,等控制台初始化完成后 一次性刷新出来。这也是为什么启动日志中前面几行的输出有一定的延迟感。
1.8.5 第四阶段:rest_init() → 启动第一个用户进程
rest_init() 创建两个关键线程,然后自己进入 idle(空闲)状态:
kernel_init(PID 1):这就是 init 进程的前身kthreadd(PID 2):内核线程守护者,负责创建所有[kworker]、[kswapd]等内核线程
kernel_init 的流程:
|
|
内核按顺序尝试这些路径。第一个成功执行的就成为 PID 1。如果全部失败, 内核打印 “No working init found” 并 panic。
当我们的 /init 执行 exec /bin/sh 时:
|
|
此时 Shell 成为 PID 1,等待着你的输入。整个启动过程到此结束。
1.8.6 关键源码位置速查
| 阶段 | 源码位置 | 关键函数 |
|---|---|---|
| 启动入口(汇编) | arch/x86/boot/header.S |
_start |
| 实模式初始化 | arch/x86/boot/main.c |
main() |
| 解压内核 | arch/x86/boot/compressed/misc.c |
extract_kernel() |
| 64 位入口 | arch/x86/kernel/head_64.S |
startup_64 |
| 内核主初始化 | init/main.c |
start_kernel() |
| 启动 init 进程 | init/main.c |
rest_init() → kernel_init() |
| 运行 init 程序 | init/main.c |
run_init_process() |
💡 如果你好奇某个启动日志是怎么产生的,可以
grep内核源码找到那行printk(), 从调用链追溯上下文。这是理解内核最直接的方法。
1.9 踩坑记录
坑 1:Toybox 没有 Shell
现象:系统启动后,init 脚本执行失败,因为 /bin/sh 不存在或报
“toybox: Unknown command toybox”。
原因:Toybox 的 CONFIG_SH 在 pending 目录,默认不启用。
修复:编译 Toybox 前手动开启:
|
|
坑 2:initramfs 是空的
现象:内核启动报 “Kernel panic - not syncing: No working init found”。
原因(两种可能):
- 打包命令错误:使用了错误的 find + cpio 组合,只打包了目录名没有递归打包文件。
正确的打包命令:
find . | cpio -o -H newc | gzip > rootfs.cpio.gz - Alpine BusyBox find 不支持
-printf:当使用CONFIG_INITRAMFS_SOURCE嵌入 rootfs 时,内核构建系统用宿主机的find -printf生成 cpio, 但 BusyBox 版本不支持-printf,导致生成 512 字节的空 cpio。
修复:安装 GNU findutils:
|
|
坑 3:initramfs 里没有 /dev/console
现象:内核报 “Warning: unable to open an initial console”,然后杀掉 init。
原因:内核在运行 init 之前需要输出设备。/dev/console(设备号 5,1)必须存在。
修复:在 rootfs/dev 下手动创建设备节点:
|
|
1.10 本章小结
你在本章完成了:
- ✅ 用 Docker 搭建了一致的 Alpine Linux 构建环境
- ✅ 理解了 musl libc 和静态链接的选择理由
- ✅ 用 tinyconfig 白名单策略编译了一个 2.7MB 的 Linux 内核
- ✅ 静态编译了 Toybox,一个文件提供常用的命令
- ✅ 手工构建了根文件系统,编写了 init 启动脚本
- ✅ 从源码角度理解了 Linux 内核的启动全过程
- ✅ 用 QEMU 成功启动,进入了交互式 Shell
此时你的系统特点:
- 完全在内存中运行,不依赖任何磁盘
- 重启后一切数据消失(没有持久存储)
- 不能访问任何物理硬盘
这些限制正是第 2 章要解决的问题。