打开你手边任何一个程序,打印出某个指针的值,比如0x7ffe3a1b2c40。你以为这是内存条上的真实位置?不是。这个地址甚至都不对应你电脑里任何一根内存颗粒上的物理单元,它是一个“虚拟地址”。把虚拟地址翻译成真实物理地址的那套机制,就是操作系统里的页式内存管理。这也是几乎所有计算机专业学生学操作系统时绕不开的第一个硬骨头,考研要考,面试要问,做实验时缺页中断还会突然蹦出来恶心你一下。
这篇东西我想用一篇完整博文的篇幅,从“为什么需要分页”讲到“现代CPU和Linux里的多级页表”,中间把地址转换、TLB、缺页中断、页面置换这些核心概念全部串起来。不堆术语,每个概念都配例子和手算过程,适合三类人看:正在复习操作系统准备考试的学生、被学校实验课折腾的本科生、以及自学编程想搞清楚“内存地址到底是什么”的非科班开发者。
1. 分页出现之前:物理内存直接裸奔的时代
1.1 直接使用物理地址的三个麻烦
早期操作系统比现在简单得多,进程看到的内存就是物理内存,程序里写的地址就是真实插在主板上的内存条位置。听起来挺直接,但直接使用物理地址会带来一连串问题。
第一个麻烦是进程间互相踩踏。如果你是一个进程,你的地址空间从0x1000到0x2000;另一个进程从0x2000到0x3000。大家有没有越界?谁也不知道,因为程序里的地址就是物理地址,访问内存不需要任何中间人把关。一旦某个程序写错了指针,它可能直接抹掉另一个进程的内存数据,整个系统当场崩溃。那个时代没有“段错误(Segmentation Fault)”这种温和的说法,出错的程序会直接把系统搞挂。
第二个麻烦是加载位置必须写死或整体搬迁。编译器在生成可执行文件时,如果默认程序加载到物理地址0x1000,那所有指令里的地址都按0x1000计算。可内存里不可能永远给这个程序留着一块从0x1000开始的连续空间。于是就有了重定位技术,程序加载到别的起始地址时,把所有的地址统一加上一个偏移量。但这个方案只解决了“整体搬家”,它要求程序还是占一整段连续的物理内存,位置挪了但内部结构一点都不能散。
第三个麻烦更棘手——物理内存有限,而进程必须整体装进内存才能运行。如果系统只有256MB内存,一个需要300MB的程序就永远无法启动,哪怕它的代码和数据可以分段载入、部分使用也不行。这就是后来虚拟内存和请求分页要解决的核心痛点。
1.2 连续分配与外部碎片:搬家的代价
在分页出现之前,操作系统主流的方案是连续内存分配。固定分区最简单,提前把内存切成几个固定大小的区域,每个区域放一个进程,但大进程装不进小分区,小进程占着大分区又浪费。动态分区看上去灵活一点,进程需要多大就从空闲内存里找一块刚好够大的连续区域给它,但系统跑起来之后,随着进程反复创建和退出,内存会被切成很多大小不等的小洞。
这些小洞单独看都不大,合起来总量却不小,这就是外部碎片。你没法用它们,因为它们不连续。解决办法有两个方向:一是用各种放置算法(首次适应、最佳适应、最差适应)尽量挑合适的洞来减少碎片,二是做压缩——把运行中的进程内存整体搬到一起,腾出一块大的连续空间。压缩听起来可行,但搬移一段正在运行的进程的内存是极其昂贵的操作,需要暂停进程、复制全部数据、修改所有地址引用,系统越忙这个操作越伤。
为什么连续分配这么费劲?最根本的原因是它强加了一个不必要的约束:一个进程的逻辑地址空间必须在物理内存里连续存放。好像你要在图书馆里给一个读者找座位,规定他的所有书必须放在连续的同一排书架,找不到那么长的一排,他就没法看书。这个约束真的必要吗?分页给出的答案是:没有必要。
1.3 分页的破题思路:放开连续性的约束
分页管理的核心思想一句话就能说清:把进程的逻辑地址空间切成等长的小块,这些小块叫做“页”;把物理内存也切成同样等长的小块,叫做“页框”或“帧”。逻辑上连续的每一页,可以放到物理内存中任意一个空闲页框里,不需要连续。
这就意味着,一个程序的“第0页”可能放在物理页框5,“第1页”放在物理页框19,“第2页”放在物理页框7。代码和数据在物理内存里像散落一地的拼图,但只要操作系统维护一张记录“每个逻辑页放在哪个物理页框”的表格,CPU 就能按图索骥找到真实数据。
这个思路的厉害之处在于:它同时解决了前面说的所有问题。程序不再需要一整块连续物理内存,外部碎片彻底消失,剩下的只是页内部微小的“内部碎片”。进程之间天然隔离,因为每个进程有自己的翻译表,进程A的第0页可以映射到页框10,进程B的第0页可以映射到页框200,互不干扰。一个进程也无需全部载入内存——暂时没被用到的页可以干脆不映射,用到时再临时调入。所有这些,都指向一张神奇的表:页表。
2. 页式管理的核心构造:页、页框与页表
2.1 页与页框:两套等长的世界观
要理解分页,脑子里必须时刻保持两套坐标。一套是进程的虚拟世界,一套是物理内存的真实世界。虚拟世界被切成一页一页,叫页;物理世界被切成同样大小的格子,叫页框。页和页框的尺寸完全一致,这是整个机制能够成立的前提。
为什么尺寸必须一致?因为只有一致,虚拟页和物理页框之间的映射才是整页整页对应,地址转换时可以只换算页号,页内偏移量不用动。为什么页大小必须是2的幂(比如4KB=4096=2^12)?因为2的幂让地址拆分变成纯位运算——高一些位是页号,低12位是偏移量,换算物理地址只需要移位和按位或,硬件做起来极其高效。如果页大小是个奇怪的数字,比如1000字节,那每次地址转换都要做除法取余数,CPU得疯。
对单页大小直观理解一下:4KB意味着一个页最多存4096字节,如果一个程序需要4097字节,它就要占两页,第二页只用了1字节,剩余的4095字节就浪费了。这就是分页引入的唯一一种新的浪费——页内碎片,也叫内部碎片。它平均每页浪费半个页,听起来不划算,但对于消灭外部碎片这种巨大的收益来说,代价已经很小了。
2.2 页表:按虚拟页号索引的翻译表
页表是一张存储在内核内存里的数据结构,每个进程一张。它的本质是一个数组,数组的下标是虚拟页号,数组元素是页表项(PTE),页表项里记录着这个虚拟页对应的物理页框号。
用索引查表,意味着页表天然就是为快速查找设计的:CPU拿到一个虚拟地址,拆出虚拟页号,拿页号直接去查页表对应下标的那一项,不需要遍历、不需要匹配,一次数组访问就能拿到物理页框号。这个结构简单粗暴,但在它身上已经能体现出内存管理的精髓——每个进程一张页表,是进程之间内存隔离的最底层依据。
设想没有页表的世界:两个进程同时访问虚拟地址0x1000,指令会最终落到同一个物理位置,那其中一个进程改了数据,另一个进程立刻被影响。有了页表,进程A把0x1000映射到页框3,进程B把0x1000映射到页框50,两边看起来都在用“同一个地址”,实际却住在完全不同的物理房间里。这就是现代操作系统让进程互不干扰的底牌。
2.3 为什么经典页大小是4KB
页大小绝不是拍脑袋定的,它是一个多方权衡的结果。页越小,页内碎片越少,按需调入内存时越精细,内存利用率越高;但页越小同样大小的地址空间需要的页数就越多,页表就越庞大。页越大,页表越小,CPU里的快表(TLB,后文详述)能覆盖的内存范围也越大,大块数据传输效率越高;但内碎片增多,而且程序很小的时候,大页也会带来明显浪费。
经典折中结果是4KB,这个值从x86诞生初期一直用到今天。一个32位地址空间(4GB)按4KB分页,正好是2^20 = 1,048,576个页,页表项占4字节,整张页表大约4MB。这个数字在当年的硬件条件下还能接受。现代大内存服务器和高性能场景会用到2MB甚至1GB的大页(Huge Pages),原因很简单:页越大,TLB能覆盖的内存越多,某些极端性能场景下命中率提升立竿见影。但这些都只是参数调整,页式管理的骨架从40多年前到今天没有本质改变。
3. 一次地址转换的完整路线图:手把手推演到物理地址
3.1 地址拆解:高位是页号,低位是偏移
理解了页表和页框之后,真正核心的操作就是地址转换。以最常见的32位系统、4KB页大小为例,一个虚拟地址(逻辑地址)有32位。低12位叫页内偏移量,因为它表示地址在某一页内部从第0字节到第4095字节的哪个位置。剩下的高20位就是虚拟页号。
地址拆分公式非常简单:
- 虚拟页号 = 虚拟地址 >> 12(32位地址右移12位)
- 页内偏移量 = 虚拟地址 & 0xFFF(取低12位)
比如虚拟地址0x2A1C,十六进制展开为二进制,低12位是0xA1C,那么页内偏移量就是0xA1C;右移12位后高20位是2,说明它在虚拟页2。这一步是纯位运算,没有任何除法或取模的开销,硬件可以在一两个时钟周期内完成。
3.2 页表项:除了帧号,还藏着权限和状态
很多初学者以为页表项就是一个“帧号”字段,其实真正的页表项里带着一堆状态位,这些位是内存保护和虚拟内存机制能工作的关键。一个典型的页表项至少包含以下字段:
| 字段 | 作用 |
|---|---|
| 物理页框号 | 该虚拟页对应的物理页框地址,这是核心中的核心 |
| 存在位(Present) | 该页是否已经被加载进物理内存,0表示缺页 |
| 读写位(R/W) | 该页是否可写,0表示只读 |
| 用户位(U/S) | 该页是否允许用户态访问,0表示仅内核可访问 |
| 访问位(Accessed) | 该页最近是否被访问过,页面置换算法会用到 |
| 脏位(Dirty) | 该页是否被写入过,决定换出时需不需要回写到磁盘 |
让我着重解释一下存在位。如果一个虚拟页已经映射到物理页框,存在位是1,地址转换直接成功;如果存在位是0,意味着这一页数据此刻不在内存里,CPU 会触发一个缺页异常(Page Fault),切换到内核态去处理。这种设计让虚拟内存成为可能:程序自己以为整块地址空间都在内存里,实际上操作系统只把活跃的页放进物理内存,不活跃的页躺在磁盘的交换分区或者原始文件里,要用的时候再临时读入。
权限位也不只是好看。典型的崩溃场景——你写了一个C程序往NULL指针地址写数据,其实NULL这个虚拟页在页表里压根没有对应项,存在位为0或者干脆整项为空,CPU 一访问就知道非法,直接把进程打死并报出“Segmentation Fault”。也就是说,你平时见到的一切段错误,底层都是页表机制在执法。
3.3 一次手算:从0x223C到物理地址
光讲理论还是有点虚,我们动手算一次完整的地址转换。假设一个32位系统,页大小4KB,当前进程的页表内容如下:
| 虚拟页号 | 页表项内容 |
|---|---|
| 0 | 物理页框9,存在位1,可读可写 |
| 1 | 物理页框3,存在位1,可读可写 |
| 2 | 物理页框5,存在位1,可读可写 |
| 3 | 物理页框7,存在位0(未加载) |
现在程序访问虚拟地址0x223C。第一步拆地址:0x223C的低12位是0x23C,右移12位后虚拟页号是2。第二步查页表:页表第2项说物理页框是5。第三步计算物理地址:物理页框5的起始地址是5 × 0x1000 = 0x5000,加上页内偏移0x23C,得到物理地址0x523C。
再试一个:访问0x323C,虚拟页号是3,但页表第3项存在位是0。于是 CPU 无法完成地址转换,触发缺页异常,操作系统接手把第3页从磁盘读入某个空闲页框(比如页框12),更新页表,然后重新执行这条访问指令。这次能成功了,物理地址变成0xC23C。
注意一个有意思的细节:物理地址就是把页表项里的物理页框号放高位,然后把虚拟地址的页内偏移量原封不动地放到低位。虚拟页号和物理页框号可能完全不同,但偏移量在转换前后一字不差。这就是为什么页和页框必须等长——偏移量不需要做任何换算。
4. 快表TLB:分页机制最大的性能闸门
4.1 分页的最初代价:一次访存变两次
页表放在哪里?物理内存里。那CPU访问一个数据要经历什么过程?先把虚拟地址拆开,去内存里读页表项,拿到物理页框号;再拿着算出来的物理地址去内存里读真正的数据。也就是说,一次普通的内存访问,在引入分页后变成了两次内存访问。
内存访问本来就需要几十到上百纳秒,翻倍之后所有程序都要变慢将近一半,这个代价在计算机性能世界里是灾难级的。如果每次地址转换都要完整地走一遍“读内存里的页表→访问数据”,分页机制根本没有实用价值。靠什么化解?靠局部性原理和一级高速缓存——TLB。
4.2 TLB命中与失效:局部性决定速度
程序在短时间内访问的地址总是集中在一个较小的区域内,比如循环里反复读取同一个数组元素、连续执行相近的指令。这意味着页表访问也有很强的局部性——刚刚查过的页,大概率马上还会再查。于是CPU在芯片内部加入了一个专门缓存页号到页框号映射关系的小型高速缓存,就是TLB(Translation Lookaside Buffer),也叫快表或转译后备缓冲器。
TLB的工作原理和普通缓存一样,以虚拟页号为索引,快速查找对应的物理页框号。如果命中,地址转换只需要几个时钟周期,一次访存变回一次真正的内存访问;如果没命中,CPU 就必须回到内存里的页表重新查询,然后把新映射关系填入TLB。
一个直观的对比:现代CPU的L1缓存访问大约4个时钟周期,访问一次内存大约100多个时钟周期,而一个TLB命中只需要1到2个周期。TLB的命中率通常在99%以上,这1%以上的失效代价却不可忽视——每次失效都可能引入一次完整的内存页表查询,在极端情况下会把一批连续访问的性能拖垮。也正因为TLB容量有限(通常只有几十到几百项),页大小改为2MB或1GB就很有吸引力:一个TLB条目能覆盖的内存范围从4KB跳到2MB,有效命中率大幅提升。
4.3 ASID与上下文切换:别让进程互相串台
TLB是硬件资源,但多个进程共用同一个CPU,进程切换时TLB里的映射关系会出现一个大问题:进程A留下的页表映射对进程B完全无效,甚至有害——如果B的虚拟页号刚巧和A用过的一样,TLB命中会把B的虚拟地址错误地翻译成A的物理页框。
最简单的解决办法是进程切换时把整个TLB清空,叫做TLB刷新。清空之后新进程第一次访问内存时TLB全是空的,大量失效,性能会有一个明显的低谷。尤其在一个进程快速切换进出的场景下,反复刷新TLB的成本很高。
现代CPU引入了ASID(Address Space Identifier,地址空间标识符),给每个进程分配一个唯一的ID,TLB的每个条目记录自己属于哪个ASID。地址转换时,只有ASID匹配的TLB条目才会命中,进程切换不再需要把整个TLB刷掉,只要新的进程带着自己的ASID进来查就行了。这是一个典型的用空间换时间的优化,也是为什么现在的操作系统在进程切换上能比早期系统轻快那么多。
5. 缺页与页面置换:当内存装不下你的工作集
5.1 请求分页:用到哪页才载哪页
前面我们已经看到,页表里的存在位可以是0,表示这一页不在物理内存。这种“允许部分页不在内存”的设计,催生了虚拟内存的经典玩法——请求分页。系统启动一个程序时,并不把整个可执行文件全部装入内存,只加载最开始需要的那部分页,剩下的页在磁盘上待命。程序运行过程中访问到某个缺失的页,硬件触发缺页异常,操作系统从磁盘读出该页、放入空闲页框、更新页表,然后让程序继续执行。
这个过程频繁到你可能没意识到:你打开一个大型软件,进度条走得慢,其实很大一部分时间就是在触发缺页、从磁盘读页。我自己写过一段程序去申请4GB内存但只逐个字节写,观察真实的物理内存占用(RES)变化,会发现内存占用是阶梯式增长的,而不是一次性涨到位——这正是请求分页在背后运作的证据。
缺页异常是硬件和操作系统合作的一个经典场景。CPU在地址转换阶段发现存在位为0,硬件层面什么事都做不了,立刻触发一个保留给操作系统的异常,把控制权交给内核的缺页处理函数。内核分配物理页框、从磁盘调入数据、修改页表,然后回到用户态重新执行那条导致缺页的指令。整个过程对应用程序完全透明,程序只觉得自己“慢了那么一下”,根本不知道背后已经发生了一场磁盘IO。
5.2 置换算法的选择:OPT是梦想,LRU是理想,CLOCK是现实
缺页频繁发生时,物理内存会被慢慢填满。当系统需要调入一个新页,却发现所有页框都在使用中,就必须置换——踢出一个旧页,把页框让给新页。问题来了:踢谁?
教科书里先给了理论天花板,OPT算法,最佳置换算法:淘汰以后最长时间不会被访问的页。这个算法缺页率最低,但它需要预知未来的访问序列,现实中做不到,所以它只作为衡量其他算法好坏的参照系。
考研和面试最常考的是LRU(最近最少使用)。思路很直觉:如果一个页过去一段时间都没被访问,那未来一段时间大概也不会被访问,先淘汰它。LRU在理论上非常接近OPT,但实现起来极其麻烦——要为每一次页访问记录确切时间,还要维护一个按访问时间排序的链表,每次访问都要调整链表位置,硬件开销大到不可接受。所以真实的操作系统几乎不会实现纯LRU。
工程上真正大量使用的是CLOCK算法,也叫第二次机会算法。它用一种优雅的近似方式模拟LRU:每个页有一个访问位,页面被访问时硬件置1;系统有一个循环指针扫描页框,遇到访问位为1的页就把位清0、继续找下一个,遇到访问位为0的页就淘汰它。这个“给第二次机会”的思路,相当于为每个页保留了最近的访问历史,实现成本极低,效果却非常接近LRU。你用的Linux内核里,页面回收的核心逻辑就是各种CLOCK变种。
5.3 Belady异常与工作集:为什么加内存有时更卡
页面置换算法里有一个反直觉的现象值得单独拎出来说。通常我们觉得,给一个进程分配更多的页框,缺页应该更少、性能应该更好。但FIFO算法会出现一种反常情况:页框变多,缺页反而增加,这就是Belady异常。
我们用一个经典访问序列验证一下:1, 2, 3, 4, 1, 2, 5, 1, 2, 3, 4, 5。用FIFO算法,3个页框时缺页9次;4个页框时缺页反而变成10次。多给你一页内存,换来的却是更差的性能。
| 页框数 | 缺页次数 | 结果 |
|---|---|---|
| 3 | 9 | FIFO算法的正常表现 |
| 4 | 10 | 内存多了,缺页反而多了,Belady异常 |
LRU和CLOCK这类“栈式算法”不会出现Belady异常,但陷阱已经足够让人警醒:内存不是越多就一定越快,置换策略不当时,更大的内存可能引入更糟的行为模式。
更深一层的问题是抖动。如果系统里运行的进程太多,每个进程活跃的页面加在一起已经超过了物理内存总量,那么系统会把大量时间花在缺页和换页上,真正执行程序的时间占比极低。宏观表现就是电脑卡到鼠标都拖不动,CPU占用率看起来却不低。这背后是一个叫工作集的概念:每个进程在一段时间内实际访问的页面集合。如果所有进程的工作集之和大于物理内存,抖动的唯一出路是降低系统并发度或让部分进程睡眠,死撑只会越来越慢。
6. 现代系统里的多级页表:从4MB页表到TB内存的应对
6.1 一级页表的内存开销:每个进程白养4MB
前面算过,32位地址空间、4KB页、每页表项4字节时,一个进程的单级页表大小是4MB。4MB听起来还可以接受?别忘了这是每个进程都有一份。跑100个进程,光页表就占400MB物理内存,而这只是用来存放地址映射关系,不包含任何实际数据。更不合理的是,大多数进程根本用不满4GB地址空间,往往只用了几十MB、几百MB,但单级页表必须为所有可能的2^20个虚拟页都预留表项,哪怕绝大多数表项都是空的或无效的。
到了64位时代就更夸张了。现代CPU的x86-64架构允许每个进程拥有256TB的虚拟地址空间(用户态部分48位),如果还做单级页表,每个进程的页表大小按2^36个页表项、每项8字节算,就是512GB。这已经不是浪费,这是不可能。
6.2 多级页表如何省内存:按需建表
解决方案是把一张大页表拆成多级小表,核心原则只有四个字:按需建表。以32位系统常见的两级页表为例,虚拟地址被拆成三个部分:高10位是页目录索引,中间10位是页表索引,低12位是页内偏移。
页目录(Page Directory)有1024项,每一项指向一个二级页表(Page Table);每个二级页表又有1024项,每项指向一个物理页框。这样带来的好处是:如果进程只用了一小段地址空间,页目录只需要建少数几个有效的二级页表,其余目录项都标记为空,虚拟上百万个页表项的物理空间可以完全不用分配。一个只用20MB内存的进程,页表开销从4MB降到几千字节,差距是三个数量级。
x86-64进一步拉了四级:PML4、PDPT、PD、PT,加上最终12位偏移,地址转换要经历的步骤变多了,但底层页表可以按需逐级创建,256TB的虚拟空间在内存里只占用实际需要的表项。多级页表用“更多步的查表过程”换来了“极低的空间浪费”,而前面讲的TLB恰恰把这个多步查表的性能损失掩盖掉了——绝大多数查表动作在TLB里就完成了,根本走不到那四步。
6.3 在Linux里观察分页:几个值得亲手做的实验
光看理论容易云里雾里,我建议你在自己的Linux机器上做几个小实验,十分钟就能把分页机制摸出一半。
第一个实验最简单,查看系统页大小:
getconf PAGESIZE在绝大多数x86-64的Linux上输出是4096,这印证了4KB经典页大小。
第二个实验感受请求分页。写一小段C代码,分配一块64MB的内存,然后只对每4096字节边界写一个字节:
#include <stdio.h> #include <stdlib.h> #include <string.h> #define SIZE (64 * 1024 * 1024) int main() { char *p = malloc(SIZE); memset(p, 0, SIZE); for (size_t i = 0; i < SIZE; i += 4096) { p[i] = 1; } printf("done\n"); return 0; }用time ./a.out观察真实时间(real time)和用户时间(user time)的差异。memset一次性把64MB清零时,系统会逐个页触发缺页并从磁盘(或zero页)装载,这段过程的耗时明显高于后面只写每页第一个字节的时间。多跑几次,你会发现第一次运行总是比第二次慢,因为第二次的页面已经在系统缓存里了。
第三个实验可以用来理解TLB的威力:写一个程序访问一个很大的数组,分别按步长1和步长4096遍历整个数组。按步长4096时,数组的每个4KB页只碰一次,TLB命中率极高;按步长1时,会在每个页内连续访问64个元素再跳到下一页,TLB命中模式完全不同。两种遍历的总访问次数相同,但实际运行时间能差出几倍,这就是TLB和页粒度最直观的感受。
如果对内核实现感兴趣,可以去看Linux源码里mm/memory.c的handle_mm_fault,以及更轻量级的教学操作系统 xv6 里walkaddr的实现。后者只有几十行代码,却把“多级页表逐级查找”这件事展示得清清楚楚。我当年学到这里才恍然大悟,教科书上的那些结构图,内核里真的是一行一行写出来的。
页式内存管理不像缓存、分支预测那些纯硬件黑魔法,它是操作系统和硬件之间的一场细致合作:硬件负责在纳秒级别完成地址转换和异常触发,操作系统负责在微秒和毫秒级别管理页表、处理缺页、调度置换。理解了这层分工,你再看任何系统里的内存问题——内存占用高、程序卡顿、段错误——都会多一个清晰的底层视角。这也是我把这篇内容写得这么长的原因:分页不是一块知识点,它是连接CPU、内存、磁盘和操作系统的中枢神经,值得多花点时间搞透。