4. 切换到保护模式:从实模式到保护模式的切换机制
当 Linux 内核引导程序(如 arch/x86/boot/pmjump.S)完成实模式下的硬件初始化后,必须将 CPU 从 16 位实模式切换到 32 位保护模式。这一切换是内核解压前最关键的一步,它重新定义了内存访问模型:从段基址:偏移量直接计算物理地址,转变为通过段选择子查找全局描述符表(GDT)获取段描述符,从而获得段基址、段限长和访问权限。
4.1 保护模式的核心数据结构:GDT 与 LDT
保护模式使用两种描述符表来管理内存段:
- 全局描述符表(GDT):系统唯一的描述符表,存放操作系统和所有任务共享的段描述符。GDT 的基地址和限长存储在
GDTR寄存器中。 - 局部描述符表(LDT):每个任务可拥有独立的描述符表,用于隔离任务私有段。LDT 的基地址和限长存储在
LDTR寄存器中。
在 Linux 内核早期启动阶段,仅使用 GDT,且只定义少数几个必要的段描述符:
| 段选择子值 | 段用途 | 基址 | 限长 | DPL | 粒度 |
|---|---|---|---|---|---|
| 0x00 | 空描述符(必须存在) | — | — | — | — |
| 0x08 | 内核代码段(CS) | 0x00000000 | 0xFFFFF | 0 | 4KB |
| 0x10 | 内核数据段(DS/ES/SS) | 0x00000000 | 0xFFFFF | 0 | 4KB |
关键点: 实模式下的段基址是 16 字节对齐的,而保护模式下的段基址可以是 32 位任意值。内核将代码段和数据段的基址都设为 0x00000000,限长设为 4GB(0xFFFFF × 4KB),实现平坦内存模型(Flat Model)。这使得内核在保护模式下无需关心段基址计算,直接使用 32 位线性地址即可访问整个 4GB 空间。
4.2 GDT 的临时设置与加载
在切换到保护模式之前,引导代码必须在内存中构建一个临时的 GDT。该 GDT 通常放置在引导扇区或引导加载程序的数据区域中。加载 GDT 使用 lgdt 指令:
/* 临时 GDT 定义(位于引导代码数据段) */
gdt:
.quad 0x0000000000000000 /* 空描述符 */
.quad 0x00CF9A000000FFFF /* 内核代码段:基址 0,限长 4GB,代码/读,DPL=0 */
.quad 0x00CF92000000FFFF /* 内核数据段:基址 0,限长 4GB,数据/读写,DPL=0 */
gdt_ptr:
.word (gdt_ptr - gdt - 1) /* GDT 限长 */
.long gdt /* GDT 基址 */
/* 加载 GDT */
lgdt gdt_ptr
描述符字节详解(以内核代码段 0x00CF9A000000FFFF 为例):
- 段限长(低 16 位):0xFFFF
- 基址(低 24 位):0x000000
- 类型/属性(第 5 字节):0x9A → P=1, DPL=00, S=1, 代码段, 可读, 不可访问
- 标志/限长高 4 位(第 6 字节高 4 位):0xC → G=1(4KB 粒度), D/B=1(32 位), L=0, AVL=0
- 基址(高 8 位):0x00
4.3 CR0 寄存器控制:保护模式使能开关
CR0 是 CPU 的控制寄存器,其第 0 位(PE 位,Protection Enable)控制 CPU 是否运行在保护模式。切换流程如下:
- 关闭中断:使用
cli指令,防止在切换过程中发生中断导致不可预测的行为。 - 加载 GDT:使用
lgdt指令加载临时 GDT。 - 设置 CR0.PE 位:将 CR0 寄存器的第 0 位置 1。
- 远跳转刷新 CS:执行一个远跳转(far jump)或远调用,强制 CPU 重新加载代码段选择子,使保护模式生效。
- 更新段寄存器:将数据段寄存器(DS、ES、FS、GS、SS)加载为保护模式下的数据段选择子。
/* 切换到保护模式(x86 汇编) */
cli /* 关闭中断 */
lgdt gdt_ptr /* 加载临时 GDT */
movl %cr0, %eax
orl $0x1, %eax /* 设置 CR0 的 PE 位 */
movl %eax, %cr0
/* 远跳转刷新 CS:跳转到保护模式代码段 */
ljmp $0x08, $protected_mode_entry
protected_mode_entry:
/* 现在 CPU 运行在 32 位保护模式 */
movw $0x10, %ax /* 加载内核数据段选择子 */
movw %ax, %ds
movw %ax, %es
movw %ax, %fs
movw %ax, %gs
movw %ax, %ss
/* 设置栈指针 */
movl $stack_top, %esp
/* 可以开启中断 */
sti
注意事项: 在设置 CR0.PE 位之后、远跳转之前,CPU 实际上已经处于保护模式,但 CS 寄存器仍然保留着实模式下的段基址。如果不立即执行远跳转,下一条指令的取指将使用实模式的段基址计算方式,导致指令地址错误。因此,
ljmp 必须在设置 CR0 后的下一条指令立即执行,中间不能插入任何其他指令。
4.4 保护模式切换完整流程 SVG 图
4.5 实模式与保护模式关键差异对比
| 特性 | 实模式 | 保护模式 |
|---|---|---|
| 地址计算方式 | 段基址 × 16 + 偏移量 | 段选择子 → GDT → 段描述符 → 基址 + 偏移量 |
| 段基址来源 | 段寄存器直接存储 16 位基址 | 段寄存器存储选择子,基址从 GDT 描述符获取 |
| 可访问地址空间 | 1MB(0x00000–0xFFFFF) | 4GB(0x00000000–0xFFFFFFFF) |
| 段限长检查 | 无 | 有,违反则触发 #GP 异常 |
| 特权级保护 | 无 | 4 级特权(Ring 0–3) |
| 中断处理 | 中断向量表(IVT)固定地址 0x00000 | 中断描述符表(IDT)可重定位 |
| 分页支持 | 不支持 | 支持(通过 CR0.PG 位启用) |
4.6 保护模式切换后的内存布局变化
切换完成后,CPU 的地址空间模型发生根本性变化。下图展示了切换前后内存访问方式的对比:
4.7 常见问题与避坑指南
避坑 1:GDT 必须包含空描述符作为第一个条目。 Intel 手册规定 GDT 的第一个描述符必须是空描述符(全零),否则在某些 CPU 上会导致 #GP 异常。Linux 引导代码始终遵守此规则。
避坑 2:远跳转的目标地址必须使用保护模式段选择子。 在
ljmp $0x08, $protected_mode_entry 中,0x08 是保护模式下的代码段选择子,而不是实模式下的段基址。如果误用实模式段值,CPU 会将其解释为选择子,导致加载错误的段描述符。
调试技巧: 如果切换后系统立即崩溃,可以使用 Bochs 或 QEMU 的调试功能检查 CR0 寄存器的值、GDT 的内容以及 CS 选择子是否加载正确。在 QEMU 中,使用
-d int,cpu_reset 参数可以捕获异常和重置事件。
4.8 本章小结
从实模式到保护模式的切换是 Linux 内核启动过程中一个不可逆的转折点。通过设置 GDT、加载 GDTR、置位 CR0.PE 并执行远跳转,CPU 从 16 位分段寻址模式切换到 32 位平坦内存模型。这一切换为后续的内核解压、分页初始化以及进程管理奠定了硬件基础。理解 GDT 描述符的结构、CR0 控制位的语义以及切换指令的执行顺序,是分析内核早期启动代码的必备知识。