简介:操作系统课程配套实验源码包,面向高校计算机专业学生、Linux系统学习者及备考者,聚焦进程管理、存储器管理、设备管理与文件系统四大核心模块。资源共32个文件,以C/C++源代码为主体,含20个头文件、11个C源文件与1个C++文件,压缩包整体15.42MB,代码结构清晰、模块划分明确,便于按实验章节逐一对照学习。已有1063人学习下载,属于操作系统实验教学领域的高频参考资料。包内覆盖进程的软中断通信与管道通信、Linux存储器管理、字符设备驱动程序,以及一级文件系统的设计与实现等典型实验,源码可直接编译运行,也可作为课程设计或考研复试的复习素材。通过研读这些代码,读者能更直观地理解操作系统底层机制,掌握Linux环境下进程调度、内存分配、设备驱动及文件系统构建的完整思路。
1. 计算机操作系统实验指导:不只是一本习题集,而是一套落地路线图
先给结论:拿《计算机操作系统实验指导(第3版)》当期末复习提纲来读的人,基本浪费掉它一半的价值。我带操作系统课程设计这些年,看得最多的翻车现场,不是概念背不熟,而是实验环境跑到一半崩了、日志打不出来、报告里贴的代码编译不过。这本指导书做的事,是把操作系统原理里进程调度、同步互斥、内存映射、文件系统这些“看不见”的思想,压成一台真实机器上能编译、能运行、能观察的“看得见”的实验。它会带你从fork()开始,一路走到调度器、页表、磁盘调度这些硬话题。适合三类人:在校学生要交实验报告,要过的不是背概念而是跑代码;备考复试的人要用最短时间把核心算法过成代码;转岗或补底层知识的一线开发者,需要一个不用绕路的学习路线。这本书真正的价值,恰恰在于用它做引子,把原理变成你自己的产物。这里没有新概念,只有你把概念落地后会遇到的隐性裂缝。
2. 把实验环境立起来:虚拟机、双系统和 WSL2 的选型与最小配置
2.1 先配环境还是先敲代码?别在第一步选错路
操作系统实验和普通算法实验最大的不同点,是它的代码大多直接操作系统调用、信号和中断。你在 Windows 上写 C 语言,#include <unistd.h>都过不了编译。所以第一件事不是在 IDE 里建项目,而是选一个“能让你看到 Linux 内核接口”的运行环境。实验指导第 3 版里的操作对象一般围绕 POSIX 接口展开,常见做法是用 Ubuntu 22.04 LTS 做主力系统。这里给你三条路:物理机装双系统、虚拟机装 Linux、以及 WSL2。双系统的好处是性能最接近真实内核,坏处是一旦切换系统就需要重启,打断实验思路;虚拟机的隔离性最好,快照功能等于给你吃了后悔药;WSL2 的启动速度最快,但实时性受限,碰到时钟中断、驱动相关的实验会翻车。对大多数人,我推荐虚拟机起步,等真到性能瓶颈再切双系统。教材里的实验代码量不大,虚拟机那点性能损耗几乎感知不到,反而换来“坏了能恢复”的安全感。
2.2 最小配置:用 VirtualBox 建一台实验机的完整步骤
这里以 VirtualBox 配合 Ubuntu 22.04 为例。安装过程不做细讲,关键是几个参数,直接避坑:
| 资源 | 建议值 | 理由 |
|---|---|---|
| 内存 | 不低于 4 GB | 实验代码不大,但 gcc 编译多进程时会吃内存 |
| CPU 核数 | 2 核或以上 | 线程同步实验需要真实多核才能看到竞争 |
| 硬盘 | 20 GB 动态分配 | 快照和编译中间文件会膨胀 |
| 显存 | 128 MB | 图形界面卡顿会影响操作体验 |
装完之后,先做两件事:安装增强工具,把共享剪贴板和共享文件夹打开;然后换国内镜像源,把 apt 的软件源地址换成阿里云或清华的镜像,否则装 gcc、build-essential 会慢得让你怀疑人生。然后跑这个命令验证环境:
sudo apt update && sudo apt upgrade -y sudo apt install -y build-essential gcc g++ make git uname -a cat /etc/os-release第一行更新软件源索引,第二行装的是编译工具链,第三行uname -a用来确认内核版本。为什么要装 build-essential?因为实验指导里那些.c文件多数没有 IDE 工程文件,你必须在命令行用手写 makefile 或一条 gcc 命令编译,编译器、链接器和 make 都是刚需。装完之后建议你立刻拍一张快照,名称写baseline-after-install。后面实验环境搞坏了直接恢复,不必从头再来。这个习惯后的价值比任何技巧都大。如果你用的是 openEuler、银河麒麟这类国产发行版,命令层面也差不多,只是包管理器变成 dnf 或 yum,装包的写法要跟着变,核心的 gcc、make 概念完全一样。
2.3 WSL2 的边界:哪些实验它扛得住,哪些不能
WSL2 是有自己的位置的。它启动快、磁盘占用低,做进程实验、写 fork 同步完全够用。但问题出在三处:第一,WSL2 跑在 Hyper-V 虚拟化层上,时钟精度和调度实时性和真机有差异,统计调度时间片时数据会飘,而且不稳;第二,一些需要直接操作设备文件或读内核日志的实验,比如dmesg、/dev/tty,经常会发生权限不足或设备不存在;第三,内核模块加载受限,想实验自定义内核模块基本是玄学,看运气。所以我的建议是:用 WSL2 快速验证代码逻辑可以,但涉及“系统调用转发级别”的实验,或者实验指导里明确要求看内核日志的,还是老老实实回虚拟机。你完全可以在 WSL2 里先写完并调试大部分 C 代码,最后在虚拟机上再跑一遍拿正式数据。这样既享受了启动速度,又保证报告数据的来源经得起追问。
3. 进程与线程实验:从 fork 系统调用到调度日志的完整链路
3.1 指导书里的实验到底在训练什么?先讲透目标
进程这一章,指导书绝对不会只让你背 fork 的返回值。它真正检验的是三件事:第一,你懂不懂“进程是资源分配单位”这句话在代码里的表现;第二,你会不会用wait()和exit()管理父子进程的生命周期;第三,你能不能把一个调度算法从原理变成可观察的调度日志。如果你只写一个 printf 观察 pid 打印顺序,那这个实验只能拿及格分。拉开差距的是在调度器里插入日志,画出时间线。这需要你先理解:Linux 的调度时间片,不是你在代码里随便usleep一个值就等价于内核时间片的。实验调度器通常是模拟层,模拟调度器无法直接干预真实 CPU 调度,所以实验指导的做法一般是让你在用户态实现一个“模拟调度核心”,用线程或进程队列来跑算法,再用真实时钟函数打时间戳。这一点想明白了,整个实验的代码结构就清晰了。
3.2 fork、wait 与 exec:最小可复现代码与参数说明
先给一段最小却完整的代码,覆盖三个核心系统调用:
#include <stdio.h> #include <stdlib.h> #include <unistd.h> #include <sys/wait.h> int main() { pid_t pid = fork(); if (pid < 0) { perror("fork failed"); exit(1); } else if (pid == 0) { printf("[child] pid=%d, parent=%d\n", getpid(), getppid()); execl("/bin/echo", "echo", "child exec done", NULL); perror("execl failed"); // 只有 exec 失败才会执行到这一行 exit(1); } else { printf("[parent] pid=%d, child=%d\n", getpid(), pid); int status; waitpid(pid, &status, 0); if (WIFEXITED(status)) { printf("[parent] child exit code=%d\n", WEXITSTATUS(status)); } } return 0; }这段代码里最容易被忽略的是execl后面的perror那行。如果 execl 成功,它会被新程序映像替换,永远执行不到。一旦你看到“execl failed”,说明路径写错了或者目标文件没有执行权限。而waitpid的status宏——WIFEXITED和WEXITSTATUS——是检查子进程退出原因的标准方式。实验报告里问“孤儿进程怎么产生”,你就可以改这段代码,让父进程直接 return,不给子进程 wait,然后看子进程的 ppid 会不会变成 1。这种一行代码的改动,远比解释有说服力。
3.3 让调度器“开口说话”:如何打时间戳,验证时间片轮转
下一个层级就是把理论调度算法和代码挂钩。用 pthread 模拟三个进程,给每个线程记录运行起止时间:
#include <stdio.h> #include <pthread.h> #include <time.h> #include <unistd.h> #define PROCESS_NUM 3 typedef struct { int id; struct timespec start, end; } proc_info; void* worker(void* arg) { proc_info* info = (proc_info*)arg; clock_gettime(CLOCK_MONOTONIC, &info->start); for (volatile int i = 0; i < 100000000; i++); // 模拟计算 clock_gettime(CLOCK_MONOTONIC, &info->end); return NULL; } int main() { pthread_t tids[PROCESS_NUM]; proc_info infos[PROCESS_NUM]; for (int i = 0; i < PROCESS_NUM; i++) { infos[i].id = i; pthread_create(&tids[i], NULL, worker, &infos[i]); } for (int i = 0; i < PROCESS_NUM; i++) { pthread_join(tids[i], NULL); long ms = (infos[i].end.tv_sec - infos[i].start.tv_sec) * 1000 + (infos[i].end.tv_nsec - infos[i].start.tv_nsec) / 1000000; printf("thread %d ran for %ld ms\n", i, ms); } return 0; }这里用CLOCK_MONOTONIC而不是gettimeofday,是因为单调时钟不会因用户改系统时间而跳变,统计运行时长时数据更稳。volatile空循环是为了让编译器不要优化掉计算块。如果你写for (int i=0; i<100000000; i++);,开了-O2之后编译器可能直接把整个循环优化没了,线程秒退,实验现象直接消失。这不是玄学,是编译优化规则。调度器实验报告里的关键,不是运行时间多少毫秒,而是时间戳的采集方式是否可复现。把你打日志的代码也贴到报告里,读者一眼就知道你理解原理。
4. 内存与文件系统实验:地址换算和磁盘调度参数怎么才算“做通了”
4.1 虚拟地址换算:先手工算,再上代码验证
内存管理实验里最容易劝退的就是地址变换。很多同学一上来就贴一个malloc和free就觉得完事,但这跟操作系统的内存管理没关系,那是 libc 的堆管理。指导书里要你做的应该是模拟一个页式地址转换:给定逻辑地址、页表,算出物理地址。手工计算的方法是:逻辑地址addr,页大小page_size,页号p = addr / page_size,页内偏移w = addr % page_size。然后查页表得到物理块号f,物理地址 =f * page_size + w。这是个纯算术题,但实验真正练的是把它写成通用函数:
#include <stdio.h> #include <stdint.h> int translate(uint32_t logical_addr, uint32_t page_size, const uint32_t* page_table, int table_size, uint32_t* phys_addr) { uint32_t p = logical_addr / page_size; uint32_t w = logical_addr % page_size; if (p >= table_size) return -1; // 缺页 uint32_t f = page_table[p]; if (f == 0xFFFFFFFF) return -1; // 页未加载 *phys_addr = f * page_size + w; return 0; } int main() { uint32_t pt[4] = {2, 5, 0xFFFFFFFF, 9}; uint32_t out; if (translate(0x1234, 4096, pt, 4, &out) == 0) { printf("phys=0x%x\n", out); } else { printf("page fault\n"); } return 0; }这个实现里我故意用0xFFFFFFFF表示页未加载,模拟缺页的入口。实验中你还可以把访问位、修改位、有效位放进页表项的位域里,那就是 TLB 和多级页表的扩展方向。这里有两个大家常写错的点:第一,页内偏移是直接用逻辑地址整除取余得到,不能右移位;第二,页表大小必须传进来,否则越界溢出就是未定义行为。实验报告里,把你手工算的那一组结果粘进去,再贴这段代码跑出来的输出作对照,两者一致才能证明代码是对的。只有代码没有手工过程,或只有手工过程没有代码验证,都不算完整。
4.2 实现一个 SCAN/C-SCAN 磁盘调度器:从算法到带参数的代码
磁盘调度实验是典型的“看起来简单、做起来烦”的类型。SCAN 算法也叫电梯算法,核心是维护一个移动方向,向一个方向的请求服务完之后再反向。C-SCAN 则是单向服务完直接回到起始端。如果你写代码时不把“当前磁头位置”和“请求队列”分开,后面报表统计吞吐时就乱套。下面是一个只保留核心逻辑的简化实现:
#include <stdio.h> #include <stdlib.h> #define REQUESTS 8 int cmp(const void* a, const void* b) { return (*(int*)a) - (*(int*)b); } void scan(int req[], int n, int start, int direction) { int sorted[REQUESTS]; for (int i = 0; i < n; i++) sorted[i] = req[i]; qsort(sorted, n, sizeof(int), cmp); int total = 0, pos = start; printf("SCAN start=%d direction=%d\n", start, direction); if (direction == 1) { for (int i = 0; i < n; i++) { if (sorted[i] >= pos) { total += sorted[i] - pos; pos = sorted[i]; printf("visit %d, seek=%d\n", pos, total); } } for (int i = n - 1; i >= 0; i--) { if (sorted[i] < pos) { total += pos - sorted[i]; pos = sorted[i]; printf("visit %d, seek=%d\n", pos, total); } } } // direction == -1 的对称逻辑:先向下,再向上 printf("total seek: %d\n", total); } int main() { int req[] = {98, 183, 37, 122, 14, 124, 65, 67}; scan(req, 8, 53, 1); return 0; }这里的qsort排序后,关键的坑就出现了:直接在所有请求有序的前提下判方向,导致每个请求最多访问两次。现实中扫描到最内或最外磁道才回头,如果磁头在最内轨,方向上已经没有请求,这就到了你该处理的边界。完善版本要把磁头可移动范围当参数传进去,比如head_range_end,超出边界立即反向。实验报告里,你应该把direction参数、磁头起始位置、队列长度都写进输入数据表,再给一张输出表。只给一个运行截图是不够的。SCAN 和 C-SCAN 的差异,只有在队列两侧不对称时才会体现出来,所以你至少得设计两组输入,一组请求集中在磁头一侧,一组分布在两侧,才能讲清楚两种算法的区别。
4.3 把文件系统“打开看”:inode 与目录项的实验观察
文件系统实验一般有两种风格:一种是写一个模拟文件系统,用数组模拟磁盘块;另一种是真在 Linux 下用stat和opendir观察真实文件系统的行为,第二种的现实感强得多。例如在 Linux 下写一个 C 程序调用stat()读取文件元信息,再对比目录项里的文件名和 inode 号:
#include <stdio.h> #include <sys/stat.h> #include <time.h> int main() { struct stat st; if (stat("/etc/hosts", &st) != 0) { perror("stat"); return 1; } printf("inode=%lu size=%ld blocks=%ld\n", st.st_ino, st.st_size, st.st_blocks); printf("mtime=%s", ctime(&st.st_mtime)); return 0; }这里输出的st_blocks是 512 字节磁盘块数量,不是st_size。很多同学把这两个值混成一谈,报告里写“块大小是 st_size”,这说明从来没真的跑过实验。你还可以打开目录,读取目录项,跟stat对比 inode 号,就能直观看到“目录项映射文件名到 inode”的链路。如果实验要求深入 ext2 文件系统,那会用到dd和debugfs。但这章的思路是一样的:先用系统调用观察真实文件系统,再向里深入。内核看到的是什么,和你代码里看到的是什么,两个对上号,这个实验才算做通。
5. 实验避坑:环境变量、编译选项与并发冲突的常见问题
5.1 编译过了,一运行就Segmentation fault
现象:源代码在指导书里看起来天衣无缝,gcc 编译无报错,但运行到一半直接Segmentation fault (core dumped)。原因往往是三类:数组越界、野指针、栈溢出。在操作系统实验里,最常见的是页表或位图数组用了固定长度,但实际传参时访问了下标越界。解决:先用编译器开启调试信息,再用工具链排查:
gcc -g -fsanitize=address -o exp exp.c ./exp-fsanitize=address是 AddressSanitizer,会在越界访问发生时直接打印是哪一行代码出了问题,省去几十行 printf 的排查黑匣子时间。如果输出显示heap-buffer-overflow,立刻去看数组长度与索引关系。如果只是常规排查,用 gdb 跑gdb ./exp core,再执行bt看栈回溯。这类崩溃问题大多不是玄学,是“地址计算到底谁负责”没想清楚。
5.2fork()之后父子进程打印顺序完全随机,实验报告怎么解释
现象:代码里写了先printf("[parent]...")再printf("[child]..."),期望先父后子,结果运行多次输出顺序不同。原因:fork 成功后,父子进程进入就绪队列的先后不由代码决定,由内核调度器决定,你不做同步,打印顺序天然是竞争的。解决:如果实验目的就是要观察竞争,那报告里直接说明“这一点证明了调度不确定性”;如果实验目的是固定协作顺序,就必须引入同步原语:
int fd[2]; pipe(fd); // 子进程 write(fd[1], "go", 1); 父进程 read(fd[0], ...) 后再打印用管道做握手同步,是最原生、最贴合操作系统原语的做法。用sleep(1)反而最容易把实验做坏,因为它掩盖了真实调度,还引入了无必要的时间依赖,也体现不出你对进程同步的理解。
5.3 线程同步实验里“卡死”在某个等待点
现象:生产者消费者实验,跑了不到几秒钟就卡死,打印停在“waiting to produce”。原因:最常见的是条件变量 signal 时没有配合互斥锁使用,或者pthread_cond_wait在等之前就已经释放了锁。还有一种提交是把队列判空的逻辑放在锁外,导致读到旧数据。解决:把锁边界画出来,在pthread_cond_wait调用前后都打印当前锁状态。注意,pthread_cond_signal是唤醒一个等待者,如果多个消费者同时等在同一个条件变量上,而你只发了一个 signal,没有 while 循环复查条件,就会漏掉唤醒。教科书说要写成while (count == 0) pthread_cond_wait(...),就是为了防止“虚假唤醒”。删掉while改成if,很多“神秘卡死”的根本原因就在这里。
5.4 虚拟机时钟不稳,调度实验的时间数据漂移
现象:在 VirtualBox 里跑调度实验,统计出来的时间片要么特别长,要么跳变,重启后数据也复现不了。原因:宿主机负载、虚拟机时钟虚拟化偏差、实验代码又用了CLOCK_REALTIME。解决:统一用CLOCK_MONOTONIC采集耗时;不要在虚拟机里开启“同步宿主机时间”的选项;实验报告里明确注明“结果在虚拟机内测量,绝对数值仅用于横向比较”。如果你对比不同调度算法的运行数据,要保证在同一环境、同一负载条件下跑多次取中位数,而不是拿第一次跑的数直接写报告。调度计时本身就是实验对象,计时方式写不清,整份报告的可信度都会打折扣。
5.5 实验报告贴了代码,评审却认定你没跑过
现象:报告贴了 include、主函数、运行截图,但老师问一个输出细节,你答不上来。原因:贴代码不算证据,没有“现场痕迹”。解决:跑完实验后保存一份原始 shell 日志(script命令)和一份 CSV 格式的输出数据;在报告里贴出你生成的带时间戳的调度日志。如果用的是模拟实验,把你传入的具体输入参数,比如起始磁头位置、请求队列、页表内容,都写清楚。把“发生了什么”变成“可复核的记录”,这一条做到位,报告的质量会直接拉开一个档次。很多翻车现场,不是实验没做,是没留下“做过的痕迹”。
6. 把实验报告写成评审愿意信的“证据链”
实验做完,最后一步是把过程凝练成报告,但这步经常被低估。我的做法是,给每章实验准备三个固定产出:环境清单、关键日志、参数表格。硬件和软件环境是实验的前提;运行时的原始日志是黑匣子里的记录,原始输出是白盒;参数表格用来覆盖不同实例下算法表现,避免“只会跑一个用例”的质疑。
以磁盘调度实验为例,我最后会在报告里附一张表:
| 输入队列 | 起始磁头 | 算法 | 总寻道距离 | 平均等待 |
|---|---|---|---|---|
| [98, 183, 37, 122, 14] | 53 | SCAN | 236 | 按实际计算 |
| [98, 183, 37, 122, 14] | 53 | C-SCAN | 236 | 按实际计算 |
| [14, 65, 67, 98, 122] | 53 | SSTF | 连续取最近值 | 按实际计算 |
这张表的意义,是证明“算法有两个以上用例的对比”。评审最怕的就是只有一个用例且无边界条件。你再进一步做“验证性实验”——调大请求队列到 100 个、把磁头范围调到 0~199,观察寻道距离是否按预期增长。如果增长了,说明你的实现是符合定义的;如果没增长,说明你算法里藏着边界 bug,这时候回头查代码才是对自己负责。
我再分享一个习惯:每跑完一次实验,立刻用script命令存一份终端会话日志。遇到实验中途改代码,旧日志也不要删,保存成v1.log、v2.log。这不仅是为了报告完整性,更能在期末复习时迅速回忆整个思考过程:哪个参数让你从 F 改到 C,为什么。操作系统实验真正的收获,不是那几个标准算法代码,而是“我给你一台裸机,你怎么把原理落地成一个能跑、能测、能看的系统”的工程判断力。希望这个工作流能帮你少走一些弯路,也希望那些报告里打出来的日志,不只是应付老师,而是成为你真正理解内核接口的证据。
本文还有配套的精品资源,点击获取