ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

HNU_ostep操作系统实验包:进程调度与虚拟内存代码实战

HNU_ostep操作系统实验包:进程调度与虚拟内存代码实战

简介:HNU_ostep-操作系统实验是一套面向高校计算机专业学生的操作系统课程实践资源,围绕进程管理、内存管理、文件系统与I/O系统等核心模块展开,适合正在学习操作系统原理、需要动手实现内核组件的学习者。压缩包共收录2000个文件,以910个C源文件和760个头文件为主体,辅以Python脚本、Shell脚本、CMake与Makefile构建文件、Markdown与txt说明文档,以及少量pdf、docx讲义资料,整体约42.51MB。其中ostep_code目录提供实验相关源码蓝本,readme.txt给出实验总览、环境搭建与任务要求,附书资料则补充经典教材与讲义,帮助理解操作系统内部机制。通过阅读和修改调度、内存分配等代码,读者可直观观察不同算法对执行效率的影响,并借助构建脚本快速复现实验环境。目前已有165人学习,适合希望以实践方式夯实系统软件开发基础的学习者参考。

1. 从一份操作系统实验包说起:它到底能帮你解决什么

如果你正在啃操作系统课,大概率会遇到一个尴尬局面:教材上的进程调度、虚拟内存、文件系统讲得头头是道,但真让你写一个能跑的调度器或者页表管理器,手就僵住了。这份 HNU_ostep 操作系统实验包,就是冲着这个断层来的。它把 OSTEP 那本教材里的核心章节拆成了可编译、可运行、可验证的实验单元,每个单元都配了骨架代码和测试脚本。你不需要从零搭环境,也不用猜“到底要改哪个函数”,拿到手就能在本地跑起来,看着自己的代码把进程调度、内存分配这些黑匣子一层层剥开。适合谁?正在上操作系统课、准备实验报告、或者想补上“理论懂但动手虚”这块短板的人。它不教你写操作系统内核,但能让你把课本里那些抽象概念,变成终端里能打印出来的真实数据。

2. 实验包结构拆解:从进程调度到虚拟内存的代码地图

2.1 目录布局与模块划分

拿到压缩包解压后,你会看到一套按 OSTEP 章节组织的目录结构。常见做法是每个实验一个独立文件夹,里面包含Makefile、*.c源文件、*.h头文件和一个test或tests目录。比如进程调度实验通常放在scheduler/下,虚拟内存相关在vm/下,文件系统在fs/下。每个文件夹的README会写明实验目标、需要修改的函数名和预期输出格式。我一般会先扫一遍Makefile,看编译目标有哪些,再决定从哪个实验切入。如果你是新手,建议按目录编号顺序来,因为后面的实验会复用前面定义的链表、队列等基础结构。

2.2 核心数据结构与接口约定

这套实验包最值得称道的地方是接口定义清晰。以进程调度为例,骨架代码里通常有一个struct task_struct或类似的进程控制块,里面包含pid、state、priority、runtime等字段。你需要实现的调度器函数会接收一个任务队列指针,返回下一个要运行的任务。参数说明上,runtime一般表示该任务已运行的时间片,priority是静态优先级,数值越小优先级越高。虚拟内存实验里,你会看到struct pte(页表项)和struct frame(物理帧)的定义,页表项里通常有valid、dirty、ref等标志位。这些字段不是摆设,测试脚本会检查你在页面置换时是否正确更新了它们。常见做法是先把头文件里的结构体注释读一遍,再动手写逻辑,否则很容易出现“功能对了但标志位没置位”导致测试挂掉的情况。

2.3 编译与运行环境准备

实验包一般基于 Linux 环境,依赖gcc、make和标准 C 库。如果你用 Windows,建议在 WSL2 或者虚拟机里跑,因为部分实验会用到fork()、mmap()这类系统调用。先确认gcc --version和make --version能正常输出,然后进入某个实验目录执行make。如果编译报错,八成是缺少某个头文件或者Makefile里的路径写死了。我遇到过Makefile里用-I./include但实际头文件在../include的情况,改一下路径就行。编译通过后,运行./test或make test会启动测试脚本,它会打印每个测试用例的通过情况。注意,有些实验的测试脚本需要 Python 3 环境来解析输出,提前装好python3能省不少事。

3. 进程调度实验:从 FCFS 到多级反馈队列的代码落地

3.1 调度器骨架与任务队列操作

进程调度实验的核心是让你实现几种调度算法,并对比它们的性能指标。骨架代码里通常已经定义好了任务队列的入队、出队函数,你需要做的是在schedule()函数里决定下一个运行谁。以 FCFS(先来先服务)为例,逻辑很简单:直接取队列头。但代码里要注意,取出任务后要更新它的state为RUNNING,并把runtime累加。下面是一个典型的调度循环骨架:

// scheduler.c #include "scheduler.h" // 从就绪队列中选取下一个任务 struct task_struct *schedule(struct queue *ready_q) { if (queue_empty(ready_q)) { return NULL; // 没有可运行任务 } // FCFS: 直接取队头 struct task_struct *next = queue_pop(ready_q); next->state = RUNNING; return next; } // 模拟时间片推进,更新任务状态 void tick(struct task_struct *curr, struct queue *ready_q) { if (curr == NULL) return; curr->runtime++; // 如果任务还没完成,重新放回就绪队列 if (curr->runtime < curr->burst) { curr->state = READY; queue_push(ready_q, curr); } else { curr->state = DONE; } }

这段代码里,queue_pop和queue_push是骨架提供的队列操作,你不用自己实现。burst表示任务需要的总 CPU 时间,runtime是已经运行的时间。每次tick模拟一个时间单位,如果任务还没跑完就放回队列。参数说明:ready_q是就绪队列指针,curr是当前运行的任务。逻辑上,FCFS 的缺点很明显——短任务如果排在长任务后面,等待时间会很长。测试脚本会输出平均周转时间和平均等待时间,你可以通过对比不同算法的输出来验证。

3.2 时间片轮转与优先级调度的参数调优

实现 RR(时间片轮转)时,关键参数是时间片大小quantum。骨架里通常用一个全局变量或宏定义,比如#define QUANTUM 4。你需要在tick函数里判断curr->runtime % QUANTUM == 0,如果是就把当前任务放回队尾,重新调度。这里有个坑:如果quantum设得太大,RR 退化成 FCFS;设得太小,上下文切换开销占比高。我一般会先用quantum=1跑一遍看趋势,再调到 4 或 8 观察平均等待时间的变化。优先级调度则需要在schedule里遍历队列找priority最小的任务,但要注意,如果多个任务优先级相同,应该按 FCFS 顺序选。代码上可以用一个循环遍历队列节点,记录当前最小优先级和对应任务。测试脚本会检查你是否正确处理了优先级反转的情况——比如低优先级任务持有锁时,高优先级任务不能抢占。

3.3 多级反馈队列的实现与测试验证

多级反馈队列(MLFQ)是这套实验里最复杂的一个。你需要维护多个就绪队列,每个队列有不同的优先级和时间片。新任务进入最高优先级队列,用完时间片后降到下一级。为了防止饥饿,通常还会定期把所有任务提升回最高优先级。骨架代码里可能已经定义了struct mlfq包含多个queue数组和boost_interval参数。实现时,schedule函数要从最高优先级队列开始找,非空就取队头。tick函数里要判断当前任务是否用完当前队列的时间片,如果是就降级。下面是一个简化的降级逻辑:

// mlfq.c void mlfq_tick(struct mlfq *sched, struct task_struct *curr) { if (curr == NULL) return; curr->runtime++; int level = curr->level; // 检查是否用完当前级别的时间片 if (curr->runtime % sched->quantum[level] == 0) { if (level < MAX_LEVEL - 1) { curr->level++; // 降级 } curr->state = READY; queue_push(&sched->queues[curr->level], curr); } else if (curr->runtime >= curr->burst) { curr->state = DONE; } else { curr->state = READY; queue_push(&sched->queues[level], curr); } }

参数说明:sched->quantum[level]是第level级队列的时间片大小,MAX_LEVEL是队列总数。逻辑上,任务用完时间片就降级,但没跑完的任务会重新入队。测试脚本会模拟一组任务,输出每个任务的完成时间和队列变化。验证时,你可以手动算一下某个任务的预期完成时间,和脚本输出对比。如果对不上,先检查boost_interval是否触发了优先级提升,再看降级条件是否写反了。

4. 虚拟内存实验:页表、TLB 与页面置换的代码实现

4.1 页表结构与地址翻译流程

虚拟内存实验通常要求你实现一个简化的地址翻译模块。输入是一个虚拟地址,输出是物理地址或缺页异常。骨架代码里会定义页表项struct pte和页目录struct page_table。地址翻译的流程是:从虚拟地址中提取页号和偏移量,查页表得到物理帧号,再拼接偏移量得到物理地址。如果页表项valid为 0,就触发缺页异常,需要从磁盘加载页面。下面是一个典型的翻译函数:

// vm.c #include "vm.h" // 地址翻译:返回物理地址,-1 表示缺页 int translate(struct page_table *pt, int vaddr) { int page_num = vaddr >> PAGE_SHIFT; // 提取页号 int offset = vaddr & (PAGE_SIZE - 1); // 提取偏移量 struct pte *entry = &pt->entries[page_num]; if (!entry->valid) { return -1; // 缺页 } entry->ref = 1; // 设置引用位 int paddr = (entry->frame_num << PAGE_SHIFT) | offset; return paddr; }

参数说明:PAGE_SHIFT是页大小的位移量,比如 4KB 页对应 12;PAGE_SIZE是页大小。entry->frame_num是物理帧号。逻辑上,翻译成功后要置ref位,供页面置换算法使用。测试脚本会模拟一系列地址访问,检查翻译结果和缺页次数。常见错误是忘记处理偏移量,或者页号计算时用了错误的位移量。我一般会先打印几个地址的翻译结果,手动验证一下。

4.2 TLB 模拟与命中率统计

TLB(快表)实验要求你模拟一个有限容量的 TLB,并统计命中率。骨架里会定义struct tlb_entry和struct tlb,你需要实现查找、插入和替换逻辑。常见做法是用 LRU 替换策略:每次访问更新条目的时间戳,满了就淘汰最久未使用的。下面是一个 TLB 查找和插入的示例:

// tlb.c #include "tlb.h" // 查找 TLB,命中返回帧号,未命中返回 -1 int tlb_lookup(struct tlb *t, int page_num) { for (int i = 0; i < t->size; i++) { if (t->entries[i].valid && t->entries[i].page_num == page_num) { t->entries[i].last_access = t->clock++; // 更新访问时间 return t->entries[i].frame_num; } } return -1; // 未命中 } // 插入 TLB,满了则替换最久未使用的条目 void tlb_insert(struct tlb *t, int page_num, int frame_num) { int victim = -1; int oldest = t->clock; for (int i = 0; i < t->size; i++) { if (!t->entries[i].valid) { victim = i; break; } if (t->entries[i].last_access < oldest) { oldest = t->entries[i].last_access; victim = i; } } t->entries[victim].page_num = page_num; t->entries[victim].frame_num = frame_num; t->entries[victim].valid = 1; t->entries[victim].last_access = t->clock++; }

参数说明:t->clock是一个全局递增计数器,模拟时间。last_access越小表示越久未使用。逻辑上,查找时更新访问时间,插入时找无效条目或最久未使用的条目替换。测试脚本会输出 TLB 命中率,你可以通过调整 TLB 大小观察命中率变化。注意,TLB 命中时不需要查页表,所以命中率直接影响平均访问时间。

4.3 页面置换算法:FIFO、LRU 与 Clock 的对比

页面置换实验要求你实现 FIFO、LRU 和 Clock 三种算法,并对比缺页次数。FIFO 最简单,用一个队列记录页面进入内存的顺序,淘汰队头。LRU 需要记录每个页面的最近访问时间,淘汰最久未访问的。Clock 是 LRU 的近似,用一个循环指针和引用位,扫描时如果引用位为 1 就置 0 并跳过,否则淘汰。下面是一个 Clock 算法的核心逻辑:

// swap.c #include "swap.h" // Clock 页面置换:返回被淘汰的帧号 int clock_replace(struct clock_data *cd, int page_num) { while (1) { if (cd->frames[cd->hand].ref == 0) { int victim = cd->hand; cd->hand = (cd->hand + 1) % cd->num_frames; return victim; } cd->frames[cd->hand].ref = 0; // 给第二次机会 cd->hand = (cd->hand + 1) % cd->num_frames; } }

参数说明:cd->hand是时钟指针,cd->frames[i].ref是引用位。逻辑上,扫描时如果引用位为 0 就淘汰,否则置 0 并继续。测试脚本会模拟一组页面访问序列,输出每种算法的缺页次数。我一般会先用小规模序列手动验证,比如1 2 3 4 1 2 5 1 2 3 4 5,然后跑大规模随机序列看趋势。常见坑是 Clock 算法里忘记更新hand指针,导致死循环。

5. 避坑与排查:实验包里最容易翻车的五个地方

5.1 编译报错“undefined reference toqueue_push”

现象:make时链接阶段报错,提示某个队列操作函数未定义。原因:骨架代码里队列实现放在单独的queue.c里,但Makefile的SRCS变量没包含它。解决:打开Makefile,找到SRCS = scheduler.c这一行,改成SRCS = scheduler.c queue.c,或者直接用wildcard *.c。如果还不行,检查头文件里函数声明是否加了extern "C"(C++ 项目常见)。

5.2 测试脚本输出“Segmentation fault”

现象:运行./test直接崩溃,没有输出。原因:最常见的是空指针解引用,比如schedule返回NULL后调用方没检查就访问curr->pid。解决:在tick和schedule里加空指针判断,用gdb ./test跑一遍,bt看栈回溯定位到具体行。另一个可能是队列操作在空队列上pop,检查queue_empty是否在pop前调用。

5.3 调度结果与预期不符,平均等待时间偏差大

现象:测试脚本输出的平均等待时间和手算结果差很多。原因:时间片推进逻辑写错了,比如runtime累加时机不对,或者任务完成后没有从队列移除。解决:在tick函数里加printf打印每个时间单位的状态,和测试脚本的预期输出逐行对比。注意,有些实验的burst是总 CPU 时间,不是剩余时间,别搞混。

5.4 页面置换缺页次数异常高

现象:LRU 的缺页次数比 FIFO 还多,不符合理论预期。原因:LRU 的访问时间更新逻辑有误,比如只在插入时更新,查找时没更新。解决:检查tlb_lookup和页表访问函数,确保每次访问都更新last_access或ref位。另外,如果页面大小设置不对,比如PAGE_SHIFT写成了 10 而不是 12,会导致页号计算错误,缺页次数飙升。

5.5 多级反馈队列出现任务饥饿

现象:低优先级任务一直得不到运行,测试超时。原因:没有实现优先级提升机制,或者boost_interval设置得太大。解决:在mlfq_tick里加一个全局计数器,每经过boost_interval个时间单位就把所有任务移回最高优先级队列。参数上,boost_interval一般设为 50 到 100 个时间单位,太小会导致优先级频繁重置,太大则饥饿依旧。

6. 进阶技巧:用脚本自动化验证与性能对比

跑通单个实验只是第一步,真正省时间的是把多个算法的输出自动化对比。我习惯写一个 Python 脚本,调用make编译不同版本,然后解析测试输出,生成对比表格。比如下面这个脚本,可以同时跑 FCFS、RR 和 MLFQ,提取平均周转时间:

# compare.py import subprocess import re algorithms = ['fcfs', 'rr', 'mlfq'] results = {} for algo in algorithms: # 编译对应版本 subprocess.run(['make', f'ALGO={algo}'], check=True) # 运行测试并捕获输出 out = subprocess.run(['./test'], capture_output=True, text=True).stdout # 提取平均周转时间 match = re.search(r'Avg Turnaround: ([\d.]+)', out) if match: results[algo] = float(match.group(1)) # 打印对比表格 print(f"{'Algorithm':<10} {'Avg Turnaround':<15}") for algo, tat in results.items(): print(f"{algo:<10} {tat:<15.2f}")

这段脚本的关键是Makefile里支持ALGO变量切换编译目标,比如CFLAGS += -DALGO_$(ALGO),然后在代码里用#ifdef选择不同调度器。参数说明:subprocess.run的check=True会在编译失败时抛异常,capture_output=True捕获标准输出。逻辑上,脚本自动编译、运行、解析、对比,省去手动改代码的麻烦。我一般还会加一个--repeat参数,跑多次取平均值,减少随机性影响。

另一个进阶技巧是给测试脚本加超时保护。有些实验在死循环时不会自己退出,用timeout 10 ./test可以强制终止。如果超时,说明调度逻辑有死循环,优先检查while循环的退出条件。从那以后我每次跑实验都强制走一遍timeout,省得终端卡死。希望这些经验能帮你少走点弯路。

本文还有配套的精品资源,点击获取

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进