文章

Pintos Loader.S 详解(一):初始化

Pintos 引导加载程序的第一部分,负责在 CPU 启动后建立最基本的运行环境,包括段寄存器和栈的设置。

Pintos Loader.S 详解(一):初始化

概述

这是 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 代码"]
  1. CPU 上电复位:CPU 被初始化到一个已知状态
  2. BIOS 启动:CPU 从固定地址(通常是 0xFFFF0)开始执行 BIOS 代码
  3. BIOS 自检:检测内存、硬盘等硬件
  4. 加载引导扇区:BIOS 将硬盘第一个扇区(512 字节)加载到内存地址 0x7C00
  5. 跳转执行:BIOS 跳转到 0x7C00,开始执行我们的 loader 代码

什么是实模式(Real Mode)?

x86 处理器有多种运行模式:

模式位宽最大内存特点
实模式16位1MBCPU 启动时的默认模式
保护模式32位4GB现代操作系统使用
长模式64位16EB64位操作系统使用

CPU 启动时总是处于 16 位实模式,这是为了向后兼容最早的 8086 处理器(1978年)。

实模式的内存寻址

在实模式下,地址由两部分组成:段地址偏移地址

1
物理地址 = 段地址 × 16 + 偏移地址

例如:

  • 段地址 = 0x0000,偏移 = 0x7C00
  • 物理地址 = 0x0000 × 16 + 0x7C00 = 0x7C00

这种设计允许 16 位寄存器访问 20 位地址空间(最大 1MB)。

段寄存器

x86 有 4 个主要的段寄存器:

寄存器名称用途
CSCode Segment代码段,指令从这里取
DSData Segment数据段,默认数据访问使用
SSStack Segment栈段,push/pop 使用
ESExtra Segment附加段,字符串操作等使用

逐行详解

第 1 行:.code16

1
.code16

这是什么?

这是一个汇编器指令(Assembler Directive),不是 CPU 指令。它告诉汇编器(如 GAS):

“接下来的代码应该生成 16 位的机器码”

为什么需要?

因为 CPU 启动时处于 16 位模式,我们必须使用 16 位指令。如果汇编器默认生成 32 位代码,CPU 会错误地解释这些指令。

对比示例:

指令16位机器码32位机器码
mov $0, %axB8 00 0066 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, %ax29 C02 字节
mov $0, %axB8 00 003 字节

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 指令修改。它只能通过跳转指令(如 jmpcallret)间接改变。

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    │
    └─────────────┘
         低地址

栈的用途:

  1. 保存函数返回地址
  2. 传递函数参数
  3. 存储局部变量
  4. 临时保存寄存器值

为什么选择 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 时:

  1. ESP 先减小(例如从 0xF000 变成 0xEFFE)
  2. 然后数据写入新地址

当你执行 pop 时:

  1. 先读取 ESP 指向的数据
  2. 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 的栈空间对于引导加载程序来说绰绰有余。我们只需要保存少量的返回地址和寄存器。


练习思考

  1. 如果将 sub %ax, %ax 改为 xor %ax, %ax,效果相同吗?为什么?

  2. 如果我们把栈设置在 0x7000 会有什么问题?

  3. 为什么不能用 mov $0, %cs 来设置代码段寄存器?


练习答案

点击查看答案 1

效果完全相同。 两条指令都将 AX 清零,且机器码都是 2 字节:

指令机器码
sub %ax, %ax29 C0
xor %ax, %ax31 C0

两者的区别:

  • sub 会设置 CF(进位标志),而 xor 总是清除 CF
  • 在这个场景下,这个区别无关紧要
  • 两种写法在实际代码中都很常见
点击查看答案 2

如果把栈设置在 0x7000,可能会覆盖 Loader 代码!

  • Loader 代码位于 0x7C00-0x7DFF
  • 栈向下增长
  • 如果 ESP = 0x7000,当栈增长超过约 3KB 时(0x7000 → 0x6400),栈还是安全的
  • 但如果函数调用层次很深或局部变量很多,栈向上增长到 0x7C00 以上就会覆盖代码

实际上 0x7000 的位置是相对安全的,因为栈向下增长不会到达代码区。但为了留出更多空间并避免与任何可能的数据冲突,选择 0xF000 更加保守和安全。

点击查看答案 3

CS 是代码段寄存器,出于安全和架构设计考虑,x86 不允许直接用 mov 指令修改它。

原因:

  1. 执行安全:如果可以随意修改 CS,程序可能跳转到任意代码执行,造成安全漏洞
  2. 原子性:修改 CS 必须同时修改 IP(指令指针),否则 CPU 会从错误位置取指令
  3. 设计决定:Intel 设计 x86 时决定 CS 只能通过以下方式改变:
    • 远跳转:ljmp segment:offset
    • 远调用:lcall segment:offset
    • 中断返回:iret
    • 远返回:lret

这些指令都会同时设置 CS 和 IP,保证执行的连续性。


下一部分

初始化完成后,下一步是配置串口,用于调试输出。请参阅下一篇文章。

本文由作者按照 CC BY 4.0 进行授权