我学《计算机操作系统》第四章的时候,一度怀疑自己是不是脑子转不过来。书上从程序的装入和链接一路讲到虚拟存储器,页表、分段、段式、段页式、局部性、抖动几个概念轮番上阵,我明明每个名词都眼熟,做题却总是卡壳。后来才想明白,问题出在我把存储器管理当成了一堆需要背的考点,而没有把它当成操作系统在资源紧缺时的一条“分配方案链”。这篇就围绕第四章-存储器管理,把这条链是怎么一环扣一环的讲清楚:从物理内存怎么切、地址怎么映射,到虚拟内存怎么换页,再到我亲手写的一个C语言模拟实验,以及复习时最容易翻车的细节。如果你正在啃这个章节,或者在准备操作系统课设、考研复试,这篇文章应该能给你一条更容易走通的路。
1. 别急着背概念,先想想“内存为什么需要管”
1.1 程序员和操作系统眼中的内存,不是同一个东西
写程序的时候,我们觉得内存是理所当然的:定义一个变量,malloc一块空间,指针随便指来指去。可在操作系统的视角里,内存是一个又贵又小的资源,而且同一时刻可能有好几个进程都在抢它。每个进程都以为自己拥有一整块连续的地址空间,实际上物理内存被切得七零八落,由操作系统负责把这些“假地址”映射到真正的内存条上。
所以存储器管理要回答的问题其实很朴素:谁住哪块内存、怎么让它们不打架、放不下怎么办、换进换出怎么换。教材第一页经常提到的“内存利用率、程序运行效率、系统可靠性”这三个词,本质就是这些问题的目标。学习这一章前,先把这句话刻在脑子里:逻辑地址是进程视角,物理地址是机器视角,中间的翻译工作就是存储器管理。
1.2 从单道到多道,内存管理是被逼出来的
在最早的单道程序环境下,一个程序独占整个内存,CPU遇到I/O操作只能傻等,内存再大也用不满。后来有了多道程序设计,内存里同时驻留几个程序,CPU在它们之间切换,利用率才上来。可多道程序带来的第一个问题,就是“这些程序怎么塞进内存”。
早期方案是连续分配,一个进程必须占一整块连续的空间。连续分配已经发展出了好几种策略:固定分区是提前把内存切成固定大小的区,一个区装一个进程,简单但内部碎片严重;动态分区是等进程来了再切一块正好大小的区,又细分为首次适应(找到第一个够大的空闲区就用)、最佳适应(找最小的足够空间)、最坏适应(找最大的空闲区)。这三个算法是第四章前几节的必考内容,但考试只考选择的话,很多人容易把“最佳适应”为什么会产生小碎片搞混——因为它宁可找最小的够用空隙,结果是剩下一个更小的碎片,很难再被利用。
动态分区还有一个绕不过去的问题:外部碎片。进程换进换出,内存里会出现一些散落的、彼此不相邻的小空闲区,总和可能很大,却没有一块能连续装下新进程。比如两个空闲区分别是30KB和40KB,来了一个需要50KB的进程,就算总空闲70KB,也分配不出去。经典办法是“紧凑”(compaction),把所有进程挪到一起合并出大块空闲区,但挪动进程的代价很高,后来基本被分页方案取代。
1.3 覆盖和交换的教训:管理粒度决定一切
在分页方案成熟之前,人们也尝试过两种“硬凑”的办法。
- 覆盖(overlay):程序员自己把程序拆成若干模块,同一时间只让一部分模块驻留内存,其他模块用到时再从磁盘调入。这个方案把内存管理的负担完全甩给了程序员,你得自己分析模块间的依赖关系,程序规模一大就是灾难。
- 交换(swapping):操作系统把暂时不运行的进程整体换到磁盘,等需要时再换入内存。交换的粒度是整个进程,换入换出一次就是几MB甚至几十MB的I/O,非常耗时。
这两个方案失败的原因,本质上都是管理粒度过粗。覆盖要由人来决定粒度,交换以进程为粒度,都缺乏灵活度。分页方案出现后,把粒度细化为固定大小的小块(页),才真正解决了碎片和调度的两难。学到这里,你会看到一个清晰的递进逻辑:管理粒度越细,内存利用越灵活,操作系统要做的事情也越多。第四章后面所有内容,都是在这个逻辑上展开的。
2. 分页、分段、段页式:三种方案到底在解决什么
2.1 分页:用离散空间消灭外部碎片
分页的核心思想一句话就能说清:把进程的逻辑地址空间切成等大小的块,叫页面(page);把物理内存也切成等大小的块,叫页框(frame)。页面大小通常是4KB,也可以是2KB、8KB。进程页面可以离散地放在任意空闲页框里,不再要求连续,所以外部碎片直接被消灭了。
实现这个离散映射的关键数据结构是页表,页表记录“逻辑页号 -> 物理页框号”的对应关系。地址格式是:逻辑地址 = 页号 + 页内偏移;物理地址 = 页框号 + 页内偏移。页内偏移在页面大小是2的n次幂时,就是逻辑地址的低n位,前面剩下的高位就是页号,非常方便硬件拼接。
分页不是没有碎片,它只是把外部碎片变成了很小的内部碎片:进程最后一个页面往往装不满,平均浪费半页。但这在工程上完全可以接受,4KB页面最多浪费2KB,比连续分配随时可能出现的几十KB外部碎片温和多了。
2.2 分段:让内存也跟着程序逻辑走
分页是从内存利用角度出发的,是机器视角;分段则站在程序员视角,按程序的逻辑结构切分。一个程序天然就是由代码段、数据段、堆栈段等组成的,段内是连续的逻辑空间,各段大小不一样。
分段地址 = 段号 + 段内偏移,段表里记录每个段的基址和段长。访问时要先查段表,再检查偏移是否超过段长,超出就触发越界中断,这是分页没有的“逻辑保护”。分段还有个分页难以替代的好处:共享方便。两个进程想共享同一个代码库,只需要让它们的段表项指向同一个段基址,而不像分页那样要逐页共享。
所以分页和分段的区别经常被拿来当简答题:分页是系统自动完成、对程序员透明、解决物理碎片;分段是用户可见、按逻辑单位划分、方便共享和保护。
2.3 段页式:先分段后分页,逻辑和物理都要
既然分段有逻辑优势,分页有物理优势,那就合体:先按逻辑分段,每段内部再分页。地址结构变成“段号 + 段内页号 + 页内偏移”三层,段表项里存的不是段基址,而是该段的页表始址和页表长度。访问一次数据需要查段表、查页表、读内存,共三次访存,性能代价比单纯分页高,但换来的是逻辑保护和物理离散双重收益。
段页式是第四章的难点,最容易被问倒的点是:别把段表和页表的层次搞反。段表放在全局,根据段号找到对应页表;页表是每段一个,根据段内页号找到页框号;最后一层偏移直接和页框号拼接得到物理地址。可以把它想象成查书目录:先按章查(段号),再按节查(页号),最后定位到具体行(偏移)。
2.4 三张分配方案的对比角度
考前如果把这三张方案放在一张表里对比,会清晰很多:
| 方案 | 分配单位 | 地址结构 | 访存次数 | 主要碎片 | 共享/保护 |
|---|---|---|---|---|---|
| 连续分配 | 整个进程 | 基址+长度 | 1次 | 外部碎片为主 | 困难 |
| 分页 | 定长页面 | 页号+偏移 | 2次 | 内部碎片 | 共享不便 |
| 分段 | 变长逻辑段 | 段号+偏移 | 2次 | 外部碎片 | 天然支持 |
| 段页式 | 分段拆分页 | 段号+页号+偏移 | 3次 | 内部碎片 | 支持 |
注意,分段因为段长可变,依然可能出现外部碎片;分页因为等宽,外部碎片清零,但会有少量内部碎片。考试答“分页消除了外部碎片”时,记得补一句“没有消除内部碎片”,这就是得分点。
3. 地址变换是考点,也是理解门:从公式到TLB
3.1 一个典型计算:逻辑地址怎么变成物理地址
地址变换是这一章的“动手题”。基础公式只有两条:
- 页号 p = 逻辑地址 / 页面大小,页内偏移 d = 逻辑地址 % 页面大小;
- 物理地址 = 页框号 f × 页面大小 + 页内偏移 d。
页面大小是2的幂时,其实不用乘除,直接移位和拼接更直观。假定页面大小4KB(2^12),逻辑地址是十六进制0x1123,低12位是页内偏移,也就是0x123,高位的0x1是页号。如果页表里页号1对应的页框号是7,物理地址就是偏移0x123前面接上页框号7的二进制11位表示,结果为0x7123。
这里有个坑很多人踩:题目如果给物理内存大小是4GB,页面4KB,那页框号要有20位才够表示2^20个页框。计算物理地址时,要把页框号左移12位再和偏移相或,不能直接写成“页框号乘以4096”就完事,这个乘法和左移本质上是一样的,但能帮你保持位数直观。
3.2 页表多大、多级页表为什么省
32位逻辑地址,4KB页面,则页号占20位。如果页表项占4字节,一张页表的大小就是 2^20 × 4B = 4MB。每个进程一张页表,100个进程就是400MB,光页表就吃掉了大量物理内存,这显然不划算。
多级页表的思路很简单:只给实际用到的虚拟页面建立二级页表。32位地址分两级,一级页表有 2^10 项,每项指向一个二级页表;二级页表同样 2^10 项。一个进程如果只用了很少的地址空间,就不需要为未使用的区域分配二级页表,页表总占用从4MB降到了几十KB级别。代价是地址变换时多查一次页表,访存次数从2次变成3次。所以多级页表不是免费的午餐,它是用时间换空间。
3.3 快表TLB和有效访问时间
页表放在内存里,意味着CPU每次取数据都要先查内存中的页表,访存次数翻倍。为了解决这个性能问题,硬件在CPU里加了一个很小的快表(TLB),保存最近访问的页表项。CPU生成逻辑地址后先查TLB,命中就直接得到页框号,只需再访问一次内存取数据;未命中才去查内存中的页表,查完顺便把页表项装进TLB。
有效访问时间EAT是常考计算题。设访存时间为 t,查快表时间为 λ,命中率为 α,则:
EAT = α × (λ + t) + (1 - α) × (λ + 2t)
这里第二项未命中时是 λ + t(查页表) + t(取数据),共两次访存。举个例子:t = 100ns,λ = 20ns,命中率98%,则 EAT = 0.98×120 + 0.02×220 = 117.6 + 4.4 = 122ns。如果命中率掉到90%,EAT = 108 + 22 = 130ns,性能下降不大;但如果没有TLB,EAT = 200ns,立刻翻倍。这就是为什么现代CPU会把TLB命中率当作重要性能指标。
3.4 页表项里藏着哪些标志位
页表项不只是“页号->页框号”的映射表,每个页表项里还有几个关键标志位:
- 存在位(有效位):页是否在物理内存中。为0时,访问该页会触发缺页中断。
- 修改位:页在内存期间是否被写改。换出时如果修改位为1,必须把页写回磁盘;为0则可以直接丢弃,省一次磁盘I/O。
- 访问位:页最近是否被访问过。页面置换算法里的CLOCK算法主要靠它打分。
这几位在做模拟实验时非常重要,许多人写置换算法时只盯着页号,忘了维护修改位和访问位,实验效果就差很多。
4. 虚拟存储器:放不下就换,关键在置换算法
4.1 局部性原理是虚拟内存的物理基础
虚拟内存能成立,靠的不是魔法,而是局部性原理。程序运行时的指令和数据访问不是均匀分布的:循环体内的代码会被反复执行,这是时间局部性;访问过某个地址后,它附近的地址很快也会被访问,这是空间局部性。
因为局部性,操作系统只需要把当前要用的页面放在内存里,其他页面留在磁盘。对进程来说,它拥有一个比物理内存大得多的地址空间,这就是“虚拟”的来源。第四章后面讲请求调页、预调页,基础都是局部性原理。很多人不理解虚拟内存为什么不是“一次性把程序全部调入再运行”,就是没想明白局部性。
4.2 缺页中断的完整流程
当CPU访问的页面不在内存时,会发生缺页中断。流程分几步:
- CPU查页表(如果TLB没命中),发现存在位为0。
- 缺页中断触发,操作系统被叫起来。
- 在内存里找空闲页框;如果没有空闲页框,就要选一个受害者页面换出。
- 换出时看修改位,如果页被修改过就写回磁盘,否则直接丢弃。
- 从磁盘把需要的页面读入空闲页框。
- 更新页表,把存在位置1,填上页框号。
- 重新执行导致缺页的那条指令。
注意最后一步:缺页是在一条指令执行到一半时发生的,处理完缺页后CPU必须重新执行那条指令,而不是从下一条继续。这个细节经常被忽略,但在简答题里是送分别丢掉的分。
4.3 页面置换算法:谁走谁留,各有门道
到了内存满了还要继续调入新页时,就必须置换。四个算法是本章核心中的核心:
- OPT(最佳置换):淘汰以后最长时间不会被访问的页。它是理想情况,实际没法实现,因为操作系统没法预测未来。但它是衡量其他算法优劣的标尺。
- FIFO(先进先出):淘汰最早进入内存的页。实现简单,用一个队列就行。但它不看访问频率,容易把正在被高频使用的页换走,甚至出现Belady异常:分配到的物理页框增多,缺页次数反而增加。
- LRU(最近最久未使用):淘汰最长时间没被访问的页。它比较符合局部性直觉,但实现需要每次访问都记录时间戳,硬件开销大。
- Clock(时钟):LRU的近似实现。每个页有一个使用位,缺页时指针沿着环形队列走,使用位为1就清零并继续走,碰到0就换出。开销小,被工业界用得最多。
Belady异常是FIFO的标志性现象。经典引用串:1 2 3 4 1 2 5 1 2 3 4 5,物理块数从3增加到4,FIFO的缺页次数反而从9次变成10次。LRU和OPT则不会出现这种反常——块数增加只可能缺页减少或不变。这个点经常被拿来出选择题,务必记住FIFO是唯一闹这个脾气的算法。
4.4 颠簸(thrashing)和工作集
如果分配给一个进程的物理页框数太少,或者置换策略不对,进程会频繁缺页,CPU大量时间花在等待磁盘I/O上,系统吞吐量暴跌,这种现象叫颠簸。
理解颠簸要引入工作集概念:工作集是一个进程在某段时间内实际访问到的页面集合。如果进程的驻留集(实际占用的页框数)小于工作集,就会一直缺页;反过来,驻留集足够大,缺页率就会降低。解决颠簸的常见手段是调整驻留集大小、采用局部置换(进程只能替换自己的页面)。这个知识点把虚拟内存和进程调度连起来了,很多学生学到这章期末才发现前面没学扎实,原因就是没把工作集当作“动态的访问集合”来理解。
5. 用C语言写一个页面置换模拟器,实测FIFO和LRU
5.1 为什么值得写这个模拟器
纸上得来终觉浅,这句话在操作系统身上尤其对。你能很轻松地背出“FIFO淘汰最早进入的页”,但真让你模拟一个引用串,很多人在第一步就卡住:页表怎么表示?空闲页框怎么找?内存里的页面要不要顺序记录?这些细节不亲手写一遍,永远只是模糊的印象。
写一个最小模拟器还有一个好处:它能帮你做实验。你可以在同一段引用串上对比FIFO和LRU,观察Belady异常什么时候出现,然后把算法扩展成Clock再跑一遍。比起看十遍教材,自己改代码跑数据印象深得多。
5.2 数据结构与两个算法的实现思路
我用C语言写了一个精简模拟器。数据结构很简单:
- 一个
frames[]数组表示物理页框中现在装的页号,初始化为-1表示空闲; - 一个
loaded[]数组记录每个页框里那个页是第几次调入的,用来实现FIFO; - 一个
last_access[]数组记录每个页最近一次被访问的时间,用来实现LRU。
FIFO的思路是用“装入时间”代替队列:每次缺页时扫描loaded,找到值最小的页框就是最早装入的,把它替换掉。LRU每次命中时刷新last_access,缺页时找last_access最小的页框替换。这样两份逻辑对称,对比起来特别直观。
5.3 核心代码与运行结果
直接看关键函数:
#include <stdio.h> #define MAX_FRAMES 32 #define MAX_REF 128 int my_fifo(int ref[], int n, int fc) { int frames[MAX_FRAMES]; int loaded[MAX_FRAMES]; int time = 0, faults = 0; for (int i = 0; i < fc; i++) { frames[i] = -1; loaded[i] = -1; } for (int i = 0; i < n; i++) { int hit = 0; for (int j = 0; j < fc; j++) { if (frames[j] == ref[i]) { hit = 1; break; } } if (hit) continue; faults++; int free_slot = -1; for (int j = 0; j < fc; j++) { if (frames[j] == -1) { free_slot = j; break; } } if (free_slot >= 0) { frames[free_slot] = ref[i]; loaded[free_slot] = time; } else { int victim = 0; for (int j = 1; j < fc; j++) { if (loaded[j] < loaded[victim]) victim = j; } frames[victim] = ref[i]; loaded[victim] = time; } time++; } return faults; } int my_lru(int ref[], int n, int fc) { int frames[MAX_FRAMES]; int last_access[MAX_FRAMES]; int time = 0, faults = 0; for (int i = 0; i < fc; i++) { frames[i] = -1; last_access[i] = -1; } for (int i = 0; i < n; i++) { int hit = 0; for (int j = 0; j < fc; j++) { if (frames[j] == ref[i]) { hit = 1; last_access[j] = time; break; } } if (hit) { time++; continue; } faults++; int free_slot = -1; for (int j = 0; j < fc; j++) { if (frames[j] == -1) { free_slot = j; break; } } if (free_slot >= 0) { frames[free_slot] = ref[i]; last_access[free_slot] = time; } else { int victim = 0; for (int j = 1; j < fc; j++) { if (last_access[j] < last_access[victim]) victim = j; } frames[victim] = ref[i]; last_access[victim] = time; } time++; } return faults; }跑一组简单引用串1 2 3 1 4 1 2 3,物理块3个:FIFO缺页7次,LRU缺页6次。你还会在该代码上复现经典FIFO的Belady异常:引用串1 2 3 4 1 2 5 1 2 3 4 5,物理块3时缺页9次,块4时反而缺页10次。这两个实验做完,比背十遍书本有用。
5.4 踩过的坑和扩展方向
写模拟器时最容易在这几个地方翻车:
- 初始装载也是缺页:很多新手从第1次缺页开始算,但首次调入页面同样要走缺页中断流程,必须计入。
- 页面编号和数组下标:引用串如果从1开始,内存数组的索引又是从0开始,初始化时容易有一串“段错误”。
- FIFO的队列不要自己去实现“搬移”:除非你愿意写散列表,否则用装入时间戳代替队列又简单又稳。
- LRU时间戳溢出:模拟数据规模小不致命,但如果你把引用串拉长到几万条,int时间戳可能溢出,换成long或者定期重排。
扩展方向很多:加入Clock算法,给每个页加访问位和修改位,比较两种Clock版本的缺页数;或者把模拟器升级成“分配器”,接收一个虚拟地址,按照页表计算出物理地址并打印整个过程。这就是一个相当完整的操作系统课设雏形了。
6. 把第四章织成一张网:复习路线与答题要点
6.1 四个问题自查,比抄十遍笔记有效
复习到后期,我会用四个问题检验自己是否真的理解这一章,而不是背概念:
- 外部碎片是怎么产生的?分页为什么能消除它?分段为什么又会引入外部碎片?
- 一次地址变换到底要访存几次?单级页表几次,多级页表几次,段页式几次?为什么TLB能减少这些访问?
- 缺页中断的完整流程,从CPU访存到重新执行指令,能面不改色讲三分钟吗?
- 什么是颠簸?工作集和驻留集的关系是什么?调整哪个参数能缓解颠簸?
这四个问题每个都能串起一组知识点。比如第一个问题的答案里,其实就包含了连续分配、动态分区算法、紧凑、分页、分段全部内容。能讲清楚,说明你已经把“分配方案链”打通了。
6.2 三类高频考题的“坑位”提醒
我见过太多人复习时栽在同样的坑里:
- 计算物理地址:题目给页面大小和页表,求某逻辑地址的物理地址。坑在页大小不是2的幂次或页号从0/1开始的约定,先统一再算,别直接乘。
- 页表大小:页表项不是只有页框号,还有标志位。算页表占多少字节时,要注意页表项是几个字节;算多级页表时,要保证每一级页表恰好能放进一个页面。
- 置换算法缺页次数:初始装载算缺页;FIFO可能出现Belady异常;OPT不能实现只能分析。遇到给引用串算缺页的题,别紧张到把“命中”也记成缺页。
还有个更容易忽略的角度:为什么页面大小不能无限大?页面太大,内部碎片多,页数少;页面太小,页表太大,TLB能覆盖的范围也小。现代操作系统选4KB是一个折中,理解这个权衡,面试时比死背“4KB是经典选择”生动得多。
6.3 结合Linux和实验平台加深理解
如果电脑装了Linux,强烈建议花10分钟做个小实验:用free -m查看物理内存和交换分区,用ulimit -v限制一个进程的虚拟地址空间再运行大程序,你会亲眼看到缺页和换页对程序运行速度的影响。不要只看数字,带上“局部性原理”去感受。
在学校常用的实验平台(比如头歌这类在线操作系统实验)上,也会配一些存储管理题目:补全分配算法、模拟页面置换、实现地址变换。虽然题目很小,但和教材习题完全是两种体验——教材告诉你“页表项有存在位”,实验里你才能真正意识到,如果存在位0还傻乎乎去取页框号,程序就跑飞了。
我自己复习这一章时还有一个笨办法:每天抽10分钟,在白纸上画一遍“逻辑地址 -> TLB -> 页表 -> 物理内存”的流程。一开始卡壳,画了几天之后,缺页中断该在哪一步触发、修改位该在哪一步更新,就都长在肌肉记忆里了。这个习惯帮我打通的不只是第四章,后来的文件系统、进程调度复习也一直在用。如果你现在正被存储器管理绕晕,不妨试试这套方法,大概率能少走很多弯路。