课堂练习4.2 页式内存管理这一题,我带过几届人做,规律特别明显:真正卡住大家的从来不是“页”这个概念本身,而是把它和实际地址计算、页表组织、缺页处理串起来的那几步。很多人能顺口背出“页式内存管理把逻辑地址空间切成等长的页,把物理内存切成等长的页框”,可真给他一个十六进制逻辑地址和一张页表,让他算物理地址,手就开始抖——要么忘了页内偏移原样保留,要么把页号当成了页框号,要么在被问到“为什么不用单级页表”的时候只会说“因为太大了”,说不出大多少、大在哪里。内存管理这四个字听着抽象,其实是操作系统里最讲“算术”的一块内容,凡是能用公式推、能用代码跑出来的东西,都不该靠背。
这篇就按我在机房带练习的实际顺序走:先把页式管理要解决的东西讲透,再把地址转换这条主线走一遍并配手算,然后给两段能直接编译运行的代码,一段模拟 MMU 做地址翻译,一段把 FIFO、LRU、Clock、OPT 四种页面置换算法放在同一条引用串上跑出对比结果,最后落到真实系统,看看 Linux 内存管理子系统里 mm_struct、vm_area_struct 这些数据结构到底是怎么把课堂上的抽象页表变成能跑的东西。学有余力的可以照着改参数做实验,只求过关的把第 2 章和第 6 章的表格抄进笔记,考试和作业都够用。
1. 先搞清楚页式内存管理到底在解决什么问题
1.1 连续分配的三个坑,踩过才知道疼
要理解分页为什么出现,得先看它替代的是什么。早期的内存分配走的是连续分配路线:一个进程进来,就给它一整块连续的物理内存,从某个基址开始,长度等于进程大小。这做法直观,地址转换也简单,基址寄存器加偏移就完事,硬件代价极低。但它有三个绕不过去的坑,而且一个比一个难缠。
第一个坑是外部碎片。内存里进程反复地申请和释放,日子久了就会剩下一堆零散的小空洞。这些空洞加起来可能有好几百 MB,但没有一个足以装下新来的进程。解决办法是把进程挪一挪、把空洞挤到一起,也就是“紧凑”,可紧凑要拷贝整块内存、要暂停进程、要更新所有地址引用,代价高得离谱,绝大多数系统根本不常用。
第二个坑是内部碎片。为了避免外部碎片,有的方案改成按固定大小的分区分配,但进程大小是任意的,分区给大了就浪费,给小了又装不下。这部分被浪费在分区内部、进程用不到的空间就是内部碎片。有意思的是,分页其实也有内部碎片,只是它把碎片限制在“最后一页”这个很小的范围内,这就是取舍的艺术。
第三个坑是进程空间必须连续。连续分配要求进程的物理内存是连续的一整块,这意味着内存分配器得像停车场找车位一样,必须找到一块够大的连续区域。而进程还常常需要动态增长,比如堆往上长、栈往下长,连续分配下你得提前预留空间,预留多了浪费,少了又不够。分页把这些约束一次性全打开了——进程的页可以散落在物理内存的任意位置,只要页表记得住就行。
1.2 分页的核心思想:等长切块加一张对照表
分页的思路其实非常朴素。把进程的逻辑地址空间按固定大小切成一块块,叫页;把物理内存也按同样大小切成一块块,叫页框(有的教材叫物理块、帧)。页和页框大小完全一致,这是硬性前提。然后,进程的每一页可以装进任意一个空闲页框里,不需要连续。哪个页放在哪个页框,由一张页表记录。
打个生活化的比方。你把一本三百页的书拆散,每一页单独塑封,然后随便塞进图书馆书架的任意空位。书页之间不再要求挨着,但你在书末的目录里记一笔:“第 17 页在 B 区 3 层第 5 格”。想找第 17 页,先查目录拿到位置,再过去取。页表就是这本目录,页号是目录条目的编号,页框号是书架上格子的编号。这样做的收益很直接:只要有空位就能塞,外部碎片直接消失了;而且目录本身也可以拆散存放,进一步省空间。
代价也很清楚。第一,每次访问内存都要先查页表,内存访问次数翻倍,这就是为什么必须引入 TLB。第二,页表本身占用内存,页表规模一大就得想办法压缩,于是有了多级页表。第三,页是物理单位不是逻辑单位,它不管你的代码段、数据段边界在哪,一个函数可能正好跨在两页上。课堂练习里的很多题,考的就是你有没有把这些代价算清楚。
1.3 页式和分段最容易搞混的几个点
练习卷里最常见的一种送命题,是把分页和分段混在一起问。两者确实都用了“离散分配”这个手段,但出发点完全不同,用一张表把它们摆开看最省事。
| 对比维度 | 页式管理 | 分段管理 |
|---|---|---|
| 划分依据 | 物理单位,按固定大小机械切分 | 逻辑单位,按程序结构切分(代码段、数据段、栈段) |
| 大小 | 固定,由硬件决定,如 4KB | 可变,由程序逻辑决定 |
| 对用户可见性 | 对程序员透明,看不见页的存在 | 对程序员可见,段是程序的一部分 |
| 地址结构 | 页号 + 页内偏移 | 段号 + 段内偏移 |
| 主要解决的问题 | 外部碎片、内存利用率 | 共享、保护、动态增长 |
| 碎片类型 | 只有最后一页的内部碎片 | 主要是外部碎片 |
真正考试里更狠的是“段页式”,也就是先分段再分页:逻辑地址变成段号、段内页号、页内偏移三段。我第一次做这种题的时候就是漏掉了段表里还要存页表长度,导致越界判断写错。记住一条:段页式里每个段有自己的页表,段表项指向该段页表的基址,同时记录该段的页数用于越界检查。这句话能挡掉一大半概念题。
2. 地址转换这条主线:从逻辑地址到物理地址
2.1 地址结构的位拆解与两条公式
页式管理的所有计算,都建立在同一个拆解上:逻辑地址被切成两部分,高位是页号,低位是页内偏移。设页大小为 2 的 n 次方字节,那么逻辑地址的低 n 位就是页内偏移,剩下的高位就是页号。用除法和取模表达就是:
页号 = 逻辑地址 / 页大小(整数除法,向下取整) 页内偏移 = 逻辑地址 % 页大小(取余)
高位存放页号、低位存放偏移这个设计不是随便定的,它有个非常实际的好处:页大小取 2 的整数次幂时,除法和取模退化成移位和按位与,硬件一条指令就能完成。这就是为什么实际系统里的页大小几乎都是 4KB、8KB、16KB、2MB、1GB 这种 2 的幂,而不是 5000 字节之类的“整数”。你去看 Linux 支持的页大小,全是 2 的幂,原因就在这里。
换算成位运算,页大小 4KB 等于 2 的 12 次方,所以偏移占低 12 位,页号是逻辑地址右移 12 位:
// 页大小 4KB,PAGE_SHIFT = 12 #define PAGE_SHIFT 12 #define PAGE_SIZE (1UL << PAGE_SHIFT) // 4096 #define PAGE_MASK (PAGE_SIZE - 1) // 0xFFF unsigned long page_offset = addr & PAGE_MASK; // 页内偏移,低12位 unsigned long page_number = addr >> PAGE_SHIFT; // 页号,高位这两行代码建议直接背下来。后面的所有题目,无论是手算还是写程序,都是在这两行上做文章。我踩过的一个坑是:页大小换成 8KB 的时候,忘记把 PAGE_SHIFT 从 12 改成 13,结果所有地址都算错了一倍,排查了半天才发现是宏没跟着改。
2.2 手算一遍单级页表转换,把每一步都写清楚
光有公式不够,练的时候必须落到具体数字上。假设这样一个场景:32 位逻辑地址空间,页大小 4KB,页表内容如下表,现在要算逻辑地址 14927(十六进制 0x00003A4F)对应的物理地址。
| 页号 | 页框号 |
|---|---|
| 0 | 5 |
| 1 | 2 |
| 2 | 9 |
| 3 | 8 |
| 4 | 1 |
第一步,求页号:14927 ÷ 4096 = 3 余 2639,所以页号是 3,页内偏移是 2639。用位运算验证一下,14927 的十六进制是 0x3A4F,低 12 位是 0xA4F 也就是 2639,高位是 0x3 也就是 3,对得上。
第二步,查页表:页号 3 对应的页框号是 8。
第三步,算物理地址:物理地址 = 页框号 × 页大小 + 页内偏移 = 8 × 4096 + 2639 = 32768 + 2639 = 35407,十六进制是 0x8A4F。
注意最后这个结果的结构:高位 0x8 就是页框号,低位 0xA4F 就是页内偏移,偏移部分一个字都没变。这是页式地址转换最关键的性质,也是我自己讲过很多遍还会有人写错的地方——偏移量在转换前后完全相同,你只需要把页号那一段替换成页框号。理解了这一点,很多题可以直接用“拼接”的方式口算,不用真的去做乘法和加法。
还有两个细节要留意。一是越界检查:查表之前必须先比较页号和页表长度,如果页号大于等于页表长度,说明访问越界了,要触发地址越界错误,而不是去查一个不存在的表项。二是有效位检查:表项里通常有个有效位(valid bit),标记这一页当前是否真的在内存里,无效就得触发缺页中断。练习题里经常故意给一个有效位为 0 的表项,看你会不会直接拿去算物理地址。我见过最典型的错误就是拿到页表项直接用,完全无视有效位。
2.3 多级页表为什么必须存在:把账算给质疑的人看
每次讲到这里都有人问,单级页表不是挺好吗,为什么要搞两级、三级、四级?答案很简单,把内存占用量算出来就一目了然了。
还是 32 位地址空间、页大小 4KB 这个配置。页内偏移占 12 位,剩下 20 位是页号,也就是说一个进程最多有 2 的 20 次方也就是约 104 万个页。每个页表项假如占 4 字节,那么一个进程的页表就要 1048576 × 4 字节 = 4MB。如果系统同时有 100 个进程在跑,光页表就要 400MB。这在 32 位系统只有 4GB 地址空间的年代,简直是灾难。
多级页表的思路是给页表本身也分页。改成两级之后,20 位的页号被拆成两段各 10 位:高 10 位是页目录索引,低 10 位是页表索引。顶级页目录有 2 的 10 次方也就是 1024 个条目,每个条目 4 字节,总共 4KB;每个二级页表也是 1024 个条目、4KB。关键在于,二级页表可以按需存在。一个进程实际用到的地址空间往往只集中在少数几个区域,比如代码段、数据段、堆、栈,对应的二级页表可能只有几个,其余的根本不用分配。这样实际占用的页表内存可能只有几十 KB,比 4MB 小了两个数量级。
不过这算盘不能只算一半。多级页表也有它的代价:一次地址转换需要多次访存。两级页表要访问两次内存才能拿到真正的页框号,四级就是四次,这是实打实的开销。所以多级页表必须和 TLB 配合使用才有意义——TLB 命中时,这些多级查找全部被跳过。这也就解释了一个常见疑问:为什么 Linux 决定用四级页表,多一级不是更慢吗?因为多出来的那一级只在 TLB 未命中时才起作用,而 TLB 命中率在实际负载下通常能到 98% 以上,用一点点未命中路径的开销换取页表空间的巨大节省,这笔账非常划算。
3. 课堂练习4.2 的动手实现:两段能直接跑的代码
3.1 第一段:模拟硬件 MMU 做地址翻译
课堂上写地址转换,很多同学是拿笔一步步算,算完也不知道对不对。我的建议是当场写个小程序把规则固化成代码,跑几个用例验证,心里就踏实了。下面这段 C 代码实现了一个简化版的 MMU:给定页表数组和一个逻辑地址,输出物理地址,同时做越界检查和有效位检查。
#include <stdio.h> #include <stdint.h> #define PAGE_SHIFT 12 #define PAGE_SIZE (1u << PAGE_SHIFT) /* 4096 */ #define PAGE_MASK (PAGE_SIZE - 1) #define PT_ENTRIES 16 /* 本练习只用 16 个页表项 */ #define VALID 1 #define INVALID 0 typedef struct { uint32_t frame; /* 页框号 */ uint8_t valid; /* 有效位:1 在内存,0 不在 */ } pte_t; /* 返回 0 成功,-1 越界,-2 缺页 */ int translate(const pte_t *pt, uint32_t vaddr, uint32_t *paddr) { uint32_t pageno = vaddr >> PAGE_SHIFT; uint32_t offset = vaddr & PAGE_MASK; if (pageno >= PT_ENTRIES) { printf("地址越界:页号 %u 超过页表长度 %d\n", pageno, PT_ENTRIES); return -1; } if (pt[pageno].valid != VALID) { printf("缺页:页号 %u 不在内存,需触发缺页中断\n", pageno); return -2; } *paddr = (pt[pageno].frame << PAGE_SHIFT) | offset; printf("逻辑地址 0x%08X -> 页号 %u, 偏移 %u -> 页框 %u -> 物理地址 0x%08X\n", vaddr, pageno, offset, pt[pageno].frame, *paddr); return 0; } int main(void) { pte_t pt[PT_ENTRIES] = {0}; pt[0].frame = 5; pt[0].valid = VALID; pt[1].frame = 2; pt[1].valid = VALID; pt[2].frame = 9; pt[2].valid = VALID; pt[3].frame = 8; pt[3].valid = VALID; pt[4].frame = 1; pt[4].valid = INVALID; /* 故意制造缺页 */ uint32_t paddr; translate(pt, 0x00003A4F, &paddr); /* 应输出页框 8,物理地址 0x8A4F */ translate(pt, 0x00004000, &paddr); /* 页号 4,缺页 */ translate(pt, 0x00020000, &paddr); /* 页号 32,越界 */ return 0; }这段代码有几个点值得对着练习卷看。第一,frame << PAGE_SHIFT | offset这个写法就是前面说的“拼接”,比乘法加法更能体现结构。第二,越界检查和有效位检查的顺序有讲究,必须先判越界再判有效位,否则可能去读数组外面的内存,那是未定义行为。第三,把pt[4].valid设成 INVALID 是故意的,方便你亲眼看到缺页分支被走到。编译运行的话,gcc -o mmu mmu.c && ./mmu就行,不需要任何额外依赖。
3.2 第二段:四种页面置换算法放在同一条引用串上对比
缺页了就得从内存里挑一页换出去,挑谁就是置换算法的事。练习里最爱考的四个是 FIFO、LRU、Clock 和 OPT。FIFO 按进入内存的先后顺序淘汰,实现最简单,但有 Belady 异常——页框数增加,缺页反而可能变多。LRU 淘汰最久没被访问的页,理论效果好,但精确实现代价高,需要记录每次访问的时间戳或者维护访问顺序链表。Clock 是 LRU 的近似,用一个循环指针和访问位,命中就置 1,淘汰时扫到 0 就换出、扫到 1 就清 0 继续,实现便宜且效果不错,Linux 的页面回收就用了类似思想。OPT 是理论最优,淘汰未来最长时间不会被访问的页,但它需要预知未来,只能用作评价其他算法的基准。
下面这段 Python 把四个算法都实现了一遍,用同一条经典引用串跑出对比:
def fifo(ref, frames): mem, faults = [], 0 for p in ref: if p not in mem: faults += 1 if len(mem) < frames: mem.append(p) else: mem.pop(0) mem.append(p) return faults def lru(ref, frames): mem, faults = [], 0 for p in ref: if p in mem: mem.remove(p) mem.append(p) # 访问过就移到队尾,队首是最久未用 else: faults += 1 if len(mem) >= frames: mem.pop(0) mem.append(p) return faults def opt(ref, frames): mem, faults = [], 0 for i, p in enumerate(ref): if p in mem: continue faults += 1 if len(mem) < frames: mem.append(p) else: far, victim = -1, None for q in mem: try: nxt = ref.index(q, i + 1) # 下一次被访问的位置 except ValueError: nxt = float('inf') # 以后不再访问,优先淘汰 if nxt > far: far, victim = nxt, q mem.remove(victim) mem.append(p) return faults def clock(ref, frames): mem, use, faults, hand = [None]*frames, [0]*frames, 0, 0 for p in ref: if p in mem: use[mem.index(p)] = 1 continue faults += 1 while True: if mem[hand] is None: mem[hand], use[hand] = p, 1 hand = (hand + 1) % frames break if use[hand] == 0: mem[hand], use[hand] = p, 1 hand = (hand + 1) % frames break use[hand] = 0 hand = (hand + 1) % frames return faults if __name__ == "__main__": ref = [7,0,1,2,0,3,0,4,2,3,0,3,2,1,2,0,1,7,0,1] for f in (3, 4): print(f"页框数={f}") print(" FIFO :", fifo(ref, f)) print(" LRU :", lru(ref, f)) print(" CLOCK:", clock(ref, f)) print(" OPT :", opt(ref, f))把这条引用串跑出来,3 个页框时的结果是 FIFO 15 次缺页、LRU 12 次、OPT 9 次。如果换成 4 个页框,FIFO 会变成 10 次——页框多了,缺页反而只降了 5 次,而且历史上这个例子正是用来展示 Belady 异常的经典材料:在某些引用串下,FIFO 的缺页数会随着页框数增加而上升。代码你可以自己改引用串验证,这是理解算法差异最快的办法,比看书上的表格管用得多。
| 算法 | 3 页框缺页数 | 缺页率 | 是否会出现 Belady 异常 | 实现代价 |
|---|---|---|---|---|
| FIFO | 15 | 75% | 会 | 极低 |
| LRU | 12 | 60% | 不会 | 高 |
| Clock | 12~14(依赖具体实现) | 约 60%~70% | 不会 | 低 |
| OPT | 9 | 45% | 不会 | 无法实用 |
注意:不同教材对 Clock 算法的细节处理不一样,比如命中时是否把访问位置 1、初始指针位置在哪,都会让缺页计数差一两次。考试遇到时,看清题目给的前提再动手,别拿代码里的结果硬套。
3.3 用实验数据反推概念,比死记结论强得多
写完代码之后,我一般还会让做练习的人干一件事:把页框数从 1 试到 8,把四种算法的缺页曲线画出来(用表格记录即可)。你会发现几条规律:页框数到一定程度之后,所有算法的缺页数都会快速下降然后趋于平缓,这是局部性原理在起作用——程序在一段时间内只会集中访问一小撮页,只要页框数能覆盖这个工作集,缺页就很少了。反过来,如果页框数长期小于工作集大小,缺页率会居高不下,系统把大量时间花在换页上而不是执行指令上,这就是抖动。
这也解释了为什么置换算法的好坏不能只看单条引用串。OPT 在每条串上都最优,但它不实用;FIFO 在某些串上会反常;LRU 效果好但代价高。实际系统选的是近似 LRU 的 Clock 类算法,加上访问位的定期清零,用一个很小的硬件代价换到接近 LRU 的效果。课堂练习里很多人只记住“LRU 比 FIFO 好”,但说不出为什么好、好在什么条件下,跑一遍数据这些问题就全通了。
4. 从课堂练习延伸到真实系统:Linux 内存管理子系统里的关键结构
4.1 mm_struct、vm_area_struct、page 三者怎么串起来
课堂上的页表是一维数组加一个页表基址寄存器,真实内核里要复杂得多,但骨架是相通的。拿 Linux 举例,每个进程有一个 task_struct,里面有个指针指向 mm_struct,这个结构描述整个进程的地址空间。mm_struct 里维护着一棵或一条 vm_area_struct 组成的区间树(现代内核用红黑树加链表),每一个 vm_area_struct 描述一段连续的、权限相同的虚拟地址区间,比如代码段、数据段、堆、栈、mmap 映射的共享库,各自一个 vm_area_struct。
逻辑层次是这样的:进程要访问某个虚拟地址,CPU 先查 TLB,TLB 未命中就去走多级页表;页表里的表项最终指向一个物理页。而物理页由 struct page 描述,内核为每个物理页维护一个 struct page,记录引用计数、映射信息、所属 zone 等。页表负责“虚拟到物理”的映射关系,struct page 负责“这个物理页被谁用着、用了多久”。两者配合,才构成完整的内存管理。
这个结构和课堂练习的对应关系其实很直接:页表项对应页表数组的一个元素,页框号对应物理页的页帧号,有效位对应页表项里的 present 位。区别只在于维度——课堂上一张页表管所有页,内核里每个进程有自己的页表,而且页表是多级的,vm_area_struct 负责在缺页时判断这次访问到底合不合法、该从哪加载数据。
4.2 四级页表与地址翻译的完整路径
64 位 Linux 上常见的四级页表是 PGD、PUD、PMD、PTE。48 位虚拟地址被切成五段:9 位 PGD 索引、9 位 PUD 索引、9 位 PMD 索引、9 位 PTE 索引、12 位页内偏移,加起来正好 48 位。PGD 的物理基址存在 CR3 寄存器里,这是每次进程切换都要改的东西——切换进程时换 CR3,就等于切换了整个页表,这是进程地址空间相互隔离的硬件基础。
翻译路径是这样:CPU 从 CR3 拿到 PGD 基址,用虚拟地址高 9 位做索引找到 PUD 表;PUD 表里再取 9 位找 PMD 表;PMD 表取 9 位找 PTE 表;PTE 表取 9 位拿到最终物理页框号,再拼上 12 位偏移得到物理地址。这条链一次最多访问四次内存,所以 TLB 的存在非常关键。内核里有一整套 pgd_offset、pud_offset、pmd_offset、pte_offset 宏来走这条路径,你去看arch/x86/include/asm/pgtable_64.h就能找到对应的位掩码定义,和课堂上的 PAGE_MASK、PAGE_SHIFT 完全是一脉相承的思路。
4.3 malloc 到物理页:一次分配的完整链路
很多做练习的人分不清 malloc 和页式管理的关系,这里梳理一条从调用 malloc 到真正拿到物理内存的完整链路。第一步,malloc 在用户态先看自己的空闲链表里有没有可用块,有就直接返回,这时候根本没有发生任何系统调用,也没有触碰页表。第二步,如果没有,malloc 通过 brk 或 mmap 向内核申请一批地址空间,内核做的事情是修改 mm_struct 里的 vm_area_struct,记录“这个地址区间现在归你了”,但此刻并没有分配任何物理页,页表项也还是空的。
第三步,真正关键的一步——当你的程序第一次往这块地址写数据时,CPU 查页表发现对应的页表项无效,触发缺页异常。第四步,内核的缺页处理程序接手,根据出错地址在 vm_area_struct 里找到对应的区间,判断这次访问合不合法、权限对不对。第五步,合法的话,从伙伴系统分配一个物理页,必要时从磁盘把数据读进来,然后填写页表项,设置 present 位。第六步,异常处理返回,重新执行刚才那条出错指令,这次页表有效,顺利通过。
这个“延迟分配”的策略非常重要,它意味着你 malloc 了 1GB 内存,只要不真的去写,物理内存和页表项都不会产生。我第一次用top观察一个申请了大块内存但没用的程序,发现 RSS 几乎为 0 的时候还挺惊讶,后来才明白这就是按需分页的实际效果。课堂练习里的缺页中断和有效位,在真实系统里就是这条链路上最核心的一环。
5. 参数怎么选:页大小、TLB 命中率的量化权衡
5.1 页大小选择背后的计算
页大小不是随便定的,它牵动着一整串指标。页越大,页表项越少、页表越省空间、TLB 一条表项能覆盖的地址范围越大,命中率越高;但页越大,最后一页的内部碎片越严重,平均浪费是页大小的一半,一个 4KB 的页平均浪费 2KB,2MB 的大页平均浪费 1MB,这在大量小进程场景下会很浪费内存。反过来,页越小,碎片越小但页表越大、TLB 覆盖范围越小。
算一笔账。假设某程序平均大小是 10KB,用 4KB 页,需要 3 页,最后一页用掉 2KB 浪费 2KB,浪费率 2/(10+2) 约 16.7%。用 64KB 页,需要 1 页,用掉 10KB 浪费 54KB,浪费率超过 84%。这就是为什么现代系统在保留 4KB 基础页的同时,还会提供 2MB 的透明大页——大页不是给所有场景用的,它是给那些占用大块连续内存、访问又密集的应用(比如大型数据库、虚拟机)准备的,用可控的碎片代价换 TLB 效率和页表空间。
5.2 TLB 命中率对有效访问时间的影响
有效访问时间(EAT)这个公式是练习里的常客,必须会算。设 TLB 查找耗时 t,一次内存访问耗时 m,TLB 命中率 h。命中时:查 TLB(t)加访问内存一次(m);未命中时:查 TLB(t)加访问页表取页框号(m)再加访问数据(m),也就是 t 加两次内存访问。
EAT = h × (t + m) + (1 − h) × (t + 2m)
代进去算一下。设 t = 10ns,m = 100ns,h = 0.98,那么 EAT = 0.98 × (10 + 100) + 0.02 × (10 + 200) = 0.98 × 110 + 0.02 × 210 = 107.8 + 4.2 = 112ns。如果命中率掉到 0.90,EAT = 0.9 × 110 + 0.1 × 210 = 99 + 21 = 120ns,多出来的 8ns 就是 TLB 未命中的代价。可以看出,即使命中率从 98% 掉到 90%,EAT 也只涨了约 7%,这个结果常常出人意料——因为 TLB 未命中只是多访问一次内存,代价约为一次内存访问时间,而不是数量级的惩罚。真正致命的是缺页,那是毫秒级的磁盘操作,和纳秒级的内存访问差六个数量级。这也再次说明,练习里的算法选择要分轻重:TLB 命中率优化收益有限,缺页率优化才是重中之重。
| 参数 | 典型值 | 变化趋势对性能的影响 |
|---|---|---|
| 页大小 | 4KB(基础)/ 2MB(大页) | 增大降低页表开销、提升 TLB 覆盖,但加剧内部碎片 |
| TLB 条目数 | 64~1536 条 | 增大提升命中率,但硬件成本和查找延迟上升 |
| TLB 命中率 | 95%~99% | 每降 1% 大约多几次内存访问,影响小于 10% |
| 缺页率 | 追求趋近 0 | 每次缺页毫秒级,是数量级的惩罚 |
| 页表项大小 | 8 字节(64 位) | 影响多级页表总空间占用 |
6. 常见问题与排查技巧实录
6.1 高频错误速查表
这批练习批下来,错的点高度集中,我把最常见的问题整理成一张表,考前扫一遍能省不少分。
| 现象 | 根本原因 | 正确做法 |
|---|---|---|
| 把页号直接当页框号用 | 混淆了逻辑地址的页号和物理内存的页框号 | 页号是索引,必须查页表得到页框号 |
| 物理地址算出来偏移变了 | 以为偏移也要重新计算 | 页内偏移转换前后完全相同,直接拼接 |
| 页大小改了代码没改 | 宏 PAGE_SHIFT 没同步更新 | 页大小始终是 1 左移 PAGE_SHIFT 位,改大小只改 SHIFT |
| 忽略有效位直接算地址 | 没有检查页表项的 present 位 | 先查有效位,为 0 走缺页处理 |
| 忘记越界检查 | 没有比较页号和页表长度 | 越界检查必须在查表之前 |
| 多级页表页号拆分搞错 | 不知道每一级占多少位 | 位数 = log2(每级表项数),从高位往低位依次切 |
| Belady 异常判断错 | 以为所有算法都遵守“页框多缺页少” | 只有 FIFO 会出现,LRU 和 OPT 不会 |
6.2 自查方法:把答案代回题目验证
手算类的题做完,别急着交卷,用两个办法自检,能抓出八成错误。第一个办法是区间验证:算出物理地址之后,反推它的页框号和偏移,看页框号是不是页表里那个值、偏移是不是和原逻辑地址一致。如果偏移对不上,那一定是算错了。
第二个办法是边界试探:拿刚好跨页的地址试一下。比如页大小 4KB,测试 0x0FFF(本页最后一个字节)和 0x1000(下一页第一个字节),看页号是不是分别加了一。这个测试特别能暴露位运算的边界错误,像>写成>=、掩码少算一位之类的,一试就露馅。我自己写页码计算的时候,习惯性会跑一遍跨页测试,这个习惯帮我省过好几次熬夜排查。
对算法模拟类的题,还有第三个办法:用极端输入测。页框数设为 1,看结果是不是每次都缺页;页框数设得比不同页数还多,看结果是不是等于不同页的数量(因为这时候一次都不会置换)。如果这两个极端都符合预期,中间情况通常就没问题。
6.3 几个从实操里攒下来的经验
最后分享几个在机房带练习时反复强调的点,都是文档里不太会写的东西。
第一,先画地址位图再动笔算。拿张草稿纸,把逻辑地址的二进制或十六进制写出来,在上面标出哪几位是页号、哪几位是偏移,再从中间切开。这个动作看起来笨,但能避免绝大多数移位错误,尤其是多级页表里要切好几刀的时候。
第二,页表项的结构要当成结构体来记。别只记“页框号”,要记住一个页表项里至少还有有效位、保护位、访问位、脏位、修改位这些东西。练习里经常问“访问位置 1 是什么时候”“脏位有什么用”,答案就是:访问位用于置换算法判断最近是否被访问,脏位标记这一页是否被修改过、换出时是否需要写回磁盘。Clock 算法就是靠访问位工作的,脏位则决定换出时的 I/O 代价。
第三,别把页和缓存块混了。页是虚拟内存管理的单位,缓存块是 CPU 缓存和主存之间传输的单位,两者都是 2 的幂但大小和用途完全不同。我见过有人在缺页计算里套用缓存块的映射方式,结果整题错。记住:页和页框尺寸一致,缓存块和主存块尺寸一致,这是两套独立机制。
第四,遇到“抖动”相关的题,先找工作集。抖动就是分配的页框数小于工作集大小,导致频繁换页。判断的关键是看引用串里的局部性,通常连续一小段里反复出现的页就构成当前的工作集。只要能估算出工作集大小并和页框数比较,这类题就有清晰的判断依据,不用靠感觉猜。
第五,写代码验证比反复看笔记划算。前面那两段代码加起来不到 200 行,但把它们跑一遍、改改参数看输出变化,抵得上抄好几遍定义。尤其是置换算法这类题,用代码跑出来的缺页计数表格,比死记结论可靠得多,也更容易在考场上回忆起推导过程。
这套练习的价值其实不在于记住几个数字,而在于建立起“地址怎么拆、表怎么查、页怎么换”这条完整的思维链。把这条链在自己的脑子里走顺了,后面学到虚拟内存、写时复制、内存映射文件这些内容时,你会发现它们都是在这条链上继续往上搭的,不会再有那种突然断片的感觉。