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

按下电源键的那一秒,CPU 读到的第一条指令到底是什么?在 RAM 空空如也、操作系统还没影子的情况下,电脑是怎么一步步把控制权交给你的 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,于是主板上的固件出场了。最常见的就是 BIOSUEFI

固件存在哪里

固件不在 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
8
9
磁盘第一个扇区

┌──────────────────────────────┐
│ │
│ 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

看到 CR0CR3CR4EFERPAE、页表、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 启动细节折磨。

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

🏗️ 学习 OS 应有的”层次感”

现在千万不要一头扎进 CR0CR3EFER、GDT、IDT、页表、PAE 的细节里,否则非常容易崩。先建立这张地图:

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_mangle]、链接器脚本——正是上一篇《Rust OS 入门:从 main 到 _start》的内容。两篇连起来读,启动的完整图景就通了。

Happy Hacking! 🎉