
简介面向操作系统课程学习者南京大学操作系统课程实验的开源实现覆盖Lab1至Lab4全部要求从实模式与保护模式下的Hello World、在保护模式下加载磁盘程序运行到printf格式化输出、进程切换机制以及fork/sleep/exit与信号量等系统调用实现。压缩包共107个文件以C源文件、头文件、汇编文件及makefile构建脚本为主另有少量Perl辅助脚本整体仅65KB结构十分紧凑适合对照实验网站逐模块阅读源码。代码按lab1至lab4分目录组织bootloader、内核与系统调用等模块划分清晰便于理解操作系统启动引导、中断处理、进程管理与同步机制的具体写法同时包含UbuntuQEMU环境说明方便本地复现实验。已有1879人浏览学习对于正在攻克系统底层实验、希望参考完整实现思路的学生具有直接价值。 做完整套南京大学操作系统实验OSLabs之后有一点我很确定操作系统这层窗户纸靠看书是捅不破的。哪怕你在B站把理论课刷了三遍CSAPP课后题全做了一遍第一次面对那一堆启动汇编、中断向量表和页表结构时照样一脸懵。但反过来如果你把 OSLabs 从头到尾推过一遍再回头看理论课里的进程调度、虚拟内存、文件系统那些原本很抽象的概念会自动落到具体的寄存器、内存地址和函数指针上有种“原来如此”的通透感。这篇文章我会把这套实验的整体设计、环境搭建、关键实验的解题思路、以及我在实操中踩过的坑一次性讲清楚。不管你是在校学生准备选修这门课还是已经工作想通过补实验来加深对 OS 的理解都可以拿来参考。前半部分偏认知和方案选型后半部分偏实操和排错建议按顺序读遇到需要的部分可以直接跳到对应小节。1. 项目整体认知与实验设计思路1.1 实验体系概况南京大学操作系统实验的公开版本核心目标是让你在真实硬件模型上实现一个可运行的操作系统内核。它不像普通课程作业那样只写几个调度算法函数而是要求你从 CPU 上电后的第一条指令开始逐步完成中断处理、内存管理、进程切换、系统调用甚至文件系统。实验通常跑在 QEMU 模拟的 RISC-V 或 x86 平台上官方给出来的是一个“半成品”内核编译器、链接脚本、基本的启动汇编都已经备好但核心机制需要你自己补齐。比如用户态进程的加载与切换、系统调用从用户态陷入内核态的过程、页表映射与缺页异常处理都是实打实的代码量不是填空题。我接触到的版本分成了几个递进式 Lab第一类偏向“基础机制”比如调试环境、串口输出、内核线程中间阶段会要求实现进程与系统调用涉及 trap 的完整流程再往后就是虚拟内存页表、缺页处理与文件系统。每个 Lab 之间有明确依赖关系前面的机制没写扎实后面根本跑不起来。1.2 为什么操作系统实验要“写代码”而非“背概念”很多人以为操作系统难在概念多其实概念再多也就是进程、线程、地址空间、文件这几座大山。真正的难点在于“状态”和“时机”。你写一个用户程序逻辑错了大不了重新打印但内核代码一旦出错可能是某个中断没关、某个栈指针被改坏、某个页表项没置 Present 位系统直接静默死机或者反复复位连报错都没有。南大这套 OSLabs 有意思的地方在于它不会替你掩盖这些“脏活”。QEMU 启动后内核的每一行代码都得真实执行你必须亲手处理汇编层面的寄存器保存和恢复问题。比如实现系统调用时如果 trap entry 里少保存了一个通用寄存器用户态程序回来时某个变量就神秘变化这种 bug 很难靠肉眼看出来只能靠调试器一步步追。写多了你会有一种感觉操作系统根本不是“设计”出来的而是“调试”出来的。每个机制最后能被接受不是因为它理论优美而是因为它在所有边界条件下都能活下来。1.3 适合谁的实验如果你是科班学生正在学操作系统原理课程这套实验会是最硬核的补充如果你已经工作想自己实现一个小内核来加深对 Linux 的理解同样值得完整做一遍。但说实话它不适合零基础入门的人。我建议先具备 C 语言指针、结构体、链表和简单汇编知识最好还写过一点 RISC-V 汇编或看过 CSAPP。否则光是理解那个 5 级页表就够呛别说自己写了。2. 开发环境搭建与工具链选择2.1 环境与依赖准备清单这套实验对操作系统平台有一定要求但不算特别苛刻。官方推荐的是 Linux 环境我在 Ubuntu 22.04 和 WSL2 里都跑通过。核心依赖包括RISC-V 交叉编译器riscv64-linux-gnu-gcc 或 riscv64-unknown-elf-gcc、QEMU 系统模拟器、GDB 调试器、make、git。如果你要用官方提供的测试脚本可能还需要 python3 和 pyserial。这里有个关键点编译内核时尽量用 riscv64-unknown-elf-gcc 而非 riscv64-linux-gnu-gcc。后者链接出来的程序会链接 Linux 的 glibc 启动代码不适合裸机bare-metal内核。官方仓库一般会准备好 Makefile 和工具链检测但如果你自己配环境一定记得先用riscv64-unknown-elf-gcc --version验证一下工具链是否可用并确认 QEMU 能启动自带的 ELF 镜像。2.2 QEMU 与 GDB 的调试链路我见过很多同学一上来就直接make run发现屏幕黑一下就没反应然后开始瞎改代码。这种玩法效率太低。正确姿势是先建立 QEMU 与 GDB 的调试链路。QEMU 启动时加-S -gdb tcp::1234-S表示启动后暂停等待调试器GDB 端执行target remote localhost:1234连过去。连上之后先在_start入口下断点再执行continue。配合使用一个~/.gdbinit文件把常用命令做成宏能节省大量时间。我在实验期间一直开着两个终端一个跑 QEMU一个跑 GDB。改完代码重新编译后QEMU 直接重启GDB 重新连接整个过程十秒内完成。如果不用 GDB 而是靠 print 打日志我建议把串口输出函数比如printf的缓冲关掉否则输出顺序会和实际执行顺序错乱。2.3 构建系统的几个细节官方仓库的 Makefile 通常会提供make run、make debug、make test等目标。make debug已经帮你接好了 GDB 和 QEMU 的调试参数直接riscv64-unknown-elf-gdb build/kernel.elf即可。我自己的习惯是在 Makefile 里加两个目标make dump用来导出内核的汇编反汇编结果make mmio用来列出外设寄存器地址。碰到虚拟地址和物理地址不一致的问题时反汇编结果能快速帮你确认某条指令到底访问的是哪个内存地址。内核开发最大的障碍不是代码写不出来而是出错后定位太慢构建系统就是你的第一道防线。3. 核心实验拆解与实现思路3.1 启动流程从冷启动到第一个内核线程实验最开始的部分一般是启动流程也就是从 QEMU 加电开始到第一个 C 函数被调用为止。看起来简单但它要求你理解_start、链接脚本Linker Script、栈初始化、BSS 段清零这些概念。这里最容易犯的错是栈初始化顺序。RISC-V 架构里sp寄存器在启动时是未定义的必须由启动代码先设置到内核栈顶。如果链接脚本里定义了_stack_top符号但启动汇编忘记加载它第一个call指令就会把返回地址压到一个非法地址内核直接跑飞。我当时排查这个问题用了很久代码看起来没问题但第一次调用 C 函数就崩最后用 GDB 查看sp的值才发现它指向了 0。实操上建议把启动流程拆成三句话设栈、清零 BSS、跳转到kmain。设栈用la sp, _stack_top清零 BSS 用循环逐字写 0最后call kmain。每一步都在 GDB 里确认sp变化和跳转目标是否正确。不要跳过这一步因为后面所有功能都依赖这里。3.2 中断与系统调用trap 的全流程理解到了系统调用和中断处理的 Lab理论上是最容易让人崩溃的部分。核心在于 RISC-V 的异常处理流程CPU 检测到异常后自动把当前 PC 保存到sepc同时把特权级切换到 S-mode 或 M-mode跳转到stvec指向的入口。然后所有的活都交给你保存上下文、判断异常原因、分发处理、恢复现场、sret返回。我在实现时把所有寄存器保存到一个trapframe结构体里用csrw sscratch保存指向这个结构体的指针这样异常处理函数就能通过sscratch找到完整上下文。这个设计很常规但有个坑嵌套中断。如果处理一个外部中断时又打开了中断理论上可能出现嵌套实验里可以简化成“处理期间关中断”避免上下文被覆盖。另一个容易忽略的点是sret回用户态时需要把用户态的程序计数器放回sepc同时恢复sstatus里的特权级位。如果你只保存了通用寄存器忘了sstatus返回时可能会回到错误的特权级然后再次触发异常进入死循环。3.3 虚拟内存与页表管理从直映射到用户地址空间虚拟内存实验是整套 OSLabs 的分水岭。这里需要实现多级页表的创建、地址映射切换、tlb 刷新以及用户进程的独立地址空间。RISC-V 的 Sv39 分页机制把 64 位虚拟地址拆成 99912 的偏移每个页表项占 8 字节。我会建议先搞清楚物理内存分配器的接口再动页表代码。如果kalloc返回的物理页没有清零页表里残留旧数据会导致映射错乱尤其是指针型 PTE 上残留的旧值可能让你跳到一个完全错误的下级页表。每次新建页表时强制memset(satp)相关内容能省掉大量查 bug 时间。从直映射切换到用户页表时sfence.vma指令不能省。在调试时如果发现修改页表后 CPU 仍然访问旧地址大部分情况就是没有刷 TLB。另外用户进程的加载地址、栈地址和 trapframe 的物理地址应当提前规划好否则系统调用时内核无法找到用户页表里的上下文结构切换就会失败。3.4 进程调度与并发控制锁的粒度决定生死进程调度实验里最重要的不是调度算法本身而是切换时机的正确性。一个常见的写法是在时钟中断处理中调用schedule()选择下一个进程然后切换上下文。这里有个并发问题频繁开关中断会导致时钟中断丢失而完全不开中断又会让调度变成“假并发”其他进程饿死。我的做法是在schedule()外层关闭中断切换完成后在switch_to返回时重新开中断。注意switch_to必须像函数调用一样保存被调用者保存寄存器否则新进程恢复现场时寄存器状态是乱的。你可以把进程上下文直接设计成callee-saved寄存器加 PC 的组合这样switch_to看起来就像普通函数调用切换完“返回”到另一个进程的 PC。锁的粒度要尽量小尤其是页表分配器和进程链表上的锁。如果持有锁期间进入睡眠或开关中断容易造成死锁。实验里常见死锁现象是系统在输出一行字之后全部卡住通常就是某个锁没释放或中断没关。可以用一个小技巧在关键操作前后打印“进入/离开”日志快速确认卡点。3.5 文件系统与持久化最后一公里如果实验包含文件系统部分一般会要求在一个虚拟磁盘上实现简单的块管理、inode 和文件读写。相比前面的机制文件系统更强调数据结构设计。磁盘上的布局一旦定下来后续兼容性和扩展性都很受影响。可以先把磁盘抽象成若干固定大小块用位图记录空闲块再用 inode 表记录文件元数据。关键点是写盘顺序如果先写数据块再写 inode机器突然断电会导致数据块被分配出去但 inode 没更新反过来先写 inode 再释放旧数据块又可能覆盖还没刷盘的内容。我在实验里简化成只保留一级间接块但每次写盘前都保证先写数据再写元数据实测下来没有被测试脚本扣分。4. 常见问题与排查技巧实录4.1 QEMU 黑屏或反复复位这是最常见的启动问题。不要急着改代码先用 GDB 连上去看 PC 停在哪里。如果 PC 停在 0x1000 附近反复跳说明 QEMU 的 ROM 加载没问题但固件跳转到内核后崩溃了。重点检查_start里的sp初始化、链接脚本的内存布局以及 BSS 段清零是否有越界。还要检查编译出来的 ELF 是 RV64 还是 RV32QEMU 指定的是-machine virtCPU 型号要和编译选项匹配。这个错误很隐蔽因为编译器和 QEMU 不会主动报错只是程序跑起来行为诡异。4.2 GDB 断点不生效或者单步乱跳如果断点设在内核函数上但一直不触发很有可能是内核代码运行在虚拟地址空间而 GDB 加载的符号表是基于链接地址的。解决办法是把断点设在物理地址对应的虚拟地址上或者让 GDB 使用与 QEMU 相同的内存映射。更直接的排查方式在kmain入口加一句固定串口输出如果能看到输出说明内核执行到了这里问题出在后续流程而非断点设置。4.3 用户态程序返回时寄存器被破坏如果你在系统调用返回后用户程序某个变量值不对大概率是 trap 恢复时没有完整恢复寄存器或者恢复了错误的地址。我建议把trapframe里保存寄存器的顺序和恢复顺序做成镜像关系并在关键位置打印sepc、sp、a0-a7的值做对照。一个省心的做法是恢复完成后在sret之前执行csrw sepc, t0将某个保存的临时寄存器填好避免覆盖。4.4 常见问题速查表现象可能原因排查方向启动后 PC 卡在 0x1000内核入口地址错误 / 链接脚本不对GDB 查看 entry 地址第一次调用 C 函数崩溃sp未初始化 / 栈溢出检查_stack_top和栈大小调用printf无输出串口初始化失败 / 缓冲区问题直接读 UART 寄存器验证系统调用返回后用户变量丢失trapframe 恢复不完整检查寄存器保存/恢复顺序修改页表后访问旧地址TLB 未刷新执行sfence.vma多进程切换后全部卡死锁未释放 / 中断未恢复打印进入/离开日志定位磁盘文件写入后读不出写盘顺序错误 / 元数据不一致检查 inode 与数据块的同步5. 学习路径与个人经验5.1 从“跑通测试”到“理解设计”很多同学做这类实验容易陷入“面向测试编程”的状态测试过了就欢呼过不了就盲改。但真正的收获来自第二个阶段。当所有测试都通过之后我建议你回头重读一遍自己的代码给每一段加注释这里的sret为什么能返回这个分支处理的是哪种异常如果中断嵌套会发生什么我做完每个 Lab 后都会重新画一张简化的状态机图进程从创建到运行到等待到退出经过了哪些函数、改了哪些寄存器。这张图画完操作系统理论课里那些晦涩概念就不再是“名词”而变成了一张可以随时查询的“地图”。5.2 调试器的神级用法条件断点GDB 条件断点是真的救命。比如我只想在内核切换到某个 pid 时才停下来可以直接break schedule if current-pid 2。这个技巧在处理并发 bug 时特别有效可以不用一直按continue观察几十次。另一个常用命令是watch监测某个内存地址被改写的情况。内核崩溃很多时候是某个野指针覆盖了关键结构体watch能帮你精确定位是哪里写坏的。5.3 给后来者的几点建议不要一上来就追求“完美设计”。操作系统内核的实现充满权衡先跑通再优化。每个 Lab 开始前先花三十分钟阅读官方给出的 README 和测试用例明确评分点否则容易做了一堆漂亮但没用上的功能。版本管理也重要。我习惯每完成一个小目标就 commit 一次代码写坏了可以直接回退不用靠注释大法。在找 bug 时git diff能告诉你“这次改动到底动了什么”配合 GDB 排查反而更快。最后一个私人习惯把官方仓库里的Makefile完整读一遍。里面藏着很多作者的设计思路比如默认的编译选项、链接脚本里各段的内存位置这些都是文档里不会明说的“隐藏提示”。把这层理解透了实验难度会降低一大截。本文还有配套的精品资源点击获取