4. 引导加载程序(BootLoader)中的CPU模式切换
说真的,写BootLoader最头疼的部分,就是跟CPU模式切换打交道。你从一个上电后的默认状态,一路要跳到保护模式、长模式,每一步都像在走钢丝。我早年调试过一个嵌入式Linux系统,因为漏了一条模式切换的同步指令,结果系统死在“跳转后第一条指令”上,查了整整两天——嗯,从此我对这个流程就特别较真。
4.1 为什么要切换模式?
CPU上电后,大部分架构会进入一个“最安全”但功能极有限的初始模式。比如x86的实模式,只有20位地址线,连保护机制都没有。你想想看,要运行现代操作系统,不开分页、不开特权级,连4GB内存都访问不全,这怎么行?所以BootLoader必须一步步引导CPU进入更高级的模式。
模式切换本质上就是修改CPU内部的控制寄存器(比如CR0、CR4)和MSR(Model-Specific Registers)。但光改寄存器不行,还得配合指令流、段描述符、页表。我习惯把整个过程拆成三个核心阶段:
- 实模式 → 保护模式:开启分段、分页(可选),获得32位地址空间
- 保护模式 → 长模式:开启IA-32e模式,激活4级页表,进入64位
- 长模式内部切换:切换CPL、栈段、页表基址等运行时调整
4.2 实模式到保护模式的切换细节
这是我个人认为最容易出BUG的一步。先看代码片段:
; 切换到保护模式
cli ; 关闭中断
lgdt [gdt_descriptor] ; 加载GDT
mov eax, cr0
or eax, 1 ; 设置CR0的PE位
mov cr0, eax
jmp CODE_SEG:flush_pm ; 远跳刷新流水线
[bits 32]
flush_pm:
mov ax, DATA_SEG
mov ds, ax
mov es, ax
; ... 设置其余段寄存器
注意那个jmp,很多新手会写成jmp flush_pm(近跳)。一旦用近跳,流水线里残留的实模式指令可能继续执行,直接导致崩溃。我曾经在一个项目里看到一位同事写的就是近跳,结果机器每次重启时机都不对,后来加了个远跳就稳定了。
4.3 保护模式到长模式的桥梁
要进入IA-32e模式(64位模式),需要先切到保护模式,再设置EFER.LME位,然后启用分页并加载4级页表,最后通过修改CR0.PG激活长模式。流程像是嵌套的俄罗斯套娃。
看这张图,我把整个调用链整理出来了:
4.4 段描述符的“坑”
模式切换时,GDT里的段描述符必须适配目标模式。我记得有一回,一位伙伴在进入长模式前忘了把代码段描述符的L位(长模式标志)置1,结果CPU一直认为还在32位兼容模式,指令解码全乱套。这个L位在段描述符的第21位(对应GDT entry的bit 21)——说白了就是:
| 段类型 | 描述符关键位 | 32位保护模式 | 64位长模式 |
|---|---|---|---|
| 代码段 | L (bit 21) | 0 | 1 |
| 代码段 | D (bit 22) | 1 | 0(忽略) |
| 数据段 | D/B (bit 22) | 1 | 0 |
| 代码/数据 | P (bit 15) | 1 | 1 |
你如果只是在保护模式下跑,L=0没问题。但要从保护模式往长模式跳,代码段的描述符必须设L=1且D=0。嗯,这个组合很容易写错,我建议你把它做成一个宏,避免手写。
0x0020980000000000(G=1, L=1, DPL=0, P=1, S=1, TYPE=0xA)。解析一下:bit 53是L,bit 54是D,bit 43是S……用宏记比记裸十六进制稳得多。
4.5 分页初始化——避坑指南
长模式强制使用4级页表(如果支持5级,那就是5级)。页表本身由4K对齐的结构组成:PML4、PDPT、PD、PT。一次典型的初始化流程:
- 在内存中分配4K对齐的空间(通常在BootLoader的BSS后或固定地址)
- 清零所有页表项
- 映射至少一个可执行的代码区域(我吃过亏:只映射了数据页,跳转后直接#PF)
- 设置CR3指向PML4基址
- 检查CPU是否支持NX、WP等特性,适当设置EFER.NXE
我曾经在一个UEFI BootLoader里犯过一个低级错误:页表基址写成了物理地址,但CR3需要的是物理地址没错,然而我拿的是虚拟地址……后来用mov cr3, eax后开始分页,立马重启。排查时发现我取地址时忘了减掉KASLR偏移。唉,那两天净盯着页表看了。
4.6 模式切换后的“软着陆”
模式切换完成后,并不是直接跳进C代码就完事了。还得注意三点:
- 栈指针:必须切换到一个可读写且映射了的地址。我习惯在BootLoader开始时就预留一段内存作为初始栈。
- IDT/异常处理:至少注册一个#PF和#GP的处理入口,不然分页出错直接三重启。
- GDT的TSS段:如果要支持任务切换或特权级提升,TSS必须设置。不过大部分单核BootLoader不需要,可以跳过。
说白了,模式切换只是万里长征的第一步。我个人习惯在切换成功后,先写一句mov rax, 0xdeadbeef到某个已知的MMIO地址,用逻辑分析仪确认信号,再继续往下走。这个方法虽然原始,但比打印字符串可靠得多——在Boot阶段串口可能还没初始化呢。
4.7 汇总对比
我把三种主要模式的切换成本、关键寄存器变化列成一张表,方便你照葫芦画瓢:
| 切换方向 | 影响寄存器 | 必须执行的操作 | 常见失败原因 |
|---|---|---|---|
| 实模式→保护模式 | CR0.PE, GDTR, CS/DS/SS | 加载GDT → 远跳 → 重设段寄存器 | 漏了远跳,或段描述符的DPL不匹配 |
| 保护模式→长模式 | EFER.LME, CR0.PG, CR3, CR4 | 置LME → 建页表 → 置PG → 远跳 | 页表未映射跳转地址,或CS段未设L=1 |
| 长模式内部CPL切换 | CS.RPL, SS, RSP0/1/2 | 通过中断门或调用门改变特权级 | TSS的RSP0未正确设置,导致栈错误 |
嗯,回到最开始说的——模式切换就像在玩“多米诺骨牌”。只要第一步加载GDT的地址写错了,后续全崩。我建议你写代码时,每一步都加一个hlt或死循环做锚点,分阶段验证。别一次写完几百行直接跑,那个调试体验……我不说你也懂。
最后顺手留个原则:先验证,后推进;先映射,后跳转。搞定了这些,BootLoader的CPU模式切换部分就算稳了。