4. 进入保护模式:从实模式到保护模式的切换、GDT/LDT的初始化、A20地址线的开启

好,咱们继续往下走。上一章我们把内核镜像从磁盘搬到了内存里,现在CPU还活在实模式那个“小房间”里。接下来要干的事,就是把它拽进保护模式这个“大别墅”。

实模式下的CPU,说白了就是个16位的古董。寻址空间只有1MB,而且没有权限保护——任何代码都能访问任何内存。这在单任务时代还行,但你要跑现代操作系统?门儿都没有。保护模式提供了32位地址线、4GB寻址空间,还有特权级保护。嗯,这一步是必须迈过去的坎儿。

4.1 为什么非要折腾这一步?

我刚开始学内核时,也觉得实模式挺好用的。直到有一次我在调试一个驱动,发现它莫名其妙地覆盖了中断向量表——因为实模式下谁都能写那个区域。那次排查花了我整整两天。从那以后,我深刻理解了保护模式的意义:没有保护,就没有安全

保护模式带来的核心能力有三个:

  • 32位寻址:告别1MB的束缚,直接访问4GB空间
  • 特权级保护:Ring 0到Ring 3,内核和用户程序各玩各的
  • 虚拟内存基础:分页机制的前提就是保护模式

说白了,没有保护模式,Linux根本跑不起来。你想想看,用户程序随便改内核数据,那还得了?

4.2 A20地址线:一个历史遗留的坑

在开启保护模式之前,有个绕不开的步骤——打开A20地址线。这玩意儿是历史遗留问题,但你不处理它,系统就会出诡异的问题。

⚠️ 避坑指南: 我曾经在一个老旧的PC上调试启动代码,忘记开A20,结果访问1MB以上的内存时,地址被回卷到了0MB附近。那个bug查了我一整天,最后发现是A20没开。嗯,从那以后我写启动代码第一件事就是检查A20状态。

A20地址线的问题源于8086处理器的设计。当时只有20根地址线,访问超过1MB的地址时,地址会自动回卷。到了80286,地址线变成了24根,但为了兼容老软件,默认A20是关闭的。所以我们需要手动开启它。

开启A20的常见方法有三种:

方法 操作方式 适用场景
键盘控制器 向0x64端口发命令,再向0x60端口写数据 最通用,几乎所有PC都支持
系统控制端口A 读取0x92端口,置位第1位后再写回 较新的主板,简单快速
BIOS中断 调用int 15h,AX=2401h 实模式下可用,但进入保护模式后失效

我个人习惯用键盘控制器方式,虽然代码多几行,但兼容性最好。来看看代码:

; 开启A20地址线(键盘控制器方式)
enable_a20:
    cli                     ; 关中断,防止干扰
    
    call    wait_kbc        ; 等待键盘控制器空闲
    mov     al, 0xD1        ; 写命令:准备向0x60端口输出数据
    out     0x64, al
    
    call    wait_kbc
    mov     al, 0xDF        ; 数据:开启A20(置位第1位)
    out     0x60, al
    
    call    wait_kbc
    sti                     ; 开中断
    ret

wait_kbc:
    in      al, 0x64        ; 读取状态寄存器
    test    al, 0x02        ; 检查输入缓冲区是否满
    jnz     wait_kbc        ; 如果满,继续等待
    ret

这段代码的核心逻辑很简单:告诉键盘控制器我要发数据,然后把开启A20的指令发过去。注意那个0xDF,它的二进制是11011111,第1位是1——就是这一位控制着A20的开关。

4.3 GDT:保护模式的“户口本”

开启A20之后,下一步就是准备GDT(全局描述符表)。我把它理解成保护模式的“户口本”——每个内存段是什么属性,从哪里到哪里,谁有权访问,全写在这个表里。

GDT里的每个条目叫“描述符”,占8个字节。一个描述符定义了:

  • 基地址:段的起始位置(32位)
  • 段界限:段的大小(20位)
  • 属性:特权级、读写权限、代码/数据等

你可能会问:为什么不用LDT?LDT(局部描述符表)是每个任务私有的,而GDT是全局共享的。在启动阶段,我们只需要GDT就够了。LDT是后面多任务切换时才用到的。

💡 关键点: 保护模式下,CPU不再直接使用物理地址。所有地址访问都要通过“段选择子 + 偏移量”的方式,段选择子指向GDT中的某个描述符,描述符里存着真正的基地址。这就是保护模式“保护”二字的由来——你不能随便访问内存,得先有“户口”。

来看看GDT的初始化代码。我习惯定义一个最小化的GDT,包含三个描述符就够了:

; 定义GDT
gdt_start:
    ; 第一个描述符必须是空描述符(CPU强制要求)
    dd 0x00000000
    dd 0x00000000

; 代码段描述符:基地址0,界限4GB,Ring 0,可执行可读
gdt_code:
    dw 0xFFFF        ; 段界限低16位
    dw 0x0000        ; 基地址低16位
    db 0x00          ; 基地址中间8位
    db 10011010b     ; 属性:存在、Ring 0、代码段、可执行、可读
    db 11001111b     ; 属性:粒度4KB、32位、段界限高4位
    db 0x00          ; 基地址高8位

; 数据段描述符:基地址0,界限4GB,Ring 0,可读写
gdt_data:
    dw 0xFFFF
    dw 0x0000
    db 0x00
    db 10010010b     ; 属性:存在、Ring 0、数据段、可读写
    db 11001111b
    db 0x00

gdt_end:

; GDT描述符(告诉CPU GDT的位置和大小)
gdt_descriptor:
    dw gdt_end - gdt_start - 1  ; GDT大小减1
    dd gdt_start                 ; GDT基地址

注意那个属性字节的二进制位。我刚开始学的时候,总是记不住这些位的含义。后来我总结了一个规律:代码段是10011010,数据段是10010010。差就差在第3位——代码段可读,数据段可读写。嗯,记住这个区别就够了。

4.4 切换保护模式:临门一脚

GDT准备好了,A20也开了,现在可以正式切换了。这一步其实不复杂,但顺序很重要。我见过有人把顺序搞反,结果系统直接死机。

正确的切换步骤:

  1. 关中断(CLI)
  2. 加载GDT(LGDT指令)
  3. 设置CR0寄存器的PE位(保护模式使能位)
  4. 执行远跳转(刷新CPU流水线)
  5. 更新段寄存器(DS、ES、FS、GS、SS)
  6. 设置栈指针(ESP)

💡 个人经验: 第4步的远跳转很多人不理解。为什么非要跳转一下?因为CPU在执行CR0置位后,流水线里还缓存着实模式的指令。远跳转会清空流水线,让CPU重新以保护模式的方式取指令。不跳转的话,下一条指令可能被错误解码——我踩过这个坑。

来看看完整的切换代码:

; 切换到保护模式
switch_to_pm:
    cli                     ; 1. 关中断
    
    lgdt [gdt_descriptor]   ; 2. 加载GDT
    
    mov     eax, cr0
    or      eax, 0x1        ; 3. 设置PE位
    mov     cr0, eax
    
    ; 4. 远跳转,刷新流水线
    ; 0x08是代码段选择子(第1个描述符,索引1,Ring 0)
    jmp     0x08:pm_entry

[BITS 32]                   ; 告诉汇编器,现在是32位模式
pm_entry:
    ; 5. 更新数据段寄存器
    mov     ax, 0x10        ; 0x10是数据段选择子(索引2,Ring 0)
    mov     ds, ax
    mov     es, ax
    mov     fs, ax
    mov     gs, ax
    mov     ss, ax
    
    ; 6. 设置栈指针
    mov     esp, 0x90000    ; 临时栈,后面会重新设置
    
    ; 现在,我们正式在保护模式下运行了!
    call    kernel_main

注意那个[BITS 32]指令。它告诉汇编器,从这里开始生成32位指令。因为之前的代码是16位的,如果不切换,CPU会错误地解码32位指令——那结果就是乱码执行。

4.5 保护模式下的内存布局

切换完成后,内存的布局和实模式完全不同了。我画了一张图,帮你理解保护模式下的地址转换过程:

保护模式地址转换流程 逻辑地址 段选择子 : 偏移量 段选择子 GDT(全局描述符表) 索引 → 描述符(基地址、界限、属性) 例:0x08 → 索引1,Ring 0,基地址0 基地址 线性地址 基地址 + 偏移量 分页机制(可选,通常在保护模式之后开启) 线性地址 → 页目录 → 页表 → 物理地址 如果未开启分页,线性地址直接等于物理地址 物理地址(最终访问的内存位置) 范围:0x00000000 ~ 0xFFFFFFFF(4GB)

从这张图可以看出,保护模式下的地址转换分两步:先通过GDT把逻辑地址转成线性地址,再通过分页(如果开启)转成物理地址。在启动阶段,我们通常先关掉分页,让线性地址直接等于物理地址,等内存管理初始化完成后再开启。

4.6 常见问题与避坑

切换保护模式的过程中,有几个地方特别容易出问题。我根据自己的经验,列了几个最常见的坑:

  • GDT描述符的界限值:GDT描述符中的界限是“大小减1”。如果你设错了,CPU访问内存时会触发异常。我刚开始写的时候,总是忘记减1,结果系统一启动就重启。
  • 段选择子的格式:段选择子不是简单的索引号。它的低2位是请求特权级(RPL),第3位是表指示位(0=GDT,1=LDT)。所以代码段选择子0x08 = 二进制00001000,表示索引1、Ring 0、GDT。
  • 栈指针的设置:切换后要立即设置ESP。因为之前的栈还在实模式的地址空间里,保护模式下可能访问不到。我习惯设到0x90000,这个位置在实模式下的栈上方,不会冲突。
  • 中断的重新配置:进入保护模式后,实模式的中断向量表就失效了。在重新设置IDT(中断描述符表)之前,千万不要开中断。否则一个中断进来,CPU找不到处理函数,直接三重故障重启。

🔑 核心要点回顾:

  • A20地址线必须开启,否则1MB以上内存无法访问
  • GDT是保护模式的基石,至少需要代码段和数据段两个描述符
  • 切换保护模式的关键步骤:关中断→加载GDT→置位CR0→远跳转→更新段寄存器
  • 切换后立即设置栈指针,且不要立即开中断

好了,到这里我们已经成功从实模式切换到了保护模式。CPU现在可以访问4GB的内存空间,也有了基本的权限保护。下一件大事,就是建立页表、开启分页,让内核真正拥有虚拟内存的能力。不过那是后面章节的内容了,咱们先把这一步消化好。

如果你在切换过程中遇到问题,不妨回头检查一下GDT的每个字节——我敢打赌,八成是描述符的属性位写错了。嗯,我自己就犯过这种低级错误,而且不止一次。


公众号:蓝海资料掘金营,微信deep3321