分页、分段与页表
基于通用操作系统概念 · 核于 2026-08
速查
- 分页(Paging):虚拟地址空间切成固定大小页(Page,4KB),物理内存切成同大小帧(Frame),页表记录"页号→帧号"映射。优点:无外部碎片(任意空闲帧能放任意页);缺点:内部碎片(最后一页填不满,平均浪费页大小一半)。
- 页表项(PTE):物理帧号 + 权限/状态位——Present(在内存?)、R/W(读写)、U/S(用户/内核)、Dirty(写过?换出要写回磁盘)、Accessed(访问过?供 LRU/Clock)。
- 单级页表占内存:32 位/4KB 页 → 单进程 4MB 页表;64 位更恐怖。无法承受。
- 多级页表:分级(x86-64 是 4 级:PGD→PUD→PMD→PTE),只为实际用到的虚拟地址建底层表,大量空洞只占上层一个"不存在"标记——省内存。代价:翻译多查几次(4 次),靠 TLB 缓存。
- TLB(Translation Lookaside Buffer):CPU 内页表项缓存,命中 1 个时钟周期,miss 才查内存页表。命中率 >99% 是 VM 性能关键;**大页(2MB/1GB)**减少 TLB 压力,PCID/ASID 让进程切换不必 flush TLB。
- 反置页表(Inverted Page Table):按物理帧号索引(而非虚拟页号),全系统只有一张,省内存——用于 PowerPC/IA-64。代价:查"虚拟→物理"需哈希,更复杂。
- 分段(Segmentation):按逻辑段(代码/数据/栈)切分,段大小可变。优点:符合程序结构、易共享;缺点:外部碎片(空闲块零碎不连续,难分配大块)。
- 段页式(Paged Segmentation):先分段、段内再分页——结合两者(逻辑清晰 + 无外部碎片)。x86 历史支持,现代 64 位 OS 用 flat 模式只用分页。
- 逻辑地址 vs 物理地址:逻辑地址=程序产生的地址(段:偏移 或纯虚拟地址);物理地址=送到内存总线的地址。两者经地址翻译(MMU + 页表)转换。
- 地址转换计算模板:①页大小→页内偏移位数(4KB=2^12→12 位);②虚拟地址拆 VPN(高位)+ offset(低位 12 位);③查页表 VPN→PFN;④物理地址 = PFN 拼接 offset。
- 共享页:多个进程的页表项映射同一物理帧——用于共享库(libc 只装入一份物理帧,各进程共享)、写时复制(COW)。
- 进阶顺序:本文讲地址翻译机制 → 下一步 页面置换算法与抖动 讲内存满淘汰谁的算法。
一、分页:固定大小的页与帧
分页把虚拟地址空间和物理内存都切成固定大小的块:
虚拟地址空间 物理内存(RAM)
┌──────────┐ ┌──────────┐
│ 页 0 │──────────────│ 帧 5 │
│ 页 1 │──────┐ │ 帧 6 │
│ 页 2 │ └──────│ 帧 7 │
│ 页 3 │──────────────│ 帧 2 │ ← 页可以不连续存放
│ 页 4 │──┐ │ 帧 8 │ (帧 5/7/2/8 非连续)
│ ... │ └───────────│ 帧 9 │
└──────────┘ (未装入) └──────────┘- 页(Page):虚拟地址空间的固定大小单位,通常 4KB(也有支持 2MB/1GB 大页)。
- 帧(Frame):物理内存的固定大小单位,与页大小相同。任意空闲帧都能放任意页——这是分页无外部碎片的原因(所有帧等大,没有"块大小不匹配"问题)。
- 页表:记录"虚拟页号 → 物理帧号"的映射表。每个进程一张,进程切换时切换页表(CR3 寄存器指向当前页表基址)。
- 内部碎片:分页的唯一碎片。假设页 4KB,一个 5KB 的结构占 2 页(8KB),浪费 3KB(平均每个分配浪费页大小的一半)。无法消除(除非页足够小,但页小了页表变大)。
二、页表项 PTE:映射与权限位
页表由大量**页表项(PTE)**组成,每项记录一个虚拟页的信息:
PTE 格式(简化):
┌──────────────┬─────┬─────┬─────┬─────────┬────────┬─────┐
│ 物理帧号 PFN │ P/R │ U/S │ R/W │Accessed │ Dirty │ ... │
└──────────────┴─────┴─────┴─────┴─────────┴────────┴─────┘
Present 位:1=页在内存,0=不在(缺页)- Present 位:最关键。1 表示页在物理内存,0 表示不在(可能换出到 swap、或未分配、或 COW 触发)。访问 Present=0 的页触发缺页中断。
- R/W 位:读写权限。只读页被写时(如 COW 共享页)触发缺页,内核复制一份。
- U/S 位:用户/内核权限。内核页 U/S=0,用户态访问触发保护异常(这是隔离内核内存的机制)。
- Dirty 位:该页自装入后是否被写过。换出时若 Dirty=1 要写回磁盘(内容变了),Dirty=0 可直接丢弃(磁盘已有最新副本,如代码段)。
- Accessed 位:该页最近是否被访问过。页面置换算法(Clock/二次机会)依据它判断页是否活跃——硬件在访问时自动置位,OS 周期性清零来采样。
三、多级页表:省内存的分级结构
单级页表的问题是占内存太大:
假设 32 位地址、4KB 页:
- 虚拟地址空间 = 2^32 = 4GB
- 页数 = 2^32 / 2^12 = 2^20 = 100 万页
- 每个页表项 4 字节 → 单进程页表 = 4MB
- 100 个进程 = 400MB 全是页表(物理内存才几 GB)
64 位地址更恐怖:2^48 → 单进程页表 PB 级,根本存不下多级页表的省内存之道:把页表分级,只为实际用到的虚拟地址范围建底层表:
4 级页表(x86-64,4KB 页):
虚拟地址 48 位 = [9位 PGD 索引][9位 PUD][9位 PMD][9位 PTE][12位 offset]
PGD(顶级) → PUD → PMD → PTE(底层) → 物理帧
- 大量未使用的虚拟地址(如 0x0 到 0x400000 之间的空洞)
只需 PGD 一个"不存在"项,下面三级都不建 → 省海量空间
- 只有实际用到的地址(堆/栈/代码/数据段)才建完整四级表- 省内存的本质:单级页表必须为整个虚拟地址空间预留表项(即使 99% 不用);多级页表只为用到的部分建表,未用的部分上层一个标记即可。
- 代价:翻译层级多:4 级页表意味着一次翻译要 4 次内存访问(查 PGD→PUD→PMD→PTE),比单级的 1 次慢得多。必须靠 TLB 缓存——99%+ 的访问走 TLB,只有 miss 才走多级页表。
- 5 级页表:随着物理内存增大(TB 级),x86-64 引入 5 级页表(LA57),虚拟地址扩展到 57 位。
四、TLB:页表项的硬件缓存
TLB(Translation Lookaside Buffer)是 CPU 内部缓存最近用到的页表项的高速缓存:
CPU 访存(虚拟地址)
│
▼
┌─────────────────────────────────┐
│ TLB 查询(1 个时钟周期) │
│ 命中 → 直接拿物理帧号 ✓ │ 99%+ 的访问走这里
│ miss → 走页表(多级,慢) │
└──────────────┬──────────────────┘
▼
物理地址 → 访 DRAM- TLB 容量小但关键:通常 64-2048 项,每项映射一个 4KB 页(覆盖 256KB-8MB)。对局部性好的程序,命中率 > 99%。
- TLB miss 的代价:4 级页表要 4 次内存访问(每次几十 ns),单次 miss 可能耗时数百 ns——比访存本身慢 5 倍以上。
- 大页(Huge Pages):用 2MB 或 1GB 大页,一项 TLB 项覆盖范围扩大 512 倍/262144 倍,显著减少 TLB miss。数据库(PostgreSQL/Oracle)、JVM、Hypervisor 常开启大页以降低 TLB 压力。
- 进程切换与 TLB:传统上进程切换要 flush TLB(否则 A 进程用 B 的旧映射会出错),但 flush 后新进程要重新预热 TLB(前期大量 miss)。现代 CPU 用 PCID(Process Context ID,x86)/ASID(ARM) 给 TLB 项打进程标签,进程切换时不必全 flush,新进程的 TLB 项可能还在——显著提升上下文切换性能。
五、反置页表(进阶)
传统页表按虚拟页号索引,每个进程一张。反置页表(Inverted Page Table, IPT)反过来——按物理帧号索引,全系统只有一张:
- 思路:表的大小 = 物理帧数(与进程数无关),无论多少进程都只有一张表,省内存。
- 查找:要从"虚拟地址"找"物理地址",需要反向查——用 (进程 ID, 虚拟页号) 做哈希,在 IPT 里找对应的物理帧。通常配合哈希表加速。
- 应用:PowerPC、IA-64(Itanium)使用。x86/Linux 仍用传统多级页表(多级已足够省内存)。
六、分段:按逻辑段切分
分段(Segmentation)是比分页更早的地址空间组织方式,按程序的逻辑段切分:
程序的逻辑结构:
┌──────────┐ 代码段(只读、可共享)
│ main() │
├──────────┤ 数据段(读写)
│ 全局变量 │
├──────────┤ 堆段(向上增长)
│ ↑ │
│ ... │
│ ↓ │ 栈段(向下增长)
└──────────┘- 段表(段寄存器):每个段有 段基址(base)+ 段限(limit)+ 权限。虚拟地址 = 段号 + 段内偏移;物理地址 = 段基址 + 段内偏移。
- 优点:符合程序逻辑结构(代码段/数据段天然分离)、易于按段共享(共享代码段)、易于按段保护(代码段只读、数据段读写)。
- 缺点:外部碎片。段大小可变,频繁分配回收后,物理内存出现大量零碎的空闲块——可能总空闲够,但没有一个连续大块能装新段。需要紧凑(compaction,移动段合并空闲块,代价极大)。
- 为什么被淘汰:外部碎片是长期运行的系统的灾难(无法根治)。x86 历史上支持段页式(先分段、段内再分页),但现代 64 位 OS 用 flat 模式(段基址=0、段限=最大),相当于关闭分段、只用分页。
七、段页式:结合两者
段页式(Paged Segmentation)试图结合分段与分页的优点:
- 机制:先按逻辑分段,每个段内再分页。地址翻译:虚拟地址 = 段号 + 段内页号 + 页内偏移 → 查段表得段基址 → 段内查页表得物理帧。
- 优点:保留了段的逻辑清晰(易共享/保护)+ 分页的无外部碎片(段内按页分配,无空洞)。
- 缺点:地址翻译两层(段表 + 页表),更复杂。
- 现状:x86 硬件支持段页式,但实际部署中 OS 多用 flat 模式(关闭分段),所以段页式主要是教科书概念,工程上少用。
八、地址转换计算:解题模板
题型:给定页大小、页表(或部分映射)、虚拟地址,求物理地址。
解题步骤:
已知:页大小 = 4KB = 2^12 字节
页表(部分):
VPN 0 → PFN 5
VPN 1 → PFN 9
VPN 2 → PFN 3
求虚拟地址 0x1543 的物理地址。
步骤:
1. 页内偏移位数 = log2(页大小) = log2(4096) = 12 位
→ offset = 虚拟地址 & (0xFFF) = 0x1543 & 0xFFF = 0x543
2. VPN = 虚拟地址 >> 12 = 0x1543 >> 12 = 0x1(即页号 1)
3. 查页表:VPN 1 → PFN 9
4. 物理地址 = (PFN << 12) | offset = (9 << 12) | 0x543
= 0x9000 | 0x543 = 0x9543关键点:
- offset 位数 = log2(页大小):4KB→12 位,8KB→13 位,64KB→16 位。
- offset 不变:虚拟地址的 offset 直接搬到物理地址(页内偏移不变)。
- VPN 是高位:虚拟地址去掉 offset 位剩下的就是 VPN。
- PFN 来自页表:查 VPN 对应的 PFN,物理地址 = (PFN << offset位数) | offset。
- 特殊情况:VPN 查不到(Present=0)→ 缺页中断;VPN 越界 → 段错误(SIGSEGV)。
九、共享页:节省内存的利器
多个进程可以共享同一个物理帧——只要它们的页表项都映射到该帧:
- 共享库(shared library):libc.so 被几百个进程链接,但物理内存里只装入一份代码段帧,各进程的页表都映射到这些帧。代码段只读,共享安全;数据段每个进程有独立副本(写时复制或独立分配)。
- 写时复制(COW):
fork()后父子进程共享物理页(页表都映射同一帧,标记只读)。任一方写时触发缺页(写只读页),内核才复制一份新帧——延迟复制,绝大多数 fork 后立即 exec(丢弃共享页),避免无谓复制。 - 共享内存 IPC:
shmget/mmap(MAP_SHARED)让多个进程的页表映射同一物理帧,直接读写通信(最快的 IPC)。
下一步
讲清了地址翻译机制(页表/多级/TLB/分段)后,下一步解决"物理帧满了淘汰谁"——页面置换算法与抖动 讲 FIFO/LRU/最优/Clock 的抉择、Belady 异常、抖动与工作集理论。