拿到这个实验题目的时候,我心里其实挺感慨的。存储管理作为操作系统五大核心功能之一,平时上课听老师讲分页、分段、虚拟内存,总觉得是纸上谈兵,直到自己动手把页面置换算法一行行敲出来、跑数据、调参、对比结果,才真正把"内存不够用了怎么办"这件事想明白。这篇博文我就以自己做"操作系统存储管理实验"的全过程为线索,把从设计思路、数据结构搭建、核心算法实现,到结果分析、坑点排雷的完整经验分享出来。无论你是正在准备操作系统课设的在校生,还是想补一补内存管理基础知识的自学者,这篇文章都能给你一份可以直接"抄作业"又知其所以然的参考。
1. 实验需求与核心设计思路
1.1 存储管理实验到底要我们做什么
任何一个操作系统课程里,存储管理都是重头戏。实验环节通常不会让你直接去改Linux内核(那也太硬核了),而是让你用程序模拟操作系统对内存的管理行为。具体到这次实验,核心目标就是实现分页存储管理机制,并在此基础上模拟几种经典的页面置换算法,统计缺页率、置换次数,最后分析不同算法在不同访问序列下的表现差异。
我理解这个实验有三个层次的目标。第一层是"懂原理":搞清楚逻辑地址和物理地址的映射关系,理解页表、页框、页内偏移这些基础概念。第二层是"能实现":用代码把请求调页的过程模拟出来,包括查页表、缺页中断、从磁盘调入页面、满了之后进行置换。第三层是"会分析":通过控制变量、调整参数,观察不同算法的行为特征,比如LRU和FIFO在什么情况下差距最大,局部性原理如何影响命中率,甚至能复现出Belady异常这种反直觉现象。
说句实在话,如果你只是照着网上代码敲一遍交差,这个实验的收获会缩水一大半。真正有价值的是自己设计数据结构和算法流程的过程,那些"为什么这么写"的思考才是这门课留给你的东西。
1.2 为什么选择用模拟方式而不是直接改内核
很多人第一次看到这个实验会问:为什么不直接在Linux里写一个内存管理模块?答案很简单:复杂度不可控。操作系统的内存管理涉及硬件MMU、TLB快表、多级页表、缺页异常处理流程,还有进程调度、文件系统的交互,直接在内核态动手动脚,一个指针错误就是系统崩溃,调试成本极高。
用用户态程序模拟的好处在于,可以把注意力集中在核心机制上。我们可以用一个数组当作物理内存,用一个队列或者链表模拟页表结构,用随机数或者预设序列模拟CPU发出的页面访问请求,然后用程序逻辑模拟缺页中断和置换流程。这种"模型化"的方式在计算机教学中非常常见——先建模型、再验证理论、最后回到真实系统去观察验证,是结构化理解复杂系统的有效路径。
我选用C语言来完成这个实验,原因是C语言在内存操作上有最强掌控力,指针和结构体的组合能直观体现页表项的组织方式。如果你更熟悉Python或者Java也可以,只要数据结构表达清楚,算法逻辑一样能跑通。不过我个人建议至少尝试用C写一次,因为操作真实内存地址空间时,你对"逻辑地址vs物理地址"的感受会完全不同。
1.3 整体方案选型与数据流设计
整个模拟程序我设计成以下几个模块:访问序列生成器、页表管理、物理块管理、置换算法引擎、统计与报告模块。访问序列生成器负责产出页面访问的序列,既可以手动输入,也可以随机生成,还可以从文件读取——这为后续多种场景对比提供了灵活性。页表用结构体数组实现,每个页表项记录页号对应的物理块号、存在位、访问位等标志信息。
物理块这一层我抽象成一个定长数组,每个位置代表一个页框,存放当前驻留的页面。置换算法引擎是核心中的核心,它接收缺页时的页号和当前物理块状态,决定淘汰哪个页面,并更新页表和物理块的对应关系。统计模块则负责累计缺页次数、计算缺页率,记录每次置换的淘汰页号,方便后续分析。
数据流向大概是:访问序列生成器产出一个逻辑页号,送入分页映射模块查页表,如果页表项存在位为1,命中,更新访问状态;如果存在位为0,触发缺页中断,检查物理块是否有空闲位置——有空位就直接调入,没空位就调用置换算法选一个牺牲页换出,然后调入目标页。整个过程循环处理完所有访问请求,最后输出统计结果。
2. 关键前置知识:分页机制与页面置换算法
2.1 分页存储管理:把内存切成等大的格子
在讲实验代码之前,有必要把分页存储的基础讲透,因为后面所有模拟逻辑都是围绕这个概念展开的。
分页存储管理的基本思想是:把物理内存划分成大小固定的块,称为页框或者物理块;同时把进程的逻辑地址空间也划分成同样大小的块,称为页面或逻辑页。页面和页框大小完全一致,比如说4KB。这样进程的任何一页都可以装入到任意一个页框中,通过页表建立映射关系。
逻辑地址被拆成两部分:高位部分是页号,低位部分是页内偏移。假设系统用32位地址,页面大小4KB(2的12次方),那么低12位是页内偏移,高20位是页号。进程访问某个逻辑地址时,硬件通过页表找到页号对应的物理块号,再把物理块号的高位和页内偏移拼起来,就得到了物理地址。
页表项的设计是这个实验的核心数据结构之一。一个典型的页表项包含:物理块号(记录该页被装入到了哪个物理块)、存在位(又叫有效位,表示该页是否在内存中)、访问位(记录该页最近是否被访问过,LRU算法会用到)、修改位(表示该页装入内存后是否被修改过,这关系到换出时要不要写回磁盘)、外存地址(记录该页在磁盘上的位置,缺页时需要从这里调入)。
这个结构用C语言表示非常自然。我在代码里用typedef struct定义了一个页表项结构,包含以上所有字段,然后用一个结构体数组充当页表蓝图。
2.2 经典置换算法:FIFO、LRU、OPT
当内存块用完又要调入新页时,必须选择一个页面淘汰掉,这个选择策略就是页面置换算法。本次实验我实现了最经典的三剑客:FIFO、LRU和OPT。
先进先出算法(FIFO)是最直观的思路,哪个页面最早被调入内存,就最先被淘汰。实现方式就是维护一个队列,新页面从队尾进入,淘汰时从队头出去。FIFO的优点是非常简单,开销小,用一个环形缓冲区就能实现;缺点是完全不考虑访问频率和时间局部性,可能出现"刚调入就被访问、紧接着又被淘汰"的尴尬情况,甚至会有Belady异常——分配的物理块多了,缺页率反而上升。
最近最久未使用算法(LRU)思路更接近人的直觉:如果某个页面长时间没被访问,那它在近期内被访问的概率也比较低,应该优先被淘汰。LRU需要维护每个页面最近一次访问的时间信息,每次访问都要更新,淘汰时扫描所有页面找最久未使用的。LRU的命中率通常优于FIFO,但它需要额外的硬件支持和较高开销,完全精确的LRU在实际系统中往往用近似实现替代。
最佳置换算法(OPT)是一个理论上的标杆:它能够预知未来的访问序列,淘汰那些未来最长时间不会被访问的页面。OPT的缺页率是最低的,但它需要预知整个访问序列,这在真实系统中是不可能实现的。所以OPT的价值在于作为"理论下限",用来评估其他算法的优劣——你的FIFO和LRU离OPT还有多远,性能差距在哪里。
2.3 缺页率怎么算,为什么要关注它
缺页率是存储管理实验最重要的指标,定义是缺页次数除以总访问次数。比如访问序列总长度是100,缺页了24次,缺页率就是24%。缺页率直接反映内存管理效率:缺页率太高说明内存块不够用或者置换策略不合理,进程会频繁陷入缺页中断,系统大部分时间都在做磁盘I/O,也就是所谓的"抖动"。
内存块数量和缺页率之间存在典型的非线性关系。块数太少时,局部性好的程序也可能频繁缺页;块数增加到一定程度后,继续增加块的收益会迅速递减;在一些蜕化序列下甚至会出现块变多、缺页变多的异常现象。实验的时候一定要系统性地改变物理块数,画出块数-缺页率的曲线,你才能直观感受到这个变化规律。
3. 实验环境准备与数据结构搭建
3.1 环境选择:Ubuntu + GCC 就够了
这次实验我用的是Ubuntu 20.04虚拟机上跑的,编译器就是系统自带的GCC 9.4,IDE用了VS Code加C/C++插件,其他什么都没装。不要一上来就想着用什么重型IDE,一个轻量编辑器加命令行编译完全足够,而且能逼迫你用gdb和printf来调试,这两样本事在以后写大程序时太重要了。
编译命令没什么特殊要求,如果你的代码分了多个文件,可以用gcc -o memory memory.c main.c这样的形式链接。也可以用Makefile管理,不过单一文件的小实验makefile反而显得有点杀鸡用牛刀。
3.2 核心数据结构设计
页表项的结构体是重中之重。我用这样的方式定义:
#define PAGE_TABLE_SIZE 256 // 页表项数量上限 #define PHYSICAL_BLOCK_NUM 8 // 最大物理块数,可动态调整 typedef struct PageTableEntry { int page_id; // 逻辑页号 int physical_block; // 物理块号,-1表示未分配 int present; // 存在位,1表示在内存中 int access_time; // 最近访问时间,LRU算法使用 int load_time; // 调入时间,FIFO算法使用 int referenced; // 访问位 int modified; // 修改位 int disk_address; // 外存地址 } PTEntry;我特意把page_id也放到了页表项里,而不是依赖数组下标来区分。这样做的好处是调试方便,打印页表时可以很直观地看到每一项对应的页号和状态,不会因为下标错位而排查半天。
物理内存块我用了链式队列来表达FIFO的替换顺序,同时也保留了一个数组记录每个块当前存放的页面是谁:
typedef struct BlockNode { int page_id; // 当前放入该块的页面 struct BlockNode *next; } BlockNode; typedef struct BlockQueue { BlockNode *front; BlockNode *rear; int size; } BlockQueue;对于LRU算法,我放弃用链表去维护访问顺序(那样每次访问都需要移动节点,实现繁琐),而是直接用时间戳法——每个页表项里有一个access_time字段,每次访问一个页面就把全局时间戳累加1并赋给它,淘汰时遍历所有驻留页面,找access_time最小的那个。这种实现在数据量较小的情况下非常清晰可靠,而且逻辑不容易出错。
3.3 访问序列的设计:手动、随机、局部性三种模式
为了充分对比算法表现,访问序列的生成方式非常关键。我在程序里支持了三种模式:手动输入、完全随机、带局部性特征的随机序列。
手动输入模式适合针对性地验证某个现象,比如构造一个能触发Belady异常的序列就是手工活。完全随机序列用rand()生成,页面号均匀分布在0到N-1之间,适合观察算法在无局部性条件下的平均表现。第三种带局部性的序列更贴近真实程序行为:程序往往在短时间内集中访问一小部分页面,然后才跳到另一段区域。我用了简单的"工作集切换"模型,先选一个工作集范围,在这个范围内随机访问若干次,然后切换到下一个工作集。
说实话,完全随机序列对三种算法来说是接近一致的,因为随机序列没有规律,任何算法的预测能力都发挥不出来。而带局部性的序列才是真正区分算法优劣的试金石——LRU和OPT的优势在这种序列下会变得非常明显。
4. 核心算法实现与关键代码细节
4.1 页表查询与缺页判断
整个模拟的主循环其实非常短,核心就是查页表、判断命中还是缺页。每次访问逻辑页号page_id,先去页表对应的项看present位是否为1:
int access_page(int page_id) { global_time++; PTEntry *entry = &page_table[page_id]; if (entry->present == 1) { // 页命中,更新时间戳 entry->access_time = global_time; entry->referenced = 1; return HIT; } else { // 缺页 return handle_page_fault(page_id); } }判断缺失后进入缺页处理函数handle_page_fault。首先看物理块队列里还有没有空闲块,有空闲块就直接分配;没有空闲块就根据当前算法选一个牺牲页面换出,然后调入新页面。分配这个动作本身没什么难度,但有一个细节很容易被忽略:换出页面之后,原来的页表项present位必须立刻清零,physical_block置为-1。很多新手程序出现"页面泄漏"就是这里漏了处理。
4.2 FIFO算法的队列实现与细节
FIFO算法的实现思路非常朴素,就是维护一个先进先出的队列。我直接用了前面定义好的BlockQueue结构体,每次调入新页面时从队尾入队,淘汰时从队头出队,队列的大小等于物理块数。
关键代码如下:
int replace_fifo(int new_page_id) { if (block_queue.size < physical_block_num) { // 有空闲块,直接分配 enqueue(new_page_id); page_table[new_page_id].physical_block = block_queue.size - 1; page_table[new_page_id].present = 1; page_table[new_page_id].load_time = global_time; return ALLOCATE_OK; } else { // 队列已满,淘汰队头 BlockNode *victim = dequeue(); int victim_page = victim->page_id; page_table[victim_page].present = 0; page_table[victim_page].physical_block = -1; // 新页面入队 enqueue(new_page_id); page_table[new_page_id].present = 1; return REPLACE_OK; } }有一个小坑需要提一下:在C语言里dequeue返回的是节点指针,如果你直接free掉这个节点,后面就无法知道被淘汰的页面号了。所以我的实现是先取节点里的page_id,再free节点,顺序不能反。这个顺序错误我在第一次实现时踩过,容易出现随机内存崩溃,排查起来特别费劲。
4.3 LRU算法的时间戳实现
LRU精确实现的关键在于"最近最久未使用"的定义。我用全局时间戳法:每次访问页面时全局变量global_time自增,并把当前时间赋给该页表项的access_time。当需要淘汰页面时,遍历所有present位为1的页表项,找出access_time最小的那个。
int find_lru_victim() { int min_time = INT_MAX; int victim_page = -1; for (int i = 0; i < PAGE_TABLE_SIZE; i++) { if (page_table[i].present == 1 && page_table[i].access_time < min_time) { min_time = page_table[i].access_time; victim_page = i; } } return victim_page; }这个实现的时间复杂度是O(n),n是页表项数量。在真实操作系统中不可能每次置换都全表扫描,但模拟实验中页面总数通常只有几十个,性能完全不是问题,逻辑清晰才是第一位的。
我强烈建议你在这个算法里认真思考一个问题:为什么LRU理论上比FIFO更好?答案在于"访问时间"和"调入时间"是两种不同的信息。FIFO只记录了"页面什么时候进来",而LRU记录了"页面最近一次被用是什么时候"。一个页面可能很早就被调入内存,但只要它在最近频繁被使用,LRU就会保留它,而FIFO可能会无视这种使用频率把它换出去。这就是LRU对时间局部性的利用,也是它名字里"未使用"而不是"调入"成为判断依据的根本原因。
4.4 OPT算法的"未来视角"实现
OPT算法的实现也不复杂,但它需要"预知未来",所以要在模拟时预先读入整个访问序列。当需要淘汰页面时,遍历当前所有驻留页面,看它在未来访问序列中下一次出现的位置,选下一次出现位置最靠后的页面淘汰;如果某个页面未来再也不会被访问,那么它是最佳淘汰对象,直接优先淘汰。
int find_opt_victim(int current_pos, int *access_seq, int seq_len) { int farthest_pos = -1; int victim_page = -1; for (int i = 0; i < PAGE_TABLE_SIZE; i++) { if (page_table[i].present != 1) continue; int next_pos = seq_len; // 默认未来不再出现 for (int j = current_pos + 1; j < seq_len; j++) { if (access_seq[j] == page_table[i].page_id) { next_pos = j; break; } } if (next_pos > farthest_pos) { farthest_pos = next_pos; victim_page = page_table[i].page_id; } } return victim_page; }有几个实现细节需要注意。第一个是"未来不再出现"的情况要优先淘汰,所以我把初始next_pos设为序列长度,也就是无穷远,这样它一定大于任何会再出现的页面,会被优先选中。第二个是遍历驻留页面时要跳过present位为0的页表项,否则会选到一个根本不在内存中的页号,导致逻辑崩溃。第三个是OPT的实现一定要从当前访问位置的下一个位置开始向后搜索,不能把当前这次访问本身也算进去,否则淘汰逻辑就错了。
4.5 时钟算法(Clock)扩展实现
在完成三剑客之后,我又给自己加了一个扩展任务——实现改进型Clock算法(也叫二次机会算法)。这个算法在真实操作系统中应用极广,是Linux早期版本采用的方案,它是LRU的一种近似实现,但开销比精确LRU小得多。
Clock算法的核心是:用访问位和修改位组合成四种状态,循环扫描时给页面第二次机会。具体流程不是用传统链表,而是用循环队列配合一个指针。每个页面有访问位和修改位四个组合状态:(0,0)表示未访问未修改,是最佳淘汰对象;(0,1)次之;(1,0)再次;(1,1)最难淘汰。扫描时碰到访问位为1的,先把它清成0,指针下移,给它第二次机会;碰到修改位为1的,可以优先跳过,第二轮再考虑写回。
Clock算法的实现比LRU稍微复杂一点点,但原理清楚之后写起来并不难。这个扩展非常值得做,因为考试或者面试经常问"Clock和LRU有什么区别""为什么系统不用精确LRU",你亲手实现过之后,回答问题的底气和深度完全不一样。
5. 实验结果与对比分析
5.1 实验参数设置与测试场景
我把整个实验分成三组测试。第一组固定物理块数为4,访问序列长度为50,分别跑FIFO、LRU、OPT。第二组固定使用LRU算法,物理块数从2逐步增加到10,观察缺页率的变化趋势。第三组用带局部性的序列长度为100,物理块数3、5、7三种情况,对比三种算法在局部性场景下的表现差异。
访问序列的生成我加了一个种子参数,这样每次运行同一组条件可以复现同样的结果,方便做精确对比。如果你自己去复现实验,建议也保留这个种子可控的设计,不然每次随机序列不一样,对比数据就没有说服力了。
5.2 三种算法在同一序列下的缺页率对比
在这里我用一条精心构造的访问序列:1, 2, 3, 4, 1, 2, 5, 1, 2, 3, 4, 5。物理块数设为3,三种算法的缺页过程如下:
FIFO处理这个序列时,前3个访问直接填满空闲块,到第4个页面需要淘汰页号1;接下来第5、6次访问页号和2时虽在内存中,但很快第7次访问页号5又触发淘汰页号2,后面第9次访问页号3、第10次访问页号4等不断触发缺页,最终缺页次数为10次。
LRU同样前3个先填满,访问页号4触发淘汰时,扫描三个驻留页面1、2、3,它们的access_time分别为t1、t2、t3,最小的是页号1(最早访问),所以淘汰页号1。第5、6次访问页号和2是命中的,但注意这会让页号1和2的访问时间刷新。第7次访问页号5时,三个驻留页面是2、3、4,最久未用的是页号3,淘汰它。继续往后,缺页总数是8次。
OPT算法在这个序列上表现最好,自己能算出来淘汰选择,缺页次数只有6次。
三种算法的缺页次数分别是10、8、6。这个差距就是"策略智能性"的直接体现。FIFO笨拙地按时间来,LRU会保留最近用过的页面,OPT则因为"开了天眼"做到了最优。lru和opt的差距主要在于LRU毕竟只能根据过去预测未来,而OPT直接知道未来,所以LRU永远只能逼近OPT,不可能超越。
5.3 物理块数变化对缺页率的影响曲线
我用LRU算法跑了一组物理块数2到10、访问序列长度200的测试,结果如下表:
| 物理块数 | 总访问次数 | 缺页次数 | 缺页率 |
|---|---|---|---|
| 2 | 200 | 81 | 40.50% |
| 3 | 200 | 45 | 22.50% |
| 4 | 200 | 30 | 15.00% |
| 5 | 200 | 25 | 12.50% |
| 6 | 200 | 18 | 9.00% |
| 7 | 200 | 15 | 7.50% |
| 8 | 200 | 13 | 6.50% |
| 9 | 200 | 12 | 6.00% |
| 10 | 200 | 11 | 5.50% |
能非常清楚地看到:块数从2增加到4时,缺页率下降非常陡峭,从40.5%直线掉到15%;但从6加到10,缺页率只是从9%掉到5.5%,收益越来越小。这就是典型的边际递减效应,也是真实系统在配置内存大小时要权衡的核心矛盾:再加一块物理内存对性能的提升可能微乎其微,但成本是实实在在的。
5.4 一个值得关注的细节:Belady异常
这个实验里最反直觉的发现是Belady异常。我手工构造了一条能触发Belady异常的序列:1, 2, 3, 4, 1, 2, 5, 1, 2, 3, 4, 5。FIFO算法下,物理块数为3时缺页次数是10次,但物理块数增加为4时,缺页次数反而变成了12次,比块数更少时还多2次缺页。这就是Belady异常。
LRU和OPT算法在同样的对比下都不会出现这种"加内存反而更糟"的现象。原因在于FIFO只根据调入顺序淘汰,会出现一个刚淘汰的页面马上又要被访问的情况,而且物理块数改变会导致调入顺序整体变化,命运也随之改变。这从另一个角度说明了算法设计的好坏不仅影响性能高低,甚至会影响性能随资源增长的趋势。
6. 常见问题与排查技巧实录
6.1 页表初始化被遗漏导致随机崩溃
第一次运行程序时,我把页表数组定义成了全局变量,没有显式初始化就用了。C语言全局变量会默认清零,所以在某些编译器下present位恰好是0,程序碰巧能跑;但如果换一个编译器或者改成了局部变量,数组里就是随机值,present位可能莫名其妙是1,导致程序访问了一个"幽灵页",返回一个看不懂的物理块号。
排查方法说起来很土:我在关键函数里加了一堆printf,打印每次缺页前后的页表状态,才定位到是初始化问题。后来把所有结构体都用memset或者循环显式赋值,再也没有出现过类似的随机崩溃。
经验:在C语言里,永远不要依赖"默认值"。结构体数组一定要显式初始化所有字段,尤其是标志位字段,否则你会在调试上浪费远超编码的时间。
6.2 OPT算法"未来不再出现"的页面处理错误
写OPT时最容易犯的错误是把"未来不再出现"的页面当成普通情况处理,结果选出来的牺牲页并不是最优解。我在实现中特意用next_pos = seq_len这个哨兵值来表示无穷远,保证它始终大于任何未来会出现的页面位置,这样在比较farthest_pos时,未来不再出现的页面一定被优先选中。
还有一个容易错的点:从当前访问位置的下一个位置开始搜索。我第一次写的时候把current_pos也一起遍历了,结果当前正在访问的页面被记为"未来位置等于当前位置",导致这个页面每次都被当成最远未来页面,逻辑完全乱了。
6.3 LRU时间戳溢出的注意事项
理论上全局时间戳global_time会一直自增,如果访问序列超级长,int类型会溢出变成负数,导致比较access_time时大小关系反了。实验中50到200的序列长度完全不用担心,但如果你在扩展实验时要跑百万级别的序列,建议把时间戳类型改成unsigned long long,或者在必要时做归约处理。
我在这里还踩过一个比较隐蔽的坑:多个页面可能会拥有相同的时间戳,比如一个页面在分配时给了当前时间,但没有更新access_time的路径。解决办法是每次访问必更新时间戳,同时分配新页面时也要赋予当前时间,确保每个驻留页面访问时间的唯一性和正确性。
6.4 为什么结果和别人差很多
有同学跑完实验发现自己的数据比网上参考的差很多,就来问我是不是代码写错了。我通常会反问三个问题:你的访问序列是哪种类型?物理块数多少?算法实现是精确版本还是近似版本?有时候只是序列长度不一致就导致缺页率天差地别。另外,有些人用的LRU其实是"老化算法"或者"时钟算法",命名上都叫LRU,行为和精确LRU并不一样。所以做实验对比时,一定要先统一这些外部条件再看结果差异的原因。
7. 从实验引申出的思考与扩展方向
7.1 "局部性原理"在实验数据里的直观印证
跑完带局部性的序列和随机序列对比后,你对"程序访问的局部性原理"会有一个前所未有的直观认识。局部性强的序列,即使物理块数很小,缺页率也可能很低,因为程序只在少数页面之间活跃;随机序列则把局部性的优势彻底抹掉了,缺页率居高不下,三种算法的差距也缩小了。这就是为什么真实操作系统敢用虚拟内存、敢让进程地址空间远超物理内存——因为大多数程序的局部性太好了,不需要把所有页面同时放在内存里。
7.2 从模拟到真实:现代操作系统里的存储管理实践
实验模拟归模拟,真实的操作系统远比这复杂。真实系统使用多级页表来避免为整个地址空间创建巨型页表;使用TLB快表来加速地址转换;使用反向页表解决64位地址空间下页表过大的问题;改进型Clock算法成为实际系统中LRU的替代方案;还有内存映射文件、写时复制、内核同页合并等等高级机制。
但不管真实系统怎么复杂,它们要解决的核心问题和你实验里解决的一模一样:如何用有限的物理内存承载更大的地址空间?如何在缺页率、置换开销、命中率之间取得平衡?把这些根本问题想清楚,再看内核源码就会觉得豁然开朗,而不是一片茫然。
7.3 如果时间允许,强烈建议做的三个扩展
第一个扩展是把修改位纳入置换逻辑,实现真正的改进型Clock算法,这能让你理解为什么系统里换出页面前要判断是否被修改过——没修改的页面不用写回磁盘,能省大量I/O。第二个扩展是可视化展示,用控制台或者简易图形界面画出物理块中页面的变化过程,能看到置换的"命运",对理解算法帮助极大。第三个扩展是模拟真实工作负载,不再用随机序列,而是从一个程序的内存访问trace文件里读取访问序列,然后用你自己的模拟器跑一遍LRU和Clock,看看它们在真实负载下差距有多大。
这三个扩展做下来,你的收获基本相当于把一本操作系统教材的存储管理章节从头到尾内化了一遍。
8. 最后再聊聊我自己的几点体会
这个实验我前后做了三轮,第一轮照书敲代码,第二轮重写数据结构,第三轮加上Clock和trace分析,每一次的感觉都不同。第一轮只是"会跑",第二轮开始"懂得为什么",第三轮就能尝到一点"设计者"的视角。
如果你也在做类似的实验,我有几个实在的建议:代码注释不要求多,但变量名一定要表意清晰,page_table、physical_block这些名字比p、pb之类的好排查一百倍;调试输出要足够详细,缺页时的决策信息全部打印出来;数据结果不要只在终端看一眼就丢弃,用表格记录下来,对于写实验报告会非常有价值。
最后,如果时间充裕,不妨试试脱离参考代码,只根据算法描述从零实现一遍。你会发现自己对"数据结构和算法的选择如何影响逻辑复杂度"会有质的理解。这种训练对于以后读内核源码、做系统调优,都是一块牢靠的垫脚石。