源代码参见我的github: https://github.com/YaoZengzeng/jos
Part A: Multiprocessor Support and Cooperative Multitasking
Multiprocessor Support:
1、SMP(symmetric multiprocessing)是这样一种多处理器模型:每个CPU对于系统资源例如内存和IO总线都有平等的访问权限
2、在启动期间,处理器可以被分为两类,一个叫BSP(the bootstrap processor)用于系统的初始化以及启动操作系统;还有一类叫AP(the application processors),它们是在操作系统启动并且运行之后由BSP启动的。至于哪个处理器作为BSP是由硬件和BIOS决定的
3、在SMP系统中,每个CPU都有一个局部的APIC(LAPIC)。LAPIC用于系统间中断的转发,同时每个LAPIC还为与它相连的CPU提供了一个独特的id。在这个lab中,我们主要用到了LAPIC以下的几个功能:
(1)通过LAPIC ID来判断我们的代码运行在哪个CPU上(见cpunum())
(2)BSP发送处理器间中断(IPI)STARTUP至AP来唤醒其他处理器(见lapic_startap())
(3)利用LAPIC内置的时钟中断来支持原生的multitasking(见apic_init())
4、每个处理器都通过MMIO(memory-mapped IO)来访问它的LAPIC。在MMIO中,一部分物理内存和一些IO设备的寄存器进行了绑定,因此我们能像访问内存一样利用load/store指令去访问这些设备寄存器。LAPIC的这片内存区域开始于0xFE000000(4GB以下32MB),这样的地址对于我们显然是太高了。因此,JOS的虚拟内存从MMIOBASE开始留下了4MB的空间,用于设备的映射。
Application Processor Bootstrap:
1、在启动AP之前,BSP首先要收集有关多处理器系统的信息,例如CPU总数,它们的APIC ID,LAPIC的MMIO地址。在jos中,这些都是kern/mpconfig.c中的mp_init()通过读取BIOS内存中的MP配置表得到的。
2、kern/init.c中的boot_aps()驱动着AP的启动。AP首先在实模式下启动,就像boot/boot.S下的bootloader启动一样,将entry code(kern/mpentry.S)拷贝到实模式下可以寻址的内存空间。与bootloader不同的是,我们可以控制AP从何处开始执行代码,通常我们将entry code拷贝到0x7000(MPENTRY_PADDR)。
3、之后,boot_aps()逐个激活AP,通过向每个对应的AP的LAPIC发送STARTUP以及AP开始执行entry code的起始地址的CS:IP(即MPENTRY_PADDR)。kern/mpentry.S中的entry code和boot/boot.S很类似。经过一些简单的设置,AP进入保护模式,之后调用kern/init.c中的mp_main()中的C setup routine。通常boot_aps会一直等待AP将struct CpuInfo中的cpu_status字段置为CPU_STARTED之后才会去唤醒下一个CPU。
Per-CPU State and Initialization
1、kern/cpu.h定义了许多per-CPU state,包括struct CpuInfo,其中包含了每个CPU独有的一些变量,cpunum()总会返回调用它的CPU的ID,它可以作为cpus这样的数组的下标。其中thiscpu是当前CPU的struct CpuInfo
2、Per-CPU kernel stack:因为多个CPU可能同时陷入内核,因此需要为每个处理器分配独立的kernel stack,防止它们执行时互相干扰
3、Per-CPU TSS and TSS descriptor:用于标示每个CPU位于kernel stack的位置
4、Per-CPU current environment pointer:因为每个CPU上可以同时运行用户进程,因此我们用cpus[cpunum()].cpu_env(或者thiscpu->cpu_env)来表示在当前CPU上运行的environment。
5、Per-CPU system registers:所有的寄存器,包括系统寄存器都是每个CPU私有的。因此那些初始化寄存器的指令,例如lcr3(), ltr(), lgdt(), lidt(), 等等都必须在每个CPU上都执行一遍。env_init_percpu和trap_init_percpu()就是用于这个目的。
Locking
1、big kernel lock:是一种全局锁,当environment进入内核的时候就持有该锁,当environment重回用户模式的时候就释放该锁。在这种模型之下,处于用户模式下的environment可以在任意可获得的CPU上运行,但是只有一个environment能在内核模式下运行,其他任何environment想要进入内核都必须等待。
Part B: Copy-on-Write Fork
User-level page fault handling
1、用户级别的copy-on-write fork()需要知道在操作写保护的页面时产生的page faults,因此我们需要先实现它。而Copy-on-Write又是用户级别page fault handling众多用途中的一种。
2、为了处理page faults,user environment需要向JOS kernel注册一个page fault handler entrypoint。user environment 可以通过系统调用sys_env_set_pgfault_upcall注册page fault entrypoint。并且我们已经在strut Env中加入了一个新的成员env_pgfault_upcall,用于记录该信息
3、static int sys_env_set_pgfault_upcall(envid_t envid, void *func):当envid代表的environment产生了一个page fault时,内核会向exception stack压入一个fault record,之后进入func运行
Normal and Exception Stacks in User Environments
1、当程序正常执行的时候,user environment通常会运行在normal user stack:它的ESP从USTACKTOP开始,栈中的数据会存放在USTAKTOP-PGSIZE到USTACKTOP-1的范围内。当在用户态发生page fault时,kernel会在一个新的叫做user exception stack的栈上运行user environment的page fault handler。事实上,我们会让JOS kernel代表user environment实现自动的“stack switching”。就像x86处理器已经自动为JOS实现在用户态到内核态的"stack switching"。
2、JOS中的user exception stack同样是一个页的大小,它的栈顶的virtual address是UXSTACKTOP,因此它的合法地址是UXSTACKTOP-PGSIZE到UXSTACKTOP-1。当运行在exception stack的时候,用户态的page fault handler可以使用普通的JOS系统调用来映射新的页或者调整页面的映射,从而解决之前造成page fault的问题。之后,page fault handler返回,回到之前在普通堆栈造成page fault的代码。
3、每个user environment如果想要支持user-level的page fault handling需要为它自己的exception stack分配页表,利用之前的sys_page_alloc()系统调用。
Invoking the User Page Fault Handler
1、我们将page fault 发生时,user environment的状态称为trap-time state
2、当一个user environment 已经运行在user exception上时,如果又发生了一个exception,我们需要从当前的tf->tf_esp而不是UXSTACKTOP重新开始一个stack frame。
Implementing Copy-on-Write Fork
fork()和dumbfork()一样,也会创建一个新的进程,然后扫描parent environment的整个地址空间,然后设置子进程相关的页面映射。它们最大的不同是,dumbfork拷贝页,而fork只拷贝页面映射。fork只会在有一个进程要进行写操作时,才会进行页拷贝。
1、fork()的基本运行流程如下:
(1)、父进程利用之前的set_pgfault_handler设置pgfault()作为page fault handler。
(2)、父进程调用sys_exofork()生成子进程
(3)、对于每个一个地址低于UTOP,并且属性为writable或者为copy-on-write的页面,父进程调用duppage,将子进程中的该页设置为copy-on-write,并且反过来将自己地址空间的该页重映射为copy-on-write。dupage设置了两个进程的PTE,所有两个进程中的该页都是不可写的,并且标志为PTE_COW区分与单纯的只读页。但是exception stack不是用上述方式重映射的,我们需要为子进程的exception stack重新分配一个页面。因为page fault handler会运行在exception stack上。fork()同时还要处理那些既不是writable或者copy-on-write的页面。
(4)、父进程还需要设置子进程的page fault entrypoint
(5)、子进程现在可以准备运行了,父进程需要将它标记为runnable
2、每当一个environment要写一个copy-on-write的页面时,都会产生一个page fault。接下来是page fault handler的处理流程:
(1)、kernel将page fault传递到_pgfault_upcall,它会调用fork()的pgfault()
(2)、pgfault()检查这是一个写错误并且该页的PTE被标志为PTE_COW。否则panic
(3)、pgfault()获得一个新的页面,将它映射到一个临时的位置,并且将相应的页的内容拷贝到里面。之后,将该页重新映射到对应的位置,并且设置好权限位。
Part C: Preemptive Multitasking and Inter-Process communication(IPC)
Clock Interrupts and Preemption
运行user/spin 测试程序。这个测试进程产生fork一个child environment,这个child environment 只是在获取CPU之后陷入不断的循环。而不管是parent environment或是kernel都不能再获取CPU了。这显然不是我们想要的,因此,为了让内核抢占一个正在运行的environment,再次夺回CPU的控制权,我们需要扩展JOS kernel,从而支持来自于clock设备的外部硬件时钟中断。
Interrupt discipline
external interrupts通常被称为IRQ。总共有16个可能的IRQ,标记从0到15,并且从IRQ编号到IDT表的映射不是固定的。在picirq.c的pic_init函数将0到15的IRQ映射到IRQ_OFFSET到IRQ_OFFSET+15的表项中。
在inc/trap.h中,IRQ_OFFSET被定义为32,因此IDT 32到47被映射为IRQ的0到15。比如,clock interrupt是IRQ 0。因此,IDT[IRQ_OFFSET + 0](或者IDT[32])包含了clock interrupt处理程序在内核中的地址。之所以要设置IRQ_OFFSET是为了避免device interrupt和不和processor exception造成的混淆。事实上,在早期运行MS-DOS的PC机上,IRQ_OFFSET的值其实为0,这样就会在处理processor exception和hardware interrupts的时候造成混淆。
在JOS中,当运行在内核模式时,外部的设备中断总是被禁止的(和xv6类似,在用户空间是可用的)。外部中断通常通过eflags寄存器的FL_IF标志位进行控制。当它被置位时,外部中断是可用的。通常有好几种方式对FL_IF位进行修改,不过我们将它简化了,我们只会在切换用户模式时保存或者恢复eflag寄存器的时候对它进行改变。
我们必须确保在运行user environment的时候,FL_IF标志位是置位的,这样我们才能确保在中断到来的时候,我们的中断处理代码能被执行。除此之外,interrupts始终是被忽略或是屏蔽的。我们在执行bootloader的时候就把中断屏蔽了,直到现在为止都没有再启动过它。
Handling Clock Interrupt
在user/spin这个程序中,当child environment开始运行之后,它就进入无限的循环,kernel就再也夺不回控制权了。我们必须对硬件进行编程,让它能定时产生时钟中断,这样我们才能让kernel重新获得控制权,从而运行其他的用户进程。
其中对于lapic_init和pic_init进程的调用,我们已经能够让时钟和中断控制器定时产生中断了,接下来要做的就是对这些中断进行处理。
Inter-Process communication(IPC)
IPC in JOS
我们需要为JOS kernel添加几个简单的系统调用来提供简单的进程间通信的机制。即需要实现sys_ipc_recv和sys_ipc_try_send两个系统调用,以及ipc_recv和ipc_send两个库函数。在JOS中,两个user environment之间相互传递的“信息”主要由两部分组成:一个32位的变量,以及一个可选的page mapping。允许environment之间能够传递page mapping,可以让传递的数据更多,并且能够让进程之间共享内存变得更容易。
Sending and Receiving Messages
为了能够接收message,当前environment调用sys_ipc_recv。该系统调用重新调度了当前environment,并且在接收到message之前不再重新运行。当一个environment处于接收状态时,任何其他的environment都能向它发送message,而不是某个特定的environment或者具有父子关系的environment。同时,IPC是经过精心设计并且安全可靠的,因此一个environment不会因为另一个environment给它发了一个message就出问题,除非目标进程自己本身就有问题。
当需要传递一个值时,environment需要调用sys_ipc_try_send,参数为接收environment的id和要传输的值。如果目标进程刚好处于接收状态,则将信息发送出去,并返回0。否则返回-E_IPC_NOT_RECV表示目标进程并非处于接收状态。
库函数ipc_recv会接手sys_ipc_recv的任务,并且在当前environment的struct Env中获取信息。同理,库函数ipc_send会接手sys_ipc_try_send直到发送成功。
Transferring Pages
当一个environment调用sys_ipc_recv,获得了一个有效的dstva(小于UTOP),那么,该environment表示愿意接受这个page mapping。当发送者发送了一个page mapping之后,那么这个page就要映射到接受者地址空间的dstva上。如果接受者之前已经在dstva上已经有页面映射了,那么之前的页面将被unmapped。
当一个environment用合法的srcva(即小于UTOP)调用sys_ipc_try_send,这意味着发送者希望将位于srcva的页面发送给接收者,权限为perm。在一个成功的IPC之后,发送者依然在srcva保持原有的页面映射,同时接收者在dstva拥有对同一物理页面的映射。因此,这个物理页就做到了在发送者和接收者之间的共享。
当发送者和接收者都不需要页面传输时,那么页面就不传输了。在任何一次IPC之后,接收者Env的env_ipc_perm域都会被设置为接收到的页的权限,如果没有页面被接收的话,设置为0。