
简介这份资料是哈工大李治军教授操作系统课程实验的完整素材包面向正在做课程实验的本科生、考研复试者或自学操作系统的开发者用来理解进程管理、内存管理、文件系统、设备管理和调度算法等核心机制。包体共245个文件压缩后约21.89MB以C源码、头文件、目标文件、汇编代码和Makefile脚本为主同时包含系统镜像、启动引导等底层文件能反映内核实验的典型目录结构。实验内容涉及进程创建与同步、P/V操作、哲学家就餐与生产者消费者问题虚拟内存页面分配与LRU/FIFO置换算法文件系统模拟以及磁盘调度和进程调度策略基本覆盖哈工大实验一到实验八的主要命题。已有3463人学习下载适合在搭建实验环境后对照参考也方便分析原有代码的思路与排错过程进而独立完成各模块实验。利用这些材料可以直观看到经典算法的代码实现和模块组织方式帮助把抽象理论落到具体函数与数据结构上为后续系统软件开发打下扎实基础。1. 操作系统实验资源拆解一门课把内核从引导写到文件系统做操作系统实验最怕的不是不会写代码而是面对一套陌生内核代码不知道从哪下手。这套课程实验资源围绕一个早期 Linux 内核的教学裁剪版展开把启动引导、中断与系统调用、进程调度、信号量同步、内存管理、文件系统六块内容串成一条主线每个模块都要求在真实内核源码上改完、编译、跑通。它不是给你一个黑匣子看效果而是逼你逐行读源码、打断点、查内存。适合两类人一是正在修操作系统课、想拿高分又不想抄答案的学生二是工作后想补内核底子、但没动力从零读 Linux 源码的从业者。我从拿到资源到完整复现前后花了两周中间的坑比预想多但每踩一个对内核的理解就深一层。2. 先搞懂实验载体内核裁剪版、补丁与编译链路2.1 这套实验为什么用早期 Linux 内核而不是现代内核课程选用的是早期 Linux 内核的教学裁剪版版本停留在 x86 体系还比较单纯的年代。好处显而易见代码量少核心机制完整一个文件几百行就能讲清楚一个子系统。现代内核动辄几千万行光看目录结构就能劝退初学者。这个裁剪版保留了进程管理、内存管理、文件系统三大核心砍掉了大量驱动和网络协议栈使得学生能在有限课时内通读关键路径。内核源码包解压后大约几十兆核心代码集中在 kernel、mm、fs 三个目录。比源码更重要的是配套补丁——教学团队把实验涉及的关键函数做了标注和简化比如调度器的schedule()函数里加了注释和空实现要求学生自己补全逻辑。这种设计避免了学生面对一整片原生代码不知从何下手的问题。2.2 环境搭建工具链版本匹配是第一道坎我一般会在 Ubuntu 18.04 或 20.04 上做这套实验内核源码的编译依赖 gcc、make、binutils 和 libncurses-dev。版本太新会出编译告警太旧则链接器报错。推荐用如下组合sudo apt install build-essential libncurses-dev bison flex libssl-dev sudo apt install qemu-system-x86 # 用 QEMU 跑内核比 Bochs 调试方便这段命令装的是编译内核的完整工具链和模拟器。注意几个细节bison和flex是生成语法分析器用的内核编译的配置阶段必须要有libssl-dev在较新版本内核编译时是必装项否则证书相关代码过不了编译。我一般会再加一个gdb-multiarch用于内核调试这套实验的调试体验很大程度依赖 gdb 与 QEMU 的配合。2.3 编译与启动从源码到看到一个能动起来的系统编译内核的流程和编译普通程序完全不同。先配好默认配置再手动调整关键选项cd linux-0.12 # 教学裁剪版内核根目录 make defconfig make menuconfig # 手动确认如下选项make defconfig生成默认配置menuconfig打开图形化配置界面。必须确认开启的内核选项包括CONFIG_EXPERT显示高级选项、CONFIG_DEBUG_INFO生成调试信息gdb 调试必需、CONFIG_KALLSYMS内核符号表。这三个选项不开后面做内核态调试会遇到符号找不到的问题非常被动。编译时指定make -j4如果环境是双核四线程直接-j4起步两分钟内可以完成。编译完成后输出arch/x86/boot/bzImage此时用 QEMU 启动最小系统qemu-system-x86_64 -kernel arch/x86/boot/bzImage -append consolettyS0 -serial stdio这条命令把内核串口输出重定向到终端能看到 boot 过程打印的日志。第一次跑通时屏幕上滚出一行行的初始化信息那种内核真的动起来了的感觉比任何教科书都来得直观。如果启动后只有光标闪烁没有日志优先检查-append参数里的consolettyS0这是教学内核最常见的静默问题后文避坑章节会细说。3. 系统调用实验从用户态到内核态的一次完整穿越3.1 系统调用的实现机制int 0x80 与系统调用表教学内核的系统调用机制很直观用户态程序通过int 0x80触发中断CPU 切换到内核态内核根据eax寄存器中的系统调用号在系统调用表中查找处理函数。整个过程不涉及现代内核的syscall指令和 MSR 寄存器逻辑更加朴素适合教学。系统调用表定义在arch/x86/kernel/entry.S中教学版本里它被简化为一个很短的数组。格式如下// kernel/syscall_table.S ENTRY(sys_call_table) .long sys_restart_syscall /* 0 - restart */ .long sys_exit /* 1 - exit */ .long sys_fork /* 2 - fork */ .long sys_read /* 3 - read */ .long sys_write /* 4 - write */每行对应一个系统调用编号.long指示符把函数地址放入表中。新增一个系统调用核心动作就是在这个表里追加一行把函数名和编号绑定。3.2 添加一个新系统调用六步走通全链路以添加一个返回当前 jiffies内核心跳计数的系统调用为例完整改动涉及六个文件// 1. include/linux/sys.h 增加函数声明 asmlinkage int sys_get_jiffies(void); // 2. kernel/sys.c 增加实现 asmlinkage int sys_get_jiffies(void) { return jiffies; } // 3. arch/x86/kernel/entry.S 系统调用表追加 .long sys_get_jiffies /* 223 - get_jiffies */代码逻辑本身非常简单但把它跑通涉及完整链路第一步是头文件声明编译各模块时都能识别新函数第二步是函数实现返回内核全局变量jiffies这个变量每 10ms 加一是一个隐式的时钟源第三步注册到系统调用表中编号 223。// 4. include/asm/unistd.h 增加宏定义 #define __NR_get_jiffies 223 // 5. 用户态测试程序 #include linux/unistd.h _syscall0(int, get_jiffies) // 内嵌汇编封装 int main() { printf(current jiffies %d\n, get_jiffies()); return 0; }第 4 步是给用户态提供宏定义第 5 步的_syscall0宏是老内核提供的内嵌汇编封装自动生成int 0x80指令和寄存器传递代码用户程序不需要手写汇编就能调用新系统调用。这段封装写不对编译报错时最常见的问题就是寄存器约束冲突。关键参数说明函数前的asmlinkage修饰符告诉编译器参数传递不走寄存器优化路径而是通过内核栈传递这是老内核系统调用的约定不能省略。3.3 验证与回退新系统调用跑通后的收尾操作测试程序编译运行后应该输出一个正数。注意这个数每 10ms 变化一次如果你打印两次就能看到差值——这是验证系统调用确实进入内核态、读到了真实内核变量的最直接方法。一个容易忽略的坑修改系统调用表后没有重新编译内核就运行测试程序测试程序会报Bad system call。这是因为用户态调用号 223 在内核里查不到对应函数。我习惯每次改完内核源码都执行以下检查序列make -j4 qemu-system-x86_64 -kernel arch/x86/boot/bzImage -append consolettyS0 -serial stdio如果只是改了系统调用表而没有改头文件编译时会有隐式声明告警如果改了头文件而没有清理编译缓存有时新的宏定义不生效最好make clean后再编。这些细节看似琐碎实际排查起来非常耗时。4. 调度、信号量与内存三个必做实验的完整拆解4.1 进程调度实验时间片轮转改为优先级抢占教学内核的默认调度器是基于时间片的轮转调度每个进程运行完一个时间片后进入就绪队列尾部。调度实验通常要求改成优先级抢占式调度高优先级进程一旦就绪立即抢占正在运行的进程。核心改动在kernel/sched.c的schedule()函数。默认实现逻辑简单从就绪队列头部取一个进程切换。我改成了遍历就绪队列找优先级最高的进程// kernel/sched.c schedule() struct task_struct *p, *next NULL; int max_prio 0; list_for_each_entry(p, run_queue, run_list) { if (p-prio max_prio) { max_prio p-prio; next p; } } if (next) switch_to(next);这段代码里run_queue是就绪队列的头节点run_list是进程控制块中用于串入就绪队列的链表节点。p-prio是优先级字段数值越高优先级越高。遍历链表找最大值是优先级调度最朴素的实现方式。这个实验最容易踩的坑是忽视了优先级更新的时机。进程创建时设置初始优先级不难难的是运行时怎么动态调整优先级——如果高优先级进程一直不退出低优先级进程会永远得不到 CPU表现为系统卡死。常见解决方案是引入老化机制每次时钟中断时为就绪队列中所有进程的优先级加一个固定增量等待越久优先级越高。这里我参考了调度实验文档给出的相对优先级思路但实现上根据自己的理解做了调整。时钟中断的处理逻辑在kernel/sched.c的timer_interrupt()中需要在这里加入优先级调整代码。这个地方涉及中断上下文与进程上下文的交互改不好会出现隐性竞争。4.2 信号量实验内核态同步的原语实现信号量实验要求在内核态实现sem_wait和sem_signal两个原语并用它解决进程同步问题。教学版本里没有完整的信号量实现只有空壳需要自己补全。// kernel/semaphore.c typedef struct { int value; struct task_struct *wait_queue; } semaphore_t; void sem_wait(semaphore_t *sem) { while (sem-value 0) { // 将当前进程加入等待队列并调度 current-state TASK_UNINTERRUPTIBLE; list_add_tail(current-wait_list, sem-wait_queue); schedule(); } sem-value--; } void sem_signal(semaphore_t *sem) { sem-value; if (!list_empty(sem-wait_queue)) { struct task_struct *wake list_first_entry( sem-wait_queue, struct task_struct, wait_list); wake-state TASK_RUNNING; list_del(wake-wait_list); } }这段实现是经典的内核信号量写法sem_wait用一个 while 循环检查 value若小于等于零则把自己挂入等待队列并让出 CPU等被唤醒后再重新检查。这里用while而不是if是刻意的——即使被唤醒也可能有其他进程抢先拿走资源必须重新验证条件。sem_signal则是递增 value唤醒等待队列中的第一个进程。实现中要防止多个进程同时操作等待队列的链表节点所以调用sem_signal的地方必须关闭中断或持有自旋锁。我在一次实验中忘记加锁跑生产者-消费者测试时偶发空指针崩溃排查到半夜才定位是等待队列被并发修改。4.3 内存管理空闲页块的分配与回收内存管理实验基于内核的物理页分配器要求实现一种基于位图的空闲页管理。教学裁剪版保留了mm/page_alloc.c的骨架但初始化流程被简化。核心函数是get_free_page()和free_page()// mm/page_alloc.c unsigned long get_free_page(void) { int i; for (i 0; i MAP_NR; i) { if (!mem_map[i]) { // 0 表示空闲 mem_map[i] 1; return LOW_MEM i * PAGE_SIZE; } } return 0; } void free_page(unsigned long address) { int idx (address - LOW_MEM) / PAGE_SIZE; if (mem_map[idx] 0) mem_map[idx] 0; }mem_map是教学内核中的页状态数组每个元素对应一页物理内存。LOW_MEM是内核可分配物理内存的起始地址PAGE_SIZE是页大小通常 4KB。get_free_page()线性扫描数组找第一个空闲页free_page()根据地址反算页索引把状态清零。这个实验真正的难点不在分配函数本身而在初始化阶段——mem_map数组的哪些页该标记为占用、哪些标记为空闲直接决定了系统启动后可用内存的边界。如果初始化时把内核本身占用的页误标为空闲后续某个进程申请到了内核代码段的页一执行就 panic。我刚开始实验时把LOW_MEM以下的内存也标记为空闲系统启动后每次切换进程都会崩溃最后对照启动日志才明白内核代码段数据和页表占用了地址空间低端。实现完基础分配器后还有一个进阶任务伙伴系统。在教学内核中实现完整伙伴系统需要的链表数组和合并操作复杂度比位图高一个量级。如果实验要求只做位图不必强行上伙伴系统否则排错成本会拉高很多。5. 避坑指南编译、运行与调试的高频故障排查记录5.1 编译阶段报错信息指向的其实是工具链版本问题现象执行make时报一堆unknown type name或implicit declaration错误指向内核源码中某些结构体定义。原因大多数情况是编译器版本过新启用了更严格的语法检查。教学内核是 GNU C89 时代的代码新版 gcc 默认开启了-Werrorimplicit-function-declaration等严格选项直接把旧风格的隐式函数声明当作错误处理。解决在 Makefile 里追加KBUILD_CFLAGS -Wno-error把告警降级或者显式加上-stdgnu89编译标准。如果这样仍不行检查是否安装的 gcc 到了 10 以上版本建议用gcc-9替代默认编译器。我的经验是优先换编译器版本不要逐个改源码。5.2 运行阶段QEMU 启动后一片空白没有输出日志现象启动命令执行后QEMU 窗口打开但屏幕全黑没有任何启动信息CtrlAlt 键无法响应。原因最常见的是串口终端参数没配对。教学内核默认把日志输出到串口 ttyS0但 QEMU 没有启用串口重定向导致输出丢失。另一种可能是内核在更早的阶段崩溃panic 信息也被吞掉。解决先确认启动命令带上了-serial stdio和consolettyS0参数如果这样仍无输出换成-nographic模式运行它会禁用图形窗口、直接用当前终端充当串口日志无处可藏。定位内核崩溃时还可以加-d int -D qemu.log让 QEMU 把所有中断向量记录到文件能看到最后一次中断是在哪个指令触发的。5.3 调试阶段gdb 看到全问号没有符号信息现象用 gdb 连接 QEMU 做内核调试打断点时报错Function not definedbt显示栈帧全是问号。原因内核编译时没有生成调试符号或者符号地址与运行时加载地址不匹配。早期教学内核的链接地址通常固定在0x0或0x100000但如果 QEMU 加载时用了不同起始地址gdb 拿到的符号地址就会失效。解决编译时必须加-g参数对应CONFIG_DEBUG_INFO然后确认vmlinux文件存在。QEMU 启动时加-s参数监听端口 1234gdb 端连接后先执行file vmlinux再target remote localhost:1234。如果符号仍然对不上检查是否在 bochs 环境中遇到的——bochs 需要单独配置符号文件路径这一度让我误以为内核崩了实际上是调试器没接上。5.4 加载阶段测试程序Bad system call或段错误现象新编译的测试程序在内核里运行时报错返回 -1象征性的Bad system call提示。原因大概率是系统调用号不一致。用户态头文件的宏定义编号和内核系统调用表里的编号没对齐或者内核没重新编译、还在跑旧镜像。解决逐个核对include/asm/unistd.h里的宏定义和entry.S中添加的行。改完内核后确认bzImage重新生成并确认 QEMU 加载的就是新镜像。第一次做实验时我把make bzImage的输出文件路径记错一直拿旧镜像跑排查了半小时才发现是路径问题。5.5 死锁与卡死进程不切换系统像冻住一样现象调度实验或信号量实验运行后系统无响应时间片不再流转但按 q 键能退出 QEMU。原因调度器里出现了死循环或者是当前进程把自己加入等待队列后没有其他进程能把它的状态改回可运行。典型的错误包括等待队列操作时加了自旋锁但锁初始化失败或者schedule()在while(1)循环中找不到可运行进程。解决在schedule()的函数入口和出口各加一个printk打印当前进程的 pid用日志确认是不是每次调度都在同一个进程上打转。如果是进程不被唤醒的问题检查sem_signal中唤醒逻辑是否拿对了进程控制块指针这里最容易犯的错误是拿链表头的地址去当进程指针用类型强转后访问到错误的内存偏移导致状态更新失败。6. 内核动手能力验证三招自检实验完成度与正确性实验做完不等于实验做对了。我实践下来最有效的验证手段是把三个自检动作固化成习惯通过 dmesg 日志确认关键路径被执行通过 proc 或自定义系统调用读取内核内部状态最后对照实验要求逐条核验功能点。第一个自检动作是开启函数调用跟踪。内核的printk是实验阶段最强力的观测手段我在系统调用实验里刻意在sys_get_jiffies的实现中加了printk(sys_get_jiffies called by pid %d\n, current-pid)。跑测试程序时dmesg 里能看到这条日志说明用户态确实穿越到了内核态且调用链完整。打印当前进程 pid 是为了辅助确认用户态进程与内核态上下文一致不至于被多进程环境干扰判断。第二个自检动作是写一个简单的钩子检测测试程序验证调度实验的优先级抢占是否真的按预期工作。创建一个低优先级进程输出数字 1再创建一个高优先级进程输出数字 2按我的实现高优先级进程创建后应该中断低优先级进程的执行日志里两个数字的输出顺序应该呈交错状态。这个验证方法并不复杂但能有效发现调度切换时机错误如果你的实现是高优先级进程只在时间片超时后才能抢到 CPU日志输出顺序就完全不对。第三个自检动作是从实验要求文档反推验收点。每一个实验要求里都有明确的验收语句比如能够通过用户态程序触发特定系统调用并返回预期值。我在每完成一个实验时会把这个语句抄到笔记最前面然后逐条核对是否满足返回值语义、是否处理了异常参数、是否在边界条件下不崩溃。这套方法帮我抓住了不少功能做了但细节没对齐的问题比如信号量实验要求阻塞时就绪队列行为——我初期实现里进程进入等待队列后没有主动让出 CPU导致系统整体失去调度能力。回顾整个过程最深刻的体会是读源码比写代码重要。做错了回头看多半是没读懂原来代码的执行上下文靠猜的补全逻辑自然漏洞百出。从那以后我每次拿到一段不熟悉的内核代码第一件事是在源码里标注关键变量的生命周期和状态流转而不是急着写代码——这个习惯让我后续做文件系统实验时少走了大量弯路。希望这套实验资源对你的内核学习路有同样的帮助。本文还有配套的精品资源点击获取