在开发最小 x86 内核时,80386 保护模式是实现特权隔离的基础。Bootloader 需要正确配置全局描述符表(GDT)和中断描述符表(IDT),定义代码 / 数据段描述符,并处理特权环(Ring)0 到 Ring 3 的转换,从而为内核提供基本的内存访问控制和指令特权检查。本文聚焦工程化参数,给出可直接复制的描述符字节值、汇编片段和隔离清单,避免常见的三重故障(triple fault)。
保护模式与分段机制概述
80386 处理器从实模式切换到保护模式后,引入段机制:所有内存访问通过段寄存器(CS/DS/ES/FS/GS/SS)和 GDT 中的段描述符解析。每个描述符定义基址(base)、限长(limit)、访问权限(DPL)和类型(代码 / 数据)。平坦模型(flat model)下,base=0x00000000,limit=0xFFFFF(G=1 时扩展为 4GiB),简化 C 语言指针使用,但隔离依赖 DPL 和类型检查。
特权环分为 Ring 0(内核)和 Ring 3(用户),当前特权级(CPL)由 CS 选择子低 2 位决定。数据访问要求 CPL ≤ DPL 且 RPL ≤ DPL;Ring 3 无法执行 lgdt/lidt 等特权指令,也不能直接访问 Ring 0 数据段。
GDT 构建:段描述符参数详解
GDT 是数组,每个条目 8 字节。Bootloader 在实模式下构建最小 GDT:
- 索引 0:空描述符(全 0)。
- 索引 1:内核代码段(selector 0x08),Ring 0 执行 / 读。
- 索引 2:内核数据段(selector 0x10),Ring 0 读 / 写。
- 索引 3:用户代码段(selector 0x1B),Ring 3 执行 / 读。
- 索引 4:用户数据段(selector 0x23),Ring 3 读 / 写。
描述符字节(平坦 32 位 4GiB):
内核代码段:
dw 0xFFFF ; limit low (0-15)
dw 0x0000 ; base low (0-15)
db 0x00 ; base mid (0-7)
db 0x9A ; access: P=1, DPL=00, Type=1010 (code, exec, read)
db 0xCF ; flags+limit hi: G=1, D/B=1, L=0, AVL=0 | limit(16-19)=1111
db 0x00 ; base high (24-31)
内核数据段:access 改为 0x92(Type=0010,data, expand-down=0, write=1)。
用户段类似,但 access DPL=11(0xFA/0xF2)。
GDTR 结构:dw (GDT 大小 - 1),dd GDT 基址。NASM 示例:
gdt_start:
dq 0 ; null
; code desc...
; data desc...
gdt_end:
gdt_desc: dw gdt_end - gdt_start -1, dd gdt_start
OSDev Wiki 的 GDT Tutorial 强调,描述符必须精确,否则进入保护模式后立即 GP# 异常。[1]
Bootloader 进入保护模式步骤
实模式(16 位)Bootloader 序列(假设已启用 A20):
- cli 禁用中断。
- lgdt [gdt_desc]。
- mov eax, cr0 | 1; mov cr0, eax 置 PE 位。
- jmp 0x08:pm_entry 远跳转刷新流水线,加载新 CS。
保护模式入口(32 位):
[BITS 32]
pm_entry:
mov ax, 0x10; mov ds,ax; mov es,ax; mov fs,ax; mov gs,ax; mov ss,ax
mov esp, 0x90000 ; 内核栈顶
; 继续初始化 IDT 等
此后,所有地址为线性平坦地址。
IDT 初始化:中断门与系统调用
IDT 类似 GDT,256 条目,每条 8 字节(32 位门)。中断门(type=0xE/14)用于 IRQ / 异常,陷阱门(0xF)不自动清 IF。
最小 IDT:默认处理程序(打印寄存器或 hlt)。条目格式:
- offset low (0-15)
- selector (KCODE=0x08)
- 0
- type_attr (e.g. 0x8E: P=1,DPL=0,type=1110 interrupt)
- offset high (16-31)
系统调用如 int 0x80,使用 DPL=3 的门(type_attr=0xEE)。IDTR:dw (IDT 大小 - 1),dd IDT 基址。
在 pm_entry 后立即 lidt,然后可选 sti。
特权环转换:Ring 0 ↔ Ring 3
内核 → 用户:使用 IRET 伪造栈帧。
; 在 Ring 0
mov ax, 0x23; mov ds,ax; ... ; 加载用户数据段
push 0x23 ; 用户 SS
push user_esp ; 用户栈顶
pushfd ; EFLAGS (清 IOPL=0)
push 0x1B ; 用户 CS
push user_entry ; 用户 EIP
iret ; 切换到 Ring 3
用户 → 内核:int 0x80,CPU 自动切换栈(需 TSS)。
TSS(Task State Segment)必需:定义 ss0:esp0 为内核栈。GDT 添加 TSS 描述符(type=0x9,S=0),ltr 加载。
段限与隔离工程清单
平坦模型下,段限不提供内存隔离(全地址可见),但特权检查防止 Ring 3 写内核代码 / 数据。为增强隔离:
-
参数阈值:
组件 参数 值 作用 代码段 limit 0xFFFFF G=1 4GiB 平坦 DPL 0/3 特权检查 数据段 Type 0x2/0xA 写 / 执 TSS limit 0x67 (sizeof TSS-1) 栈切换 IDT 门 DPL 0x0/0x3 异常 /syscall -
隔离清单:
- 内核数据段仅 Ring 0,用户不可加载。
- 所有用户指针限 Ring 3 段。
- 监控 GP# (中断 13) 处理非法访问。
- 回滚:若 triple fault,检查 GDT base/limit 和 far jmp。
- 后期加分页(CR3/PDPT)真隔离。
常见 pitfalls:GDT 限长错误(dw gdt_end-gdt_start-1),未远跳导致旧 CS 缓存;IDT 未设致异常重置。
监控与调试要点
- Bochs/QEMU debug:监视 CR0.PE、GDTR/IDTR。
- 异常栈:#GP (13)/#PF (14) 表示段违规 / 页违。
- 参数验证:GDT 每个字节手工校验,如 code access=0x9A。
此配置适用于 GRUB 无 GDT 场景的自制 bootloader。后续可扩展 LDT 和分页。
资料来源: [1] OSDev Wiki: GDT Tutorial (https://wiki.osdev.org/GDT_Tutorial) [2] BrokenThorn OSDev Tutorial (https://brokenthorn.com/Resources/OSDev8.html)
(本文约 1250 字,基于公开 OS 开发资源整理,非官方手册。)