简介:操作系统课程中页面置换实验的完整实现包,面向本科计算机专业正在学习虚拟存储管理的读者,解决页面置换算法模拟与对比的代码与思路参考问题。包内提供C++实现的可运行置换算法模拟程序,支持输入不同内存页面引用串和实存帧数,观察缺页情况与性能;程序具备随机生成引用串能力,可动态观测各算法表现,便于与LRU、FIFO以及增强二次机会等策略进行对比。实验文档对算法补充思路、运行结果和性能分析做了整理,源码中头文件与主程序分离,结构清晰。压缩包共5个文件,主要含doc实验报告、cpp源程序、h头文件及vmrp/vm相关文件,整体仅31KB。目前已有472人学习,适合有C++基础的读者快速搭建实验环境并深入比较不同置换算法的命中率差异。
1. 页面置换实验为什么值得自己从头写一遍
页面置换实验是操作系统实验里最容易“做完了还是不懂”的一个:表面上看,只要把 FIFO、LRU、OPT 三个算法各写几十行,跑出一张缺页率对比表就算交差。但真被追问起来,很多人答不出两个反直觉问题:为什么 FIFO 在物理块数增加时缺页率反而上升?为什么 LRU 效果好,真实系统却不直接用?这两个问题恰恰是页面置换实验的核心价值所在,也是实验报告里最容易被加分或扣分的地方。
这份资源围绕“页面置换”实验展开,包含一套可编译运行的 C 语言模拟器、三种经典置换算法实现、一个带局部性参数的请求序列生成器,以及多组验证用例。它适合正在赶课程实验的本科生、准备复试上机的考生,以及想快速搭起置换实验框架的开发者。按后面的步骤走,半小时内就能组装出一个能交差、也经得起追问的实验版本。
2. 实验前置认知:算法选型、统计口径与模拟器架构
2.1 三种教科书算法为什么被选为主角
一上来就写代码是很多人的习惯,但页面置换实验最容易出问题的地方,恰恰在动手之前。先回答一个基本问题:为什么几乎所有实验指导书都选 FIFO、LRU、OPT 这三个算法?
原因不是它们最能代表现代操作系统,而是它们刚好构成一个完整的教学标尺。FIFO 代表“最简单、最容易实现”的下限,用一个队列就能完成,但存在 Belady 异常;LRU 代表“可用且效果好”的平实策略,基于时间局部性假设,接近最优但维护成本高;OPT 代表“理论最优”的上限,需要预知未来访问序列,现实中无法实现,只用于对照。
| 算法 | 替换依据 | 需要的额外结构 | 真实系统使用情况 |
|---|---|---|---|
| FIFO | 最早装入的页 | 先进先出队列 | 简单,有 Belady 异常 |
| LRU | 最久未被访问 | 访问时间戳 / 双向链表 | 成本高,多用近似算法 |
| OPT | 未来最久不被访问 | 需要预知未来 | 不可实现,仅作理论上限 |
把三个算法放进同一个实验,用意是让缺页率形成一个“FIFO 不太好看、LRU 较接近、OPT 最低”的梯度。如果跑出来三个结果没有梯度,那大概率不是算法写错,就是请求序列或统计口径出了问题,后面第 4 章专门讲。
2.2 缺页次数怎么数:谁计入缺页、谁计入置换
第二个前置问题是统计口径。很多实验报告翻车,不是因为算法错,而是因为“缺页次数”和“置换次数”混在一起算。
模拟器通常从空内存开始。第一次访问一个页号,该页不在内存,这算一次缺页。即使此时物理块还未占满,也要计入。这条规则如果不写清楚,三次算法结果横向对比就没有意义。而“置换次数”严格来说只统计内存已满、需要把某个旧页换出时的次数。初期的装入动作不算置换。
缺页率是缺页次数除以总访问次数。举个例子,访问序列长度为 100,缺页 34 次,缺页率就是 34%。很多实验指导书还会要求给“命中率”,注意一定先看清题目要哪个,别两个指标混在一个表里。
我一般会在模拟器里把两个计数分开:page_faults对所有不在内存的访问加一,replacements只在物理块满时加一。这样实验报告里的“缺页数”“置换数”能各取所需,也比只给一个总数要稳。
2.3 模拟器模块拆分:统一函数签名与数据流
明白了统计口径,下一步是规划代码结构。很多人写页面置换实验时会把主循环复制三份,每个算法一个版本。这种做法短期看起来快,后面排查问题却非常痛苦:改了一处统计逻辑,另外两份容易忘记同步。
常见做法是定义统一返回结构和统一函数签名,三种算法只负责实现各自的淘汰策略,主循环只写一遍。
typedef struct { int page_faults; // 缺页次数:不在内存就算一次 int replacements; // 置换次数:内存满后淘汰旧页才算 int hits; // 命中次数 } sim_result; sim_result simulate_fifo(int *seq, int len, int frame_count); sim_result simulate_lru(int *seq, int len, int frame_count); sim_result simulate_opt(int *seq, int len, int frame_count);返回结构体而不是用全局变量传结果,是为了避免三套算法互相污染状态。参数seq是请求序列,len是序列长度,frame_count是物理块数。三个函数输入输出完全一致,后面做对比表、跑参数扫描,只需要循环调用即可。
数据流也很简单:请求序列生成器产生int *seq,模拟器按顺序读每个页号,判断是否命中;命中则更新统计信息,未命中则交给对应算法做淘汰决策,然后更新物理块内容。整个模拟器的骨架就这一条线,搞清楚之后,代码实现只是往里填肉。
3. 动手实现:访问序列生成器与FIFO、LRU、OPT的完整C代码
3.1 生成访问序列:均匀随机与局部性模型
实验里最容易被忽略的是请求序列怎么生成。直接用rand() % page_max生成的均匀随机序列,会让 LRU 和 OPT 的优势很难体现。真实程序的访存具有局部性:一段时间内集中访问某几个页面,然后整体跳到另一片区域。所以生成器要支持局部性参数。
#include <stdio.h> #include <stdlib.h> // 生成长度为 len、页号范围 [0, page_max) 的访问序列 // locality 控制局部性强度:值越大,序列越倾向于在当前页附近徘徊 void gen_seq(int *seq, int len, int page_max, int locality) { int i; int current = rand() % page_max; for (i = 0; i < len; i++) { if (rand() % (locality + 1) == 0) { current = rand() % page_max; // 小概率跳到完全随机的页 } else { // 大概率在当前页 ±2 的邻域内游走,模拟时间局部性 current = (current + (rand() % 5) - 2 + page_max) % page_max; } seq[i] = current; } }locality参数怎么理解:值越大,产生随机跳变的概率越低,序列越聚集。locality=0时完全退化成均匀随机;locality=3到5是实验里比较常用的区间,能让三种算法明显拉开差距。page_max建议设置为物理块数的 3 倍以上,页面范围太小会导致各类算法命中率都偏高,看不出置换策略差异。
需要注意取模前先加page_max,因为rand() % 5 - 2可能产生负数。实验时用固定随机种子,比每次srand(time(NULL))更适合复现结果。
3.2 FIFO:环形队列维护进入顺序
FIFO 的思路很直接:谁先进来谁先被换出。模拟实现可以不用链表,用环形数组加一个入队位图就够了。
sim_result simulate_fifo(int *seq, int len, int frame_count) { int queue[64]; // 环形队列,存放当前内存中的页号 char in_mem[64] = {0}; // in_mem[p] = 1 表示页 p 在内存 int head = 0, tail = 0; // head: 最老页位置; tail: 新页写入位置 int full = 0; sim_result r = {0, 0, 0}; for (int i = 0; i < len; i++) { int page = seq[i]; if (in_mem[page]) { r.hits++; continue; } r.page_faults++; if (full < frame_count) { queue[tail] = page; // 空闲块直接装入 tail = (tail + 1) % frame_count; in_mem[page] = 1; full++; } else { int old = queue[head]; // 最早进入的页 in_mem[old] = 0; queue[head] = page; // 最老位置写入新页 in_mem[page] = 1; head = (head + 1) % frame_count; // head 与 tail 同步前进 tail = (tail + 1) % frame_count; r.replacements++; } } return r; }这里用in_mem位图判断页面是否在内存,避免每次线性扫描物理块。页号范围限制在 0 到 63,教学实验够用;如果页号范围更大,应换成哈希表或布尔数组动态分配。queue只存页号,物理块本身不需要额外建模。
注意“未满装入”和“满后置换”是两种不同状态:前者只增加page_faults,后者才增加replacements。这个区分是实验报告统计口径的关键,别混在一起。
3.3 LRU:单调递增时间戳与最小值淘汰
LRU 的实现方式很多。教学实验里最直观的是为每个物理块记录最后访问时间,淘汰时找最小时间戳。
sim_result simulate_lru(int *seq, int len, int frame_count) { int frames[64]; // frames[i] 物理块 i 当前存放的页号 int last[64]; // last[i] 物理块 i 上次被访问时的时钟值 char in_mem[64] = {0}; int full = 0, clock = 0; sim_result r = {0, 0, 0}; for (int i = 0; i < len; i++) { int page = seq[i]; if (in_mem[page]) { for (int j = 0; j < full; j++) { if (frames[j] == page) { last[j] = clock; // 命中必须更新时间戳 break; } } clock++; r.hits++; continue; } r.page_faults++; if (full < frame_count) { frames[full] = page; last[full] = clock++; in_mem[page] = 1; full++; } else { int victim = 0; for (int j = 1; j < full; j++) { if (last[j] < last[victim]) victim = j; // 最小即最久未用 } in_mem[frames[victim]] = 0; frames[victim] = page; last[victim] = clock++; in_mem[page] = 1; r.replacements++; } } return r; }clock从 0 开始只增不减,保证每个时间戳唯一。命中时如果忘记更新last[found],LRU 就会悄悄退化成 FIFO,这是最常见的隐形错误。找 victim 的线性扫描只遍历物理块,帧数在几十以内时性能无压力。
真实系统不用纯 LRU 的核心原因是:每次访问都要更新所有页面的访问顺序,硬件成本太高。模拟实验里用时间戳数组是为了尽量贴近语义,报告里可以点出这一层差异。
3.4 OPT:前向扫描实现“预知未来”
OPT 是唯一不能真正实现的算法,它在模拟器里充当“理论上限”。实现方式很朴素:缺页且内存满时,逐个检查驻留页,看它下一次出现的位置,选未来最晚出现的页淘汰。如果某个页以后不再出现,直接优先淘汰它。
sim_result simulate_opt(int *seq, int len, int frame_count) { int frames[64]; char in_mem[64] = {0}; int full = 0; sim_result r = {0, 0, 0}; for (int i = 0; i < len; i++) { int page = seq[i]; if (in_mem[page]) { r.hits++; continue; } r.page_faults++; if (full < frame_count) { frames[full++] = page; in_mem[page] = 1; } else { int victim = 0; int furthest = -1; for (int j = 0; j < full; j++) { int pos = -1; // -1 表示未来不再访问 for (int k = i + 1; k < len; k++) { if (seq[k] == frames[j]) { pos = k; break; // 只找最近的未来位置 } } int dist = (pos == -1) ? len + 1 : pos; if (dist > furthest) { furthest = dist; victim = j; } } in_mem[frames[victim]] = 0; frames[victim] = page; in_mem[page] = 1; r.replacements++; } } return r; }len + 1是为了让“不再出现”的页面具有最大的“淘汰优先级”,因为它的下次访问距离被看作无穷远。前向扫描从i + 1开始,不能包含当前位置。这段代码的时间复杂度偏高,当序列长度上万时会有明显卡顿,但教学实验的序列一般在几十到几百,完全够用。
把三个函数和生成器放进同一个 C 文件,主函数里依次调用就行:
int main(void) { int seq[512], len = 100, fc = 4; srand(2024); // 固定种子,结果可复现 gen_seq(seq, len, 12, 3); sim_result r1 = simulate_fifo(seq, len, fc); sim_result r2 = simulate_lru(seq, len, fc); sim_result r3 = simulate_opt(seq, len, fc); printf("FIFO faults=%d hits=%d\n", r1.page_faults, r1.hits); printf("LRU faults=%d hits=%d\n", r2.page_faults, r2.hits); printf("OPT faults=%d hits=%d\n", r3.page_faults, r3.hits); return 0; }seq数组长度要大于len,防止越界。srand固定成2024之后,每次运行结果一致,这比每次随机更有说服力。
4. 常见问题:页面置换实验的五个翻车点与排查方法
4.1 缺页次数比理论值少很多
现象:同样一个请求序列,自己模拟器输出的缺页次数比同学少一截,怎么看都不合理。
原因:最常见的是用frames数组同时承担“存放页号”和“是否在内存”两个职责,数组初始化为 0。如果页号范围恰好从 0 开始,那么页 0 永远会被判定为“已经在内存中”,永远不会产生缺页。另一个常见原因是没有把空内存阶段的首次访问计入缺页。
解决:要么把初始值改成 -1,要么干脆维护一个独立的in_mem位图。实验初期用-1初始化最省事:
for (int j = 0; j < 64; j++) frames[j] = -1;改完之后用“全部访问同一个页号”的序列测一遍,缺页次数应该正好等于 1,这是后面第 6 章的冒烟用例。
4.2 LRU的结果总是和FIFO一模一样
现象:LRU 和 FIFO 打印出来的缺页数、置换数完全相同,报告里的对比表失去意义。
原因:命中时没有更新last[found]时间戳。没有时间戳更新的 LRU,本质上就是按装入顺序淘汰,和 FIFO 没有区别。另一种原因是有人为了省事,直接把 FIFO 的队列头当作 LRU 的淘汰对象,概念上就已经错了。
解决:检查命中分支里是否有last[j] = clock或等价操作。用一个简单序列做验证:1 2 3 4 1 2 5,内存 3 块。LRU 在访问 1、2 时会刷新它们在内存里的“新鲜度”,FIFO 不会,后续淘汰对象会有明显差异。如果打印出每个时刻的物理块状态,差异一眼可见。
4.3 OPT跑出比理论下限还低的结果
现象:OPT 的缺页数比 LRU 还低,甚至低到不合理的程度,比如某个长序列缺页次数只有 2 次。
原因:处理“未来不再出现”的页面时用了错误的标志值。常见是把pos初始化为 0,导致所有未来不再出现的页面都被当成“下一次访问位置是 0”,永远轮不到被淘汰。还有一个方向性错误:前向扫描写成了k < i,向后看变成了向前看,把历史当作未来。
解决:初始化为-1,并在代码里单独判断:
if (pos == -1) { victim = j; // 未来不再出现,优先淘汰 break; }同时确认扫描起点是i + 1。调试阶段可以加一个打印,检查被淘汰的页在后续序列中是否真的最晚出现。
4.4 三种算法的数字完全相同,报告没法写
现象:FIFO、LRU、OPT 的缺页率几乎一样,实验报告完全编不出分析。
原因:请求序列太随机、页号范围太小或物理块数太大。局部性弱的序列会让所有算法频繁缺页,差异被噪声淹没;页号范围只有 5 个、物理块给到 4 块时,命中率天然偏高,算法差异自然缩小。
解决:换用一组有区分度的验证序列。经典的教材验证序列是7,0,1,2,0,3,0,4,2,3,0,3,2,1,2,0,1,7,0,1,三种算法在不同物理块数下的缺页数差异明显:
| 物理块数 | FIFO 缺页数 | LRU 缺页数 | OPT 缺页数 |
|---|---|---|---|
| 3 | 15 | 12 | 9 |
| 4 | 10 | 8 | 8 |
用这组数据可以反向验证模拟器正确性。如果上面四个数字对不上,说明实现有偏差;对得上,再用随机序列做扩展测试。
4.5 统计结果出现负数或天文数字
现象:代码逻辑看起来正常,但最终打印的page_faults是负数,或者大到一个明显越界的数值。
原因:局部变量没有初始化,sim_result只声明没有赋值,里面的hits拿到的是栈上的遗留数据。另一个隐蔽原因是frame_count被外部传入 0,导致循环里出现除零或环形队列越界。
解决:所有结果变量一律初始化:
sim_result r = {0, 0, 0};同时在入口校验物理块数:
if (frame_count < 1 || frame_count > 64) { fprintf(stderr, "frame_count 必须在 [1,64] 范围内\n"); return 1; }这类问题最难排查,因为现象出现在最后,根因却在入口。养成“入口先校验参数”的习惯会省很多时间。
5. 进阶验证:Belady逆例、参数敏感性与Clock算法扩展
5.1 复现Belady逆例:物理块数增加,缺页率上升
Belady 异常是 FIFO 最著名的反直觉现象:物理块数增加,缺页次数反而上升。用的序列是1 2 3 4 1 2 5 1 2 3 4 5,这是教材里最经典的逆例。
用模拟器分别跑物理块数 3 和 4,结果应该如下:
| 物理块数 | FIFO 缺页次数 |
|---|---|
| 3 | 9 |
| 4 | 10 |
复现这个现象不需要额外功能,只需要跑两次主函数并把输出记下来即可。如果想看完整变化趋势,写一个循环更直观:
for fc in 1 2 3 4 5 6; do ./sim $fc belady.seq donebelady.seq文件里是上面那 12 个页号。观察输出会发现缺页次数在fc=3到fc=4时出现反弹。报告里可以补一句:Belady 现象是 FIFO 的固有缺陷,LRU 和 OPT 不会出现这种情况,所以现代系统不会直接采用纯 FIFO 策略。
5.2 参数敏感性:局部性强度如何改变算法排位
实验做到最后,往往要回答“为什么真实系统偏爱近似 LRU”。这个问题可以通过调节局部性参数直观看到。固定序列长度 1000、页号范围 16、物理块数 4,然后分别用locality=0/2/5生成三组序列,跑三种算法。
| locality | FIFO 缺页率 | LRU 缺页率 | OPT 缺页率 |
|---|---|---|---|
| 0 | 高,三者差距小 | 高,三者差距小 | 略低 |
| 2 | 中等 | 较低 | 低于或等于 LRU |
| 5 | 中等偏上 | 明显低于 FIFO | 最低 |
跑这种批量实验时,建议把模拟器改成从文件读序列、输出纯数字结果,然后用脚本批量驱动:
import subprocess for locality in [0, 2, 5]: # 先生成对应 locality 的序列文件 # 再对三种算法各跑一次 for policy in ["fifo", "lru", "opt"]: out = subprocess.check_output( ["./sim", policy, "seq_%d.dat" % locality, "4"] ) print(locality, policy, out.decode().strip())运行结果会呈现一个稳定趋势:局部性越强,LRU 相对 FIFO 的优势越明显,OPT 始终是最低线。随机序列跑出来的绝对数值不同,但趋势不会变。报告里用这个趋势说明“时间局部性是 LRU 生效的前提”就很自然。
5.3 向真实系统靠拢:把模拟器扩展到Clock算法
真实系统不用纯 LRU,而常用近似实现。Clock 算法(二次机会算法)就是教学和工程之间的桥梁:物理块排成环形,每块维护一个引用位ref。访问命中时置 1;缺页时从指针当前位置扫描,遇到ref=1就清零并继续,遇到ref=0就淘汰这块页面。
// Clock 淘汰:返回被淘汰的物理块下标,并更新指针位置 int clock_evict(int frames[], int ref[], int n, int *hand) { while (1) { if (ref[*hand] == 1) { ref[*hand] = 0; // 给它第二次机会 *hand = (*hand + 1) % n; } else { int victim = *hand; // 找到引用位为0的块 *hand = (*hand + 1) % n; return victim; } } }仿真时在命中分支把对应页面的ref位置 1,缺页时调用clock_evict得到 victim,再写入新页。一个容易踩的细节是:新装入的页引用位建议置 0,防止它刚进来就获得“免死金牌”。Clock 最多扫描两圈就能找到 victim,所以不会死循环,这也是它能用于真实内核的重要原因。
放到实验报告里,这段扩展可以放在“讨论”部分,用来解释理论 LRU 和真实系统实现的差距。代码量不大,但对评分来说是一个明显的加分点。
6. 收尾技巧:先用两个极端用例给整个模拟器做冒烟测试
算法写完之后,不要直接跑随机序列看结果。先跑两个极端用例,能过滤掉八成以上的低级错误。
第一个用例是“全部访问同一个页号”。比如连续 20 次访问页号 4,无论 FIFO、LRU 还是 OPT,首次访问缺页一次,之后全部命中,所以page_faults必须等于 1。如果大于 1,说明命中判断有问题,或者in_mem位图更新时机不对。
第二个用例是“单物理块 + 多个不同页号”。假设访问序列是0 1 2 3 4 5 6 7,物理块数为 1,那么每个新页号都会缺页,缺页次数必须等于 8。这个用例能同时验证缺页计数、装入逻辑和置换逻辑:每次缺页都要把上一页换出,所以replacements应该是 7。
用断言方式固化这两个用例,以后每次改动都快速回归:
#include <assert.h> int test_all_same[] = {4, 4, 4, 4, 4, 4, 4, 4, 4, 4}; int test_single_frame[] = {0, 1, 2, 3, 4, 5, 6, 7, 7, 7}; sim_result r; r = simulate_fifo(test_all_same, 10, 3); assert(r.page_faults == 1); r = simulate_lru(test_single_frame, 10, 1); assert(r.page_faults == 8);再配合第 4 章里的经典验证序列,把三组缺页数对上表,模拟器基本可以确定没有计数级错误。从那以后,我每次写完页面置换实验的新版本,都强制先跑一遍这两个用例,再去做随机序列对比。贝叶斯直觉告诉我,这一步省下的调试时间比写代码还多,希望帮到你。
本文还有配套的精品资源,点击获取