操作系统课设拆解:进程调度、页面置换与银行家算法可复现实战 简介这份东北大学计科操作系统课程设计资料包面向正在完成操作系统课设、需要参考完整实现与实验报告的高校学生。内容围绕进程管理、内存管理、文件系统与设备管理展开涉及进程创建调度与同步互斥、虚拟地址映射与页面替换、文件及目录的创建读写删除、I/O中断与缓冲区管理等典型知识点并配有课程设计报告便于对照理解设计思路与实现细节。压缩包共24个文件约2.11MB以21个C源文件为主辅以头文件、可执行程序和Word课程设计报告源码按文件系统、目录、读写、打开关闭、权限登录、内存分配回收等模块组织可帮助读者梳理课设的代码结构与功能划分。已有281人学习适合用来复现实验、排查编译运行问题并在此基础上完成自己的课程设计。1. 一份计科操作系统课设拆开才知道哪里值钱每年到期末计科专业的操作系统课设都会被翻出来重新跑一遍。多数人第一反应是找一份能编译通过的代码交上去但真正卡人的地方从来不是“能不能跑”而是调度结果为什么和别人算出来的平均周转时间对不上、页面置换的命中率为什么每次都不一样、银行家算法在某个请求上到底安不安全。这份资源的价值就在这儿它把进程调度、同步互斥、请求分页和死锁避免这几块拆成可单独编译、单独喂数据的小模块每个模块都有固定输入输出跑完能对着表格逐行核对。适合三类人赶课设 deadline 的计科学生、想拿它当教学演示的助教以及想复习一遍操作系统底层机制的开发者。下面按“先跑通、再改参数、最后防翻车”的顺序拆。2. 把工程跑起来目录分层、编译链和第一个可复现结果拿到一份课设代码最忌讳直接make一把梭。先花十分钟看清目录比后面花两小时查段错误划算得多。这份工程的组织方式比较典型算法层、驱动层、数据层分开改一个调度算法不需要动主函数。2.1 先看清模块边界哪些文件是算法、哪些是壳常见的目录分层是这样不同版本的命名会有出入但职责基本对应目录/文件职责改它的频率src/sched/FCFS、SJF、RR、优先级调度实现高课设主要改这里src/sync/生产者消费者、读者写者、信号量封装中src/mem/页表、FIFO/LRU/OPT 置换高src/banker/银行家算法安全性检查中include/PCB、页表项、信号量等结构体定义高改字段要全局重编tests/输入用例一般是纯文本一行一个进程或一次请求高Makefile编译入口低判断一份课设代码写得好不好看一个细节就够结构体定义是不是集中在include/下。如果每个.c文件里都各自抄了一份struct PCB那后面调字段一定会踩到“改了这边忘了那边”的坑。我一般会先grep -rn struct PCB .扫一遍确认只有一处定义再往下走。提示如果代码里进程数、页框数、资源种类数用的是#define MAX 100这类宏先记下来后面喂测试数据不能超过它否则数组越界不一定报错但结果会变成玄学。2.2 编译参数怎么定从 -O0 到 -O2 的取舍课设代码量不大编译不是瓶颈但参数选错会直接影响调试体验。下面这套命令是我一般会先跑的先保守编译看警告再开优化看结果是否一致# 第一步清理旧产物避免链接到上一次的 .o make clean # 第二步开全警告编译先不优化方便 gdb 单步 make CCgcc CFLAGS-O0 -g -Wall -Wextra -stdc11 all # 第三步如果第二步有大量警告先把 -Werror 去掉再跑别一上来就卡死 # 第四步结果确认无误后再开优化验证一遍数值是否漂移 make clean make CCgcc CFLAGS-O2 -Wall -stdc11 all参数含义逐条说清楚-O0 -g保留调试符号变量不会被优化掉gdb里print p[i].remain能看到真实值-O2下编译器可能把循环里的临时变量放进寄存器断点跳行是常事所以调试阶段别开。-Wall -Wextra必须开课设代码里最常见的“有符号和无符号比较”“变量未初始化”都能在这里暴露。-stdc11是为了让pthread、stdint.h这些用法标准化避免不同机器上gcc默认标准不一致导致编译失败。如果make报undefined reference to pthread_create说明链接阶段少了线程库把-lpthread补到链接命令末尾即可。这个报错在生产者消费者模块里出现频率极高跟代码逻辑没关系纯粹是链接顺序问题。2.3 输入用例与输出约定让三组数据都能对得上课设答辩最常见的翻车场景是程序输出了一堆数字老师问“这个平均周转时间怎么算的”答不上来。所以跑通之后第一件事不是改算法而是确认输入输出格式。典型的调度输入用例长这样四列分别是进程号、到达时间、服务时间、优先级# tests/sched_case1.txt 1 0 5 3 2 1 3 1 3 2 8 2 4 4 2 4读取代码一般用fscanf循环这里要注意两个细节一是跳过以#开头的注释行二是文件末尾不能多一个空行否则会多读一个全零进程。/* 读取调度用例跳过注释行读到 EOF 或进程数上限为止 */ int load_procs(const char *path, PCB p[], int max_n) { FILE *fp fopen(path, r); if (!fp) { perror(open); return -1; } char line[128]; int n 0; while (n max_n fgets(line, sizeof(line), fp)) { if (line[0] # || line[0] \n) continue; /* 注释与空行 */ int pid, arrive, burst, prio; if (sscanf(line, %d %d %d %d, pid, arrive, burst, prio) ! 4) { fprintf(stderr, bad line: %s, line); continue; /* 坏行跳过而不是整体失败方便边测边改 */ } p[n].pid pid; p[n].arrive arrive; p[n].burst burst; p[n].remain burst; /* 剩余时间初始化为服务时间 */ p[n].priority prio; n; } fclose(fp); return n; }这段代码的关键点在于remain字段的初始化。时间片轮转和抢占式优先级调度都依赖它如果忘了在读取时赋值后面算出来的完成时间全是错的而且不一定崩溃。另一个是坏行处理答辩现场临时改用例很常见跳过坏行比直接exit(1)友好但要在 stderr 打出来别静默吞掉。输出侧建议统一成一张表字段固定为进程号、到达、服务、开始、完成、周转、带权周转。先把这张表打印出来再去算平均值这样任何一次数值对不上都能定位到具体是哪个进程的哪一列出了问题。3. 进程调度与同步PCB 数据结构、RR 时间片与信号量封装调度和同步是课设里最容易被“糊过去”的两块。调度部分很多人只写一个算法就交同步部分直接用while(flag);忙等。这两处恰恰是老师最爱追问的地方。3.1 PCB 字段决定后面所有统计量PCB 不是随便塞几个字段就行它决定了你能算出哪些指标。最小可用集合如下typedef struct { int pid; /* 进程号输出表格第一列 */ int arrive; /* 到达时间调度器选进程的依据之一 */ int burst; /* 服务时间原始值不要被修改 */ int remain; /* 剩余服务时间抢占式算法靠它判断是否继续 */ int priority; /* 优先级约定数值越小优先级越高写进注释避免歧义 */ int start; /* 首次开始时间-1 表示尚未运行 */ int finish; /* 完成时间 */ int turnaround; /* 周转时间 finish - arrive */ int wait; /* 等待时间 turnaround - burst */ } PCB;几个字段容易写错。start必须初始化为-1否则第一次比较p[i].start -1会失效导致开始时间被覆盖成第二次运行的时间。burst和remain必须分开如果图省事只用remain算周转时间时就没有原始服务时间可用了。priority的方向一定要在头文件里写死注释我见过同一份代码里两处比较方向相反的结果优先级调度跑出来和 FCFS 一模一样。派生指标统一用函数算不要在每个算法里各写一遍/* 统一计算周转、等待与平均值避免各算法口径不一致 */ void calc_metrics(PCB p[], int n, double *avg_turn, double *avg_wait) { double st 0, sw 0; for (int i 0; i n; i) { p[i].turnaround p[i].finish - p[i].arrive; p[i].wait p[i].turnaround - p[i].burst; st p[i].turnaround; sw p[i].wait; } *avg_turn n ? st / n : 0; *avg_wait n ? sw / n : 0; }n为 0 时返回 0 而不是除零这个小判断在测试空用例时能省一次崩溃排查。3.2 时间片轮转的队列实现与边界处理RR 的核心是一个就绪队列加上“新进程到达”和“时间片用完”两个事件的处理顺序。顺序搞反结果就差一个时间片。下面是一份可以直接改的骨架/* 时间片轮转时间片用完放回队尾新到达进程先入队再取队头 */ void rr_schedule(PCB p[], int n, int quantum) { int queue[MAX], head 0, tail 0; int time 0, done 0, arrived 0; /* 按到达时间排序后先把 time0 时已到达的进程入队 */ sort_by_arrive(p, n); while (arrived n p[arrived].arrive time) queue[tail] arrived; while (done n) { if (head tail) { /* 队列空时间跳到下一个到达点 */ time p[arrived].arrive; while (arrived n p[arrived].arrive time) queue[tail] arrived; continue; } int idx queue[head]; if (p[idx].start -1) p[idx].start time; int run (p[idx].remain quantum) ? p[idx].remain : quantum; time run; p[idx].remain - run; /* 关键顺序先接纳本时间片内到达的新进程再把当前进程放回队尾 */ while (arrived n p[arrived].arrive time) queue[tail] arrived; if (p[idx].remain 0) { p[idx].finish time; done; } else { queue[tail] idx; } } calc_metrics(p, n, avg_turn, avg_wait); /* 统一口径统计 */ }这段代码里最值得讲的是“先接纳新进程再放回队尾”这个顺序。如果反过来一个在时间片结束瞬间到达的进程就会被排到当前进程后面导致周转时间整体偏大。这是课设判分时最容易被抓的差异点因为不同实现算出来的平均值能差好几个百分点。另一个边界是head tail的空队列分支。如果所有进程都还没到达直接continue会死循环必须把时间推进到下一个到达点。这里用p[arrived].arrive是安全的因为循环条件保证了done n且队列空说明还有进程没进来。quantum参数的调试建议从 1 开始逐步加到最大服务时间观察平均等待时间的变化曲线。时间片过大时 RR 退化成 FCFS过小时上下文切换开销在模拟里体现为进程被反复入队平均周转时间反而上升。这个对比表放进报告里比单纯贴一张结果图有说服力。3.3 生产者消费者别用忙等糊弄信号量同步模块最常见的偷懒写法是用一个全局int flag加while(flag);轮询。这种代码在单核上碰巧能跑但答辩时老师问一句“这算不算忙等、CPU 占用怎么算”就答不上来。正确做法是用互斥锁加条件变量封装一个信号量代码量不大但逻辑要严丝合缝。/* 用互斥锁 条件变量模拟 P/V 操作避免忙等 */ typedef struct { int value; /* 信号量计数缓冲区空位数或产品数 */ pthread_mutex_t lock; pthread_cond_t cond; } sem_t_custom; void sem_init_c(sem_t_custom *s, int v) { s-value v; pthread_mutex_init(s-lock, NULL); pthread_cond_init(s-cond, NULL); } void sem_wait_c(sem_t_custom *s) { /* P 操作 */ pthread_mutex_lock(s-lock); while (s-value 0) /* 必须用 while防虚假唤醒 */ pthread_cond_wait(s-cond, s-lock); s-value--; pthread_mutex_unlock(s-lock); } void sem_post_c(sem_t_custom *s) { /* V 操作 */ pthread_mutex_lock(s-lock); s-value; pthread_cond_signal(s-cond); /* 唤醒一个等待者 */ pthread_mutex_unlock(s-lock); }while而不是if是这段代码的命门。条件变量存在虚假唤醒用if判断一次就往下走可能出现计数已经为 0 却仍然进入临界区的情况表现为生产者把产品写到了已满的缓冲区上数据被覆盖而且不报错。这个问题在负载轻的时候几乎复现不出来一上多线程压力测试就开始丢数据属于典型的血泪经验。缓冲区本身建议做成环形队列用in、out两个下标加一个容量常量。生产者和消费者各自持有一个信号量empty初始化为容量full初始化为 0再加一把互斥锁保护in/out的更新。三个同步原语的加锁顺序要固定否则会死锁。答辩演示时可以在临界区里加一句usleep(1000)放大竞争窗口让问题暴露得更明显。4. 内存管理与死锁避免页表、置换算法与银行家算法的验证路径内存和死锁这两块的特点是“算法本身不难难在数据结构和统计口径”。页面置换的命中率算法写错一个判断结果就全偏银行家算法的安全性检查漏一个回滚就会把不安全状态判成安全。4.1 请求分页模拟器的骨架页表项该存什么页面置换模拟器不需要真的分配内存只需要维护一个页框数组和一张页表。页表项最小字段是页面号、装入时间、最近访问时间、访问位其中后三个直接决定了 FIFO、LRU、时钟算法怎么选牺牲页。typedef struct { int page_no; /* 页面号-1 表示该页框为空 */ int load_time; /* 装入时刻FIFO 用它挑最早进来的 */ int last_access; /* 最近一次访问时刻LRU 用它挑最久未用的 */ int ref_bit; /* 访问位时钟算法用每次访问置 1 */ } Frame; /* 初始化页框page_no 置 -1 表示空框 */ void init_frames(Frame f[], int n) { for (int i 0; i n; i) { f[i].page_no -1; f[i].load_time 0; f[i].last_access 0; f[i].ref_bit 0; } }页框数n是实验里最重要的自变量。它一般从 3 开始逐次加 1一直到超过访问序列里不同页面的总数此时命中率应该稳定在理论值不再上升。如果页框数增加后命中率反而下降那不是程序 bug而是 FIFO 的 Belady 异常这个现象本身就可以写进报告里当分析点。访问序列的读入格式建议用一行整数遇到-1结束方便手工构造测试串7 0 1 2 0 3 0 4 2 3 0 3 2 1 2 0 1 7 0 1 -14.2 FIFO/LRU/OPT 的命中率对照实验三个算法的骨架高度相似差别只在“挑哪个页框换出”。把它们写成同一个函数加一个策略参数能避免三份代码各自维护导致口径不一致/* 统一置换入口policy 0FIFO 1LRU 2OPT返回缺页次数 */ int simulate(Frame f[], int nframes, int refs[], int nref, int policy) { int faults 0; for (int t 0; t nref; t) { int page refs[t], hit -1, empty -1; for (int i 0; i nframes; i) { if (f[i].page_no page) { hit i; break; } if (f[i].page_no -1 empty -1) empty i; } if (hit 0) { /* 命中只更新访问信息 */ f[hit].last_access t; f[hit].ref_bit 1; continue; } faults; int victim (empty 0) ? empty : pick_victim(f, nframes, refs, nref, t, policy); f[victim].page_no page; f[victim].load_time t; f[victim].last_access t; f[victim].ref_bit 1; } return faults; }pick_victim里三个策略的实现要点FIFO 选load_time最小的LRU 选last_access最小的OPT 要向后扫描refs[t1 ... nref-1]选“下一次访问最晚或不再访问”的页框扫描时要处理“某页之后再也不出现”的情况把它当作无穷远优先换出。LRU 有一个常见误解以为可以用“访问次数最少”替代“最久未访问”。这两者在访问序列有局部性时结果接近但在精心构造的序列上会分叉。课设报告里如果要做对比务必把访问序列一并附上否则复现不了。下面是一组典型序列在 3 个页框下的对照数字会随序列不同而变化但相对关系基本稳定算法缺页次数命中率特点FIFO1525%实现最简单存在 Belady 异常LRU1240%命中率稳定需维护访问时间OPT955%理论下界无法在线实现只作对照这张表的说服力在于“OPT 必须是最低的”。如果跑出来 OPT 比 LRU 还高那一定是pick_victim里向后扫描的边界写错了最常见的是把t1写成t导致当前页被当成“即将访问”而保留。4.3 银行家算法安全性检查与请求试探银行家算法分两步先做安全性检查判断当前状态是否安全再对某个请求做试探分配分配后重新检查不安全就回滚。很多人只写了第一步第二步直接用“请求小于可用资源”就放行这是错的。/* 安全性检查work 初始化为 available找能跑完的进程并回收其资源 */ int safety_check(int need[][M], int alloc[][M], int avail[], int n, int safe_seq[]) { int work[M], finish[MAX]; for (int j 0; j M; j) work[j] avail[j]; for (int i 0; i n; i) finish[i] 0; int cnt 0; while (cnt n) { int found -1; for (int i 0; i n; i) { if (finish[i]) continue; int ok 1; for (int j 0; j M; j) if (need[i][j] work[j]) { ok 0; break; } if (ok) { found i; break; } } if (found -1) return 0; /* 找不到可完成进程不安全 */ for (int j 0; j M; j) work[j] alloc[found][j]; finish[found] 1; safe_seq[cnt] found; } return 1; }请求试探的正确流程是先检查request[j] need[i][j]再检查request[j] available[j]然后做一次假分配available - request、alloc request、need - request调用safety_check安全就保留不安全就把三个数组回滚。回滚这一步漏掉后面所有判断都建立在错误状态上而且程序不会崩只是结论错属于最隐蔽的一类 bug。M是资源种类数课设里常见是 3。safe_seq输出的是一个安全序列注意它不唯一只要找到一个即可。测试时可以用教材上的经典例子验证如果算出来的安全序列和教材不同但同样合法不要急着改代码先确认每一步work的变化是否都满足条件。5. 避坑与排查五个让人连夜返工的细节课设代码跑通只是开始能不能在答辩现场稳定复现才是关键。下面五条是我见过、也自己踩过的按“现象、原因、解决”写清楚。第一条同一组数据两次运行结果不一样。现象是调度平均周转时间每次差一点或者页面置换命中率浮动。原因通常是遍历顺序依赖了未排序的输入或者用了rand()生成访问序列却没固定种子。解决方式是把所有输入排序后再处理随机序列统一用srand(20240101)这种固定种子并在报告里注明种子值。可复现性比“看起来随机”重要得多。第二条程序在小用例上正常进程数一多就输出乱码或段错误。现象是数组越界但编译期不报错。原因基本是#define MAX开小了或者读取时没检查n max_n。解决方式是所有for循环边界统一用实际读入的n读函数返回前打印一句loaded %d procs一眼就能看出是不是读多了。第三条信号量版本的生产者消费者偶尔死锁。现象是程序跑到一半卡住CtrlC才退出。原因是 P 操作的加锁顺序不一致或者sem_wait里用了if而不是while导致计数被抢。解决办法是把所有同步原语的获取顺序固定成“先资源信号量、后互斥锁”释放时反过来并在pthread_cond_wait外层强制用while复查条件。第四条LRU 和 FIFO 结果完全一样。现象是两个算法跑出来缺页次数一模一样。原因通常是last_access字段在命中时忘了更新导致它和load_time恒等LRU 退化成了 FIFO。排查方法是在命中分支里加一句临时打印确认last_access确实在变。这个坑不打印很难发现因为结果“看起来合理”。第五条银行家算法把不安全状态判成安全。现象是某个请求明明会导致死锁程序却输出“可以分配”。原因多半是试探分配后没有回滚或者safety_check里work数组没有在每次调用时重新初始化为available。解决办法是把试探分配和回滚封装成一对函数中间只夹一次safety_check调用结构上就不给漏写的机会。注意以上五条里前三条属于“代码写错”后两条属于“逻辑写错但程序不报错”。后者更危险因为它们不会让程序崩溃只会让结论悄悄错掉答辩时被追问一句就露馅。6. 从“交作业”到“能讲清楚”一张自检表和一次复现代码能跑、数据能对最后一步是把结果整理成别人能复现的形式。我一般会在工程根目录放一个run_all.sh把四组实验串起来输出统一落到out/下#!/usr/bin/env bash # 一键复现编译 跑四组实验结果分别落盘 set -e make clean make CCgcc CFLAGS-O2 -Wall -stdc11 all mkdir -p out ./bin/sched tests/sched_case1.txt 4 out/sched_rr_q4.txt ./bin/sched tests/sched_case1.txt 1 out/sched_rr_q1.txt ./bin/page tests/ref_string1.txt 3 out/page_3frames.txt ./bin/banker tests/banker_case1.txt out/banker_result.txt # 汇总关键指标方便直接贴进报告 grep -h avg_turnaround\|avg_wait\|page_faults\|safe out/*.txtset -e保证任何一步失败就停不会拿着半截结果往下跑。时间片分别取 4 和 1 跑两遍是为了在报告里给出对比页框数从 3 开始逐次加一能画出命中率随页框数变化的曲线这条曲线比单点数值更能说明问题。自检表建议按这个顺序过一遍每项都对应一个具体文件或输出检查项看哪里合格标准编译零警告make输出没有warning:行输入读取正确读函数返回的进程数与用例行数一致统计口径统一calc_metrics调用点所有算法走同一个函数置换下界成立OPT 缺页次数不高于 FIFO 与 LRU不安全请求被拒银行家算法输出回滚后状态与请求前一致一个具体的技巧把每次运行的命令行、输入文件名、关键参数和输出摘要一起写进out/README.txt。答辩时老师随手点一个结果你能立刻说出它是哪条命令跑出来的这比任何 PPT 都有说服力。我自己的教训是早些年赶课设改完 LRU 直接看缺页次数“差不多”就交了结果被问“为什么你的 LRU 和 FIFO 差这么少”当场翻代码才发现last_access只在换页时更新、命中时没动。从那以后我每次改完置换逻辑都强制走一遍“命中分支打印 小序列手算对照”确认命中路径真的被执行到。希望帮到你。本文还有配套的精品资源点击获取