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 | 主板 |
所以断电之后:RAM 内容消失,BIOS / UEFI 的内容还在。这就是为什么你关机以后 BIOS 并不会消失。
CPU 怎么找到固件
这是 x86 CPU 的设计规定:上电后 CPU 进入一种特殊的初始状态,从一个预定义的地址开始取指令并执行。这个地址就是固件代码所在的位置。
这里引出了启动流程中第一个关键概念——指令指针(Instruction Pointer,IP / RIP):
CPU 下一条要执行哪条指令?
1 | 内存: |
CPU 不断重复读指令 → 执行 → IP 移到下一条,启动的本质就是这条执行链,而链条最开头的指令来自固件。
🧬 BIOS 上电后干什么
BIOS 的工作可以拆成四步:
1 | BIOS |
前几步合称 POST(Power-On Self Test,开机自检)——你开机时看到的厂商 Logo、听到的提示音、内存检查,都属于这个阶段。
去哪里找操作系统
老式 BIOS 的方法非常朴素:去磁盘的最开始找。磁盘第一个区域叫启动扇区(Boot Sector):
1 | 磁盘 |
BIOS 把这个扇区的内容加载进内存,然后 CPU 开始执行里面的代码。
📦 为什么处处强调 512 字节
传统 BIOS 启动时,读取的是磁盘的第一个扇区(sector),而传统磁盘扇区大小是 512 bytes。所以 Bootloader 的起点只有 512 字节的舞台:
1 | 磁盘第一个扇区 |
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 干的事才是重头戏:
- 找 Kernel:Kernel 在磁盘/SSD 上,Bootloader 要知道它在哪。
- 把 Kernel 放进 RAM:CPU
不能直接执行硬盘上的普通文件,必须先读到内存里。比如把
kernel.bin加载到0x100000,再让 CPU 跳过去执行。 - 配置 CPU:把 CPU 从启动时的 16 位状态配置成 64 位状态。
- 收集机器信息传给 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 | mov eax, cr0 |
看到
CR0、CR3、CR4、EFER、PAE、页表、GDT
这些词先别慌。你现在不需要全部理解,只需要知道:
Bootloader 通过修改 CPU 的特殊寄存器和内存管理结构,把 CPU 从兼容老系统的状态配置成现代 64 位状态。
这正是教程作者推荐 bootimage、而不是让你手写 Bootloader
的原因。
🧮 寄存器速览:RIP 与 RSP
寄存器(register)就是 CPU 内部非常非常快的一小块存储空间:
1 | mov rax, 42 ; 把 42 放进 RAX |
其中有两个和启动流程强相关的寄存器。
RIP:指令指针
CPU 下一条要执行的指令在哪?
1 | 内存 |
注意三条指令的长度各不相同: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 | 先准备一块 RAM: |
然后告诉 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 | Bootloader |
使用方式:
1 | cargo bootimage |
得到 bootimage.bin 后,用 QEMU 模拟一台电脑启动它:
1 | qemu-system-x86_64 -drive format=raw,file=bootimage.bin |
bootimage 背后到底发生了什么
实际 cargo bootimage 在幕后做了三步:
- 编译 kernel:等价于
cargo build,产出 kernel ELF(内核的可执行文件,入口是_start); - 编译 bootloader:把
bootloadercrate 编译成独立的引导程序; - 组合成镜像:把 kernel ELF 按字节拼到 bootloader 的末尾,生成可启动的磁盘镜像(bootable disk image)。
组合完成后,bootimage.bin
内部其实是三层结构——正好呼应前面讲的两阶段 Bootloader:
1 | bootimage.bin |
BIOS 启动时只会读磁盘最前面的扇区,所以 bootloader 也被设计成了小入口
+ 主体的两段式:前面一小段代码(类似第一阶段 Bootloader)负责把控制权从
BIOS 手中接过来,随后才是真正干活的引导程序主体(类似第二阶段
Bootloader)——它来解析内核 ELF、把控制权交给
_start。启动的接力棒从 BIOS → 小入口 → 引导主体 →
内核,一路传下去。
kernel ELF 内部长什么样、BIOS 为什么不能直接启动它、bootloader 又是怎么把它的各个段加载进内存的——这些细节我们在《Rust OS 入门:ELF 与程序内存布局》里完整展开。这里只需要记住:kernel ELF 是操作系统本体,bootloader 是启动它的工具,bootimage 是把两者装进同一个能开机的硬盘。
🏗️ 学习 OS 应有的层次感
建立这张地图:
1 | ① 硬件 |
你正在看的 Rust OS 教程,恰好处于 ③ Bootloader → ④ Kernel 这个交界处。
最后送一个类比——把电脑启动想象成一家公司换老板:
1 | BIOS: "公司开门了,我检查一下水电、员工、设备。" |
所以 BIOS → Bootloader → Kernel 不是三个独立的程序,而是电脑启动控制权逐级交接的过程。
✅ 总结
按下电源键后,CPU 从主板固件开始取指令,BIOS 自检并找到启动扇区,Bootloader 接力加载 Kernel 并把 CPU 从 16 位一路配置到 64 位,最后跳转到 Kernel 入口——控制权逐级交接,直到内核接管电脑。
真正值得花时间打通的下一条链是:
1 | BIOS |
这条链的后半段——_start
入口、no_std/no_main、栈从哪里来、自定义编译目标与链接参数——正是上一篇《Rust OS 入门:从 main 到
_start》的内容。两篇连起来读,启动的完整图景就通了。
Happy Hacking! 🎉