简介:哈工大操作系统实验(李治军)配套学习资料,面向计算机专业学生、考研复试者及操作系统自学人群。实验内容覆盖进程创建与同步、内存分页与页面置换、文件系统模拟、磁盘I/O调度、CPU调度策略及死锁防范等核心模块,可辅助完成代码阅读、编译与原理对照。资源共245个文件,压缩包约21.89MB,主体为88个C源文件、45个头文件、15个汇编文件与8个Makefile构建脚本,另含61个目标文件、12个说明文档及若干辅助工具,适合在Linux环境下复现内核实验并跟踪模块结构。已有3453人浏览学习,适合系统能力提升与实验复盘场景。通过源码、构建脚本和文档的组合,既能梳理实验环境搭建思路,也能对比自身实现;对想深入理解页面置换、磁盘调度和信号量机制的读者,尤其具有参考价值。
1. 操作系统实验课:先别急着写代码,把这门课的底层逻辑看清楚
提到操作系统实验,很多人的第一反应是"又要写内核了",然后赶紧去翻课件、找源码。但我见过太多人卡在第一个 lab 就放弃了,不是代码能力不够,而是没弄明白这门课的实验到底在训练什么、评测标准是什么、往年通过率高的同学是怎么安排时间的。李治军老师的操作系统实验体系,核心不是让你从零写一个 Linux,而是让你在真实内核上做几处关键模块的替换与验证,把教材里那些"玄学"概念——进程调度、中断处理、系统调用——落到能跑的代码上。
这门课的实验环境是深挖细节的黑匣子,每一步都要求你同时理解硬件机制和内核源码组织方式。适合的人群很明确:已经学完操作系统原理、能看懂 C 和部分汇编、愿意在调试器里耗时间的人。如果你只是想要一个"能通过检查"的答案,这套实验会让你很难受;但如果你想把"用户态和内核态切换时到底发生了什么"彻底搞懂,它可能是性价比最高的一门实验课。接下来我会按环境搭建、启动与引导、进程调度、文件系统四个方向,把常见做法和踩坑点拆开讲。
2. 搭建实验环境:Bochs 和 QEMU 的选择、内核编译与磁盘镜像的坑
2.1 为什么这门课坚持用真实内核而不是模拟器上的玩具系统
很多高校的操作系统实验用教学用微型内核,比如用几个 C 文件模拟进程调度,在用户态打印个结果就算完事。李治军老师的实验路线不同,它直接把 Linux 内核早期版本拿来,让你修改调度算法、添加系统调用,然后在虚拟机里跑起来看效果。这意味着你面对的是真实的中断描述符表、真实的任务切换汇编代码、真实的磁盘驱动——每一条原理都能在代码里找到对应物,而不是一张纸上的流程图。
这也直接决定了环境选型:你需要一个能跑真实内核的虚拟机,而不是一个只跑教学系统的模拟器。常见的选择是 Bochs 和 QEMU。两者都能模拟 x86 平台,但侧重点不同。Bochs 的调试能力更强,单步跟踪、断点、内存查看都做得比较顺手,缺点是运行速度慢;QEMU 速度快,但调试体验相对粗糙,一般要配合 GDB 使用。我一般建议新手先用 Bochs,因为它的调试输出和日志更直观,等你把实验涉及的启动流程、中断流程都摸熟了,再换 QEMU 也不迟。
提示:Bochs 的配置文件略繁琐,但一旦配好就几乎不用再改。QEMU 更适合已经熟练的人,少一层配置,但多一层调试成本。
2.2 从零编译内核的最小命令序列
拿到实验包后,第一件事不是读代码,而是把内核编译通过。这一步能筛掉一半人:很多人代码改对了,但编译不过,或者能编译却起不来。常见做法是先确认工具链版本,然后执行清理、配置、编译三步。以下命令序列是多年下来比较稳的路线:
sudo apt-get install build-essential nasm gcc-multilib g++-multilib bochs bochs-x qemu-system-x86 make clean make -j$(nproc)逻辑说明:build-essential提供 gcc 和 make;nasm是汇编器,内核启动阶段的内核代码里有一部分是 16 位和 32 位汇编,必须用 nasm 编译;gcc-multilib是为了在 64 位宿主机上编译 32 位内核目标文件,不加这个会遇到 gnustubs 相关的报错;bochs和bochs-x分别提供模拟器和图形界面支持;qemu-system-x86是可选,但如果后续实验要对比调试体验,建议一起装。
参数说明:编译时-j$(nproc)用满 CPU 核心数能省很多时间;如果你改动了少量文件,不要总是make clean,直接make会增量编译,能快很多。一个常见错误是编译时提示找不到<linux/xxx.h>,这通常是头文件搜索路径没有包含内核源码根目录,检查 Makefile 里的-I参数即可。
编译通过后,下一步是生成磁盘镜像。实验包通常会提供一个空镜像或者一个可启动的镜像,但如果你自己创建,需要把内核写到镜像的引导扇区之后。常见步骤是先用dd创建镜像,再把编译出的内核文件写入指定偏移:
dd if=/dev/zero of=hdc.img bs=512 count=2880 dd if=linux-0.11/kernel.bin of=hdc.img bs=512 seek=1 conv=notrunc逻辑说明:第一行生成一个 1.44MB 的软盘镜像,对应 2880 个扇区;第二行把编译出的内核写入镜像的第二个扇区,seek=1表示跳过引导扇区。conv=notrunc是必须的,否则dd会截断目标文件。
参数说明:这里以软盘镜像为例,因为最早实验手册默认从软盘启动。实际上很多同学会用硬盘镜像,那么写入偏移会不同。如果你不确定,先查看实验包里的 Makefile 或 start 脚本,里面通常已经写好了生成镜像的规则。
2.3 磁盘镜像、引导配置文件与常见启动失败的判断方法
Bochs 没有图形化配置界面,一切靠一个文本配置文件。常见配置里最关键的是三行:内存大小、启动盘、调试日志开关。以下是一个能直接跑通实验镜像的配置示例:
megs: 32 floppya: 1_44=hdc.img, status=inserted boot: a log: bochsout.txt逻辑说明:megs指定虚拟机内存,内核早期版本对内存要求不高,但不要低于 16MB,否则一些初始化代码会出问题;floppya指定软盘镜像路径;boot: a表示从软盘启动;log把 Bochs 的运行日志写到文件里,这是排查启动失败最重要的信息源。
参数说明:如果日志文件里出现unexpected interrupt或unknown opcode,说明内核代码在 CPU 上执行了非法指令,多半是因为你改的代码破坏了控制流;如果日志显示磁盘读取超时,则优先检查镜像路径和dd写入的扇区偏移。
这段最容易翻车的点是环境位数的兼容性:64 位宿主机上编译 32 位内核时,Bochs 本身可以跑 32 位代码,但 qemu-system-x86 默认也可能以 64 位模式启动,如果你在 Makefile 里混用了-m32和-m64,会出现链接错误。解决办法是统一在 Makefile 的 CFLAGS 里加-m32,并且确认你用的是gcc-multilib而不是纯 64 位交叉工具链。
3. 从引导到内核初始化:理解实模式到保护模式的切换,以及第一个会卡住你的点
3.1 实验 1 到底在验证什么:不是"会启动"而是"理解启动时的每一行汇编"
这门课的第一个实验通常涉及引导扇区,有时还需要修改内核启动参数,或者在启动阶段加入自己的打印信息。很多人以为实验目标只是让内核跑起来,直接make完就提交,结果发现评分看的是你对启动过程的理解。启动部分的核心是从 16 位实模式切换到 32 位保护模式,期间要设置 GDT、开启 A20 地址线、跳转到 32 位入口。每一步都有对应的汇编代码,而这些代码就藏在实验目录里。
常见做法是把boot/bootsect.s从头读到尾,逐行注释,标注每条指令执行前后的寄存器状态。你要能回答几个经典问题:为什么进入保护模式前要关中断?为什么跳转指令要用ljmp而不是jmp?GDT 里的段描述符是怎么组织的?这些问题不是死记硬背,而是调试时真正会用到的判断依据——如果你在内核初始化早期出现异常,只能靠这些知识定位是 GDT 设置错了还是 A20 没开。
3.2 抓取启动日志、对比输出定位卡死阶段的具体命令
启动类问题,最有效的排查手段不是猜,而是看 Bochs 日志和串口输出。实验包里一般会有调试信息打印函数,或者你可以在内核初始化代码里插入临时打印语句。以下是我会用来定位启动卡住位置的命令组合:
bochs -f bochsrc -q tail -f bochsout.txt grep -n "panic\|error\|fault" bochsout.txt逻辑说明:bochs -f bochsrc -q读入配置文件并直接开始模拟,不需要手动点击运行按钮;tail -f实时查看日志;grep过滤关键字。如果日志里没有任何异常信息但内核就是不动,最可能是显示模式的初始化出了问题,此时需要检查 VGA 相关的初始化代码是否在保护模式切换后被正确调用。
参数说明:如果你在日志里看到多个page fault,并且地址看起来不是随机的,那么大概率是页表没建立。早期内核版本在进入保护模式后,要经过一段汇编代码设置页目录和页表,这段代码的地址计算非常容易出错,常见问题是用物理地址而不用线性地址,导致 CPU 拿到的地址直接越界。
内存调试还有一个很实用的点:在启动早期,内核代码所在位置和栈顶位置距离很近。如果你在临时打印时使用了过多的栈空间,比如在中断处理函数里声明一个大数组,不会立即崩溃,而是在某次函数返回时把返回地址覆盖了。判断方法是看日志里最后一次打印的函数名和崩溃点是否偏离很远。如果偏离很远,基本就是栈溢出。
4. 进程调度与内核态切换:修改时间片、添加系统调用的标准路径
4.1 从 schedule 函数到 switch_to 宏:你要动的那几行到底在哪里
进程调度实验通常是这门课的分水岭。教材讲的是调度算法原理,实验要你改的是内核里真实运行的进程队列。早期版本的调度核心在kernel/sched.c里,一个叫schedule的函数,它做的事是遍历任务数组,挑出时间片最大的任务,然后调用switch_to做上下文切换。新手最容易犯的错误是直接改schedule里的比较逻辑,却忽略时间片是在时钟中断里被重新赋值的。
常见做法是:先读kernel/sched.c里的schedule和wake_up等函数,搞清楚current、task这两个全局指针的关系;再读kernel/system_call.s里的switch_to宏,理解它怎么保存老任务的寄存器到tss和内核栈,怎么加载新任务的tss。你改的算法可以是简单的优先级反转、时间片轮转调整,或者加入一个运行计数,但无论怎么改,入口和出口必须保持原来的函数签名。
4.2 给内核添加一个系统调用的五个步骤与最小代码片段
系统调用实验是另一个必做项目,它要求你在内核里加一个新的系统调用,然后在用户态程序里调用它。步骤看起来固定,但每一步都有细节。以下是最小路径:
// 1. 在 include/linux/sys.h 中声明 extern int sys_mycall(void); // 2. 在 sys.h 的系统调用表里加一行 // ..., sys_mycall // 3. 在 kernel/system_call.s 中把总调用数加 1 // nr_system_calls = 73 => 74 // 4. 实现函数体 int sys_mycall(void) { return current->pid; }逻辑说明:第一步声明函数,如果不声明,内核链接时可能因隐式声明而警告或报错;第二步把函数指针加到系统调用表里,这个表是用户态调用时内核根据中断号索引的;第三步nr_system_calls是系统调用的总数上限,必须同步更新,否则调用号超过旧上限会被当作非法调用;第四步是真正的实现,current是全局指针,可以直接拿到当前进程的 task_struct。
参数说明:如果新系统调用需要参数,不能直接像用户态函数一样在声明里写参数,因为系统调用是通过寄存器传参的。你需要把用户态传入的参数从寄存器里取出来,常见做法是在system_call.s里新增一个处理分支,或者复用已有的sys_call_table参数传递机制。最常见的翻车点是忘记在用户态程序里用_syscallN宏,或者宏的返回值类型写错,导致拿到的结果永远是 0 或 -1。
4.3 用 printk 和 Bochs 调试输出对比不同调度策略的实际效果
改完调度算法,怎么验证真的生效?最简单可靠的手段是加printk,在内核日志里打印每次切换的进程号和剩余时间片。以下是我常用的临时调试代码:
printk("schedule: prev=%d next=%d\n", prev->pid, next->pid);逻辑说明:在schedule函数确定了next之后、调用switch_to之前插入这一行,就能看到每一次进程切换的动向。这比任何用户态测试程序都直观,因为用户态程序只能感知到"自己是否被暂停",看不到别的进程状态。
参数说明:如果日志刷得太快,导致 Bochs 运行变慢,可以把printk加上条件,比如只在next->pid == 某个值时输出。也可以用环形缓冲区,只保留最近 N 条日志,避免日志文件无限增长。我一般会把日志重定向到文件,然后在宿主机上分析,而不是开着 Bochs 窗口看滚动输出。
注意:
printk的格式字符串和用户态的printf略有差异,内核早期版本不支持%zu这类动态宽度格式,最好只用%d、%x、%s这种最基础的格式。
5. 避坑:操作系统实验里最常翻车的五个细节与判断方法
5.1 现象:改了调度算法之后,系统启动后频繁死机或键盘无响应
原因多半是你修改的schedule函数破坏了原来的语义,比如在你自己的逻辑里把while循环用成了for且提前break,导致没有选出合法的next;或者你在schedule里用了睡眠或锁,而它本身是在关中断状态下运行的,任何睡眠都会导致死锁。
解决方法是先在schedule函数开头加一个断言,打印当前中断状态和current->pid,确认调度器被调用时上下文是否符合预期。如果断言触发,说明你的修改在某个路径上被错误调用。
5.2 现象:系统调用返回值一直不对,用户态程序拿到的是 -1
先别急着查内核实现,去查用户态程序的系统调用宏。很多同学用_syscall0(int, mycall)声明后,调用时传入了一个参数,导致参数被忽略,返回值变成-ENOSYS。判断方法:在用户态程序里直接打印errno,如果是 38,那就是调用号没对上。
解决方法是确认你修改的sys.h表里新函数的索引值,和用户态宏里写的调用号一致。常见错误是只改了nr_system_calls而忘了把新函数插入到表中间,导致索引错位。
5.3 现象:Bochs 能启动,但一执行某个用户程序就重启
这种"重启"不是虚拟机崩溃,而是 CPU 触发了一次未处理异常,导致处理器复位。原因通常是段错误或页错误发生在内核态,而内核态的异常处理入口没有正确设置。
解决方法是打开 Bochs 的异常日志输出,在配置里加cpu: reset_on_triple_fault=0,这样会在三次故障后暂停而不是重启,日志里会给出异常类型和出错地址。定位到地址后,用addr2line把地址转成源码行号。
5.4 现象:编译通过,但链接时报__stack_chk_fail或undefined reference
这是典型的工具链版本不匹配。早期内核代码用了不少自定义的内联汇编,这些汇编可能依赖于旧的 gcc 语法。新版 gcc 默认开启了堆栈保护,会往函数里插入检查代码,导致链接失败。
解决方法是编译时加上-fno-stack-protector,并在 Makefile 的 CFLAGS 里统一加上-m32 -fno-stack-protector -O2。如果还报undefined reference到某个符号,大概率是某个头文件里的宏定义在新版本 glibc 里被移除了,需要手动补一个宏。
5.5 现象:在 Windows 上用 WSL 编译总是失败,换成虚拟机后立刻通过
这不是玄学,是文件权限和路径问题。WSL 的文件系统跟 Linux 在 inode 处理上有差异,内核源码树里一些脚本用find或ls判断文件类型,在跨文件系统挂载点下会得到错误结果。解决方法是把源码放到 WSL 的原生文件系统内,比如~/oslab,而不是/mnt/c/...目录。另外,WSL 的bash脚本默认的换行符是 LF,如果你的源码在 Windows 里被git clone时自动转换成了 CRLF,基本无法编译。
6. 把实验做深:用 trace 脚本验证调度行为,形成可复现的调试习惯
做到最后一个实验,不要只追求"通过检查",那会让你损失最宝贵的一部分收获。一个很值得投入的做法是写一个简单的 trace 脚本,在 Bochs 日志里记录每次进程切换的序号、时间戳和 PID,然后把这些数据用 Python 或 awk 整理成时间线图。这样你就能直观看到时间片轮转到底是均匀的,还是被某个高优先级进程饿死了。
常见做法是在schedule函数里打印一个固定格式的行,比如TRACE pid=%d ticks=%d jiffies=%d。然后用一个时钟中断的计数代表时间戳。日志收集完后,用以下脚本快速统计每个进程被调度的次数:
grep "TRACE" bochsout.txt | awk '{print $2}' | sort | uniq -c逻辑说明:这条命令把 TRACE 行中的 PID 提取出来,统计每个 PID 出现次数,就能看出谁的调度次数明显偏多或偏少。这比肉眼看滚动日志可靠得多。当你后来遇到一个奇怪的问题,比如某个进程卡死,你也能用同样的方式,把它的调度历史和其它进程对比,迅速判断是不是被饿死了。
参数说明:如果你在日志里看到大量TRACE行,可以把输出重定向到文件再分析,不要直接在终端上滚动。还可以给printk加一个编译开关,平时关掉,需要调试时打开,避免每次运行都产生海量日志。
这个习惯的另一个好处是形成你自己的回归测试集:每改一次调度策略,跑一遍同样的测试程序,对比调度次数分布,就能立刻判断改动是否引入了回归。我当年做实验时,最初也是只会用printk打印一两行看个响,后来被一个调度不稳定的问题折磨了两天,才下决心写这套 trace 和分析流程。从那以后,再没因为"到底改了哪里导致行为变化"发过愁。
如果你也卡在某个实验,不妨先停下来问自己:我能不能用一个可重复的命令,把当前内核行为和预期行为做一次对比?如果能,问题就已经解决了一半。这条经验不仅在这门课里适用,以后调试内核、调试驱动、调分布式系统,都是一样的逻辑。希望帮到你。
本文还有配套的精品资源,点击获取