MIT6.828-Lab4-总结
Part A: 多处理器支持和协作多任务处理
JOS支持symmetric multiprocessing
(SMP)的多处理器模型,symmetric
表示所有CPU拥有相同的的权限获取系统资源, SMP模型中的CPU按照启动顺序分为bootstrap processor
和application processor
,后者被前者启动。
LAPIC(本地高级可编程中断控制器)
多核系统中,使用了LAPIC
用于协助每个CPU独立管理中断,每个CPU对应一个LAPIC
,其不仅能处理外部设备的中断,还能处理其他处理器的中断信号。
在JOS中,为了访问CPU专属的LAPIC
,每个CPU都会根据mutliprocessor configuration table
(BIOS初始化的)的信息(例如LAPIC
的物理地址),将其映射在自己的虚拟内存MMIO
位置处。
下面是我整理的多核启动流程图:
![](/2024/03/24/MIT6-828-Lab4-%E6%80%BB%E7%BB%93/pic1.png)
sched_yield
是JOS的循环调度的实现
System calls that create/destory environments
此部分主要是封装系统系统调用供用户态使用。重点提一下fork为什么可以产生两个不同的返回值
fork的大致流程为:
- 创建一个新的进程
- 将其父进程栈帧复制给子进程(在JOS中具体实现为
child_env->env_tf = currenv->env_tfcurrenv
即当前进程为父进程),标记子进程为NOT_RUNNABLE
,直到父进程将.data
,.text
,.bss
等段同样映射在子进程中。 - 最重要的一步,设置子进程的栈帧中的用于存储函数返回值的寄存器(eax)的值设置为0,这样子就像子进程也调用了fork一样,但其函数返回值为0。
Part B: Copy-on-Write Fork
Page fault处理机制
写时复制机制极大地提高了fork
时的内存操作开销,其基于在fork
时,父子进程的地址空间的虚拟页上都被标记为只读且PTE_COW
(PTE_COW
是属于page table entry的低9-12位的AVL位),CPU会触发page fault,随后进行响应。
- JOS中,page fault的处理被下放给用户态实现,用于降低系统耦合性,这也说明了JOS不是宏内核。
- JOS中,发生缺页中断时,会分配一页的用户异常栈,用于保存上下文,且响应的异常处理均在此栈中进行,重点在于需要在堆栈中保存用户态下的
page_fault_handler
,当内核构建好异常栈并且建立栈帧后,便会切回用户态。 - JOS中,
page_fault_handler
是由用户决定的,使用set_pgfault_handler
来将用户决定的异常处理机制告知给pfentry.S
(这是发生page_fault
时的入口,会在用户态下执行page_fault_handler
,随后返回出错时的位置继续执行。
那么是怎么做到从异常栈切换到用户栈,并且回到正确的位置继续执行呢?
- 我们可以先想一想怎么从内核态进用户态的?
我们先创建用户环境,并在struct Env
结构中保存好了相应的栈帧,使得通过从此栈帧中恢复相应的寄存器使得好像是之前进入内核态,现在从内核态返回用户态一样(这就是为什么我们叫他”中断返回“) - 用户态怎么进内核态呢?
这是凭借中断实现的,通过在IDT表中注册相应的处理函数地址,使之成为现实的。(更具体一点的话可见我的Lab3笔记) - 异常栈怎么切换到用户栈呢?
JOS采用了巧妙的方法,下面是异常栈的内容:我们需要将旧的1
2
3
4
5
6
7
8
9// trap-time esp
// trap-time eflags
// trap-time eip
// utf_regs.reg_eax
// ...
// utf_regs.reg_esi
// utf_regs.reg_edi
// utf_err (error code)
// utf_fault_va <-- %espeip
的值保存在即将回到的用户栈下,并且需要注意此时esp
寄存器的值还没变随后再将相应的值恢复到对应的寄存器中,切换到用户栈后,我们使用1
2
3
4
5addl $8, %esp // 忽略fault_va和err
movl 0x20(%esp), %eax // trap-time eip移入eax寄存器
subl $4, 0x28(%esp) // 将trap-time 栈首下移4字节用于保存eip
movl 0x28(%esp), %ebx // 将trap-time 栈首地址移入ebx寄存器
movl %eax, (%ebx) // 将eip存入trap-time的栈中ret
指令将用户栈顶的旧eip值保存到eip
寄存器中,这样子就成功解决了page fault
并继续执行下去1
2
3
4
5
6
7
8
9// Restore the trap-time registers.
popal
// Restore eflags from the stack.
add $4, %esp // 忽略eip
popfl // restore eflags寄存器
// Switch back to the adjusted trap-time stack.
popl %esp
// Return to re-execute the instruction that faulted.
ret
tips:我认为也不是不能用iret
来返回用户栈,可能是因为异常栈帧(UTrapframe)和内核栈帧(Trapframe)不一样导致无法复用env_pop_tf
来实现返回用户栈。
实现COW
现在我们已经有了page fault
处理机制了,现在只用要封装好fork
就实现COW
了。
在这里一定要讲一下JOS中的另一个巧妙机制
我们需要知道用户态是无法使用内核态的函数去查找虚拟地址对应的page table entry的,但在COW机制中,我们很需要判断某一虚>拟地址的pte的权限,那该怎么办呢?
在JOS中,我们使用uvpt[va>>12]
来获取va
对应的pte
,uvpt
被链接在UVPT
处,我们需要知道对于pgdir
,它的PDX(UVPT)
处存的就是pgdir
本身的物理地址,下面是我画的图帮助理解这个trick
![](/2024/03/24/MIT6-828-Lab4-%E6%80%BB%E7%BB%93/pic2.png)
具体步骤:
- 设置
pgfault
函数,它会判断虚拟地址对应的pte
是否有PTE_COW
标记并且错误号表示与写操作相关,如果通过检查, 则分配新的页面并将错误页面拷贝至新页面,随后新页面被映射在正确的位置。 - 遍历
UTEXT
至USTACKTOP
的内存, 将标记为PTE_W
或PTE_COW
的页面也映射在子进程中,父子进程中的页面都需要标记为只读。 - 为子进程分配异常栈空间,并也给子进程设置
page_fault_handler
,最后将子进程设置为RUNNABLE
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37envid_t
fork(void)
{
envid_t envid;
int r;
unsigned pn;
// 1. 设置缺页处理函数,这样之后父子进程的实际缺页处理函数就是pgfault了,但是只有在父进程的struct env中存有_pgfault_upcall这个中转站,因此下面还要进一步处理。 对应下面的2.
envid = sys_exofork(); // 这里分配子进程的pgdir,并建立内核代码的映射
set_pgfault_handler(pgfault);
if (envid == 0) {
// 子进程
// 修改thisenv,thisenv是用户态的数据结构,内核不会帮我们修改
thisenv = &envs[ENVX(sys_getenvid())];
return 0;
}
// 父进程
// 遍历 utext和ustacktop之间的虚拟内存页,然后对存在的虚实页面映射进行拷贝
for (pn=PGNUM(UTEXT); pn<PGNUM(USTACKTOP); pn++){
if ((uvpd[pn >> 10] & PTE_P) && (uvpt[pn] & PTE_P))
if ((r = duppage(envid, pn)) < 0)
return r;
}
// 给子进程分配异常栈,父进程的异常栈已经在上面调用set_pgfault_handler时设置好了。
if ((r = sys_page_alloc(envid,(void *) (UXSTACKTOP - PGSIZE), (PTE_U | PTE_P | PTE_W))) < 0) {
panic("fork.c:fork() : sys_page_alloc failed");
}
extern void _pgfault_upcall(void); //缺页处理的汇编函数入口,它会调用pgfault
// 2. 对应上面的1.
if((r = sys_env_set_pgfault_upcall(envid, _pgfault_upcall)) < 0){
panic("fork.c:fork() : sys_set_pgfault_upcall:%e", r);
}
if ((r = sys_env_set_status(envid, ENV_RUNNABLE)) < 0)//设置子进程可运行,在这一行之后,子进程才可能被调度执行!
return r;
// 父进程返回子进程envid
return envid;
}
Part C: Preemptive Multitasking and Inter-Process communication (IPC)
抢占式调度实现
此处非常容易实现,只需检测到和定时器相关的trap_no
,便使用lapic_eoi
通知LAPIC
有中断来临,并使用sched_yield
来换另一个任务进行执行。
进程内部通信
JOS通过将传递的信息存放在struct Env
结构中,对于envs
数组,内核态拥有读写权限,用户态只有读权限。
那么内核态用户态权限不同是怎么做到的呢?
进入用户态的入口位于lib/entry.S
,它首先改变envs
变量的值为UENVS
,而之前我们已经在mem_init
中将envs
的物理地址映射在kern_pgdir
的 UENVS
(任意环境的UTOP
以上的地址和kern_pgdir
是一样的)。所以我们只需要使得两个不同的虚拟地址映射在同一物理地址并赋予不同权限即可。
内核态对envs
的权限在以下代码被赋值为只有内核可读可写
n = (1LL << 32) - KERNBASE;
boot_map_region(kern_pgdir, KERNBASE, n, 0, PTE_W);
用户态对envs
的权限 在以下代码被赋值为内核用户均只读
boot_map_region(kern_pgdir, UENVS, sizeof(struct Env) * NENV, PADDR(envs), PTE_U);
在这里才真正地让我认识到了分页机制的妙处。
修改envs数组的操作为什么不需要上锁的呢?
因为envs数组只有内核可写,而任何想进入内核态的代码都需要获取大内核锁,而JOS中,都是通过中断门进入内核态的,而中断门会清除EFLAGS
寄存器,屏蔽所有中断。
JOS中主要实现sys_ipc_try_send
和sys_ipc_recv
的系统调用,然后将其封装成ipc_send
和ipc_recv
作为库函数。
sys_ipc_recv
会将自己设置为NOT_RUNNABLE
,然后交出CPU使用权,直到被sys_ipc_try_send
设置为RUNNABLE
才会继续运行下去。sys_ipc_try_send
在检查一切条件符合后,便会发送相应的信息,并将目标环境设置为RUNNABLE
,在这里需要注意的是一定要将目标环境的栈帧的eax
寄存器设置为0,否则sys_ipc_recv
函数会错误返回。