Rust OS 入门:从按下电源键到内核接管

按下电源键时,RAM 还是空的、操作系统也还没有踪影,电脑需要一步步把控制权交给内核。本文梳理从 CPU 上电到 Rust 内核接管之间的启动过程。

本文是《Writing an OS in Rust》(https://os.phil-opp.com/)入门系列的前置铺垫,回答按下电源键到 Kernel 接管电脑之间发生的事。它正好接上上一篇《Rust OS 入门:从 main 到 _start》里没展开的启动链路。

📚 基本概念速读

名称 定义 省流
固件(firmware) 比操作系统更早运行的软件,存在主板 Flash 上,断电不消失 启动第一棒
BIOS / UEFI 最常见的固件,负责自检、初始化硬件、找启动盘 找盘、点火
POST Power-On Self Test,开机自检 体检
Boot Sector 磁盘第一个扇区,BIOS 把它加载进内存并执行 512 字节的入口
Bootloader 负责找 Kernel、加载 Kernel、配置 CPU 的程序 BIOS 与 Kernel 的交接员
实模式 / 保护模式 / 长模式 CPU 的 16 位 / 32 位 / 64 位运行模式 越新越强
寄存器(register) CPU 内部极快的小块存储 CPU 的便签
RIP 指令指针,指向下一条要执行的指令 程序计数器
RSP 栈指针,指向当前栈顶 栈顶在哪
Memory Map 描述内存地址空间中哪些区域可用的表 内存地图
bootimage 把 Bootloader 和你的 Kernel 打包成可启动镜像的工具 一键出镜像

🗺️ 先建立一张启动地图

不急着逐句抠细节,先记住整条链路:



graph TD
    A["按下电源键"] --> B["CPU Reset"]
    B --> C["固件 BIOS / UEFI<br/>(存在主板 Flash 上)"]
    C --> D["POST 自检 + 初始化硬件"]
    D --> E["找可引导的存储介质"]
    E --> F["读取磁盘第一个扇区<br/>(512 字节 Boot Sector)"]
    F --> G["第一阶段 Bootloader"]
    G --> H["第二阶段 Bootloader"]
    H --> I["配置 CPU:16 位 → 32 位 → 64 位"]
    I --> J["把 Kernel 加载到 RAM"]
    J --> K["跳转到 Kernel 入口,移交控制权"]
    K --> L["Kernel 接管电脑"]

整篇文章就是在解释这张图里的每一个箭头。下面按顺序拆开。


🔌 CPU 刚通电时,什么都不知道

想象一台刚通电的电脑:RAM 里空空如也,没有操作系统,没有程序。那么 CPU 要去哪里找第一条指令?

答案是固件(firmware)——一个比操作系统更早运行的软件。

平时我们写个 Rust 程序 cargo run,背后已经有操作系统、进程、虚拟内存、文件系统、硬盘驱动层层伺候着。但问题是:

操作系统还没启动的时候,谁来负责启动操作系统?

这时候不能指望 Linux / Windows,于是主板上的固件出场了。最常见的就是 BIOS 和 UEFI。

固件存在哪里

固件不在 RAM 里,而是存储在主板上的一块非易失性 Flash 存储器中:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
                 主板
┌─────────────────────────────────┐
│ │
│ CPU │
│ │ │
│ RAM │
│ │
│ Flash ROM │
│ ┌───────────────┐ │
│ │ BIOS / UEFI │ │
│ │ firmware │ │
│ └───────────────┘ │
│ │
└─────────────────────────────────┘

所以断电之后:RAM 内容消失,BIOS / UEFI 的内容还在。这就是为什么你关机以后 BIOS 并不会消失。

CPU 怎么找到固件

这是 x86 CPU 的设计规定:上电后 CPU 进入一种特殊的初始状态,从一个预定义的地址开始取指令并执行。这个地址就是固件代码所在的位置。

这里引出了启动流程中第一个关键概念——指令指针(Instruction Pointer,IP / RIP):

CPU 下一条要执行哪条指令?

1
2
3
4
5
6
7
内存:

0x1000 mov ...
0x1003 add ...
0x1006 jmp ...
↑
IP

CPU 不断重复读指令 → 执行 → IP 移到下一条,启动的本质就是这条执行链,而链条最开头的指令来自固件。


🧬 BIOS 上电后干什么

BIOS 的工作可以拆成四步:

1
2
3
4
5
6
BIOS
│
├── 检查 CPU
├── 检查 RAM
├── 初始化硬件
└── 找有没有可以启动的磁盘

前几步合称 POST(Power-On Self Test,开机自检)——你开机时看到的厂商 Logo、听到的提示音、内存检查,都属于这个阶段。

去哪里找操作系统

老式 BIOS 的方法非常朴素:去磁盘的最开始找。磁盘第一个区域叫启动扇区(Boot Sector):

1
2
3
4
5
6
7
8
9
10
11
12
13
磁盘

0
↓
┌─────────────────────┐
│ Boot Sector │ ← BIOS 找这里
├─────────────────────┤
│ │
│ │
│ │
│ Operating System │
│ │
└─────────────────────┘

BIOS 把这个扇区的内容加载进内存,然后 CPU 开始执行里面的代码。


📦 为什么处处强调 512 字节

传统 BIOS 启动时,读取的是磁盘的第一个扇区(sector),而传统磁盘扇区大小是 512 bytes。所以 Bootloader 的起点只有 512 字节的舞台:

1
2
3
4
5
6
7
磁盘第一个扇区

┌──────────────────────────────┐
│ │
│ Bootloader │ ← 只有 512 bytes 的舞台
│ │
└──────────────────────────────┘

512 字节写一个像样的 Bootloader 都很勉强,更别说写操作系统了。于是才有了两阶段设计。


🪜 两阶段 Bootloader:接力棒

第一阶段 Bootloader(First Stage Bootloader)只有几百字节,它的全部任务就是:找到第二阶段 Bootloader,把它加载到 RAM,然后跳过去执行。



graph TD
    A["BIOS"] -->|"读取第一扇区,加载进 RAM"| B["第一阶段 Bootloader<br/>(512 字节)"]
    B -->|"找到并加载"| C["第二阶段 Bootloader"]
    C -->|"找到 Kernel,加载到 RAM"| D["Kernel"]
    D -->|"接管电脑"| E["操作系统"]

为什么不直接让 BIOS 加载 Kernel?因为 BIOS 太古老、功能太简单——它只负责找到启动扇区,剩下的事全部交给 Bootloader。

第二阶段 Bootloader 干的事才是重头戏:

  1. 找 Kernel:Kernel 在磁盘/SSD 上,Bootloader 要知道它在哪。
  2. 把 Kernel 放进 RAM:CPU 不能直接执行硬盘上的普通文件,必须先读到内存里。比如把 kernel.bin 加载到 0x100000,再让 CPU 跳过去执行。
  3. 配置 CPU:把 CPU 从启动时的 16 位状态配置成 64 位状态。
  4. 收集机器信息传给 Kernel:内存多大、哪些区域可用。

🕰️ CPU 的模式变迁:16 位 → 32 位 → 64 位

这是启动流程中最容易懵的部分:电脑明明是 64 位 CPU,为什么启动时非得从 16 位开始?

答案是一个著名的历史包袱:x86 为了兼容几十年前的软件,保留了古老的启动机制。

最早的 x86 CPU(8086,1981 年的 IBM PC 用的就是它)是 16 位处理器。后来 Intel 一路升级到 80286、80386……直到现代 x86-64,但 Intel 没有把老东西扔掉,而是尽量兼容旧软件。所以今天的 CPU 上电后,仍然按照 1980 年代的规则启动。

模式可以粗略理解为:CPU 当前按哪种规则解释指令、访问内存。你需要知道的三个模式:

模式 位数 说明
实模式(Real Mode) 16 位 CPU 上电后的默认状态,启动的起点
保护模式(Protected Mode) 32 位 80386 引入,有内存保护、特权级
长模式(Long Mode) 64 位 现在的 x86-64 运行模式,你的 Rust Kernel 在这里跑

所以启动过程是:



graph LR
    A["16-bit Real Mode<br/>(上电默认)"] --> B["32-bit Protected Mode"]
    B --> C["开启 PAE / 配置页表 / 设置 EFER"]
    C --> D["64-bit Long Mode"]
    D --> E["Rust Kernel"]

为什么不能从 16 位直接跳到 64 位? 因为 x86 的历史设计就是这样,中间必须经过一系列意图并不明显的步骤——比如往某个寄存器写入魔术数字:

1
2
3
mov eax, cr0
or eax, 1
mov cr0, eax

看到 CR0、CR3、CR4、EFER、PAE、页表、GDT 这些词先别慌。你现在不需要全部理解,只需要知道:

Bootloader 通过修改 CPU 的特殊寄存器和内存管理结构,把 CPU 从兼容老系统的状态配置成现代 64 位状态。

这正是教程作者推荐 bootimage、而不是让你手写 Bootloader 的原因。


🧮 寄存器速览:RIP 与 RSP

寄存器(register)就是 CPU 内部非常非常快的一小块存储空间:

1
2
mov rax, 42    ; 把 42 放进 RAX
add rax, 10 ; RAX = RAX + 10 → 最终 RAX = 52

其中有两个和启动流程强相关的寄存器。

RIP:指令指针

CPU 下一条要执行的指令在哪?

1
2
3
4
5
内存

0x1000 mov rax, 1
0x1005 add rax, 2
0x1008 hlt

注意三条指令的长度各不相同:mov rax, 1 占 5 字节,add rax, 2 占 3 字节,hlt 只有 1 字节。

x86 是变长指令集(variable-length instruction set):指令长度不固定,短则 1 字节(如 hlt),长则可达十几字节,具体长度取决于操作数、寻址方式等编码细节(上图只是示意图,实际编码长度以汇编器输出为准)。

x86 的 CPU 取完一条指令后,要靠解码算出这条指令到底占多长,才能把 RIP 正确推进到下一条指令的起点。这跟 RISC-V、ARM 等定长指令集(fixed-length instruction set)不同——后者每条指令固定占 4 字节,取指按固定步长推进即可。

理解了这一点,再看 RIP 为什么能从 0x1000 精确跳到 0x1005 就不会困惑了。

RIP 指向 0x1000,CPU 就取这条指令执行,然后 RIP 移到 0x1005,如此往复。启动的最后一步——跳转到 Kernel 入口——本质就是:

RIP = Kernel 的入口地址

CPU 从此不再执行 Bootloader,而开始执行 Kernel。

RSP:栈指针

RSP(Stack Pointer)指向当前栈顶地址,它直接回应上一篇遗留的问题:创建栈的底层操作是什么?申请内存空间?还是只是给寄存器存一个地址?

答案是:两者都有,但概念上是分开的。

1
2
3
4
5
6
7
8
9
10
先准备一块 RAM:

0x800000
↓
┌──────────────┐
│ │
│ Stack │
│ │
└──────────────┘
0x801000

然后告诉 CPU 把这块内存当栈用:

1
mov rsp, 0x801000

分配栈空间 ≠ 设置 RSP。前者是准备内存,后者只是告诉 CPU 把这块内存当栈用。Bootloader 把 CPU 切到 64 位之后,在跳进 Kernel 之前,就负责把 RSP 设置好。


📍 内存映射:Kernel 需要知道机器长什么样

假设电脑有 16GB RAM,你可能会天真地认为地址空间里全是可用的内存。实际上不是——里面混杂着可用 RAM、BIOS/firmware 区域、MMIO(硬件映射)、保留区域等。

Kernel 必须知道:哪些地址对应可用 RAM,哪些区域不能碰。这个信息就记录在内存映射(Memory Map)里,由 BIOS 提供、Bootloader 整理后传给 Kernel。

为什么 Kernel 自己不去问 BIOS?因为 BIOS 只是启动阶段的固件接口,现代操作系统并不是一直依赖它。可以这样理解整个交接关系:

Bootloader 是 BIOS 和 Kernel 之间的交接员——BIOS 提供硬件信息 → Bootloader 整理 → 传给 Kernel → Kernel 接管。


🔗 bootimage:把启动细节藏起来

理解了上面的复杂度,就知道为什么教程作者说”我们不会讲解如何编写自己的引导程序”——他在保护你,不让你现在就被 BIOS + 汇编 + x86 启动细节折磨。

为什么不直接用现成的 GRUB?

既然 Bootloader 这么复杂,那能不能直接用别人写好的引导程序?可以,但先要解决一个接口问题:

每个操作系统都实现自己的引导程序,而这套引导程序只对单个操作系统有效。

1995 年,自由软件基金会(Free Software Foundation,FSF)颁布了一个开源的引导程序标准——Multiboot 标准(Multiboot Specification)。它定义了引导程序和操作系统之间的统一接口:任何适配 Multiboot 的引导程序,都能加载任何同样适配 Multiboot 的操作系统。GNU GRUB 就是一个可供参考的 Multiboot 实现,也是最热门的 Linux 引导程序之一。

要编写一个适配 Multiboot 的内核,只需要在内核文件开头插入一段被称为 Multiboot 头(Multiboot header) 的数据片段,GRUB 就能认出并引导这个内核。

但 GRUB 和 Multiboot 标准也有一些已知的问题:

  • 只支持 32 位保护模式:引导之后,你依然需要自己配置 CPU 切换到 64 位长模式;
  • 设计重心是引导程序简单,而不是内核简单:内核需要以调整过的默认页长度(default page size)链接,否则 GRUB 找不到内核的 Multiboot 头;引导信息(boot information)包含大量与架构相关的数据,启动时直接传给操作系统,没有经过清晰的抽象;
  • 文档没有被详细解释:阅读相关文档需要一定经验;
  • 开发时必须安装 GRUB:这加大了基于 Windows 或 macOS 开发内核的难度。

出于这些考虑,教程作者决定不使用 GRUB 或 Multiboot 标准。不过 Multiboot 支持也在 bootimage 的开发计划之中,从原理上讲,未来用 GRUB 引导你的内核是可能的。

bootimage 工具帮你生成:

1
2
3
4
5
Bootloader
+
你的 Kernel
↓
bootable image(可启动镜像)

使用方式:

1
cargo bootimage

得到 bootimage.bin 后,用 QEMU 模拟一台电脑启动它:

1
qemu-system-x86_64 -drive format=raw,file=bootimage.bin

bootimage 背后到底发生了什么

实际 cargo bootimage 在幕后做了三步:

  1. 编译 kernel:等价于 cargo build,产出 kernel ELF(内核的可执行文件,入口是 _start);
  2. 编译 bootloader:把 bootloader crate 编译成独立的引导程序;
  3. 组合成镜像:把 kernel ELF 按字节拼到 bootloader 的末尾,生成可启动的磁盘镜像(bootable disk image)。

组合完成后,bootimage.bin 内部其实是三层结构——正好呼应前面讲的两阶段 Bootloader:

1
2
3
4
5
6
7
bootimage.bin
│
├── BIOS boot code ← 类似 Stage 1:最前面的小入口,BIOS 把控制权交进来
│
├── bootloader ← 类似 Stage 2:主体引导程序,负责加载内核
│
└── kernel ELF ← 你的 Rust 代码

BIOS 启动时只会读磁盘最前面的扇区,所以 bootloader 也被设计成了小入口 + 主体的两段式:前面一小段代码(类似第一阶段 Bootloader)负责把控制权从 BIOS 手中接过来,随后才是真正干活的引导程序主体(类似第二阶段 Bootloader)——它来解析内核 ELF、把控制权交给 _start。启动的接力棒从 BIOS → 小入口 → 引导主体 → 内核,一路传下去。

kernel ELF 内部长什么样、BIOS 为什么不能直接启动它、bootloader 又是怎么把它的各个段加载进内存的——这些细节我们在《Rust OS 入门:ELF 与程序内存布局》里完整展开。这里只需要记住:kernel ELF 是操作系统本体,bootloader 是启动它的工具,bootimage 是把两者装进同一个能开机的硬盘。


🏗️ 学习 OS 应有的层次感

建立这张地图:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
① 硬件
│
├── CPU / RAM / SSD / 主板
│
② Firmware
│
├── BIOS / UEFI
│
③ Bootloader
│
├── 找 Kernel
├── 加载 Kernel
├── 配置 CPU(16 → 32 → 64 位)
└── 给 Kernel 提供硬件信息
│
④ Kernel
│
├── 内存管理 / 中断 / 进程 / 调度
├── 文件系统 / 驱动
│
⑤ 用户空间
│
├── Shell / 应用程序 / 你的 Rust 程序

你正在看的 Rust OS 教程,恰好处于 ③ Bootloader → ④ Kernel 这个交界处。

最后送一个类比——把电脑启动想象成一家公司换老板:

1
2
3
4
5
6
7
8
9
10
BIOS:  "公司开门了,我检查一下水电、员工、设备。"

Bootloader:
"我找到新老板的办公室了。先把办公室准备好,
顺便把公司的资产清单交给他。"

Kernel:"现在我接管公司。以后内存、CPU、设备、进程都归我管。"

User Programs:
"老板,我想运行 Chrome / 游戏 / Rust 程序。"

所以 BIOS → Bootloader → Kernel 不是三个独立的程序,而是电脑启动控制权逐级交接的过程。


✅ 总结

按下电源键后,CPU 从主板固件开始取指令,BIOS 自检并找到启动扇区,Bootloader 接力加载 Kernel 并把 CPU 从 16 位一路配置到 64 位,最后跳转到 Kernel 入口——控制权逐级交接,直到内核接管电脑。

真正值得花时间打通的下一条链是:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
BIOS
↓
Bootloader
↓
64-bit Long Mode
↓
设置 RSP(栈)
↓
设置 RIP(跳转到入口)
↓
_start
↓
Rust 初始化
↓
kernel_main()

这条链的后半段——_start 入口、no_std/no_main、栈从哪里来、自定义编译目标与链接参数——正是上一篇《Rust OS 入门:从 main 到 _start》的内容。两篇连起来读,启动的完整图景就通了。

Happy Hacking! 🎉