从零写 OS 内核-第五篇:启动分页机制 —— 构建虚拟内存的基石

"段机制只是过渡,分页才是现代操作系统的真正护城河。
今天,我们亲手开启 x86 的分页功能,让内核拥有虚拟内存!"

在上一篇中,我们深入理解了 保护模式与段式内存管理,并亲手实现了从实模式到保护模式的切换。
但你可能已经听说:现代操作系统几乎不用段机制,而是依赖 分页(Paging) 实现内存隔离与虚拟化。

为什么?
因为分页提供了更灵活、更高效的内存管理方式:
✅ 支持非连续物理内存映射为连续虚拟地址
✅ 实现进程间内存隔离(安全!)
✅ 支持按需换页、共享内存、写时复制等高级特性

今天,我们就来在 32 位 x86 保护模式下,启动最基础的分页机制,为后续的内核开发打下坚实基础。


🧱 一、为什么需要分页?段机制不够吗?

段机制虽然能实现内存保护和特权级,但它存在致命缺陷:

问题 说明
段必须连续 一个段在物理内存中必须是连续的,无法利用碎片内存
段大小固定 段界限一旦设定,难以动态扩展
切换开销大 切换任务需切换整个段描述符表
硬件支持弱 现代 CPU 优化重点在分页,而非分段

分页机制将内存划分为固定大小的 页(Page)(x86 中通常为 4KB),通过 页表(Page Table)虚拟地址 → 物理地址 映射,完美解决上述问题。

💡 Linux、Windows 等现代 OS 都采用"平坦段模型 + 分页"
所有段都设为 0x00000000 ~ 0xFFFFFFFF段机制"退居二线"分页扛起内存管理大旗


🗺️ 二、x86 分页机制原理(32 位)

x86 32 位分页采用 两级页表结构

  1. 页目录(Page Directory):1 个,1024 项,每项 4 字节 → 4KB
  2. 页表(Page Table):最多 1024 个,每个 1024 项 → 每个 4KB

🔍 虚拟地址如何翻译?

32 位虚拟地址被划分为三部分:

| 31        22 | 21        12 | 11        0 |
| 页目录索引   | 页表索引     | 页内偏移     |
| (10 bits)    | (10 bits)    | (12 bits)    |

翻译流程:

  1. CPU 从 CR3 寄存器 读取 页目录基地址(物理地址)
  2. 高 10 位 作为索引,查页目录项 → 得到 页表基地址
  3. 中间 10 位 作为索引,查页表项 → 得到 物理页帧地址
  4. 拼接 低 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 $                 ; 远跳转刷新流水线

🌟 开启分页后,所有内存访问都经过地址翻译!


🛠️ 五、完整启动流程(保护模式 + 分页)

  1. 实模式保护模式(设置 GDT,加载 CR0.PE)
  2. 设置栈,调用 C 代码
  3. 在 C 中初始化页目录/页表(恒等映射)
  4. 加载 CR3
  5. 设置 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 手册分页章节精要(中文)