从零写OS内核 | 虚拟内存——分页机制与线性地址的完整旅程
你写了一句 int *p = (int*)0x00401000; *p = 42;,这个地址是真实的物理地址吗?操作系统是怎么保证每个进程都觉得自己有 4GB 内存,而实际上物理内存只有 8GB?
答案是分页。
分页是现代操作系统虚拟内存管理的核心机制:CPU 把线性地址(程序看到的地址)通过页表转换成物理地址(硬件实际访问的地址),而操作系统负责建立和维护这张映射表,并通过页面置换算法让有限内存服务无限进程。
今天,我们来完整走一遍这个转换过程。
1. 为什么需要分页:连续与离散
如果不用分页,内存管理还有什么方式?
段式管理:每个程序一段连续空间,基址寄存器 + 限长寄存器。问题是外部碎片——每个程序都要连续的物理内存,大程序找空间难,小程序浪费空间。
分页:把物理内存和虚拟内存都切成固定大小的块(页)。程序的虚拟地址空间也切成同样大小的页(页面)。离散映射解决了外部碎片,内存利用率大大提高。
段式管理 vs 分页:
段式:每个进程一段连续空间
进程A: [物理 0-100K] 进程B: [物理 200-300K] 进程C: [物理 150-200K]
→ 大程序放不进去
分页:固定4KB块,映射到任意物理页
进程A虚拟页0 → 物理页5 进程A虚拟页1 → 物理页12
进程B虚拟页0 → 物理页7 进程B虚拟页1 → 物理页3
→ 物理页可以任意排列组合,无外部碎片2. x86 分页基础:两级页表
x86 的分页机制经历了两代:32位两级页表(IA-32)和 PAE 四级页表(IA-32e),以及 x86_64 的四级页表(PML4)。
2.1 32位两级页表(无PAE)
32位线性地址分解:
┌──────────┬──────────┬──────────┐
│ 目录(10) │ 页表(10) │ 偏移(12) │
└──────────┴──────────┴──────────┘
↑ ↑ ↑
CR3指向 页目录 4KB页内
页目录 (PDE) 偏移量
转换过程:
CR3(页目录基址)→ 找到页目录项(PDE)→ 找到页表(PTE)→ 找到物理页框 → 加偏移每一级索引 10 位,所以页目录和页表各有 1024 个条目(4KB,正好一个页)。
页目录项 (PDE) 格式(4字节):
┌─────────────────────────────────────────────────────────────────┐
│ Bit 31-12: 页表基址(高20位,4KB对齐) │
│ Bit 9: Reserved (0) │
│ Bit 8: PCD (Page Cache Disable) │
│ Bit 7: PWT (Write-Through) │
│ Bit 6: Accessed │
│ Bit 5: Reserved (0) │
│ Bit 4: PS (Page Size) = 0(表示指向页表,不是大页) │
│ Bit 3: P (Present) = 1 表示存在 │
│ Bit 2: R/W (Read/Write) │
│ Bit 1: U/S (User/Supervisor) │
│ Bit 0: P (Present) │
└─────────────────────────────────────────────────────────────────┘
页表项 (PTE) 格式(4字节):
┌─────────────────────────────────────────────────────────────────┐
│ Bit 31-12: 物理页框基址(高20位,4KB对齐) │
│ Bit 9-7: Global (TLB相关) │
│ Bit 6: PAT (Page Attribute Table) │
│ Bit 5: Dirty │
│ Bit 4: Accessed │
│ Bit 3: PCD │
│ Bit 2: PWT │
│ Bit 1: U/S │
│ Bit 0: R/W │
│ Bit 0: P (Present) │
└─────────────────────────────────────────────────────────────────┘2.2 为什么需要两级页表
如果用一级页表(直接 4GB / 4KB = 1,048,576 个 PTE),每个进程需要一个 4MB 的页表(1M × 4字节)。进程多了内存开销巨大。
两级页表的优势:按需分配。进程的虚拟地址空间可能只有 0x00000000~0x00400000(代码段)和 0xBFFF0000~0xC0000000(栈)等几个区间,不需要为整个 4GB 空间建立页表。
页目录本身也是 4KB(1024 个 PDE),如果某个 PDE 为空(Present=0),它指向的页表根本不需要分配。这叫层级页目录管理。
2.3 页面大小
标准页面是 4KB。但 x86 还支持大页(2MB 或 4MB)——通过 PDE 的 PS 位(Page Size)直接指向 2MB 物理区域,跳过二级页表。大页适合数据库等需要大块连续内存的场景。
3. 线性地址到物理地址的完整转换
用一个具体例子走一遍:
假设:
CR3 = 0x00123000(页目录物理地址)
要转换的线性地址:0x00401000
Step 1: 分解线性地址
高10位(目录索引)= 0x00401000 >> 22 = 0x01
中10位(页表索引)= (0x00401000 >> 12) & 0x3FF = 0x001
低12位(页内偏移)= 0x00401000 & 0xFFF = 0x000
Step 2: 读页目录
页目录物理地址 = CR3 = 0x00123000
页目录项地址 = 0x00123000 + 0x01 × 4 = 0x00123004
假设页目录项内容 = 0x00125003(页表存在,R/W=1,U/S=1)
→ 页表物理地址 = 0x00125000 & ~0xFFF = 0x00125000
Step 3: 读页表
页表物理地址 = 0x00125000
页表项地址 = 0x00125000 + 0x001 × 4 = 0x00125004
假设页表项内容 = 0x00D3E003(物理页存在,R/W=1,U/S=1)
→ 物理页框基址 = 0x00D3E000
Step 4: 组合物理地址
物理地址 = 物理页框基址 + 页内偏移
= 0x00D3E000 + 0x000
= 0x00D3E000完整流程图:
线性地址: 0x00401000
↓
[目录索引 0x01] → CR3(0x00123000) + 0x01×4 → 读PDE → 页表在 0x00125000
↓
[页表索引 0x001] → 页表(0x00125000) + 0x001×4 → 读PTE → 页在 0x00D3E000
↓
[偏移 0x000] → 物理地址 = 0x00D3E0004. 页面fault:虚拟内存的核心机制
虚拟内存不只解决地址翻译问题,还解决"内存不够用"的问题——当物理页不存在时,CPU 会触发 Page Fault(#PF)异常,操作系统介入处理。
4.1 缺页异常的三种情况
1. 合法访问,但页不在内存(Soft Page Fault)
- PTE 的 Present 位 = 0,但 PTE 其他位有效(指向交换区或文件)
- OS 从磁盘换入页面,更新 PTE,恢复执行
2. 非法访问(Access Violation)
- PTE 全为 0,或访问了用户态无权访问的地址
- OS 发送 SIGSEGV,杀死进程
3. Copy-on-Write(COW)
- 父子进程共享同一个物理页,但都标记为只读
- 写时触发 #PF,OS 复制物理页,更新 PTE,恢复执行
页面fault处理流程:
CPU 执行访问 → MMU 查页表 → PDE/PTE Present=0 → 触发 #PF
↓
OS 接管 #PF handler
↓
检查地址是否在进程合法 VMA(虚拟内存区域)中?
↓ 是 ↓ 否
↓ 发送 SIGSEGV
换入页面 杀死进程
↓
更新 PTE(设置 Present=1, 物理地址)
恢复执行4.2 换页策略
当物理内存真的不够时,OS 需要把某个页面换出到磁盘。Linux 用 LRU(最近最少使用)变体——活跃页面和非活跃页面分开管理,配合周期性扫描。
# 查看当前内存和换页情况
vmstat 1
procs -----------memory---------- ---swap-- -----io---- -system-- ------cpu-----
r b swpd free buff cache si so bi bo in cs us sy id wa st
0 0 0 655360 12340 234567 0 0 0 0 34 12 5 2 93 0 0
# si = swap in(换入页/秒),so = swap out(换出页/秒)
# si/so 非零说明正在换页5. PAE:突破4GB内存上限
32位 CPU 理论上能访问 4GB 地址空间,但 32位页表只能寻址 4GB 物理内存。如果机器有 8GB 物理内存,32位 OS 怎么用?
答案是 PAE(Physical Address Extension)。
5.1 PAE 的页表结构(3级)
PAE 下的线性地址分解(36位物理地址):
┌──────────┬──────────┬──────────┐
│ 目录指针 │ 页目录 │ 页表 │ → 9+9+9 = 27位索引
│ (2) │ (9) │ (9) │ → 剩余12位是偏移
└──────────┴──────────┴──────────┘
CR3 指向 Page Directory Pointer Table (PDPT),4项(每项4字节)
每项指向一个 Page Directory(512项)
每个 Page Directory 项指向一个 Page Table(512项)
每个 Page Table 项指向 4KB 物理页
最大物理寻址:2^36 = 64GB(PAE 实际是 36 位地址线)5.2 PAE 的限制
PAE 只是扩展了物理地址,虚拟地址仍然是 32 位。这意味着:
- 单个进程仍然只能使用 4GB 虚拟地址空间
- 要真正让单个进程使用超过 4GB,需要 IA-32e 模式(x86_64),它有 48 位虚拟地址
6. x86_64 分页:四级页表(PML4)
现代 64位 Linux 使用四级页表:
x86_64 线性地址(48有效位)分解:
┌────────┬────────┬────────┬────────┬────────────┐
│ PML4 │ PDPT │ PD │ PT │ 偏移 │
│ (9bit) │ (9bit) │ (9bit) │ (9bit) │ (12bit) │
└────────┴────────┴────────┴────────┴────────────┘
47-39 38-30 29-21 20-12 11-0
PML4(Page Map Level 4):CR3 指向,512项
PDPT(Page Directory Pointer Table):512项,每项指向一个 Page Directory
PD(Page Directory):512项,每项指向一个 Page Table
PT(Page Table):512项,每项指向 4KB 物理页
虚拟地址高 16 位是符号扩展(bit 47 ~ bit 63 必须相同,或全0或全1)
这就是为什么 x86_64 实际只有 48 位虚拟地址(用户空间通常是 47 位)Linux 把四级页表统一抽象为四级通用页表:PGD → PUD → PMD → PTE,屏蔽了硬件差异。
7. Linux 实践:观察页表和内存映射
# 查看进程的页表(页目录基址)
cat /proc/1/pagemap
# 找到某个虚拟地址对应的物理页
pmap 1
# 查看内存区域映射(进程的 VMA)
cat /proc/1/maps
# 观察页表占用(PSS 是实际占用的物理内存)
cat /proc/1/smaps_rollup# 查看系统内存布局(e820 内存映射对应页表初始化)
cat /proc/iomem
00000000-00000fff : Reserved
00001000-0009ffff : System RAM ← 0-640KB,低端内存
000a0000-000fffff : Reserved ← VGA/ROM 区
00100000-xxxxxxxx : System RAM ← 1MB 以上,可用于页表
...# 用 /proc/self/map_files 观察两个进程共享同一物理页(COW)
# 启动两个 cat 进程,看它们的 map_files
ls -la /proc/$$/map_files | head -20
# 或用 pagemap 查看指定虚拟地址的物理页帧号
python3 -c "
import os
with open('/proc/self/pagemap', 'rb') as f:
# 读取 /proc/self/maps 找到某个地址
pass
"8. 从零实现:wandos 的分页机制
⚠️ wandos 当前状态:kernel/memory/paging.cpp 是 wandos 实现虚拟内存管理的核心文件。wandos 使用和 Linux 类似的四级页表结构,以下分析其实现。
8.1 页面目录结构(paging.cpp)
// wandos 分页结构(基于 x86_64 架构)
// 源码:kernel/memory/paging.cpp
// 页表项结构
struct page_table_entry {
uint64_t present : 1; // 页是否存在
uint64_t writable : 1; // 读写权限
uint64_t user_access : 1; // 用户态可访问
uint64_t pcd : 1; // 禁用缓存
uint64_t accessed : 1; // 被访问过
uint64_t dirty : 1; // 被修改过
uint64_t pat : 1; // 页属性
uint64_t global : 1; // 全局页(TLB 不刷新)
uint64_t frame_addr : 40; // 物理页帧地址(高40位)
uint64_t reserved : 11; // 保留
uint64_t xd : 1; // 执行禁用(NX)
} __attribute__((packed));
// 四级页表指针
struct pml4_entry {
uint64_t present : 1;
uint64_t writable : 1;
uint64_t user_access : 1;
uint64_t pwt : 1;
uint64_t pcd : 1;
uint64_t accessed : 1;
uint64_t reserved1 : 1;
uint64_t ps : 1; // 页大小(0=普通页)
uint64_t reserved2 : 4;
uint64_t frame_addr : 40;
uint64_t reserved3 : 11;
uint64_t xd : 1;
} __attribute__((packed));8.2 虚拟地址翻译函数
// 虚拟地址 → 页表项查找
page_table_entry* walk_page_table(uint64_t virtual_addr, pml4_entry* pml4) {
// 提取四级索引
uint64_t pml4_idx = (virtual_addr >> 39) & 0x1FF;
uint64_t pdpt_idx = (virtual_addr >> 30) & 0x1FF;
uint64_t pd_idx = (virtual_addr >> 21) & 0x1FF;
uint64_t pt_idx = (virtual_addr >> 12) & 0x1FF;
// PML4 → PDPT
if (!pml4[pml4_idx].present) return NULL;
pdpt_entry* pdpt = (pdpt_entry*)(pml4[pml4_idx].frame_addr << 12);
// PDPT → PD
if (!pdpt[pdpt_idx].present) return NULL;
pd_entry* pd = (pd_entry*)(pdpt[pdpt_idx].frame_addr << 12);
// PD → PT
if (!pd[pd_idx].present) return NULL;
pt_entry* pt = (pt_entry*)(pd[pd_idx].frame_addr << 12);
// PT → 物理页
if (!pt[pt_idx].present) return NULL;
return &pt[pt_idx];
}8.3 页面映射建立
// 建立虚拟地址到物理地址的映射
void map_page(uint64_t virtual_addr, uint64_t physical_addr,
uint64_t flags, pml4_entry* pml4) {
page_table_entry* pte = walk_page_table_create(virtual_addr, pml4);
if (!pte) return; // 分配失败
pte->present = 1;
pte->writable = (flags & PTE_WRITABLE) ? 1 : 0;
pte->user_access = (flags & PTE_USER) ? 1 : 0;
pte->frame_addr = physical_addr >> 12; // 物理页帧号
pte->xd = (flags & PTE_NX) ? 1 : 0; // 执行禁用
// 刷新 TLB
invalidate_tlb(virtual_addr);
}8.4 wandos 和 Linux 的主要差异
wandos 的分页实现(paging.cpp)基本覆盖了 x86_64 分页的核心要素,但和 Linux 比还有以下差距:
- 没有换页机制:没有 swap 空间支持,所有虚拟地址必须对应真实物理页
- 没有 COW 实现:fork 时没有页面级 COW,只有简单复制
- 没有页面回收:内存不足时无法回收页面
- TLB 刷新策略不完整:没有考虑多核间的 TLB shootdown
9. 动手环节:实现一个页表遍历器
今天的目标:在 os-kernel-from-scratch 里实现一个页表遍历工具,给定任意虚拟地址,能打印出它的完整页表翻译路径(PML4→PDPT→PD→PT→物理页)。
任务 1:实现虚拟地址分解
给定 64位虚拟地址,打印出 PML4、PDPT、PD、PT、Offset 的索引值(十六进制格式)。
任务 2:实现四级页表遍历
读取 /proc/self/pagemap(需要 root),给定一个本进程的虚拟地址,打印出对应的物理页帧号。
验收标准
- 给定虚拟地址
0x7fff00001000,能正确打印:PML4=0x7f, PDPT=0x1, PD=0x00, PT=0x10, Offset=0x000 - 能从
/proc/self/pagemap读取并解析出物理帧号(如果 root)
10. 踩坑与注意事项
坑 1:页表分配时的递归映射
四级页表在建立早期页目录时,可能还没有页目录自己的物理页——这时需要用递归页表(Recursive Paging):让页目录的某个 entry 指向自己,这样通过固定的虚拟地址就能访问页目录本身。Linux 在早期初始化时就是用递归映射。
坑 2:写时复制(COW)的 PTE 权限
fork 之后父子共享物理页,PTE 标记为只读(Writable=0)。写时触发 #PF,PF handler 复制页面。如果误把 COW 页标为 Writable,父子都会获得独立副本,失去共享的意义。
坑 3:TLB 和页表的同步
修改页表后,如果不刷新 TLB,CPU 可能会继续用旧的翻译结果(TLB 是 CPU 内部的缓存)。修改 PTE 后必须 invlpg(单条)或 mov cr3, cr3(刷新整页目录)。多核系统中一个核修改了另一个核的页表,需要 IPI 中断触发 TLB shootdown。
坑 4:大页(Huge Page)的 PTE 和 PDE
大页(2MB)的 PDE 直接指向 2MB 物理区域,PS 位 = 1,这时 PDE 下面的页表不存在。访问大页内某个 4KB 子页不需要通过二级页表——是直接映射。大页的 PTE 格式和普通 PTE 不同,不能混用。
写在最后
分页是虚拟内存的心脏:通过固定大小的页和两级(或多级)页表,把”程序看到的地址”和”硬件使用的地址”完全解耦。这个解耦带来了:进程间隔离、内存超售(overcommit)、写时复制、内存映射文件……现代 OS 的所有高级特性都建立在这个基础上。
理解分页,你才真正理解为什么 malloc 可以分配超过物理内存的空间,以及为什么一个进程崩溃不会把整个系统带崩。
下篇预告:中断和异常处理——IDT 的设置、硬件中断与 CPU 异常。
相关阅读
- Linux Kernel Source:
arch/x86/mm/pgtable.c(页表管理) - Linux Kernel Source:
arch/x86/mm/fault.c(Page Fault 处理) - wandos:
kernel/memory/paging.cpp(分页实现) - wandos:
kernel/memory/slab_allocator.cpp(物理页分配) - 本文 Demo: https://github.com/golang12306/os-kernel-from-scratch (demos/paging/)
- https://github.com/zhangfuwen/wandos — Linux 内核教程
- 下一篇:《从零写OS内核 | 中断和异常处理——IDT的设置与硬件中断》
动手环节
想深入理解本文内容?动手实践是最好的方式:
今天的目标:下载 wandos 代码仓库,理解 paging.cpp 的实现,对比 Linux 原版。
-
下载 wandos:
git clone https://github.com/zhangfuwen/wandos.git cd wandos -
找到对应模块:查看
kernel/memory/paging.cpp的完整实现 -
实现作业:根据文中”动手环节”章节的要求,完成代码编写
-
提交作业:Fork 仓库,提交你的改动,在 GitHub 上开一个 Pull Request
评论