nachos

实验一

分析threads文件夹内容

思路:通过thread类中的方法,来调用schedule类中的调度函数,实现线程的创建,就绪,运行,阻塞和结束五种状态的转换。

thread

  1. 创建线程:fork()。首先为线程分配栈资源(StackAllocate()),然后将线程状态设为就绪态(setStatus()),并放入就绪队列中(Append()),等待被调度即可。
  2. 线程阻塞:sleep()。首先将状态设置为阻塞态,然后从就绪队列中寻找新的线程来调度(FindNextToRun()),如果就绪队列中有线程就可以执行该线程(run()),如果就绪队列中没有线程则阻塞等待时钟中断到来(idle())。
  3. 线程切换:yield()。 从就绪队列中寻找新的线程来调度(FindNextToRun()),如果就绪队列中有线程就可以执行该线程(run()),同时将当前运行的线程放入就绪队列中(ReadyToRun());如果就绪队列中没有线程,则修改状态后直接退出即可。(区别于sleep(),yield()不会进入阻塞等待状态)
  4. 线程结束:finish()。先将线程标记为待删除的线程(Run()中进行删除),然后直接调用sleep()即可。为什么不调用yield():因为yield()虽然也释放cpu资源,但却将线程再次放入就绪队列中,而线程结束则意味着线程不需要再使用cpu,因此不应该再放入就绪队列中,调用sleep()则正好满足要求。

scheduler

  1. 寻找下一个运行的线程:FindNextToRun()。查看就绪队列中是否还有线程,如果有就从就绪队列中删除这个线程,并将其返回;如果没有线程则返回NULL。
    准备执行线程:ReadyToRun()。本质上就是创建到就绪态的转换,先将线程状态设为就绪态(setStatus()),然后将其放入就绪队列中即可(Append())。
  2. 执行线程:Run()。本质上就是就绪态到运行态的转换,先将线程状态设为运行态(setStatus()),然后进行上下文的切换(SWITCH()),最后判断被调度下去的线程是否需要销毁(finish()中进行的设置),如果要销毁那么就进行destory。

初始化函数Initialize()的工作

这个属于system类,即当执行一个main()函数时,操作系统自己进行的初始化工作。首先进行硬件资源如磁盘和网络设备的初始化工作,这里频繁使用ifdef,endif的搭配组合,工程项目中须要掌握这点。之后进行中断,调度队列,时钟的初始化。然后便可以创建main()主线程,并将其设为运行状态。

main()函数的工作

注意,这个main()函数并不是我们写的main()函数,而是nachos操作系统运行的内核程序,因为操作系统本质上也是一个程序,而nachos作为一个模拟的操作系统也自然需要去运行。main()函数首先调用Initialize()函数进行初始化工作,需要关注的是之后调用了ThreadTest()函数做测试,其中又调用了fork()创建了新的线程,并用新的线程和主线程均调用了SimpleThread()函数,但是传递的参数却不同,因此执行结果自然不同。

gdb基本使用

  1. 查看函数地址:首先list,然后找到要查看的函数位置,利用break设置断点即可。
  2. 查看线程对象地址:首先list,找到创建线程的代码位置,并利用break在创建线程返回值的位置设置断点,然后用run命令运行线程,到达这个位置时会暂停,此时利用p查看线程地址即可。
  3. 查看汇编代码的返回地址:可以用break在函数入口打断点,然后利用disass查看汇编代码,由于函数返回时一定会将返回地址放入寄存器中,则可以在返回的汇编代码的下一步中,利用info r查看寄存器内容,从而得到返回地址。需要注意的是,s和n命令都是c语言级的,想要单步执行汇编代码,需要利用si和ni,这些命令是汇编级的。

实验三

分析synch文件

首先需要了解的是,list文件中自己实现了链表节点,并基于此实现了链表类,然后在synch文件中利用这个链表实现了信号量,互斥锁和条件变量。其中信号量主要是维护了一个等待当前信号量的线程队列和信号量的值,而互斥锁则是维护了一个大小为1的信号量和锁的拥有者,由于本次实验我们使用信号量和互斥锁来实现生产者消费者模型,因此条件变量不在此详述,只需知道条件变量和互斥锁配合也完全可以实现生产者消费者模型,这其实可以作为线程池的实现方式。

  1. P()操作:为了实现线程安全,信号量的操作必须是原子性的,而保证原子性的方法就是关掉中断,即禁止了线程切换。然后判断当前信号量的值是否为0,若为0则需要加入队列,并进入阻塞状态;若不为0则可以将信号量值减一,重新打开中断即可。
  2. V()操作:依然需要先关闭中断,然后判断当前队列中是否有等待的线程,若有则将其从等待队列中删除,并准备执行(ReadyToRun())。最后将信号量值加一,并打开中断。
  3. Acquire()操作:首先依然是关闭中断,然后修改锁的持有者,并直接调用P()操作即可,最后记得重新打开中断。
  4. Release()操作:首先关闭中断,然后锁的持有者设为NULL,并直接调用V()操作即可,最后记得重新打开中断。

实验内容

缓冲区是在ring文件中实现的,就是一个简单的数组,并用in和out标记当前插入和取出的位置。还需要注意的是由于是环形缓冲区,因此需要取余。
需要填充的只有prodcons文件,首先明确的是由于缓冲区是临界区,因此需要互斥锁;并且缓冲区的大小有限,不可能无限取,也不可能无限填充,因此需要生产者和消费者进行同步的信号量。因此完成的工作主要是信号量和互斥锁的初始化,以及生产者的填入数据和消费者的取出数据工作。由于初始时默认缓冲区大小为空,因此对应的取出信号量为0,而填入信号量则为缓冲区大小。此时消费者会进入阻塞状态,等待生产者填入数据。
当生产者填入数据时首先遵循先同步后互斥的原则,先调用填入信号量的P()操作,然后调用加互斥锁。然后将数据填入缓冲区中,最后再解锁和调用取出信号量的V()操作即可。
消费者取出数据道理也类似,先调用取出信号量的P()操作,再加锁。将数据取出后,再解锁和调用填入信号量的V()操作即可。

实验四,五

理解nachos的文件系统

简单介绍

文件系统是指操作系统层面对磁盘文件的管理。大的层面讲就是内部维护文件的管理方式,并向用户提供对文件进行操作的接口。
例如用户在使用Linux环境下使用的文件命令:touch,cp,mv,ls,rm…这些都是对Linux中文件进行的操作,其都是通过调用文件的相关操作实现。
在介绍详细的文件操作过程之前,首先说明一下文件的读写和存储单位,由于磁盘文件的读写单位并不是整个文件,而是一个个磁盘块,文件数据也是按照磁盘块的单位存放在文件中。因此我们需要管理的最小单位是磁盘块,而磁盘块是由一个个扇区组成,一般一个磁盘块有2^n个扇区,实验所用的nachos系统中为了简化,将一个磁盘默认为一个扇区,大小为128B(后面的实验内容将不再区分磁盘块和扇区,统一使用磁盘块表示,但须明白其实这既是磁盘块,也是扇区)。这样一个磁盘文件便由多个磁盘块组成,操作系统负责对所有的磁盘块进行编址,并进行保存。
既然磁盘文件由多个磁盘块组成,当我们需要去读写某个文件时,就需要去访问对应的所有磁盘块,因此需要记录某个文件所拥有的所有磁盘块。文件大小,文件所占磁盘块等信息都统一保存在文件头中,文件头也属于文件,其占据一个磁盘块。由于文件头的存在,我们可以从中方便的得知文件的相关信息,但我们如何直到文件头的位置呢?读写文件时如何寻找文件头呢?这就需要一个根目录表的结构了,其维护了一个目录表,表中有多个目录项,记录着硬盘中所有文件的名字和文件头的存放位置。
现在读写文件的简单过程已经梳理好,但是创建和删除文件的过程还没有构建。当用户使用touch命令创建一个文件时,操作系统又是如何操作的呢?
前面说过文件的存储单位是磁盘块,那么创建文件时,我们自然需要为创建的文件分配所需的空闲磁盘块个数 。操作系统如何知道哪些磁盘块是空闲的呢?nachos中空闲磁盘块的管理方式是位示图。其通过位的方式记录每块是否已经被分配。这样当我们需要创建文件时,就查找位示图,找出所需的块数,分配即可。
那么回收呢?nachos系统中回收时并不会真的释放文件内容等,而是将位示图中文件头和文件数据对应的磁盘块标记为空闲,并在目录表中将对应文件项标记为不再使用,仅此而已。
以上简单介绍了文件的创建删除,以及寻找文件的过程。接下来还需要介绍一下文件的复制过程等。
文件的复制过程需要利用上面的几种操作,比如首先需要得到源文件的大小,然后读取源文件数据,写入目标文件中。写入文件过程较为复杂,需要根据写入数据的大小,判断是否需要为目标文件分配新的磁盘块,这些过程后面将详细介绍。
大致框架已经搭好,下面将分别详细介绍上面及部分:

管理方式

  1. 位示图:由于磁盘文件的读写单位并不是整个文件,而是一个个磁盘块,文件数据也是按照磁盘块的单位存放在文件中。因此需要对空闲的磁盘块进行管理,这是通过位示图的方法实现的;位示图是一个bit数组,其中每bit都代表这一位对应的磁盘块是否处于空闲状态。位示图也有相应的文件头,并存放在磁盘的第4到第131字节(第0到3字节为nachos硬盘魔数)中,其中记录了位示图在第三个磁盘块中,位示图文件也占据一个磁盘块。
  2. 根目录表:存放了n条目录项,每一项通过一个三元组,对一个文件建立了简单的“索引”,这个三元组由<name, inuse, sector>,分别表示某个文件的名字,以及文件当前是否存在,还有文件头(下面介绍)所在的扇区号。前面曾经简单介绍说当删除一个文件时并不会真正执行文件数据的删除等操作,那么如何知道该文件是否被删除呢?就是通过位示图+目录项的方式,位示图比较好理解,而目录项就是通过inuse来作为标记为进行记录的,当文件被删除时,inuse位修改为false即可。
    每一个目录项占20B,该实验中设定允许存放的最大文件数目为10,因此所需要的大小为200B,必须由两个磁盘块来存放。根目录表的文件头存放在第二个磁盘块中,而根目录表文件存放在第四和第五个磁盘块中。
  3. 文件头:文件头类似于文件的一个组织者,其记录了文件的大小,文件所占据的磁盘块数目,以及用一个数组记录占据的各个磁盘块。通过这种方式,当我们需要访问一个文件时,只需要从根目录表的文件头开始,先找到根目录表所在磁盘块,并根据name从中读出所要查找的文件的三元组,三元组的sector部分记录着所要查找的文件的文件头所在磁盘块,然后读取对应的磁盘块,获得文件头内容,其中记录着文件数据保存在哪些磁盘块上,依次读取即可。
    以下是nachos文件系统的简单布局方式:
    在这里插入图片描述

nachos模拟的文件系统

由于实验用的是nachos操作系统,其只是一个简单的模拟文件系统,并没有实现真正的硬盘等,因此在介绍一些文件的具体操作之前需要了解一下用磁盘模拟硬盘的方法,方便之后的代码理解。
我们前面已经介绍了nachos系统的文件管理方式,那么我们如何模拟硬盘呢?实验中采用的是在一个磁盘文件中模拟,既然是在文件中,那么就涉及到文件的读写过程,事实上无论是对文件的任何处理,我们都需要先将文件中保存的目录表和位示图从文件中读出来,并用根目录表和位示图这两个类进行保存,后面对相应数据进行修改,最后写回文件,也就是虚拟的硬盘中。

文件操作

  1. create:文件的操作中最早的往往是文件的创建工作,因为创建后才能进行后续的相关处理。创建时我们需要首先将保存在虚拟硬盘上的根目录表和位示图读出,判断目录表中是否已经存在对应文件名,如果存在则创建失败;如果不存在,则从位示图中寻找一个空闲磁盘块进行分配,用来保存文件的文件头。如果没有空闲块,创建失败;否则,创建成功,并修改位示图对应位,以及在根目录表中插入一个目录项,并执行文件头的初始化操作,如文件大小和磁盘块数等。最后需要将文件头,根目录表和位示图写回虚拟硬盘中。
  2. copy:复制文件的过程实验中已经提供,首先需要根据源文件的大小,创建一个新的nachos文件;然后循环读出源文件的数据,并写入nachos文件中,因为文件大小是已知的,因此我们可以直接进行磁盘块的分配。然后依次写入磁盘块中,并将相应的位示图和根目录表进行修改。至于写入磁盘块的具体操作,下面会详细讲解。
  3. write:写入文件之前,我们需要首先找到文件的文件头,这个过程之前已经详细介绍过,从文件头中可以得知当前文件的总大小,以及磁盘块,从而计算出最后一个磁盘块所使用的空间,然后根据我们要写入的数据,判断是否需要分配新的磁盘块。如果需要,那么读入位示图,并调用相关函数,寻找一块新的空闲磁盘块进行分配,若没有空闲磁盘块返回错误即可。若成功分配,那么还需要修改文件的文件头。获得足够的磁盘块后,需要将当前的数据依次写入磁盘块中,由于我们对文件读写的最小单位是磁盘块,因此文件是存在内碎片问题的,即磁盘块中可能并没有完全被文件数据占满空间。因此我们还需要判断当前磁盘块是否对齐,如果没有对齐,需要先读出最后一块磁盘块,然后在此磁盘块后面进行追加式的写入。最后需要将文件内容完全写回硬盘中。
  4. remove:文件的删除操作实际上前面已经简单介绍过了。首先依然需要将位示图和根目录表读出,nachos系统中回收时并不会真的释放文件内容等,而是将位示图中文件头和文件数据对应的磁盘块标记为空闲,并在根目录表中将对应的目录项的inuse字段标记为false。最后需要将位示图和根目录表写回虚拟硬盘中即可。

实验六

nachos可执行文件

由于nachos是一个操作系统,因此其运行的可执行文件也是nachos系统下支持的特定文件,其后缀为.noff。分析noff.h文件,可发现其文件内容按照段式存储,主要由code段,initData段,uninitData段,除此之外还有标识noff文件的魔数。那么段又是由什么构成的呢?首先思考段的构成中必须包含该段数据的内存地址,还有一个就是由于段和页存储特点不同,页式存储中每页的大小是固定不变的,而段式存储是根据段的具体含义,大小可自由定义,因此还需要记录段的大小。这样分析之后,段的数据结构呼之欲出:虚拟地址空间中的地址virtualAddr,以及段数据在对应文件中的位置inFileAddr,还有段的大小size

进程的创建和执行过程

提到进程的创建和执行,Linux系统下首先想到的方法是先使用fork()调用创建子进程,然后通过exec()调用执行对应的可执行文件。nachos系统中也是同理,但这里我们主要讨论的是根据文件名进行主进程的创建和执行(还有另一个原因是此时nachos系统还不支持多道程序设计,因此无法支持多进程模式,只能存在一个进程。实验7,8中将对此进行改进)。
接下来将从userprog/proggtest.cc文件中的StartProcess()函数开始,对nachos系统中进程的创建和执行过程进行详细的分析:
从大的层面来总结,首先需要根据可执行文件的名称,打开文件;之后根据文件大小创建用户地址空间,并初始化页表内容;然后读入对应的段数据放入用户地址空间;用户地址空间创建之后,本来应该需要创建对应的进程块,其中保存进程的标识,文件描述符等信息,但是由于此时nachos系统并没有涉及到多进程,因此也没有维护进程块的数据结构,而是将地址空间,页表等信息由各自的类维护。既然不维护特定的进程块,那么就需要将系统当前的用户地址空间切换为对应进程的地址空间,并初始化当前的寄存器,需要注意的是每个进程的地址从0开始,因此PC寄存器的首个存储内容一定为0;同时修改页表指针,使其指向当前的进程。这样便进行了一个简单的进程创建过程,然后便可以调用Run()函数执行进程。

  1. open():根据文件名打开文件,直接通过nachos文件系统中实现的open()函数即可实现。
  2. AddrSpace():用户地址空间的构造函数,而用户地址空间的类AddrSpace其实只是维护了一个页表指针和页的数目。因此与其说初始化用户地址空间,不如说初始化页表,而页表的数据结构中主要有虚地址,实地址,以及当前位是否有效,是否为脏页…其中一些内容在本实验中并无实际意义,比如脏页,因为此时nachos系统都是直接将段数据放入物理内存中,因此不存在页面置换的情况,也自然不存在脏页写回的问题。那么,在这个构造函数中,需要做的主要是利用文件的ReadAt()函数读入可执行文件的段数据,由于nachos地址空间由段数据和堆栈指针构成,因此可据此获得用户地址空间的大小,从而计算出所需要的页表数目,然后便可对页表中的每一页进行初始化即可,最后还有一步很重要的操作是将各段数据放入物理内存中(此时还不支持调页的方式)。
  3. InitRegisters():cpu寄存器的初始化过程,其中不仅包括通用寄存器,还有很重要的两个寄存器是PC寄存器和nextPC寄存器,两者也同样需要初始化,寄存器初始之后,还有需要初始化的是堆栈指针,也在这个函数中进行即可。
  4. RestoreState():修改机器的页表指针指向当前进程的页表即可。
  5. Run():进程的执行过程,这是一个很复杂的函数,其中涉及到每一个指令的执行过程,其中执行指令的过程均通过调用OneInstruction()函数进行。函数中执行的具体过程为:首先根据PC寄存器中存储的地址,调用ReadMem()函数获取对应地址所存储的指令,然后分析并执行指令即可,最后还需要修改PC寄存器和其他寄存器的内容,使其保存各自对应指令的地址。需要补充一下的是ReadMem()函数,因为PC寄存器中保存的是虚地址,因此该函数的作用其实类似于MMU单元,需要将虚地址转换为实地址,这个步骤主要是通过调用函数Translate()函数来实现的,其中主要过程为判断是否存在快表以及是否命中快表,如果没有命中则需要访问页表,并修改其响应的标识位。而ReadMem()函数除了调用Translate()函数以后,还进行了地址转换过程中的各种异常处理,以及不同数据存储方式的读取过程(字节,字…)。

以上就是实验六的相关内容,其更主要的一个作用是为了解进程的创建和执行所需要维护的一些数据结构和基本过程,为实验七,八做铺垫,因为很多东西在该实验中还没有实现,需要在接下来的两个实验中自己补充实现。

实验七,八

最后两个实验是对上一个实验的进一步理解和完善,上一个实验更倾向于进程的创建和执行过程,最后两个实验则更侧重于系统调用的实现相关数据结构的使用过程

系统调用

  1. exec():该系统调用的功能时加载运行另一个应用程序,在Linux环境下使用时实现的方法往往是依然在此进程中,只不过用另一块地址空间替换了当前进程的地址空间,因此当前进程中“exec(“xxx”)“之后的代码一般是不执行的,因为地址空间已经改变,即代码段和数据段等内容也均改变。但是此次实验中使用nochos环境下,exec()的实现方法和Linux环境下的略有不同:首先依然需要从特定寄存器读入exec()的参数(文件名称),然后据此参数打开文件,装入内存中,但是并不是替换当前进程的地址空间,而是建立一个新的进程,并分配空闲地址空间,有点类似于fork()调用,但是创建的新线程和原来的线程并没有父子关系。而且,也不是立刻执行对应的新线程,只是将其放入就绪队列中,等待被调度后执行。也正是因为没有采用地址空间替换的方式,原线程中exec()调用之后的代码依然会继续执行。
  2. exit()和join():退出当前线程,并且保存进程的退出状态,用状态码记录下来。那么,进程都已经退出了,如何获取对应的状态码呢?或者说状态码保存在哪里?这就引出了join()调用,其参数是进程pid,含义为等待对应的进程,并返回其退出状态,记调用join()的线程为joiner,被等待的线程为joinee。可是,问题依然没有解决,调用join()时joiner线程如何判断当前joinee线程是否已经结束了呢?或者说它是该继续等待还是直接获取退出码返回。为此,本实验中为进程额外建立一种新的状态–terminated状态,即当进程该退出时不会立刻退出,而是进入terminated状态,并且维护了一个terminated链表,这样joiner线程就可以在调用join()时根据等待的pid遍历terminated链表,如果存在自己等待的线程,那么就说明joinee已经执行结束,可以直接记录joinee的状态码,并继续执行当前线程后面的代码;而如果不存在自己等待的线程,则需要进入等待状态,直到joinee结束为止。因此,除了维护一个terminated链表,还需要维护一个waiting链表,用来记录等待状态的joiner。那么,什么时候使其退出等待状态呢?逻辑也很简单,当joinee线程退出时,遍历waiting链表,如果找到等待自己的joiner线程,就可以唤醒它,并将自己的状态码传递给joiner线程;而如果没有找到joiner线程,则直接进入terminated状态即可。这样就解决了join()和exit()调用的耦合关系。还有一个小细节就是,所有线程结束之后都会进入terminated状态,并加入terminated链表,那么什么时候从链表中删去该线程呢?本实验中采用的一种暴力的方法,就是通过在调用exit()时,规定一个特殊的状态码,作为清空terminated链表,当然,这种方法存在很大的问题,不宜采用。。。

数据结构

  1. pid相关:为了实现系统调用,除了逻辑和基本功能,还需要使用一些辅助的数据来记录数据,比如进程pid,比如退出状态码…首先考虑pid的问题,pid主要用来每个线程的处理,以及join调用中joiner等待joinee时的参数,因此考虑用两个属性UserProgramId和waitingProcessSpaceId来分别表示线程对应的pid和join()时的参数joinee(没有使用join调用的进程可忽略这个属性),前者可以在分配pid时即可构建,而后者则在调用在使用join时赋值即可。
  2. 进程状态相关:之前讨论中已经提及,为了给系统调用join()和exit()提供支持,需要为进程加一个terminated状态,并且还需要在thread类中增加一个terminated链表和waiting链表,分别保存结束的进程以及调用join()后等待的进程。
  3. 状态码相关:状态码只与exit()和join()调用相关,即分别对应joinee线程和joiner线程,也可以用两个属性exitCode和waitProcessExitCode来表示,前者可以在exit()传入参数时赋值,后者则需要在调用join()时具体考虑:遍历terminated链表中如果找到了对应的joinee,则可以直接获取waitProcessExitCode,而如果没有找到对应的joinee,则进入等待状态,并加入waiting链表中。直到joinee线程结束时,在遍历waiting链表时,再将对应的joiner线程的waitProcessExitCode设为当前线程的exitCode,并唤醒对应线程。

其他内容

除了上面的处理之外,还有一些额外的辅助信息,如AddrSpace文件中将文件中内存装入主存,由于nachos原来只支持单进程,所以直接从虚地址的0地址开始即可,但是现在支持多进程同时存在,因此装入主存的位置就需要根据利用页的分配原则,将虚地址转换为主存的实地址进行装载。还有使系统调用和其他的指令处理过程统一起来的AdvancePC()函数等。

总结

到这里,nachos实验就全部结束了。整体个过程下来,最大的感受就是对操作系统的进程管理和内存管理有了更深入的理解,从开始编写源程序文件保存到磁盘上开始,涉及到文件的存储方式,以及文件目录表的管理,磁盘文件的分配等,当然,还有与文件相关的一些系统调用,如open(),create(),write(),copy()等;当源程序文件装载到内存中时,静态的文件就变成了动态的进程和线程,这里又涉及到包括进程的创建与执行,内存中为进程进行地址的分配,以及寻址时虚地址和实地址的转换,进程的上下文切换过程等。当然,这一切的前提,都是基于操作系统首先进行装载,因为本质上操作系统也是一个进程,上面的这些功能都是通过运行这个进程来实现的。因此当我们开机时,首先需要通过一个init()函数来将操作系统内核装入内存中,然后运行操作系统进程来进行接下来的过程。
但是,说到底nachos只是一个简单的操作系统的模拟,它并不是一个真正的可用的操作系统,甚至比起类似于Linux,Windows操作系统远远不及,本实验的目的只是打开我对操作系统这个庞然大物的面纱,接下来需要我进一步去探索其中更加复杂的奥秘。对于操作系统的学习之路,才只是刚刚开始而已。

  • 2
    点赞
  • 22
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值