Pintos Loader.S 详解(一):初始化
Pintos 引导加载程序的第一部分,负责在 CPU 启动后建立最基本的运行环境,包括段寄存器和栈的设置。
概述
这是 Pintos 引导加载程序的第一部分,负责在 CPU 启动后建立最基本的运行环境。这部分代码非常短,但每一行都至关重要。
原始代码
1
2
3
4
5
6
7
8
9
10
11
# Runs in real mode, which is a 16-bit segment.
.code16
# Set up segment registers.
# Set stack to grow downward from 60 kB (after boot, the kernel
# continues to use this stack for its initial thread).
sub %ax, %ax
mov %ax, %ds
mov %ax, %ss
mov $0xf000, %esp
前置知识
计算机启动过程
当你按下电源按钮时,会发生以下事情:
flowchart LR
A["CPU 上电复位"] --> B["BIOS 启动"]
B --> C["BIOS 自检"]
C --> D["加载引导扇区<br/>到 0x7C00"]
D --> E["跳转执行<br/>Loader 代码"]
- CPU 上电复位:CPU 被初始化到一个已知状态
- BIOS 启动:CPU 从固定地址(通常是 0xFFFF0)开始执行 BIOS 代码
- BIOS 自检:检测内存、硬盘等硬件
- 加载引导扇区:BIOS 将硬盘第一个扇区(512 字节)加载到内存地址
0x7C00 - 跳转执行:BIOS 跳转到
0x7C00,开始执行我们的 loader 代码
什么是实模式(Real Mode)?
x86 处理器有多种运行模式:
| 模式 | 位宽 | 最大内存 | 特点 |
|---|---|---|---|
| 实模式 | 16位 | 1MB | CPU 启动时的默认模式 |
| 保护模式 | 32位 | 4GB | 现代操作系统使用 |
| 长模式 | 64位 | 16EB | 64位操作系统使用 |
CPU 启动时总是处于 16 位实模式,这是为了向后兼容最早的 8086 处理器(1978年)。
实模式的内存寻址
在实模式下,地址由两部分组成:段地址 和 偏移地址
1
物理地址 = 段地址 × 16 + 偏移地址
例如:
- 段地址 = 0x0000,偏移 = 0x7C00
- 物理地址 = 0x0000 × 16 + 0x7C00 = 0x7C00
这种设计允许 16 位寄存器访问 20 位地址空间(最大 1MB)。
段寄存器
x86 有 4 个主要的段寄存器:
| 寄存器 | 名称 | 用途 |
|---|---|---|
| CS | Code Segment | 代码段,指令从这里取 |
| DS | Data Segment | 数据段,默认数据访问使用 |
| SS | Stack Segment | 栈段,push/pop 使用 |
| ES | Extra Segment | 附加段,字符串操作等使用 |
逐行详解
第 1 行:.code16
1
.code16
这是什么?
这是一个汇编器指令(Assembler Directive),不是 CPU 指令。它告诉汇编器(如 GAS):
“接下来的代码应该生成 16 位的机器码”
为什么需要?
因为 CPU 启动时处于 16 位模式,我们必须使用 16 位指令。如果汇编器默认生成 32 位代码,CPU 会错误地解释这些指令。
对比示例:
| 指令 | 16位机器码 | 32位机器码 |
|---|---|---|
mov $0, %ax | B8 00 00 | 66 B8 00 00 00 00 |
可以看到,同一条指令在不同模式下编码完全不同。
第 2 行:sub %ax, %ax
1
sub %ax, %ax
这是什么?
这条指令将 AX 寄存器的值减去自身,结果存回 AX。
1
AX = AX - AX = 0
为什么不直接用 mov $0, %ax?
让我们比较两种方式:
| 指令 | 机器码 | 字节数 |
|---|---|---|
sub %ax, %ax | 29 C0 | 2 字节 |
mov $0, %ax | B8 00 00 | 3 字节 |
sub %ax, %ax 节省了 1 个字节!
在引导扇区中,我们只有 512 字节的空间,其中还包括数据结构。每一个字节都很宝贵,所以程序员使用这种技巧来节省空间。
其他等效的清零技巧:
1
2
3
4
sub %ax, %ax # 2 字节,常用
xor %ax, %ax # 2 字节,同样常用
and $0, %ax # 3 字节,较少用
mov $0, %ax # 3 字节,最直观
第 3-4 行:设置段寄存器
1
2
mov %ax, %ds
mov %ax, %ss
这是什么?
将 AX 的值(现在是 0)复制到 DS 和 SS 寄存器。
为什么需要?
BIOS 跳转到我们的代码时,段寄存器的值是不确定的。不同的 BIOS 可能设置不同的值。为了确保代码正确运行,我们必须自己初始化它们。
为什么设置为 0?
设置 DS = SS = 0 意味着:
- 数据访问的物理地址 = 0 × 16 + 偏移 = 偏移
- 栈操作的物理地址 = 0 × 16 + 偏移 = 偏移
这样,偏移地址就等于物理地址,简化了地址计算。
为什么不设置 CS?
CS(代码段)不能直接用 mov 指令修改。它只能通过跳转指令(如 jmp、call、ret)间接改变。
BIOS 跳转到 0x7C00 时,通常设置 CS:IP = 0x0000:0x7C00,所以 CS 已经是 0。
为什么不设置 ES?
ES 会在后面使用前设置。这里先不管它。
第 5 行:设置栈指针
1
mov $0xf000, %esp
这是什么?
将栈指针 ESP 设置为 0xF000(十进制 61440,约 60KB)。
栈是什么?
栈是一块用于临时存储的内存区域,遵循”后进先出”(LIFO)原则:
1
2
3
4
5
6
7
8
9
10
11
12
13
高地址
┌─────────────┐
│ │
│ (空闲) │
│ │
├─────────────┤ ← ESP 指向这里(栈顶)
│ 数据 3 │
├─────────────┤
│ 数据 2 │
├─────────────┤
│ 数据 1 │
└─────────────┘
低地址
栈的用途:
- 保存函数返回地址
- 传递函数参数
- 存储局部变量
- 临时保存寄存器值
为什么选择 0xF000?
让我们看看内存布局:
1
2
3
4
5
6
7
8
地址 内容
─────────────────────────────────
0x00000-0x003FF 中断向量表(BIOS 使用)
0x00400-0x004FF BIOS 数据区
0x00500-0x07BFF 可用内存
0x07C00-0x07DFF 我们的 Loader(512 字节)
0x07E00-0x0FFFF 可用内存(约 33KB)
0x10000-... 后面会用来加载内核
0xF000 位于可用区域内,向下增长时不会覆盖 Loader 代码或 BIOS 数据。
为什么是 ESP 而不是 SP?
ESP 是 32 位寄存器,SP 是其低 16 位。在实模式下,只有低 16 位有效(0xF000),但使用 ESP 可以确保高位清零,避免潜在问题。
栈向下增长是什么意思?
当你执行 push 时:
- ESP 先减小(例如从 0xF000 变成 0xEFFE)
- 然后数据写入新地址
当你执行 pop 时:
- 先读取 ESP 指向的数据
- ESP 再增大
1
2
3
4
PUSH 操作: POP 操作:
ESP ↓ 减小 ESP ↑ 增大
栈向低地址增长
内存状态图
初始化完成后,内存状态如下:
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
物理地址
┌─────────────────┐ 0x100000 (1MB)
│ │
│ BIOS ROM 等 │
│ │
├─────────────────┤ 0xA0000 (640KB)
│ │
│ 可用内存 │
│ (用于内核) │
│ │
├─────────────────┤ 0x10000 (64KB)
│ │
│ 栈空间 ↓ │ ← SS = 0
│ │
├─────────────────┤ 0x0F000 (60KB) ← ESP
│ │
│ 可用内存 │
│ │
├─────────────────┤ 0x07E00
│ Loader 代码 │
├─────────────────┤ 0x07C00 ← DS = 0, 代码从这里开始
│ 可用内存 │
├─────────────────┤ 0x00500
│ BIOS 数据区 │
├─────────────────┤ 0x00400
│ 中断向量表 │
└─────────────────┘ 0x00000
常见问题
Q1: 为什么 BIOS 选择 0x7C00 这个地址?
这是历史原因。IBM PC 的最初设计者选择了这个地址:
- 0x7C00 = 0x7FFF - 512 + 1 = 32KB - 512 + 1
- 当时 IBM PC 最小内存是 32KB
- 引导扇区放在 32KB 末尾,为操作系统预留前面的空间
Q2: 初始化时 AX 的值是什么?
不确定!BIOS 可能在 AX 中放了任何值。这就是为什么我们要用 sub %ax, %ax 显式清零。
Q3: 如果不初始化段寄存器会怎样?
程序可能在某些 BIOS 上正常工作,在其他 BIOS 上崩溃。这是一个很难调试的问题,因为它依赖于具体的硬件。
Q4: 栈空间会不会太小?
0xF000 - 0x7E00 ≈ 29KB 的栈空间对于引导加载程序来说绰绰有余。我们只需要保存少量的返回地址和寄存器。
练习思考
如果将
sub %ax, %ax改为xor %ax, %ax,效果相同吗?为什么?如果我们把栈设置在 0x7000 会有什么问题?
为什么不能用
mov $0, %cs来设置代码段寄存器?
练习答案
点击查看答案 1
效果完全相同。 两条指令都将 AX 清零,且机器码都是 2 字节:
| 指令 | 机器码 |
|---|---|
sub %ax, %ax | 29 C0 |
xor %ax, %ax | 31 C0 |
两者的区别:
sub会设置 CF(进位标志),而xor总是清除 CF- 在这个场景下,这个区别无关紧要
- 两种写法在实际代码中都很常见
点击查看答案 2
如果把栈设置在 0x7000,可能会覆盖 Loader 代码!
- Loader 代码位于 0x7C00-0x7DFF
- 栈向下增长
- 如果 ESP = 0x7000,当栈增长超过约 3KB 时(0x7000 → 0x6400),栈还是安全的
- 但如果函数调用层次很深或局部变量很多,栈向上增长到 0x7C00 以上就会覆盖代码
实际上 0x7000 的位置是相对安全的,因为栈向下增长不会到达代码区。但为了留出更多空间并避免与任何可能的数据冲突,选择 0xF000 更加保守和安全。
点击查看答案 3
CS 是代码段寄存器,出于安全和架构设计考虑,x86 不允许直接用 mov 指令修改它。
原因:
- 执行安全:如果可以随意修改 CS,程序可能跳转到任意代码执行,造成安全漏洞
- 原子性:修改 CS 必须同时修改 IP(指令指针),否则 CPU 会从错误位置取指令
- 设计决定:Intel 设计 x86 时决定 CS 只能通过以下方式改变:
- 远跳转:
ljmp segment:offset - 远调用:
lcall segment:offset - 中断返回:
iret - 远返回:
lret
- 远跳转:
这些指令都会同时设置 CS 和 IP,保证执行的连续性。
下一部分
初始化完成后,下一步是配置串口,用于调试输出。请参阅下一篇文章。