资讯动态

存储管理实验:分页地址转换、TLB与页面置换算法实践

发布时间:2026/10/27 6:02:50 来源:尧图企业网站定制
1. 先搞清楚存储管理实验在练什么1.1 从一次“地址越界”说起很多人第一次做存储管理实验都是从一段看起来人畜无害的代码开始的定义一个大数组、按下标访问、打印结果。程序跑通了但脑子里其实没留下什么东西。真正让这个实验变得有意思的是某一次故意写错下标程序直接崩掉终端只留下一行 segmentation fault。那一瞬间你才会意识到平时写代码时随手用的a[i]背后其实站着一整套地址翻译机制而存储管理实验就是把这套机制从黑盒里拽出来摊在桌面上让你看清楚。存储管理要解决的核心问题很朴素进程看到的地址和内存条上真实的物理位置不是一回事。每个进程都以为自己独占一整片连续空间这就是逻辑地址空间而物理内存被切成一个个页框由操作系统统一调度。逻辑地址到物理地址的映射、什么时候把页面装进内存、内存不够时把谁换出去这三件事构成了存储管理实验的主干。你做的不只是“模拟几个算法”而是在亲手实现一个微缩版的地址翻译与页面调度器。这个实验适合谁我的观察是刚学完操作系统课程、能背出“分页”“分段”“LRU”这些名词但一让你算一道地址转换题就卡壳的人最该动手做一遍。还有一类是准备面试、被问到“缺页中断处理流程”只能背八股的人把流程用代码跑一遍记忆会牢固得多。实验本身不难难的是很多人把它当成填表任务抄一遍算法输出就交差结果什么都没学到。提示实验开始前先明确目标——你是要验证算法正确性还是要观测真实系统的行为这两条路的技术选型完全不同混在一起做容易两头不讨好。1.2 实验环境怎么搭用户态模拟和内核态观测的取舍存储管理实验一般有两条路。第一条是纯用户态模拟自己定义页表结构、自己维护页框数组、自己写置换算法所有数据结构都在你的进程里跟真实硬件没关系。第二条是借助真实系统做观测比如通过系统调用观察缺页行为、读取进程的内存映射信息或者在一个小型教学内核里改代码。两条路各有各的坑。用户态模拟的最大优势是可控。引用串你自己造页框数你随便设算法输出能一行行打印出来对照。缺点是容易“自嗨”你写的页表项里放了有效位、修改位、访问位但实际上没有任何硬件会去更新它们所谓“访问位置位”全靠你手动写一行赋值。很多人做完实验对“谁把访问位置 1”这个问题仍然没有概念原因就在这里。我的建议是用户态模拟一定要配一份“角色说明”在代码注释里写清楚这一位由 MMU 在地址转换时置位这一位由操作系统在换出时清零。别小看这几行注释它是把模拟和真实系统连起来的桥。内核态观测的门槛高一些需要你对系统调用和进程地址空间有基本了解。好处是你看到的缺页次数、页表层级、内存映射都是真的。比较稳妥的做法是先用用户态模拟把算法逻辑跑通再挑一两个观测点去真实系统上验证。比如你模拟出来“顺序访问的缺页率低、随机访问的缺页率高”那就写个小程序分别用顺序和随机方式遍历一个大数组观察运行时间的差异。这种“模拟结论加真实佐证”的组合比单独做任何一边都有说服力。环境选型上我倾向于 Linux 加 C 或 Python。C 适合写贴近硬件的结构体、位运算和地址拆分能真实体会“页号取高位、偏移取低位”的操作Python 适合快速验证算法、批量跑数据、画对比图。两者不冲突可以一个写核心逻辑一个做数据统计。编译器用系统自带的就行记得开-Wall很多位运算写错比如该用的地方用了/或者掩码位数不对都能被警告揪出来。方案优点缺点适合人群用户态 C 模拟贴近硬件、可控性强、能练位运算需要自己维护所有状态位想打牢基础的人用户态 Python 模拟开发快、便于批量统计和画图抽象层次高容易忽略细节想快速验证算法的人真实系统观测数据真实、能建立直觉环境差异大、可重复性差已有一定基础的人教学内核改代码全链路完整、理解最深门槛高、调试成本大想深入内核方向的人2. 分页与地址转换把一条逻辑地址拆开算2.1 页表项里到底存了什么页表是存储管理实验里最容易被“符号化”的东西。课本上画一个方框写上“页号 | 页框号”好像映射关系就是一张两列的表。真动手时你会发现页表项是一个按位划分的结构体每一个比特都有用途而且这些位的更新时机决定了整个系统的行为。一个典型的页表项至少包含这几类信息物理页框号决定数据最终落在哪里有效位标记这个页面当前是否在内存中缺页中断就是靠它触发的访问位记录页面最近有没有被读写Clock 算法和 LRU 近似实现全靠它修改位也叫脏位标记页面被换出前是否需要写回保护位控制可读、可写、可执行权限。有些设计还会放全局位、缓存禁用位但在实验里先把前五个搞清楚就够用了。我见过不少同学在结构体里把页框号定义成int然后页表项大小算出来 20 多字节跟课本上“一项 4 字节”对不上。这不是错但会让你在计算页表占用空间时得出离谱的结论。真实系统里页表项大小是 2 的幂通常 4 字节或 8 字节页框号字段的位数由物理内存大小决定。比如物理内存 4GB、页大小 4KB页框数就是 2 的 20 次方页框号需要 20 位剩下 12 位分给各种标志位正好凑成 32 位。这个推导过程建议自己在纸上写一遍比背结论有用。/* 一个贴近真实布局的 32 位页表项 */ typedef struct { unsigned int present : 1; /* 有效位页面是否在内存 */ unsigned int writable : 1; /* 可写位 */ unsigned int accessed : 1; /* 访问位置换算法使用 */ unsigned int dirty : 1; /* 修改位换出时是否需要写回 */ unsigned int reserved : 8; /* 保留位实验里可忽略 */ unsigned int frame_no : 20; /* 物理页框号支持 2^20 个页框 */ } pte_t; /* 页大小 4KB 时页内偏移占 12 位 */ #define PAGE_SHIFT 12 #define PAGE_SIZE (1u PAGE_SHIFT) #define PAGE_MASK (PAGE_SIZE - 1)上面这段代码里位域的顺序和编译器实现有关跨平台时不能假设内存布局但在实验环境里用于理解位划分已经足够。如果你想严格验证布局可以用sizeof(pte_t)打印大小再用一个联合体把 32 位整数和结构体叠在一起逐位赋值观察。这种小验证花不了几分钟但能让你对“页表项就是一个 32 位整数”这件事产生实感。注意位域的可移植性较差不同编译器对位域的对齐和填充策略可能不同。实验报告里如果要写“页表项占 4 字节”最好用sizeof实测而不是想当然。2.2 手算一遍地址转换别跳过这一步地址转换的计算是存储管理实验的基本功但很多人习惯直接写代码跳过手算。结果代码跑出来的物理地址和预期对不上排查半天发现是页大小设成了 1024 却按 4096 去移位。我的建议是实验前先在纸上算三道题把公式和边界都摸清楚。以 32 位逻辑地址、页大小 4KB 为例。逻辑地址0x00003A7C页号等于地址右移 12 位也就是0x00003A7C 12 0x3页内偏移等于地址与0xFFF按位与得到0xA7C。如果页表第 3 项记录的页框号是0x1F那么物理地址就是0x1F 12 | 0xA7C 0x1FA7C。整个过程只有移位、掩码、或运算没有除法。这一点很关键硬件做地址转换必须足够快除法在电路里代价太高所以页大小一定取 2 的幂拆分地址就是纯粹的位操作。多级页表是另一个容易绕晕的地方。为什么要多级因为单级页表要求连续存放一个 32 位进程的页表本身就有 4MB几百个进程加起来光页表就把内存吃光了。多级页表把页号再拆开让页表可以不连续存放用到哪一级才分配哪一级。以两级页表、页大小 4KB 为例32 位地址被拆成 10 位一级页号、10 位二级页号、12 位偏移。逻辑地址0x00003A7C拆出来就是一级页号 0、二级页号 3、偏移 0xA7C。先查一级页表第 0 项拿到二级页表的基址再查二级页表第 3 项拿到物理页框号最后拼上偏移。def split_two_level(addr, page_shift12, bits_per_level10): offset addr ((1 page_shift) - 1) rest addr page_shift l2 rest ((1 bits_per_level) - 1) l1 rest bits_per_level return l1, l2, offset # 0x00003A7C - 一级页号 0二级页号 3偏移 0xA7C print([hex(x) for x in split_two_level(0x00003A7C)])跑一下这段代码输出是[0x0, 0x3, 0xa7c]和手算一致。别嫌这个例子太简单实验中真正的问题往往出在“页表基址寄存器里存的是物理地址还是虚拟地址”“一级页表项指向的是二级页表还是最终页框”这类概念混淆上。手算加代码对照能把这些歧义一个个消掉。还有一点容易被忽略页表项里的页框号是物理页框号不是物理地址。拼物理地址时要先左移页内偏移的位数。我见过有人直接把页框号和偏移相加在小地址范围内居然也能跑出看似合理的结果但一旦页框号变大就全错。这种错误在实验里很隐蔽因为你的模拟器可能根本没做越界检查。提示地址转换的所有运算都应该是移位、按位与、按位或。如果你的实现里出现了除法或者取模先停下来想想是不是页大小没取 2 的幂或者你把字节地址和页号搞混了。2.3 TLB 和有效访问时间为什么快表能救命分页解决了内存碎片和进程隔离问题但引入了一个新麻烦每次访问内存都要先查页表而页表本身也在内存里等于一次访问变两次。两级页表更糟变成三次。这个开销在早期系统里是不可接受的所以硬件里加了一个小容量的相联存储器专门缓存最近用过的页表项这就是 TLB通常翻译成快表或地址转换后备缓冲。TLB 的容量很小一般几十到几百项但它遵循局部性原理命中率可以做到 90% 以上。有效访问时间EAT的公式是实验里常考的计算题假设 TLB 查找耗时 t、内存访问耗时 m、TLB 命中率为 h那么 EAT 等于 h 乘以 (t m) 加上 (1 减 h) 乘以 (t 2m)。两级页表的话未命中时还要多一次内存访问公式相应变成 t 3m。很多教材会把这个公式简化成“命中时一次内存访问、未命中时两次”实验里建议用完整公式并且自己代入几组数字感受一下命中率的影响。比如 t 取 10ns、m 取 100ns、h 取 0.9单级页表的 EAT 等于 0.9×110 0.1×210结果是 120ns。如果命中率掉到 0.5EAT 立刻涨到 160ns涨幅超过三成。这就是为什么 TLB 刷新策略很重要进程切换时如果全部刷新 TLB新进程刚启动那段时间命中率极低性能会明显抖动。真实系统会用地址空间标识来区分不同进程的 TLB 项避免无谓刷新。实验里如果让你设计 TLB记得把这一条写进设计说明它能体现你对“局部性”和“上下文切换代价”的理解。场景TLB 命中率EATt10nsm100ns说明顺序遍历大数组接近 1.0约 110ns页内连续访问命中率极高随机跨页访问0.3 至 0.5约 160ns 至 180ns每次跨页都可能未命中进程刚切换接近 0约 210nsTLB 被刷新后的冷启动多级页表未命中同上更高需要逐级查表访问次数增加这张表不是让你背数字而是建立“命中率对性能影响是非线性”的直觉。实验里如果你能测出自己模拟程序的 TLB 命中率再和 EAT 公式对上这一节的内容就真正吃透了。3. 页面置换算法从纸面推演到代码验证3.1 FIFO、LRU、OPT 三件套怎么写才不出错页面置换算法是存储管理实验里代码量最大、也最容易“看起来对了其实错了”的部分。三个经典算法里FIFO 最好写用一个队列记录进入内存的顺序缺页时弹出队首LRU 需要记录每个页面的最近使用时间可以用时间戳、栈或者双向链表OPT 需要预知未来访问序列实现上就是每次缺页时向后扫描找出最久才会被用到的页面换出去。写 FIFO 时最常见的错误是“命中时也更新队列”。FIFO 只看进入内存的先后命中不改变顺序所以命中时队列不能动。很多人顺手写了一句“访问后把页面移到队尾”那就变成 LRU 了。另一个坑是初始状态页框没装满时直接入队不要触发置换。这个边界条件在手工推演时很容易漏掉。LRU 的实现方式直接决定代码复杂度。用时间戳最简单维护一个全局计数器每次访问就把当前页面的时间戳更新为计数器值缺页时扫描页框找时间戳最小的。缺点是每次缺页都要线性扫描页框多的时候效率低但实验规模下完全够用。用栈的话每次访问把页面移到栈顶栈底就是最久未使用的缺页时直接换栈底效率更高但代码要多写几行链表操作。我一般建议先用时间戳版把逻辑跑对再改成栈版对比结果两次输出必须完全一致这本身就是一次很好的自测。OPT 的难点不在算法而在“未来序列”怎么表示。模拟时你手里有完整的引用串和当前访问位置向后扫描即可。但要注意如果某个页面在未来再也不出现它的“下次使用距离”应该记为无穷大优先被换出。这一点在代码里体现为把初始最小值设成一个足够大的数而不是 0否则会选错。def fifo(refs, frames): mem, queue, faults [], [], 0 for page in refs: if page in mem: continue faults 1 if len(mem) frames: mem.append(page) queue.append(page) else: victim queue.pop(0) mem[mem.index(victim)] page queue.append(page) return faults def lru(refs, frames): mem, last_used, faults [], {}, 0 for tick, page in enumerate(refs): if page in mem: last_used[page] tick continue faults 1 if len(mem) frames: mem.append(page) else: victim min(mem, keylambda p: last_used[p]) mem[mem.index(victim)] page last_used[page] tick return faults def opt(refs, frames): mem, faults [], 0 for i, page in enumerate(refs): if page in mem: continue faults 1 if len(mem) frames: mem.append(page) continue farthest, victim -1, None for p in mem: try: nxt refs.index(p, i 1) except ValueError: nxt float(inf) if nxt farthest: farthest, victim nxt, p mem[mem.index(victim)] page return faults用经典引用串1,2,3,4,1,2,5,1,2,3,4,5、3 个页框跑一遍FIFO 缺页 9 次LRU 缺页 10 次OPT 缺页 7 次。这个结果值得停下来琢磨FIFO 居然比 LRU 少一次缺页。很多人第一反应是“LRU 不是更优吗”其实 LRU 只是比 FIFO 更符合局部性假设并不保证在任何引用串上都赢。这个反直觉的例子正好说明算法比较必须基于具体负载不能拍脑袋下结论。注意refs.index(p, i 1)在 Python 里是线性扫描引用串很长时 OPT 会跑得很慢。实验里引用串几百个元素无所谓但如果你要跑几万次访问的统计建议预处理每个位置的下次出现位置把复杂度降下来。3.2 Belady 异常和 ClockFIFO 的坑与 LRU 的近似Belady 异常说的是 FIFO 算法在页框数增加时缺页次数反而变多。经典例子还是上面那个引用串3 个页框时 FIFO 缺页 9 次4 个页框时缺页 10 次。这个现象在实验里一定要亲手复现一遍因为它打破了一个很自然的直觉——资源越多表现应该越好。原因在于 FIFO 只看进入时间不看使用频率增加页框反而可能让某些“即将被用到”的页面被更晚换出打乱了原本的节奏。复现方法很简单把上面的fifo函数分别用frames3和frames4跑同一个引用串打印每次缺页时的内存状态。你会看到 4 个页框时第 7 次访问页面 5 换出了页面 1而 3 个页框时换出的是页面 4后续访问 1 时恰好命中。这种“蝴蝶效应”在手工推演时很难察觉用代码打印状态就一目了然。实验报告里把两次的页框状态并排列出来比写一大段文字解释更有说服力。Clock 算法是 LRU 的工程近似目的是避免 LRU 每次访问都要更新精确时间戳的开销。它的结构是一个环形缓冲区加一个指针每个页面有一个访问位。缺页时从指针位置开始扫描遇到访问位为 1 的就清零并跳过遇到访问位为 0 的就选为牺牲页。这个“给第二次机会”的设计很巧妙访问位为 1 说明页面最近被用过清零相当于把它降级下次再扫到还没被用过就换出。写 Clock 时有几个细节容易翻车。第一是扫描指针的推进方向必须固定顺时针或逆时针不能来回跳。第二是访问位清零后指针要移动不能停在原地。第三是命中时只置访问位不动指针。第四是初始时所有页面的访问位置 1 还是 0不同教材约定不同实验里要写清楚你的约定否则结果对不上会以为是代码错了。我一般把初始访问位设为 1模拟页面刚装入就已经被访问过这更贴近真实场景。算法实现复杂度是否需要额外信息典型缺页次数示例串主要缺点FIFO低进入顺序队列93 页框Belady 异常、不考虑使用频率LRU中时间戳或栈103 页框实现开销大、需要硬件支持OPT中未来引用序列73 页框无法在线实现只能做基准Clock中低访问位 环形指针介于 FIFO 和 LRU 之间近似精度受扫描策略影响想让 Clock 的结果更接近 LRU可以在缺页时同时考虑修改位形成改进型 Clock访问位和修改位都为 0 的页面优先换出其次换出访问位 0、修改位 1 的页面因为换出脏页需要写回磁盘代价更高。这个优化在实验里属于加分项能体现你对“置换代价不只是缺页次数”的理解。4. 缺页处理与内存分配策略4.1 缺页中断的完整链路缺页中断是存储管理实验里最像“系统”的部分因为它把硬件和操作系统的分工体现得淋漓尽致。访问一个页面时硬件先查页表发现有效位是 0立刻触发缺页异常把控制权交给操作系统。注意这时候出错的虚拟地址必须被保存下来通常放在一个专门的寄存器里否则操作系统不知道要加载哪个页面。这一步在很多模拟程序里被简化成“函数调用时传个参数”但真实系统里它是硬件自动完成的。操作系统接手后第一件事是检查这个地址是否合法。如果访问的地址根本不在进程的地址空间范围内那就不是缺页而是段错误直接终止进程。这一步很多实验会跳过导致模拟程序对非法地址也去分配页面逻辑上不严谨。第二件事是找一个空闲页框空闲链表为空就触发页面置换。第三件事是把页面从磁盘读到页框里如果是写操作导致的缺页还要标记脏位。第四件事是更新页表项把有效位置 1填上页框号。最后重新执行那条触发缺页的指令这时候硬件再查页表就能命中。这条链路里最容易被忽略的是“重新执行指令”。缺页处理完成后不是继续执行下一条而是回到出错的那条指令重新来一遍。因为原来的指令根本没执行成功寄存器状态可能已经部分改变必须回滚到指令开始前的状态。真实硬件会保证缺页异常的精确性模拟时如果你用函数调用的方式实现很容易写成“处理完直接往下走”结果相当于跳过了一次内存访问统计出来的缺页次数看起来对实际语义是错的。/* 缺页处理主流程的伪代码突出检查顺序 */ int handle_page_fault(process_t *p, vaddr_t addr, int is_write) { if (!in_address_space(p, addr)) { return FAULT_SEGMENTATION; /* 非法地址不是缺页 */ } pte_t *pte get_pte(p, addr); if (pte-present) { return FAULT_PROTECTION; /* 页在内存但权限不对 */ } frame_t *frame alloc_frame(); if (!frame) { frame evict_page(p); /* 触发页面置换 */ } load_page_from_disk(p, addr, frame); pte-frame_no frame-index; pte-present 1; pte-accessed 1; if (is_write) { pte-dirty 1; } return FAULT_RESOLVED; /* 返回后重新执行原指令 */ }这段伪代码里检查顺序很重要先判地址合法性再判页表项存在性最后才分配。顺序颠倒会导致非法地址也去分配页框把内存泄漏伪装成缺页。另一个细节是evict_page的返回值置换出来的页框要清零访问位如果原页面是脏的还要写回。实验里如果只统计缺页次数这些状态位不更新也能跑但一旦你要模拟“写回开销”它们就是关键。提示真实系统的缺页处理非常忌讳在缺页上下文中做太多事比如分配内存、拿锁、等待 I/O都可能引发嵌套缺页甚至死锁。实验里虽然不用考虑这么深但在报告里提一句“缺页处理路径应尽量短”能体现你对系统设计的理解。4.2 工作集、抖动和页框分配页面置换算法解决的是“内存不够时换谁”但还有一个前置问题每个进程到底分多少个页框分少了缺页频繁分多了其他进程不够用。这个问题在实验里经常被简化成固定分配但真实系统用的是工作集模型或者缺页率反馈。工作集的定义是进程在最近一段时间窗口内访问过的页面集合。窗口大小通常用时间或者访问次数衡量比如最近 10000 次内存访问。工作集的大小反映了进程当前的局部性强度顺序遍历大数组时工作集很小因为页面用完就丢随机访问多个数据结构时工作集很大。操作系统如果能准确估计每个进程的工作集就可以按需分配页框工作集大的多给小的少给。抖动的本质是“分配的页框数小于工作集大小”。进程频繁缺页每次缺页都要换出一个页面而换出的页面很可能马上又被用到于是刚换出去又缺页CPU 大部分时间耗在等待磁盘 I/O 上利用率断崖式下跌。实验里模拟抖动很简单把页框数设成 2 或 3用一个跨页频繁的引用串跑 LRU观察缺页率随访问次数变化的曲线。当缺页率高到一定程度你会看到曲线不再下降反而在一个高位震荡那就是抖动的特征。页框分配策略一般分三类。固定分配局部置换每个进程分固定数量的页框缺页时只在自己的页框里换页。优点是实现简单、进程间互不影响缺点是无法适应不同进程的工作集变化。可变分配全局置换页框池统一管理缺页时从全局空闲链表拿不够就换出任意进程的页面。灵活但可能误伤其他进程。可变分配局部置换根据缺页率动态调整每个进程的页框数缺页率高的多给低的回收。这是比较理想的做法实现也最复杂。分配策略页框来源置换范围优点缺点固定分配局部置换进程创建时确定本进程隔离性好、易实现不适应工作集变化可变分配全局置换全局空闲链表任意进程灵活、利用率高可能引发进程间干扰可变分配局部置换动态调整本进程兼顾灵活与隔离需要缺页率统计和调整策略实验里我建议至少实现固定分配局部置换然后在此基础上加一个简单的缺页率反馈每隔一段时间统计每个进程的缺页率高于阈值就多给一个页框低于阈值就回收一个。这个扩展不需要写太多代码但能让你对“操作系统如何做资源调度”有更具体的感受。5. 实验数据怎么测、报告怎么写5.1 用脚本批量跑引用串别只测一组数据只用一个经典引用串跑出三个数字实验报告的含金量是很低的。真正能说明问题的是多组不同特征的负载下的对比。我一般会构造三类引用串顺序型0 到 99 循环、随机型在 0 到 99 之间均匀随机、局部型80% 的访问集中在 20% 的页面。每类跑 10000 次访问页框数分别取 4、8、16、32统计缺页率和缺页次数。批量测试的代码不复杂核心是把上面的算法函数包一层循环。需要注意的是随机种子要固定否则每次跑出来的数字都不一样报告里没法复现。局部型引用串的构造可以用“热点页面加冷门页面”的方式用随机数决定这次访问热点区还是冷门区热点区页面少但概率高。这样构造出来的序列能明显体现 LRU 相对 FIFO 的优势因为 LRU 会把热点页面留在内存里而 FIFO 可能把刚进来的热点页面又换出去。import random def gen_sequential(n, pages): return [i % pages for i in range(n)] def gen_random(n, pages, seed42): rng random.Random(seed) return [rng.randrange(pages) for _ in range(n)] def gen_locality(n, pages, hot_ratio0.8, seed42): rng random.Random(seed) hot pages // 5 seq [] for _ in range(n): if rng.random() hot_ratio: seq.append(rng.randrange(hot)) else: seq.append(rng.randrange(hot, pages)) return seq def miss_rate(algo, refs, frames): return algo(refs, frames) / len(refs) refs gen_locality(10000, 100) for f in (4, 8, 16, 32): print(f, miss_rate(fifo, refs, f), miss_rate(lru, refs, f), miss_rate(opt, refs, f))跑这段代码你会看到随着页框数增加三种算法的缺页率都在下降但下降速度不同。局部型负载下 LRU 通常明显优于 FIFO因为热点页面被频繁访问LRU 的时间戳机制能保住它们。这个结果比单个引用串的 9 比 10 比 7 更有说服力也更接近真实负载的特征。5.2 数据记录表和图表让结论站得住实验报告里最忌讳只贴代码和截图没有数据组织。我习惯用两张表一张是固定页框数、不同负载下的缺页率对比另一张是固定负载、不同页框数下的缺页率变化。两张表配合一张折线图基本能把算法的特性说清楚。下面这张表是局部型负载、10000 次访问的示意数据具体数字会随随机种子变化实验时以实测为准页框数FIFO 缺页率LRU 缺页率OPT 缺页率Clock 缺页率418.6%15.2%12.9%16.1%811.3%8.4%7.1%9.0%166.2%4.1%3.5%4.6%323.4%2.2%1.9%2.5%从表里能读出几个结论OPT 始终最低符合它作为理论上限的定位LRU 稳定优于 FIFO差距在页框少时更明显Clock 介于两者之间页框越多越接近 LRU。这些结论单独看某一行是得不出来的必须横向纵向一起比。图表用 Python 的绘图库画折线图就行横轴页框数纵轴缺页率每条线一个算法。画图时注意纵轴从 0 开始否则几条线的差距会被视觉放大显得不客观。图下面配一段文字说明趋势和拐点比如“页框数从 4 增加到 8 时缺页率下降最快超过 16 后收益递减”这比单纯罗列数字有价值。注意实验数据一定要保留原始记录包括随机种子、引用串长度、页框数、算法实现版本。报告里给出的每一个百分比都应该能被复现否则答辩时被问到“这个数怎么来的”会很被动。6. 常见问题与排查实录6.1 常见问题速查表存储管理实验的 bug 有个特点程序不崩但数字不对。你很难通过报错定位问题只能靠对照手算结果和打印中间状态。下面这张表是我自己踩过、也帮别人排查过的典型问题按出现频率排序。现象可能原因排查方法缺页次数比手算多一次初始页框未满时错误触发置换打印每次缺页时的页框数量和内容FIFO 和 LRU 结果完全一样命中时错误地更新了 FIFO 队列检查命中分支是否修改队列地址转换结果偏大或偏小页框号左移位数错误或页大小不是 2 的幂用已知地址手算对照检查移位常量OPT 结果优于理论值未来序列查找起点写成了当前位置检查index的起始参数是否为i 1Clock 算法死循环访问位清零后指针未移动或所有位都是 1加最大扫描次数保护打印指针轨迹多级页表查表越界页号位数划分与页大小不匹配打印拆分结果核对各级页号范围缺页率随页框数增加反而上升引用串太短统计噪声大或算法实现有状态残留加长引用串每次测试前重置全局状态模拟结果和真实系统差异大忽略了 TLB、预取、写回等真实因素明确模拟边界在报告中说明简化假设这张表里最值得说的是“状态残留”。很多人写算法函数时把页框数组定义在函数外面跑完一组测试没清空下一组接着用结果第一组的数据污染了第二组。这种 bug 在批量测试时特别隐蔽因为单组测试看起来完全正常。解决办法很简单每次调用算法前显式初始化所有状态或者把状态封装在函数内部用局部变量。6.2 几个踩过的坑和私房技巧第一个坑是引用串的页面编号从 0 开始还是从 1 开始。课本例子常用 1 到 5代码里数组下标从 0 开始混用会导致页框里出现“第 0 页”而引用串里没有 0命中判断永远失败。我的习惯是统一用 0 开始报告里如果引用课本例子在开头注明“本文引用串已统一转换为 0 起始编号”。第二个坑是 LRU 的时间戳在命中时忘记更新。缺页时更新了命中时没更新结果算法退化成 FIFO 的变种缺页率和预期对不上。排查方法是在命中分支加一行打印看时间戳有没有变化。这种错误手工推演很难发现因为推演时你脑子里默认“访问了就算用过”代码却没这么写。第三个坑是 OPT 的“无穷大”处理。用float(inf)没问题但如果用一个大整数比如999999而引用串长度超过这个数批量测试时很容易就会误判。建议直接用float(inf)或者在扫描前先统计引用串长度用长度加一作为无穷大。私房技巧方面我强烈建议写一个“轨迹打印”开关。算法每做一次决策就打印当前访问的页面、页框内容、是否缺页、换出了谁。调试时打开跑一个小引用串肉眼对照手算结果确认无误后关掉跑批量测试。这个开关花不了几行代码但能省掉大量猜测时间。还有一个技巧是给算法写单元测试。用课本上的经典例子作为测试用例断言缺页次数等于预期值。比如 FIFO 在引用串1,2,3,4,1,2,5,1,2,3,4,5、3 页框下应该等于 9。每次改完代码跑一遍测试防止改一处坏一处。测试用例不用多五六个覆盖边界情况就够空引用串、页框数大于引用串长度、所有访问都命中、所有访问都缺页。最后说一个容易被忽略的点实验报告里的“结论”不要写成“LRU 比 FIFO 好”这种一句话。好的结论应该带条件和数据比如“在局部型负载、页框数为 8 时LRU 的缺页率比 FIFO 低约 2.9 个百分点但在顺序型负载下两者差距缩小到 0.5 个百分点以内”。这种带条件的结论才经得起追问也说明你真的跑了数据、看了趋势而不是抄了个标准答案。我个人在这个实验上花时间最多的地方不是写算法而是把地址转换、页表项、缺页处理这三块的状态变化对齐。算法跑对只是第一步能说清楚“某一次缺页发生时页表项哪几位变了、页框链表怎么动、TLB 要不要刷新”才算真正把存储管理这条线串起来。如果你做完实验还有余力不妨把模拟器扩展一下加一个简单的 TLB 模型看看加入 TLB 后有效访问时间怎么变那个数字会比课本上的公式生动得多。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑