简介:面向操作系统课程学习者与实验开发者的南京大学操作系统实验完整框架包,覆盖实模式/保护模式Hello World、系统调用printf实现、进程切换与Fork/Sleep/Exit、信号量同步等核心实验环节,适合高校学生对照课程要求进行内核编程实践。包体共107个文件,包含31个C源文件、43个头文件、15个Makefile构建脚本、8个汇编文件及7个Perl辅助脚本,另有Git忽略与说明文档,整体约65KB,目录按lab1至lab4分模块组织,便于按阶段查阅。已有1879人浏览学习。借助这套框架可快速搭建QEMU+Ubuntu实验环境,理解Bootloader启动流程、中断与系统调用实现、进程调度与同步机制;所有源码与配置文件齐全,可直接在此基础上修改调试,节省从零编写底层代码的时间。 OSLabs这套实验我在带学生和自己复刷的时候反复折腾过好几轮,今天把完整的踩坑记录和实操路径整理出来。如果你正准备刷南京大学的操作系统实验,或者想找个能真正动手写内核的入门项目,这篇文章应该能帮你省下大量绕路时间。
先说这个项目是什么。OSLabs对应的是南京大学计算机系的操作系统课程实验,整体基于xv6这个教学操作系统内核展开。xv6是MIT开发的类Unix教学内核,代码量控制在几千行级别,用RISC-V指令集,专门为课堂设计,能跑通完整的进程管理、虚拟内存、文件系统。南大这套实验把它做成了四个递进的Lab,每个Lab都会往内核里加东西,从进程管理到虚拟内存再到写时复制,一路做到用户态线程库。它不是一个“看着视频敲代码”的课,而是真的要求你读懂内核源码、改内核、跑通测试,最终理解操作系统到底在干什么。
很多初学者问的第一个问题是,为什么不直接学Linux内核,非要拿xv6练手。答案很现实:Linux内核几千万行代码,新手连从哪下手都不知道。xv6把操作系统最核心的概念浓缩在不到一万行代码里,你能在几周内通读全部源码,并且亲手改它、跑它、调试它。南大招办和课程团队把这个实验公开出来之后,基本成了国内高校操作系统实验的天花板之一,也是很多考研党复试前突击内核实战的首选资料。
1. 项目整体思路与实验设计
1.1 为什么选xv6而不是别的内核
先说说南大为什么选xv6。操作系统实验要落地,教学内核得满足几个条件:一是代码量要少到学生能在学期内读完,二是功能要完整到能展示操作系统核心机制,三是有成熟的模拟器支持,让学生不需要真实硬件就能跑内核。
xv6完美踩中这三个点。代码量大约六千行C语言加少量汇编,一个学期通读完全可行。功能上覆盖了进程调度、虚拟内存、文件系统、中断异常、系统调用等全部核心子系统,麻雀虽小五脏俱全。运行环境用QEMU模拟RISC-V机器,不需要买开发板,笔记本上就能跑,调试手段也比真机丰富得多。
另外一个被很多人忽略的点是,xv6的源码风格非常接近真实内核。它没有为了教学把代码过度简化,而是保留了真实内核的关键结构,比如进程控制块、页表层级、inode缓存这些概念,你在xv6里搞懂了,看Linux内核的时候会发现底层逻辑是相通的。
1.2 四个Lab的递进逻辑
南大OSLabs的四个Lab设计有一条清晰的递进线,从“会用”到“能改”再到“能造”,每一步都踩在上一步的基础上。
第一个Lab是入门热身,核心是理解xv6的进程模型和系统调用机制。任务量不大,但需要你真正读代码、跑通内核、学会调试工具。第二个Lab开始上强度,要求实现内核线程和用户态线程库,需要你理解线程切换的本质,在用户态模拟内核调度器。第三个Lab进入内存管理,实现页表、缺页异常和mmap,这一步是很多人的分水岭,虚拟内存的概念从“课本上的图”变成“自己写的代码”。第四个Lab做写时复制(Copy-on-Write),属于虚拟内存和进程管理的综合实战,要处理的问题非常接近真实内核面临的问题。
这四步走完,你对操作系统核心机制的理解深度会完全不一样。前三个Lab对应的是“书上怎么说的”,第四个Lab对应的是“实际工程里怎么做的”。
2. 实验环境搭建与工具链
2.1 开发环境配置(Windows/WSL2/macOS/Linux)
环境搭建是第一道坎,很多人在这一步卡了一两天。先说结论:最省心的方案是装一个原生Linux虚拟机或双系统,其次是Windows下用WSL2,macOS也能跑但有些兼容性小坑。
我用WSL2比较多,原因是一个终端搞定编译和调试,不需要来回切虚拟机窗口。如果你在Windows上,建议走这条路径:
# 在WSL2的Ubuntu 22.04里安装依赖 sudo apt update sudo apt install build-essential gdb qemu-system-misc \ gcc-riscv64-linux-gnu binutils-riscv64-linux-gnu \ git make python3 # 克隆南大实验代码仓库 git clone git://gitee.com/nju-operating-system-lab/oslab.git cd oslabmacOS用户需要注意,QEMU的安装方式和Linux不同:
brew install qemu brew install riscv-tools这里有个容易踩的坑:xv6教学内核需要特定版本的QEMU支持RISC-V架构,新版本通常没问题,但个别发行版预装的QEMU可能缺少RISC-V支持。如果输入qemu-system-riscv64报command not found,需要单独安装qemu-system-misc或qemu-riscv相关包。
2.2 QEMU调试环境与GDB联动
环境装好只是开始,真正提升效率的是调试环境。这是整个实验里我认为最值得提前花时间配置的一步。
xv6的Makefile里内置了调试目标,在实验目录下执行:
make qemu-gdb这会启动QEMU但暂停CPU,等待GDB连接。再开一个终端:
gdb-multiarch然后在GDB里连接QEMU:
set architecture riscv64 target remote localhost:26000连接成功之后,你就可以像调试普通程序一样调试内核了。比如在内核的进程切换函数里设断点,单步跟踪switch机制,看寄存器上下文怎么保存和恢复的。这比在代码里加printf然后重新编译要高效太多,强烈建议提前学会。
另一个提升效率的工具是vim/VS Code的远程开发插件,用WSL2的话直接在Windows上用VS Code Remote连进去,编辑体验和本地开发没区别。
3. 四个Lab的核心细节与实操拆解
3.1 Lab1:进程与系统调用
这个Lab的目标是让你搞清楚一个进程从创建到退出的完整生命周期。任务要求新增一个系统调用,同时在用户态实现一个应用程序来验证。
先读源码,重点是kernel/proc.c里的allocproc()和freeproc(),以及kernel/syscall.c里的系统调用分发逻辑。你需要理解内核是怎么通过trap机制从用户态陷入内核态的,syscall()函数怎么根据系统调用号找到对应的处理函数。
实操时有个关键细节:在proc.c里新分配的进程拿到了一个内核栈,内核栈的trapframe字段保存了用户态寄存器的完整快照。你新增的系统调用需要正确使用argint/argaddr这些参数读取函数,否则从用户态传进来的参数会拿不到。
我见过大量同学在这个Lab踩同一个坑:在用户程序里直接调用了系统调用,但忘了在user/usys.pl或对应的汇编入口里生成跳转代码,导致链接时找不到函数。xv6用的系统调用入口是通过脚本生成的,新增系统调用必须同步修改生成脚本,这个步骤很容易漏。
这个Lab有一个“陷阱”设计,涉及内核态与用户态切换时的上下文保存,你需要仔细看kernel/trap.c的usertrap和kernel/trapasm.S里的汇编代码。理解了这段,后面所有Lab的难度会降低三分之一。
3.2 Lab2:内核线程与用户态线程库
进入Lab2,事情开始变得有意思。任务要求实现两套线程机制:内核态的内核线程,以及用户态线程库。
内核线程相对好理解,本质上是给进程加一个上下文切换接口。你需要实现thread_create和thread_join,核心是掌握进程调度的切换函数swtch。这里的关键是理解每个线程要有独立的栈和独立的上下文结构,切换的时候把当前CPU寄存器的状态保存到旧线程的上下文,然后从新线程的上下文恢复。
用户态线程库则是完全另一套逻辑。它不依赖内核,而是在用户态用ucontext库或自写汇编实现上下文切换。这个Lab的难点在于你需要模拟一个“调度器”,跑在某个线程的栈上,负责切换各个用户态线程的执行流。
我在做这个Lab时花了最多时间理解的东西是:用户态线程切换时栈怎么切换。每个用户态线程要分配一块独立的栈空间,线程切换的本质就是换栈加换指令流,通过setcontext/getcontext或自写的汇编代码完成。
实操建议:写context_switch的核心汇编时,先把寄存器的保存和恢复逻辑画出来,对照RISC-V的ABI规范看清楚哪些寄存器是caller-saved哪些是callee-saved。我当时漏掉了ra寄存器的保存,导致线程切换后返回地址错乱,整个程序崩溃,调了一整个晚上才找到。
3.3 Lab3:虚拟内存与mmap
Lab3是这套实验真正的分水岭。之前的Lab你还在“操作系统外围”打转,这一步直接钻进最核心的虚拟内存机制内部。
任务要求实现mmap和munmap系统调用,让用户程序能把文件映射到进程的地址空间。这需要你理解xv6的页表机制、缺页异常处理流程、以及文件系统的交互。
先理清概念:每个进程有一个独立的页表,walkaddr函数负责把虚拟地址翻译成物理地址。mmap要做的事情是,在进程的虚拟地址空间里找一块空闲区域,建立虚拟地址到文件页面之间的映射关系,但真正的物理页分配可以推迟到访问时再触发。
这个“延迟分配”是理解虚拟内存的关键。访问一个尚未映射物理内存的页面时,CPU会触发缺页异常,内核在异常处理流程里分配物理页、把文件内容读进来、更新页表,然后重新执行触发异常的指令。整个过程对用户程序完全透明,用户只感觉到“程序能跑”,不知道背后发生了这么多事。
实操层面有几个难点。
第一,页表结构是三级页表,用PTE标志位控制读写权限。你要在trap.c里处理scause等于13(load page fault)和15(store page fault)的异常。
第二,和Lab1、Lab2不同,Lab3的测试会做大量边界检查。比如mmap的地址对齐、长度参数必须按页对齐、映射重叠时怎么办,这些细节都会成为测试用例的扣分点。
第三,内存回收时机。munmap需要判断页面是否被修改过(dirty bit),如果修改过需要回写文件,否则直接释放物理页。xv6的PTE里有D标志位,实现回写时要读这个位,这个细节被我忽略过,导致文件内容不对。
3.4 Lab4:写时复制(Copy-on-Write)
Lab4作为压轴实验,难度直接拉满。核心目标是实现fork时的写时复制优化:父进程被fork时不再复制全部物理内存,而是让父子进程共享同一份物理页,并且把页表标记为只读。当其中一方尝试写入时,触发缺页异常,内核在异常处理中复制物理页,再重新映射并恢复写权限。
这个机制为什么重要?因为真实操作系统里的fork基本都是用写时复制实现的。如果每次fork都把全部内存复制一遍,fork大程序的开销会非常夸张。写时复制让fork几乎瞬间完成,只有真正写入时才产生复制成本。
实现时最核心的逻辑在缺页异常处理函数里。Lab3你已经处理过缺页异常,Lab4的缺页处理复杂得多:你要判断触发异常的虚拟地址对应的物理页是不是共享页,如果是,就分配新页、复制内容、更新页表、设置写权限,然后返回用户态重新执行指令。
调试这个Lab最头疼的问题是“共享页面的引用计数”。一个物理页可能被多个进程的页表同时引用,全都不写时谁也不能释放;一旦有进程写入触发复制,原来那个页的引用计数要减一。如果引用计数管理出了bug,要么内存泄漏,要么一个进程释放了另一个进程还在用的页,出现极其诡异的内存破坏问题。
我在做这个实验时用了一个技巧:在物理页结构体里加一个引用计数字段,然后在进程释放页表时递归遍历并递减计数。排查引用计数bug时,写一个小的调试函数,打印每块物理页的引用次数,肉眼对比看是否和预期一致。
4. 常见问题与排错经验速查
4.1 环境与编译类问题
这个类别的问题很多时候和代码无关,纯粹是环境没配好,但你会觉得莫名其妙。
| 现象 | 可能原因 | 解决方案 |
|---|---|---|
make qemu提示找不到qemu-system-riscv64 | QEMU未安装或缺少RISC-V支持 | 安装qemu-system-misc,Linux发行版不同包名不同 |
编译报错找不到riscv64-linux-gnu-gcc | 交叉编译工具链未安装 | apt install gcc-riscv64-linux-gnu |
make grade全部失败但自己测试正常 | 测试脚本期望的输出格式不匹配 | 检查是否启用了make qemu后的默认输出,重新执行make clean |
| 代码改动后测试结果没变化 | 编译缓存或未重新编译 | 执行make clean && make强制重新编译 |
这里特别提一下make clean的重要性。xv6的构建系统有时不会正确跟踪所有依赖关系,改了一个头文件但其他文件没触发重编译,导致你测试的还是旧版本内核。遇到“明明改了代码但行为没变”的诡异情况,先无脑make clean && make。
4.2 运行与逻辑类问题
运行时的崩溃和逻辑问题才是实验的主战场。
内核panic提示“page fault”:大概率是你的页表设置有问题,或者访问了未映射的内存。用GDB看触发异常的地址,然后检查该地址在页表里有没有对应映射,以及权限位是否正确。
测试用例超时:xv6的测试脚本对每个用例有运行时间限制。如果超时,通常是你的实现陷入了死循环,或者是某个锁没有释放导致调度卡死。
僵尸进程不消失:和wait系统调用的实现有关系。检查你在proc.c里回收进程的逻辑,确认zombie状态下的进程真的被父进程回收了,而不是停留在进程表里占着位置。
空闲内存越来越少:这是经典问题。多半是kfree调用次数少于kalloc,也就是物理页泄漏了。Lab3和Lab4里最容易出现这类问题,尤其是munmap和写时复制的页面释放逻辑。写一个内存统计函数,跑几轮测试后对比可用内存数量,很容易定位是哪一部分泄漏了。
4.3 调试工具使用技巧
调试这块,建议花半小时专门练一下GDB连接QEMU的流程。具体操作前面已经写了,这里补充几个调试系统内核时特别有用的命令:
# 查看当前进程的进程控制块 p *myproc() # 查看页表项 p *walkaddr(myproc()->pagetable, 0x1000) # 查看寄存器上下文 info registers # 在内核切换点设置条件断点 b swtch if myproc()->pid == 2还有一个非常实用的技巧:在代码里加临时printf输出关键变量值,比GDB在用户态配合QEMU串口输出更直观。xv6的printf会直接打印到QEMU的终端里,而且它会自动加锁,不会多核并发输出乱序。
5. 一些个人建议与扩展方向
整套实验刷下来,我的一个总体感受是:它不是在教你“背操作系统概念”,而是在逼你“像工程师一样解决问题”。每个Lab给出的代码骨架足够你跑通基础功能,但真正拿满分需要你自己在边界条件、并发安全、内存管理这些细节上做文章。
如果你刷完四个Lab还有余力,建议深入做两件事。一是去读xv6原版(MIT 6.S081)的课程代码和作业,南大有不少实验题就是从那里演化来的,两边对照着看能加深理解。二是尝试给xkernel(南大自己开发的xv6变体)添加新功能,比如实现一个简单的信号量、加一个优先级调度算法,甚至试着移植一个简单的用户态程序。
另外一个容易被忽略的环节是写实验报告。做实验时随手记录你改动过的文件、关键函数、遇到的问题和解决思路。这些记录不仅能帮你通过验收,还是面试时非常好的项目素材。我在面试候选人时,最怕听到“操作系统实验我做了但忘了细节”,而能清晰讲出“我在写时复制里怎么处理引用计数”的候选人,给了我很深的印象。
最后,如果你在实验中途卡在某个点上很久,我的建议是:放下代码,重新去读xv6源码里对应子系统的那部分。很多看起来逻辑不对的地方,其实是你没理解xv6原有的设计意图。读源码花的时间,往往比瞎试代码省得多。祝实验顺利。
本文还有配套的精品资源,点击获取