ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

NACHOS操作系统课设全攻略:从环境搭建到系统调用调试

NACHOS操作系统课设全攻略:从环境搭建到系统调用调试 简介山东大学2020级操作系统课程设计成果基于NACHOS-3.4-UALR-2022教学内核完成面向计算机专业本科生及系统编程学习者。项目使用C语言实现覆盖进程管理、线程调度、内存管理、文件系统与同步机制等核心模块通过修改并扩展NACHOS源码将操作系统理论转化为可运行的系统代码。压缩包共633个文件大小7.6MB主要包含o目标文件、d汇编文件、cc与c源码、h头文件、makefile构建脚本、nachos可执行文件以及coff2noff等辅助工具和文档目录结构完整便于按模块检索学习。目前已有170人学习下载。这份资料适合需要参考完整课设思路、对照实验细节或复习内核实现的读者可直接查看源码组织、编译配置与运行脚本辅助完成同类操作系统实验或课程项目。1. 先看明白NACHOS 这门操作系统课设到底在考什么2022 年春季学期山东大学 2020 级学生拿到的操作系统课设包是 NACHOS-3.4-UALR-2022。这不是哪个互联网大厂的新框架而是加州大学伯克利分校传下来的教学操作系统内核UALR 版本又给它加了更现代的交叉编译支持和调试接口。第一次翻源码的人往往会懵文件夹里全是 C 文件却带着一个 MIPS 模拟器你要做的是在这个模拟器里把一个简化操作系统“补完”。课设的难点从来不在写代码而在读懂一份老内核的并发模型、地址空间划分和系统调用路径。这篇笔记面向两类人马上要交 NACHOS 作业的学生以及第一次带队做操作系统课设、想快速抓重点的助教和老师。2. 搭好 NACHOS-3.4-UALR-2022 的运行环境交叉编译与 Makefile 排错2.1 先想清楚环境要装到什么程度NACHOS 的代码分两层一层是跑在你本机 Linux 上的内核代码也就是 threads、userprog、filesys 这些目录里的 C 源文件另一层是编译出来的用户程序这些程序要被 MIPS 交叉编译器编成模拟器能读的格式然后由 NACHOS 内置的 MIPS 解释器逐条执行。很多人在这一步就走偏了试图把 NACHOS 直接编译成本机的 x86 程序跑。NACHOS 的 Makefile 默认用交叉工具链把内核编成 MIPS 指令再用nachos这个宿主程序加载它。UALR 版本在 x86 主机上做得比较顺原因在于它自带了一个模拟器前端你只要把 MIPS 工具链装好、Makefile 变量指对就能在普通 Linux 发行版上完成整个构建。我一般会建议用 Ubuntu 20.04 或 22.04 的 LTS 版本。24.04 不是不行但交叉工具链的包名和版本有变化新系统容易卡在 glibc 兼容性上。如果是新装虚拟机记得给足 4GB 内存和 20GB 磁盘编译虽然不重但课设过程中要反复 make clean 重来磁盘太小会很难受。2.2 安装 MIPS 交叉工具链三条命令的事UALR 版本的默认工具链前缀是mips-linux-gnu-在 Ubuntu 上直接装官方源里的包就行# 交叉编译器与 binutils sudo apt-get install -y gcc-mips-linux-gnu g-mips-linux-gnu binutils-mips-linux-gnu # 本机构建工具 sudo apt-get install -y make gdb-multiarch # 验证工具链是否可用 mips-linux-gnu-gcc --version参数说明gcc-mips-linux-gnu和g-mips-linux-gnu是 C/C 交叉编译器负责把 test 目录下的用户程序编成 MIPS 指令binutils-mips-linux-gnu提供mips-linux-gnu-ld、mips-linux-gnu-objcopy等工具NACHOS 的构建脚本要拿它们处理 coff 格式gdb-multiarch用来调试 MIPS 用户程序后面我会专门讲它的用法。装完先输出版本号避免后续报错分不清是工具链没装还是路径没配。如果课程包里有自带工具链目录优先用包内版本。不同课设包的 Makefile.common 变量名不完全一致最常见的是在文件顶部定义CROSS mips-linux-gnu-你只需要确认这个前缀和你装好的命令一致。2.3 编译 threads 工程最小命令与输出判断以 Threads 阶段为例完整跑通一次要执行这几步cd nachos-3.4/code/threads # 清理上一次构建产物防止旧 .o 干扰 make clean # 生成头文件依赖关系 make depend # 编译并链接 make # 安静模式运行线程自检 ./nachos -q逻辑说明make clean必须先跑因为 NACHOS 的 Makefile 对头文件依赖跟踪得不彻底直接make经常漏编译改过的头文件导致你改了synch.h但synch.o还是旧的。make depend会根据当前目录的.cc文件重新生成.d依赖文件这一步能解决大部分“改了头文件没效果”的怪问题。./nachos -q的-q是 quiet 模式不打印调试噪声只输出 ThreadTest 的测试结论适合做回归冒烟。跑完看到类似Test thread finished或Machine halting的输出就说明环境通了。如果报错说找不到../mips.h或coff.h去Makefile.common里把NACHOS_HOME或INCLUDE_DIR指到 code 目录如果报mips-linux-gnu-gcc: command not found说明工具链没进 PATH用which mips-linux-gnu-gcc确认。2.4 常用的调试参数别全开要分次开NACHOS 的调试输出靠-d参数控制后面的字母决定打印哪类信息。我整理了一张常用表做实验时按需打开参数作用建议使用时机-d t打印线程创建、切换、销毁过程排查死锁、线程跑飞-d s打印调度器内部操作看就绪队列变化-d i打印中断现场分析时钟抢占导致的问题-d u打印用户指令执行轨迹用户程序崩溃时逐条跟踪-d a打印系统调用进入与返回UserProg 阶段调试系统调用-rs seed用固定整数 seed 驱动伪随机抢占复现并发问题同一 seed 必现同一时序-s退出时打印统计信息验证页表命中率、磁盘 IO 次数调试时最忌讳一次性-d t -d s -d i -d u全开输出量太大新手完全抓不住重点。我习惯先开最相关的一类跑挂之后再看第二类。比如线程死锁先-d t看谁阻塞在哪确定不是调度问题再加-d s。提示-rs是并发调试的后悔药。NACHOS 的抢占式调度由时钟中断驱动在随机数种子的影响下每次运行中断点都不同。固定种子后同一次运行会复现完全相同的中断时序这比反复碰运气高效得多。3. 从 Threads 模块开始把同步原语写成能过检的代码3.1 源码地图哪些文件是必改项线程阶段的核心代码集中在code/threads/目录你应该优先读这几个文件thread.h/thread.cc线程对象本身包含栈分配、Yield、Sleep、Finish这些基础操作scheduler.h/scheduler.cc就绪队列管理ReadyToRun把线程放入队列FindNextToRun挑下一个运行的线程Run完成上下文切换synch.h/synch.cc信号量、锁、条件变量的实现这是绝大多数课设题目的主场main.cc内核入口启动后会把命令行参数解析完最后调用ThreadTest执行你写的测试函数。很多课设把题目设计成“补全信号量的P/V操作”或“实现条件变量”但我建议你直接读原版代码而不是等题目发下来再动手。原版 NACHOS 的信号量和锁是完整的你的任务往往是在此基础上扩展或重写。读源码时抓住一条主线线程切换是协作式的Yield是主动让出TimerInterrupt是抢占式的时钟中断这两条路径都会把 CPU 交给另一个线程。处理机调度这个知识点在这里就活了。NACHOS 默认的调度策略是简单的 FIFO 加时间片轮转但你只要改FindNextToRun的选队逻辑就能体会优先级调度和短作业优先的差异这也是操作系统课设里很喜欢出的一道改错题。3.2 自己实现条件变量释放锁和睡眠的顺序决定生死条件变量的Wait操作是教材重点也是 NACHOS 源码里最容易出问题的地方。一个正确的Condition::Wait必须完成三件事把当前线程挂到等待队列、释放锁、睡眠。这三步的顺序错了并发正确性就崩了。下面是一个我常用的教学骨架// synch.cc 中 Condition::Wait 的可靠性写法 void Condition::Wait(Lock *conditionLock) { // 前置校验只有持有锁的线程才能等条件变量 ASSERT(conditionLock-IsHeldByCurrentThread()); // 1. 先把当前线程放入等待队列 waitQueue-Append(currentThread); // 2. 释放锁 conditionLock-Release(); // 3. 睡眠让出 CPU currentThread-Sleep(); // 4. 被唤醒后重新抢锁抢到才能返回 conditionLock-Acquire(); }逻辑说明先入队、再释放锁、最后睡眠是教科书给的标准顺序。如果反过来先释放锁再入队那么在释放锁和入队之间的时间窗口里另一个线程调用Signal会看到等待队列为空于是什么都不做当前线程随后入队睡眠唤醒信号就永久丢失了如果先睡眠不释放锁那当前线程就是抱着锁睡死其他线程永远拿不到锁。在 NACHOS 的单核模拟环境里Release和Sleep之间不会插入其他线程执行所以这个顺序是安全的但你心里要清楚边界。Signal的实现相对简单但同样有语义选择// synch.cc 中 Condition::Signal 的教学写法 void Condition::Signal(Lock *conditionLock) { // 调用者必须持有锁 ASSERT(conditionLock-IsHeldByCurrentThread()); // 从等待队列取出一个线程放入就绪队列 if (!waitQueue-IsEmpty()) { Thread *next waitQueue-RemoveFront(); scheduler-ReadyToRun(next); } }这段代码体现的是 Mesa 语义Signal只负责把等待者变成就绪不立刻切换过去。Hoare 语义则要求Signal直接把 CPU 让给唤醒者实现复杂得多。NACHOS 原生采用近似 Mesa 的做法所以你的测试代码不能假设Signal之后马上就能抢到锁。3.3 写一个屏障测试把死锁暴露在提交之前只写同步原语不写测试等于没做。我给课设学生最常布置的一道题是实现屏障 BarrierN 个线程同时到达某个点之后所有线程再一起放行。这个测试能覆盖条件变量和信号量的核心场景而且死锁特征特别明显。// 用信号量实现 Barrier放到自己的测试文件里 class Barrier { public: Barrier(int n) : count(n), nThreads(n), mutex(1), turnstile(0) {} void Wait() { mutex.P(); // 保护 count 的修改 count--; if (count 0) { // 最后一个线程到达放行所有等待者 for (int i 0; i nThreads; i) turnstile.V(); } mutex.V(); turnstile.P(); // 每个线程都在这里等放行 } private: int count; int nThreads; Semaphore mutex; Semaphore turnstile; };逻辑说明mutex信号量保证count的减少是原子的最后一个线程发现count 0后连续做 N 次V操作把 n 个等待在turnstile上的线程都唤醒。这里有个关键细节最后一个线程自己也要执行turnstile.P()所以V的次数必须等于线程总数否则最后一个线程会把自己卡死。这个测试既能验证信号量的P/V是否配对也能暴露唤醒丢失。跑这个测试时用-rs 1这样的固定种子多跑几遍。如果每次都正常退出再换几个不同的 seed 测确认不是“恰好没触发”。我见过不少实现开固定种子能过换一个 seed 就死锁原因多半是Wait里的顺序写错了。交作业前这个测试应该是你的常驻项目而不是写一次就删。4. 走通 UserProg 阶段加载、参数传递与系统调用三条线4.1 先分清谁在跑谁模拟器与用户程序的关系进入 UserProg 阶段后很多人的困惑从“线程怎么切换”变成“用户程序到底怎么被带起来”。NACHOS 的machine/mipssim.cc里有一个 MIPS 解释器循环它逐条读取用户程序的 MIPS 指令翻译成内存读写和寄存器操作。这个解释器本身运行在你的 x86 宿主进程里而用户程序只是躺在mainMemory数组里的字节流。所以整个加载过程可以拆成三步把用户可执行文件读进mainMemory建立虚拟页到物理页的映射关系也就是初始化页表把 CPU 寄存器设置为用户程序的入口状态让解释器开始执行。课设里最常见的失败恰恰是第一步和第三步没对齐文件读进来了但页表翻译错了CPU 从错误地址取第一条指令然后一路执行垃圾数据最终报bus error。调试时不要直接看汇编先确认入口地址和代码段起点是否一致。4.2 把 coff 可执行文件变成地址空间页表初始化NACHOS 的用户程序格式是 coff一个简化的可执行文件格式。内核要先读文件头拿到代码段和数据段的大小再按页分配物理内存。下面是一个阶段一的典型写法// AddrSpace 构造函数为用户程序分配物理页并初始化页表 AddrSpace::AddrSpace(OpenFile *executable) { // 从可执行文件头部读入 coff 头 executable-ReadAt(noffH, sizeof(noffH), 0); ASSERT(noffH.noffMagic NOFFMAGIC); // 计算需要的页数代码段 数据段向上取整到页边界 numPages divRoundUp(noffH.code.size noffH.initData.size, PageSize); pageTable new TranslationEntry[numPages]; // 阶段一使用简单的线性映射 for (int i 0; i numPages; i) { pageTable[i].virtualPage i; pageTable[i].physicalPage i; pageTable[i].valid TRUE; pageTable[i].use FALSE; pageTable[i].dirty FALSE; pageTable[i].readOnly FALSE; } // 把代码段和数据段从文件拷入内存 // 具体偏移量按 noffH 中的字段计算 }参数说明noffH.code.size和noffH.initData.size是 coff 头里记录的代码、数据字节数divRoundUp是 NACHOS 自带的向上取整函数valid位表示该页是否可访问readOnly位表示是否只读。这里最容易被忽视的是readOnly字段代码段应该标成只读但如果你的课设要求简化全标FALSE也能跑代价是用户程序可以非法改写自己的代码段。阶段一直接做全量加载没问题因为物理页足够。到了 VM 阶段你要改成按需调页把valid置为FALSE触发缺页中断时再从文件读入。这一步是后面作业的伏笔现在把页表结构看懂后面能省一半时间。4.3 用户栈与参数传递argc/argv 到底放在哪NACHOS 用户程序通过Exec系统调用启动父进程要把参数传给子进程。这一段的经典实现是在用户地址空间顶部预留一块栈区把参数从高地址往低地址压栈再让 CPU 的栈指针指向正确位置。栈布局长这样栈内偏移内容sp 0argc参数个数sp 4argv[0] 指针sp 8argv[1] 指针......sp 4 * argcargv[argc - 1] 指针sp 4 * (argc 1)argv[argc]固定为 0更低地址各参数字符串的实际字节很多同学会把指针表和字符串区放反导致用户程序从栈里读出错误的参数地址。记住一个口诀指针表在低地址、靠近栈顶字符串在高地址、远离栈顶。MIPS 的栈是向下增长的所以压字符串时要从高地址开始往低地址写最后把sp指针移到指针表上方。写参数时还要注意对齐。MIPS 要求 4 字节对齐字符串长度不是 4 的倍数就要补零。这个坑在测试阶段不明显因为halt程序不读参数但等你在Exec里传入文件名时错位会导致用户程序读出乱码。4.4 系统调用实现骨架从 Halt 到 Exec系统调用的入口很统一用户程序执行syscall指令解释器识别后把系统调用编号放在寄存器 2参数放在寄存器 4、5、6然后跳转进内核的处理函数。你实现时只需要写一个分发函数// 系统调用分发函数按编号处理请求 void SyscallHandler(int type) { int result 0; switch (type) { case SC_Halt: // 最简系统调用直接停机 interrupt-Halt(); break; case SC_Exit: // 当前线程退出调度器选下一个线程 currentThread-Finish(); break; case SC_Exec: { // 从用户寄存器 4 读出参数字符串地址 int nameAddr machine-ReadRegister(4); // StartProcess 负责加载新程序返回子进程标识 result StartProcess(nameAddr); break; } case SC_Join: { // 等待指定子进程退出 // 若子进程还在运行当前线程应睡眠 break; } default: printf(Unknown syscall %d\n, type); ASSERT(FALSE); } // 把返回值写回用户可见的 v0 寄存器 machine-WriteRegister(2, result); }逻辑说明ReadRegister(4)读出来的是用户程序的虚拟地址是用户在 MIPS 指令里传的参数地址不是宿主进程地址。你必须在用户内存里读出字符串这中间要做一次地址翻译。很多课设的翻车点就在这直接把虚拟地址交给strcpy读出来全是乱码。正确做法是用machine-ReadMem逐字节读取或先做页表翻译再直接访问mainMemory。Join是最容易做错的一个系统调用。它要求父线程阻塞到子线程退出你要是写一个忙等待循环在单核模拟器里会让系统卡死正确姿势是让父线程睡在一个“进程退出条件变量”上子线程执行Exit时唤醒它。这个逻辑跟第 3 章的条件变量实现直接挂钩属于融会贯通题。提示调试系统调用阶段把-d a单独打开它能打印每次系统调用的编号和寄存器现场。输出很干净适合自己比对传参正确性。千万不要和-d u一起开指令级跟踪会把输出刷到怀疑人生。5. NACHOS 课设高频翻车现场5 个坑的现象、原因与后悔药5.1 编译能过链接时却报 undefined reference现象在 threads 目录执行make编译出一堆.o最后链接阶段刷出几十行undefined reference to ...感觉像是整个模块都没编进来。原因这是 NACHOS 课设里最常见的第一道坎多半是make depend没有执行或者 Makefile.common 里的 OBJS 列表漏掉了你新加的.o文件。还有一种情况是你改了头文件但.o还是旧的make没有感知到依赖变化。解决先make clean再make depend最后make。如果还是缺符号打开Makefile.common把新加入的源文件对应.o追加进OBJS变量。不要跳过make depend它不是可选步骤而是 NACHOS 这种老式 Makefile 体系正常工作的前提。5.2 用户程序一跑就报 bus error 或 Illegal instruction现象./nachos -u test/halt启动后立刻崩溃屏幕上出现bus error、illegal instruction或machine-ReadMem断言失败。原因崩溃点几乎都集中在地址翻译。可能是页表里valid位没置对可能是代码段加载偏移算错也可能是 PC 寄存器初始值指向了数据段。MIPS 解释器取到乱码指令时就会报 illegal instruction。解决先用-d u分步看第一条指令的地址再回头核对 coff 头里的 codeStart 和页面大小计算。我给学生的排查顺序是先确认文件头魔数打出了NOFFMAGIC再打印分配到的numPages最后检查 PC 初始值。按照这个顺序走百分之八十的 bus error 都能定位。5.3 并发测试时好时坏开固定种子也偶发死锁现象用自己写的测试跑生产者消费者多数时候正常偶尔卡死换一个-rs种子后必现死锁。原因唤醒丢失。典型实现错误是在条件变量里先释放锁再入队或者Signal在队列为空时直接返回。教科书要求的“先入队、再释放锁、最后睡眠”顺序很多人只记住了结论没理解为什么改着改着就调换了顺序。解决回到第 3 章那个骨架严格按顺序写。加一个 ASSERT 校验Wait调用者必须持有锁。再把-rs固定下来跑同一个种子十遍如果必现就是用调试输出观察线程阻塞位置。这题是课设里最能拉开分差的点值得反复打磨。5.4 改了 test 目录的用户程序运行结果却没变化现象把test/halt.c里加了自己的打印重新 make 后./nachos输出和之前一模一样。原因test 目录里的用户程序是独立编译的你执行的是内核目录下的make它根本不会重新编译 test 目录。更隐蔽的是如果内核加载的可执行文件路径写的是固定文件名你改了源码但可执行文件没更新加载的还是旧二进制。解决进入test/目录单独执行make clean make再回到内核目录运行nachos。同时检查启动参数里加载的文件路径确保指向新生成的可执行文件。建议每次改完 test 里源码都顺手把文件时间戳看一眼确认 mtime 更新了。5.5 调试日志像黑匣子输出太多找不到关键行现象开了两三个调试参数后终端刷屏快赶上 DDOS想看的线程切换记录被淹没在大量指令日志里。原因调试参数全开信息叠加后没有主次。NACHOS 的 DEBUG 宏输出不带线程名、不带锁对象地址全堆在一个输出流里肉眼根本分不清谁是谁。解决分次开会话一次只开一个调试维度。再给自己的锁和条件变量加带标识的打印宏比如printf(Thread %s waiting on lock %p\n, currentThread-getName(), this);。日志重定向到文件后用 grep 过滤关键词不要盯着终端看。体感上这个过程从“黑匣子”变成“可追溯事件流”后你的调试效率会翻倍。6. 把随机种子当成后悔药NACHOS 并发回归测试的两个习惯6.1 固定种子跑回归让玄学死锁变成必然事件并发 bug 最难受的地方是不稳定重现。我交课设前必做的一件事是用脚本把多个固定种子跑一遍把“偶尔挂”变成“必现挂”。下面这个脚本可以放在 code/threads 目录下#!/usr/bin/env bash # 并发回归脚本固定三个种子逐个运行并检查输出 for seed in 1 2 3; do ./nachos -rs $seed -d t run_$seed.log 21 if grep -q Thread test finished run_$seed.log; then echo PASS seed$seed else echo FAIL seed$seed, see run_$seed.log exit 1 fi done参数说明-rs $seed让每次运行的时钟中断序列完全一致-d t输出线程切换细节到日志grep检查你的测试是否跑完。脚本会停在第一个失败的种子并保留完整日志。这个习惯帮我省了无数重复运行的等待时间。6.2 交作业前的冒烟清单三个命令判断该不该提交我每次收工前不会跑大测试而是按顺序跑三件小事先跑./nachos -q确认线程模块还活着再跑./nachos -u test/halt确认用户程序加载没退化最后跑自己的 Barrier 测试确认同步逻辑没被改崩。这三件事加起来不到半分钟但能把课设里最容易互相影响的三个模块全部覆盖到。改动任何一个模块后都跑一遍比攒到最后一起验证稳妥得多。养成这个习惯后我交 NACHOS 课设心里特别稳。没事别随机换工具链版本也别为了调格式重写整段核心代码。NACHOS 这套老代码像个倔脾气的老机器它不怕笨办法怕你自作聪明。希望帮到你。本文还有配套的精品资源点击获取
返回列表