MIT6.828-Lab4-总结

Part A: 多处理器支持和协作多任务处理

JOS支持symmetric multiprocessing(SMP)的多处理器模型,symmetric表示所有CPU拥有相同的的权限获取系统资源, SMP模型中的CPU按照启动顺序分为bootstrap processorapplication processor,后者被前者启动。

LAPIC(本地高级可编程中断控制器)

多核系统中,使用了LAPIC用于协助每个CPU独立管理中断,每个CPU对应一个LAPIC,其不仅能处理外部设备的中断,还能处理其他处理器的中断信号。
在JOS中,为了访问CPU专属的LAPIC,每个CPU都会根据mutliprocessor configuration table(BIOS初始化的)的信息(例如LAPIC的物理地址),将其映射在自己的虚拟内存MMIO位置处。
下面是我整理的多核启动流程图:

  • 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_COWPTE_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,随后返回出错时的位置继续执行。

那么是怎么做到从异常栈切换到用户栈,并且回到正确的位置继续执行呢?

  1. 我们可以先想一想怎么从内核态进用户态的?
    我们先创建用户环境,并在struct Env结构中保存好了相应的栈帧,使得通过从此栈帧中恢复相应的寄存器使得好像是之前进入内核态,现在从内核态返回用户态一样(这就是为什么我们叫他”中断返回“
  2. 用户态怎么进内核态呢?
    这是凭借中断实现的,通过在IDT表中注册相应的处理函数地址,使之成为现实的。(更具体一点的话可见我的Lab3笔记
  3. 异常栈怎么切换到用户栈呢?
    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 <-- %esp
    我们需要将旧的eip的值保存在即将回到的用户栈下,并且需要注意此时esp寄存器的值还没变
    1
    2
    3
    4
    5
    addl $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对应的pteuvpt被链接在UVPT处,我们需要知道对于pgdir,它的PDX(UVPT)处存的就是pgdir本身的物理地址,下面是我画的图帮助理解这个trick

具体步骤:

  • 设置pgfault函数,它会判断虚拟地址对应的pte是否有PTE_COW标记并且错误号表示与写操作相关,如果通过检查, 则分配新的页面并将错误页面拷贝至新页面,随后新页面被映射在正确的位置。
  • 遍历UTEXTUSTACKTOP的内存, 将标记为PTE_WPTE_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
    37
    envid_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_pgdirUENVS(任意环境的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_sendsys_ipc_recv的系统调用,然后将其封装成ipc_sendipc_recv作为库函数。

  • sys_ipc_recv会将自己设置为NOT_RUNNABLE,然后交出CPU使用权,直到被sys_ipc_try_send设置为RUNNABLE才会继续运行下去。
  • sys_ipc_try_send在检查一切条件符合后,便会发送相应的信息,并将目标环境设置为RUNNABLE,在这里需要注意的是一定要将目标环境的栈帧的eax寄存器设置为0,否则sys_ipc_recv函数会错误返回。

MIT6.828-Lab4-总结
http://bugeater.space/2024/03/24/MIT6-828-Lab4-总结/
Author
BugEater
Posted on
March 24, 2024
Licensed under