简介:面向高校操作系统课程学习的实验代码资料包,主要针对计算机专业本科生及操作系统自学入门者。内容覆盖杭电操作系统实验中的多个经典模块,包含多组实验代码与不同版本实现,其中可见简单文件系统相关源码,便于理解底层原理并对照实验要求进行调试与分析。压缩包内含28个文件,以C源码为主(12个.c、4个.h),另有txt说明文档、makefile构建脚本及main入口文件,资源总大小仅56KB,轻便易取;txt文档帮助梳理实验要点,makefile简化编译流程,按实验目录组织,便于快速定位目标模块。目前已有244人学习浏览,适合缺乏实验参考或想完善实验报告的学生。借助该资料,读者可获得完整可运行的实验实现、清晰的目录结构和多种实验版本思路,用于自查编码规范、复习关键考点或进行二次开发实验。
1. 操作系统实验:一门用 C 语言还债的课程
先从这个标题说起。所谓“HDU操作系统实验”,是某高校(HDU)操作系统课程里一届届学生传下来的实验资源包,最终打包成 zip 分发。很多人以为这是“背背 PV 操作、看看页面置换就交差”的课,真正打开那个 zip 才发现,五个实验要交的可执行程序和实验报告,底层全是 C 语言、Makefile、信号量和调度算法。我的看法是:这套实验的及格线是“能跑出对比数据”,优秀线是“能解释清楚为什么这个算法比那个算法快”。适合正在被操作系统实验折磨的在校生、想补系统底层知识的后端开发者,也适合准备面试前拿真实算法练手的人。这篇不会带你逐行读某个人的源码,而是沿着这套实验最常见的组织方式,把环境准备、进程与调度、同步与死锁、踩坑排查、验证技巧一次讲透。
2. 搭好实验工程:为什么必须用 C 和 Makefile
2.1 实验环境选型:Linux + GCC 的理由与边界
先定环境。操作系统实验里要求的 fork、信号量、waitpid 这些接口,在 Windows 的 C 运行时上要么不存在、要么行为差异很大,所以各高校的验收机器普遍是某个 Linux 发行版配 GCC 和 make。我一般会在项目根目录放一份环境清单,gcc 版本、make 版本、内核版本都写进去,因为不同版本下栈大小、信号量默认行为会有细微差别,记录到实验报告里,老师追问运行环境时也少一轮来回。
关键建议是千万别用 IDE 的图形按钮来编译。命令行 gcc + Makefile 有三个不可替代的好处:一是 make 按依赖关系只重编改过的文件,调度器改一个函数就全盘重编会浪费大量时间;二是提交的是一个工程目录,Makefile 是验收时最熟悉的构建入口;三是后面写自动对比脚本时,直接调 make 就好收标准输出。实验环境边界要记住两点:互斥锁和线程的编译选项、POSIX 信号量需要的链接库,都要在 Makefile 里显式声明,不能靠 IDE 悄悄替你补。
2.2 工程目录与 Makefile:一次编译全部实验
我习惯的实验工程目录长这样:
ostl/ ├── Makefile ├── include/ │ ├── comm.h │ └── pcb.h ├── src/ │ ├── proc.c │ ├── sched.c │ ├── pv.c │ └── banker.c ├── data/ │ ├── sched_input.txt │ └── deadlock_case.txt └── bin/Makefile 需要覆盖三个使用场景:单独编某个实验、全部编译、清理。下面这个写法在多数课程里都能直接抄,注释里标了每个变量的用途:
CC = gcc CFLAGS = -Wall -Wextra -g -Iinclude LIB = -lpthread -lrt TARGETS = bin/proc bin/sched bin/pv bin/banker all: $(TARGETS) bin/proc: src/proc.c include/comm.h $(CC) $(CFLAGS) -o $@ src/proc.c $(LIB) bin/sched: src/sched.c include/pcb.h include/comm.h $(CC) $(CFLAGS) -o $@ src/sched.c $(LIB) bin/pv: src/pv.c include/comm.h $(CC) $(CFLAGS) -o $@ src/pv.c $(LIB) bin/banker: src/banker.c include/comm.h $(CC) $(CFLAGS) -o $@ src/banker.c $(LIB) clean: rm -f $(TARGETS) src/*.o这里的$@指目标名本身,-Iinclude让编译器去 include 目录找头文件,-g是为了后面用 gdb 查段错误。-lpthread和-lrt是给线程和信号量用的,不做 PV 实验时删掉也不影响编译;但只要你用了sem_open这类实时信号量接口,-lrt就必须留,这是很多人编译报错后反复查才想起的点。每个实验单独写一条规则而不是写一个通配规则,是因为依赖头文件不同,写清楚依赖后,改 comm.h 只触发相关文件重编,spinner 和 scanf 界面那套花活不属于实验,省下的等待时间非常可观。
注意:GCC 链接时库要放在引用它的目标文件之后。如果写成
$(CC) $(CFLAGS) $(LIB) -o $@ src/proc.c,静态链接阶段会因顺序问题报 undefined reference,这是一条必踩的链接坑。
2.3 公共头文件与日志宏:让多个实验的输出格式统一
多个实验共用一个日志模块,后面的对比脚本才能写。我在 include/comm.h 里放了一套最简单的日志宏,诊断信息统一走 stderr:
#ifndef COMM_H #define COMM_H #include <stdio.h> #include <stdlib.h> #include <string.h> #define LOG(level, fmt, ...) \ fprintf(stderr, "[%s] " fmt "\n", level, ##__VA_ARGS__) #define INFO(...) LOG("INFO", __VA_ARGS__) #define WARN(...) LOG("WARN", __VA_ARGS__) #define ERROR(...) LOG("ERROR", __VA_ARGS__) #endif日志走 stderr 而不是 stdout 是有意为之:实验结果数据走 stdout 进管道,诊断信息走 stderr 就不会混进去,后面用 awk 统计结果时少一层过滤。##__VA_ARGS__是 GNU 扩展,允许不传额外参数时自动去掉逗号。这个宏在 proc、pv、banker 三个实验里直接复用,实验报告里写一句“日志模块统一”,验收观感也好。有一个地方要特别留意:你要是把 LOG 的 stderr 改成 stdout,验证脚本抓结果时会多出一堆 INFO 行,排序和平均周转时间全被打乱,属于改一行坑全局的典型。
3. 进程与调度实验:从 fork 到 FCFS/SJF/时间片轮转
3.1 用 fork 和 wait 验证进程语义
进程管理第一课是写一个程序,让父进程依次创建 N 个子进程,每个子进程打印自己的 PID 和父进程 PID,然后按创建顺序回收。这个实验看起来简单,实际是在验证“进程是资源分配的最小单位”——fork 后父子各有独立地址空间,变量修改互不可见,子进程不是函数调用。
#include <stdio.h> #include <unistd.h> #include <sys/wait.h> #define N 4 int main(void) { pid_t pids[N]; for (int i = 0; i < N; i++) { pids[i] = fork(); if (pids[i] < 0) { perror("fork"); exit(1); } if (pids[i] == 0) { printf("child pid=%d parent=%d index=%d\n", getpid(), getppid(), i); _exit(0); } } for (int i = 0; i < N; i++) { waitpid(pids[i], NULL, 0); printf("reaped pid=%d\n", pids[i]); } return 0; }子进程里用_exit(0)而不是exit(0),这是个细节:exit 会刷新 stdio 缓冲区,而 fork 时子进程复制了父进程的缓冲区,多个子进程同时刷新可能把同一段数据打出多份;_exit直接进内核,行为干净。父进程用 waitpid 按 pid 精确回收,避免 wait 的“任意回收”顺序和创建顺序错位。参数调整主要在 N 值,N 设到 64 以上可以观察并发调度现象:子进程的打印顺序不固定,那不是代码错,是内核调度顺序不确定,实验报告里写上这句,比只贴运行结果更有价值。
3.2 用结构体实现 PCB 与就绪队列:FCFS/SJF 的调度内核
调度实验的核心是定义进程控制块 PCB 并实现调度算法。PCB 最小字段是:到达时间、服务时间、开始时间、完成时间、剩余时间,外加一个进程号。FCFS、SJF、时间片轮转共用同一个 PCB,算法差异只在“从就绪队列里挑谁”和“是否抢占”两点。
typedef struct { int pid; int arrive; int serve; int remain; // 时间片轮转用 int start; int finish; int wait; int seq; // 排序稳定性键 } pcb_t;就绪队列我直接用数组加排序解决,不开链式队列,因为数据量只有几十个进程,排序比指针操作直观,日志也好打印。FCFS 先按到达时间稳定排序,然后依次执行;SJF 每次从“当前已到达且未完成”的进程里挑服务时间最短的:
int pick_next(pcb_t *p, int n, int cur_time, int *done) { int best = -1; for (int i = 0; i < n; i++) { if (done[i]) continue; if (p[i].arrive > cur_time) continue; if (best == -1 || p[i].serve < p[best].serve) { best = i; } } return best; }这个函数的意思是:只要还有进程未完成、且到达时间已经小于等于当前时刻,就挑服务时间最短的那个作为下一步执行对象。如果 best 返回 -1,说明此刻没有进程到达,调度主循环要把时间直接快进到下一个到达时刻,这个“快进”逻辑必须单独写,否则就是死循环。上面这段是非抢占式 SJF,选中就让它跑完服务时间再回到 pick_next;要扩展成最短剩余时间优先 SRTF,把比较字段从 serve 换成 remain 即可,其余逻辑不动。
主循环里记录每个进程的开始时间、完成时间,最后计算平均周转时间、平均带权周转时间。输出每行固定为pid arrive serve finish turnaround weight_turnaround五个字段,和后面的自检脚本配合时不需要额外解析。
3.3 时间片轮转的参数选择与排序稳定坑
时间片轮转在同一个 PCB 上加 remain 和排队逻辑。RR 的关键参数是时间片 q,我试过 q=1、q=2、q=4 三组数据,q 越小短任务响应越快,但上下文切换次数变大,总完成时间变长;q 越大越接近 FCFS。实验报告里放一张三组 q 的对比表,结论就是书上那句“时间片应当比一次上下文切换开销大得多”。
实现时用“当前时刻 t,把所有 arrive <= t 且未完成的进程按顺序排进队列,每轮从队头取一个进程执行 min(q, remain),执行完若 remain>0 重新挂到队尾”。这个思路用数组加头尾游标就能做,不一定要真链表。
这里有个排序稳定坑特别值得记:C 的 qsort 不稳定。当多个进程同时到达且服务时间相同时,排序后的相对次序可能和输入顺序不一致,FCFS 的结果就对不上手算数据。解决办法是 PCB 里的 seq 字段,比较时先比 arrive,arrive 相同比 seq:
int cmp_pcb(const void *a, const void *b) { const pcb_t *x = a, *y = b; if (x->arrive != y->arrive) return x->arrive - y->arrive; return x->seq - y->seq; }这个改法五行内搞定,能避免实验报告里最尴尬的“运行结果和手算顺序不同”。输入数据格式我用每行四个整数:进程号、到达时间、服务时间、优先级。优先级字段留给优先级调度算法扩展用,不做就先填 0,同时保证比较函数里不要因为优先级而改变次序。
4. 同步互斥与死锁实验:信号量、银行家算法与数据构造
4.1 用 POSIX 信号量实现生产者-消费者
同步实验首选生产者-消费者模型,吞吐量指标直观,而且信号量接口能完整覆盖 sem_wait/sem_post 两个核心原语。我用无名信号量加多线程,缓冲区大小 BUF_SIZE=5,生产者和消费者各 N 个线程:
#include <pthread.h> #include <semaphore.h> #define BUF_SIZE 5 #define PRODUCERS 3 #define CONSUMERS 3 #define ITEMS 100 static sem_t empty, full, mutex; static int buf[BUF_SIZE], in = 0, out = 0; void *producer(void *arg) { (void)arg; for (int i = 0; i < ITEMS; i++) { sem_wait(&empty); sem_wait(&mutex); buf[in] = i; in = (in + 1) % BUF_SIZE; sem_post(&mutex); sem_post(&full); printf("produce %d\n", i); } return NULL; } void *consumer(void *arg) { (void)arg; for (int i = 0; i < ITEMS; i++) { sem_wait(&full); sem_wait(&mutex); int v = buf[out]; out = (out + 1) % BUF_SIZE; sem_post(&mutex); sem_post(&empty); printf("consume %d\n", v); } return NULL; }信号量初值必须满足 empty=BUF_SIZE、full=0、mutex=1,这三个初值本身就是“有空位才能生产、有数据才能消费、同一时刻只能一个线程动缓冲区”的代码表达。循环次数设成 ITEMS 而不是 while(1),是为了保证线程能退出,避免 main 里 pthread_join 超时。环形缓冲区的 in/out 取模是边界条件里最容易被忽略的:漏掉% BUF_SIZE,数组越界后 mutex 内存被写坏,程序会表现出时好时坏的玄学。我踩过这个坑,最后用 AddressSanitizer 编译才定位到写越界。
4.2 银行家算法:安全性检测与预分配回滚
死锁实验的标准题型是银行家算法。它维护四个矩阵:进程对各类资源的最大需求 Max、已分配 Allocation、还需 Need、系统可用资源 Available。分配前做两步检查,Request 是否小于等于 Need,是否小于等于 Available;都满足就预分配,然后调用安全性检测算法判断是否存在安全序列:
int is_safe(int avail[], int max[][M], int alloc[][M], int n) { int work[M], finish[N]; memcpy(work, avail, sizeof(work)); memset(finish, 0, sizeof(finish)); for (int k = 0; k < n; k++) { int found = 0; for (int i = 0; i < n; i++) { if (finish[i]) continue; int can = 1; for (int j = 0; j < M; j++) { if (max[i][j] - alloc[i][j] > work[j]) { can = 0; break; } } if (can) { for (int j = 0; j < M; j++) work[j] += alloc[i][j]; finish[i] = 1; found = 1; printf("safe seq -> P%d\n", i); } } if (!found) return 0; } return 1; }这段实现里最关键的教学点是:安全性检测必须保证完整一轮都推进不了才返回 0,而不是中间某个进程找不到就立即判定不安全。内层遍历顺序会影响输出安全序列的具体排列,但不影响“是否存在安全序列”这个结论。Need 直接用 max-alloc 实时计算,避免单独维护一个随分配不断变化的矩阵。调用前把预分配的结果填入 alloc 和 avail,检测后如果是安全状态才真正提交这次分配,否则回滚——这就是“预分配 + 回滚”语义。实验数据必须准备“分配成功”和“分配失败”各两组样例,否则安全性检测返回 0 的分支没有被覆盖,代码等于白写。
4.3 构造演示数据:让三个实验在报告里自洽
死锁实验的数据造起来比调度实验讲究。调度实验的数据可以随手生成,银行家算法的数据必须保证 Available 接近临界值。我的做法是拿一个 5 进程 3 资源类型的样例,Available 设为 (3,3,2),Max 矩阵里有两个进程的最大需求刚好在当前 work 值附近,这样才能演示“先执行哪个进程决定了后续是否死锁”。如果所有进程的 Need 都远小于 Available,算法一路绿灯,报告里看不出安全性检测的判断逻辑。
PV 实验的数据靠线程数和 ITEMS 数控制。线程数多、产品数少,容易出现某个线程长时间抢不到锁,输出看起来像卡死,其实是线程饿死。我一般把生产者和消费者线程数都设为 3、ITEMS 设为 100,运行时间控制在百毫秒级,既能稳定复现,又不会让验收时等太久。每个线程的循环结束后打印一行“thread X done”,主线程 join 超时时能区分是死锁还是线程未退出,排查成本低一个量级。
5. 操作系统实验排查:六个让实验翻车的具体坑
这一章按“现象 → 原因 → 解决”拆开写,都是真实调试记录,照着查能少走很多弯路。
5.1 子进程里改了变量,父进程读不到
现象:子进程修改了一个全局变量,父进程打印发现还是旧值。原因:fork 后的子进程拿到的是父进程地址空间的拷贝,写时拷贝保证物理页在修改时才复制,但逻辑上父子已经分家。解决:跨进程共享数据要用管道、共享内存或信号量;如果只是要并发修改同一份数据,把进程实验改成多线程,线程才共享地址空间。这个坑在第 3 章的进程管理实验里几乎必现,属于对 fork 语义理解不到位。
5.2 链接阶段 undefined reference tosem_wait' 和pthread_create'
现象:代码编译通过,链接报 undefined reference to sem_wait、pthread_create。原因:glibc 把线程接口拆到 libpthread,POSIX 信号量实现依赖实时库 librt,链接时必须显式指定。解决:Makefile 的 LIB 里写全-lpthread -lrt,并且放在源文件之后。这条每个学期都有人翻车,最稳的写法就是第 2 章那个 Makefile 格式,直接抄不会错。
5.3 qsort 排序后输出顺序和手算不一致
现象:多个进程到达时间相同,FCFS 算出的完成时间对,但输出顺序和实验报告手算表不一致。原因:C 标准库 qsort 是不稳定排序,相等元素的相对次序不保证保持。解决:给 PCB 加 seq 字段,比较函数先比到达时间,再比 seq。这样排序结果确定性好,多次运行输出不变,后面写自动对比脚本时才不会偶发 diff。
5.4 银行家算法把“本轮找不到可推进进程”当死锁直接返回
现象:预分配后明明存在安全序列,程序却输出不安全。原因:is_safe 的实现里某一次内层循环找不到可完成进程就立刻 return 0,忽略了应该继续检查剩余未完成进程的情况。解决:每次外层循环重新扫描所有未完成进程,只有完整一轮一个都推进不了才返回不安全。同时把“本轮推进了哪个进程”的日志打出来,和手算逐行核对,定位极快。
5.5 自检脚本统计结果飘忽不定,平均值忽大忽小
现象:同一份输入跑脚本,平均周转时间每次都不同。原因:调试用的 printf 和实验数据都打在 stdout,数据行被 INFO 日志冲断,awk 统计时把日志行也算了进去。解决:日志全部走 stderr,数据行走 stdout。判断日志层是否干净很简单,终端跑./bin/sched < data/input.txt 2>/dev/null,如果数据行完好,说明 stdout 没有混入诊断信息。
5.6 段错误:环形缓冲区和调度快进的越界
现象:某个输入下程序直接 Segment Fault,生成 core 文件。解决:编译保留 -g,用 gdb 看 backtrace,重点查环形缓冲区取模和调度循环里 pick_next 返回 -1 后的处理。我遇到过的越界基本都长在“队列尾部+1”这种代码上,逻辑上躲过了检查,物理上跨进了相邻数组。AddressSanitizer 编译一次就能直接给出越界行号,比肉眼快得多。
6. 收尾技巧:写一个一键自检脚本,比实验报告更值钱
最后落地一个自动验证入口。我在每个实验目录放一个 check.sh,对所有可执行文件做冒烟测试并检查退出码。这个脚本不算课程要求,但改完代码回归一遍只要几秒,“运行结果”从此可复现:
#!/usr/bin/env bash set -euo pipefail cd "$(dirname "$0")/.." make >/dev/null run_exp() { local bin=$1 input=$2 if [[ ! -f "data/$input" ]]; then echo "skip $bin : missing $input" return 0 fi ./bin/$bin < data/$input > "out/$bin.out" 2> "out/$bin.log" echo "$bin : exit $? lines $(wc -l < "out/$bin.out")" } run_exp sched sched_input.txt run_exp pv pv_cfg.txt run_exp banker deadlock_case.txt run_exp proc proc_case.txtset -euo pipefail让脚本在管道出错时立刻停下,而不是带着脏数据继续跑。每个实验的输出落到 out 目录,日志和数据分离,后面要做对比表或图表时直接拿 out 下的文件做输入。除了通常样例,三种必查边界是:空输入文件、全部进程同时到达、故意构造的死锁样例。空输入查边界处理,同时到达查排序稳定性,死锁样例查 is_safe 的返回值与回滚逻辑。超出这三个边界仍能稳定退出,代码基本可以交。
个人习惯是每做完一个实验就把当天的 Makefile 和自检脚本复制一份按日期存档。毕业回看时,实验报告记录的是“我做了”,脚本和代码才记录“我怎么做、哪里卡住过”。当初调银行家算法调了两个晚上,最后发现不是算法错而是 Available 样例造得太宽松,从此我每写一个判断分支都先问自己:这个分支的 false 路径有没有数据能走到。这套习惯从课程带到工作,踩过的坑也都留在这些脚本旁边。希望帮到你。
本文还有配套的精品资源,点击获取