4. 进入保护模式:从实模式到保护模式的切换、GDT/LDT的初始化、A20地址线的开启
好,咱们继续往下走。上一章我们把内核镜像从磁盘搬到了内存里,现在CPU还活在实模式那个“小房间”里。接下来要干的事,就是把它拽进保护模式这个“大别墅”。
实模式下的CPU,说白了就是个16位的古董。寻址空间只有1MB,而且没有权限保护——任何代码都能访问任何内存。这在单任务时代还行,但你要跑现代操作系统?门儿都没有。保护模式提供了32位地址线、4GB寻址空间,还有特权级保护。嗯,这一步是必须迈过去的坎儿。
4.1 为什么非要折腾这一步?
我刚开始学内核时,也觉得实模式挺好用的。直到有一次我在调试一个驱动,发现它莫名其妙地覆盖了中断向量表——因为实模式下谁都能写那个区域。那次排查花了我整整两天。从那以后,我深刻理解了保护模式的意义:没有保护,就没有安全。
保护模式带来的核心能力有三个:
- 32位寻址:告别1MB的束缚,直接访问4GB空间
- 特权级保护:Ring 0到Ring 3,内核和用户程序各玩各的
- 虚拟内存基础:分页机制的前提就是保护模式
说白了,没有保护模式,Linux根本跑不起来。你想想看,用户程序随便改内核数据,那还得了?
4.2 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也开了,现在可以正式切换了。这一步其实不复杂,但顺序很重要。我见过有人把顺序搞反,结果系统直接死机。
正确的切换步骤:
- 关中断(CLI)
- 加载GDT(LGDT指令)
- 设置CR0寄存器的PE位(保护模式使能位)
- 执行远跳转(刷新CPU流水线)
- 更新段寄存器(DS、ES、FS、GS、SS)
- 设置栈指针(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把逻辑地址转成线性地址,再通过分页(如果开启)转成物理地址。在启动阶段,我们通常先关掉分页,让线性地址直接等于物理地址,等内存管理初始化完成后再开启。
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