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、引导加载程序早期 | 操作系统内核 |
切换过程其实不复杂,但顺序不能错。我习惯把它分成三步走:
- 准备GDT:在内存里建好全局描述符表。
- 加载GDTR:用
lgdt指令把GDT的地址和大小告诉CPU。 - 设置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切换,每一步都是环环相扣的。
嗯,到这里,CPU已经从实模式成功切换到了保护模式。接下来,它就可以在32位的“法治社会”里,从容地加载操作系统内核了。这个过程虽然只有几步,但每一步都藏着无数细节。我建议你亲手写一遍切换代码,哪怕只是在模拟器里跑一跑——相信我,踩过坑之后,你才能真正理解这些寄存器的作用。