4. 上电复位与CPU初始化:从Reset Vector到保护模式

好,咱们正式开始聊启动流程里最底层的部分——CPU是怎么从一片空白,变成能执行操作系统的。你按下电源键的那一刻,CPU其实还处于“混沌”状态。它需要一套固定的流程来唤醒自己。这套流程,就是本章要讲的核心。

4.1 Reset Vector:CPU的第一口“奶”

CPU上电后,第一件事是复位。复位后,它会去一个固定的地址取第一条指令。这个地址,就是Reset Vector。

对于x86架构,这个地址是 0xFFFFFFF0(在32位模式下)。为什么是这个地址?因为CPU设计者把这片区域映射到了BIOS ROM里。说白了,CPU一醒来,就跑到BIOS的门口敲门:“快给我指令!”

关键点:Reset Vector里存的通常是一条跳转指令(jmp),跳转到BIOS真正的启动代码入口。它本身不干实事,就是个“引路人”。

我在调试一块老式工控板时,遇到过Reset Vector被意外改写的情况。结果CPU上电后直接飞了,连BIOS都进不去。嗯,那会儿我才意识到,这个地址是硬件写死的,但ROM里的内容可以被篡改——所以现在很多板子都会做写保护。

4.2 从实模式到保护模式:一次“降维打击”

CPU刚启动时,工作在实模式。这个模式是Intel为了兼容8086留下的老古董。实模式下,CPU只能访问1MB内存,而且没有权限保护——任何程序都能访问任何地址。你想想看,这要是跑现代操作系统,岂不是乱套了?

所以,操作系统加载前,必须把CPU切换到保护模式。保护模式下,CPU能访问4GB内存(32位),而且有了特权级、分段、分页等机制。说白了,就是从“原始社会”进入了“法治社会”。

特性 实模式 保护模式
地址宽度 20位(1MB) 32位(4GB)
段寄存器用法 段基址直接左移4位 段选择子查GDT
权限保护 无 有(4个特权级)
典型用途 BIOS、引导加载程序早期 操作系统内核

切换过程其实不复杂,但顺序不能错。我习惯把它分成三步走:

  1. 准备GDT:在内存里建好全局描述符表。
  2. 加载GDTR:用 lgdt 指令把GDT的地址和大小告诉CPU。
  3. 设置CR0的PE位:把控制寄存器CR0的第0位置1,CPU就进入保护模式了。

注意:切换保护模式后,必须立即执行一条远跳转(far jmp)来刷新指令流水线。否则CPU可能还在用实模式的逻辑执行下一条指令,直接崩溃。我曾经在这个坑里趴了一整天……

4.3 GDT与分段机制:内存的“户籍系统”

保护模式下的分段,靠的是全局描述符表(GDT)。你可以把GDT想象成一本户籍册,每个段(代码段、数据段、栈段)都在里面有登记。段寄存器里存的不是基地址,而是一个索引——叫段选择子。CPU拿着这个索引去GDT里查,找到对应的段描述符,才知道这个段的基地址、大小、权限。

一个段描述符占8字节,结构如下:

位域 含义
Base 0-15 段基地址低16位
Base 16-23 段基地址中8位
Base 24-31 段基地址高8位
Limit 0-15 段界限低16位
Limit 16-19 段界限高4位
Flags 粒度、默认操作大小等
Access Byte 段类型、特权级、存在位等

我个人习惯在启动代码里至少定义三个段描述符:

  • 空描述符(索引0):必须全0,CPU硬性要求。
  • 代码段描述符:可执行、可读,特权级0。
  • 数据段描述符:可读写,特权级0。

下面是一个典型的GDT定义示例(NASM语法):

; GDT 起始标签
gdt_start:

; 空描述符(必须)
gdt_null:
    dd 0x00000000
    dd 0x00000000

; 代码段描述符
gdt_code:
    dw 0xFFFF        ; Limit 0-15
    dw 0x0000        ; Base 0-15
    db 0x00          ; Base 16-23
    db 10011010b     ; Access Byte: Present, DPL=0, Code, Execute/Read
    db 11001111b     ; Flags: 4KB granularity, 32-bit
    db 0x00          ; Base 24-31

; 数据段描述符
gdt_data:
    dw 0xFFFF        ; Limit 0-15
    dw 0x0000        ; Base 0-15
    db 0x00          ; Base 16-23
    db 10010010b     ; Access Byte: Present, DPL=0, Data, Read/Write
    db 11001111b     ; Flags: 4KB granularity, 32-bit
    db 0x00          ; Base 24-31

gdt_end:

; GDT描述符(给lgdt用)
gdt_descriptor:
    dw gdt_end - gdt_start - 1  ; GDT大小减1
    dd gdt_start                ; GDT基地址

小技巧:GDT的基地址必须放在内存里,而且要在切换保护模式前加载好。我一般把它放在引导扇区的末尾,确保不会被后续代码覆盖。

4.4 CR0寄存器的PE位:那临门一脚

所有准备工作做完后,最后一步就是设置CR0寄存器的PE位(Protection Enable,位0)。

CR0是一个32位的控制寄存器,它的每一位都控制着CPU的一种工作模式。PE位就是那个“开关”——置1,进入保护模式;清0,回到实模式。

切换代码通常长这样:

; 假设我们已经加载了GDT
mov eax, cr0
or eax, 0x1        ; 设置PE位
mov cr0, eax

; 立即远跳转,刷新流水线
jmp 0x08:flush     ; 0x08是代码段选择子(索引1,RPL=0)

[bits 32]
flush:
    ; 现在已经在保护模式下了
    ; 重新加载数据段寄存器
    mov ax, 0x10    ; 数据段选择子(索引2,RPL=0)
    mov ds, ax
    mov es, ax
    mov ss, ax
    ; ... 继续执行

这里有个细节:jmp 0x08:flush 中的 0x08 是段选择子。它的二进制是 0000 0000 0000 1000,其中:

  • 位0-1:RPL(请求特权级),这里是00(特权级0)。
  • 位2:TI(表指示符),0表示GDT。
  • 位3-15:索引,这里是1(指向代码段描述符)。

所以 0x08 就是“GDT中索引为1的段描述符,特权级0”。

我曾经犯过的错:有一次我忘了在切换后重新加载 ds、es、ss,结果后续的内存访问全用的是实模式下的段基址,数据全写歪了。调试了整整一个下午才发现——嗯,从那以后,我每次切换完都会立即刷新所有段寄存器。

4.5 本章知识体系总览

下面这张图把整个流程串起来了。你可以看到,从Reset Vector开始,到GDT准备,再到CR0切换,每一步都是环环相扣的。

上电复位到保护模式切换流程 上电复位 Reset Vector (0xFFFFFFF0) BIOS 启动代码 准备GDT并加载GDTR 设置CR0.PE = 1 远跳转 + 刷新段寄存器 → 保护模式 注意 GDT必须在切换前 准备好,且不能跨越 页边界。

嗯,到这里,CPU已经从实模式成功切换到了保护模式。接下来,它就可以在32位的“法治社会”里,从容地加载操作系统内核了。这个过程虽然只有几步,但每一步都藏着无数细节。我建议你亲手写一遍切换代码,哪怕只是在模拟器里跑一跑——相信我,踩过坑之后,你才能真正理解这些寄存器的作用。