☰
操作系统内存管理:地址变换、TLB、缺页与页面置换全链路
2026/10/27 4:13:33 网站建设 项目流程

1. 第三章的难点不在概念,在"地址"这两个字

翻到计算机操作系统第三章的时候,多数人第一反应是"概念我都认识,题就是不会做"。页面、页表、段、块、缺页中断,这些词读起来毫无障碍,课本上的图也画得清清楚楚,可一到给定逻辑地址求物理地址、或者算有效访问时间,手就僵住了。我自己第一次学这一章时也栽过同样的跟头,后来带过几届同学做课程设计,发现问题高度集中:大家把第三章当成一堆名词来背,却没有在脑子里建立一条完整的地址变换链路。

这一章在不同教材版本里落点略有差异,主流写法基本都在存储管理/内存管理上:从地址空间、装入与链接讲起,到连续分配、离散分配(分页、分段、段页式),再到虚拟内存、请求调页、页面置换、抖动与工作集。也有一部分教材把"处理机调度与死锁"放在第三章,但内存管理这条线的概念密度和计算量最大,考场上最能拉开差距的也在这里。这篇内容就以内存管理这条主线来写,其他版本的第三章相关问题后面会单独说明。

适合谁看:正在学这门课、准备考试的人;做课程设计需要自己实现一个页面置换模拟器的人;工作几年后回头补底层知识、想搞明白"为什么进程内存看起来那么大、物理内存却只有那么点"的人。三类人关注点不同,但共同的基础都是同一个——你得先能手动把一次地址转换走通。

1.1 一道地址换算题暴露的三个认知断层

拿一道最朴素的题开场。某系统页面大小为 4KB,逻辑地址空间 32 位,某进程的页表中,页号 3 对应的物理块号是 0x1C。求逻辑地址0x00003A2F对应的物理地址。

这道题的完整流程应该是:先把逻辑地址拆成页号和页内偏移,再查页表把页号换成物理块号,最后拼回物理地址。具体算:

  • 页面大小 4KB = 2^12,所以页内偏移占低 12 位
  • 逻辑地址0x00003A2F,低 12 位0xA2F就是偏移,剩下高位0x3就是页号
  • 页号 3 → 物理块号 0x1C
  • 物理地址 =0x1C << 12 | 0xA2F=0x1CA2F

三行完事,但真正卡住人的地方不在这三行,而在三个隐性的认知断层上。

第一个断层:不知道"页号占几位"是怎么推出来的。很多人是死记"4KB 对应 12 位",换个页面大小比如 1KB 或 8KB 就懵。正确的心智模型是:页内偏移的位数 = log2(页面大小),因为一个页面里要能寻址到每一个字节。32 位地址减去偏移位数,剩下的就是页号位数。页面大小 1KB → 偏移 10 位 → 页号 22 位;页面大小 8KB → 偏移 13 位 → 页号 19 位。这个换算必须变成肌肉记忆。

第二个断层:分不清逻辑地址和物理地址到底谁长谁短。在单级分页里,两者的位数其实是一样的(都是 32 位),只是高位那段从"页号"变成了"块号"。很多人以为虚拟地址空间比物理地址空间大,这个印象来自虚拟内存的容量概念,而不是地址的位宽。要区分清楚:地址位宽决定寻址范围,页表项里存的是块号,块号的位数由物理内存块总数决定。

第三个断层:不知道页表项里除了块号还有什么。课本图里常常只画一个块号格,实际页表项还包含有效位(在不在内存)、修改位(脏位)、访问位(置换算法要用)、保护位(读/写/执行权限)。这些位在后面的请求调页和置换算法里全都是主角,如果一开始就忽略,后面讲到"为什么置换算法需要一个访问位"时就会觉得突兀。

提示:拿到任何一道地址转换题,第一步永远是画一条地址位图,把哪几位是页号、哪几位是偏移标出来,再动手算。跳过这一步直接心算,错位是最常见的失分点。

1.2 局部性原理:后面所有设计的底层依据

第三章后半段那一堆机制——请求调页、工作集、置换算法、TLB——看起来各说各的,其实全部挂在同一个前提上:程序访问内存具有局部性。如果程序是真随机访问内存的,虚拟内存这套东西一天都活不下去,因为缺页会密集到系统无法前进。

局部性分两层。时间局部性指刚被访问过的数据很可能马上又被访问,循环变量、循环体指令、热点配置对象都是典型。空间局部性指刚被访问的位置附近的地址很可能马上被访问,数组遍历、结构体连续访问、指令顺序执行都是典型。这两条性质在硬件层面被 TLB 和 Cache 放大利用,在操作系统层面被页面置换算法利用。

理解了局部性,很多"看起来不讲道理"的设计就讲得通了。为什么页面大小不能太小?页面太小,页面数量暴涨,页表本身就吃掉大量内存,而且 TLB 能覆盖的地址范围变小,命中率下降。为什么页面大小不能太大?页面太大,一个页面里可能装了很多其实用不到的数据,内部碎片变大,一次缺页要读入的数据量变大,换入换出开销上升。所以页面大小是在页表开销、TLB 覆盖率、内部碎片、I/O 效率四者之间折中,主流系统停在 4KB 附近,大页机制在特定场景下用 2MB 甚至 1GB 的页面来提升 TLB 覆盖范围。

再比如,为什么置换算法里"最近最少使用"比"先进先出"更合理?因为时间局部性告诉我们,最近被频繁访问的页面在接下来一段时间很可能还会被访问,而最早进来的页面大概率已经是冷数据。这个推理链条如果在脑子里建立起来,你就不需要死记"LRU 比 FIFO 好",而是能自己推出来。

注意:局部性原理是统计意义上的,不是绝对规律。那些故意按大步长随机访问大块内存的程序(比如某些图算法、随机哈希探测),局部性极差,实测吞吐会掉得很难看。这也是为什么高性能计算里特别强调数据布局——本质上是在给操作系统和硬件"制造局部性"。

2. 一条访存指令走过的地址变换链路

要把第三章学扎实,最好的办法是把自己"塞进"CPU 里,跟着一条mov eax, [0x40001000]指令走完整个流程。这一节把链路一段一段拆开,每一段都补充课本上一笔带过的细节。

起点是 CPU 发出的逻辑地址(也叫虚拟地址)。这个地址先被送进MMU(内存管理单元),MMU 做的第一件事不是查页表,而是先查 TLB。TLB 是一小块高速相联存储器,里面缓存的是"页号 → 块号"的映射,容量通常几十到几百项。命中就直接拿到块号,拼出物理地址;不命中才真正走多级页表,一路读内存。这个顺序至关重要,因为TLB 命中与否直接决定了平均访问时间,而平均访问时间正是考试里最常考的计算。

如果 TLB 不命中,MMU 根据页表基址寄存器找到页表,用页号去索引。如果页表项的有效位为 0,说明这个页面不在内存里,硬件触发缺页异常,控制权交给操作系统。操作系统此时要做的事情相当重:在空闲块链里找一个物理块(没有就触发置换)、把页面从外存读进来、更新页表项、如果被换出的页面被修改过还要写回外存、最后重新执行那条被中断的指令。整个过程里,进程是被挂起的,但系统还在跑别的进程——这就是虚拟内存能做到"用少量物理内存支撑大地址空间"的原因。

2.1 分页地址的拆分与手算全过程

继续用具体数字把流程钉死。设 32 位地址、页面大小 4KB、页表项大小 4B、采用两级页表,页目录号 10 位、页表索引 10 位、页内偏移 12 位。

逻辑地址0x00403ABC的二进制拆解:

部分位范围取值(十六进制)
页目录号31–220x001
页表索引21–120x003
页内偏移11–00xABC

计算过程:0x00403ABC >> 22 = 1,所以页目录号是 1;(0x00403ABC >> 12) & 0x3FF = 0x003,页表索引是 3;0x00403ABC & 0xFFF = 0xABC,偏移是 0xABC。

接着查页目录第 1 项,得到二级页表的物理基址,假设是0x2A000;再查该二级页表第 3 项,得到物理块号,假设是0x1F5;最终物理地址 =0x1F5 << 12 | 0xABC=0x1F5ABC。

整个过程的关键点在于:每一级查表都要访问一次内存。两级页表意味着 TLB 不命中时要访问 3 次内存(页目录、二级页表、实际数据),这就是"多级页表提升空间效率但牺牲时间效率"的具体体现,也是 TLB 存在的全部理由。

提示:手算时建议把每个中间结果都写下来,尤其是页目录基址这种"题目给的"和"算出来的"容易混淆的量。我见过太多人把页目录号当成页目录项的物理地址直接拿去加偏移。

2.2 多级页表为什么能把 4MB 压到几KB

单级页表的问题很直观:32 位地址、4KB 页面,共有 2^20 个页表项,每项 4B,页表本身就要4MB。如果系统里有 100 个进程,光页表就占 400MB,这还没算上真正给进程用的内存。更荒唐的是,绝大多数进程根本没用到完整的 4GB 地址空间,页表里大量表项是空的,这些空表项全是浪费。

多级页表的核心思想是按需分配。把页号拆成两段,一级页目录永远在内存里(10 位 → 1024 项 → 4KB),二级页表按需创建。一个进程如果只用了 4MB 数据(1024 个页面),它只需要 1 个二级页表就能覆盖(一个二级页表管理 1024 个页面),总开销是 4KB(页目录)+ 4KB(二级页表)= 8KB,相比 4MB 压缩了 500 倍。

这个"压缩比"要在理解上到位:不是说多级页表一定比单级省内存,而是多级页表把"必须常驻的页表内存"和"实际使用的地址范围"关联起来了。地址空间用得越稀疏,多级页表的优势越明显。反过来,如果进程把 4GB 地址空间全用满,多级页表的总开销反而比单级页表多一点点(多了一层页目录)。

实际系统里还有更省的办法:倒排页表(以物理块为索引,每个物理块一项)和多级页表的组合,以及 64 位系统下常见的四级甚至五级页表。这些扩展不是本章重点,但知道"多级页表不是唯一解"有助于理解设计空间。

2.3 TLB命中率如何折算成有效访问时间

有效访问时间(EAT)的计算是第三章最标准的计算题型,也是错误率最高的一类。核心思路只有一句话:按概率加权求期望。

假设 TLB 访问时间 10ns,内存访问时间 100ns,TLB 命中率 90%,采用单级页表。那么:

  • 命中时:访问 TLB(10ns)+ 访问内存取数据(100ns)= 110ns,概率 0.9
  • 不命中时:访问 TLB(10ns)+ 访问内存查页表(100ns)+ 访问内存取数据(100ns)= 210ns,概率 0.1
  • EAT = 0.9 × 110 + 0.1 × 210 = 99 + 21 =120ns

如果没有 TLB,单级页表下每次访存都要 200ns(查页表 100 + 取数据 100),有了 TLB 后降到 120ns,加速比约 1.67。

换成两级页表、命中率还是 90%,不命中的代价变成 10 + 100 + 100 + 100 = 310ns,EAT = 0.9 × 110 + 0.1 × 310 = 99 + 31 =130ns。

再考虑缺页。设缺页率为 p,缺页处理时间 8ms(含磁盘 I/O,约 8,000,000ns),命中 TLB 时的数据访问按 110ns 算。这里要注意,缺页的路径是先在 TLB 和页表里都找不到,然后才触发,所以严格写法是:

EAT ≈ (1 - p) × 110 + p × 8,000,000

当 p = 0.0001(万分之一)时,EAT ≈ 110 + 800 = 910ns,比不缺页时慢了 8 倍多。这个数字很能说明问题:缺页率的容忍度极低。想让系统性能不明显下降,缺页率通常要控制在十万分之一量级。这也是后面讲抖动时"缺页率一旦起来就刹不住"的量化依据。

常见错误有三个:一是把 TLB 访问时间漏掉(不命中时只算两次内存);二是在多级页表下少算一层内存访问;三是把缺页处理时间当成"加上去"而不是"替换掉"。做题时建议先写清楚"命中路径"和"不命中路径"各自的完整步骤,再套概率。

3. 分页、分段、段页式三种切法的取舍逻辑

课本上这三种方式常常并列给出,表格一摆就过去了,导致很多人只记住"分页有内部碎片、分段有外部碎片"这两句话,却说不清为什么要有两种。这个问题的答案要从"切分的目的"往回找。

分页的目的是消除外部碎片、简化内存分配。把地址空间机械地切成固定大小的页,物理内存切成同样大小的块,任何一页都能装进任何一块,分配就退化成"找空闲块"这一件事,不需要连续空间,也不会有"明明有 100KB 空闲但最大连续块只有 30KB"的尴尬。代价是最后一页装不满,产生内部碎片,平均浪费半页,4KB 页面对应平均 2KB 浪费,可以接受。

分段的目的是贴合程序的逻辑结构。一个程序天然分成代码段、数据段、堆栈段,每段长度不同,段内连续、段间不要求连续。这样做的收益是保护和共享可以按段粒度做:代码段设成只读可执行,多个进程共享同一个库的代码段,数据段各自私有。代价是段长不固定,物理内存里会出现大小不一的空洞,产生外部碎片,需要紧凑(compaction)来回收,而紧凑的代价是大量数据搬移。

段页式是把两者拼起来:先按逻辑分段,每段内部再分页。这样既保留了分段在保护和共享上的优势,又用分页消除了外部碎片。代价是地址转换要做两次:先查段表拿到该段的页表基址,再查页表拿到块号,一次访存要访问三次内存(段表、页表、数据),TLB 的重要性进一步提升。

3.1 碎片问题决定了方案边界

碎片这件事值得单独拎出来说,因为它是判断"该选哪种方案"的第一依据。

内部碎片是分配给进程但用不上的空间。它只在"分配单位大于需求"时产生。分页的最后一页没填满,就是内部碎片。段页式里段的最后一页没填满,也是内部碎片。内部碎片的量可以估算:平均每页浪费半页,总浪费 ≈ 进程数 × 页面大小 / 2。

外部碎片是没分配给任何进程、但因为太小或太散而无法使用的空闲空间。它只在"需要连续空间"时产生。连续分配和纯分段都会产生外部碎片。外部碎片的量难以精确估算,取决于分配释放的历史,最坏情况下可用内存总量足够但任何一块都不够大。

这两种碎片不会同时消失。你可以用分页消掉外部碎片,代价是接受内部碎片;也可以接受外部碎片换取按需分配和逻辑结构。段页式的思路是"把外部碎片压缩到段这一级,再把段内部用分页消化",实际上是把外部碎片的规模从"整个内存"缩小到"段表能覆盖的范围",但并没有完全消除——段本身仍然要求连续的虚拟地址空间。

注意:很多资料上说"段页式没有外部碎片",这个说法不严谨。段页式在物理内存层面确实没有外部碎片(因为物理分配单位是固定大小的页),但在虚拟地址空间层面,段与段之间的空洞依然存在。面试或答题时如果被追问,务必区分清楚是哪一层。

3.2 三张维度对照,把差异钉死

光靠文字描述容易糊,直接上表。下面这张表建议默写一遍,比背十遍定义都管用。

维度分页分段段页式
划分依据物理,固定大小逻辑,长度可变先逻辑后物理
地址空间一维(一个地址)二维(段号+段内偏移)二维(段号+段内偏移)
碎片类型内部碎片外部碎片段内有内部碎片,物理层无外部碎片
对用户可见不可见可见(程序员划分)段可见,页不可见
共享与保护以页为单位,粒度细以段为单位,语义清晰段级保护,页级分配
地址转换次数1 次查页表1 次查段表2 次(段表+页表)
典型用途通用内存管理逻辑结构清晰的场景兼顾两者的系统

关于"对用户可见"这一行,需要解释一下。分页是操作系统为了管理内存自己做的切分,程序员写代码时完全感知不到页的存在,所以是"一维地址"。分段是把程序的逻辑结构暴露出来,程序员(或编译器)需要显式指定段号,所以是"二维地址"。这个差异在考试里经常以"下列哪种方式用户可见"的形式出现。

还有一点容易被忽略:段的长度是有限的,因为段号通常只有十几位,段内偏移也有限,单段最大长度受限;而分页的地址空间由地址位宽决定,可以很大。这个差异在 64 位系统里体现得不明显,但在早期 16 位、32 位系统里影响很大。

4. 页面置换算法:手算、代码验证与Belady异常

置换算法这一块,我建议的学习顺序是:先手算一遍,再写代码跑一遍,最后对比两者的结果。手算能让你理解每一步为什么这样选,代码能验证你有没有漏掉边界情况。很多同学手算全对但写不出代码,或者代码能跑但考场上算错,都是因为只做了一半。

4.1 同一串引用、三种算法、三种缺页数

统一用页面引用串1, 2, 3, 4, 1, 2, 5, 1, 2, 3, 4, 5,物理块数 3,逐个走一遍。

FIFO(先进先出):

访问内存状态是否缺页
11缺
21,2缺
31,2,3缺
42,3,4缺(换出 1)
13,4,1缺(换出 2)
24,1,2缺(换出 3)
51,2,5缺(换出 4)
11,2,5命中
21,2,5命中
32,5,3缺(换出 1)
45,3,4缺(换出 2)
55,3,4命中

缺页9 次,缺页率 9/12 = 75%。

LRU(最近最少使用):前三次同样缺页。第 4 次访问 4 时,1 是最久未用的(位置 1),换出 1;访问 1 时,最久未用的是 2(位置 2),换出 2;访问 2 时换出 3;访问 5 时换出 4。之后再访问 1、2 命中,访问 3 时最久未用的是 1,换出 1;访问 4 时最久未用的是 2,换出 2;访问 5 命中。

缺页同样9 次。这个例子恰好 FIFO 和 LRU 打平,用来做对比其实不太典型,但它能说明一件事:不是所有情况下 LRU 都优于 FIFO,优势是在长序列的统计意义上体现的。

OPT(最佳置换):访问 4 时,未来引用中 1 在位置 5、2 在位置 6、3 在位置 10,3 最远,换出 3;访问 5 时,1 在位置 8、2 在位置 9、4 在位置 11,4 最远,换出 4;访问 3 时,1 和 2 之后都不再出现,视为无限远,任选其一换出;访问 4 时同理。

缺页7 次,缺页率 7/12 ≈ 58.3%。OPT 是理论下界,实际无法实现(需要预知未来),只用来做性能上界的参照。

4.2 用Python把算法跑一遍,再对照手算

手算完之后,强烈建议立刻写代码验证一遍。Python 几十行就能搞定,而且能把"边界情况"暴露出来。

from collections import deque, OrderedDict def fifo(refs, n): frames, q, faults = set(), deque(), 0 for r in refs: if r in frames: # 命中:队列不动,这是 FIFO 的关键 continue faults += 1 if len(frames) < n: frames.add(r); q.append(r) else: frames.remove(q.popleft()) frames.add(r); q.append(r) return faults def lru(refs, n): frames, faults = OrderedDict(), 0 for r in refs: if r in frames: frames.move_to_end(r) # 命中即刷新"最近使用" continue faults += 1 if len(frames) == n: frames.popitem(last=False) # 弹出最久未用 frames[r] = True return faults def opt(refs, n): frames, faults = set(), 0 for i, r in enumerate(refs): if r in frames: continue faults += 1 if len(frames) < n: frames.add(r); continue far, victim = -1, None for f in frames: # 找未来最远才会被用到的页 try: nxt = refs.index(f, i + 1) except ValueError: nxt = float('inf') if nxt > far: far, victim = nxt, f frames.remove(victim); frames.add(r) return faults refs = [1,2,3,4,1,2,5,1,2,3,4,5] for n in (3, 4): print(n, "帧:", "FIFO", fifo(refs, n), "LRU", lru(refs, n), "OPT", opt(refs, n))

跑出来的结果是3 帧: FIFO 9 LRU 9 OPT 7和4 帧: FIFO 10 LRU 9 OPT 8。和手算完全对得上,说明逻辑没写错。

写这段代码时有几个坑值得说一下。第一,FIFO 命中时队列绝对不能动,很多人的实现里一命中就把它移到队尾,那就变成 LRU 了。第二,LRU 的"命中刷新"必须用move_to_end,普通字典在 Python 3.7+ 虽然保持插入顺序,但不会自动刷新,语义就错了。第三,OPT 里"未来不再出现"要当成正无穷处理,如果直接当成一个很大的常数,遇到同样不再出现多个页时会因为相等而选错。

提示:把代码扩展成可视化版本,把每一步的内存状态打印出来,配合手算表格逐行对照。这个练习做一遍,置换算法基本就不会再出错了。

4.3 Belady异常:给更多内存反而更差

把上面代码里帧数从 3 改成 4,FIFO 的缺页次数从 9 变成10。多了内存反而更慢,这就是Belady 异常。

这个现象的根源在于 FIFO 的决策依据(进入时间)和局部性原理没有关系。增加内存块后,队列里保留的历史页变多,某些本来会被及时换出的页反而赖着不走,把即将被访问的页挤了出去。LRU 和 OPT 不会出现这个异常,因为它们都属于栈式算法:n 个帧时的驻留集合,永远是 n+1 个帧时驻留集合的子集。这个性质保证了"帧数增加,缺页次数不增"。

用代码验证 Belady 异常很直观:

for n in range(2, 7): print(n, fifo(refs, n), lru(refs, n))

跑一下就能看到 FIFO 的缺页数偶尔出现"台阶式上升",而 LRU 单调不增。

实际系统里几乎不用纯 FIFO,道理也在这里。Clock 算法(也叫二次机会算法)是 FIFO 的改良版:把页面排成环形链表,每页带一个访问位,置换时从指针位置扫描,访问位为 1 就清 0 并跳过(给一次"机会"),为 0 就换出。它实现简单(不需要维护复杂的链表)、开销小,是很多真实系统的选择。改进型 Clock还会考虑修改位,优先换出"既没被访问也没被修改"的页,因为这类页换出时不需要写回磁盘,代价最低。

这个"访问位 + 修改位"的两比特设计,本身就是一道经典考点:四种组合的换出优先级从高到低是(0,0)、(0,1)、(1,0)、(1,1)。理解逻辑也很自然——没访问过说明是冷数据,没修改过说明不用写盘,两者都满足的页换出成本最低。

5. 抖动与工作集:系统变慢时的连锁反应

第三章最后一块内容是抖动(thrashing)。这个概念很多人只停留在一句话定义上:"进程频繁缺页,导致系统吞吐量急剧下降"。但要真正理解,得把因果链条拆开看清楚。

链条的起点通常是多道程序度过高。系统为了提升 CPU 利用率,不断往里塞新进程,每个进程分到的物理块越来越少。当某个进程的可用块数低于它当前的工作集(活跃页面集合)时,它的每一次指令执行都可能缺页。缺页意味着要等磁盘 I/O,进程被阻塞;CPU 空闲出来,系统又调入新进程;新进程进一步挤占别人的块,缺页率继续上升。这是一个正反馈,一旦启动就很难自己停下来。

链条的终点是CPU 利用率断崖式下跌。这个现象很有迷惑性:监控上看到 CPU 空闲率很高,运维的第一反应往往是"负载不够,加任务",结果越加越糟。正确的判断依据是缺页率和换页 I/O 量,只要这两个指标在飙升,就说明问题不是 CPU 不够,而是内存不够。

5.1 缺页率失控的传导路径

把这条路径拆成几个可观测的环节,排查时就有抓手了。

第一步,缺页率上升。从/proc/统计里能看到进程的次缺页(minor fault)和主缺页(major fault)计数,主缺页才涉及磁盘 I/O,是真正致命的。如果主缺页次数在持续增长,基本可以确定是内存压力。

第二步,页换入换出量上升。系统级的换页统计会明显抬头。这时候磁盘队列长度通常也在涨,因为换页和正常文件 I/O 在抢同一块盘。

第三步,有效访存时间变长。用第 2.3 节的公式估算一下就明白了:缺页率从万分之一涨到百分之一,EAT 从几百纳秒直接到几十微秒,慢了两个数量级。表现出来就是"程序没死,但每一步都像在泥里走"。

第四步,CPU 利用率下降。注意是下降,不是上升。因为进程都在等 I/O,可运行的进程变少,调度器找不到活干。

第五步,系统进入抖动状态。此时加进程只会更糟,减进程才能缓解。这就是为什么操作系统需要负载控制机制——在抖动边缘主动挂起一部分进程,把物理块让给剩下的进程。

注意:不要一看到 swap 用量高就判定抖动。swap 里存着大量冷页面是完全正常的,只要换入换出速率低,对性能没有实质影响。真正要看的是换页速率,而不是换页总量。这个区分在实际排查中非常关键。

5.2 工作集模型的近似与多道程序度控制

工作集的概念是:进程在最近 Δ 个时间单位内访问过的页面集合,记作 WS(t, Δ)。这个集合的大小决定了进程当前需要多少物理块才能"跑得顺"。Δ 太小学不到完整的工作集,Δ 太大则把冷页面也算进来,浪费内存。实践中 Δ 通常取几千次访存的时间窗口。

工作集模型的思想很优雅,但精确实现几乎不可能,因为需要记录每一次访存。实际系统用的是近似方案:

  • 用访问位 + 定时采样。每隔一个时间片,扫描所有页表项的访问位,把访问位置 1 的页面记为"本周期活跃",然后清零。连续若干个周期都活跃的页面就属于工作集。
  • 用缺页率反馈。直接盯住每个进程的缺页率,高于上界就给它加块,低于下界就回收它的块。这个方案简单粗暴但有效,被很多系统采用。
  • 用页面错误间隔。记录两次缺页之间的时间,间隔很短说明块不够,间隔很长说明块有余。这个思路在早期的分页系统里用得比较多。

真正落地时,操作系统一般不会精确计算工作集,而是用缺页率作为间接指标。这个选择很务实:缺页率是现成的、每次缺页都会更新的计数器,不需要额外的采样开销。

负载控制的具体做法是挂起部分进程,把它们的页面全部换出,物理块回收给活跃进程。挂起谁?通常是优先级最低的、或者最近最不活跃的。恢复的时机是系统缺页率回落到阈值以下。这套机制在多用户系统里非常重要,因为用户提交的任务数量是不可控的,操作系统必须自己兜底。

6. 把第三章搬到真实系统上验证

课本上的模型是理想化的,真实系统里有一堆工程细节:页缓存、匿名页和文件页的区别、透明大页、内存回收的优先级、cgroup 限制。想把这些搞清楚,最好的办法是动手做几个小实验。

6.1 四个命令看清缺页与内存压力

先给一套最小命令集,日常排查够用了。

# 1. 总体内存与 swap 概况(关注 available 而不是 free) free -m # 2. 内存明细,重点看 MemAvailable、Dirty、Writeback grep -E "MemTotal|MemFree|MemAvailable|SwapTotal|SwapFree|Dirty|Writeback" /proc/meminfo # 3. 每秒采样,重点看 si/so(换入换出)和 r/b(运行队列与阻塞) vmstat 1 5 # 4. 单进程的缺页计数,字段 10 是 minflt,12 是 majflt awk '{print "minflt="$10, "majflt="$12}' /proc/<PID>/stat

free -m里free那一列常年很小是正常的,Linux 会把空闲内存拿去做页缓存,真正要看的指标是available——它表示在不触发换页的前提下,还能拿出多少内存给新程序用。这个"用空闲内存做缓存"的策略正是利用了空间局部性,让文件读取尽可能命中缓存。

vmstat的si/so是换入换出速率,单位 KB/s。这两个值长期大于 0,说明系统在持续换页,内存确实紧张。但如果只是偶尔抖动一下,不用紧张。

/proc/<PID>/stat的第 10 和第 12 字段分别是次缺页和主缺页累计次数。次缺页是"页面在内存里,只是映射没建立",代价很小;主缺页是"页面不在内存,需要读盘",代价很大。区分这两个数字是判断性能问题的第一步。

还有一个组合很好用:

/usr/bin/time -v ./your_program

它会输出Minor (reclaiming a frame) page faults和Major (requiring I/O) page faults两个数字,跑一次就能知道程序的内存访问模式是否友好。

6.2 手工制造一次major fault并观察

光看数字不够直观,自己造一次缺页印象最深。下面这段代码用 mmap 映射一个大文件,逐页读一个字节,然后立刻用madvise把这一页的映射丢掉,强制下一次访问重新缺页。

#include <stdio.h> #include <stdlib.h> #include <fcntl.h> #include <unistd.h> #include <sys/mman.h> #include <sys/stat.h> int main(int argc, char **argv) { int fd = open(argv[1], O_RDONLY); if (fd < 0) { perror("open"); return 1; } struct stat st; fstat(fd, &st); size_t len = st.st_size; size_t pages = len / 4096; char *p = mmap(NULL, len, PROT_READ, MAP_PRIVATE, fd, 0); if (p == MAP_FAILED) { perror("mmap"); return 1; } volatile unsigned long sum = 0; for (size_t i = 0; i < pages; i++) { sum += p[i * 4096]; /* 首次访问触发缺页 */ madvise(p + i * 4096, 4096, MADV_DONTNEED); /* 丢弃映射,制造下一次缺页 */ } printf("sum=%lu pages=%zu\n", sum, pages); munmap(p, len); close(fd); return 0; }

编译运行:

gcc -O2 -o fault_demo fault_demo.c dd if=/dev/zero of=/tmp/bigfile bs=1M count=256 /usr/bin/time -v ./fault_demo /tmp/bigfile

跑完看Major (requiring I/O) page faults这一项,如果文件已经被读进页缓存,这一项可能接近 0,而次缺页数量会是几万。想看到真正的主缺页,可以先用posix_fadvise(fd, 0, 0, POSIX_FADV_DONTNEED)把文件从缓存里踢掉(需要相应的权限),或者换一个远大于物理内存的文件。

这个实验的价值在于让你亲眼看到:同一次"读一个字节"的操作,代价可以从几十纳秒到几毫秒,差五个数量级。理解了这一点,你才会真正重视局部性,也才会明白为什么数据库、搜索引擎这些系统在数据布局上要下那么大功夫。

提示:做这类实验一定要在虚拟机或容器里,别在跑着重要服务的机器上折腾。内存压力测试很容易把系统拖进抖动,恢复起来很麻烦。

7. 复习第三章最容易翻车的几个点

带过几轮之后,我发现错误高度集中在几个固定位置。下面把它们列出来,配纠正方式,考前过一遍基本能避开大部分坑。

序号高频错点正确理解
1页号与页内偏移的位数搞反偏移位数 = log2(页面大小),页号位数 = 地址位数 − 偏移位数
2有效访问时间漏算 TLB 访问命中路径 = TLB + 一次内存;不命中 = TLB + 每级页表一次内存 + 数据一次
3认为分页有外部碎片分页只有内部碎片,外部碎片出现在连续分配和纯分段
4FIFO 命中时刷新队列命中不改变 FIFO 顺序,改了就是 LRU
5认为 Belady 异常所有算法都有只有非栈式算法(如 FIFO)才有,LRU、OPT 不会
6把缺页中断当成普通中断缺页是内部异常,在指令执行期间发生,一条指令可能触发多次

第 6 点值得单独展开。缺页中断和普通中断的区别有三个:产生时机不同,普通中断在指令执行完之后检查,缺页在指令执行过程中产生;返回位置不同,缺页处理完之后要重新执行被中断的那条指令,而不是执行下一条;次数不同,一条指令可能跨越两个页面,从而触发两次缺页。这三点在选择题里经常出现,也经常有人记混。

第 2 点再补一个细节:如果题目没说 TLB,只说了页表在内存里,那就按"每级页表一次内存访问"来算。单级页表是 2 次(页表 + 数据),两级是 3 次,三级是 4 次。这个"加一"的规律记牢了,无论题目给几级都不会错。

还有一类题容易被忽略:给了缺页率和缺页处理时间,求平均访问时间。这类题的关键是理解"缺页处理时间是替换掉正常访问时间,而不是额外叠加"。正确的写法是(1-p) × 正常时间 + p × 缺页时间,其中缺页时间通常已经包含了磁盘读入的完整耗时。

7.1 一道综合题的完整推演

最后走一道覆盖面比较全的综合题,把前面几节的知识串起来。

题目:某系统采用两级页表,地址 32 位,页面大小 4KB,页表项 4B。TLB 访问时间 10ns,内存访问时间 100ns,TLB 命中率 95%,缺页率 0.0001,缺页处理时间 5ms。求平均访存时间。

第一步,确定地址结构。页面大小 4KB = 2^12,偏移 12 位;两级页表各 10 位,共 32 位,正好。

第二步,算命中路径。TLB 命中:10ns(TLB)+ 100ns(取数据)= 110ns。注意命中时不需要查页表。

第三步,算不命中路径。TLB 不命中时:10ns(TLB)+ 100ns(页目录)+ 100ns(二级页表)+ 100ns(取数据)= 310ns。

第四步,算不缺页时的期望。0.95 × 110 + 0.05 × 310 = 104.5 + 15.5 = 120ns。

第五步,叠加缺页。缺页率 0.0001,缺页时间 5ms = 5,000,000ns。

EAT = (1 − 0.0001) × 120 + 0.0001 × 5,000,000 = 119.988 + 500 =619.988ns ≈ 620ns

对照一下:如果不缺页,EAT 只有 120ns;加上万分之一的缺页率之后,变成 620ns,慢了 5 倍多。而如果缺页率涨到千分之一,EAT 会变成约 5120ns,慢 42 倍。

这组数字是我认为第三章最值得记住的东西。它解释了为什么操作系统在内存管理上如此保守,为什么宁可频繁回收页缓存也不愿意让系统进入换页状态,也解释了为什么"内存不够就加 swap"这种做法在性能敏感场景下基本无效。

我在实际带同学做课程设计时的体会是:第三章的每一个机制都不是孤立的知识点,它们是一条因果链上的不同环节。局部性原理决定了虚拟内存可行,虚拟内存的代价是缺页,缺页的代价催生了置换算法,置换算法的失误导致抖动,抖动的应对是工作集和负载控制。把这条链在脑子里串起来,比背十张表格都管用。至于那些公式和手算,说白了就是把这条链上的每一步量化——你理解了链条,公式自然就写得出来;反过来只背公式,题目稍微变个形就全乱了。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询