很多朋友在学内核或者做操作系统课程设计的时候,第一个绕不开的坎就是 x86 保护模式。实模式下用段寄存器左移四位加偏移的套路,写点裸机 Demo 没问题,可一旦想实现多进程、隔离内存、来得及处理异常和中断,这套东西就完全不够用。x86 保护模式与内核之间的关系,本质上是"硬件给你提供了什么约束,你又该怎么借用这些约束去实现自己的设计"。这篇文章不是教材的复刻,而是我在实际写内核和调保护模式问题时,总结出来的一套知识点汇总。已经理解了分段和分页的人可以拿它当复习提纲,刚开始接触的人也能跟着思路把整套机制串起来。
要学的点很多,但核心其实就五个:段描述符与选择子、GDT/LDT/IDT/TSS 这些表的构造、分页的两级查表、特权级与门描述符、以及从实模式切换到保护模式的完整流程。下面我按自己理解的逻辑顺序一条条展开。
1. 为什么需要保护模式——从实模式的窘境谈起
1.1 实模式地址空间与特权缺陷
回想一下 8086 时代的实模式:一个 20 位地址总线,最多访问 1MB 内存;程序通过"段寄存器 x 16 + 偏移"算出物理地址,段和段之间根本没有天然的隔离墙。你的代码写得再规矩,也拦不住别人的代码用一个远跳转或者一个野指针,把数据段指到了你的代码段上。更麻烦的是,实模式下 CPU 完全不知道"用户程序"和"操作系统"的边界在哪儿。任何一段程序都可以随意修改段寄存器、关闭中断、甚至改写中断向量表。要是系统里跑的程序互相不信任,这种模式就是一场灾难。
从硬件角度讲,保护模式要做的事情很明确:第一,把"段"从简单的偏移加法变成受管控的映射关系;第二,加入特权级,让低特权级的代码不能随便碰高特权级的数据和代码;第三,引入分页机制,让线性地址到物理地址的映射变得可控,给每个进程一个"好像自己独占内存"的假象。这三个目标就是后面所有机制的设计动机。
1.2 保护模式到底保护了什么
很多初学者以为"保护模式"保护的是内存条不受损坏,其实保护的是一种访问规则。硬件在每一次内存访问、每一条指令执行前,都会做一层甚至多层权限校验。如果你违反了规则,CPU 不会直接照做,而是触发异常(通常是 #GP 或 #PF),把控制权交给异常处理程序——也就是内核设置的"执法人员"。这样一来,用户程序想乱来,也只会让自己的进程收到异常信号,而不是直接把整个系统搞崩。
这里我建议把保护模式理解成三套并行的闸门:
- 分段机制:负责检查"段"层面的访问合法性(是否存在、权限够不够、是否可读可写可执行)。
- 分页机制:负责检查"页"层面的访问合法性(是否映射,用户态能不能访问,读写权限)。
- 中断/异常机制:负责在违规发生时,把控制权安全地转移到高特权级的处理程序,同时保存现场。
三套闸门配合起来,内核才能在用户程序崩溃的情况下继续存活。明白了这个背景,下面每一个知识点就都有"它到底在防谁"的答案了。
2. 分段机制的底层逻辑——选择子、描述符、GDT 与 LDT
2.1 段寄存器从"段基址"到"选择子"的转变
实模式下,MOV AX, 0x1000再MOV DS, AX,CPU 直接就把段基址用到了后续的地址计算里。保护模式完全不同:往段寄存器里放的是一个 16 位选择子(Selector),CPU 根据这个选择子去内存里的描述符表找对应的段描述符,然后把描述符里的段基址、段限长、属性加载到 CPU 内部不可见的"描述符缓存"中。这一步既是保护模式的关键,也是新手最容易懵的地方——直接往段寄存器里写一个"看似随意"的值,CPU 找不找得到描述符、描述符权限对不对,都会立刻触发异常。
选择子本身只有 16 位,结构非常紧凑:
- 高 13 位:描述符索引(Index),用来在 GDT 或 LDT 中定位第几个描述符。
- 第 2 位:TI 位,0 表示查 GDT,1 表示查 LDT。
- 低 2 位:请求特权级 RPL(Requested Privilege Level)。
一个典型的选择子比如0x10,展开二进制是0000 0000 0001 0000,TI=0,Index=2,RPL=0,意思就是"用 GDT 里的第 2 个描述符,当前请求的特权级为 0 级"。真正的段基址不在选择子里,在描述符里。这就是为什么保护模式下你不能直接把内存地址塞进段寄存器。
2.2 描述符表:GDT 与 LDT 的设计
描述符表就是一张存储在内存里的数组(更准确说是"表"),每一项 8 字节。GDT(全局描述符表)是一个系统范围内的表,内核在启动时把它建立好,并通过LGDT指令告诉 CPU 它的基址和限长。GDT 里通常放着内核代码段、内核数据段、用户代码段、用户数据段、TSS 段描述符等。LDT 则是每进程可选的局部描述符表,Linux 早期在部分场景下用过它,现代内核基本只依赖 GDT 加分页,但考试和面试很喜欢问这两者的区别,我这里提一下:GDT 全局共享,LDT 可以按进程切换,LLDT指令可以加载它,访问 LDT 里的段时选择子的 TI 位需要置 1。
一个段描述符的 8 字节结构是必考的,我直接列一张表:
| 位域组合 | 含义 |
|---|---|
| Base 0:15 / Base 16:23 / Base 24:31 | 32 位段基址,拆三处存放 |
| Limit 0:15 / Limit 16:19 | 20 位段限长,配合 G 位解释 |
| P(Present) | 段是否存在,0 时访问该段触发 #NP |
| DPL(Descriptor Privilege Level) | 描述符特权级,0 最高,3 最低 |
| S(System) | 0 表示系统段描述符,1 表示代码/数据段描述符 |
| Type(4 位) | 段类型:代码段/数据段、可读可写、可执行、一致/非一致等 |
| G(Granularity) | 0 时限长单位是字节,1 时限长单位是 4KB |
| D/B | 代码段默认操作大小 0=16 位,1=32 位;栈段表示栈指针大小 |
| AVL | 软件可用位,硬件不关心 |
| L(Long) | IA-32e 模式下是否 64 位代码段 |
这里要特别注意 G 位的含义。如果 G=1,Limit 20 位实际可以表示到 4KB 4KB = 4GB 的段大小,但段内偏移的上限计算不是直接用 Limit,而是段限长+1 乘以 4KB。例如 Limit=0xFFFFF,G=1 时,段限长就是 4GB。很多早期内核代码写 GDT 时会设置0x00CF9A000000FFFF这样的值,前面的0xCF其实就是把 G、D/B、AVL、Limit 高四位都编码进去了,拆开看才知道为什么是 0xCF。
2.3 段权限检查:CPL/RPL/DPL 与一个容易混淆的规则
特权级有三个来源:当前特权级 CPL(存在 CS 的低 2 位)、段描述符里的 DPL、选择子里的 RPL。CPU 从低特权级跳到高特权级不是随便跳的,这在访问数据段和代码段时有完全不同的规则:
- 数据段访问:要求
CPL <= DPL且RPL <= DPL,也就是当前特权级和请求特权级都必须"不高于"数据段的 DPL 才允许访问。看内核代码的时候你会发现用户态程序无论 RPL 写得多高,只要它 CPL=3,就够不着 DPL=0 的内核数据段。 - 代码段访问:分两种。非一致代码段要求
CPL == DPL,内核跳用户态程序是通过特权级转换后RETF或IRET实现的,不能直接用JMP从 CPL=0 跳到 CPL=3 的非一致段;一致代码段(Conforming)则允许CPL >= DPL,且跳转后 CPL 不升高,仍然保持原来的低特权级。
有一句话我反复跟朋友强调:"CPL 不等于想象中某个程序的身份标签,它只是当前 CS 寄存器里的低两位。" 所以特权级切换的本质,是换 CS 的值。内核态和用户态之所以能切换,靠的就是中断/异常返回时,CPU 把用户态的 CS 和 SS 弹出到寄存器中。理解了这点,后面看中断门和 TSS 会顺很多。
3. 分页机制:从线性地址到物理地址的两次查表
3.1 页目录与页表的层级结构
分段把"虚拟地址"(准确说是逻辑地址)翻译成线性地址后,分页机制继续把线性地址翻译成物理地址。经典 32 位分页使用两级结构:线性地址被拆成三段,高 10 位作为页目录索引,中间 10 位作为页表索引,低 12 位作为页内偏移。CR3 寄存器存放页目录基址,每次地址翻译都要先查页目录,再查页表,最后加上偏移。这个过程很机械,但正因为机械,硬件才能通过 TLB(Translation Lookaside Buffer)缓存结果,把经常访问的页表项缓存起来,不然每条指令的地址翻译都要访问两次内存,加上取指令本身,性能会崩坏。
页目录的每一项 PDE(Page Directory Entry)和页表的每一项 PTE(Page Table Entry)结构非常相似,核心字段如下:
| 位 | 名称 | 含义 |
|---|---|---|
| 0 | P | 存在位,0 表示未映射,访问时触发缺页异常 |
| 1 | RW | 读写位,0 表示只读 |
| 2 | US | 用户/超级用户位,0 表示仅特权级可访问;1 表示允许 CPL=3 访问 |
| 3 | PWT / 4 PCD | 缓存控制位 |
| 5 | A | 访问位,CPU 在读该页时置 1 |
| 6 | D | 脏位,写该页时置 1(PDE 在 4KB 页模式下不参与) |
| 7 | PS | 页大小位,PDE 的 PS=1 表示页大小是 4MB,不再查页表 |
| 8 | PAT / 12 G | PAT / 全局页位 |
分页模式下内核手里最大的牌是 P 位和 U/S 位:把用户进程的页表里很多页的 P 位置 0,用户程序一访问就触发 #PF,由内核决定是报段错误还是从磁盘换页进来;U/S 位则保证用户程序不能伸手到内核页里。这也是现代操作系统实现"每个进程独立地址空间"的基础:切换进程的时候只要换个 CR3,整个线性地址映射就彻底换了。
3.2 4MB 页与 1GB 大页
这里有一个在性能优化里很常用的点:PDE 的 PS 位。当 PS=1 时,页目录项直接指向一个 4MB 的大页,中间的页表可以省掉,地址转换少一级。TLB 覆盖率大幅度提高,对性能敏感的内核镜像映射、显存映射特别友好。64 位模式下还有 2MB 页和 1GB 页。我在自己写内核时,早期图省事把所有物理内存按 4MB 大页映射,好处是页表数量少、代码简单,坏处是如果你要做按页细粒度的权限控制,比如用户进程只给你一页内存,大页就完全使不上力。所以常规做法是内核区域大页映射,用户进程再用 4KB 页细粒度管理。
3.3 从实模式到分页:CR0 与 CR3 的配合
分页不是单单把页表建好就行,还得打开 CR0 的 PG 位,同时把 CR3 指向有效的页目录。打开分页和打开保护模式不是同一个操作:保护模式相关的是 CR0.PE,分页相关的是 CR0.PG。如果开了分页但 CR3 没有设置,第一次取指就会炸。常见经验是,先关中断,把页目录和页表建好,在物理内存固定位置,然后一次性完成CR3 <- 页目录基址; CR0 |= PG的动作。部分 CPU 还要求切换 CR3 前执行MOV CR3先清 TLB 旧映射,虽然现在有 PCID 可以缓解,但早期就这么干。
分页模式下还有一个很多人忽略的细节:CR3本身并不存在于"线性地址空间"里,它保存的是物理地址。同样,段描述符里的基址在 32 位分段模式下也是物理地址。但在开启分页后,LGDT传入的 GDT 基址和LIDT传入的 IDT 基址,则是线性地址,因为它们会经过一次分页翻译。所以当你用临时页表映射内核的时候,GDT 和 IDT 所在页必须也被映射了,不然一开分页,GDT 直接找不到,CPU 三重故障。
4. 控制转移与中断门——IDT、门描述符,以及特权级切换的隐形成本
4.1 中断描述符表里放什么
IDT(Interrupt Descriptor Table)和 GDT 一样,也是一张 8 字节一项的表,每个中断或异常向量占一项,最多 256 项。与 GDT 不同的是,IDT 里通常不放普通数据段描述符,放的是"门描述符":中断门、陷阱门和任务门。每个门描述符最关键的信息是:目标代码段的选择子、目标代码段内的偏移、DPL、以及门类型。
当外部设备触发中断,或者 CPU 执行INT n、INT3、异常指令时,CPU 根据向量号查 IDT。查到了对应的门描述符后,CPU 要做一系列检查:当前特权级是否被允许进入这个门(要求CPL <= DPL)?目标段选择子指向的描述符是否存在、代码段是否可执行?如果调用者特权级高于目标段 DPL,则需要切换特权级栈。这一步的检查非常密集,任何一步不满足都会抛出对应的异常,异常再去找 IDT 里的处理项。
4.2 中断门、陷阱门与调用门
这三种门的区别,考试常常考,但实际内核开发里调用门用得很少。我给一个对比表:
| 门类型 | 典型用途 | 是否清 IF | 特权级切换行为 |
|---|---|---|---|
| 中断门 | 外部硬件中断处理 | 是 | 跳转同时清中断标志,防止嵌套 |
| 陷阱门 | 系统调用/调试异常 | 否 | 跳转后保持 IF,允许嵌套中断 |
| 任务门 | 硬件任务切换 | 看情况 | 通过 TSS 切换到新任务 |
| 调用门 | 特权级调用(跨 ring) | 否 | 允许低特权级调用高特权级子例程 |
中断门会清 IF,这是一个很重要但容易被新手忽视的点。如果你用陷阱门去处理硬件中断,中断处理过程中可能再次来同一个或更高优先级的中断,除非你在代码里自己处理嵌套,否则会出现不可预期的重入。另一方面,系统调用入口一般用陷阱门或中断门都行,现代内核很多用中断门,因为要顺带禁止同核心上的中断,让入口代码更可控。
4.3 用户态到内核态的切换:栈切换与 TSS
这里就是整个保护模式最精妙的部分。当用户态(CPL=3)发生中断或系统调用,CPU 发现目标门描述符的 DPL 比当前 CPL 更严格(数字更小),就会自动从 TSS 中加载SS0和ESP0作为新栈,并把用户态的SS、ESP、EFLAGS、CS、EIP压进新栈。然后跳转到门的偏移地址。返回时执行IRET,CPU 弹出用户态的 CS、EIP、EFLAGS,再弹回 SS、ESP,恢复用户栈。
这个"从 TSS 加载内核栈"的动作是硬件自动完成的,省了内核自己保存用户栈指针的麻烦。但注意,CPU 只负责切换栈和压寄存器,不会自动保存通用寄存器。这就是为什么每个中断入口处都要执行PUSHAD/PUSHAD之类的操作,把 AX/CX/DX/BX/SP/BP/SI/DI 全部压栈,退出前再POPAD。中断入口的汇编代码都有一个固定模板,先压错误码和向量号(如果硬件没压),再把所有寄存器压栈,最后调用 C 函数。这个细节在写第一个中断处理程序时一定会踩到:如果你漏保存寄存器,跳进 C 代码里随便一用变量,现场就全乱了。
另外要特别注意 TSS 中的I/O 位图。TSS 末尾还有一个可选的 I/O 权限位图,用来控制低特权级程序能否直接执行IN/OUT指令访问 I/O 端口。如果 I/O 位图对应的位置 0,则允许访问;置 1,则触发一般保护异常。内核靠这个位图接管所有设备访问,用户程序不能直接控制硬件。这个细节在设备驱动课程里经常被忽略,但它其实是"保护"的一部分。
5. 任务状态段与任务门——内核调度的一点硬件基础
5.1 TSS 到底存了什么
TSS(Task State Segment)不是用来存代码的,它是一个"状态快照"结构,里面记录了每个特权级对应的栈指针(SS0/ESP0、SS1/ESP1、SS2/ESP2)、CR3、各种寄存器现场,还有上面提到的 I/O 位图。CPU 做特权级切换时要靠 TSS 提供新栈;做硬件任务切换时会把当前任务的所有寄存器存入旧 TSS,再从新 TSS 恢复全部寄存器。
但现代操作系统普遍只在每个 CPU 上建立一个 TSS,日常只使用其中的SS0/ESP0和 I/O 位图,任务切换完全由内核软件完成。为什么可以这样?因为硬件任务切换是"原子"的,切换一整套寄存器和栈,但这套机制太重,也没法在切换时执行内核代码来做地址空间切换、调度算法等。我见过一个培训项目想用硬件任务切换模拟真实调度器,结果发现每切换一次任务,就要修改 TSS 描述符并远跳转一次,开销大、灵活性差,最后还是回归软件切换。所以在学保护模式时,你要分清"硬件提供的任务切换"和"操作系统实际上用的软件切换"——前者是理解 TSS 的好入口,后者才是真实系统的做法。
5.2 TSS 描述符的加载与 LTR
TSS 在 GDT 中也有一份描述符,类型是系统描述符(S=0),Type 字段是 0x9(可用 TSS)或 0xB(忙 TSS)。CPU 用一条LTR指令加载 TSS 描述符到任务寄存器 TR 中,加载后 TR 寄存器的不可见部分保存了 TSS 的基址和限长。注意,LTR只能在 CPL=0 下执行,通常在内核初始化时做一次。
TSS 的限长必须至少覆盖要访问的字段(到 I/O 位图为止)。调试中最常见的坑是 TSS 描述符里的基址指向的物理内存没有映射,或者 GDT 限长太短,导致加载后一访问 TSS 就触发 #GP。另一个容易犯的错是:修改 TSS 里的 ESP0 时,只改了内存里的值,但 TR 缓存没有刷新,导致第一次访问依然是旧值。解决方法是重新执行LTR或者对 TSS 描述符做一次远跳转来刷新,实际中很多内核在每次任务切换时都会重新写 TR 或使用LTR确保缓存同步。
5.3 初始化一个最小 TSS 的实操要点
写一个最小内核时,TSS 只需要设置 SS0、ESP0 和 I/O 位图。内核启动时栈顶地址往往是编译阶段就确定好的。我建议把 TSS 放到一个对齐的全局结构体里,然后手动构造 GDT 项。构造时注意描述符里的 Limit 一定要包含结构体的长度,不然 CPU 访问到边界外会报异常。代码通常是:
struct tss_t { uint32_t link; // 上一个任务指针,不用就填 0 uint32_t esp0; // 内核栈指针 uint32_t ss0; // 内核栈段 // esp1/ss1, esp2/ss2 可以全 0 uint32_t cr3; uint32_t eip; ... uint16_t iomap_base; } __attribute__((packed));然后往 GDT 里写一个 0x89 类型的描述符,让基址指向这个结构体。初始化代码执行:
mov ax, TSS_SELECTOR ltr ax之后每次进中断,CPU 就能自动用 TSS.esp0 作为内核栈了。这里提示一下:ESP0 必须指向一个 32 位栈顶,也就是它是否可以容纳后续压栈的现场数据,别把栈顶指向栈中间某个只够一个变量的位置,不然压十几个寄存器直接溢出到未知地址。
6. 从实模式到保护模式——一个可复现的启动切换流程
6.1 进入保护模式前的准备
如果你的内核在裸机上跑,开机时 CPU 默认是实模式。要进入保护模式,最干净的做法是严格按照顺序来:
- 关闭中断:
CLI,因为关闭前 IDT 还是实模式的,任何中断进来都会跳转到一个你不知道的地方。 - 打开 A20 地址线:实模式下的段式寻址存在 20 位环绕问题,访问超过 1MB 的地址会被回绕到 0 地址附近。这一行为在现代系统上已经不可用于访问高内存,所以必须通过键盘控制器或 BIOS 服务打开 A20。
- 在内存中建立 GDT,并执行
LGDT加载。 - 设置 CR0 的 PE 位为 1,这一步后 CPU 立即处于保护模式。
但"立即处于保护模式"不代表你马上可以用JMP乱飞。一个最经典的坑是:在开了 PE 位后,CPU 还在使用流水线中预取的老指令。所以切换保护模式的标准动作是紧接着执行一个远跳转JMP 0x08:start_protected,这个远跳转会刷新 CS 和 EIP,丢弃预取的实模式指令。如果不做这一步,后续行为完全不可预测,我见过不少人卡在这。
6.2 典型切换代码拆解
下面给一段能直接参考的引导代码骨架,它不限于哪种汇编器,主要是流程:
.code16 cli ; 打开 A20 的常见写法:通过 I/O 端口 0x92 的 bit1 in al, 0x92 or al, 0x02 out 0x92, al ; 临时 GDT,注意 gdt_ptr 是 6 字节:2 字节限长 + 4 字节基址 lgdt [gdt_ptr] ; 开启保护模式 mov eax, cr0 or eax, 1 mov cr0, eax ; 远跳转刷新流水线 jmp 0x08:protected_mode_start .code32 protected_mode_start: ; 重新加载数据段寄存器 mov ax, 0x10 mov ds, ax mov es, ax mov fs, ax mov gs, ax mov ss, ax ; 设置栈 mov esp, stack_top ; 如果还要分页,在这里初始化页表和 CR3 ...代码里lgdt传入的 gdt_ptr 必须位于可访问内存里,最好放在引导区固定地址。实模式下地址空间只有 1MB,但开了 A20 后你的 GDT 如果放在 1MB 以上,实模式下lgdt也一样能加载,只要地址总线能访问到。最简单的是放在 0x10000 之类的地方。还有一个小细节:mov ss, ax和紧接着的mov esp, stack_top之间,某些 CPU 会禁止中断,但出于保险,建议CLI一直维持到栈设置完成再开中断。
6.3 切换后第一件事:重新加载段寄存器与栈
为什么要重新加载所有段寄存器?因为进入保护模式前,这些段寄存器里还存着实模式的"旧基址"。虽然远跳转刷新了 CS,但 DS、SS 等段寄存器的隐藏缓存仍然是实模式的 64KB 段映射。如果你的数据访问超过了那段范围,实模式习惯还在,后果就是拿到错误的基址。逻辑上,你必须在进入保护模式后立刻用新选择子重新加载它们。假如某个段寄存器指向的选择子在 GDT 里不存在,CPU 会拒绝对该段寄存器的加载,然后触发异常。所以 GDT 至少要保证索引 0(空描述符)、内核代码段、内核数据段这几个项的设置正确。
栈同样重要:实模式下的栈很可能是 BIOS 提供的,在低内存某处。进入保护模式后,这个栈依然存在,但你可能马上要用大页映射或重新规划内存布局,就必须把栈迁到内核预留位置。这里最常见的错误是忘记在开启分页前把栈切成新映射,导致分页开启后栈指针指向的线性地址没有对应的物理页,栈一压数据就缺页。
6.4 安全跳出保护模式:回实模式才是真正的考验
很多内核做完保护模式切换后就不考虑回去了,因为没必要。但调试时或者启动多阶段引导时可能需要临时回实模式。回实模式的办法和去保护模式正好相反:先关闭分页(CR0.PG=0),再跳到一个 16 位代码段(需要 GDT 里提前放一个 16 位代码段),把 CR0.PE 清零,最后做一次远跳转刷新 CS。还要重新设置 IDT 为实模式的中断表,给堆栈设置新的值。这串步骤中间的指令编码必须确保在 16 位和 32 位模式间切换正确运行,否则要么是缺页要么是 #UD。我建议不到万不得已别在日常开发里来回切,调试还是靠模拟器比较省心。
7. 踩坑实录与调试技巧——保护模式内核开发中的常见问题
7.1 A20 未开启导致的地址环绕
我自己调试一个模拟内核时,把内核搬运到 0x100000(1MB)上方,结果运行到那里发现从 0x100000 读到的数据和 0x000000 一样,好像所有高地址都镜像到了低地址。这就是 A20 没开。在写引导程序时,很多人以为所有的虚拟机/开发板默认都开了 A20,但很多模拟器默认行为并不一样,有的甚至忽略键盘控制器。最稳妥的做法是引导早期就强制开启 A20,并读取状态位确认。
开 A20 有多种方式,传统键盘控制器方式兼容性好,但慢;使用 0x92 端口最快捷,但某些老硬件可能有兼容问题。实践上可以在引导区里先试 0x92,然后检查 A20 是否真的开着(通过比较 0x000000 和 0x100000 的数据),没开再走键盘控制器。写自带 bootloader 的同学一定要保留这个检查逻辑,不然就把所有精力花在排查一个三行代码就能解决的 bug 上。
7.2 GDT 限长或基址设置错误导致的三重故障
保护模式下只要发生一次未处理的异常,而该异常的分发过程本身又失败(比如 IDT 没设置),CPU 会进入"三重故障"状态,直接复位重启。这一幕在屏幕上一个错误提示都不会有,虚拟机里表现为"系统重启"或者模拟器直接退出。90% 的早期内核开发崩溃都源于三重故障,最常见的原因就两个:
- GDT 加载成功了,但 GDT 描述符里的基址写错,远跳转时找不到代码段描述符。
- IDT 没初始化或设置错误,一旦触发中断,CPU 找不到可行的处理入口。
- 段描述符的 P 位没置 1,访问时触发 #NP。
排查三重故障的思路是:用模拟器配合调试器查看最后的指令流,看看是哪条指令触发的异常,然后顺着异常向量反查 IDT 和 GDT。如果调试器都进不去,先用最小的"关中断 + 线性跳转 + 死循环"排除法,一条一条加上去。绝对不能幻想跑一遍源码就能看出问题,这类 bug 九成靠断点。
7.3 特权级切换后栈指针丢失的经典症状
另一个高频问题是你编好中断门以后,用户程序随便执行一条INT 0x80或触发一次除法异常,结果内核打印出来的寄存器和现场完全不对,而且后续停止响应。这种多半是 TSS 没设置好——新栈从 TSS 取的ESP0指向了没有映射的物理内存,或者栈空间耗尽。检查思路很简单:在中断入口的第一条指令,用调试器查看 ESP 值,对照你预期 TSS.ESP0 的值。如果两者不一致,就去检查 TSS 的加载是否成功、GDT 里 TSS 描述符类型是不是 0x89、有没有执行LTR。我踩过一次以后学乖了:所有内核线程共用内核栈没问题,但每个 CPU 的 TSS 里的 ESP0 必须和当前运行的任务栈一致,不然一次中断就会跳进一个完全没准备的内核栈。
7.4 调试心得:日志比单步更高效
写保护模式代码时,串口日志是最可靠的伙伴。屏幕输出在实模式和保护模式切换时会有各种兼容问题,而串口输出只要初始化好了,就一直能工作。我建议在引导区初始化完串口后,往串口写几个固定标记字符,比如[OK] GDT loaded、[OK] paging on。出问题时看最后一行日志就知道是哪一步炸的。这是一个成本极低却极高收益的做法,比在调试器里反复单步快得多。另一个调试技巧是利用 CPU 内置的CPUID或RDMSR之类指令来确认当前是否真的处于保护模式,如果读到的值是乱的,大概率是段寄存器缓存没刷新成功。
7.5 对"知识点汇总"的整理建议
最后给正在复习的人一个建议:不要孤立地背选择子结构、描述符位域、页表项位标志,把它们放在一条完整的事件链里记忆。比如遇到"用户态过程调用内核功能",整条链路是:
- 用户程序执行
int 0x80(或syscall),CPL=3。 - CPU 查 IDT 中向量 0x80 的中断门描述符。
- 门描述符 DPL=3,允许当前特权级进入。
- 目标代码段选择子的 DPL=0,说明要切换特权级。
- CPU 读取 TR 指向的 TSS,加载
SS0和ESP0作为内核栈。 - 用户态现场(SS、ESP、EFLAGS、CS、EIP)被压入内核栈。
- CPU 跳转到入口,清除 IF(中断门),保存通用寄存器,执行服务工作。
- 完成后恢复通用寄存器,执行
IRET,CPU 弹出用户态现场,回到 CPL=3。
把它串下来,GDT、IDT、TSS、门描述符、特权级切换、中断现场这些知识点就全都活了。后续真正写内核时,这套理解可以直接用来设计系统调用的入口和异常处理框架。
根据我个人经验,把保护模式学扎实,对理解现代内核帮助极大。虽然现在很多教学直接讲 x64 和硬件虚拟化,但保护模式里那套"权限检查—现场保护—栈切换"的思路,在后来的异常处理、进程切换、虚拟内存管理里都有一个又一个的回响。入门阶段别怕反复重启和三重故障,每解决一次,你对 CPU 工作方式的理解就扎实一层。如果你也正在写自己的小内核,建议把目标定小一点:先跑通实模式到保护模式的切换,再点亮一个能打印日志的串口,然后加异常处理和定时器中断。这条路走完,你对整个系统的掌控感会有一个质的提升。