【操作系统】Nachos 多道程序设计

2021SC@SDUSC

题目要求

英文原版见官网

Nachos 的第二个阶段是支持多道程序设计。和第一次作业一样,我们给你一些你需要的代码;你的任务就是完成系统并加强它。到目前为止,你为 Nachos 编写的所有代码都是操作系统内核的一部分。在一个真正的操作系统中,内核不仅在内部使用它的程序,而且允许用户级程序通过系统调用访问它的一些程序。

第一步是阅读和理解我们为你编写的系统的一部分。内核文件在nachos.userprog包中,还有一些额外的机器模拟类会被使用:

  • Processor模拟了一个 MIPS 处理器;
  • SerialConsole模拟了一个串行控制台(用于键盘输入和文本输出);
  • FileSystem是一个文件系统接口。要访问文件,请使用Machine.stubFileSystem()返回的FileSystem。该文件系统可访问test目录中的文件。

这项任务的新内核文件包括:

  • UserKernel.java:一个多程序的内核;
  • UserProcess.java:一个用户进程;管理地址空间,并将一个程序加载到虚拟内存;
  • UThread.java:一个能够执行用户 MIPS 代码的线程;
  • SynchConsole.java:一个同步的控制台;使得在多个线程之间共享机器的串行控制台成为可能。

在这项作业中,我们给你一个模拟的 CPU,它模拟了一个真实的 CPU(MIPS R3000芯片)。通过模拟执行,我们可以完全控制一次执行多少条指令,地址转换如何进行,以及如何处理中断和异常(包括系统调用)。我们的模拟器可以运行由 C 语言编译成 MIPS 指令集的正常程序。唯一需要注意的是,不支持浮点运算。

我们提供的代码可以一次运行一个用户级的 MIPS 程序,并且只支持一个系统调用:halthalt所做的就是要求操作系统关闭机器。这个测试程序可以在test/halt.c中找到,它代表了最简单的支持 MIPS 的程序。

我们在 Nachos 发行版的test目录中提供了其他几个 MIPS 示例程序。你可以使用这些程序来测试你的实现,或者你可以编写新的程序。当然,在你实现适当的内核支持之前,你将无法运行利用 I/O 等功能的程序!这将是你在本项目中的任务。

test目录包括 C 源文件(.c文件)和 Nachos 用户程序二进制文件(.coff文件)。二进制文件可以在test目录下通过运行gmake构建,也可以从proj2目录下通过运行gmake test构建。

要运行halt程序,请到test目录再gmake;然后到proj2目录,gmake,并运行nachos -d ma。追踪用户程序被加载、运行和调用系统调用时发生的情况('m'调试标志启用 MIPS 反汇编,'a'调试标志打印出进程加载信息)。

为了编译测试程序,你需要一个 MIPS 交叉编译器。在教学机上已经安装了 mips-gcc(详见测试目录中的 Makefile)。如果你没有使用教学机,你必须下载适当的交叉编译器并相应地设置 ARCHDIR 环境变量。

构建一个与 Nachos 兼容的 MIPS 二进制文件有多个阶段(所有这些都由test Makefile处理):

  1. 源文件(*.c)被mips-gcc编译成对象文件(*.o);
  2. 一些对象文件被链接到libnachos.a,即 Nachos 标准库;
  3. start.s被预处理并组装成start.o。该文件包含初始化进程的汇编语言代码。它还提供了系统调用的 “存根代码”,使系统调用得以调用。这就利用了 MIPS 的特殊指令syscall,它可以向 Nachos 内核捕捉调用系统调用的信息;
  4. 一个对象文件与libnachos.a链接,产生一个与 Nachos 兼容的 MIPS 二进制文件,其扩展名为*.coff。(COFF 代表通用对象文件格式,是一种行业标准的二进制格式,Nachos 内核可以理解这种二进制格式)。

你可以通过运行以下程序来运行其他测试程序:

nachos -x PROGNAME.coff

其中PROGNAME.cofftest目录中 MIPS 程序二进制的名称。请随意编写你自己的 C 测试程序 – 事实上,你需要这样做来测试你自己的代码!

与实验一相同,你应该在项目中提交并记录你的测试用例(包括你的代码的 Java 和 C 部分)。在这个项目中,大多数测试用例将以 C 语言程序的形式实现,测试你的系统调用,但也有可能在 Java 中进行一些“内部”测试。

题目 1

实现文件系统调用(记录在syscall.h中的creatopenreadwritecloseunlink)。你将在UserProcess.java中看到halt的代码;你最好也把你的新系统调用放在这里。注意,你不是在实现一个文件系统;相反,你只是让用户进程有能力访问我们已经为你实现的文件系统。

  • 我们已经为你提供了从用户程序中调用系统调用所需的汇编代码(见start.sSYSCALLSTUB宏为每个系统调用生成了汇编代码);
  • 你需要保护 Nachos 内核不受用户程序错误的影响;用户程序不应该做任何事情来破坏操作系统(除了明确调用halt()系统调用)。换句话说,你必须确保用户程序不会向内核传递假的参数,从而导致内核破坏其内部状态或其他进程的状态。另外,你必须采取措施确保如果一个用户进程做了任何非法的事情,比如试图访问未映射的内存或跳转到一个错误的地址,该进程将被干净地杀死并释放其资源;
  • 你应该使halt()系统调用只能由“根”进程调用,也就是系统中的第一个进程。如果其他进程试图调用 halt(),系统调用应该被忽略并立即返回;
  • 由于作为参数传递给系统调用的内存地址是虚拟地址,你需要使用UserProcess.readVirtualMemoryUserProcess.writeVirtualMemory来在用户进程和内核之间传输内存;
  • 用户进程在其虚拟地址空间中将文件名和其他字符串参数存储为以null结尾的字符串。作为参数传递给系统调用的字符串的最大长度是 256 字节;
  • 当一个系统调用希望向用户表明一个错误情况时,它应该返回 -1(而不是在内核中抛出一个异常)。否则,系统调用应该返回适当的值,如test/syscall.h中记载的;
  • 当任何进程被启动时,它的文件描述符 0 和 1 必须是指标准输入和标准输出。使用UserKernel.console.openForReading()UserKernel.console.openForWriting()来使之更容易。用户进程被允许关闭这些描述符,就像open()所返回的描述符一样;
  • 已经为你提供了一个 UNIX 文件系统的存根文件系统接口;该接口由machine/FileSystem.java类给出。你可以通过静态字段ThreadedKernel.fileSystem访问这个存根文件系统。(注意,由于UserKernel扩展了ThreadedKernel,你仍然可以访问这个字段)。这个文件系统能够访问你的 Nachos 发行版中的test目录,当你想支持exec系统调用(见下文)时,这将非常有用。你不需要实现任何文件系统的功能。您应仔细研究FileSystemStubFileSystem的规范,以确定您应在系统调用中提供哪些功能,以及哪些功能由文件系统处理;
  • 不要实现任何类型的文件锁定;这是文件系统的责任。如果ThreadedKernel.fileSystem.open()返回一个非空的OpenFile,那么用户进程就被允许访问给定的文件;否则,你应该发出错误信号。同样,你也不需要担心如果多个进程同时试图访问同一个文件会发生什么细节;存根文件系统会为你处理这些细节;
  • 你的实现应该支持每个进程至少有 16 个并发打开的文件。一个进程所打开的每个文件都应该有一个唯一的文件描述符与之相关联(详见syscall.h)。文件描述符应该是一个非负的整数,它只是用来索引该进程当前打开的文件的表格。注意,如果与之相关的文件被关闭,一个给定的文件描述符可以被重复使用,不同的进程可以使用同一个文件描述符(即整数)来指代不同的文件。

题目 2

实现对多道程序的支持。我们给你的代码只限于一次运行一个用户进程;你的工作是使它对多个用户进程有效。

  • 想出一个分配机器物理内存的方法,使不同的进程在内存使用上不发生重叠。请注意,用户程序不使用malloc()free(),这意味着用户程序实际上没有动态内存分配需求(因此,没有堆)。这意味着,当一个进程被创建时,你就知道它的完整内存需求。你可以为进程的堆栈分配一个固定数量的页面;8 个页面应该足够了。
    我们建议维护一个空闲物理页的全局链接列表(也许是作为UserKernel类的一部分)。在访问这个列表时,请确保在必要时使用同步化。你的解决方案必须尽可能为新进程分配页面,从而有效地利用内存。这意味着只在一个连续的块中分配页是不能接受的;你的解决方案必须能够利用空闲内存池中的“空隙”。
    还要确保一个进程的所有内存在退出时被释放(无论它是通过系统调用exit()正常退出,还是由于非法操作而异常退出)。
  • 修改UserProcess.readVirtualMemoryUserProcess.writeVirtualMemory,它们在内核和用户的虚拟地址空间之间复制数据,以便在多个用户进程中工作。
    MIPS 机器的物理内存是通过Machine.processor().getMemory()方法访问的;物理页的总数是Machine.processor().getNumPhysPages()。你应该为每个用户进程维护pageTable,它将用户的虚拟地址映射到物理地址。TranslationEntry类表示一个单一的虚拟到物理页的转换。
    如果页面来自被标记为只读的 COFF 部分,TranslationEntry.readOnly字段应被设置为true。你可以使用CoffSection.isReadOnly()方法确定这一点。
    请注意,这些方法在失败时不应该抛出异常;相反,它们必须总是返回传输的字节数(即使这个数字是零)。
  • 修改UserProcess.loadSections(),使其使用你上面决定的分配策略,分配它所需要的页数(也就是基于用户程序的大小)。这个方法还应该为进程设置pageTable结构,以便进程被加载到正确的物理内存页中。如果新的用户进程不能装入物理内存,exec()应该返回一个错误。
    请注意,用户线程(见UThread类)已经在上下文切换时保存和恢复用户机器状态以及进程状态。所以,你不需要对这些细节负责。

题目 3

实现系统调用(记录在syscall.hexecjoinexit)。

  • 同样,所有在寄存器中传递给execjoin的地址都是虚拟地址。你应该使用readVirtualMemoryreadVirtualMemoryString来在内核和用户进程之间传输内存;
  • 同样,你必须保护这些系统调用;
  • 注意,子进程的状态对这个进程来说是完全私有的。这意味着父进程和子进程不直接共享内存或文件描述符。注意,两个进程当然可以打开同一个文件;例如,所有进程都应该有文件描述符 0 和 1 映射到系统控制台,如上所述;
  • KThread.join()不同的是,只有一个进程的父进程可以加入(指 join)到它。例如,如果 A 执行 B,B 执行 C,A 不允许加入 C,但 B 允许加入 C;
  • join需要一个进程 ID 作为参数,用于唯一地识别父进程希望加入的子进程。进程 ID 应该是一个全局唯一的正整数,在每个进程被创建时分配给它(尽管在这个项目中,进程 ID 的唯一用途是join,但对于以后的项目阶段,进程 ID 在系统中的所有运行进程中是唯一的,这一点很重要)。实现这一目标的最简单方法是维护一个静态计数器,该计数器指示要分配的下一个进程 ID。由于进程 ID 是一个int,如果系统中有许多进程,那么这个值就有可能溢出。在这个项目中,你不需要处理这种情况;也就是说,假设进程 ID 计数器不会溢出;
  • 当一个进程调用exit()时,其线程应立即终止,并且该进程应清理与其相关的任何状态(即释放内存,关闭打开的文件等)。如果一个进程非正常退出,也要进行同样的清理工作;
  • 在父进程调用join系统调用的情况下,退出的进程的退出状态应该被转移到父进程中。非正常退出的进程的退出状态由你决定。就join而言,如果子进程以任何状态调用exit系统调用,它就会正常退出;如果内核将其杀死(例如由于未处理的异常),它就会非正常退出;
  • 最后一个调用exit()的进程应该通过调用Kernel.kernel.terminate()使机器停止运行。(注意,只有根进程可以调用halt()系统调用,但最后一个退出的进程应该直接调用Kernel.kernel.terminate()

题目 4

实现一个彩票调度器(放在threads/LotteryScheduler.java中)。注意,这个类扩展了PriorityScheduler,你应该可以重用该类的大部分功能;彩票调度不应该是大量的额外代码。唯一的主要区别是用于从队列中挑选线程的机制:进行抽奖,而不是仅仅挑选优先级最高的线程。你的彩票调度应该实现优先级捐赠(注意,由于这是一个彩票调度,优先级倒置实际上不能导致饥饿!然而,你的调度器无论如何都必须做优先级捐赠)。

  • 在彩票调度中,等待的线程不是捐赠优先权,而是将票据转给他们所等待的线程。与标准的优先级调度器不同,一个等待的线程总是把它的票数加到队列所有者的票数上;也就是说,所有者的票数是它自己的票数和所有等待者的票数之和,而不是最大值。请确保正确实现这一点;
  • 即使系统中有数十亿张票据,你的解决方案也应该是有效的(也就是说,不要保留一个包含每张票据条目的数组);
  • 当调用LotteryScheduler.encreatePriority()时,一个进程所持有的彩票数量应该增加 1。同样,对于decreasePriority()来说,这个数字应该减去 1;
  • 系统中的(实际)门票总数保证不超过Integer.MAX_VALUE。最大的个人优先级现在也是Integer.MAX_VALUE,而不是 7(PriorityScheduler.priorityMaximum)。如果你愿意,你也可以假设最小优先级增加到 1(从 0)。

具体实现

关于交叉编译

编译的意思是指,把程序员能看懂的代码翻译成机器能看懂的代码。

在 Windows 系统中,可执行文件是以.exe结尾的文件;在 Linux 中,我们可以运行具有执行权限的文件。同理,对于 Nachos 系统来说,它把.coff文件当做它的可执行文件。

正常来说,我们可以在 Windows 系统上把.cpp文件编译成.exe文件,所以理论上讲,我们也可以在 Nachos 系统把这个.cpp文件编译成.coff文件。但问题是,我们没有 Nachos 上的编译器,甚至我们的 Nachos 还没实现呢!

交叉编译器的作用就是,把一个系统上的源文件编译成另一个系统上的可执行文件。交叉编译多见于嵌入式开发,因为某些小系统难以支持编译。

所以,我们的实验需要的并不是在自己电脑上安装这个交叉编译器,而是交叉编译后所得到的这个.coff结尾的文件。如果我们想在 Nachos 上运行我们自己写的程序,但是自己并没有配置好交叉编译器,那用别人的就好了。

nachos/test目录下面已经有一些可执行文件了,比如cat.coffecho.coffsh.coffhalt.coff等等,我们在进行实验的时候,可能会用到它们。

基本配置

首先,与nachos目录同层的nachos.conf文件中,还保存着前一个实验的配置,我们可以进入到nachos/proj2/nachos.conf中,把里面的内容复制过来。

Machine.stubFileSystem = true
Machine.processor = true
Machine.console = true
Machine.disk = false
Machine.bank = false
Machine.networkLink = false
Processor.usingTLB = false
Processor.numPhysPages = 64
ElevatorBank.allowElevatorGUI = false
NachosSecurityManager.fullySecure = false
ThreadedKernel.scheduler = nachos.threads.RoundRobinScheduler #nachos.threads.LotteryScheduler
Kernel.shellProgram = halt.coff #sh.coff
Kernel.processClassName = nachos.userprog.UserProcess
Kernel.kernel = nachos.userprog.UserKernel

注意到,上面的配置项中有两行带了注释(#后面的)。ThreadedKernel.scheduler表示的是采用的线程调度方式,当前的值为循环调度,注释后面是彩票调度,这个在题目 4 中会进行修改。Kernel.shellProgram表示的启动机器时首先要运行的可执行文件,halt.coff的唯一作用就是关闭这个操作系统,sh.coff运行后,我们就像是在操作 Linux 系统一样,输入一行指令,系统就是执行相应的功能。

nachos/machine/Machine.javamain方法中,我们可以看到系统似乎在寻找test目录:

public static void main(final String[] args) {
    
    
	...
	String testDirectoryName = Config.getString("FileSystem.testDirectory");

	if (testDirectoryName != null) {
    
    
	    testDirectory = new File(testDirectoryName);
	} else {
    
    
	    testDirectory = new File(baseDirectory.getParentFile(), "test");
	}
	...
}

但是,之前的nachos.conf中压根就没有FileSystem.testDirectory这一项,而且baseDirectory指的是nachos-java,它的父目录下面也没有test文件夹。要使系统能找到test,要么我们修改配置文件,要么我们移动test文件夹,要么我们就修改上面的代码(虽然这个源文件好像不让学生修改),我选择第三者:

public static void main(final String[] args) {
    
    
	...
	testDirectory = new File(nachosDirectory, "test");
	...
}

还有之前我们做实验一时在nachos/threads/KThread.javaselfTest()中添加的代码,为了避免多余的内容对我们产生影响,我们可以把这个方法所调用的测试程序进行注释。

nachos/userprog/UserKernel.java中也有一个selfTest方法,内容如下:

public void selfTest() {
    
    
    super.selfTest();

    System.out.println("Testing the console device. Typed characters");
    System.out.println("will be echoed until q is typed.");

    char c;

    do {
    
    
        c = (char) console.readByte(true);
        console.writeByte(c);
    }
    while (c != 'q');

    System.out.println("");
}

selfTest是控制台的测试,检查是否能正常输入输出。它会打印我们向控制台输入的内容,直到我们输入q表示结束。而对于我们的实验来说,这个方法并没有什么作用,除了第一行调用父级的selfTest方法之外,其余的也都注释掉。

题目 1

该题目让我们实现一个文件系统。

进入nachos/userprog/UserProcess.java,声明作为参数传递给系统调用的字符串的最大长度:

private static int maxFilenameLength = 256;

声明存放打开文件的数组,并在构造方法中进行初始化,同时让文件描述符 0 和 1 引用标准输入和标准输出。

private OpenFile[] openFiles;

public UserProcess() {
    
    
	int numPhysPages = Machine.processor().getNumPhysPages();
    pageTable = new TranslationEntry[numPhysPages];
    for (int i = 0; i < numPhysPages; i++)
        pageTable[i] = new TranslationEntry(i, i, true, false, false, false);

    this.openFiles = new OpenFile[16];
    this.openFiles[0] = UserKernel.console.openForReading();
    this.openFiles[1] = UserKernel.console.openForWriting();
}

文件调用的处理方法的实现思路可以查看nachos/test/syscall.h,具体实现如下:

/* 文件管理系统调用: creat, open, read, write, close, unlink
 *
 * 文件描述符是一个小的非负整数,它引用磁盘上的文件或流(例如控制台输入、控制台输出和网络连接)
 * 可以将文件描述符传递给 read() 和 write(),以读取/写入相应的文件/流
 * 还可以将文件描述符传递给 close(),以释放文件描述符和任何相关资源
 */

/**
 * 尝试打开给定名称的磁盘文件,如果该文件不存在,则创建该文件,并返回可用于访问该文件的文件描述符
 * <p>
 * 注意 creat() 只能用于在磁盘上创建文件,永远不会返回引用流的文件描述符
 *
 * @param fileAddress 目标文件地址
 * @return 新的文件描述符,如果发生错误,则为 -1
 */
private int handleCreate(int fileAddress) {
    
    
    // 从该进程的虚拟内存中读取字符串
    String fileName = readVirtualMemoryString(fileAddress, maxFilenameLength);

    // 非空检验
    if (fileName == null || fileName.length() == 0) {
    
    
        return -1;
    }

    // 与该文件相关联的文件描述符
    int fileDescriptor = -1;

    // 遍历 openFiles
    for (int i = 0; i < this.openFiles.length; i++) {
    
    
        // 找出一个暂时未与进程打开文件相关联的文件描述符
        if (this.openFiles[i] == null) {
    
    
            // 此时该文件描述符未进行关联
            if (fileDescriptor == -1) {
    
    
                // 设置关联
                fileDescriptor = i;
            }
            continue;
        }

        // 检查该文件是否已经打开
        if (this.openFiles[i].getName().equals(fileName)) {
    
    
            return i;
        }
    }

    // 暂时没有空闲的文件描述符
    if (fileDescriptor == -1) {
    
    
        return -1;
    }

    // 打开该文件,如果文件不存在,则创建一个文件
    OpenFile openFile = ThreadedKernel.fileSystem.open(fileName, true);

    // 使空闲的文件描述符与该文件相关联
    this.openFiles[fileDescriptor] = openFile;

    return fileDescriptor;
}

/**
 * 尝试打开指定名称的文件并返回文件描述符
 * <p>
 * 注意 open() 只能用于打开磁盘上的文件,永远不会返回引用流的文件描述符
 *
 * @param fileAddress 目标文件地址
 * @return 新的文件描述符,如果发生错误,则为 -1
 */
private int handleOpen(int fileAddress) {
    
    
    // 从该进程的虚拟内存中读取以 null 结尾的字符串
    String fileName = readVirtualMemoryString(fileAddress, maxFilenameLength);

    // 非空检验
    if (fileName == null || fileName.length() == 0) {
    
    
        return -1;
    }

    // 与该文件相关联的文件描述符
    int fileDescriptor = -1;

    // 遍历 openFiles
    for (int i = 0; i < this.openFiles.length; i++) {
    
    
        // 找出一个暂时未与进程打开文件相关联的文件描述符
        if (this.openFiles[i] == null) {
    
    
            // 此时该文件描述符未进行关联
            if (fileDescriptor == -1) {
    
    
                // 设置关联
                fileDescriptor = i;
            }
            continue;
        }

        // 检查该文件是否已经打开
        if (this.openFiles[i].getName().equals(fileName)) {
    
    
            return i;
        }
    }

    // 暂时没有空闲的文件描述符
    if (fileDescriptor == -1) {
    
    
        return -1;
    }

    // 打开该文件,如果文件不存在,则返回 null
    OpenFile openFile = ThreadedKernel.fileSystem.open(fileName, false);
    if (openFile == null) {
    
    
        return -1;
    }

    // 使空闲的文件描述符与该文件相关联
    this.openFiles[fileDescriptor] = openFile;

    return fileDescriptor;
}


/**
 * 尝试从 fileDescriptor 指向的文件或流中读取数个字节到缓冲区
 * <p>
 * 成功时,返回读取的字节数
 * 如果文件描述符引用磁盘上的文件,则文件地址将按此数字前进
 * <p>
 * 如果此数字小于请求的字节数,则不一定是错误
 * 如果文件描述符引用磁盘上的文件,则表示已到达文件末尾
 * 如果文件描述符引用一个流,这表示现在实际可用的字节比请求的字节少,但将来可能会有更多的字节可用
 * 注意 read() 从不等待流有更多数据,它总是尽可能立即返回
 * <p>
 * 出现错误时,返回 -1,新文件地址为未定义,发生这种情况的原因可能是:
 * fileDescriptor 无效、缓冲区的一部分为只读或无效、网络流已被远程主机终止且没有更多可用数据
 *
 * @param fileDescriptor 文件描述符
 * @param vaddr 虚拟内存地址
 * @param length 读取内容长度
 * @return 成功读取的字节数,如果失败,则为 -1
 */
private int handleRead(int fileDescriptor, int vaddr, int length) {
    
    
    // 检查文件描述符是否有效
    if (openFiles[fileDescriptor] == null) {
    
    
        return -1;
    }

    // 获取该文件
    OpenFile openFile = openFiles[fileDescriptor];

    // 用于存储读取内容的缓冲区
    byte[] buf = new byte[length];

    // 将内容读取到 buf,并获得成功读取的字节数
    int successRead = openFile.read(buf, 0, length);

    // 将数据从缓冲区写入到该进程的虚拟内存,并获得成功写入的字节数
    int successWrite = writeVirtualMemory(vaddr, buf, 0, successRead);

    // 检查传输的完整性
    if (successRead != successWrite) {
    
    
        return -1;
    }

    return successRead;
}

/**
 * 尝试将缓冲区中的数个字节写入到 fileDescriptor 所引用的文件或流
 * write() 可以在字节实际流动到文件或流之前返回
 * 但是,如果内核队列暂时已满,则对流的写入可能会阻塞
 * <p>
 * 成功时,将返回写入的字节数( 表示未写入任何内容),文件位置将按此数字前进
 * 如果此数字小于请求的字节数,则为错误
 * 对于磁盘文件,这表示磁盘已满
 * 对于流,这表示在传输所有数据之前,远程主机终止了流
 * <p>
 * 出现错误时,返回 -1,新文件地址为未定义,发生这种情况的原因可能是:
 * fileDescriptor 无效、缓冲区的一部分为只读或无效、网络流已被远程主机终止
 *
 * @param fileDescriptor 文件描述符
 * @param vaddr 虚拟内存地址
 * @param length 写入内容长度
 * @return 成功读取的字节数,如果失败,则为 -1
 */
private int handleWrite(int fileDescriptor, int vaddr, int length) {
    
    
    // 检查文件描述符是否有效
    if (openFiles[fileDescriptor] == null) {
    
    
        return -1;
    }

    // 获取该文件
    OpenFile openFile = openFiles[fileDescriptor];

    // 用于存储读取内容的缓冲区
    byte[] buf = new byte[length];

    // 将数据从该进程的虚拟内存读取到缓冲区,并获得成功读取的字节数
    int successRead = readVirtualMemory(vaddr, buf);

    // 将内容写入到该文件,并获得成功写入的字节数
    int successWrite = openFile.write(buf, 0, successRead);

    // 检查传输的完整性
    if (successRead != successWrite) {
    
    
        return -1;
    }

    return successRead;
}

/**
 * 关闭文件描述符,使其不再引用任何文件或流,并且可以重用
 *
 * 如果文件描述符引用一个文件,则 write() 写入的所有数据将在 close() 返回之前转移到磁盘
 * 如果文件描述符引用流,则 write() 写入的所有数据最终都将转移(除非流被远程终止),但不一定在 close() 返回之前
 *
 * 与文件描述符关联的资源将被释放
 * 如果描述符是使用 unlink 删除的磁盘文件的最后一个引用,则该文件将被删除(此详细信息由文件系统实现处理)
 *
 * @param fileDescriptor 文件描述符
 * @return 成功时为 0,错误发生时为 -1
 */
private int handleClose(int fileDescriptor) {
    
    
    // 检查文件描述符是否有效
    if (openFiles[fileDescriptor] == null) {
    
    
        return -1;
    }

    // 获取该文件
    OpenFile openFile = openFiles[fileDescriptor];

    // 取消文件描述符与该文件的关联
    openFiles[fileDescriptor] = null;

    // 关闭此文件并释放所有相关的系统资源
    openFile.close();

    return 0;
}

/**
 * 从文件系统中删除文件
 * 如果没有进程打开该文件,则会立即删除该文件,并使其使用的空间可供重用
 *
 * 如果任何进程仍然打开该文件,则该文件将一直存在,直到引用它的最后一个文件描述符关闭为止
 * 但是,在删除该文件之前,creat() 和 open() 将无法返回新的文件描述符
 *
 * @param fileAddress 文件地址
 * @return 成功时为 0,失败时为 -1
 */
private int handleUnlink(int fileAddress) {
    
    
    // 从该进程的虚拟内存中读取以 null 结尾的字符串
    String fileName = readVirtualMemoryString(fileAddress, maxFilenameLength);

    // 非空检验
    if (fileName == null || fileName.length() == 0) {
    
    
        return -1;
    }

    for (int i = 0; i < openFiles.length; i++) {
    
    
        if (openFiles[i] != null && openFiles[i].getName().equals(fileName)) {
    
    
            openFiles[i] = null;
            break;
        }
    }

    // 移除文件
    boolean removeSuccess = ThreadedKernel.fileSystem.remove(fileName);

    // 检测移除是否成功
    if (!removeSuccess) {
    
    
        return -1;
    }

    return 0;
}

将上述处理函数添加到handleSyscall中:

public int handleSyscall(int syscall, int a0, int a1, int a2, int a3) {
    
    
    switch (syscall) {
    
    
        case syscallHalt:
            return handleHalt();
        case syscallCreate:
            return handleCreate(a0);
        case syscallOpen:
            return handleOpen(a0);
        case syscallRead:
            return handleRead(a0, a1, a2);
        case syscallWrite:
            return handleWrite(a0, a1, a2);
        case syscallClose:
            return handleClose(a0);
        case syscallUnlink:
            return handleUnlink(a0);
        default:
            Lib.debug(dbgProcess, "Unknown syscall " + syscall);
            Lib.assertNotReached("Unknown system call!");
    }
    return 0;
}

题目 2

UserProcess的构造方法,我们看到进程在获取到物理页数之后,直接创建了一个以这个页数为长度的页表。换句话说,这个进程把所有的页都给占用了,这是在进行多道程序设计时无法接受的情况,我们需要换一种分配机制,所以把下面方法中所展示的代码删掉。

public UserProcess() {
    
    
	int numPhysPages = Machine.processor().getNumPhysPages();
    pageTable = new TranslationEntry[numPhysPages];
    for (int i = 0; i < numPhysPages; i++)
        pageTable[i] = new TranslationEntry(i, i, true, false, false, false);
    ...
}

我们希望每个进程只获取它所需要的页,同时,这些页的物理地址最好允许不连续。因为要保证页连续的话,系统长时间运转时,随着进程的创建与销毁,空闲的页会变得破碎,不利于利用。

nachos/userprog/UserKernel.java中,我们创建一个链表,来存放未被进程占用的页号:

private static LinkedList<Integer> AllFreePageNums;

public UserKernel() {
    
    
    super();

    // 初始化内存列表
    AllFreePageNums = new LinkedList<>();

    // 获取物理页数
    int numPhysPages = Machine.processor().getNumPhysPages();

    // 为空闲页编号
    for (int i = 0; i < numPhysPages; i++) {
    
    
        AllFreePageNums.add(i);
    }
}

我们还需要给用户进程提供获取和归还这些页的方法:

public static LinkedList<Integer> getFreePageNums(int numPages) {
    
    
    // 声明并初始化一个空闲页号链表
    LinkedList<Integer> freePageNums = new LinkedList<>();

    // 如果空闲页足够
    if (AllFreePageNums.size() >= numPages) {
    
    
        // 从空闲页中取出指定数量的页号,并添加到 freePages 中
        for (int i = 0; i < numPages; i++) {
    
    
            freePageNums.add(AllFreePageNums.removeFirst());
        }
    }

    return freePageNums;
}

// 归还空闲页
public static void releaseOwnPageNums(LinkedList<Integer> ownPageNums){
    
    
    // 如果进程没有占有页,直接返回
    if (ownPageNums == null || ownPageNums.isEmpty()) {
    
    
        return;
    }

    // 将进程中页号转换成空闲页号
    for (int i = 0; i < ownPageNums.size(); i ++) {
    
    
        AllFreePageNums.add(ownPageNums.removeFirst());
    }
}

回到nachos/userprog/UserProcess.java,用户进程也需要一个链表来存储这些页号,注意,这些页号是物理页号。在页表元素TranslationEntry初始化的过程中,第一个参数是连续的虚拟页号,第二个参数则是物理页号,就是在这里实现的实际地址与逻辑地址之间的映射。从用户进程的角度看,它逻辑地址是从 0 开始的,是连续的,就仿佛它在使用整个内存。

private LinkedList<Integer> ownPageNums;

protected boolean loadSections() {
    
    
    // 保证程序的页数不超过物理页范围
    if (numPages > Machine.processor().getNumPhysPages()) {
    
    
        coff.close();
        Lib.debug(dbgProcess, "\tinsufficient physical memory");
        return false;
    }

    // 获取空闲页号
    ownPageNums = UserKernel.getFreePageNums(numPages);

    // 检查空闲页是否充足
    if (ownPageNums.isEmpty()) {
    
    
        return false;
    }

    // 页表数组初始化
    pageTable = new TranslationEntry[numPages];

    // 将数组中的页表初始化
    for (int i = 0; i < numPages; i++) {
    
    
        pageTable[i] = new TranslationEntry(i, ownPageNums.get(i), true, false, false, false);
    }

    // 加载用户程序到内存
    for (int s = 0; s < coff.getNumSections(); s++) {
    
    
        CoffSection section = coff.getSection(s);

        Lib.debug(dbgProcess, "\tinitializing " + section.getName()
                + " section (" + section.getLength() + " pages)");

        for (int i = 0; i < section.getLength(); i++) {
    
    
            int vpn = section.getFirstVPN() + i;

            // 装入页
            section.loadPage(i, pageTable[vpn].ppn);
        }
    }

    return true;
}

protected void unloadSections() {
    
    
    // 关闭当前进程正在执行的文件
    coff.close();
    
    // 将该进程拥有的页转换为空闲页
    UserKernel.releaseOwnPageNums(ownPageNums);
}

由于现在的虚拟地址不等于物理地址了,那对于虚拟地址的读写方法也得跟着改变:

public int readVirtualMemory(int vaddr, byte[] data, int offset, int length) {
    
    
    // 偏移量和长度非负,且不能越界
    Lib.assertTrue(offset >= 0 && length >= 0 && offset + length <= data.length);

    // 获取物理内存
    byte[] memory = Machine.processor().getMemory();

    // 传输的字节数过多,会导致虚拟内存越界
    if (length > pageSize * numPages - vaddr) {
    
    
        // 截取不超过越界的部分
        length = pageSize * numPages - vaddr;
    }

    // 不断读取虚拟内存,直到读完指定长度的数据
    int successRead = 0;
    while (successRead < length) {
    
    
        // 计算页号
        int pageNum = Processor.pageFromAddress(vaddr + successRead);

        // 检查是否越界
        if (pageNum < 0 || pageNum >= pageTable.length) {
    
    
            return successRead;
        }

        // 计算页偏移量
        int pageOffset = Processor.offsetFromAddress(vaddr + successRead);

        // 计算当页剩余容量
        int pageRemain = pageSize - pageOffset;

        // 比较未读取的内容与当页未使用的空间,取较小值用于数据转移
        int amount = Math.min(length - successRead, pageRemain);

        // 计算真实地址
        int realAddress = pageTable[pageNum].ppn * pageSize + pageOffset;

        // 将数据从内存复制到指定数组
        System.arraycopy(memory, realAddress, data, offset + successRead, amount);

        // 成功读取的数据量
        successRead = successRead + amount;
    }

    return successRead;
}

public int writeVirtualMemory(int vaddr, byte[] data, int offset, int length) {
    
    
    // 偏移量和长度非负,且不能越界
    Lib.assertTrue(offset >= 0 && length >= 0 && offset + length <= data.length);

    // 获取物理内存
    byte[] memory = Machine.processor().getMemory();

    // 传输的字节数过多,会导致虚拟内存越界
    if (length > pageSize * numPages - vaddr) {
    
    
        // 截取不超过越界的部分
        length = pageSize * numPages - vaddr;
    }

    // 不断写入虚拟内存,直到写完指定长度的数据
    int successWrite = 0;
    while (successWrite < length) {
    
    
        // 计算页号
        int pageNum = Processor.pageFromAddress(vaddr + successWrite);

        // 检查是否越界
        if (pageNum < 0 || pageNum >= pageTable.length) {
    
    
            return successWrite;
        }

        // 计算页偏移量
        int pageOffset = Processor.offsetFromAddress(vaddr + successWrite);

        // 计算当页剩余容量
        int pageRemain = pageSize - pageOffset;

        // 比较未读取的内容与当页未使用的空间,取较小值用于数据转移
        int amount = Math.min(length - successWrite, pageRemain);

        // 计算真实地址
        int realAddress = pageTable[pageNum].ppn * pageSize + pageOffset;

        // 如果当前页为只读状态,终止数据转移
        if (pageTable[pageNum].readOnly) {
    
    
            return successWrite;
        }

        // 将数据从内存复制到指定数组
        System.arraycopy(data, offset + successWrite, memory, realAddress, amount);

        // 成功读取的数据量
        successWrite = successWrite + amount;
    }

    return successWrite;
}

题目 3

这个题目是要我们实现进程管理系统调用,与题目 1 一样,我们也是要根据nachos/test/syscall.h的要求,实现我们的方法。

// 进程运行的状态
private int status;

// 进程 id 计数器(需要正整数)
private static int processIdCounter = 1;

// 当前进程的 id
private int processId;

// 进程 id 与进程的映射表
private static Map<Integer, UserProcess> processMap = new HashMap<>();

// 父子进程 id
private int parentProcessId;
private LinkedList<Integer> childrenProcessId;

// join 中需要用到的锁和条件变量
private Lock joinLock;
private Condition joinCondition;

// 是否正常退出
private boolean normalExit = false;

public UserProcess() {
    
    
	...
    // 设置当前进程 id
    this.processId = processIdCounter;

    // 进程 id 计数器自增
    processIdCounter++;

    // 将该进程添加到进程映射表中
    processMap.put(this.processId, this);

    // -1 表示无父进程
    parentProcessId = -1;

    // 子进程 id 链表初始化
    childrenProcessId = new LinkedList<>();

    // 初始化 join 用到的锁和条件变量
    joinLock = new Lock();
    joinCondition = new Condition(joinLock);
}

/* 进程管理系统调用: exit, exec, join */

/**
 * 立即终止当前进程
 * 属于该进程的任何打开的文件描述符都将关闭
 * 进程的任何子进程都不再具有父进程
 *
 * status 作为此进程的退出状态返回给父进程,并且可以使用 join 系统调用收集
 * 正常退出的进程应(但不要求)将状态设置为 0
 *
 * exit() 永不返回
 *
 * @param status 退出状态
 * @return 不返回
 */
private int handleExit(int status) {
    
    
    // 设置进程运行状态
    this.status = status;

    // 关闭可执行文件
    coff.close();

    // 关闭所有打开的文件
    for (int i = 0; i < openFiles.length; i++) {
    
    
        // 如果打开文件非空
        if (openFiles[i] != null) {
    
    
            // 关闭该文件
            openFiles[i].close();
        }
    }

    // 释放内存
    unloadSections();

    // 如果这是最后一个进程
    if (processMap.size() == 1) {
    
    
        // 终止内核
        Kernel.kernel.terminate();
        return 0;
    }

    // 如果该进程存在父进程
    if (this.parentProcessId != -1) {
    
    
        // 获取父进程
        UserProcess parentProcess = processMap.get(this.parentProcessId);

        // 将该进程从父进程的子进程列表中移除(注意:不要直接传数字,否则会被视为索引)
        parentProcess.childrenProcessId.remove(new Integer(this.processId));
    }

    // 遍历该进程的子进程
    for (int childProcessId : childrenProcessId) {
    
    
        // 将子进程的父进程设置为无
        processMap.get(childProcessId).parentProcessId = -1;
    }

    // 将该进程从映射表中移除
    processMap.remove(this.processId);

    // 设置正常退出状态
    this.normalExit = true;

    // 获得锁
    joinLock.acquire();

    // 唤醒在这个条件变量上等待的线程(可能是父进程的线程)
    joinCondition.wake();

    // 释放锁
    joinLock.release();

    // 终止线程
    KThread.finish();

    return 0;
}

/**
 * 在新的子进程中使用指定的参数执行存储在指定文件中的程序
 * 子进程有一个新的唯一进程 ID,以标准输入作为文件描述符 0 打开,标准输出作为文件描述符 1 打开开始
 *
 * file 是以 null 结尾的字符串,指定包含可执行文件的文件名
 * 请注意,此字符串必须包含 .coff 扩展名
 *
 * argc 指定要传递给子进程的参数的数量
 * 此数字必须为非负数
 *
 * argv 是指向以 null 结尾的字符串的指针数组,这些字符串表示要传递给子进程的参数
 * argv[0] 指向第一个参数,argv[argc-1] 指向最后一个参数
 *
 * exec() 返回子进程的进程 ID,该 ID 可以传递给 join()
 * 出现错误时,返回 -1
 *
 * @param fileAddress 文件地址
 * @param argc 要传递给子进程的参数的数量
 * @param argvAddress 要传递给子进程的参数的地址
 * @return 子进程的 id,出现错误时为 -1
 */
private int handleExec(int fileAddress, int argc, int argvAddress) {
    
    
    // 从该进程的虚拟内存中读取以 null 结尾的字符串
    String fileName = readVirtualMemoryString(fileAddress, maxFilenameLength);

    // 非空检验
    if (fileName == null || fileName.length() == 0) {
    
    
        return -1;
    }

    // 读取子进程的参数
    String[] args = new String[argc];
    for (int i = 0; i < argc; i++) {
    
    
        byte[] argsAddress = new byte[4];
        // 从虚拟内存中读取参数地址
        if (readVirtualMemory(argvAddress + i * 4, argsAddress) > 0) {
    
    
            // 根据读取到的地址找相应的字符串
            args[i] = readVirtualMemoryString(Lib.bytesToInt(argsAddress, 0), 256);
        }
    }

    // 创建子进程
    UserProcess newUserProcess = UserProcess.newUserProcess();

    // 执行子进程
    boolean executeSuccess = newUserProcess.execute(fileName, args);

    // 检测是否执行成功
    if (!executeSuccess) {
    
    
        return -1;
    }

    // 将新进程的父进程设置为该进程
    newUserProcess.parentProcessId = this.processId;

    // 将新进程添加到该进程的子进程中
    this.childrenProcessId.add(new Integer(newUserProcess.processId));

    // 返回新进程 id
    return newUserProcess.processId;
}

/**
 * 暂停当前进程的执行,直到 processID 参数指定的子进程退出
 * 如果在调用时孩子已经退出,则立即返回
 * 当该进程恢复时,它会断开子进程的连接,因此 join() 不能再次用于该进程
 *
 * processID 是子进程的进程 ID,由 exec() 返回
 *
 * statusAddress 指向一个整数,子进程的退出状态将存储在该整数中
 * 这是子进程传递给 exit() 的值
 * 如果子进程由于未处理的异常而退出,则存储的值为未定义
 *
 * 如果子进程正常退出,则返回 1
 * 如果子进程由于未处理的异常而退出,则返回 0
 * 如果 processID 未引用当前进程的子进程,则返回 -1
 *
 * @param processID 子进程 id
 * @param statusAddress 子进程退出状态的地址
 * @return 1(正常退出),0(子进程异常退出),-1(processID 引用错误)
 */
private int handleJoin(int processID, int statusAddress) {
    
    
    // 获取子进程
    UserProcess childProcess = processMap.get(processID);

    // 只有一个进程的父进程才能 join 到它
    if (!(childProcess.parentProcessId == this.processId)) {
    
    
        return -1;
    }

    // 父进程持有子进程的锁
    childProcess.joinLock.acquire();

    // 该进程在该锁的条件变量上等待,直到子进程退出
    childProcess.joinCondition.sleep();

    // 把锁释放掉
    childProcess.joinLock.release();

    // 获取子进程的运行状态
    byte[] childstatus = Lib.bytesFromInt(childProcess.status);

    // 将子进程的状态写入内存中
    int successWrite = writeVirtualMemory(statusAddress, childstatus);

    // 判断子进程是否正常结束
    if (childProcess.normalExit && successWrite == 4) {
    
    
        return 1;
    }

    return 0;
}

public int handleSyscall(int syscall, int a0, int a1, int a2, int a3) {
    
    
    switch (syscall) {
    
    
        case syscallHalt:
            return handleHalt();
        case syscallExit:
            return handleExit(a0);
        case syscallExec:
            return handleExec(a0, a1, a2);
        case syscallJoin:
            return handleJoin(a0, a1);
		....
    }
    return 0;
}

到现在,我们的系统就可以正常运行了,程序入口依然是nachos/machine/Machine.java中的main函数,直接运行。我们依次输入echo hellohalt,运行结果如下:

nachos 5.0j initializing...
 config interrupt timer processor console user-check grader
nachos% echo hello
echo hello
2 arguments
arg 0: echo
arg 1: hello

[2] Done (0)
nachos% halt
halt
Machine halting!

Ticks: total 106513374, kernel 56075410, user 50437964
Disk I/O: reads 0, writes 0
Console I/O: reads 17, writes 92
Paging: page faults 0, TLB misses 0
Network I/O: received 0, sent 0

Process finished with exit code 0

如果控制台打印了如下信息,不用管:

Lacked permission: ("java.lang.RuntimePermission" "createClassLoader")

题目 4

先修改系统选用的调度程序,进入nachos.conf,修改ThreadedKernel.scheduler

ThreadedKernel.scheduler = nachos.threads.LotteryScheduler

进入nachos/threads/PriorityScheduler.java,把PriorityQueue中的list的访问权限由private改成protected,以便子类利用。

protected LinkedList<ThreadState> list = new LinkedList<>();

进入同目录下的LotteryScheduler.java,因为LotteryScheduler继承了PriorityScheduler,所以我们可以复用其中的大部分方法。

声明一个内部类LotteryThreadState,让它继承优先级调度中的ThreadState,并重写部分方法:

protected class LotteryThreadState extends ThreadState {
    
    
    public LotteryThreadState(KThread thread) {
    
    
        super(thread);
    }

    public int getEffectivePriority() {
    
    
        // 尝试使用之前保存的数据
        if (KThread.getPriorityStatus()) {
    
    
            return effectivePriority;
        }

        // 重新计算有效优先级
        effectivePriority = priority;

        // 遍历该线程的等待线程列表
        for (KThread waitThread : thread.getWaitThreadList()) {
    
    
            // 等待线程的有效优先级
            effectivePriority += getThreadState(waitThread).getEffectivePriority();
        }

        return effectivePriority;
    }
}

再声明一个LotteryQueue,让它继承优先级调度中的PriorityQueue,并重写部分方法:

protected class LotteryQueue extends PriorityQueue {
    
    
    LotteryQueue(boolean transferPriority) {
    
    
        super(transferPriority);
    }

    protected ThreadState pickNextThread() {
    
    
        // 计算彩票总数
        int lotterySum = 0;
        for (ThreadState lotteryThreadState : list) {
    
    
            if (transferPriority) {
    
    
                lotterySum += lotteryThreadState.getEffectivePriority();
            } else {
    
    
                lotterySum += lotteryThreadState.getPriority();
            }

        }

        // 当前存在可运行的线程
        if (lotterySum != 0) {
    
    
            // 指定获胜彩票
            int winLottery = Lib.random(lotterySum) + 1;

            // 当前彩票计数
            int currentLotteryNum = 0;

            // 遍历所有线程,直到找到持有中奖彩票的线程
            for (ThreadState lotteryThreadState: list) {
    
    
                if (transferPriority) {
    
    
                    currentLotteryNum += lotteryThreadState.getEffectivePriority();
                } else {
    
    
                    currentLotteryNum += lotteryThreadState.getPriority();
                }

                // 找到获奖彩票
                if (currentLotteryNum >= winLottery) {
    
    
                    return lotteryThreadState;
                }
            }
        }

        return null;
    }
}

然后,把进程的选择与进程状态的绑定也修改一下:

public ThreadQueue newThreadQueue(boolean transferPriority) {
    
    
    return new LotteryQueue(transferPriority);
}

protected ThreadState getThreadState(KThread thread) {
    
    
    if (thread.schedulingState == null)
        thread.schedulingState = new LotteryThreadState(thread);

    return (ThreadState) thread.schedulingState;
}

这样,我们的彩票调度就完成了,测试用例可以沿用优先级调度题目的测试用例,也可以在此基础上进行修改和补充。修改部分配置后,运行,控制台打印如下:

nachos 5.0j initializing...
 config interrupt timer user-check grader

<--- 题目 5 开始测试 --->

B 线程开始运行,初始优先级为 4
B 线程尝试让出 CPU
B 线程重新使用 CPU
B 线程结束运行
C 线程开始运行,初始优先级为 6
C 线程尝试让出 CPU
C 线程重新使用 CPU
C 线程等待 A 线程
A 线程开始运行,初始优先级为 2
A 线程尝试让出 CPU
A 线程重新使用 CPU
A 线程结束运行
C 线程重新使用 CPU
C 线程结束运行

<--- 题目 5 结束测试 --->

Machine halting!

Ticks: total 2070, kernel 2070, user 0
Disk I/O: reads 0, writes 0
Console I/O: reads 0, writes 0
Paging: page faults 0, TLB misses 0
Network I/O: received 0, sent 0

Process finished with exit code 0

源码

项目地址:https://gitee.com/GongY1Y/nachos-study

猜你喜欢

转载自blog.csdn.net/weixin_45922876/article/details/121402080
今日推荐