MIT 6.828 JOS/XV6 LAB4-partB

这里要实现的就是UNIX标准系统调用中的fork,核心当然是copy on write技术

至于为什么采用copy on write,是因为子进程在被创建之后很可能立刻执行exec()了,之前做的一系列的拷贝是无用功

所以说,当创建一个新的子进程的时候,只需要拷贝父进程的内存映射(页表)就可以了,而且将父进程所有的内存映射页都标记为只读的,这样,当子进程或者父进程尝试去读的时候是安全的,而当尝试去写的时候,就会出发page fault,而在page fault处理例程中,单独将被写入的页(比如说栈)拷贝一份,修改掉发出写行为的进程的页表相应的映射就可以了

所以说,第一步应该先规定或者确立一个page fault处理例程

每个进程需要向内核注册这个处理例程,只需要传递一个函数指针即可

sys_env_set_pgfault_upcall函数

将当前进程的page fault处理例程设置为func指向的函数

Normal and exception stacks in user environments

这里出现了第三个栈,即异常栈

在用户程序正常运行时,出在正常的用户栈上,用户栈顶位于USTACKTOP处

而异常栈则是为了上面设置的异常处理例程设立的。当异常发生时,而且该用户进程注册了该异常的处理例程,那么就会转到异常栈上,运行异常处理例程

到目前位置出现了三个栈:

  1. [KSTACKTOP, KSTACKTOP-KSTKSIZE]

内核态系统栈

  1. [UXSTACKTOP, UXSTACKTOP - PGSIZE]

用户态错误处理栈

  1. [USTACKTOP, UTEXT]

用户态运行栈

  • 对于内核态系统栈

是运行内核相关程序的栈,在有中断被触发之后,CPU会将栈自动切换到内核栈上来,而内核栈的设置是在kern/trap.c的trap_init_percpu()中设置的

可以看到内核栈的大小是固定了的,在系统初始化的时候就设定好了的

在中断触发之后,进入kern/trapentry.S代码时,此时所处的栈就已经切换到了内核栈了,而且会在内核栈上压入一系列的内容,手动形成一个trapframe

  • 用户运行时栈

是用户运行中使用的栈,是在用户进程创建之初初始化的

实际中的应用进程在初始化的情况下只有一页的大小,如果发生了stack overflow,就会触发页错误,但是用户空间中的页错误是有处理程序的,会将栈顶下方一页映射到当前内存空间

所以说,用户运行栈是一边运行一边生长的,而内核栈是固定大小的,错误栈也是

  • 用户态错误栈

用户定义注册了自己的中断处理程序之后,相应的例程运行时的栈

这个过程如下

  • 首先陷入到内核,栈位置从用户运行栈切换到内核栈,进入到trap中,进行中断处理分发,进入到page_fault_handler()
  • 当确认是用户程序触发的page fault的时候(内核触发的直接panic了),为其在用户错误栈里分配一个UTrapframe的大小
  • 把栈切换到用户错误栈,运行响应的用户中断处理程序
  • 中断处理程序可能会触发另外一个同类型的中断,这个时候就会产生递归式的处理
  • 处理完成之后,返回到用户运行栈

Invoking the user page fault handler

当用户进程运行出错,而且对于这个错误,用户是定义了自己的异常处理例程的时候,按照前面的说法,是需要切换到异常栈上去执行的

那么如何切换过去呢?

可以将用户自己定义的用户处理进程当作是一次函数调用看待,当错误发生的时候,调用一个函数,但实际上还是当前这个进程,并没有发生变化

所以当切换到异常栈的时候,依然运行当前进程,但只是运行的中断处理函数,所以说此时的栈指针发生了变化,而且程序计数器eip也发生了变化,同时还需要知道的是引发错误的地址在哪。这些都是要在切换到异常栈的时候需要传递的信息

和之前从用户栈切换到内核栈一样,这里是通过在栈上构造结构体,传递指针完成的

这里新定义了一个结构体用来记录出现用户定义错误时候的信息Utrapframe

相比于UTrapframe,这里多了utf_fault_va,因为要记录触发错误的内存地址

同时还少了es,ds,ss等。因为从用户态栈切换到异常栈,或者从异常栈再切换回去,实际上都是一个用户进程,所以不涉及到段的切换,不用记录

在实际使用中,Trapframe是作为记录进程完整状态的结构体存在的,也作为函数参数进行传递;而UTrapframe只在处理用户定义错误的时候用到

整体上讲,当正常执行过程中发生了页错误,那么栈的切换是

用户运行栈--->内核栈--->异常栈

而如果在异常处理程序中发生了也错误,那么栈的切换是

异常栈--->内核栈--->异常栈

接下来就是要实现page_fault_handler函数

如果当前已经在用户错误栈上了,那么需要留出4个字节,否则不需要,具体和跳转机制有关系

简单说就是在当前的错误栈顶的位置向下留出保存UTrapframe的空间,然后将tf中的参数复制过来

修改当前进程的程序计数器和栈指针,然后重启这个进程,此时就会在用户错误栈上运行中断处理程序了

当然,中断处理程序运行结束之后,需要再回到用户运行栈中,这个就是异常处理程序需要做的了

这里还有一个问题

如果异常栈发生了overflow怎么办?

看一下memlayout.h就知道了

可以看到,用户异常栈就一页的大小,一旦溢出,访问的就是内核都没有访问权限的空间,会发生内核空间中的page fault,此时会直接panic,不会造成更严重的后果

接下来就是写汇编程序的一部分了,主要实现的功能是:当从用户定义的处理函数返回之后,如何从用户错误栈直接返回到用户运行栈。这部分我比较头疼,直接参考了张弛师兄的代码

还有就是set_pgfault_handler()函数了,主要是为进程设定处理历程,同时分配错误栈

接下来就是最重要的部分:实现copy-on-write fork

与之前的dumbfork不同,fork出一个子进程之后,首先要进行的就是将父进程的页表的全部映射拷贝到子进程的地址空间中去。

这个时候物理页会被两个进程同时映射,但是在写的时候是应该隔离的。采取的方法是,在子进程映射的时候,将父进程空间中所有可以写的页表的部分全部标记为可读,且COW

而当父进程或者子进程任意一个发生了写的时候,因为页表现在都是不可写的,所以会触发异常,进入到我们设定的page fault处理例程,当检测到是对COW页的写操作的情况下,就可以将要写入的页的内容全部拷贝一份,重新映射。

一开始我还在想,在进入到page fault处理例程的时候,在拷贝一份之后,就可以将这一页标记为可以写了啊,这样另外一个进程就不在写的时候触发异常了。但是我还是naive啊,因为fork操作是可以嵌套多层的,所以不知道有多少个进程有向这个页面的映射。

所以可以采取的方法是,当进程触发了page fault,完成了相应页的拷贝之后,需要解出对原来页的映射,此时可以调用page_remove,而这个函数会根据页的引用计数决定是否回收,所以不会出现内存泄漏的情况。

首先看page fault处理例程

这个逻辑还是挺明朗的,这里借用了一个一定不会被用到的位置PFTEMP,专门用来发生page fault的时候拷贝内容用的。

需要注意的是,在map之前一定要先unmap,否则的话,如果子进程和父进程都对同一位置进行了写操作,都复制了一份出来,那么原来的一份就没用了,而又没有进行unmap操作的话,这一页的引用计数永远不归零。

然后再看duppage函数

作用就是将当前进程的第pn页对应的物理页的映射到envid的第pn页上去,同时将这一页都标记为COW

fork函数

需要将映射拷贝过去,这里需要考虑的地址范围就是从UTEXT到UXSTACKTOP为止,而在此之上的范围因为都是相同的,在env_alloc的时候已经设置好了,所以不需要考虑了

首先需要为父进程设定错误处理例程,这里调用set_pgfault_handler函数是因为当前并不知道父进程是否已经建立了错误栈,没有的话就会建立一个

而sys_env_set_pgfault_upcall则不会建立错误站

调用sys_exofork准备出一个和父进程状态相同的子进程,状态暂时设置为ENV_NOT_RUNNABLE

然后进行拷贝映射的部分,在当前进程的页表中所有标记为PTE_P的页的映射都需要拷贝到子进程空间中去

但是有一个例外,是必须要新申请一页来拷贝内容的,就是用户错误栈

因为copy-on-write就是依靠用户错误栈实现的,所以说这个栈要在fork完成的时候每个进程都有一个,所以要硬拷贝过来

这里看着比较别扭,流程就是:

  1. 申请新的物理页,映射到子进程的(UXSTACKTOP-PGSIZE)位置上去
  2. 父进程的PFTEMP位置也映射到子进程新申请的物理页上去,这样父进程也可以访问这一页了
  3. 在父进程空间中,将用户错误栈全部拷贝到子进程的错误栈上去,也就是刚刚申请的那一页
  4. 然后父进程解除对PFTEMP的映射

最后把子进程的状态设置为可运行就可以了

时间: 2024-10-09 11:17:34

MIT 6.828 JOS/XV6 LAB4-partB的相关文章

MIT 6.828 JOS/XV6 LAB4-partA

这一部分要实现的是对多核处理器的支持,然后实现系统调用只喜欢用户应用创建新的应用,而且还要实现round-robin调度算法 Multiprocessor support jos中对CPU进行了抽象 要描述一个CPU, 需要知道id,运行状态,当前正在运行的进程 所有的cpu数目放在cpus数组中 接下来则是对有多个cpu的处理器的抽象,这里使用了三个结构体,总之是比较乱,目前还不能完全看懂 多核处理器的初始化都在mp_init函数中完成,首先是调用mpconfig函数,主要功能是寻找一个MP

MIT 6.828 JOS/XV6 LAB4-partC

这一部分要实现抢占式调度和进程间通信 前面的调度是进程资源放弃CPU,但是实际中没有进程会这样做的,而为了不让某一进程耗尽CPU资源,需要抢占式调度,也就需要硬件定时 但是外部硬件定时在Bootloader的时候就关闭了,至今都没有开启 而JOS采取的策略是,在内核中的时候,外部中断是始终关闭的,而在用户态的时候,需要开启中断 所以首先需要求改IDT,但是我在之前把256个都弄好了 所以现在只需要在进入用户态的时候,保证外部中断是使能的就可以了,为了做到这一点,可以在每个进程初始化的时候就将外部

MIT 6.828 JOS学习笔记2. Lab 1 Part 1.2: The kernel

Lab 1 Part 1: PC bootstrap 我们继续~ PC机的物理地址空间 这一节我们将深入的探究到底PC是如何启动的.首先我们看一下通常一个PC的物理地址空间是如何布局的:                           这张图仅仅展示了内存空间的一部分. 第一代PC处理器是16位字长的Intel 8088处理器,这类处理器只能访问1MB的地址空间,即0x00000000~0x000FFFFF.但是这1MB也不是用户都能利用到的,只有低640KB(0x00000000~0x00

MIT 6.828 JOS学习笔记17. Lab 3.1 Part A User Environments

Introduction 在这个实验中,我们将实现操作系统的一些基本功能,来实现用户环境下的进程的正常运行.你将会加强JOS内核的功能,为它增添一些重要的数据结构,用来记录用户进程环境的一些信息:创建一个单一的用户环境,并且加载一个程序运行它.你也可以让JOS内核能够完成用户环境所作出的任何系统调用,以及处理用户环境产生的各种异常. Part A: User Environments and Exception Handling 新包含的文件inc/env.h里面包含了JOS内核的有关用户环境(

MIT 6.828 JOS学习笔记7. Lab 1 Part 2.2: The Boot Loader

Lab 1 Part 2 The Boot Loader Loading the Kernel 我们现在可以进一步的讨论一下boot loader中的C语言的部分,即boot/main.c.但是在我们分析之前,我们应该先回顾一些关于C语言的基础知识. Exercise 4: 阅读关于C语言的指针部分的知识.最好的参考书自然是"The C Programming Language". 阅读5.1到5.5节.然后下载pointers.c的代码,并且编译运行它,确保你理解在屏幕上打印出来的所

MIT 6.828 JOS学习笔记16. Lab 2.2

Part 3 Kernel Address Space JOS把32位线性地址虚拟空间划分成两个部分.其中用户环境(进程运行环境)通常占据低地址的那部分,叫用户地址空间.而操作系统内核总是占据高地址的部分,叫内核地址空间.这两个部分的分界线是定义在memlayout.h文件中的一个宏 ULIM.JOS为内核保留了接近256MB的虚拟地址空间.这就可以理解了,为什么在实验1中要给操作系统设计一个高地址的地址空间.如果不这样做,用户环境的地址空间就不够了. Permission and Fault

MIT 6.828 JOS学习笔记5. Exercise 1.3

Exercise 1.3 设置一个断点在地址0x7c00处,这是boot sector被加载的位置.然后让程序继续运行直到这个断点.跟踪/boot/boot.S文件的每一条指令,同时使用boot.S文件和系统为你反汇编出来的文件obj/boot/boot.asm.你也可以使用GDB的x/i指令来获取去任意一个机器指令的反汇编指令,把源文件boot.S文件和boot.asm文件以及在GDB反汇编出来的指令进行比较. 追踪到bootmain函数中,而且还要具体追踪到readsect()子函数里面.找

MIT 6.828 JOS学习笔记4. Lab 1 Part 2.1: The Boot Loader

Part 2: The Boot Loader 对于PC来说,软盘,硬盘都可以被划分为一个个大小为512字节的区域,叫做扇区.一个扇区是一次磁盘操作的最小粒度.每一次读取或者写入操作都必须是一个或多个扇区.如果一个磁盘是可以被用来启动操作系统的,就把这个磁盘的第一个扇区叫做启动扇区.这一部分介绍的boot loader程序就位于这个启动扇区之中.当BIOS找到一个可以启动的软盘或硬盘后,它就会把这512字节的启动扇区加载到内存地址0x7c00~0x7dff这个区域内. 对于6.828,我们将采用

MIT 6.828 JOS学习笔记10. Lab 1 Part 3: The kernel

Lab 1 Part 3: The kernel 现在我们将开始具体讨论一下JOS内核了.就像boot loader一样,内核开始的时候也是一些汇编语句,用于设置一些东西,来保证C语言的程序能够正确的执行. 使用虚拟内存 在运行boot loader时,boot loader中的链接地址(虚拟地址)和加载地址(物理地址)是一样的.但是当进入到内核程序后,这两种地址就不再相同了. 操作系统内核程序在虚拟地址空间通常会被链接到一个非常高的虚拟地址空间处,比如0xf0100000,目的就是能够让处理器