B04 —— 从实模式到保护模式:GDT 和 CR0 的设置

金句:保护模式不是”保护”内存,而是让 CPU 能够管理更复杂的内存访问规则。


实模式的局限

CPU 复位后默认是 实模式(Real Mode),这是 8086 时代的工作模式。实模式有两个严重限制:

Bash
实模式的问题:

1. 地址空间只有 1MB(20-bit 地址)
   - 段寄存器 × 16 + 偏移量 = 物理地址
   - 0xFFFF:0xFFFF = 0x10FFEF(实际是 1MB + 64KB - 16B)
   - 无法访问超过 1MB 的内存

2. 没有内存保护
   - 任何代码可以访问任何内存地址
   - 没有特权级(Privilege Level)
   - 恶意代码可以覆盖 BIOS 所在的 ROM

保护模式(Protected Mode)解决了这两个问题:
  - 32-bit 地址空间(4GB)
  - 特权级(Ring 0~Ring 3)
  - 分页(Paging)支持虚拟内存

保护模式的核心:GDT

GDT(Global Descriptor Table) 是保护模式的核心数据结构,它定义了"段"的属性。

Bash
GDT 入口(Segment Descriptor,8 bytes):

 6356  |  55 | 54 | 53 | 52 | 4744 | 4340 | 3936 | 3532
 Base[31:24] | G  |DB |L  |AVL| Limit[19:16] | P  | DPL | S  | Type | Base[23:16]

 3116                                      150
 Base[15:0]                                    Limit[15:0]

字段解释:
  Base(24-bit32-bit):段基址
  Limit(20-bit):段大小(粒度 G=1 时单位是 4KB)
  P(Present):段是否在内存中(必须=1)
  DPL(Descriptor Privilege Level):特权级(0~3)
  S(System):系统段(0)还是普通段(1)
  Type(4-bit):段的类型(可读/写/执行,扩展方向)
  G(Granularity):0=字节,1=4KB
  DB(Default Size):0=16-bit1=32-bit(段寄存器大小)

GDT 的最小配置(3 个段)

Bash
Linux 内核早期使用的临时 GDT:

  // 伪描述符结构
  struct gdt_page {
      struct desc_struct gdt[16];
  } __attribute__((aligned(4096)));

  // GDT 初始化
  gdtr.base = (unsigned long)gdt;  // GDT 基址
  gdtr.limit = sizeof(gdt) - 1;   // GDT 大小

  // 三个必须的定义段:

  0: NULL descriptor(必须)
  1: KERNEL CODE (Ring 0, 4GB flat, 0xCF9A)
  2: KERNEL DATA (Ring 0, 4GB flat, 0xCF92)

  // 0xCF9A 的含义(Code Segment, DPL=0):
  // P=1, DPL=00, S=1, Type=0xA(可执行/可读/RTU)
  // G=1, DB=1, L=0, AVL=0
  // Limit=0xFFFFF(4GB)

  // 0xCF92 的含义(Data Segment, DPL=0):
  // P=1, DPL=00, S=1, Type=0x2(可读写/RTU)
  // G=1, DB=1, L=0, AVL=0
  // Limit=0xFFFFF(4GB)

段选择子(Selector)

代码用 段选择子(Selector) 来引用 GDT 中的段:

Bash
段选择子(16-bit)的结构:

  15─────────3  2  1─────────0
  Index          TI  RPL

  Index:GDT 中的索引(×8 = 偏移量)
  TI:0=GDT, 1=LDT(Local Descriptor Table)
  RPL:Requested Privilege Level(请求特权级)

例子:

  Selector = 0x10(二进制 0001 0000)

  Index = 0b00010 = 2
  TI = 0(GDT)
  RPL = 0(Ring 0)

  → 引用 GDT[2],即 KERNEL DATA 段

  Selector = 0x08(二进制 0000 1000)

  Index = 0b00001 = 1
  → 引用 GDT[1],即 KERNEL CODE 段

CR0:控制寄存器的切换

从实模式切换到保护模式,需要设置 CR0(Control Register 0)

Bash
CR0 寄存器(32-bit):

  Bit 31(PG):Paging Enable
  Bit 30(PDE):Page Directory Extensions(已废弃)
  Bit 29(CD):Cache Disable
  Bit 28(NW):Not Write-through
  Bit 18(AM):Alignment Mask
  Bit 05(NA):Numeric Assistance
  Bit 04(WP):Write Protect(User 不能写 Kernel 页面)
  Bit 03(SN):Supervisor Mode Ignore Numeric Exception
  Bit 02(ET):Math Coprocessor Extension Type(1=387, 0=287)
  Bit 01(TS):Task Switched(协处理器上下文切换)
  Bit 00(PE):Protection Enable ← 关键位!

设置 Protection Enable(切换到保护模式):

  ; 假设 GDT 已经建好

  lgdt [gdtr]     ; 加载 GDT(GDTR 寄存器)

  mov eax, cr0
  or eax, 0x00000001   ; 设置 PE 位
  mov cr0, eax          ; 切换到保护模式!

  ; 此时 CS 还是实模式的段
  ; 需要用 far jmp 来刷新流水线并加载新的 CS

  jmp 0x08:flush_cs    ; Selector=0x08(KERNEL CODE),跳到新 CS

flush_cs:
  ; 现在 CS = 0x08(GDT[1] = KERNEL CODE,Ring 0,4GB flat)
  ; 接下来可以跳到 32-bit 保护模式代码

分页(Paging)的开启

保护模式还支持 分页(Paging),这是现代操作系统的虚拟内存基础:

Bash
分页的开启顺序:

1. 先建立页表(页目录/页表/页全局目录)
2. 设置 CR3 寄存器(指向页目录)
3. 设置 CR0.PG 位(开启分页)

页表结构(x86-64):

  PML4(Page Map Level 4)→ PDPT → PD → PT
  每一级都是 512 个条目(每个条目 8 bytes)

  CR3 指向 PML4 的基址(PML4 Base Address)

x86-64 Linux 使用:
  47-bit 虚拟地址
  页面大小 4KB
  四级页表(PML4 → PDPT → PD → PT)

开启分页:

  ; 假设页表已经建立,页表基址在 init_top_pgt

  mov eax, 0x83    ; PG=1, RW=1, US=1(用户可访问)
  mov [init_top_pgt], eax

  mov eax, init_top_pgt
  and eax, ~0xFFF  ; 对齐到页边界
  mov cr3, eax     ; CR3 = 页目录基址

  mov eax, cr0
  or eax, 0x80000000   ; 设置 PG 位
  mov cr0, eax         ; 开启分页!

  jmp 1f
1:
  ; 现在虚拟地址空间工作了
  ; 线性地址会通过页表翻译成物理地址

完整的模式切换代码

Bash
从实模式到保护模式 + 分页的完整代码:

  section .text
  global _start

_start:
  ; 1. 关闭中断
  cli

  ; 2. 加载临时 GDT
  lgdt [gdt_pointer]

  ; 3. 设置 CR0.PE = 1(开启保护模式)
  mov eax, cr0
  or eax, 0x00000001
  mov cr0, eax

  ; 4. Far Jmp 刷新 CS 并跳转到 32-bit 代码
  jmp 0x08:protected_mode

protected_mode:
  ; 5. 设置数据段寄存器
  mov ax, 0x10        ; Selector = KERNEL DATA
  mov ds, ax
  mov es, ax
  mov fs, ax
  mov gs, ax
  mov ss, ax

  ; 6. 设置栈
  mov esp, stack_top  ; 栈顶在 boot_stack

  ; 7. 设置 CR3,开启分页
  mov eax, page_table_base
  mov cr3, eax

  mov eax, cr0
  or eax, 0x80000000  ; 设置 PG 位
  mov cr0, eax

  ; 8. 跳入更高层的内核代码
  jmp start_kernel

  ; GDT 定义
align 4
gdt:
  dq 0x0000000000000000  ; GDT[0] = NULL
  dq 0x00CF9A000000FFFF  ; GDT[1] = KERNEL CODE
  dq 0x00CF92000000FFFF  ; GDT[2] = KERNEL DATA

gdt_pointer:
  dw gdt_pointer - gdt - 1  ; Limit
  dd gdt                     ; Base

page_table_base: equ 0x1000  ; 早期页表放在 0x1000
stack_top: equ 0x8000        ; 早期栈顶

总结

  • 实模式限制:1MB 地址空间,无内存保护
  • GDT:Global Descriptor Table,8-byte 段描述符,定义基址/限长/类型/特权级
  • 段选择子:16-bit = Index(×8)+ TI(0=GDT)+ RPL(请求特权级)
  • CR0.PE:开启保护模式(Protection Enable),从实模式切换到保护模式
  • Far Jmp:切换保护模式后必须 far jmp 刷新 CS,否则流水线还是旧的
  • CR0.PG:开启分页(Paging),虚拟地址通过页表翻译成物理地址

下篇预告(B05):MMU(内存管理单元)是怎么把虚拟地址翻译成物理地址的?一级页表、多级页表、以及 TLB(Translation Lookaside Buffer)缓存。


关注公众号「AI不着急」,回复”资料”获取内存学习路线图。

最后修改: 2024年9月21日

作者

评论

发表评论

您的邮箱地址不会被公开。