高级 #os#memory#paging

分页与页表

分页(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 保护

为什么先学这个? 分页是虚拟内存的基础。下一节看看虚拟内存与页面置换——当物理内存不够时,操作系统如何借用磁盘空间。