Introduction
在本实验中,您将在多个同时活跃的用户模式环境中实现抢占式多任务处理。
在A部分中,我们将为JOS添加多处理器支持,实现轮询调度,并添加基本的环境管理系统调用(创建和销毁环境的调用,以及分配/映射内存的调用)。
在B部分中,我们将实现一个类似unix的fork(),它允许用户态环境创建自身的副本。
最后,在C部分,你将添加对进程间通信(IPC)的支持,允许不同的用户模式环境显式地相互通信和同步。您还将添加对硬件时钟中断和抢占的支持。
本文是lab4的实验报告,主要围绕 进程相关概念进行介绍。主要将四个知识点:
开启多处理器。现代处理器一般都是多核的,这样每个CPU能同时运行不同进程,实现并行。需要用锁解决多CPU的竞争。介绍了 spin lock和 sleep lock,并给出了spin lock的实现。
实现 进程调度算法。
实现写时拷贝fork( 进程创建)。
实现 进程间通信。
Getting Started
实验4包含许多新的源文件,其中一些你应该在开始之前浏览:
kern/cpu.h内核私有定义,用于支持多处理器
kern/mpconfig.c用于读取多处理器配置的代码
kern/lapic.c 在每个处理器中驱动本地APIC单元的内核代码
kern / mpentry.S 非引导cpu的汇编语言入口代码
kern/spinlock.h 自旋锁的内核私有定义,包括大内核锁
kern/spinlock.c实现自旋锁的内核代码
kern/sched.c用户将要实现的调度器的代码框架
Part A: Multiprocessor Support and Cooperative Multitasking
第A部分:多处理器支持和协作多任务处理
在本实验室的第一部分中,您将首先扩展JOS以在多处理器系统上运行,然后实现一些新的JOS内核系统调用,以允许用户级环境创建额外的新环境。大家还将实现协作式轮询调度,允许内核在当前环境主动放弃CPU(或退出)时从一个环境切换到另一个环境。在C部分后面的内容中,读者将实现抢占式调度,它允许内核在一段时间后从环境重新获得对CPU的控制权,即使环境不配合。
Multiprocessor Support
我们将使JOS支持“对称多处理”(SMP),这是一种多处理器模型,在这种模型中,所有cpu对系统资源(如内存和I/O总线)具有相同的访问权限。虽然在SMP中所有cpu的功能都是相同的,但在启动过程中,cpu可以分为两类:引导处理器(bootstrap processor, BSP)负责初始化系统和引导操作系统;只有在操作系统启动并运行之后,BSP才会激活应用程序处理器(APs)。哪个处理器是BSP是由硬件和BIOS决定的。到目前为止,所有现有的JOS代码都在BSP上运行。
在SMP系统中,每个CPU都有一个相应的本地APIC (LAPIC)单元。LAPIC单元负责在整个系统中交付中断。LAPIC还为其连接的CPU提供一个唯一标识符。在本实验室中,我们利用了以下LAPIC单元的基本功能(在kern/ lapic .c中):
读取LAPIC标识符(APIC ID)来判断代码当前运行在哪个CPU上(参见cpunum())。
从BSP向ap发送STARTUP 处理器间中断(IPI),以启动其他cpu(参见lapic_startap())。
在C部分中,我们编写了LAPIC的内置定时器来触发时钟中断,以支持抢占式多任务(参见apic_init())。
处理器使用内存映射I/O (MMIO)访问它的LAPIC。在MMIO中,物理内存的一部分硬连接到一些I/O设备的寄存器,因此通常用于访问内存的相同的加载/存储指令可以用于访问设备寄存器。你已经在物理地址0xA0000处看到了一个IO空洞(我们用它来写入VGA显示缓冲区)。LAPIC位于一个从物理地址0xFE000000开始的空洞中(比4GB少32MB),因此对于我们在KERNBASE上使用通常的direct map来访问它来说太高了。JOS虚拟内存映射在MMIOBASE中留下了4MB的空白,因此我们有一个地方可以像这样映射设备。由于后面的实验室引入了更多的MMIO区域,因此我们将编写一个简单的函数来从该区域分配空间并将设备内存映射到该区域。
APIC:Advanced Programmable Interrupt Controller高级可编程中断控制器 。APIC 是装置的扩充组合用来驱动 Interrupt 控制器 [1] 。在目前的建置中,系统的每一个部份都是经由 APIC Bus 连接的。"本机 APIC" 为系统的一部份,负责传递 Interrupt 至指定的处理器;举例来说,当一台机器上有三个处理器则它必须相对的要有三个本机 APIC。自 1994 年的 Pentium P54c 开始Intel 已经将本机 APIC 建置在它们的处理器中。实际建置了 Intel 处理器的电脑就已经包含了 APIC 系统的部份。
LAPIC单元负责在整个系统中提供中断。 LAPIC还为其连接的CPU提供唯一标识符。
练习1
在kern/pmap.c中实现mmio_map_region。要了解如何使用它,请查看kern/lapic.c中lapic_init的开头部分。在运行mmio_map_region的测试之前,你还必须完成下一个练习。
Application Processor Bootstrap
在启动APs之前,BSP应该首先收集有关多处理器系统的信息,例如cpu的总数、APIC id和LAPIC单元的MMIO地址。kern/mpconfig.c中的mp_init()函数通过读取位于BIOS内存区域中的MP配置表来获取该信息。
boot_aps()函数(在kern/init.c中)驱动AP引导进程。APs在真实模式中启动,就像引导加载程序在boot/boot中启动一样。因此,boot_aps()将AP入口代码(kern/mpentry.S)复制到真实模式下可寻址的内存位置。与引导加载程序不同,我们对AP开始执行代码的位置有一定的控制;我们将条目代码复制到0x7000 (MPENTRY_PADDR),但是任何低于640KB的未使用的、按页对齐的物理地址都可以工作。
之后,boot_aps()将启动IPIs发送到对应AP的LAPIC单元,以及初始的CS:IP地址,AP将在该地址开始运行其入口代码(在我们的例子中是MPENTRY_PADDR),从而逐个激活AP。kern/mpentry中的入口代码。S和boot/boot.S非常相似。经过一些简短的设置后,它将AP置于保护模式并启用分页,然后调用C设置例程mp_main()(也在kern/init.c中)。boot_aps()等待AP在其struct CpuInfo的cpu_status字段中发出CPU_STARTED标志,然后继续唤醒下一个AP。
练习2
读取kern/init.c中的boot_aps()和mp_main(),以及kern/mpentry.S中的汇编代码。确保你理解APs引导过程中的控制流转移。然后修改kern/pmap.c中对page_init()的实现,避免将MPENTRY_PADDR中的页添加到未使用内存列表中,这样我们就可以安全地复制并在该物理地址上运行AP引导代码。你的代码应该能通过更新后的check_page_free_list()测试(但更新后的check_kern_pgdir()测试可能会失败,我们很快就会修复这个问题)。
Per-CPU State and Initialization
在编写多处理器操作系统时,区分每个处理器私有的per-CPU状态和整个系统共享的全局状态是很重要的。kern/cpu.h定义了大多数各cpu状态,包括存储各cpu变量的struct CpuInfo。cpunum()总是返回调用它的CPU的ID,可以用作CPU等数组的索引。或者,宏thiscpu是当前CPU的struct CpuInfo的简写。
以下是你应该知道的per-CPU状态:
Per-CPU kernel stack.
因为多个cpu可能同时进入内核,我们需要为每个处理器提供一个单独的内核栈,以防止它们干扰彼此的执行。数组percpu_kstacks[NCPU][KSTKSIZE]为NCPU级别的内核栈分配空间。
在实验2中,您将bootstack引用的物理内存映射为BSP的内核堆栈,就在KSTACKTOP下面。类似地,在本实验中,您将把每个CPU的内核堆栈映射到这个区域,保护页充当它们之间的缓冲区。CPU 0的栈仍然会从KSTACKTOP向下增长;CPU 1的栈将从CPU 0的栈底部以下的KSTKGAP字节开始,以此类推。Inc /memlayout.h显示映射布局。
Per-CPU TSS and TSS descriptor.
还需要一个per-CPU任务状态段(TSS),用于指定每个CPU的内核栈所在的位置。CPU i的TSS存储在cpus[i]中。对应的TSS描述符定义在GDT项GDT [(GD_TSS0 >> 3) + i]中。在kern/trap.c中定义的全局ts变量将不再有用。
Per-CPU current environment pointer.
由于每个CPU可以同时运行不同的用户进程,我们重新定义了符号curenv,表示CPU [cpunum()]。cpu_env(或这个cpuu ->cpu_env),指向当前CPU(代码运行所在的CPU)上当前执行的环境。
Per-CPU system registers.
所有寄存器(包括系统寄存器)都是CPU私有的。因此,初始化这些寄存器的指令,如lcr3()、ltr()、lgdt()、lidt()等,必须在每个CPU上执行一次。函数env_init_percpu()和trap_init_percpu()就是为此定义的。
除此之外,如果您在解决方案中添加了任何额外的per-CPU状态或执行了任何额外的特定于CPU的初始化(例如,在CPU寄存器中设置新的位)以解决早期实验中的问题,请务必在这里的每个CPU上复制它们!
练习3
修改mem_init_mp()(在kern/pmap.c中)以映射从KSTACKTOP开始的各cpu栈,如inc/memlayout.h所示。每个栈的长度是KSTKSIZE字节加上未映射保护页的KSTKGAP字节。你的代码应该在check_kern_pgdir()中传递新的检查。
练习4
trap_init_percpu() (kern/trap.c)中的代码初始化BSP的TSS和TSS描述符。它在实验3中正常工作,但在其他cpu上运行时不正确。修改代码,使其可以在所有cpu上工作。(注意:您的新代码不应再使用全局ts变量。)
When you finish the above exercises, run JOS in QEMU with 4 CPUs using make qemu CPUS=4 (or make qemu-nox CPUS=4), you should see output like this:
...
Physical memory: 66556K available, base = 640K, extended = 65532K
check_page_alloc() succeeded!
check_page() succeeded!
check_kern_pgdir() succeeded!
check_page_installed_pgdir() succeeded!
SMP: CPU 0 found 4 CPU(s)
enabled interrupts: 1 2
SMP: CPU 1 starting
SMP: CPU 2 starting
SMP: CPU 3 starting
Locking
我们当前的代码在mp_main()中初始化AP后旋转。在让AP更进一步之前,我们需要首先解决多个cpu同时运行内核代码时的竞争条件。实现该目标的最简单方法是使用一个大的内核锁。大内核锁是一个全局锁,在环境进入核心态时持有,在环境返回用户态时释放。在该模型中,用户态环境可以在任何可用的cpu上并发运行,但内核态环境只能运行一个;任何其他试图进入核心态的环境都必须等待。
Kern /spinlock.h声明大内核锁,即kernel_lock。它还提供了lock_kernel()和unlock_kernel(),这是获取和释放锁的快捷方式。大家应该在4个位置应用大内核锁。
在i386_init()中,在BSP唤醒其他cpu之前获取锁。
在mp_main()中,在初始化AP后获得锁,然后调用sched_yield()在该AP上开始运行环境。
在trap()中,当从用户模式捕获时获取锁。要确定陷阱是发生在用户态还是核心态,可以检查tf_cs的低位。
在env_run()中,在切换到用户模式之前释放锁。不要太早或太晚这样做,否则你会经历竞争或死锁。
练习5
如上文所述,通过在适当的位置调用lock_kernel()和unlock_kernel()应用大内核锁。
Round-Robin Scheduling
您在这个实验室中的下一个任务是更改JOS内核,以便它可以以“轮询”方式在多个环境之间切换。JOS中的轮询调度机制如下:
新kern/sched.c中的函数sched_yield()负责选择要运行的新环境。它以循环方式顺序搜索envs[]数组,从之前运行的环境之后开始(如果没有之前运行的环境,则从数组的开头开始),选择它找到的第一个状态为ENV_RUNNABLE的环境(参见inc/env.h),并调用env_run()以跳转到该环境。
sched_yield()绝对不能在两个cpu上同时运行同一个环境。它可以判断当前环境正在某个CPU上运行(可能是当前CPU),因为该环境的状态将是ENV_RUNNING。
我们为大家实现了一个新的系统调用sys_yield(),用户环境可以调用它来调用内核的sched_yield()函数,从而主动将CPU让与另一个环境。
练习6
在sched_yield()中实现如上所述的轮询调度。别忘了修改syscall(),让它dispatch sys_yield()。
确保在mp_main中调用sched_yield()。
修改kern/init.c,创建三个(或者更多!)运行user/yield.c程序的环境。
Run make qemu. 您应该看到环境在彼此之间来回切换五次,然后终止,如下所示。
Test also with several CPUS: make qemu CPUS=2.
...
Hello, I am environment 00001000.
Hello, I am environment 00001001.
Hello, I am environment 00001002.
Back in environment 00001000, iteration 0.
Back in environment 00001001, iteration 0.
Back in environment 00001002, iteration 0.
Back in environment 00001000, iteration 1.
Back in environment 00001001, iteration 1.
Back in environment 00001002, iteration 1.
在yield程序退出后,系统中将没有可运行环境,调度器应该调用JOS内核监视器。如果上述情况没有发生,请先修复代码再继续。
System Calls for Environment Creation
尽管您的内核现在能够在多个用户级环境之间运行和切换,但仍然限于内核最初设置的运行环境。现在将实现必要的JOS系统调用,以允许用户环境创建和启动其他新用户环境。
Unix提供了fork()系统调用作为创建进程的原语。Unix fork()复制调用进程(父进程)的整个地址空间,以创建一个新进程(子进程)。从用户空间观察到的唯一区别是它们的进程id和父进程id(由getpid和getppid返回)。在父进程中,fork()返回子进程的进程ID,而在子进程中,fork()返回0。默认情况下,每个进程都有自己的私有地址空间,两个进程对内存的修改对另一个进程都不可见。
您将提供一组不同的、更原始的JOS系统调用,用于创建新的用户模式环境。通过这些系统调用,您将能够完全在用户空间中实现类unix的fork(),以及其他创建环境的风格。为JOS编写的新系统调用如下。
sys_exofork:
该系统调用创建了一个几乎是空白的新环境:没有任何东西映射到其地址空间的用户部分,环境是不可运行的。在sys_exofork调用时,新环境将具有与父环境相同的寄存器状态。在父进程中,sys_exofork将返回新创建环境的envid_t(如果环境分配失败,则返回负的错误码)。然而,在子进程中,它将返回0。(由于子进程一开始被标记为不可运行,sys_exofork实际上不会在子进程中返回,直到父进程明确允许使用....标记子进程可运行。)
sys_env_set_status:
将指定环境的状态设置为ENV_RUNNABLE或ENV_NOT_RUNNABLE。该系统调用通常用于标记一个新环境,在其地址空间和寄存器状态完全初始化之后,该环境就可以运行了。
sys_page_alloc:
分配一页物理内存,并将其映射到给定环境的地址空间中的给定虚拟地址。
sys_page_map:
将页映射(而不是页的内容)从一个环境的地址空间复制到另一个环境的地址空间,保持内存共享,使得新映射和旧映射都指向物理内存的同一页。
sys_page_unmap:
解除映射到给定环境中给定虚拟地址的页。
对于上述所有接受环境id的系统调用,JOS内核支持这样一种约定,即值0表示“当前环境”。kern/env.c中的envid2env()实现了这个约定。
我们在测试程序user/dumbfork.c中提供了类unix的fork()的非常原始的实现。这个测试程序使用上述系统调用创建并运行一个子环境,该子环境具有自己的地址空间的副本。这两个环境接下来使用sys_yield来回切换,如前一个练习所示。父进程在迭代10次后退出,而子进程在迭代20次后退出。
练习7
在kern/syscall.c中实现上述系统调用,并确保syscall()调用它们。你需要使用kern/pmap.c和kern/env.c中的各种函数,特别是envid2env()。现在,每当调用envid2env()时,都将checkperm参数传入1。请确保检查了所有无效的系统调用参数,在这种情况下返回-E_INVAL。使用user/dumbfork测试你的JOS内核,确保它能正常工作。
这就完成了实验室的A部分;运行make grade时,请确保它通过了所有A部分的测试,并像往常一样使用make handin交上来。如果您试图弄清楚为什么特定的测试用例失败,请运行./grade-lab4 -v,它将向您显示内核构建的输出,并为每个测试运行QEMU,直到测试失败。当测试失败时,脚本将停止,然后您可以检查jos。去看看内核实际上打印了什么。
Part B: Copy-on-Write Fork
如前所述,Unix提供了fork()系统调用作为主要的进程创建原语。fork()系统调用复制调用进程(父进程)的地址空间,以创建一个新进程(子进程)。
xv6 Unix通过将父进程页中的所有数据复制到为子进程分配的新页中来实现fork()。这本质上与dumbfork()采用的方法相同。将父进程的地址空间复制到子进程中是fork()操作中开销最大的部分。
然而,在调用fork()之后,经常会立即在子进程中调用exec(),用新程序替换子进程的内存。例如,这就是shell通常做的事情。在这种情况下,花在复制父进程地址空间上的时间基本上被浪费了,因为子进程在调用exec()之前只会使用很少的内存。
因此,较晚版本的Unix利用虚拟内存硬件,允许父进程和子进程共享映射到各自地址空间中的内存,直到其中一个进程实际修改为止。这种技术称为写时复制(copy-on-write)。为此,在fork()上,内核将地址空间映射从父进程复制到子进程,而不是映射页的内容,同时将现在共享的页标记为只读。当两个进程中的一个试图写入这些共享页时,该进程将发生缺页异常。在这一点上,Unix内核意识到该页实际上是一个“虚拟”或“写时复制”副本,因此它为出错的进程创建了一个新的、私有的、可写的该页副本。这样,在实际写入各个页之前,各个页的内容实际上不会被复制。这种优化使得子进程中fork()和exec()的代价要小得多:子进程在调用exec()之前可能只需要复制一页(栈的当前页)。
在本实验的下一部分中,你将实现一个“正确的”类unix的fork()方法,它具有写时复制功能,就像一个用户空间库例程。在用户空间实现fork()和写时复制支持的好处是,内核仍然简单得多,因而更可能是正确的。它还允许各个用户态程序为fork()定义自己的语义。如果一个程序想要稍微不同的实现(例如,像dumbfork()这样代价昂贵的总是复制版本,或者父进程和子进程之后共享内存),可以很容易地提供它自己的实现。
User-level page fault handling
用户级写时复制fork()需要知道写保护页上的缺页异常,因此我们首先要实现它。写时复制只是用户级缺页异常处理的众多可能用途之一。
通常需要设置一个地址空间,以便在需要执行某些操作时指定缺页异常。例如,大多数Unix内核最初只映射新进程栈区域中的一个页,之后随着进程栈消耗的增加,“按需”分配和映射额外的栈页,并在尚未映射的栈地址上导致缺页异常。典型的Unix内核必须跟踪在进程空间的每个区域发生缺页异常时应采取的行动。例如,栈区域中的错误通常会分配和映射物理内存的新页。程序BSS区域的错误通常会分配一个新页,用0填充它,然后映射它。在具有按需分页可执行程序的系统中,文本区域中的一个错误将从磁盘读取二进制文件的相应页,然后映射它。
这是内核需要跟踪的大量信息。您将决定如何处理用户空间中的每个缺页异常,而不是采用传统的Unix方法,在用户空间中,bug的破坏性较小。这种设计还有一个额外的好处,即允许程序在定义内存区域时具有极大的灵活性。稍后,您将使用用户级页面错误处理来映射和访问基于磁盘的文件系统上的文件。
设置页面异常处理程序
Setting the Page Fault Handler
为了处理自己的缺页异常,用户环境需要向JOS内核注册一个缺页异常处理程序入口点。用户环境通过新的sys_env_set_pgfault_upcall系统调用注册其缺页异常入口点。我们已经向Env结构添加了一个新成员,env_pgfault_upcall,以记录此信息。
练习8
实现sys_env_set_pgfault_upcall系统调用。在查找目标环境的环境ID时,请确保启用权限检查,因为这是一个“危险”的系统调用。
Normal and Exception Stacks in User Environments
在正常执行期间,JOS中的用户环境将运行在普通用户栈上:它的ESP寄存器开始指向USTACKTOP,它压入的栈数据位于USTACKTOP- pgsize和USTACKTOP-1(包括USTACKTOP-1)之间的页面。但在用户态发生缺页异常时,内核将在不同的栈(即用户异常栈)上运行指定的用户级缺页异常处理程序,重启用户环境。本质上,我们将让JOS内核代表用户环境实现自动“堆栈切换”,其方式与x86处理器在从用户态转换到核心态时代表JOS实现堆栈切换的方式非常相似!
JOS用户异常栈的大小也是一页,其顶部被定义为虚拟地址UXSTACKTOP,因此用户异常栈的有效字节是从UXSTACKTOP- pgsize到UXSTACKTOP-1(包括UXSTACKTOP-1)。在此异常堆栈上运行时,用户级缺页异常处理程序可以使用JOS的常规系统调用来映射新页或调整映射,以便修复最初导致缺页异常的任何问题。然后,用户级的页错误处理程序通过汇编语言存根返回到原始堆栈上的错误代码。
每个希望支持用户级页异常处理的用户环境都需要使用A部分介绍的sys_page_alloc()系统调用为自己的异常栈分配内存。
Invoking the User Page Fault Handler
调用用户页面错误处理程序
现在需要修改kern/trap.c中的缺页异常处理代码,以便在用户状态下处理缺页异常,如下所示。我们将发生故障时的用户环境状态称为trap-time状态。
如果没有注册缺页异常处理程序,JOS内核会像以前一样用一条消息销毁用户环境。否则,内核在异常栈上建立一个陷阱帧,看起来像inc/trap.h中的struct UTrapframe:
<-- UXSTACKTOP
trap-time esp
trap-time eflags
trap-time eip
trap-time eax start of struct PushRegs
trap-time ecx
trap-time edx
trap-time ebx
trap-time esp
trap-time ebp
trap-time esi
trap-time edi end of struct PushRegs
tf_err (error code)
fault_va <-- %esp when handler is run
内核接下来安排用户环境使用该栈帧恢复异常栈上运行的缺页异常处理程序的执行。你必须想办法让这一切发生。fault_va是导致缺页异常的虚拟地址。
如果在发生异常时,用户环境已经在用户异常堆栈上运行,那么缺页异常处理程序本身就发生了异常。在这种情况下,您应该在当前的tf->tf_esp下启动新的堆栈帧,而不是在UXSTACKTOP。你应该首先推送一个32位的空单词,然后是一个struct UTrapframe。
要测试tf->tf_esp是否已经在用户异常栈上,请检查它是否在UXSTACKTOP-PGSIZE和UXSTACKTOP-1之间(包括UXSTACKTOP-1)。
练习9
在kern/trap.c中实现page_fault_handler中的代码,用于将缺页异常分派给用户态处理程序。在写入异常栈时,请确保采取适当的预防措施。(如果用户环境耗尽异常栈上的空间会发生什么?)
User-mode Page Fault Entrypoint
接下来,您需要实现汇编例程,该例程将负责调用C页异常处理程序,并在原始异常指令处恢复执行。该汇编例程是使用sys_env_set_pgfault_upcall()注册到内核的处理程序。
练习10
在lib/pfentry.S中实现_pgfault_upcall例程。有趣的是返回到导致缺页异常的用户代码的原点。您将直接返回到那里,而无需返回到内核。困难的部分是同时交换栈和重新加载EIP。
最后,需要实现用户级缺页处理机制的C用户库端。
练习11
在lib/pgfault.c中完成set_pgfault_handler()。
Implementing Copy-on-Write Fork
大家现在已经有了完全在用户空间中实现写时复制fork()的内核设施。
我们已经在lib/fork.c中提供了fork()的框架。与dumbfork()类似,fork()应该创建一个新环境,然后遍历父环境的整个地址空间,并在子环境中建立相应的页映射。关键的区别在于,dumbfork()复制页面,而fork()最初只复制页面映射。fork()只会在其中一个环境试图写入每个页面时复制它。
fork()的基本控制流程如下:
父组件使用上面实现的set_pgfault_handler()函数,将pgfault()安装为c级别的页面错误处理程序。
父进程调用sys_exofork()创建子环境。
对于位于UTOP下的地址空间中的每个可写页或写时复制页,父进程都会调用duppage,该函数会将页的写时复制映射到子进程的地址空间中,然后在自己的地址空间中重新映射页的写时复制。[注意:这里的排序(即先在子页面中标记为COW,再在父页面中标记)实际上很重要!]你知道为什么吗?试着想一个具体的例子,如果颠倒顺序可能会引起麻烦。duppage设置了两个pte,使得该页不可写,并将PTE_COW包含在"avail"字段中,以区分写时复制的页和真正的只读页。
不过,异常栈不会以这种方式重新映射。相反,您需要在子进程中为异常栈分配一个新页。由于缺页 异常处理程序将执行实际的复制,而缺页异常处理程序运行在异常栈上,因此不能在写时复制异常栈: 谁会复制它呢?
Fork()也需要处理存在但不可写或写时复制的页面。
父进程设置用户页面异常入口点,使子进程看起来像自己的。
子进程现在可以运行了,因此父进程将其标记为可运行。
每当某个环境写入尚未写入的写时复制页时,就会发生缺页异常。以下是用户页面异常处理程序的控制流:
内核将缺页异常传播到_pgfault_upcall,后者调用fork()的pgfault()处理程序。
pgfault()检查异常是否是写异常(检查错误码中的FEC_WR),并且该页的PTE标记为PTE_COW。如果没有,那就惊慌吧。
Pgfault()分配一个映射在临时位置的新页,并将发生故障的页的内容复制到临时位置。然后,错误处理程序将新页映射到具有读/写权限的适当地址,而不是旧的只读映射。
用户级的lib/fork.c代码在执行上述几个操作时,必须查阅环境的页表(例如,某一页的页表项标记为PTE_COW)。内核将环境的页表映射到UVPT正是为此目的。它使用了一个巧妙的映射技巧,使查找用户代码的pte变得容易。lib /entry.S设置uvpt和uvpd,使您可以轻松查找lib/fork.c中的页表信息。
练习12
在lib/fork.c中实现fork、duppage和pgfault。
用叉树程序测试代码。它应该产生以下消息,其中穿插着` new env `, ` free env `和` exit graceful `消息。消息可能不是按此顺序出现的,而且环境id可能不同。
1000: I am ''
1001: I am '0'
2000: I am '00'
2001: I am '000'
1002: I am '1'
3000: I am '11'
3001: I am '10'
4000: I am '100'
1003: I am '01'
5000: I am '010'
4001: I am '011'
2002: I am '110'
1004: I am '001'
1005: I am '111'
1006: I am '101'
This ends part B. Make sure you pass all of the Part B tests when you run make grade. As usual, you can hand in your submission with make handin.
Part C: Preemptive Multitasking and Inter-Process communication (IPC)
在实验4的最后一部分,您将修改内核以抢占不合作的环境,并允许环境之间显式传递消息。
Clock Interrupts and Preemption
运行user/spin测试程序。这个测试程序分支出一个子环境,一旦它接收到CPU的控制,它就永远在一个紧密的循环中旋转。父环境和内核都不会重新获得该CPU。就保护系统不受用户态环境中的bug或恶意代码的影响而言,这显然不是理想的情况,因为任何用户态环境都可能导致整个系统停止,只要进入无限循环,而CPU就永远不会被占用。为了允许内核抢占运行环境,强制重新获得对CPU的控制,我们必须扩展JOS内核以支持来自时钟硬件的外部硬件中断。
Interrupt discipline
外部中断(即设备中断)称为IRQs。有16个可能的IRQs,编号从0到15。IRQ编号到IDT项的映射不是固定的。picirq.c中的pic_init将irq 0 ~ 15映射到IDT项IRQ_OFFSET到IRQ_OFFSET+15。
在inc/trap.h中,IRQ_OFFSET定义为十进制32。因而IDT项32-47对应于IRQs 0-15。例如,时钟中断是IRQ 0。因而,IDT[IRQ_OFFSET+0](即IDT[32])包含了内核中时钟中断处理程序例程的地址。选择该IRQ_OFFSET是为了使设备中断不与处理器异常重叠,这显然可能导致混淆。(事实上,在运行MS-DOS的pc的早期,IRQ_OFFSET实际上是0,这确实在处理硬件中断和处理处理器异常之间造成了巨大的混乱!)
在JOS中,与xv6 Unix相比,我们做了一个键的简化。外部设备中断在内核中总是禁用的(与xv6类似,在用户空间启用)。外部中断由%eflags寄存器的FL_IF标志位控制(参见inc/mmu.h)。在设置该比特位时,将启用外部中断。虽然位可以通过几种方式进行修改,但由于我们的简化,我们将只通过在进入和离开用户模式时保存和恢复%eflags寄存器的过程来处理它。
用户必须确保运行时在用户环境中设置FL_IF标志,以便在中断到达时,它被传递到处理器并由中断代码处理。否则,中断将被屏蔽或忽略,直至重新启用中断。我们用引导加载程序的第一个指令屏蔽了中断,到目前为止,我们还没有重新启用它们。
练习13
修改kern / trapentry.S和kern/trap.c来初始化IDT中适当的项,并为irq 0到15提供处理程序。然后修改kern/env.c中env_alloc()中的代码,以确保用户环境总是在启用中断的情况下运行。
还要取消sched_halt()中sti指令的注释,以便空闲cpu解除中断掩码。
在调用硬件中断处理程序时,处理器从不推送错误代码。此时,读者可能需要重读80386参考手册的9.2节,或IA-32《英特尔体系结构软件开发人员手册》第3卷的5.8节。
完成这个练习后,如果你用任何运行一定时间长度(例如spin)的测试程序运行内核,你应该会看到内核打印硬件中断的陷阱帧。虽然中断现在在处理器中启用了,但JOS还没有处理它们,因此您应该看到它将每个中断错误地归为当前运行的用户环境并销毁它。最终,它应该耗尽可以销毁的环境并放入监视器。
目前程序一旦进入用户模式,除非发生中断,否则CPU永远不会再执行内核代码。我们需要开启时钟中断,强迫进入内核,然后内核就可以切换另一个进程执行。
lapic_init()和pic_init()设置时钟中断控制器产生中断。需要写代码来处理中断。
Handling Clock Interrupts
在user/spin程序中,在子环境第一次运行之后,它只是在循环中旋转,内核再也没有获得控制权。我们需要对硬件进行编程,以周期性地产生时钟中断,这将迫使控制权回到内核,在内核中,我们可以将控制权切换到不同的用户环境。
对lapic_init和pic_init(来自init.c中的i386_init)的调用设置了时钟和用于产生中断的中断控制器。现在需要编写代码来处理这些中断。
练习14
修改内核的trap_dispatch()函数,使其在时钟中断发生时调用sched_yield()来查找并运行一个不同的环境。
现在应该能够让user/spin测试工作了:父环境应该fork子环境,sys_yield()多次,但每次都在一个时间片之后重新获得对CPU的控制,最后终止子环境并优雅地终止。
这是做回归测试的好时机。确保你没有通过启用中断来破坏实验室之前正常工作的任何部分(例如forktree)。此外,请尝试使用make CPUs =2 target运行多个cpu。你现在也应该能够消除压力了。快跑去看看。你现在应该得到这个实验的总分65/80分。
Inter-Process communication (IPC)
(在JOS中,这是“跨环境通信”或“IEC”,但其他人都称它为IPC,所以我们将使用标准术语。)
我们一直在关注操作系统的隔离方面,它提供了每个程序都有一台属于自己的机器的错觉。操作系统的另一项重要服务是允许程序在需要时相互通信。它可以非常强大地让程序与其他程序交互。Unix管道模型就是典型的例子。
进程间通信有许多模型。即使在今天,关于哪种模型是最好的仍然存在争论。我们就不讨论这个了。相反,我们将实现一个简单的IPC机制,然后尝试它。
IPC in JOS
我们将实现几个额外的JOS内核系统调用,它们共同提供了一个简单的进程间通信机制。用户需要实现两个系统调用,sys_ipc_recv和sys_ipc_try_send。然后你将实现两个库包装器ipc_recv和ipc_send。
用户环境可以使用JOS的IPC机制相互发送的“消息”由两个部分组成:单个32位值和可选的单个页映射。允许环境以消息的形式传递页映射,这提供了一种高效的方式来传输比单个32位整数所能容纳的更多的数据,还允许环境轻松地建立共享内存。
Sending and Receiving Messages
为接收消息,环境调用sys_ipc_recv。该系统调用取消当前环境的调度,直到收到消息后才再次运行。当一个环境等待接收消息时,任何其他环境都可以向它发送消息——不仅仅是特定的环境,也不仅仅是与接收环境有父/子关系的环境。换句话说,你在A部分实现的权限检查不适用于IPC,因为IPC系统调用经过了精心设计,是“安全的”:一个环境不会仅仅通过向它发送消息就导致另一个环境故障(除非目标环境也有bug)。
为了尝试发送一个值,环境调用sys_ipc_try_send,传入接收方的环境id和要发送的值。如果指定的环境实际上正在接收(它已经调用了sys_ipc_recv,但还没有得到值),则发送端发送消息并返回0。否则,发送返回-E_IPC_NOT_RECV,表示目标环境当前不期望接收值。
用户空间中的库函数ipc_recv负责调用sys_ipc_recv,然后在当前环境的struct Env中查找接收到的值的信息。
类似地,库函数ipc_send将负责重复调用sys_ipc_try_send,直到发送成功。
Transferring Pages
在环境用有效的dstva参数(在UTOP下面)调用sys_ipc_recv时,环境表示它愿意接收页映射。如果发送方发送了一页,那么该页应该映射到接收方地址空间的dstva。如果接收方已经在dstva映射了一页,则取消映射前一页。
当一个环境用一个有效的srcva(在UTOP下面)调用sys_ipc_try_send时,这意味着发送方想要将当前映射在srcva的页发送给接收方,并具有perm权限。在一个成功的IPC之后,发送方在其地址空间中保留其对srcva页的原始映射,但接收方也在接收方的地址空间中获得了接收方最初指定的dstva中相同物理页的映射。因此,该页面在发送方和接收方之间共享。
如果发送方或接收方没有指示要传输一页,则不传输一页。在任何IPC之后,内核将接收方的Env结构体中的新字段env_ipc_perm设置为接收到的页的权限,如果没有接收到页,则设置为0。
Implementing IPC
练习15
在kern/syscall.c中实现sys_ipc_recv和sys_ipc_try_send。在实现它们之前,请阅读它们的注释,因为它们必须一起工作。在这些例程中调用envid2env时,应该将checkperm标志设置为0,这意味着任何环境都允许向任何其他环境发送IPC消息,而且内核除了验证目标envid是否有效外,不进行特殊的权限检查。
然后实现lib/ipc.c中的ipc_recv和ipc_send函数。
使用user/pingpong和user/primes函数测试你的IPC机制。user/primes将为每个质数生成一个新环境,直到JOS用完所有环境。读者可能会对user/primes.c的内容感兴趣,因为它可以看到所有在幕后发生的分支和IPC
最后make grade测试即可