1. 虚拟存储器到底在解决什么问题
1.1 从一道课后题说起:为什么需要虚拟存储器
2022版操作系统教材里关于虚拟存储器的课后题,我印象最深的是那道“某进程访问逻辑地址0x0000,页表项中有效位为0,会发生什么”的题目。很多同学第一反应是“缺页中断”,但再往下追问“缺页中断之后操作系统具体做了什么”“页面从哪里来”“换出哪一页”,就开始卡壳了。这道题其实把虚拟存储器的核心链条全串起来了:逻辑地址到物理地址的映射、页表项的有效位、缺页异常处理、页面置换算法,缺一环都答不完整。
虚拟存储器这个概念,说白了就是给每个进程画了一张“大饼”——你觉得自己独占了一整块连续的大内存,实际上物理内存可能只有你想象的一小部分,剩下的部分操作系统帮你放在了磁盘上,用的时候再换进来。这个“画饼”的过程,就是虚拟存储器要干的事。它要解决的核心矛盾很朴素:程序越来越大,物理内存就那么点,怎么让多个程序同时跑起来还不打架。
我刚开始学的时候总把虚拟存储器和“内存管理”混为一谈,后来才想明白,分页、分段这些是内存管理的基础机制,而虚拟存储器是在这个基础上加了一层“按需加载”和“置换”的逻辑。没有虚拟存储器,分页照样能工作,只是所有页面必须一次性全部装入内存;有了虚拟存储器,页面可以懒加载,内存不够了还能把不常用的页面暂时挪到磁盘上。这个区别在课后题里经常被拿来设陷阱,比如问“分页存储管理是否一定需要虚拟存储器”,答案是否定的,分页可以脱离虚拟存储器独立存在。
1.2 局部性原理:虚拟存储器能成立的底层假设
虚拟存储器之所以能跑得动,全靠一个经验规律撑着——局部性原理。时间局部性说的是你刚访问过的数据,很可能马上还会再访问;空间局部性说的是你访问了某个地址,它旁边的地址大概率也会被访问。这两个规律听起来像废话,但它们是虚拟存储器性能的生命线。
我拿一个实际场景来算笔账。假设一个进程的页面访问序列是1、2、3、1、2、4、1、2、3、4,物理内存只能放3页。如果完全没有局部性,每次访问都是随机跳转,那缺页率会高得离谱,系统大部分时间都在做页面换入换出,CPU利用率暴跌,这种现象叫“抖动”。但真实程序不是这样的,循环、数组遍历、函数调用栈这些结构天然就有很强的局部性。一个for循环访问数组,页面访问序列可能是1、1、1、1、2、2、2、2、3、3、3、3,缺页只发生在切换页面的那几个时刻。
课后题里经常考的一个点是:局部性原理和页面大小的关系。页面太小,页表项太多,内存开销大,而且每次缺页只换进来一点点数据,缺页频率高;页面太大,内部碎片严重,而且一次换入太多用不上的数据,浪费I/O带宽。典型的页面大小在4KB到8KB之间,这个数值不是拍脑袋定的,是权衡了页表开销、I/O效率和碎片率之后的结果。我在做题的时候会特意记一下这个权衡逻辑,因为选择题经常把“页面越大越好”或“页面越小越好”作为干扰项。
1.3 虚拟存储器的三种实现方式与课后题对应关系
虚拟存储器在实现层面主要分三种:请求分页、请求分段、请求段页式。2022版教材课后题里这三种都有涉及,但侧重点不同。请求分页考得最多,因为它最常用,Linux和Windows底层都是基于分页做的;请求分段考概念辨析,比如“分段是否对用户可见”“段表项里有哪些字段”;请求段页式考综合,把两者的优缺点揉在一起问。
请求分页的核心数据结构是页表,每个页表项除了物理页框号之外,还要有有效位、修改位、访问位、保护位这些控制信息。有效位告诉硬件这个页面在不在内存里,修改位告诉操作系统换出的时候要不要写回磁盘,访问位是给置换算法用的参考信息。课后题里有一道经典题:页表项中有效位为0但物理页框号不为0,说明什么?答案是页面被换出到了磁盘上,物理页框号字段现在存的是磁盘地址。这种细节不抠清楚,考试的时候很容易丢分。
请求分段的核心数据结构是段表,每个段表项记录段的基址、长度和保护信息。分段的好处是逻辑清晰,代码段、数据段、堆栈段分开管理,共享和保护都方便。但分段有个致命问题:外部碎片。段的大小不固定,内存里东一块西一块,时间长了就没法分配大段了。所以纯分段现在基本不用了,都是段页式结合。
请求段页式是先分段再分页,每个段内部再分成固定大小的页。这样既有分段的逻辑清晰,又有分页的碎片少。代价是地址转换要查两次表:先查段表找到页表基址,再查页表找到物理页框号。课后题里经常问“段页式系统中访问一个数据需要访问几次内存”,答案是三次:查段表一次、查页表一次、取数据一次。如果加上快表(TLB)命中,次数会减少,这个在计算题里要分情况讨论。
2. 地址转换与缺页处理的核心细节
2.1 逻辑地址到物理地址的完整转换链路
地址转换是虚拟存储器的“心脏”,课后题里至少三分之一的题目跟这个有关。我先把完整链路捋一遍,然后再说课后题里常见的坑。
假设32位系统,页面大小4KB,那么页内偏移占12位,页号占20位。逻辑地址0x00003A4F,拆开就是页号0x00003、偏移0x A4F。第一步查页表,找到页号3对应的页表项。如果有效位是1,页表项里的物理页框号比如是0x0012,那么物理地址就是0x0012A4F。如果有效位是0,触发缺页异常,操作系统介入。
缺页异常的处理流程是:先看内存里有没有空闲页框,有就直接分配;没有就触发页面置换,选一个牺牲页换出。如果牺牲页的修改位是1,说明被写过,要写回磁盘;如果是0,直接丢弃。然后把需要的页面从磁盘读进来,更新页表项,重新执行那条触发缺页的指令。
这里有个课后题高频考点:缺页异常处理完之后,是重新执行当前指令还是执行下一条指令?答案是重新执行当前指令。因为缺页发生在取指或取操作数的阶段,指令还没执行完,必须重新来一遍。这个点我第一遍学的时候没在意,后来做真题才发现几乎年年考。
另一个坑是快表(TLB)和页表的配合。TLB是页表的高速缓存,里面存的是最近用过的页表项。地址转换时先查TLB,命中就直接拿物理页框号,不用访问内存里的页表;不命中才查页表,查到之后再把这一项填入TLB。课后题里经常给一个访问序列,问TLB命中率是多少、有效访问时间是多少。计算的时候要注意:TLB不命中时查页表的那次内存访问也要算进时间,如果页表本身也不在内存里(多级页表的情况),时间还要叠加。
2.2 多级页表:为什么一级页表不够用
32位系统如果页面大小4KB,页表项4字节,那一级页表就要占4MB内存(2^20个页表项乘以4字节)。每个进程都来一份4MB的页表,100个进程就是400MB,光页表就把内存吃光了。这还没算上页表本身也需要连续内存,分配起来很麻烦。
多级页表的思路是把页表再分页。32位地址拆成10位一级页号、10位二级页号、12位偏移。一级页表有1024项,每项指向一个二级页表;二级页表也有1024项,每项指向一个物理页框。这样一级页表只占4KB,二级页表按需分配,进程用不到的地址空间根本不需要二级页表。一个典型进程可能只用几MB内存,对应的二级页表也就几个,总共几十KB就够了。
课后题里有一道很经典的题:某系统采用两级页表,页面大小4KB,页表项4字节,逻辑地址32位。问访问一个数据最多需要访问几次内存?答案是三次:查一级页表一次、查二级页表一次、取数据一次。如果TLB命中,只需要一次。如果一级页表命中但二级页表不命中,还是三次。这种题画个图就清楚了,但考试的时候容易漏算。
多级页表的代价是地址转换变慢,所以现代CPU都用TLB来加速。TLB通常有几十到几百个条目,采用全相联或组相联映射。课后题里如果给出TLB的配置和访问序列,要会算命中率和有效访问时间。我一般会列一个表格,把每次访问的页号、TLB是否命中、是否缺页都标出来,这样不容易乱。
2.3 页面置换算法:OPT、FIFO、LRU、Clock的实战对比
页面置换算法是课后题的重灾区,几乎每套题都有。我把四种常考的算法拉出来对比一下,顺便说说每种算法的坑在哪里。
OPT(最佳置换)是理论最优,淘汰未来最长时间不会被访问的页面。但未来访问序列是未知的,所以OPT只是个理论基准,实际系统用不了。课后题里考OPT主要是让你算缺页率,然后跟其他算法对比,说明其他算法离最优有多远。
FIFO(先进先出)最简单,淘汰最早进入内存的页面。但FIFO有个反直觉的现象叫Belady异常:增加物理页框数,缺页率反而可能上升。课后题里经常给一个访问序列,让你分别算3个页框和4个页框下的FIFO缺页次数,然后发现4个页框的缺页次数更多。这个现象的原因是FIFO没有考虑页面的访问频率,淘汰的可能是经常用的页面。
LRU(最近最少使用)淘汰最长时间没被访问的页面。LRU没有Belady异常,性能接近OPT,但实现代价高。纯LRU需要维护一个访问顺序链表,每次访问都要更新链表,硬件开销大。实际系统里用的是LRU的近似算法,比如Clock算法。
Clock算法给每个页面一个访问位,页面组成一个环形链表。缺页时从当前指针开始扫描,遇到访问位为1的就清零并跳过,遇到访问位为0的就淘汰。这个算法实现简单,性能也不错,Linux早期版本用的就是Clock的变种。
课后题里有一类题是给一个访问序列,让你手动模拟LRU或Clock的置换过程,画出每一步的内存状态。这种题看着简单,但特别容易错在细节上:LRU是访问时更新顺序,还是缺页时才更新?Clock的指针什么时候移动?我一般会老老实实画表格,每一步都标清楚,避免心算出错。
| 算法 | 淘汰依据 | 是否需要硬件支持 | 是否有Belady异常 | 实现复杂度 |
|---|---|---|---|---|
| OPT | 未来最久未访问 | 无法实现 | 无 | 理论基准 |
| FIFO | 最早进入内存 | 简单队列 | 有 | 低 |
| LRU | 过去最久未访问 | 需要时间戳或链表 | 无 | 高 |
| Clock | 访问位+环形扫描 | 访问位+指针 | 无 | 中 |
2.4 缺页率与有效访问时间的计算套路
缺页率和有效访问时间是课后题的计算题主力。我先说公式,再说套路。
有效访问时间 = (1 - p) × 内存访问时间 + p × 缺页处理时间。其中p是缺页率,缺页处理时间包括磁盘I/O时间、操作系统处理时间、重新执行指令的时间。如果题目给了TLB,还要把TLB命中率考虑进去。
举个例子:内存访问时间100ns,缺页处理时间10ms,缺页率0.0001。有效访问时间 = 0.9999 × 100ns + 0.0001 × 10ms = 99.99ns + 1000ns = 1099.99ns。可以看到,即使缺页率只有万分之一,有效访问时间也变成了原来的11倍。这就是为什么缺页率必须压得很低,通常要求低于百万分之一。
课后题里经常给一个访问序列和页框数,让你算缺页次数和缺页率。我一般会先判断页面大小和访问序列的页号,然后模拟置换过程。模拟的时候要注意:首次访问的页面一定缺页,这个要算进去;如果题目说“预调入”或“预分页”,那首次访问可能不缺页,这个要看清楚。
还有一类题是给TLB命中率和缺页率,让你算有效访问时间。这种题的关键是分清TLB命中、TLB不命中但页面在内存、TLB不命中且缺页这三种情况。TLB命中时只需要一次内存访问(取数据);TLB不命中但页面在内存时需要两次(查页表+取数据);缺页时需要加上磁盘I/O时间。我一般会画一个概率树,把三种情况的概率和时间都标出来,然后加权求和。
3. 从课后题反推:虚拟存储器的实操模拟
3.1 用Python模拟一个请求分页系统
光看理论容易飘,我习惯用代码把课后题里的场景跑一遍。下面这个模拟器实现了FIFO和LRU两种置换算法,可以输入访问序列和页框数,输出缺页次数和缺页率。
def fifo(pages, frames): memory = [] faults = 0 for page in pages: if page not in memory: faults += 1 if len(memory) < frames: memory.append(page) else: memory.pop(0) memory.append(page) return faults def lru(pages, frames): memory = [] faults = 0 for page in pages: if page not in memory: faults += 1 if len(memory) < frames: memory.append(page) else: memory.pop(0) memory.append(page) else: memory.remove(page) memory.append(page) return faults pages = [1, 2, 3, 4, 1, 2, 5, 1, 2, 3, 4, 5] print("FIFO缺页次数:", fifo(pages, 3)) print("LRU缺页次数:", lru(pages, 3))跑出来的结果是FIFO缺页9次,LRU缺页10次。这个序列下FIFO反而比LRU好,说明置换算法的性能和访问序列强相关,没有哪个算法在所有场景下都最优。课后题里如果给了一个特定序列,一定要老老实实模拟,不要凭感觉猜。
这个模拟器还可以扩展:加上Clock算法、加上访问位和修改位、加上多级页表。我建议你自己动手改一改,改的过程中会对页表项的结构和置换算法的细节有更深的理解。比如加上修改位之后,换出页面时要判断是否写回磁盘,这个逻辑在课后题里经常考。
3.2 手动模拟一道完整课后题:从地址到物理页框
我拿一道典型的课后题来走一遍完整流程。题目:某系统页面大小1KB,页表如下,逻辑地址0x1A2B,求物理地址。
页表:
- 页号0 -> 页框号5,有效位1
- 页号1 -> 页框号3,有效位1
- 页号2 -> 页框号8,有效位0,磁盘地址0x1000
- 页号3 -> 页框号2,有效位1
第一步,拆逻辑地址。页面大小1KB,偏移占10位。0x1A2B = 0001 1010 0010 1011,低10位是00 0010 1011 = 0x02B,高6位是000110 = 6。等等,这里页号是6,但页表里只有0到3。说明这个地址越界了,会触发段错误或访问异常。课后题里经常设这种陷阱,考察你有没有检查页号是否超出页表范围。
如果题目改成逻辑地址0x0A2B,页号是2,偏移是0x22B。查页表,页号2的有效位是0,触发缺页异常。操作系统把磁盘地址0x1000的页面读入内存,假设分配到页框号7,更新页表项为有效位1、页框号7。然后重新执行指令,物理地址 = 7 × 1KB + 0x22B = 0x1C22B。
这个流程里每一步都有课后题对应的考点:地址拆分考页面大小的计算,页号越界考地址空间保护,有效位为0考缺页处理,页框号拼接考物理地址计算。我建议你拿几道真题按这个流程走一遍,走多了就形成肌肉记忆了。
3.3 页面置换算法的可视化对比
为了直观对比FIFO和LRU,我写了一个简单的可视化脚本,用不同颜色标出每次访问后的内存状态。虽然这里不能放图,但我把输出格式设计成表格,你可以直接看。
def visualize(pages, frames, algo): memory = [] print(f"{algo}模拟,页框数={frames}") print("访问页 | 内存状态 | 是否缺页") for page in pages: fault = page not in memory if fault: if len(memory) < frames: memory.append(page) else: if algo == "FIFO": memory.pop(0) else: memory.pop(0) memory.append(page) else: if algo == "LRU": memory.remove(page) memory.append(page) print(f" {page} | {memory} | {'是' if fault else '否'}") pages = [1, 2, 3, 4, 1, 2, 5] visualize(pages, 3, "FIFO") visualize(pages, 3, "LRU")跑出来的表格能清楚看到每一步内存里有哪些页面。FIFO在第4次访问时淘汰了1,LRU在第5次访问时把1移到了末尾。这种可视化对理解置换算法的差异特别有帮助,尤其是做课后题的时候,可以自己跑一遍验证手算结果。
4. 常见问题与排查技巧实录
4.1 课后题高频错误TOP5
我整理了做2022版课后题时最容易错的五个点,每一个都是血泪教训。
第一个错误是把缺页中断和缺页异常混为一谈。严格来说,缺页中断是内部中断,是CPU在执行指令时发现页面不在内存而触发的;缺页异常是操作系统层面的处理流程。课后题里如果问“缺页中断属于什么类型的中断”,答案是内部中断或异常,不是外部中断。
第二个错误是忘记更新TLB。缺页处理完之后,页表项变了,TLB里对应的旧条目必须失效,否则下次访问还会用旧的映射。课后题里如果问“缺页处理后需要做什么”,除了更新页表,还要更新TLB或使TLB失效。
第三个错误是LRU和Clock的访问位更新时机搞混。LRU是每次访问都更新顺序,Clock是访问时把访问位置1,缺页扫描时才清零。我一开始总把Clock的访问位当成LRU的时间戳用,结果模拟出来的结果全错。
第四个错误是有效访问时间计算时漏算TLB不命中的内存访问。TLB不命中时,查页表需要访问内存,这次访问的时间要算进去。如果页表是多级的,还要算多次。
第五个错误是Belady异常的判断。Belady异常只出现在FIFO和Clock的某些变种中,LRU和OPT没有。课后题里如果给了一个序列让你判断是否出现Belady异常,先看算法是不是FIFO,再看页框数增加时缺页次数是否增加。
4.2 实操排查:模拟结果和手算不一致怎么办
我经常遇到模拟器跑出来的结果和手算不一样,排查下来无非几个原因。
先检查页面大小和地址拆分。页面大小是1KB还是4KB,偏移占几位,页号占几位,这些算错了后面全错。我一般会把地址转成二进制,手动数一遍位数。
再检查访问序列的页号。有时候题目给的是地址序列,需要先除以页面大小得到页号。如果页面大小是1KB,地址0x1000的页号是4,不是0。这个转换错了,整个序列就错了。
然后检查置换算法的细节。FIFO是淘汰最早进入的,不是最早访问的;LRU是淘汰最久没访问的,不是最早进入的。这两个容易搞反。Clock的指针移动规则也要确认:是每次缺页都移动,还是只有淘汰时才移动。
最后检查初始状态。内存一开始是空的,首次访问一定缺页。如果题目说“预调入”了某些页面,那初始状态就不是空的。这个细节不看清楚,缺页次数会差很多。
4.3 独家避坑技巧:用表格法做置换算法题
我做置换算法题有一个习惯:画一个表格,行是访问序列,列是内存页框,最后一列标是否缺页。每一步都填清楚,不跳步。这个方法看着笨,但正确率极高。
| 访问页 | 页框1 | 页框2 | 页框3 | 缺页 |
|---|---|---|---|---|
| 1 | 1 | - | - | 是 |
| 2 | 1 | 2 | - | 是 |
| 3 | 1 | 2 | 3 | 是 |
| 4 | 4 | 2 | 3 | 是 |
| 1 | 4 | 1 | 3 | 是 |
| 2 | 4 | 1 | 2 | 是 |
这个表格是FIFO的模拟,每一步淘汰哪个页面一目了然。LRU的话,每次访问命中时要把该页移到最新位置,表格里可以用箭头或重新排列表示。Clock的话,额外标一个指针位置和访问位。
我考试的时候会先在草稿纸上画这个表格,确认无误后再写答案。虽然多花一两分钟,但比心算出错再检查要划算得多。
4.4 从课后题到真实系统:Linux的页面回收机制
课后题里的置换算法是简化版,真实系统要复杂得多。Linux的页面回收机制结合了LRU和Clock的思想,把页面分成活跃链表和非活跃链表。新分配的页面先放在非活跃链表尾部,被访问两次后移到活跃链表。回收时从非活跃链表头部开始,如果页面被访问过就移到活跃链表,否则回收。
这个机制比纯LRU更适应实际负载,因为活跃链表保护了热点页面,非活跃链表给了冷页面一个缓冲期。课后题里如果问“Linux为什么不用纯LRU”,答案就是纯LRU的硬件开销太大,而且对扫描型访问模式不友好。
理解真实系统的机制反过来能帮你做课后题。比如有一道题问“为什么Clock算法要给第二次机会”,答案就是给那些偶尔被访问的页面一个留在内存的机会,避免频繁换入换出。这个逻辑和Linux的活跃/非活跃链表是一致的。
4.5 常见问题速查表
| 问题现象 | 可能原因 | 排查方法 |
|---|---|---|
| 缺页次数算多了 | 首次访问重复计算 | 检查是否把预调入的页面也算缺页 |
| 缺页次数算少了 | 漏算首次访问 | 确认内存初始为空 |
| 物理地址算错 | 页框号和偏移拼接错误 | 用二进制重新拼一遍 |
| TLB命中率算错 | 混淆了TLB和页表 | 先查TLB再查页表,顺序不能反 |
| Belady异常判断错 | 算法类型搞混 | 只有FIFO和部分Clock变种有 |
| 有效访问时间偏大 | 漏算TLB不命中的内存访问 | 把三种情况的时间都加上 |
这张表是我做课后题时总结的,每次遇到不确定的题就对照着查一遍。尤其是缺页次数的计算,首次访问到底算不算缺页,题目里如果没有明确说“预调入”,默认内存初始为空,首次访问一定缺页。
4.6 一个容易被忽略的细节:页面大小与磁盘I/O的关系
课后题里很少直接考磁盘I/O的细节,但页面大小的选择直接影响I/O效率。磁盘的物理块大小通常是512字节或4KB,如果页面大小和磁盘块大小不匹配,一次页面换入换出可能需要多次磁盘I/O。比如页面大小是4KB,磁盘块是512字节,换入一个页面要读8个磁盘块,寻道时间和旋转延迟叠加起来很可观。
现代系统通常把页面大小设成和磁盘块大小一致,或者用簇的方式一次读多个块。课后题里如果问“页面大小应该怎么选”,答案要兼顾页表开销、内部碎片和I/O效率。太小了页表大,太大了碎片多,4KB到8KB是经验值。
我在模拟器里加了一个参数模拟磁盘I/O时间,发现页面大小从4KB增加到8KB时,缺页率下降但每次缺页的I/O时间翻倍,总时间不一定更优。这个权衡在课后题的计算题里偶尔会出现,要会算。
5. 虚拟存储器的性能边界与扩展思考
5.1 抖动:虚拟存储器的性能悬崖
抖动是虚拟存储器最怕遇到的情况。物理内存严重不足,页面频繁换入换出,CPU大部分时间都在等磁盘I/O,系统吞吐量断崖式下跌。课后题里如果问“抖动的原因是什么”,标准答案是“分配的物理页框数少于工作集大小”。
工作集是一个进程在某个时间窗口内实际访问的页面集合。如果物理页框数小于工作集大小,进程就会不断缺页,因为刚换出的页面马上又要用。操作系统可以通过工作集模型来动态调整分配给每个进程的页框数,但实现起来很复杂。
我做过一个简单的模拟:一个进程的工作集是5页,但只给它3个页框,缺页率高得离谱;给它5个页框,缺页率骤降;给它6个页框,缺页率几乎不变。这个拐点就是工作集大小。课后题里如果给了一个访问序列,让你算工作集大小,就是找那个让缺页率不再明显下降的页框数。
5.2 页面大小、TLB覆盖率和多级页表的三角关系
页面大小、TLB覆盖率和多级页表层级这三个参数是互相牵制的。页面越大,TLB能覆盖的地址范围越大,但内部碎片越多;页面越小,TLB覆盖率越低,但碎片少。多级页表可以解决页表太大的问题,但增加了地址转换的延迟。
我算过一笔账:32位系统,页面4KB,TLB有64个条目,覆盖率是64 × 4KB = 256KB。如果页面变成8KB,覆盖率翻倍到512KB,但内部碎片平均浪费4KB。如果页面变成2KB,覆盖率减半到128KB,但页表项翻倍,页表占用的内存也翻倍。
课后题里如果问“为什么现代系统倾向于用大页面”,答案是为了提高TLB覆盖率,减少地址转换开销。但大页面也有代价,所以实际系统通常支持多种页面大小,比如Linux的透明大页(THP)就是自动把连续的小页面合并成大页面。
5.3 从课后题到实际调优:swappiness参数
Linux有一个swappiness参数,控制内核把页面换出到磁盘的倾向。值越高,内核越倾向于换出页面;值越低,越倾向于保留页面在内存里。默认值是60,对于桌面系统可能偏高,对于服务器可能偏低。
这个参数和课后题里的置换算法选择是同一个逻辑:换出太积极会导致缺页率高,换出太保守会导致内存不足。调优的时候要根据工作负载来定。数据库服务器通常把swappiness设低,因为数据库的缓存页面被换出后重新读入的代价很大;桌面系统可以设高一点,因为桌面应用的局部性通常比较好。
我自己的机器上把swappiness设成了10,因为跑的东西不多,内存够用,不希望内核太积极地把页面换出去。这个经验在课后题里不会考,但理解了这个参数,对置换算法的实际影响会有更具体的感受。
5.4 虚拟存储器的安全边界:地址空间隔离
虚拟存储器除了解决内存不足,还有一个重要作用是地址空间隔离。每个进程有自己的页表,逻辑地址到物理地址的映射是独立的,一个进程不能直接访问另一个进程的物理内存。这个隔离是操作系统安全的基础。
课后题里如果问“虚拟存储器提供了什么保护机制”,答案包括:地址空间隔离、读写执行权限控制、内核空间和用户空间分离。页表项里的保护位可以标记一个页面是只读、可写还是可执行,CPU在地址转换时会检查这些位,违反就触发保护异常。
这个机制在实际系统里非常重要。比如一个进程试图写只读的代码段,CPU会触发保护异常,操作系统给进程发一个段错误信号。课后题里经常把缺页异常和保护异常放在一起考,要分清两者的触发条件和处理流程。
5.5 一个值得深挖的方向:反向页表
反向页表是虚拟存储器的一个变种,页表项不是按逻辑页号索引,而是按物理页框号索引。每个物理页框对应一个页表项,记录哪个进程的哪个逻辑页映射到了这个页框。这样页表的大小只和物理内存大小有关,和逻辑地址空间无关。
反向页表的优点是节省内存,尤其适合64位系统。64位系统的逻辑地址空间巨大,正向页表根本存不下。但反向页表的缺点是查找慢,给定逻辑页号要找对应的物理页框,需要遍历整个反向页表或者用哈希表加速。
课后题里如果考反向页表,通常问“反向页表的页表项数量和什么有关”,答案是物理页框数,不是逻辑页数。这个点容易搞混,我第一遍做的时候就错了。
5.6 从2022版课后题看命题趋势
2022版教材的课后题相比前几版,更注重综合应用。纯概念题减少了,计算题和综合分析题增加了。比如有一道题给了一个多级页表和TLB的配置,让你算有效访问时间,同时问缺页处理时TLB怎么更新。这种题把地址转换、TLB、缺页处理全串起来了,只背概念是答不出来的。
另一个趋势是结合实际系统。比如问“Linux的页面回收和Clock算法的异同”,这种题要求你不仅懂算法,还要知道实际系统怎么实现的。我建议做课后题的时候不要只盯着答案,多想想这个知识点在实际系统里对应什么机制,这样理解得更深,做题也更灵活。
我自己的做法是每做完一章的课后题,就用代码把里面的算法模拟一遍。模拟的过程中会遇到很多题目里没提到的细节,比如页表项的更新顺序、TLB失效的时机、置换算法的边界条件。这些细节在考试里可能不考,但在实际工作中很重要。而且模拟一遍之后,再做类似的题基本不会错,因为你是从原理层面理解的,不是死记硬背。
最后分享一个我做题的小习惯:遇到不确定的题,先画图。地址转换画地址拆分图,置换算法画内存状态表,多级页表画树状图。图一画出来,逻辑就清楚了,比在脑子里空想靠谱得多。这个习惯帮我省了很多检查的时间,也让我对虚拟存储器的整体结构有了更直观的认识。