目录

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


目录

自己动手从零构建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%AC1%E7%AB%A0/1.png

第 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
3
4
5
6
7
8
9
┌──────────────────────────────────┐
│           用户空间                │
│   /bin/sh, /bin/ls, /bin/vi...   │  ← 我们要构建的部分
├──────────────────────────────────┤
│           Linux 内核              │  ← 我们要编译的部分
│   进程管理 | 内存管理 | 驱动         │
├──────────────────────────────────┤
│             硬件                  │
└──────────────────────────────────┘

1.2.2 initramfs:内核的「第一个文件系统」

内核启动后,内存里什么都没有——没有目录、没有文件、没有硬盘。此时内核需要一个 「初始根文件系统」来找到并运行第一个用户程序(通常是 /init)。

这就是 initramfs(Initial RAM Filesystem,初始内存文件系统)。

1
Linux 内核启动 → 解压 initramfs 到内存 → 运行 /init → 初始化系统 → 启动 Shell

💡 类比:initramfs 就像搬家时先放进新房子的那个工具箱。你还没拆开家具箱子 (挂载硬盘),但工具箱(initramfs)里有螺丝刀、锤子(sh、mount 等命令), 让你先把基础设施搭起来。

1.2.3 静态链接 vs 动态链接,以及 musl vs glibc

普通 Linux 程序依赖很多 .so 动态库(比如 C 标准库 libc.so)。运行时, 操作系统在启动程序时会先加载这些库。如果 initramfs 里没有这些 .so 文件, 动态链接的程序根本无法运行。

静态链接把所有依赖「打包」进程序本身,启动时不需要任何外部文件。

1
2
动态链接:  /bin/ls  →  依赖 libc.so、libm.so...(如果缺了就报错)
静态链接:  /bin/ls  ←  所有代码都在里面,独立运行

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
2
ldd toybox
# 输出:not a dynamic executable  ← 成功了!

1.3 准备构建环境

在开始编译之前,我们需要一个一致的构建环境。本书使用 Docker + Alpine Linux。

1.3.1 为什么用 Docker?

编译内核和 Toybox 需要特定的工具链(gcc、make、各种开发库)。不同 Linux 发行版的 包名、版本、默认配置各不相同——在 Ubuntu 上能编译的内核,在 Alpine 上可能因为 musl 和 glibc 的差异而失败。

Docker 解决了两件事:

  1. 环境一致性:所有读者在完全相同的 Alpine Linux 环境中编译,避免「在我机器上能跑」的问题。
  2. 不污染宿主机:所有编译依赖都装在容器里,不需要在宿主机上安装任何东西,也不会因为误操作破坏了宿主机的环境。

1.3.2 安装 Docker

Ubuntu/Debian:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
# 安装 Docker Engine
sudo apt update
sudo apt install docker.io

# 将当前用户加入 docker 组(免 sudo 运行)
sudo usermod -aG docker $USER

# 重新登录或执行 newgrp docker 使权限生效
newgrp docker

# 验证安装
docker run hello-world

Fedora/RHEL:

1
2
3
4
sudo dnf install docker
sudo systemctl enable --now docker
sudo usermod -aG docker $USER
newgrp docker

macOS:建议官方下载并安装 Docker Desktop。

Windows:建议下载并安装 Docker Desktop,建议启用 WSL 2 后端。

1.3.3 创建 Dockerfile 和工作目录

在宿主机上创建工作目录:

1
2
mkdir -p ~/linux_build
cd ~/linux_build

创建 Dockerfile:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
FROM alpine:3.22

# 安装编译内核和 Toybox 所需的所有依赖
RUN apk add --no-cache \
    gcc make flex bison perl musl-dev linux-headers \
    openssl-dev elfutils-dev bc cpio findutils \
    git bash python3 texinfo \
    # 内核配置界面(可选,用 menuconfig 时用到)
    ncurses-dev \
    # 磁盘镜像制作工具(第 2 章用到,这里一并安装)
    dosfstools parted mtools qemu-img \
    grub-efi ovmf \
    # QEMU 虚拟机及其组件
    qemu-system-x86_64 \
    # loop 设备与分区工具(操作磁盘镜像)
    util-linux

# 设置工作目录
WORKDIR /workspace

构建镜像:

1
docker build -t linux-build:alpine .

1.3.4 启动容器

1
2
3
4
5
# 启动容器,将宿主机 ~/linux_build 挂载到容器内
docker run -it --rm \
    -v $HOME/linux_build:/workspace \
    linux-build:alpine \
    /bin/sh

参数说明:

参数 作用
-it 交互模式 + 分配 TTY
--rm 容器退出后自动删除(不留垃圾)
-v $HOME/linux_build:/workspace 将宿主机目录挂载到容器,双向同步文件
linux-build:alpine 使用的镜像名
/bin/sh 容器启动后执行的命令

💡 挂载目录的好处:在容器内编译的文件直接保存在宿主机 ~/linux_build 下, 容器退出后文件不会丢失。你也可以在宿主机上用喜欢的编辑器修改文件。

进入容器后,确认工具链正常:

1
2
3
4
5
6
7
8
$ gcc --version
gcc (Alpine 14.2.0) 14.2.0

$ make --version
GNU Make 4.4.1

$ uname -m
x86_64

1.4 编译 Linux 内核:tinyconfig 白名单策略

1.4.1 获取源码

1
2
3
4
cd /workspace
wget https://cdn.kernel.org/pub/linux/kernel/v7.x/linux-7.1.tar.xz
tar xf linux-7.1.tar.xz
cd linux-7.1

1.4.2 理解 tinyconfig

Linux 内核有几千个配置选项。普通发行版内核约 50-100MB,因为它包含了成千上万个 硬件驱动、文件系统支持、安全模块等。

tinyconfig 是内核提供的一个特殊配置目标:全部关闭,只保留能让内核编译通过的最少选项。 然后我们像点菜一样,只打开真正需要的项。

这就是 「白名单」策略 ——从零开始添加,而非从完整配置删减。最终体积可以做到 2MB 以下。

1
make tinyconfig     # 生成最小配置(关闭一切)

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 配置并编译

 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
# 编译依赖已在 Docker 镜像中安装好

# 从零开始
make tinyconfig

# 逐一开启需要的功能
scripts/config --enable CONFIG_64BIT
scripts/config --enable CONFIG_PRINTK
scripts/config --enable CONFIG_TTY
scripts/config --enable CONFIG_SERIAL_8250
scripts/config --enable CONFIG_SERIAL_8250_CONSOLE
scripts/config --enable CONFIG_BINFMT_ELF
scripts/config --enable CONFIG_BINFMT_SCRIPT
scripts/config --enable CONFIG_PROC_FS
scripts/config --enable CONFIG_SYSFS
scripts/config --enable CONFIG_DEVTMPFS
scripts/config --enable CONFIG_DEVTMPFS_MOUNT
scripts/config --enable CONFIG_BLK_DEV_INITRD
scripts/config --enable CONFIG_KERNEL_XZ
scripts/config --enable CONFIG_SLUB_TINY

# 补齐依赖项
make olddefconfig

# 编译(-j$(nproc) 使用所有 CPU 核心)
make -j$(nproc) bzImage

编译产物:arch/x86/boot/bzImage,约 1.0 MB。

💡 bzImage 是什么格式? bzImage 是一个特殊的打包格式,包含三部分:

  1. setup 头部:引导信息(内核大小、入口地址、命令行等)
  2. PE/COFF 头部:让 UEFI 固件也能识别这是一个可执行文件
  3. 压缩的 vmlinux:内核本体,用 XZ 压缩

QEMU 的 -kernel 参数可以直接加载 bzImage。

1.4.5 关于 CONFIG_INITRAMFS_SOURCE

这个选项可以在编译时把根文件系统目录嵌入内核。但现在我们先把内核编译出来, 根文件系统的嵌入等到 1.7 节一起处理。因此本节先不设置 CONFIG_INITRAMFS_SOURCE。

⚠️ 注意:如果用 CONFIG_INITRAMFS_SOURCE,内核构建系统会用宿主机的 find 命令 扫描 rootfs 目录生成 cpio 包。Alpine 默认的 BusyBox find 不支持 -printf 选项, 会导致生成的 cpio 是空的(512 字节),内核启动时找不到 /init 而 panic。

解决方法:安装 GNU findutils(已在 Dockerfile 中预装):

1
apk 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
2
3
4
5
/bin/ls    → toybox    (执行 toybox ls)
/bin/cp    → toybox    (执行 toybox cp)
/bin/sh    → toybox    (执行 toybox sh)
/bin/vi    → toybox    (执行 toybox vi)
...(上百个符号链接)

1.5.2 获取和编译

 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
cd /workspace
git clone https://github.com/landley/toybox.git
cd toybox

# 生成默认配置
make defconfig

# 开启静态链接
echo "CONFIG_STATIC=y" >> .config

# 开启 Shell(⚠️ 在 pending 目录,默认不启用)
echo "CONFIG_SH=y" >> .config

# 开启文本编辑器
echo "CONFIG_VI=y" >> .config

# 设备管理器(⚠️ 在 pending 目录,默认不启用)
echo "CONFIG_MDEV=y" >> .config

# 其他常用工具(可选,按需添加)
echo "CONFIG_AWK=y" >> .config
echo "CONFIG_SED=y" >> .config
echo "CONFIG_STRACE=y" >> .config
echo "CONFIG_FDISK=y" >> .config
echo "CONFIG_HEXEDIT=y" >> .config

# 补齐依赖项
make olddefconfig

# 静态编译
LDFLAGS="--static" make -j$(nproc)

编译产物是一个约 800 KB 的静态二进制文件 toybox。

1.5.3 验证静态链接

1
2
ldd toybox
# 期望输出:not a dynamic executable

如果输出列出了一些 .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
for d in bin dev etc proc sys tmp root; do mkdir -p /workspace/rootfs/$d; done
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
rootfs/
├── bin/
│   ├── toybox        ← 1000 KB 静态二进制
│   ├── sh -> toybox  ← Shell(/init 用 /bin/sh 执行)
│   ├── ls -> toybox
│   ├── mount -> toybox
│   ├── ...(上百个符号链接)
├── dev/
│   ├── console       ← ★ 必须手动创建!(见下文)
│   ├── tty
│   ├── null
│   └── ttyS0         ← 串口设备
├── etc/
│   ├── passwd        ← 用户信息
│   └── group
├── proc/             ← 虚拟文件系统挂载点
├── sys/              ← 虚拟文件系统挂载点
├── tmp/
└── init              ← ★ 启动脚本,内核启动后第一个执行

1.6.2 安装 Toybox

1
2
3
4
5
6
7
cd /workspace/toybox

# 安装到 rootfs/bin(make install 自动创建符号链接)
make install PREFIX=/workspace/rootfs

# 确保 sh 链接存在
ln -sf toybox /workspace/rootfs/bin/sh

1.6.3 使用mknod创建设备节点(关键的一步)

内核在运行 /init 之前,就需要一个地方输出错误信息——这就是 /dev/console。 如果 initramfs 里没有这个设备节点,内核会报:

1
Warning: unable to open an initial console.

然后杀掉 init,系统崩溃。

mknod 的使用方法

mknod(make node)是用来创建设备文件的命令。它的基本语法是:

1
mknod [-m 权限] 名称 类型 主设备号 次设备号
  • 名称:设备文件的路径(如 /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 时:

  1. Shell 打开 /dev/ttyS0 这个文件
  2. 内核发现它是一个字符设备(c 4 64),不读写磁盘
  3. 内核把数据交给主设备号 4 对应的串口驱动
  4. 串口驱动把 “hello” 通过次设备号 64 对应的串口 0 发送出去

整个过程没有涉及任何磁盘 I/O。设备文件就是一个「代理」——把文件操作翻译成硬件操作。

现在创建我们需要的设备节点:

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

# 手动创建设备节点
mknod -m 622 console c 5 1    # 5,1 是 console 的主次设备号
mknod -m 666 ttyS0 c 4 64     # 4,64 是串口0的主次设备号
mknod -m 666 null c 1 3       # 1,3 是 null 的主次设备号
mknod -m 666 tty c 5 0        # 5,0 是 tty 的主次设备号

💡 主设备号和次设备号: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 个字段:

1
用户名:密码:UID:GID:注释:主目录:登录Shell
字段 含义 说明
用户名 登录名 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 就拿到了整个系统的钥匙。

1
2
3
cat > /workspace/rootfs/etc/passwd << 'EOF'
root:x:0:0:root:/root:/bin/sh
EOF

这一行的含义:

  • 用户 root,密码在 shadow 文件中
  • UID=0(超级用户),GID=0(root 组)
  • 全名 “root”,主目录 /root
  • 登录后启动 /bin/sh

/etc/group — 用户组数据库

格式:每行一个组,冒号分隔 4 个字段:

1
组名:组密码:GID:成员列表(逗号分隔)
1
2
3
cat > /workspace/rootfs/etc/group << 'EOF'
root:x:0:
EOF
  • 组 root,组密码在 shadow 文件中,GID=0,无额外成员

有了这两个文件,id 命令就能显示用户信息,chown 就能把文件归属到正确的用户/组。

1.6.5 编写 init 脚本(逐行详解)

/init 是整个系统的「开机第一把火」。内核启动后,把它当作第一个进程(PID 1)执行。 PID 1 在 Linux 中有特殊地位——如果它退出了,内核会直接 panic,认为系统无法继续运行。

 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
cat > /workspace/rootfs/init << 'INITEOF'
#!/bin/sh

# 挂载内核虚拟文件系统
mount -t proc proc /proc      # 进程信息(ps 需要)
mount -t sysfs sys /sys       # 设备和驱动信息
mount -t devtmpfs dev /dev    # 设备节点自动管理(补充手动创建的)

# 用 mdev 扫描并创建所有设备节点
mdev -s
sleep 0.5                     # 等设备节点就绪

# 基本设置
hostname minimal
chmod 1777 /tmp

# 显示欢迎信息
echo ""
echo "  ╔══════════════════════════════════════════╗"
echo "  ║   Welcome to Minimal Linux!             ║"
echo "  ║   Kernel: Linux 7.1                     ║"
echo "  ║   This system runs entirely in RAM.     ║"
echo "  ╚══════════════════════════════════════════╝"
echo ""
echo "  Type 'toybox' (no args) to see all commands."
echo ""

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

chmod +x /workspace/rootfs/init

逐行解析

#!/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,会发生什么?

1
2
PID 1: /bin/sh /init
  └── PID 2: /bin/sh    ← 交互式 Shell(子进程)

这样 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 有两个不可替代的优势:

  1. 不需要真实硬件:你不需要找一台额外的电脑、不需要烧录 U 盘、 不需要反复重启——在 QEMU 里启动一次内核只需几秒钟。
  2. 调试友好: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 参数传入:

1
2
cd /workspace/rootfs
find . | cpio -o -H newc | gzip > ../rootfs.cpio.gz
  • cpio -o -H newc:把目录树打包成 newc 格式的 cpio archive
  • gzip:压缩(减小体积,QEMU 和内核都能直接解压)
  • newc:新版 cpio 格式(Linux 内核能识别的标准格式)

最终 rootfs.cpio.gz 约 656 KB。

QEMU 启动命令:

1
2
3
4
5
6
qemu-system-x86_64 \
    -kernel /workspace/linux-7.1/arch/x86/boot/bzImage \
    -initrd /workspace/rootfs.cpio.gz \
    -append "console=ttyS0 quiet" \
    -nographic \
    -m 256M

参数说明:

  • -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 文件:

1
2
3
4
5
6
7
8
cd /workspace/linux-7.1

# 设置 initramfs 源目录
scripts/config --set-str CONFIG_INITRAMFS_SOURCE \
    "/workspace/rootfs"

make olddefconfig
make -j$(nproc) bzImage

编译产物 arch/x86/boot/bzImage 现在包含了内核本体 + 嵌入的 rootfs。

1
2
3
4
5
6
# 不需要 -initrd 参数了!
qemu-system-x86_64 \
    -kernel /workspace/linux-7.1/arch/x86/boot/bzImage \
    -append "console=ttyS0 quiet" \
    -nographic \
    -m 256M

⚠️ 再次提醒:确保安装了 findutils(GNU find), 否则内核构建系统生成的 cpio 是空的。

1.7.3 预期启动输出

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
[    0.000000] Linux version 7.1.0 ...
[    0.000000] Command line: console=ttyS0 quiet
...
[    1.234567] Run /init as init process

  ╔══════════════════════════════════════════╗
  ║   Welcome to Minimal Linux!             ║
  ║   Kernel: Linux 7.1                     ║
  ║   This system runs entirely in RAM.     ║
  ╚══════════════════════════════════════════╝

  Type 'toybox' (no args) to see all commands.

$ ls
bin   dev   etc   init   proc   root   sys   tmp
$ toybox
Usage: toybox [command] [arguments]...
Commands:
  sh ls cp mv rm mkdir rmdir cat echo grep sed awk
  mount vi fdisk mdev chmod chown hostname ...

🎉 恭喜!你已经从零构建了一个可以启动的 Linux 系统。

1.8 Linux 启动过程简述:从代码角度看

到目前为止,我们都是用 QEMU 的 -kernel 参数「一键启动」。在进入第 2 章(UEFI 启动) 之前,有必要从源代码的角度理解「内核到底是怎么跑起来的」。下面以 x86_64 架构为例。

1.8.1 整体阶段

1
2
3
4
flowchart LR
    A[引导程序<br/>boot loader] --> B[内核解压<br/>自解压]
    B --> C[内核初始化<br/>start_kernel]
    C --> D[用户空间<br/>/init]

第 1 章中 QEMU -kernel 参数做的事情:把 bzImage 加载到内存,设置好寄存器, 然后跳转到内核入口地址。这替代了真实硬件的引导程序。

1.8.2 第一阶段:进入内核(汇编)

内核的入口点在 arch/x86/boot/header.S(汇编文件)。它做了三件关键的事:

  1. 设置基础环境:初始化段寄存器、设置栈指针,让 C 代码有地方运行
  2. 检测硬件:调用 arch/x86/boot/main.c 中的 C 函数,检测内存大小、显示模式等
  3. 跳转到保护模式/长模式:x86 CPU 启动时处于 16 位实模式(兼容 8086), 内核需要切换到 64 位长模式才能使用全部内存和现代 CPU 特性

1.8.3 第二阶段:解压内核

你编译出来的 bzImage 中的内核本体(vmlinux)是被压缩的。在能运行之前, 需要一个「解压引导程序」把它解开。

这个解压程序位于 arch/x86/boot/compressed/ 目录。它的流程是:

  1. head_64.S(汇编入口):设置 64 位环境
  2. misc.c 中的 extract_kernel():把压缩的内核解压到内存的正确位置
  3. 跳转到解压后的内核入口:startup_64(arch/x86/kernel/head_64.S)

1.8.4 第三阶段:start_kernel() — 内核初始化

这是 Linux 内核中最重要的函数,位于 init/main.c。它所做的初始化按顺序大致如下:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
start_kernel()
  ├── setup_arch()         — CPU 架构相关的初始化
  ├── setup_command_line() — 解析内核启动参数(console=ttyS0 在这里生效)
  ├── mm_init()            — 内存管理子系统初始化
  ├── sched_init()         — 进程调度器初始化(没有它就没有多任务)
  ├── init_IRQ()           — 中断处理初始化
  ├── init_timers()        — 定时器初始化
  ├── console_init()       — 控制台初始化(printk 的输出在这里终于可见)
  ├── vfs_caches_init()    — 虚拟文件系统缓存初始化
  └── rest_init()          — 创建第一个内核线程

💡 注意到 console_init() 在比较靠后的位置吗?这意味着 start_kernel() 前半部分的 printk() 输出是「不可见」的——它们被缓存在 __log_buf 里,等控制台初始化完成后 一次性刷新出来。这也是为什么启动日志中前面几行的输出有一定的延迟感。

1.8.5 第四阶段:rest_init() → 启动第一个用户进程

rest_init() 创建两个关键线程,然后自己进入 idle(空闲)状态:

  1. kernel_init(PID 1):这就是 init 进程的前身
  2. kthreadd(PID 2):内核线程守护者,负责创建所有 [kworker]、[kswapd] 等内核线程

kernel_init 的流程:

1
2
3
4
5
6
7
kernel_init()
  ├── 如果配置了 initramfs → 解压到内存,挂载为根文件系统
  ├── 尝试执行 /init     (我们的 init 脚本)
  ├── 尝试执行 /sbin/init (SysV init)
  ├── 尝试执行 /etc/init
  ├── 尝试执行 /bin/init
  └── 尝试执行 /bin/sh    (最后的保底方案)

内核按顺序尝试这些路径。第一个成功执行的就成为 PID 1。如果全部失败, 内核打印 “No working init found” 并 panic。

当我们的 /init 执行 exec /bin/sh 时:

1
2
之前: PID 1 = /bin/sh /init(运行 init 脚本)
之后: PID 1 = /bin/sh(交互式 Shell)

此时 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 前手动开启:

1
2
3
echo "CONFIG_SH=y" >> .config
make olddefconfig
LDFLAGS="--static" make -j$(nproc)

坑 2:initramfs 是空的

现象:内核启动报 “Kernel panic - not syncing: No working init found”。

原因(两种可能):

  1. 打包命令错误:使用了错误的 find + cpio 组合,只打包了目录名没有递归打包文件。 正确的打包命令:find . | cpio -o -H newc | gzip > rootfs.cpio.gz
  2. Alpine BusyBox find 不支持 -printf:当使用 CONFIG_INITRAMFS_SOURCE 嵌入 rootfs 时,内核构建系统用宿主机的 find -printf 生成 cpio, 但 BusyBox 版本不支持 -printf,导致生成 512 字节的空 cpio。

修复:安装 GNU findutils:

1
apk add findutils

坑 3:initramfs 里没有 /dev/console

现象:内核报 “Warning: unable to open an initial console”,然后杀掉 init。

原因:内核在运行 init 之前需要输出设备。/dev/console(设备号 5,1)必须存在。

修复:在 rootfs/dev 下手动创建设备节点:

1
2
3
mknod -m 622 dev/console c 5 1
mknod -m 666 dev/ttyS0 c 4 64
mknod -m 666 dev/null c 1 3

1.10 本章小结

你在本章完成了:

  1. ✅ 用 Docker 搭建了一致的 Alpine Linux 构建环境
  2. ✅ 理解了 musl libc 和静态链接的选择理由
  3. ✅ 用 tinyconfig 白名单策略编译了一个 2.7MB 的 Linux 内核
  4. ✅ 静态编译了 Toybox,一个文件提供常用的命令
  5. ✅ 手工构建了根文件系统,编写了 init 启动脚本
  6. ✅ 从源码角度理解了 Linux 内核的启动全过程
  7. ✅ 用 QEMU 成功启动,进入了交互式 Shell

此时你的系统特点:

  • 完全在内存中运行,不依赖任何磁盘
  • 重启后一切数据消失(没有持久存储)
  • 不能访问任何物理硬盘

这些限制正是第 2 章要解决的问题。