【MIT 6.828】JOS学习笔记 Lab4
在lab4中多了如下文件:kern/cpu.h、kern/mpconfig.c、kern/lapic.c、kern/mpentry.S、kern/spinlock.h、kern/spinlock.c、kern/sched.c
Part A: Multiprocessor Support and Cooperative Multitasking
在这个lab的第一部分,我们首先拓展JOS让其能在多处理器系统上运行,然后实现一些新的JOS内核系统调用以允许用户级环境创建额外的新环境。还将实现协作循环调度,当当前环境自愿放弃CPU(或退出)时,允许内核从一种环境切换到另一种环境。在第三部分中,还将实现抢占式调度,即使环境不合作,它也允许内核在经过一定时间后从环境中重新控制CPU。
Multiprocessor Support
我们将让JOS支持”对称多处理”(SMP),一种多处理器模型,其中所有CPU都具有对系统资源(如内存和IO总线)的同等访问权限。虽然在SMP模型中所有的CPU的功能都是相同的,但是启动的过程中,还是可以分为两种类型:引导处理器(BSP)负责初始化系统和引导操作系统;另一种是只有在操作系统启动并允许后,应用处理器(AP)才会被BSP激活。哪个处理器作为BSP是由硬件和BIOS决定。到目前为止,我们所有的JOS代码都已在BSP上允许。
在SMP系统中,每个CPU都有一个伴随的本地APIC(LAPIC) 单元。LAPIC单元负责在整个系统中传送中断。LAPIC还为其连接的 CPU提供唯一标识符,在本lab中,我们使用LAPIC单元的以下基本功能(kern/lapic.c):
- 读取LAPIC标识符(APIC ID)来判断我们的代码现在允许在哪个CPU上(查看cpunum())
- 从BSP向AP发送STARTUP处理器间中断(IPI)以启动其他CPU(查看lapic_startap())
- 在第三部分,我们对LAPIC的内置定时器进行编程以触发时钟中断以支持抢占式多任务处理(查看apic_init())
处理器访问LAPIC使用内存映射IO(MMIO),这样就能通过访问内存达到访问设备寄存器的目的。LAPIC从物理地址0xFE000000开始,JOS将通过MMIOBASE虚拟地址访问该物理地址。
exercise1
实现文件kern/pmap.c中的mmio_map_region函数。这个函数管理内存映射IO地址,输入一个在范围内的物理地址,函数返回一个虚拟地址,那么这个物理地址就被映射到这个虚拟地址上。这个也是一个分配器,比较原始,原理就和boot_alloc类似。从MMIOBASE开始分配,每次分配都是以页为单位。故函数维持了一个全局变量,表示当前分配到的地址,并将参数上调到4096
的边界。这些操作和boot_alloc一样。
void * |
Application Processor Bootstrap
在启动AP之前,BSP需要搜集多处理器的信息,比如总共有多少个CPU,它们的LAPIC ID以及LAPIC MMIO地址。mp_init函数从BIOS中读取这些信息。具体代码在mp_init中,该函数会在进入内核后由i386_init函数调用,主要作用就是读取mp configuration table中保存的CPU信息,初始化cpus数组,ncpu(总共可用的CPU个数),bootcput指针(指向BSP对应的CpuInfo结构)。
boot_aps函数驱动AP引导程序,AP以实模式启动,很像引导程序在boot/boot.S中的启动方式,因此boot_aps函数将AP入口代码复制到实模式下寻址的内存位置。与引导加载程序不同的是,我们可以控制AP开始执行代码的位置,我们将入口代码复制到0x7000,但任何未使用的、页面对齐的低于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标志信号,然后再唤醒下一个。
exercise2
我们需要修改我们kern/pmap.c中page_init函数的代码,来表示MPENTRY_PADDR处的地址已经不再是free的状态。因为这段地址已经被AP的引导器所占用。
void |
此时我们又能看见check_page_free_list() succeeded!了。
Question
将kern/mpentry.S与boot/boot.S并排比较。 请记住,就像内核中的其他内容一样,kern/mpentry.S被编译、链接并运行在
KERNBASE
之上,宏MPBOOTPHYS的目的是什么? 为什么这在在kern/mpentry.S很关键?换句话说,如果在kern/mpentry.S中省略了什么可能会出错?提示:回忆链接地址与加载地址的区别。boot.S中,由于尚没有启用分页机制,所以我们能够指定程序开始执行的地方以及程序加载的地址;但是,在mpentry.S的时候,由于主CPU已经处于保护模式下了,因此是不能直接指定物理地址的,给定线性地址,映射到相应的物理地址是允许的。
Per-CPU State and Initialization
在编写多处理器操作系统时,区分每个处理器私有的CPU状态和整个系统共享的全局状态很重要。kern/cpu.h定义了大多数的per-CPU状态,包括CpuInfo结构体,这个结构体存储了per-CPU的变量。cpunum() 总是返回调用它的CPU的ID,它可以作为cpus数组的索引。thiscpu宏是当前CPU的结构CpuInfo的简写。
JOS使用CpuInfo结构体来记录CPU的信息:
struct CpuInfo { |
每个CPU如下信息是当前CPU私有的:
- 内核栈:因为多个CPU可以同时陷入内核,所以我们需要为每个处理器使用单独的内核堆栈,以防止它们被彼此干扰。
percpu_kstacks[NCPU][KSTKSIZE]
数组为每个CPU都保留了KSTKSIZE大小的内核栈 - TSS和TSS描述符:每个CPU都需要单独的TSS和TSS描述符来指定该CPU对应的内核栈
- 进程结构指针:每个CPU都会独立允许一个进程的代码,所以需要Env指针
- 系统寄存器:比如cr3,gdt,ltr这些寄存器都是每个CPU私有的,每个CPU都需要单独设置
到目前为之CpuInfo和Env的关系可以总结如下:
exercise 3
处理器同时运行,不能共享一个栈,每个处理器都要有自己的栈。当然,这种区分是在虚拟地址层面上的,不是在物理地址层面上的,不同虚拟地址可以映射到相同物理地址,也可以映射到不同。在这里,我们当然希望能够映射到不同地址上。
主要工作在函数mem_init_mp
,这个函数在mem_init
初始化完成BSP
使用的栈后调用,为各个AP
映射栈地址。
讲义和代码注释要求我们给每个栈分配KSTKSIZE
大小,中间留出KSTKGAP
作为保护,使得一个栈溢出一定不会影响相邻的栈。
static void |
exercise4
在文件kern/trap.c中函数trap_init_percpu对BSP的TSS和TSS描述符进行初始化。上一个Lab留下的版本,不能正确的处理多处理器的情况,我们需要更改它,让它能够正确初始化每个AP的中断。在之前的lab中,trap_init_percpu函数在trap_init中调用,trap_init在i386_init中调用,这是给BSP初始化中断。AP内核的入口函数mp_main调用了trap_init_percpu,这是给各个AP初始化中断。在BSP调用的trap_init函数中,中断描述符表已经初始化完成了,在各个AP中也就没比要再做,故没有调用trap_init。
注意此时的代码已经执行在不同的CPU上了,而不是要初始化所有CPU,只需要初始化自身就可以了。用thiscpu->cpu_ts代替全局变量cpu_ts。
void |
Locking
目前我们已经有多个CPU同时在执行内核代码了,我们必须要处理竞争条件。最简单粗暴的办法就是使用”big kernel lock”,”big kernel lock”是一个全局锁,进程从用户态进入内核后获取该锁,退出内核释放该锁。这样就能保证只有一个CPU在执行内核代码,但缺点也很明显就是一个CPU在执行内核代码时,另一个CPU如果也想进入内核,就会处于等待的状态。
锁的数据结构在kern/spinlock.h中:
struct spinlock { |
这是一种spin-locks。让我们来看看自旋锁的实现原理。
我们最容易想到的获取自旋锁的代码如下:
void |
但是这种实现是有问题的,假设两个CPU同时执行到5行,发现lk->locked是0,那么会同时获取该锁。问题出在5行和6行是两条指令。
我们的获取锁,释放锁的操作在kern/spinlock.c中:
void |
对于spin_lock()获取锁的操作,使用xchgl这个原子指令,xchg()封装了该指令,交换lk->locked和1的值,并将lk-locked原来的值返回。如果lk-locked原来的值不等于0,说明该锁已经被别的CPU申请了,继续执行while循环吧。因为这里使用的xchgl指令,从addr指向的位置读数据保存到result,然后将newval写到该位置,但是原子的,相当于之前25和26行的结合,所以也就不会出现上述的问题。对于spin_unlock()释放锁的操作,直接将lk->locked置为0,表明我已经用完了,这个锁可以被别人获取了。
有了获取锁和释放锁的函数,我们看下哪些地方需要加锁,和释放锁:
- i386_init()中,BSP唤醒其它AP前需要获取内核锁。
- mp_main()中,AP需要在执行sched_yield()前获取内核锁。
- trap()中,需要获取内核锁,因为这是用户态进入内核的唯一入口。
- env_run()中,需要释放内核锁,因为该函数使用iret指令,从内核返回用户态。
这些添加我们就不放代码了,但是它们的意义值得思考。i386_init, mp_main函数的lock都发生在初始化完成,准备通过sched_yield进入用户进程之前。这时候加锁,让处理器依次加载用户进程,保证同一时刻只有一个处理器在内核态运行。
其它操作内核锁发生在进入和退出内核态的时候。处理器进入内核态后处在函数trap,故在trap开头加锁,等待其它处理器退出内核态。处理器要进入用户态时放开锁,也就是在env_run的最后,允许其它处理器进入内核态。
Question
big kernel lock似乎已经确保每次仅仅一个CPU能允许内核代码,为什么我们仍然需要为每个CPU设定一个内核栈
因为在alltraps到lock_kernel()的过程中,进程已经切换到了内核态,但并没有上内核锁,此时如果有其他CPU进入内核,如果用同一个内核栈,则_alltraps中保存的上下文信息会被破坏,所以即使有大内核栈,CPU也不能用用同一个内核栈。同样的,解锁也是在内核态内解锁,在解锁到真正返回用户态这段过程中,也存在上述这种情况
Round-Robin Scheduling
现要JOS内核需要让CPU能在进程之间切换。目前先实现一个非抢占式的进程调度,需要当前进程主动让出CPU,其他进程才有机会在当前CPU运行。具体实现如下:
- 实现sched_yield(),该函数选择一个新的进程运行,从当前正在运行进程对应的Env结构下一个位置开始循环搜索envs数组,找到第一个cpu_status为ENV_RUNNABLE的Env结构,然后调用env_run()在当前CPU运行这个新的进程。
- 我们需要实现一个新的系统调用sys_yield(),使得用户程序能在用户态通知内核,当前进程希望主动让出CPU给另一个进程。
void |
需要注意的是,当前CPU在envs数组中找了一圈后没找到合适的Env去执行,需要重新执行之前运行的进程,否则当前CPU就会进入停机状态。
关于Question3、4
在函数env_run中通过lcr3切换了页表之后,后面的代码依旧可以访问envs数组的成员。这是因为envs在kern_pgdir中被设置为用户态只读,而我们后面的每个用户进程的页表都是通过kern_pgdir为模板来复刻出来的,除了自身的部分,内核的部分肯定都是一样的,也就可以读取这段地址了
System Calls for Environment Creation
尽管现在的内核有能力在多进程之间切换,但是仅限于内核创建的用户进程。目前的JOS还没有提供系统调用,使用户进程能够创建新的进程。
UNIX提供fork()系统调用来创建新进程,fork()拷贝父进程的地址空间和寄存器状态到子进程。父进程从fork()返回的是子进程的进程ID,而子进程从fork()返回的是0。
我们将实现一组不同的、更原始的JOS系统调用来创建新的用户模式环境。我们需要完成如下函数:
- sys_exofork():
创建一个新的进程,用户地址空间没有映射,不能运行,寄存器状态和父环境一致。在父进程中sys_exofork()返回新进程的envid,子进程返回0。 - sys_env_set_status:设置一个特定进程的状态为ENV_RUNNABLE或ENV_NOT_RUNNABLE。
- sys_page_alloc:为特定进程分配一个物理页,映射指定线性地址va到该物理页。
- sys_page_map:拷贝页表,使指定进程共享当前进程相同的映射关系。本质上是修改特定进程的页目录和页表。
- sys_page_unmap:解除页映射关系。本质上是修改指定用户环境的页目录和页表。
exercise7
实现上述的系统调用
首先是sys_exofork函数,这个函数其实就是env_alloc函数的封装,就是创建一个空白进程,非常简单
static envid_t |
然后是sys_env_set_status函数。要使得进程从sys_exofork创建得到的状态ENV_NOT_RUNNABLE变为别的状态,也需要一个系统调用来实现。这个系统调用就是对设置Env状态的改变。
static int |
然后是sys_page_alloc通过分配器拿到一些page,然后把他们映射进程的地址空间。
static int |
sys_page_map
将一个进程的Page Directory
拷贝给另一个进程,让另一个进程获得相同的地址空间。这是对page_insert
的封装。
static int |
最后是sys_page_unmap,就是page_remove的封装。
static int |
别忘记在syscall函数中加上接口。
到这里我们就已经完成了part A的所有部分。
Part B: Copy-on-Write Fork
实现fork的方式有两种,一种是将父进程的内容全部拷贝一次给子进程,这样的话子进程和父进程就能实现进程隔离,但是这种方式非常的耗时,需要在物理内存中复制父进程的内容。
另一种方式叫做写时复制,父进程将自己的页目录和页表复制给子进程,这样父进程和子进程就能访问相同的内容。只有当子进程执行写操作时,才复制这一物理页。这样既能做到地址空间隔离,又能节省大量的拷贝工作。用来图来对比这两种方式:
要实现写时复制的fork需要先实现用户级别的缺页中断处理函数,这里我们默认认为内核一定正确,没有缺页错误。
User-level page fault handling
Copy-on-Write 只是用户级页面错误处理的许多可能用途之一。
我们将利用用户级页面错误处理方式,来决定如何处理用户空间中的每个页面错误,而不采用传统的Unix方法,因为其产生的错误的破坏性较小。 这种设计的另一个好处是允许程序在定义内存区域时具有很大的灵活性; 稍后我们将使用用户级页面错误处理来映射和访问基于磁盘的文件系统上的文件。
exercise8
实现sys_env_set_pgfault_upcall(envid_t envid, void *func)系统调用。该系统调用为指定的用户环境设置env_pgfault_upcall。缺页中断发生时,会执行env_pgfault_upcall指定位置的代码。当执行env_pgfault_upcall指定位置的代码时,栈已经转到异常栈,并且压入了UTrapframe结构。
static int |
Normal and Exception Stacks in User Environments
当缺页中断发生时,内核会返回用户模式来处理该中断。我们需要一个用户异常栈,来模拟内核异常栈。JOS的用户异常栈被定义在虚拟地址UXSTACKTOP。
Invoking the User Page Fault Handler
缺页中断发送时会进入内核的trap(),然后分配page_fault_handler来处理缺页中断。在该函数中应该做如下几件事:
- 判断curenv->env_pgfault_upcall是否设置,如果没有设置也就没办法修复,直接销毁该进程。
- 修改esp,切换到用户异常栈。
- 在栈上压入一个UTrapframe结构。
- 将eip设置为curenv->env_pgfault_upcall,然后回到用户态执行curenv->env_pgfault_upcall处的代码。
UTrapframe结构如下:
<-- UXSTACKTOP |
exercise9
按照上面的描述实现page_fault_handler()。
void |
User-mode Page Fault Entrypoint
exercise10
现在需要实现lib/pfentry.S中的_pgfault_upcall函数,该函数会作为系统调用sys_env_set_pgfault_upcall()的参数。
// LAB 4: Your code here. |
exercise11
完成lib/pgfault.c中的set_pgfault_handler()。
void |
缺页处理小结:
- 引发缺页中断,执行内核函数链子:trap()->trap_dispatch()->page_fault_handler()
- page_fault_handler()切换到用户异常栈,并且压入UTrapframe结构,然后调用curenv->env_pgfault_upcall(系统调用sys_env_set_pgfault_upcall()设置,之前已经设置为_pgfault_upcall)处的代码。又重新回到用户态。
- 执行_pgfault_upcall处的代码,调用pgfault_handler(库函数set_pgfault_handler()设置)处的代码,最后返回到缺页处理中断发生时的那条指令重新执行。
Implementing Copy-on-Write Fork
到目前已经可以实现用户级别的写时拷贝fork函数了。fork流程如下:
- 使用set_pgfault_handler()设置缺页处理函数。
- 调用sys_exofork()系统调用,在内核中创建一个Env结构,复制当前用户环境寄存器状态,UTOP以下的页目录还没有建立,新创建的进程还不能直接运行。
- 拷贝父进程的页表和页目录到子进程。对于可写的页,将对应的PTE的PTE_COW位设置为1。
- 为子进程设置_pgfault_upcall。
- 将子进程状态设置为ENV_RUNNABLE。
缺页处理函数pgfault()流程如下:
- 如果发现错误是因为写造成的(错误码是FEC_WR)并且该页的PTE_COW是1,则进行执行第2步,否则直接panic。
- 分配一个新的物理页,并将之前出现错误的页的内容拷贝到新的物理页,然后重新映射线性地址到新的物理页。
exercise12
实现lib/fork.c中的fork, duppage and pgfault。
static void |
Part C: Preemptive Multitasking and Inter-Process communication (IPC)
在partC部分,我们要实现抢占非协作式环境,并且实现进程间通信。
Clock Interrupts and Preemption
如果一个进程获得CPU后一直死循环而不主动让出CPU的控制权, 整个系统都将 halt。为了允许内核抢占正在运行的环境,强行重获CPU控制权,我们必须扩展JOS内核以支持来自时钟的外部硬件中断。
Interrupt discipline
外部中断(如设备中断)被称为 IRQs。 IRQ号到 IDT 项的映射不是固定的,其会加上一个IRQ_OFFSET的偏移,在picirq.c的pic_init中进行了这个映射过程。外部中断的初始化,实际上就是对硬件 8259A的初始化。
我们必须确保在用户环境中运行时设置FL_IF标志,以便在中断到达时,它将被传递到处理器并由中断代码处理。 否则,中断将被屏蔽或被忽略,直到重新启用中断为止。Bootloader 的第一条指令屏蔽了中断,到目前为止,我们还没有重新使能它们。
exercise13
首先修改Trapentry.s,当调用硬件中断处理时,处理器不会传入错误代码,因此我们需要调用TRAPHANDLER_NOEC宏。添加如下代码:
TRAPHANDLER_NOEC(timer_handler, IRQ_OFFSET + IRQ_TIMER); |
然后修改trap.c,注册IDT
void timer_handler(); |
在env_alloc中加入以下代码, 同时取消 sched_halt()中sti的注释,使能中断。
// Enable interrupts while in user mode. |
Handling Clock Interrupts
目前程序一旦进入用户模式,除非发生中断,否则CPU永远不会再执行内核代码。我们需要开启时钟中断,强迫进入内核,然后内核就可以切换另一个进程执行。
lapic_init()和pic_init()设置时钟中断控制器产生中断。需要写代码来处理中断。
exercise14
修改内核的trap_dispatch()函数,使其在发生时钟中断时调用 sched_yield()以查找并运行不同的环境。
// Handle clock interrupts. Don't forget to acknowledge the |
此时make grade ,我们能够得到65/80。
Inter-Process communication (IPC)
到目前为止,我们都在做隔离的事情。操作系统另一个重要的内容是允许程序相互交流。
IPC in JOS
我们将要实现sys_ipc_recv()和sys_ipc_try_send()这两个系统调用,来实现进程间通信。并且实现两个包装函数ipc_recv()和 ipc_send()。
JOS中进程间通信的“消息”包含两部分:
- 一个32位的值。
- 可选的页映射关系。
Sending and Receiving Messages
sys_ipc_recv()和sys_ipc_try_send()是这么协作的:
- 当某个进程调用sys_ipc_recv()后,该进程会阻塞(状态被置为ENV_NOT_RUNNABLE),直到另一个进程向它发送“消息”。当进程调用sys_ipc_recv()传入dstva参数时,表明当前进程准备接收页映射。
- 进程可以调用sys_ipc_try_send()向指定的进程发送“消息”,如果目标进程已经调用了sys_ipc_recv(),那么就发送数据,然后返回0,否则返回-E_IPC_NOT_RECV,表示目标进程不希望接受数据。当传入srcva参数时,表明发送进程希望和接收进程共享srcva对应的物理页。如果发送成功了发送进程的srcva和接收进程的dstva将指向相同的物理页
exercise15
首先是两个系统调用
static int |
static int |
然后把他们封装给用户态
void |
int32_t |
IPC总结图如下:
这里其实是有两个功能,传值和映射共同地址,但是映射地址的功能不一定是要用上的,具体怎么操作看代码和注释吧,注释写得挺详细的。
总结
本实现主要是围绕进程这个概念来展开的,主要介绍四部分:
支持多处理器。现代的处理器一般都是多核的,并且会有多个处理器,这样每个CPU能同时允许不同的进程,实现并行。。需要用锁解决多CPU的竞争。 CPU和进程在内核中的数据结构如下图所示:
实现进程调度。 一种是非抢占式式的,另一种是抢占式的,借助时钟中断实现,时钟中断到来时,内核调用sched_yield()选择另一个Env结构执行。
实现写时复制fork(进程创建)。fork是库函数,会调用sys_exofork这个系统调用,该系统调用在内核中为子进程创建一个新的Env结构,然后将父进程的寄存器状态复制给该Env结构,复制页表,对于PTE_W为1的页表,复制的同时,设置PTE_COW标志。为父进程和子进程设置缺页处理函数,处理逻辑就是:当缺页中断发生是因为写时拷贝的地址,分配一个新的物理页,然后将该虚拟地址映射到新的物理页。
原理图上面有
实现进程间通信。本质还是进入内核修改Env结构的页映射关系。原理图见上。