简介:面向操作系统课程实践的教学资源包,围绕 HNU_ostep 实验集展开,适合高校学生、考研复习者以及自学开发者通过动手编码理解进程管理、内存管理、文件系统与 I/O 调度等核心机制。压缩包共 2000 个文件,其中 910 个 C 源文件与 760 个头文件构成实验代码主体,覆盖进程创建与调度、虚拟内存与页面置换、文件存储与权限控制、磁盘调度等典型实验场景;61 个 Python 脚本用于自动化验证与结果分析,55 个 Markdown 文档整理实验指导,78 个 TXT 文件提供说明与笔记,46 个 Shell 脚本辅助环境配置,Makefile 与 CMake 文件管理编译构建,整体大小 42.51MB,目录结构清晰,便于按模块检索学习。已有 164 人学习下载。配套源码中包含了可修改的调度算法、内存分配策略和文件系统示例,调整参数即可直观对比不同策略对系统性能的影响;附带的教材讲义、PDF 论文与 readme 指导文件,能帮助学习者从原理到实现建立完整知识链。这份资料将理论讲授与工程实践紧密结合,既适合作为课程实验参考,也能为后续系统软件开发打下坚实基础。
1. HNU_ostep-操作系统实验:这门课到底让你做什么,以及第一周最容易卡住的地方
看到HNU_ostep-操作系统实验这个标题,做过的人第一反应是:教材ostep(Operating Systems: Three Easy Pieces)的配套项目被高校实验课接了过来,套上了一层实验框架。它不是在讲某本小说,而是一整套可运行的 C 语言实验库——实现一个简化 shell、补一个线程调度策略、写一个并发同步方案、模拟一个文件系统的分配与回收。目标很直接:你读完一章教材,立刻有对应的实验要你动手写出能跑通测试的代码。对新手来说,最容易被历史命令行项目卡住的是「实验跑起来了但测试全部失败」,因为实验框架的心智模型和平时写应用层代码完全不同:它要求你按特定数据结构补函数,而不是按业务逻辑组织代码。这篇笔记会按「先跑通最小命令 → 拆解单个实验的实现骨架 → 看懂测试与评分 → 盘点高频坑 → 提升调试效率」的顺序展开,适合第一次打开该实验仓库、想在一周内不靠抄代码把分数拿稳的读者。
2. 在本地跑通实验框架:最小命令、目录结构与评分脚本的读取方式
2.1 实验环境的选择:为什么 GNU/Linux 是默认赛道,以及虚拟机内存参数
常见做法是使用 GNU/Linux 发行版(例如 Ubuntu 桌面版或服务器版)作为实验环境。我用过 WSL 和虚拟机两种方式:WSL 启动快、路径访问方便,但网络相关的实验(例如涉及 socket 编程的 network 类项目)行为可能与标准环境有细微差异;虚拟机更接近课程评测机行为,但快照和内存分配会占资源。内存建议至少分配 2GB,swap 关闭与否没有硬性要求。编译工具链只需保证gcc、make、python3存在,不需要额外安装第三方库,因为实验项目全部基于纯 C/POSIX 接口,依赖面非常窄。
如果本地没有 Linux 环境,第一周最稳妥的做法是用虚拟机装一个最小化桌面版,装好后执行下面命令一次完成基础工具确认:
# 一次性确认实验环境的最小工具链 gcc --version | head -n 1 make --version | head -n 1 python3 --version # 若缺 make 或 gcc,在 Debian/Ubuntu 系执行: # sudo apt update && sudo apt install -y build-essential python3上面的head -n 1只是控制输出行数,真正重要的是三行输出都存在。如果gcc出现但make缺失,多半是装了gcc却没有装build-essential元包;这个问题在 Ubuntu 系非常常见,直接安装build-essential即可。Python 只需要能跑测试脚本,不需要安装pip包。
2.2 仓库目录结构的识别:先搞清 tests 目录与项目目录的对应关系
拿到实验仓库后,不要急着看代码。先用find或tree扫一眼仓库结构。这一节读代码的核心目的是找「评分脚本在哪里」「每个实验的源码模板在哪里」「writeup 在哪里」,而不是理解全部源码。
# 在仓库根目录执行,输出两层的目录结构(若没有 tree 可用 find 代替) tree -L 2 -d # 示例产出结构(不同课程可能略有差异): # . # ├── projects/ # 各实验源码模板 # ├── tests/ # 评分脚本与测试输入 # ├── writeup/ # 实验说明文档 # └── Makefile # 一键构建入口如果tree不可用,用find . -maxdepth 2 -type d也能看到同样的效果。识别结构时,优先关注三件事:第一,每个实验目录下是否有Makefile或test-*.py脚本;第二,是否有README或writeup文件,里面通常会写清评测命令;第三,源码模板中是「让你补全函数」还是「给你完整框架要求自己填逻辑」。由于课程版本差异大,一般不要求死记目录名,但「先看测试脚本、再看模板、最后写实现」的顺序是通用的。
2.3 第一个跑通的实验:以最小项目为目标,而不是挑最难的
不要一上来就选「进程调度」或「并发同步」这类大题目。实验仓库里通常存在一个「warm-up」性质的实验(例如实现stat系统调用的基本封装、或实现一个迷你cat),这类项目代码量小,且测试脚本复杂度低,最适合用来验证「我的编译链路和测试脚本链路是否通畅」。
我一般会按以下步骤跑通一个实验:
# 假设 warm-up 实验位于 projects/warmup/ cd projects/warmup # 查看 README 中要求的构建方式 less README # 或直接查看 Makefile 内容 make # 编译生成可执行文件 # 运行测试脚本(常见命名 test-warmup.py 或 run-tests.sh) python3 test-warmup.pymake命令会触发模板中的默认目标,生成的可执行文件名一般在Makefile中定义;测试脚本会调用这个可执行文件并比对输出。如果执行python3 test-warmup.py输出ALL TESTS PASSED,说明当前环境与评分脚本完全兼容。若出现FileNotFoundError,最可能的原因是测试脚本期望的可执行文件名与Makefile生成的产物名不一致——读Makefile里TARGET或PROG变量的值,把生成物改成对应名字即可。
2.4 评分脚本的读取套路:只看三行就够
测试脚本看起来可能很长(几百行 Python),但实际逻辑非常简单:构造输入、运行程序、比对 stdout。真正需要关注的只有三处:第一,程序接收的命令行参数是什么;第二,比对是「精确逐行」还是「忽略空白字符」;第三,超时时间是多少。
# 示意:查看测试脚本对目标程序的调用方式 grep -n "subprocess\|Popen\|run(" tests/test-warmup.py | head -n 20上面这行grep直接暴露测试脚本启动目标程序的语句。常见调用方式有两种:直接subprocess.run(["./target", arg1, arg2]),或用shell=True拼接字符串。如果是前者,你本地手动运行程序时就要严格按参数顺序传参,因为评分器不会“智能地帮你补参数”。理解了调用方式,本地复现测试就变成了执行同一命令并观察输出,调试成本大幅下降。
3. 按模块拆解实验:三类必做项目的实现骨架与关键参数
3.1 历史命令行(shell)类实验:从 fork/exec/wait 到管道的实现顺序
shell 类实验是操作系统实验里最常见的「第一个大项目」,要求实现一个能运行外部命令、支持环境变量与内建命令(如cd、exit)的简化命令行解释器。常见误区是一上来就写解析器,先把 token 切得花团锦簇,结果在fork和exec的父子进程关系上翻车。我建议实现顺序反过来:先写一个「不支持管道、只支持简单命令」的最小 shell,跑通测试脚本中的基础用例,再逐步加管道、重定向与后台执行。
// 简化版执行外部命令的骨架(伪代码示意,需自行补全) void execute_simple(char **argv) { pid_t pid = fork(); if (pid < 0) { perror("fork"); // 进程创建失败 exit(1); } else if (pid == 0) { // 子进程:重定向、exec execvp(argv[0], argv); // 在 PATH 中搜索并执行 perror("exec"); // exec 失败才会走到这里 exit(127); // 符合 shell 惯例:命令不存在返回 127 } else { // 父进程:等待子进程结束 waitpid(pid, NULL, 0); // 阻塞等待 } }上面的代码是 shell 实验的原子操作。execvp会自动在PATH环境变量指定的路径中搜索可执行文件,所以本地测试时不需要写绝对路径;但要注意execvp只在失败时返回,成功时当前进程映像已经被替换,因此perror("exec")后面的exit(127)必不可少。waitpid的第三个参数传0表示阻塞等待任意退出状态;如果写成WNOHANG,父进程会立即返回,导致 prompt 提前输出,这是初学最常见的 bug——输出顺序错乱。
管道实现的核心思路是「用pipe()创建一对文件描述符,把左命令的 stdout 重定向到写端,把右命令的 stdin 重定向到读端,再 fork 两个子进程」。这里有一个隐蔽点:父进程在 fork 前创建的pipe文件描述符会被两个子进程继承,因此父进程必须在 fork 后关闭自己的读写端,且每个子进程也要关闭自己不需要的那一端,否则对端的进程不会收到 EOF,管道会一直挂起等待。排查时如果发现命令执行后「卡住不退出」,绝大部分是文件描述符没有在正确的进程中全部关闭。
cd之类的内建命令必须由 shell 进程自己调用chdir(),不能放在子进程中执行,因为子进程的工作目录变更不会影响父进程。识别内建命令应在 fork 之前完成:先比较命令名,若是内建命令则直接处理并返回,不要进入 fork 逻辑。
3.2 进程调度(scheduler)类实验:理解量子与周转时间的评测口径
调度实验通常有两种形式:一是读一个 trace 文件,模拟调度算法并输出每个时间点的进程状态;二是直接在真实线程/进程上实现某种调度策略。前者居多,因为测试可确定性重复,而后者受系统负载影响太大,不容易自动化评分。
基于 trace 文件的调度实验实现起来其实不复杂:逐行读入「进程名、到达时间、运行时长」的记录,用优先队列维护就绪进程,按策略选择下一个运行的进程。核心数据结构只需要三个:一个按到达时间排序的数组、一个按策略排序的调度队列、一个记录当前模拟时钟的变量。
# 基于 trace 的调度模拟器核心逻辑(策略为 SJF 时的选择部分) ready_list.sort(key=lambda x: (x["burst"], x["name"])) # 最短作业优先:运行时长升序如果实验要求你输出每个时间点的运行进程名,要注意「时间点」的定义:通常是离散的整数时间片,而不是真实时钟时间。这意味着你的模拟器不需要sleep,只需要维护一个current_time变量,每处理一个事件就推进它。我见过有人用time.sleep(1)模拟时间片,结果测试脚本因为真实等待时间太长直接判定超时,这是完全错误的理解。
评分脚本通常不只检查「最后完成了几个进程」,还会检查「每一步哪个进程在运行」。因此你的输出行不能只写进程名,还要包含时间戳,且格式必须与脚本期望完全一致。建议先复制测试脚本中的预期输出样例,观察时间戳是在进程名前还是进程名后,再去实现输出函数,不要实现完再回头雕格式。
3.3 并发与同步(threads)类实验:用条件变量还是自旋锁的边界
并发实验的典型任务是「实现一个基于条件变量的生产者-消费者队列」或「用信号量实现读写锁」。这类实验的难点不在算法本身,而在于「测试脚本要求程序在有限时间内结束」,且在并发场景下,调试手段受限。
// 条件变量生产者的核心等待逻辑(示意补充) pthread_mutex_lock(&m); while (queue_is_full) { pthread_cond_wait(¬_full, &m); // 原子地释放锁并睡眠 } enqueue(item); pthread_cond_signal(¬_empty); pthread_mutex_unlock(&m);为什么这里必须用while而不是if检查条件?因为pthread_cond_wait返回后并不能保证条件一定满足——可能有多个消费者被唤醒,其中一个消费了唯一的数据项,其余被唤醒者必须重新检查条件。用if会让被假唤醒的线程直接消费空队列,导致崩溃或死锁。把这个while变成一个if,是并发实验里最常见的玄学翻车点。
另一个高发问题是「测试输出顺序与预期不一致」。很多并发实验允许输出顺序不做全局排序,只要求「所有线程都执行成功且共享数据最终一致」。如果你实现的是这类语义,就不应该在输出里打印时间戳或序号——打印操作本身会改变调度顺序,反而容易被评分脚本误判。最好的做法是只打印「最终结果」,例如队列最终长度、计数器最终值,让输出具备确定性。
3.4 文件系统与持久化类实验:从日志结构到磁盘块管理
文件系统实验通常提供一个小型模拟磁盘(一个二进制文件即可作为存储介质),要求实现文件的创建、读写、删除与块分配。这类实验最花时间的是「磁盘块管理的边界条件」:块满了怎么办、删除文件后块如何回收、文件大小超过直接块数量时是否要引入间接块。
教学版文件系统的实现路径是:第一步定义磁盘块大小(常见做法是 4096 字节或 512 字节)与 inode 结构;第二步实现块分配器(位图或空闲链表);第三步实现目录项管理。代码实现前,先把 writeup 中的磁盘布局图画出来,标注哪些块是超级块、哪些是 inode 区、哪些是数据区。画图的过程本身就是排查「越界写」最有用的工具。
调试文件系统实验时,xxd是必备工具:把实验生成的磁盘镜像用xxd -a -l 1024 disk.img查看前 1024 字节,能直接看出超级块和位图区域的写入情况。如果在没有调用创建文件接口前,磁盘数据区就出现了非零数据,说明你的格式化函数没有把数据区清零——这是一个很隐蔽的 bug,因为实验室评测脚本可能会重新挂载磁盘镜像,之前的脏数据会导致后续分配逻辑错乱。
4. 测试与评分:把「过测试」从黑盒变成白盒的四个方法
4.1 看懂打分脚本的评分维度:正确性占比最大,性能不只看快慢
课程评分通常由脚本自动执行,主要考察维度包括:基础功能覆盖率(运行常见命令是否成功)、边界条件(空输入、超大输入、并发压力)、资源清理(是否泄漏文件描述符、是否僵尸进程)。性能维度一般不会硬性要求「必须多少毫秒内完成」,而是设一个宽松超时(例如 10 到 30 秒)。这意味着你不需要过度优化算法,但必须保证程序在极端输入下会终止。
| 维度 | 常见测试方式 | 失败典型表现 |
|---|---|---|
| 基础正确性 | 运行预设命令,比对 stdout | 输出有偏差、多一个空格 |
| 边界条件 | 传空参数、超大文件、无效路径 | 段错误、死循环、超时 |
| 资源管理 | 重复执行 1000 次,观察内存/句柄增长 | 句柄泄漏、Cannot allocate memory |
| 异常处理 | 传不存在的命令 | 退出码不对、错误信息不对 |
上表的价值在于帮你判断该把时间花在哪:不要为了把调度模拟器的查找从O(n)优化到O(log n)而花两天时间,因为评测只在乎能跑完;而「空参数输入导致段错误」这类问题,一旦触发就直接扣掉一整个测试用例的分。
4.2 本地复现测试的方法:永远用「和脚本相同的姿势」运行程序
很多同学在本地测试时,习惯手动敲命令观察输出,例如直接运行./myshell然后输入ls。这种测试方式跟在脚本调用下程序的行为并不一致,尤其是当程序涉及交互输入时:脚本可能通过管道把输入一次性写入 stdin,而手动运行时是终端交互模式,缓冲行为完全不同。
# 模拟脚本的调用方式:用管道把输入一次性传给程序 printf "ls\n exit\n" | ./myshell上面这个命令把两行输入一次性喂给 shell 程序,是评分脚本最常用的方式。如果你的实现里用了scanf或fgets,管道输入与终端交互会表现出不同行为——比如scanf在管道输入到达 EOF 时会返回EOF,而你的代码可能没有处理这种情况导致死循环。因此测试时务必用管道方式,不要只用交互式终端。
4.3 构造自己的测试用例:与评分脚本互补的三类输入
评分脚本给出的用例数量有限,通常覆盖 5 到 10 个典型场景。为了减少提交后才发现问题的风险,我建议额外构造三类用例:空输入、超大输入、乱序输入。对 shell 实验来说,就是「一行内多个空格」「命令名带引号」「管道左右无空格」;对并发实验来说,就是「消费速度远快于生产速度」「多个消费者同时等待」。
# 快速生成大规模输入的技巧:用 yes 命令循环构造 # 例如:往 shell 里喂 5000 条 echo 命令 ( for i in $(seq 1 5000); do echo "echo test_$i"; done; echo "exit" ) | ./myshell上面这段测试脚本能一次性暴露两类问题:第一,命令解析是否支持多行持续输入;第二,大量命令执行后是否出现内存持续增长(即泄漏),如果 5000 条命令都开开心心执行完且退出,那基本说明主循环没有明显的资源泄漏。这类测试在你提交前跑一次,能省掉一次「因为超时挂掉整题」的血泪教训。
4.4 退出码与 stderr:一个容易被忽略的大分数点
评分脚本有时不检查 stdout,而只检查退出码。例如 shell 执行不存在的命令时,期望返回 127;执行被信号终止的子进程时,父进程返回的退出码可能是128 + 信号编号。如果不实现这些约定,程序功能正确但退出码不对,也会被记为失败。本地测试时,手动执行完命令后立刻echo $?观察退出码;脚本测试时,可写一行 bash 循环批量验证:
# 校验 20 种不同命令退出码是否符合预期 for cmd in "ls" "cd /nonexist" "exit 3"; do echo "$cmd" | ./myshell >/dev/null 2>&1 echo "$cmd -> exit $?" done上面2>&1的作用是把 stderr 也丢弃,避免错误信息污染终端;$?表示上一条命令的退出码。如果你发现cd /nonexist的退出码不是 0(shell 内建命令成功执行也应返回非零,但这里预期是实现各自约定),就要回到源码检查内建命令的返回值处理逻辑。退出码在大多数课程的评分占比在 5% 到 15% 之间,属于「不需要复杂设计却容易被忽略」的送分点,值得先调清楚。
5. 高频避坑:实验翻车现场的五条典型路径
5.1 现象:管道命令「卡住不退出」,像是程序进入了死循环
原因:父进程没有关闭pipe()返回的写端或读端,导致子进程的 stdin/stdout 无法收到 EOF。管道在 Linux 中是「所有写端关闭后,读端才返回 EOF」,只要有一个进程继承了写端却不写入数据,读端就会一直阻塞在read()上。解决:在 fork 前创建管道;fork 后父进程关闭读写两端;左子进程关闭读端并把 stdout 重定向到写端;右子进程关闭写端并把 stdin 重定向到读端。用lsof -p <pid>可以快速查看某个进程保有的文件描述符,确认是否还有不该存在的管道写端。
5.2 现象:测试脚本报「wrong answer」,手动运行结果却看似正常
原因:手动运行时程序输出到终端,终端是行缓冲模式;测试脚本通过管道捕获输出,管道是全缓冲模式。当程序崩溃产生Segmentation fault时,全缓冲的数据尚未 flush,脚本捕获到的内容不完整,与手动看到的输出不一致。解决:在关键输出点调用fflush(stdout);或者直接写 stderr 用于调试。调试时优先用管道方式喂入输入,不要依赖终端交互模式。若输出含有大量二进制数据,用xxd或od -c过滤不可见字符,肉眼比对时容易忽略的换行符差异会在脚本中直接判错。
5.3 现象:并发实验运行时表现时好时坏,有时一个用例过,有时同一个用例挂
原因:并发程序的行为依赖线程调度顺序,而调度顺序受系统负载影响。如果测试脚本对输出顺序有严格要求,而你的实现没有对共享输出缓冲区加锁,就会出现输出交错或乱序。解决:先判断测试脚本是「检查最终状态」还是「检查中间输出」,前者只需保证共享数据最终一致,后者需要给输出加互斥锁。不要试图用sleep来「等所有线程输出完」,这是最不可靠的方案,正确做法是主线程pthread_join所有工作线程后再退出进程。
5.4 现象:调度器模拟测试中预期的顺序正确,但时间点总是差一两个 tick
原因:对「时间片」的理解偏差。调度模拟中的时钟推进不是等长推进到每个未来时刻,而是「每次调度时快速跳到下一个事件发生时间」。例如进程 0 在第 0 秒到达、需要 3 个 tick,进程 1 在第 2 秒到达、需要 2 个 tick,正确的模拟器应按事件跳转:t=0 运行 P0,t=2 时 P1 到达抢占(如果是可抢占调度),而不是在 t=1、t=2 之间输出无意义的时间点。解决:把「输出每个 tick 的进程名」改成「输出每个事件点的进程名」,事件点包括新进程到达和当前进程运行结束两个时间源。
5.5 现象:重复运行make test有时成功有时失败,且失败时提示Text file busy
原因:上一次运行的目标程序进程还没有完全退出,残留下一个正在被执行的二进制文件;make重新编译时覆盖该问文件就会失败。解决:在测试脚本或手动测试前,先调用pkill -f <target_name>或killall <target_name>清掉残留进程。也可以修改实验 Makefile 的test目标,在编译前先执行pkill。这类问题在 shell 类实验中高发,因为 shell 进程自身会 fork 多个子进程,测试提前终止时可能留下僵尸进程占用目标文件。注意不要kill -9自己的测试脚本执行器,误杀会导致测试进程消失。
6. 从「过测试」到「真效率」:strace、批量回归与定位卡死位置的三个技巧
有一类现象几乎人人都会遇到:某个测试用例在本地跑通了,提交到服务器却超时;或者本地偶尔超时,而服务器必然超时。这时不要再盯着代码空想,用strace直接看程序的系统调用序列,是定位问题的最快路径,因为超时通常发生在某个系统调用上——大部分情况是read在等待永远不会到来的数据,或者waitpid在等待一个已经被杀死的子进程状态,或者flock在等待一个被本进程自己持有的锁。
# 追踪目标程序的系统调用与耗时(只跟踪与输入输出相关部分) strace -f -e trace=read,write,waitpid,pipe,dup2 -o /tmp/trace.log timeout 5 ./myshell-f表示跟踪子进程,没有它你看不到 fork 出来的子进程在做什么,这是 shell 类实验排查的必备参数。-e trace=read,write,waitpid,pipe,dup2把关注点限制在最常出问题的五个系统调用上,避免日志过噪;timeout 5给程序一个 5 秒上限,防止 strace 自身也卡死。打开/tmp/trace.log,找到最后一个返回-1或没有返回的系统调用,就基本锁定了卡死位置。
批量回归的思路是:不要等改完全部代码再统一测试,而是每完成一个小功能就运行一次整体测试。具体做法是把每次运行结果与上一次的结果保存下来,用diff对比差异,观察是否引入了新的失败用例。例如:
# 保存本次测试结果,供下次对比 python3 test-shell.py > /tmp/result_$(date +%H%M%S).log # 运行结束后查看最近两次结果的差异 diff /tmp/result_*.log | head -n 50上面date +%H%M%S给每次结果文件名加上时间戳,避免覆盖;diff输出能快速告诉你「这个改动影响了哪几个用例」,而不是等到最后提交前才发现一个改动破坏了三天前写好的功能。注意回归测试不需要跑全部用例,只选 3 个典型用例(基础命令、边界输入、超时用例)即可在 10 秒内完成一轮验证。
最后一个技巧是「输出带行号的调试日志」。很多人用printf("here")打点排查,但并发或多进程场景下,日志会交错在一起,看不出执行顺序。正确做法是用fprintf(stderr, "[%d] %s:%d current_time=%d\n", getpid(), __func__, __LINE__, current_time);。getpid()输出进程号,__func__与__LINE__是编译器预定义宏,直接标出位置,current_time是调度模拟器的当前时间,方便跨进程对齐日志。这个习惯看起来笨拙,但在我自己排查多进程管道问题时,效率比 strace 还高——因为它直接暴露的是程序逻辑层面的执行顺序,而不是系统调用层面的状态。
我个人的使用习惯是:先在模板代码里把所有入口函数各加一行这样的调试日志,跑一次测试拿到「正确完整」的执行路径,再用二分法删掉日志。这比「先写代码后猜哪错了」节省至少半天时间。如果中间某个函数的日志在测试中没出现,那么问题就锁死在「调用方没有正确进入这个函数」或「条件判断提前返回」上——这两个方向的排查各有边界,但至少你不会再漫无目的地看代码。希望这套「先跑通、拆骨架、读测试、用日志定位」的路径能帮你在操作系统的实验上少走几趟弯路。
本文还有配套的精品资源,点击获取