从零写 OS 内核-第五篇:启动分页机制 —— 构建虚拟内存的基石
"段机制只是过渡,分页才是现代操作系统的真正护城河。
今天,我们亲手开启 x86 的分页功能,让内核拥有虚拟内存!"
在上一篇中,我们深入理解了 保护模式与段式内存管理,并亲手实现了从实模式到保护模式的切换。
但你可能已经听说:现代操作系统几乎不用段机制,而是依赖 分页(Paging) 实现内存隔离与虚拟化。
为什么?
因为分页提供了更灵活、更高效的内存管理方式:
✅ 支持非连续物理内存映射为连续虚拟地址
✅ 实现进程间内存隔离(安全!)
✅ 支持按需换页、共享内存、写时复制等高级特性
今天,我们就来在 32 位 x86 保护模式下,启动最基础的分页机制,为后续的内核开发打下坚实基础。
🧱 一、为什么需要分页?段机制不够吗?
段机制虽然能实现内存保护和特权级,但它存在致命缺陷:
| 问题 | 说明 |
|---|---|
| 段必须连续 | 一个段在物理内存中必须是连续的,无法利用碎片内存 |
| 段大小固定 | 段界限一旦设定,难以动态扩展 |
| 切换开销大 | 切换任务需切换整个段描述符表 |
| 硬件支持弱 | 现代 CPU 优化重点在分页,而非分段 |
而分页机制将内存划分为固定大小的 页(Page)(x86 中通常为 4KB),通过 页表(Page Table) 将 虚拟地址 → 物理地址 映射,完美解决上述问题。
💡 Linux、Windows 等现代 OS 都采用"平坦段模型 + 分页":
所有段都设为0x00000000 ~ 0xFFFFFFFF,段机制"退居二线",分页扛起内存管理大旗。
🗺️ 二、x86 分页机制原理(32 位)
x86 32 位分页采用 两级页表结构:
- 页目录(Page Directory):1 个,1024 项,每项 4 字节 → 4KB
- 页表(Page Table):最多 1024 个,每个 1024 项 → 每个 4KB
🔍 虚拟地址如何翻译?
32 位虚拟地址被划分为三部分:
| 31 22 | 21 12 | 11 0 |
| 页目录索引 | 页表索引 | 页内偏移 |
| (10 bits) | (10 bits) | (12 bits) |
翻译流程:
- CPU 从 CR3 寄存器 读取 页目录基地址(物理地址)
- 用 高 10 位 作为索引,查页目录项 → 得到 页表基地址
- 用 中间 10 位 作为索引,查页表项 → 得到 物理页帧地址
- 拼接 低 12 位偏移 → 最终物理地址
📌 所有地址(页目录、页表、页帧)都是物理地址!
分页开启后,程序员看到的"地址"都是虚拟地址,硬件自动翻译。
📦 三、页表项(PTE)与页目录项(PDE)结构
每个页表项(4 字节)包含:
| 位 | 含义 |
|---|---|
| 0 (P) | 存在位:1=页在内存,0=缺页 |
| 1 (R/W) | 读写位:1=可写,0=只读 |
| 2 (U/S) | 用户/超级用户位:1=用户态可访问,0=仅内核 |
| 3 (PWT) | 页写通(缓存策略) |
| 4 (PCD) | 页缓存禁用 |
| 5 (A) | 访问位(CPU 自动置 1) |
| 6 (D) | 脏位(仅页表项,写时置 1) |
| 7 (PS) | 页大小(仅页目录项):1=4MB 大页,0=4KB |
| 12-31 | 物理页帧地址高 20 位(4KB 对齐) |
✅ 我们先用最简配置:P=1, R/W=1, U/S=0(内核可读写)
⚙️ 四、实战:开启分页(Identity Mapping)
为简单起见,我们先做 恒等映射(Identity Mapping):
虚拟地址 = 物理地址(例如:虚拟 0xC0000000 → 物理 0xC0000000)
💡 恒等映射是启动阶段的标准做法,后续可再建立更复杂的映射(如内核高地址映射)。
步骤 1:准备页目录和页表
我们需要:
- 1 个页目录(4KB)
- 若干页表(每 4MB 虚拟地址需 1 个页表)
假设我们只映射前 4MB 内存(足够内核初期使用):
// 定义在链接脚本指定的区域(如 .bss)
__attribute__((aligned(4096)))
uint32_t page_directory[1024];
__attribute__((aligned(4096)))
uint32_t first_page_table[1024];
🔑
aligned(4096)确保页表/目录 4KB 对齐(x86 强制要求)!
步骤 2:初始化页表(映射 0x00000000 ~ 0x003FFFFF)
// 映射前 4MB(1024 页 × 4KB)
for (int i = 0; i < 1024; i++) {
// 页物理地址 = i * 4096
// 标志:存在(1) + 可写(1<<1)
first_page_table[i] = (i * 4096) | 3;
}
步骤 3:初始化页目录(指向第一个页表)
// 页目录第 0 项指向 first_page_table
// 注意:必须用物理地址!
uint32_t ptable_phys = (uint32_t)first_page_table - KERNEL_VIRTUAL_BASE;
page_directory[0] = ptable_phys | 3;
⚠️ 关键点:
如果你的内核链接在高地址(如0xC0000000),但实际加载在0x100000,
则需计算物理地址:物理 = 虚拟地址 - 虚拟基地址。
步骤 4:加载 CR3 并开启分页
; 假设 page_directory 的物理地址在 eax 中
mov cr3, eax
; 开启分页:设置 CR0.PG = 1
mov eax, cr0
or eax, 0x80000000 ; bit 31 = PG
mov cr0, eax
; 刷新 TLB(可选,但推荐)
jmp $ ; 远跳转刷新流水线
🌟 开启分页后,所有内存访问都经过地址翻译!
🛠️ 五、完整启动流程(保护模式 + 分页)
- 实模式 → 保护模式(设置 GDT,加载 CR0.PE)
- 设置栈,调用 C 代码
- 在 C 中初始化页目录/页表(恒等映射)
- 加载 CR3
- 设置 CR0.PG = 1 → 分页开启!
此时,你的内核已运行在 带虚拟内存的保护模式 下!
⚠️ 六、常见陷阱与注意事项
1. 地址混淆:虚拟 vs 物理
- 页表/目录中存储的必须是物理地址
- 若内核链接在高地址(如
0xC0000000),需手动转换
2. 对齐要求
- 页目录、页表 必须 4KB 对齐
- 使用
__attribute__((aligned(4096)))或链接脚本控制
3. TLB 缓冲
- 修改页表后,需 刷新 TLB(如
invlpg指令,或远跳转) - 否则 CPU 可能使用旧映射
4. 栈位置
- 确保栈所在区域已被映射!否则开启分页后立即崩溃
🌱 七、下一步:更高级的内存布局
恒等映射只是起点。接下来你可以:
| 目标 | 说明 |
|---|---|
| 高半内核映射 | 将内核链接到 0xC0000000 以上,用户空间留低地址 |
| 动态页表管理 | 实现 map_page() / unmap_page() |
| 物理内存管理器 | 跟踪空闲物理页(Bitmap / Buddy System) |
| 缺页中断处理 | 实现按需分配、换页等高级功能 |
📚 这些将是后续
kmalloc、进程管理、文件系统的基础!
💬 写在最后
分页机制看似复杂,
但它正是现代操作系统安全、稳定、高效的基石。
今天你开启的不只是一个 CPU 特性,
而是虚拟内存时代的大门。
🌟 从恒等映射开始,你终将构建属于自己的地址空间。
📬 动手挑战:
尝试在你的内核中开启分页,并打印一个通过虚拟地址访问的变量地址。
欢迎在评论区分享你的实现或遇到的"玄学崩溃"!
👇 下一篇你想看:物理内存管理器(Bitmap 实现),还是 中断与 IDT 实战?
#操作系统 #内核开发 #x86 #分页 #虚拟内存 #Paging #内存管理 #从零开始
📢 彩蛋:关注后回复关键词 "paging",获取:
- 完整分页初始化 C 代码模板
- 恒等映射与高半内核映射对比图
- Intel 手册分页章节精要(中文)