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(近跳)。一旦用近跳,流水线里残留的实模式指令可能继续执行,直接导致崩溃。我曾经在一个项目里看到一位同事写的就是近跳,结果机器每次重启时机都不对,后来加了个远跳就稳定了。

警告:切换CR0.PE后,必须立即执行一个跨段跳转(远跳或远调用),刷新指令预取队列。否则CPU可能用实模式的方式解码保护模式的指令,造成#GP异常。

4.3 保护模式到长模式的桥梁

要进入IA-32e模式(64位模式),需要先切到保护模式,再设置EFER.LME位,然后启用分页并加载4级页表,最后通过修改CR0.PG激活长模式。流程像是嵌套的俄罗斯套娃。

看这张图,我把整个调用链整理出来了:

BootLoader CPU模式切换流程 实模式(Real Mode) 加载GDT → 设置CR0.PE 远跳刷新流水线 32位保护模式 设置EFER.LME = 1 加载4级页表 设置CR0.PG → 启用分页 CPU自动进入长模式 IA-32e 长模式 关键同步点 • EFER.LME必须在 分页激活前设置 • 页表基址(CR3) 必须指向有效4级页表 • 切换后CS必须用 长模式兼容的段描述符

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。嗯,这个组合很容易写错,我建议你把它做成一个宏,避免手写。

技巧:在BootLoader里,我习惯把长模式代码段描述符定义为: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。一次典型的初始化流程:

  1. 在内存中分配4K对齐的空间(通常在BootLoader的BSS后或固定地址)
  2. 清零所有页表项
  3. 映射至少一个可执行的代码区域(我吃过亏:只映射了数据页,跳转后直接#PF)
  4. 设置CR3指向PML4基址
  5. 检查CPU是否支持NX、WP等特性,适当设置EFER.NXE

我曾经在一个UEFI BootLoader里犯过一个低级错误:页表基址写成了物理地址,但CR3需要的是物理地址没错,然而我拿的是虚拟地址……后来用mov cr3, eax后开始分页,立马重启。排查时发现我取地址时忘了减掉KASLR偏移。唉,那两天净盯着页表看了。

重要:长模式下的页表项必须设置NX位(bit 63)以防止代码页被当作数据页执行。另外,WP位(CR0 bit 16)建议置1,否则用户态能篡改只读页表——这是现代OS的安全基线。

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模式切换部分就算稳了。