MIT6.828 Lab4

  本文参考了很多网上其他大佬的资料,这里记录仅供自己学习使用,欢迎大家一起交流讨论。参考链接:fatsheep9146,一丁点儿lab4partb
  这整个lab分为3个部分,在第一个部分, 将首先扩展 JOS 以在多处理器系统上运行, 然后实现一些新的 JOS 内核系统调用允许用户级环境创建其他新环境。 您还将实现协作轮询调度,允许内核从一个环境切换到另一个环境 当当前环境自愿放弃 CPU(或退出)时。 在最后一个部分中,将实现抢占式调度, 它允许内核从环境中重新控制 CPU 即使当前运行的任务并没有完成。
  开始这个实验之前先要有多处理器初始化的相关知识,机器的主板上有多个处理器,上电后,其中一个启动,称为Bootstrap Processor (BSP),BSP必须引导其它处理器的初始化。其它处理器称为Application Processor (AP),具体哪个处理器成为BSP由硬件决定。
  处理器之间的通信通过每个处理器伴随的APIC实现,这是个新的IO设备,映射了一段地址。操作这个IO设备的代码主要在kern/lapic.c。
  而在第一个练习中我们就要为APIC映射地址,我们需要实现kern/pmap.c中的 mmio _map _region这个函数主要负责管理内存映射IO地址输入一个物理地址,返回一个相映射的虚拟地址,这相当于实现了一个分配器的功能以页表为单位。

AP启动

  在启动APs之前,BSP先收集关于多处理器系统的配置信息,比如CPU总数,CPUs的APIC ID,LAPIC单元的MMIO地址等。程序中是通过kern/mpconfig.c中的一个mp_init()函数来实现的。之后我们需要引导其他的处理器,在BSP初始化完成后,我们需要引导各个AP的初始化,主要是通过boot_aps()这个函数,它驱动了AP引导过程。 AP以实模式启动,非常类似于 bootloader 在boot/boot.S中启动的方式,因此boot _aps()将AP进入代码(kern / mpentry.S)复制到可在实模式下寻址的内存位置。与 bootloader 不同,我们可以控制 AP 开始执行代码的位置; 我们将 entry 代码复制到0x7000(MPENTRY_PADDR),但任何未使用的,页面对齐的物理地址低于640KB都可以。之后,boot_aps函数通过发送STARTUP的IPI(处理器间中断)信号到AP的 LAPIC 单元来一个个地激活AP。在kern/mpentry.S中的入口代码跟boot/boot.S中的代码类似。在一些简短的配置后,它使AP进入开启分页机制的保护模式,调用C语言的setup函数mp_main。boot_aps 等待AP在其结构CpuInfo的cpu_status字段中发出CPU_STARTED标志信号,然后再唤醒下一个。

  之后就进行各种处理器栈初始化因为不同的处理器同时运行时,不能共享一个栈,每个处理器都是要有自己的栈。这种区分是在虚拟地址层面上的,不是在物理地址层面上的,不同虚拟地址可以映射到相同物理地址,也可以映射到不同。我们在给栈分配完KSTKSIZE大小后,中间要留出KSTKGAP作为保护,使得一个栈溢出不影响相邻的栈。
  然后再处理各个处理器中断的初始化,这里只需要在上一个lab中做一个小的改动即可,上一个lab中,函数trap_init_percpu在函数trap_init中调用,trap_init在i386_init中调用,但是实际上这是在给BSP进行初始化中断,但是之后再调用AP内核时,已经初始化过中断描述符表了,所以在AP中已经没有必要在做了,不需要再重复调用trap_init只用更改传入trap_init_percpu的变量ts(这是反应处理器号的)变为task state segment,其他操作都和之前的lab一样,区别就是要计算一下当前处理器栈的地址KSTACKTOP - thiscpu->cpu_id * (KSTKSIZE + KSTKGAP);也就是说BSP和AP的初始化的区别就是BSP中有进行中断向量表的初始化。技术术语:

  • TSS(任务状态段):用于存储任务的状态信息,包括堆栈指针、段选择子和I/O许可位图等。
  • ESP0:内核堆栈指针,用于在内核模式下处理中断和异常。
  • GDT(全局描述符表):用于存储段描述符的表格,其中包括段基址、段限制、访问权限等信息。

  因为内核在多个处理器上初始化要有一些同步机制,才能确保内核只会在其中一个处理器上运行,当用户进程让另一个处理器进入内核时,其他的处理器可以继续运行用户进程,但是不可以进入内核,必须要等到进入内核的处理器退出才可以,实现这个功能需要加一个内核大锁,目前项目中是使用自旋锁来实现的。处理器进入内核态后处在函数trap,故在trap开头加锁,等待其它处理器退出内核态。处理器要进入用户态时放开锁,也就是在env_run的最后,允许其它处理器进入内核态。

  然后实现了一个简单的进程间的循环调度,这个算法依赖用户进程自动放弃处理器的使用权,运行结束时正在运行的进程发出一个系统调用(SYS_yield),主动让内核切换到另一个进程,内核按进程创建的顺序运行,如果都没有进程在运行就用一个自旋锁不断轮询各个已创建进程的状态,直到有要运行的进程,这就完成了最简单的循环调度。

  这一部分的最后的内容是实现一个进程的fork,fork是为了复制当前进程的环境,启动另一个新的进程,因为JOS采用微内核,fork不封装在一个完整的系统调用中完成,而是拆成多次系统调用分别完成。这样可以让内核架构更加简单,把一些复杂度放到用户态,并交出了一点性能作为代价。JOS将一次fork拆分为了进程创建,进程状态修改,为进程分配地址,映射内存几个部分来进行,创建空白进程主要是对之前实现的env_alloc的封装,并设置结构体ENV的值,设置进程状态要先使用envid2env函数检查用户状态是否有问题,然后再根据要求更改env结构体中的运行状态。为进程分配地址主要是通过sys_page_map取出一些page把它们映射到进程地址空间,主要是对之前实现的page_alloc和page_insert的封装,最后完成取消映射对page_remove进行封装,这在进程销毁时会使用。

  这一部分主要完成了引导BSP加载APs,应用处理器的启动代码与BSP的启动代码最大的一个区别是:此时的BSP工作在保护模式,以虚拟地址的形式进行寻址,在启动APs时需要由物理地址变换为虚拟地址来加载页目录等操作。实现了内核的互斥访问,通过记录各个CPU的信息,最后实现了循环调度和进程的fork。

Copy-on-Write Fork

  更高版本的 Unix 利用虚拟内存硬件来允许父子进程共享映射到各自地址空间的内存,直到其中一个进程实际修改它。 这种技术称为写时复制(Copy-on-Write Fork)。为此,在fork上,内核会将地址空间映射从父级复制到子级而不是映射页面的内容,同时将现在共享的页面标记为只读。 当其中一个进程尝试写入其中一个共享页面时,该进程会发生页面错误。而在page fault处理例程中,单独将被写入的页(比如说栈)拷贝一份,修改掉发出写行为的进程的页表相应的映射。
  在实现的时候1个用户级写时拷贝的fork函数需要在写保护页时触发page fault,所以我们第一步应该先规定或者确立一个page fault处理例程,每个进程需要向内核注册这个处理例程,只需要传递一个函数指针即可,sys_env_set_pgfault_upcall函数将当前进程的page fault处理例程设置为func指向的函数。

  在正常运行期间,用户进程运行在用户栈上,栈顶寄存器ESP指向USTACKTOP处,堆栈数据位于USTACKTOP-PGSIZE 与USTACKTOP-1之间的页。当在用户模式发生1个page fault时,内核将在专门处理page fault的用户异常栈上重新启动进程。
  而异常栈则是为了上面设置的异常处理例程设立的。当异常发生时,而且该用户进程注册了该异常的处理例程,那么就会转到异常栈上,运行异常处理例程。目前为止总共出现了三个栈,分别是内核态系统栈[KSTACKTOP, KSTACKTOP-KSTKSIZE](这个是专门运行内核相关程序的栈),用户态错误处理栈[UXSTACKTOP, UXSTACKTOP - PGSIZE],用户态运行栈[USTACKTOP, UTEXT]。
  用户定义注册了自己的中断处理程序之后,相应的例程运行时的栈,整个过程如下:
  首先陷入到内核,栈位置从用户运行栈切换到内核栈,进入到trap中,进行中断处理分发,进入到page_fault_handler(),当确认是用户程序触发的page fault的时候(内核触发的直接panic了),为其在用户错误栈里分配一个UTrapframe结构体的大小(这个都是为了处理用户错误而建立的堆栈,当处理完错误要恢复之前运行程序时使用),把栈切换到用户错误栈,运行响应的用户中断处理程序中断处理程序,处理完成后,返回用户运行栈。整体上讲,当正常执行过程中发生了页错误,那么栈的切换是用户运行栈—>内核栈—>异常栈。

thiscpu->cpu_ts.ts_esp0 = KSTACKTOP - cpunum() * (KSTKGAP + KSTKSIZE);
thiscpu->cpu_ts.ts_ss0 = GD_KD;
//设置一些寄存器信息

  可以将用户自己定义的用户处理进程当作是一次函数调用看待,当错误发生的时候,调用一个函数,但实际上还是当前这个进程,并没有发生变化。所以当切换到异常栈的时候,依然运行当前进程,但只是运行的中断处理函数,所以说此时的栈指针发生了变化,而且程序计数器eip也发生了变化,同时还需要知道的是引发错误的地址在哪。这些都是要在切换到异常栈的时候需要传递的信息。和之前从用户栈切换到内核栈一样,这里是通过在栈上构造结构体,传递指针完成的。因为切换到异常栈不涉及段的切换,所以不用记录es,ds,ss等。异常栈如果溢出就会进入user_mem_assert销毁这个进程。
  在异常处理程序中主要做得是修改当前进程的程序计数器和栈指针,再重启这个进程,在用户错误栈上运行中断处理程序。(简单来说就是保存当前运行的栈的信息包括错误地址,错误码,寄存器状态等,然后将当前进程执行指针env_tf.tf_eip设置为页面错误处理函数的地址,并将栈指针env_tf.tf_esp设置为保存trapframe陷阱帧的地址,然后重新运行当前进程)
  完成了用户定义的处理函数后就要考虑从用户错误栈直接返回用户运行栈的函数(这里使用汇编实现的):_pgfault_upcall是所有用户页错误处理程序的入口,在这里调用用户自定义的处理程序,并在处理完成后,从错误栈中保存的UTrapframe中恢复相应信息,然后跳回到发生错误之前的指令,恢复原来的进程运行。
  到目前为止,我们为用户环境提供了一个注册定制的 page fault handler 的 system call。其在page_fault_handler中进行一次判断,check 是否当前进程设定了 默认的 handler(如果设定了就会将页面错误处理程序的指针保存在全局变量中供之后使用) ,再根据结果转向执行。而handler的调用与返回,需要通过一个汇编程序pfentry.S实现。 这个汇编程序才对真正的用户handler进行了调用,执行完 handler 之后依据 UTrapFrame 的内容返回到最初的用户环境执行。

  之后就是本次lab最重要的部分实现copy-on-write fork(写时拷贝)
与之前的dumbfork不同,fork出一个子进程之后,首先要进行的就是将父进程的页表的全部映射拷贝到子进程的地址空间中去。这个时候物理页会被两个进程同时映射,但是在写的时候是应该隔离的。采取的方法是在子进程映射的时候,将父进程空间中所有可以写的页表的部分全部标记为可读且COW。而当父进程或者子进程任意一个发生了写的时候,因为页表现在都是不可写的,所以会触发异常,进入到我们设定的page fault处理例程,当检测到是对COW页的写操作的情况下,就可以将要写入的页的内容全部拷贝一份,重新映射。
  在pgfault函数中先判断是否页错误是由写时拷贝造成的,如果不是则panic。借用了一个一定不会被用到的位置PFTEMP,专门用来发生page fault的时候拷贝内容用的。先解除addr原先的页映射关系,然后将addr映射到PFTEMP映射的页并将这个标记为可写和用户级别(这种级别有写,写时复制COW,用户级别),最后解除PFTEMP的页映射关系。
  接下来是duppage函数,负责进行COW方式的页复制,将当前进程的第pn页对应的物理页的映射到envid的第pn页上去,同时将这一页都标记为COW(这就是写时复制的标志位,这是在页表中声明好了的一个标志位)。
  最后完成fork函数,将页映射拷贝过去,整体的流程是:首先需要为父进程设定错误处理例程。这里调用set_pgfault_handler函数是因为当前并不知道父进程是否已经建立了异常栈,没有的话就会建立一个,而sys_env_set_pgfault_upcall则不会建立异常栈。
  调用sys_exofork准备出一个和父进程状态相同的子进程,状态暂时设置为ENV_NOT_RUNNABLE。然后进行拷贝映射的部分,在当前进程的页表中所有标记为PTE_P的页的映射都需要拷贝到子进程空间中去。但是有一个例外,是必须要新申请一页来拷贝内容的,就是用户异常栈。因为copy-on-write就是依靠用户异常栈实现的,所以说这个栈要在fork完成的时候每个进程都有一个,要硬拷贝过来。
  主要流程就是:
  1、申请新的物理页(也就是异常栈,调用上面实现的pgfault),映射到子进程的(UXSTACKTOP-PGSIZE)位置上去。(调用上面实现的duppage)
  2、父进程的PFTEMP位置也映射到子进程新申请的物理页上去,这样父进程也可以访问这一页。
  3、在父进程空间中,将用户错误栈全部拷贝到子进程的错误栈上去,也就是刚刚申请的那一页。
  4、然后父进程解除对PFTEMP的映射。
  5、最后将子进程的页错误处理函数设置为_pgfault_upcall,把子进程的状态设置为可运行,返回子进程的ID。

  这个时候我们的进程还是只能顺序去执行,如果一个进程获得了CPU资源后一直死循环不主动让出CPU控制权,那整个系统都将死锁住,所以为了允许内核抢占正在运行的环境,必须要扩展JOS内核来支持时钟的外部硬件中断。外部硬件中断(比如设备中断)被称为IRQs,IRQ号到IDT的映射不是固定的,会加上一个IRQ_OFFSET的偏移,我们首先使用TRAPHANDLER_NOEC宏修改Trapentry.s文件使得调用硬件中断处理时,处理器不会传入错误代码,然后继续修改trap.c来注册IDT事件,使用了SETGATE宏来设置中断门描述符表中的中断门,之后设置了eflags寄存器中的fl_if(这是一个宏定义,表示if位在eflags寄存器中的位置)标志位为1目的是为了开启中断,然后完成中断分发函数,先使用lapic_eoi接受这个中断并通知芯片中断已经处理完毕,再顺序去执行下一个进程,至此我们完成了操作系统的抢占中断。

  lab4的最后一步我们要实现操作系统程序间的正常通信,我们将在这里实现一个最简单IPC机制。
  先完成sys_ipc_try_send和sys_ipc_recv,第一个函数是通过envid2env这个函数获取指定进程的结构体,然后判断接受进程是否处于等待接受状态,并且没有其他进程已经请求发送数据了,接着判断如果发送数据的地址在用户空间,就查找对应的页面,判断权限是否满足(就是看权限里面是否有可写标志位),如果上述条件都满足就表示目标进程愿意接受这个页面,将这个页面映射到进程中的页表中(使用的是page_insert函数),最后设置接受进程的相关字段(比如更改处于等待接受模式),将其状态改为可运行,将eax寄存器设置为0,表示发送成功。第二个函数判断是否页对齐小于用户空间的最高地址,然后更改当前进程的状态,让他阻塞挂起,设置一些数据(比如接收标志位),然后执行sched_yield将CPU的调度权转交给其他进程执行,当其他进程运行我们上述说的第一个函数时也就是发送IPC请求时,这个进程会被唤醒并继续执行。
  之后使用这两个函数封装以下ipc_send发送和ipc_recv,第一个函数通过系统调用sys_ipc_try_send向目标进程发送一个消息,并在目标进程未准备好接收消息时,调用sys_yield将CPU让给其他进程。当sys_ipc_try_send返回值为-E_IPC_NOT_RECV时,表示目标进程未准备好接收消息,此时调用sys_yield将CPU让给其他进程。当sys_ipc_try_send返回值小于0且不为-E_IPC_NOT_RECV时,表示发送消息失败,此时调用panic函数打印错误信息并终止程序。第二个函数是使用sys_ipc_recv封装的一个ipc_recv函数,主要设置IPC消息来源进程ID,IPC消息权限,如果成功接收IPC消息就返回IPC消息的值,否则返回错误码。

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

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值