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 是否运行在保护模式。切换流程如下:

  1. 关闭中断:使用 cli 指令,防止在切换过程中发生中断导致不可预测的行为。
  2. 加载 GDT:使用 lgdt 指令加载临时 GDT。
  3. 设置 CR0.PE 位:将 CR0 寄存器的第 0 位置 1。
  4. 远跳转刷新 CS:执行一个远跳转(far jump)或远调用,强制 CPU 重新加载代码段选择子,使保护模式生效。
  5. 更新段寄存器:将数据段寄存器(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 图

保护模式切换完整流程 实模式准备 cli 关闭中断 lgdt 加载 GDT 设置 CR0.PE = 1 ljmp 远跳转刷新 CS 加载 DS/ES/SS 等 设置 ESP 栈指针 关键约束 • ljmp 必须紧接 CR0 设置 • 中间不能插入其他指令 • 否则取指地址错误

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 的地址空间模型发生根本性变化。下图展示了切换前后内存访问方式的对比:

实模式 vs 保护模式内存访问模型 实模式地址计算 段寄存器 CS × 16 偏移量 IP 物理地址 = 段基址 + 偏移 地址空间:1MB(0x00000–0xFFFFF) 无保护、无分页、无特权级 保护模式地址计算 段选择子 CS GDT 描述符 线性地址 = 基址 + 偏移 地址空间:4GB(0x00000000–0xFFFFFFFF) 段限长检查、特权级保护、可选分页

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 控制位的语义以及切换指令的执行顺序,是分析内核早期启动代码的必备知识。