分页与页表
分页(Paging)是将内存划分为固定大小的"页"(Page/Frame),通过页表实现虚拟地址到物理地址的灵活映射——是现代操作系统内存管理的基石
从分段到分页
早期 OS 用分段(Segmentation)——程序分成代码段、数据段、栈段,每段在物理内存中连续放置。问题是:物理内存会碎片化。
分段的内存布局:
[代码段_空闲_数据段__空闲__栈段__空闲__]
↑ 碎片:每段之间的空隙无法利用
分页(Paging) 解决了这个问题:把虚拟地址空间和物理内存都切成固定大小的”格子”——页(Page,通常 4KB)。
虚拟页 物理页框
VP0 ────→ PP2
VP1 ────→ PP5 ┌──── pp0 ────┐
VP2 ────→ PP0 │ pp1 │
VP3 ────→ 不存在(未分配)│ pp2 ← VP0 │
│ pp3 │
│ pp4 │
│ pp5 ← VP1 │
└──────────────┘
🏫 类比:大学宿舍分配 以前(分段):整个系必须住在一层楼——新生来了,但没有一整层空着,只能挤一起。 现在(分页):每个人的床位是独立的——张三住 301-1,李四住 205-3,谁的行李放谁的柜子,不要求整层连续。
虚拟地址到物理地址的翻译
以 32 位系统、4KB 页为例(两级页表):
虚拟地址 0x12345678
分解为:
┌─────────┬─────────┬──────────┐
│ 页目录索引│ 页表索引 │ 页内偏移 │
│ 0x48 │ 0x123 │ 0x678 │
└─────────┴─────────┴──────────┘
10 位 10 位 12 位
↓
找到页目录项(PDE) → 找到页表 → 找到页表项(PTE)
↓
物理地址 = 物理页框号 × 4096 + 0x678
页表项(PTE)的结构
每个页表项(Page Table Entry)包含:
┌──────────────────────────────────────────────┐
│ 物理页框号(PPN) | 标志位(Flags) │
│ │ │
│ 20 位 │ 12 位 │
└──────────────────────────────────────────────┘
标志位:
bit 0: Present(P) — 此页是否在物理内存中
bit 1: Read/Write(R/W) — 可写还是只读
bit 2: User/Supervisor(U/S) — 用户态可访问?
bit 3: Page Write Through(PWT)— 是否写通?
bit 4: Page Cache Disable(PCD)— 禁止缓存?
bit 5: Accessed(A) — 是否被访问过
bit 6: Dirty(D) — 是否被写过
bit 7: Page Attribute Table(PAT)
bit 8: Global(G) — 全局页(切换进程时不刷新 TLB)
💡 Present 位是关键——如果它为 0,访问此页会触发缺页异常(Page Fault),操作系统会在异常处理中加载数据。这是虚拟内存的基础。
多级页表
32 位系统用 4KB 页、10-10-12 分割需要一级页表(约 4MB 页表空间,每个进程一张)。64 位系统用多级页表(4 级或 5 级):
x86-64 的四级页表(4KB 页):
虚拟地址(48 位):
┌──────┬──────┬──────┬──────┬────────┐
│ PML4 │ PDP │ PD │ PT │ 偏移 │
│ 9位 │ 9位 │ 9位 │ 9位 │ 12位 │
└──┬───┴──┬───┴──┬───┴──┬───┴──┬─────┘
│ │ │ │ │
▼ ▼ ▼ ▼ └──→ 页内偏移
┌─────┐┌─────┐┌─────┐┌─────┐
│PML4 ││ PDP ││ PD ││ PT │
└─────┘└─────┘└─────┘└─────┘
│ │ │ │
└──────┴──────┴──────┴──→ 物理页框号
为什么用多级页表?
- 节省内存:一级页表需要 4MB(所有页表项预先分配)
- 多级页表:大部分顶级目录项为空,其下的页表不需要分配
- 一个进程只用少数几块内存,多级页表按需分配,可能只需要几十 KB
💡 四级页表查询需要 4 次内存访问(PML4→PDP→PD→PT)+ 1 次数据访问——这太慢了!所以 CPU 有 TLB(Translation Lookaside Buffer) 来缓存最近使用的页表项。
页大小
| 页大小 | 优点 | 缺点 |
|---|---|---|
| 4KB(标准) | 细粒度,浪费少 | 页表大,TLB 覆盖率低 |
| 2MB(大页) | 页表小,TLB 覆盖更多内存 | 内部碎片更多 |
| 1GB(巨页) | 极大 TLB 覆盖 | 需要应用配合使用 |
# 查看系统的页大小
$ getconf PAGE_SIZE
4096 # 4KB
# 查看大页使用情况
$ cat /proc/meminfo | grep HugePages
HugePages_Total: 0
HugePages_Free: 0
💡 数据库和大型计算应用常用 2MB 或 1GB 大页——减少 TLB 未命中,提升内存访问性能。
分页中的内存保护
分页天然支持按页的权限控制:
// 用 mmap 创建不可执行的内存
void* buf = mmap(NULL, size, PROT_READ | PROT_WRITE,
MAP_PRIVATE | MAP_ANONYMOUS, -1, 0);
// buf 指向的内存不可执行(NX 位)
// 创建只读的代码段
void* code = mmap(NULL, code_size, PROT_READ | PROT_EXEC,
MAP_PRIVATE, fd, 0);
// code 指向的内存可读可执行,不可写
常见权限组合:
| 权限 | 典型用途 |
|---|---|
| r— | 只读数据(常量、只读文件映射) |
| r-x | 代码段(可读可执行) |
| rw- | 数据段、堆、栈(可读可写) |
| --- | 不可访问(guard page 检测栈溢出) |
Guard Page——检测溢出
// 在栈底放一个不可访问的页
mmap(NULL, 4096, PROT_NONE, MAP_ANONYMOUS | MAP_PRIVATE, -1, 0);
// 如果程序栈溢出到这个页 → 段错误 → 快速定位
上下文切换与页表
每当切换进程时,OS 必须切换页表:
; 切换进程的页表(x86-64)
mov cr3, [new_process_page_table] ; CR3 指向新进程的页表基址
⚠️ 切换
cr3会导致 TLB 全部失效(除非页表项标记了 Global 位)——这是进程切换的重要开销之一。
小结
| 概念 | 要点 |
|---|---|
| 分页 | 将内存切成固定大小的页,灵活映射 |
| 页表 | 虚拟页→物理页框的映射表 |
| 多级页表 | 节省内存,按需分配页表项 |
| 页表项 | 物理页框号 + Present/Dirty/Accessed 等标志位 |
| 页大小 | 4KB(标准)、2MB(大页)、1GB(巨页) |
| 访问权限 | 每页独立设置 r/w/x 保护 |
为什么先学这个? 分页是虚拟内存的基础。下一节看看虚拟内存与页面置换——当物理内存不够时,操作系统如何借用磁盘空间。