分页机制:页、页表与地址转换

在讲连续分配的时候,我们已经看到一个很现实的问题:内存明明还有不少空闲,却因为这些空闲被切得七零八落,导致一个稍大一点的程序反而装不进去。这就是外部碎片。为了解决它,系统尝试过各种适应算法、也尝试过紧凑(compaction),但这些办法要么治标不治本,要么代价高得离谱。

于是人们开始反过来想一个问题:

既然“连续”这件事本身就是麻烦的根源,那能不能干脆放弃连续,让程序的逻辑地址和物理内存之间不再要求一一对应的整块关系?

分页机制,正是顺着这个思路诞生的。它几乎是现代操作系统内存管理的基石,后面的虚拟内存、页面置换、TLB、内存映射文件,全都建立在它之上。可以说,理解了分页,你才算真正迈进了现代内存管理的大门。

一、为什么连续分配走不通

先把上一篇的结论收一下,这样才知道分页到底在替我们解决什么。

连续分配要求每个进程占用一整段连续的物理内存。这带来两个绕不开的麻烦:

  • 外部碎片:进程不断申请和释放,空闲内存被切成很多不相邻的小块,总量够但拼不出一段连续空间;
  • 分配不灵活:程序必须等到有一整块足够大的连续空间才能装入,哪怕系统总空闲远大于它的需求。

你可以把它想象成停车场只允许“连号车位”停车:哪怕零散空着十个位置,一辆需要三个连号的车也可能停不进去。

分页要做的,就是取消“连号”这个限制。

二、分页的核心思想:化整为零再重新映射

分页的做法非常直接,但威力巨大:

把逻辑地址空间和物理内存都切成大小相同的固定小块,然后允许它们之间任意映射。

  • 逻辑地址空间被切成的块,叫页(page)
  • 物理内存被切成的块,叫页框(page frame),也叫帧;
  • 页和页框大小严格一致(常见 4KB)。

关键在于:一个进程的若干个页,在物理内存里不需要连续存放。第 0 页可以在 5 号页框,第 1 页可以跑到 200 号页框,第 2 页又回到 8 号页框。只要有一张表记录“哪个页对应哪个页框”,CPU 访问时照着翻译就行。

这样一来,外部碎片就被彻底消灭了——因为任何一个空闲页框都能装下任何一个页,再也不存在“拼不出连续空间”的问题。

三、页表:映射关系存在哪里

既然页和页框是任意映射的,那这个对应关系必须有地方记录,这个数据结构就是页表(page table)

页表本质上是一个数组,每个进程都有自己的一张:

  • 数组的下标就是页号;
  • 数组某一项里存的内容,核心是这个页对应的页框号
  • 除了页框号,页表项里通常还带一些标志位,比如有效位、读写权限位、是否被访问过、是否被修改过等。

这里要特别强调一点,它也是初学者最容易轻视的地方:

页表不是一个抽象概念,而是操作系统在内存里真实维护、CPU 硬件真实查询的关键结构。

每个进程一张页表,进程切换时页表也要跟着切换——这正是不同进程地址空间彼此隔离的硬件基础。

四、地址转换:一个逻辑地址是怎么变成物理地址的

分页最精妙的地方,在于地址转换可以做得非常机械、非常快。我们来拆解一个逻辑地址被翻译的全过程。

CPU 发出的逻辑地址,会被硬件自动拆成两部分

  • 高位部分是页号
  • 低位部分是页内偏移

之所以能这么干净地一拆为二,是因为页的大小是 2 的整数次幂。比如页大小 4KB,那低 12 位天然就是页内偏移,剩下的高位天然就是页号,连减法都不用做,纯粹是二进制位的切分。

转换流程是这样的:

  1. CPU 给出逻辑地址,硬件拆出页号和页内偏移;
  2. 用页号作下标去查页表,得到对应的页框号;
  3. 把页框号和页内偏移拼接起来,就是最终的物理地址。

注意第三步是“拼接”而不是“相加”:页框号决定了物理地址的高位,页内偏移直接照搬到低位。因为页和页框大小相同,偏移在页内和帧内是完全一致的,搬过去就行。

五、为什么页大小总是 2 的幂

上一节其实已经埋了答案,这里单独点破,因为它是分页能高效运转的隐藏前提。

如果页大小是 2 的幂,地址拆分就退化成“取高几位、取低几位”的位操作,硬件做起来几乎不花时间。反过来,如果页大小是个奇怪的数字,拆分就得做除法和取模,那地址转换的代价会高到无法接受。

所以这不是随便定的,而是为了让每一次访存的地址转换都快到可以忽略。这种“为了让高频操作变快而在设计上做约束”的思路,在计算机系统里随处可见,值得你专门记一下。

六、内部碎片:分页消灭了外部碎片,却带来一点新代价

分页不是没有代价的。它解决了外部碎片,却引入了内部碎片

原因很简单:内存是按整页分配的。如果一个进程需要的内存不是页大小的整数倍,那最后一页就会有一部分用不上,但这部分又不能分给别人。

举个例子:页大小 4KB,进程实际需要 10KB,那它要占 3 个页(12KB),最后 2KB 就被浪费了,这就是内部碎片。

所以页大小的选择其实是个权衡:

  • 页太大,内部碎片浪费就多;
  • 页太小,页表项就会非常多,页表本身占用的空间和查询开销都会上升。

理解了这个权衡,你就明白为什么主流系统把页大小定在 4KB 附近——它是在两种浪费之间取的折中。

七、页表带来的两个现实问题

把分页落到真实系统里,立刻会冒出两个棘手问题,它们也是后续很多机制存在的理由。

1. 页表本身太大

每个进程一张页表,32 位地址空间、4KB 页的话,页表项可能多达上百万条。如果每个进程都老老实实存一张完整的线性页表,光页表就能把内存吃光。这正是后面多级页表、倒排页表等结构要解决的问题。

2. 每次访存都要多查一次页表,速度变慢

原本访问一次内存,现在变成“先访问内存里的页表拿到页框号,再访问真正的数据”——访存次数翻倍了。这显然不能接受。这正是**TLB(地址转换高速缓存)**存在的理由:把最近用过的页表项缓存在 CPU 里,绝大多数转换就不必真的去查内存。

你看,分页本身只是起点,它一落地就逼出了多级页表和 TLB。这也是为什么我反复说,分页是后面一长串机制的地基。

八、和工程实践 / 后端开发的联系

分页看起来很底层,但它的影响会一路渗透到你写的业务代码里。

  • 理解局部性为什么重要:程序访问越集中在少数几个页上,TLB 命中率越高、缺页越少,性能就越好。这解释了为什么顺序访问数组比随机跳转快得多。
  • 看懂内存相关的监控指标:缺页次数(page fault)、RSS(常驻内存)、内存映射这些指标,背后都是分页机制在起作用。
  • 数据库与运行时的借鉴:数据库的 buffer pool 按页管理磁盘数据、JVM 等运行时的内存管理,都借鉴了页级管理的思路。
  • 内存映射文件(mmap):很多高性能场景用 mmap 把文件直接映射进地址空间,本质就是在利用分页的映射能力。

一句话:分页不是考试名词,它是你理解“程序为什么这么快或这么慢”的一把钥匙。

九、动手做一个最小实验:手算一次地址转换

理解分页最有效的方式,就是亲手翻译一个地址。这个实验不需要任何环境,纸笔即可。

设定条件:

  • 页大小 = 4KB = 4096 字节,所以页内偏移占 低 12 位
  • 假设页表里记录:逻辑页号 2 → 物理页框号 5。

现在给一个逻辑地址:第 2 页,页内偏移 100

推算过程:

  1. 页号 = 2,查页表得到页框号 = 5;
  2. 物理地址 = 页框号 × 页大小 + 偏移 = 5 × 4096 + 100 = 20580。

你也可以反过来玩:随便给一个逻辑地址(比如 8292),先除以 4096 得到页号 2、余数 100 是偏移,再走上面的流程。多算几个,你对“拆分—查表—拼接”这条主线的理解,会比背十遍定义都扎实。

十、学习这一部分最容易踩的坑

1. 把页表当成可有可无的辅助结构

页表是 CPU 每次访存都要用到的核心结构,进程隔离、虚拟内存全靠它。轻视它,后面 TLB、多级页表就全都接不上。

2. 以为分页消灭了所有浪费

分页消灭的是外部碎片,但引入了内部碎片。认为“分页后内存就一点不浪费”是常见误解。

3. 把地址转换记成“相加”

页框号和偏移是拼接关系,不是算术相加。记错这一点,手算地址时必然出错。

4. 脱离整体孤立地背分页

分页是为了解决连续分配的碎片问题而生,又引出了多级页表和 TLB。只有放回这条因果链里,它才不是一个孤立的名词。

总结

分页机制把内存管理从“到处找一段连续空闲空间”,推进到了“建立灵活的页到页框映射关系”。真正值得抓住的,是下面这几点认识:

  • 分页通过把逻辑空间和物理内存都切成等大的页与页框,彻底消灭了外部碎片;
  • 页表记录页到页框的映射,是每个进程独立维护、CPU 硬件真实查询的核心结构;
  • 地址转换的本质是“拆分页号与偏移 → 查页表得页框号 → 拼接成物理地址”,靠页大小为 2 的幂做到极快;
  • 分页的代价是内部碎片,页大小是在内部碎片与页表开销之间的权衡;
  • 分页一落地就逼出了多级页表(解决页表太大)和 TLB(解决访存变慢)两大机制。

把这一篇真正吃透之后,再去看 TLB、多级页表和虚拟内存,你会发现它们都在回答同一个问题——如何在“页到页框可以任意映射”这个强大能力之上,把空间和速度的代价压到可以接受

参考资源

  • 《现代操作系统》(Andrew S. Tanenbaum)
  • 《操作系统导论》(Operating Systems: Three Easy Pieces)
  • OSTEP - Paging: Introduction
  • 《深入理解计算机系统》虚拟内存章节