Mit6.828 lab4 Part A:Multiprocessor Support and Cooperative Multitasking

环境

ubuntu 20.04 64 系统

正文

在本次实验将在多个同时运行的用户程序中实现抢占式多线程(Preemptive Multitasking)。首先解释一下什么是抢占式多线程:

In computing, preemption is the act of temporarily interrupting a task being carried out by a computer system, without requiring its cooperation, and with the intention of resuming the task at a later time. Such changes of the executed task are known as context switches. They are normally carried out by a privileged task or part of the system known as a preemptive scheduler, which has the power to preempt, or interrupt, and later resume, other tasks in the system.
-- wikipedia

第一句话已经开宗明义说了什么是抢占式多任务。就是说操作系统会去主动的打断一个程序的执行,不管进程是否合作,并且在将来还会恢复这个任务的执行。这个就比较熟悉了,每个进程都有自己的运行的时间片(time slice),当时间片结束后,通过中断程序(比如收时钟中断)来挂起当前在运行的进程,再由调度算法来决定接下来运行下一个要运行的进程。周而复始,这样就能让cpu一直处于工作状态。
下面两个词条介绍了介绍了相关内容:
Preemptive Multitasking
time-sharing system

在part A,为JOS实现多处理器的功能,实现round-robin调度,增加最基本的进程管理的系统调用
在part B,将会实现一个fork(),它能够让用户进程来通过自我拷贝来创建一个新的进程
在part C, 还会实现一个IPC(inter-process communication), 使得不同的用户程序能够相互之间通信。然后还要实现时钟中断以及抢占式调度。

Part A: Multiprocessor Support and Cooperative Multitasking

在本次实验的第一部分当中,首先需要让JOS能够在多处理器的电脑上,然后实现JOS的一些system calls从而创建新的用户程序。此外还需要实现cooperative round-robin调度,当前进程自愿放弃CPU的时候,kernel可以从当前进程切换到另外一个进程。在稍后的part C当中,将会实现抢占式调度,让内核一段时间后从用户进程中获得CPU(即使用户进程没有主动放弃CPU)。
PS:

The term preemptive multitasking is used to distinguish a multitasking operating system, which permits preemption of tasks, from a cooperative multitasking system wherein processes or tasks must be explicitly programmed to yield when they do not need system resources.

这里说明了抢占式都任务和协作式多任务的区别。

Multiprocessor Suppport

我们将会在JOS中实现“symmetric multiprocessing”(SMP),一个多处理器模型,它意味着所有的CPU都可以访问系统资源,比如说内存和IO总线。尽管所有的CPU在SMP的中所具备的功能都是相同的,在引导的时候这些CPU可以分为两类:bootstrap processor(BSP)负责初始化系统并且来引导操作系统;application processors(APs)在操作系统启动后由BSP来激活。哪一个处理器作为BSP是由BIOS来指定。目前为止,现有的JOS还是运行在BSP之上的(这里的意思就是之前我们做的Lab所实现的代码都还是在BSP上,还没有引入多处理器的功能)。
在一个SMP系统当中,每一个CPU都一个accompanying local APIC uint。LAPIC units负责位系统传递中断用的。LAPIC通过一个标识符来表示它所连接的CPU。在本次lab中,我们将会充分使用一下LAPIC的基本功能(kern/lapic.c):

  • 读取LAPIC标识符来说明我们当前运行的代码是在哪个CPU上(see cpunum())
  • 从BSP中发送STARTUP IPI(interprocessor interrupt)到APs来唤醒其他的CPU(see lapic_startup())
  • 在part C中,我们要实现一个LAPIC一个内置的定时器来出发时钟中断,以此来支持抢占式调度(see apic_init())

一个处理器访问它的LAPIC通过 memory-maped I/O(MMIO).在MMIO中,有一部分的物理内存会被映射为I/O映射,所以通常的load/stroe指令可以用于访问这些设备的寄存器(我们操作显存可以用load/store指令,操作一些其他的硬件可以用in/out指令)。我们已经见识VGA的IO hole(0xA0000-0xC0000,这一部分是直接给显存使用的)。LAPIC 在物理地址0XFE00_000开始的一个IO hole当中(总共有32MB),我们不需要完全用到。JOS的虚拟内存留下了4MB的内存空间来完成这个事。因为在后面的实验当中将会介绍更多的MMIO,所以现在需要写一个简单的函数来为这块区域分配内存。

这上面的内容比较难懂,建议参考MultiProcessor Specification。APIC:advanced programmable interrupt controller。注意他的定语advanced,所以我们首先需要知道什么是PIC(programmable interrupt controller)。PIC就是用于处理来自外设的一些中断的(比如说时钟中断,IO设备发出的中断),PIC能够对这些中断设置优先级从而使得CPU来执行最合适的中断。PIC是可编程的,也就是说我们可以写入代码来决定它的行为。比较经典的比如说8259A芯片,设计用于intel 8085和intel 8086处理器的.8259A--维基百科。
APIC可以兼容PIC,但是它应该比PIC具有更多的功能(没去认真了解APIC过,这是我想当然的想法)。前面说到每一个CPU都有自己的local APIC.如下图所示:

APIC confuguration

BSP,APs都有各自的APIC,APIC都有一个标识符来表明当前是哪个CPU,比如说上图的LOCAL APIC 1,说明当前的CPU是在BSP。APIC分为两个部分,分别是Local APIC和IO APIC。两者通过ICC(Interrupt Controller Communications)BUS来传递信息。 local APIC提供 interprocessor interrupts(IPIs),用于终端其他的处理器或者设置其他的处理器。IPIs有许多,最重要的是,INIT IPI 和 START IPI用于startup 以及shutdown。 (这里我有一点疑问)
我的疑问:
在多处理器标准里面(multiprocessor specification)里面提到,INIT PIP和STARTUP IPI都是用于启动APs的,不同之处是INIT IPI主要用基于82489DX APIC以及一些Pentium的处理器。而STARTUP IPI则是用于Intel processors with local APIC versions of 1.x or higher. 我没有理解说STARTUP IPI适用于shutdown的。不过和本次lab关系不大,因为在我们的lab中用的是STARTUP IPI来激活其他的APs的

剩下还有一个内存就是关于MMIO的,在内存当中有一块地址是被用于MMIO的如下图所示。

memory layout

在图中的IO APIC下面的那一块区域就是MMIO开始的地址。整个MMIO占据了一个32MB的IO hole,0xFFFF_FFFF-0XFE00_000 = 32MB。
Exercise 1

实现kern/pmap.c中的mmio_map_regin()。查阅代码kern/lapic.c中的lapic_init来理解它是如何被使用的。你还需要实现下一个Exercise才能测试mmio_map_regin()

mmio_map_region():
如上面的图所示,LAPIC所占据的物理地址是从0xFEF0_0000开始的1MB内存。但是前面说了JOS不需要很大的MMIO。我们只需要一个4MB的内存用于MMIO。这一道题目要做的就是将虚拟内存[MMIOBASE,MMIOBASE+PTSIZE]这块区域映射到实际的LAPIC的物理地址去。当我们需要访问LAPIC的时候,只需要访问MMIOBASE这一段内存就行。

    void* ret = (void*)base;
    size = ROUNDUP(size,PGSIZE);
    if( base + size > MMIOLIM) {
        panic("mmio_map_region: size of MMIO overflow");
    }
    boot_map_region(kern_pgdir,base,size,pa,PTE_PCD|PTE_PWT|PTE_W);
    base += size;
    return ret;

Application Processor Bootstrap

在启动APs之前,BSP应该收集和多处理器系统相关的信息,比如说有多少个CPU,他们的APIC ID(前面说过,APIC ID用于标识他们是哪个CPU)以及MMIO的地址。kern/mpconfig.c的mp_init()函数从MP configuration table(位于BIOS的内存范围内)中读取这些信息。
kern/init.c中的boot_aps()函数drives AP bootstrap process. APs最开始的时候是实模式(real mode,回想一下实模式的关键点),与boot/boot.S中的bootloader和相似,所以boot_aps()将AP entry code复制到一个在实模式下可用的地址(实模式的地址范围为0-1MB且并不是所有的内存都是可用的,还有IO hole,见上图)。不想bootloader,我们可以控制AP从哪儿开始执行代码。我们将entry code复制到0x7000(MPENTRY_PADDR)。但是这个地址需要是未使用的(所以不能放到IO hole中)并且还需要page-aligned(为什么需要page-alinged看Multiprocesscor specification section B 4.2,里面有解释)。
在那之后(设置好APs的entry code之后),boot_aps()一个接一个激活APs,设置好CS:IP,从而让APs可以在此运行代码(在我们的例子当中就是MPENTRY_PADDR)。在kern/mpentry.S中的entry code和在boot/boot.S中的代码十分相似。经过一些设置后,为各个AP开启paging,还要设置好这个CPU所属的GDT。然后再调用mp_main()函数(在kern/init.c当中)。boot_aps()函数等待AP发出一个CPU_STARTED的信号,然后再接下去激活下一个AP。
Exercise 2:

阅读kern/init.c中的boot_aps()以及mp_main()的代码,以及mpentry.S中的汇编代码。确保你理解了在引导APs的时候的权限转换。然后修改你的page_init()函数,将MPENTRY_PADDR从free list中移除,这样才可以安全的复制代码到这个物理地址。验证是否通过测试点check_page_free_list().

代码实现:
前面说到我们要将AP的entry code放到MPENTRY_PADDR这个地址处。所以我们要将这块内存不放到free_list去。首先我们需要计算entry code占据了多少的内存,是不是超过了一个页的大小。此外,在lab1当中,low memory的部分原来都是空闲可用的,现在在这块内存区域当中加入了entry code,相当于在里面挖了一个洞。

    pages[0].pp_ref = 1;

    // IO hole之前的内存都是free的
    //这部分的解释可以看lab1 Low memory部分
    size_t i ;

    for (i = 1; i < MPENTRY_PADDR/PGSIZE; i++) {
        pages[i].pp_ref = 0;
        pages[i].pp_link = page_free_list;
        page_free_list = &pages[i];
    }

    //在lab4中,我们要在内存当中留出空间放mpentry.S中的代码
    //思路,1. 计算mpentry.S的代码所需要多少内存,并且计算的到的数值还要向上ROUNDUP
    //然后将这些内存标记为已被使用
    extern unsigned char mpentry_start[], mpentry_end[];
    int mp_size = mpentry_end - mpentry_start;
    int mp_size_alinged = ROUNDUP(mp_size,PGSIZE);
    for(; i < (MPENTRY_PADDR + mp_size_alinged) / PGSIZE; i++ ) {
        pages[i].pp_ref = 1;
    }
    for (; i < npages_basemem; i++) {
        pages[i].pp_ref = 0;

        //这里设想一下链表的头插法就理解了!
        pages[i].pp_link = page_free_list;

        //&pages[i]并不是真正的空闲页的地址,这是Pages这个数组中的元素的地址
        //page2pa这个函数才是得到真正的地址的。
        page_free_list = &pages[i];
    }

    //IO hole
    for(; i < EXTPHYSMEM/PGSIZE; i++) {
        pages[i].pp_ref = 1;
    }

    //Kernel占据了从0x0010_0000 - 0x0fff_ffff,这一部分是kernel的 
    //所以第一个空闲的页就紧跟在内核之后,所以 end of IO hole ~ first free page 就是内核占据的内存
    ///PADDR是将虚拟地址转为实际的物理地址
    physaddr_t first_free_addr = PADDR(boot_alloc(0));
    size_t first_free_page = first_free_addr/PGSIZE;

    for(; i < first_free_page; i++) {
        //这部分内存被内核使用的
        pages[i].pp_ref = 1;
    }

    //内核之后的所有内存都是free的
    for(; i < npages; i++ ) {
        pages[i].pp_ref = 0;
        pages[i].pp_link = page_free_list;
        page_free_list = &pages[i];
    }

Question

比较一下kern/mpentry.S以及boot/boot.S中的代码。牢记kern/mpentry.S是被编译连接到地址高于KERNBASE的,MPBOOTSPHYS的目的是什么?为什么他要在kern/mpentry.S中而不是在boot/boot.S中?换句话说,如果省略了这个会出现什么问题?提示:回想一下链接地址和加载地址之间的区别

在我们编译的时候,我们将链接的地址放在了内存很高的地方。所以mpentry.S中的编号经过编译后也是很高的地址。但是mpentry.S这些代码是要在实模式中运行的。那么原来链接的高地址自然是不能用的。所以要将虚拟地址转为物理地址。通过MPBOOTSPHYS这个宏,就将虚拟地址转为了在实模式下可用的地址。这样才可以初始化好entry code从而正常的激活其他处理器。

Per-CPU State and Initialization

当写一个多处理器的系统的时候,很重要一点就是每一个CPU的状态都是私有的,kern/cpu.h中的结构体CpuInfo定义每一个CPU的状态。cpunum() 会返回执行这个函数的CPU的ID,这个ID可以用于在数组cpus中索引对应的CPU。thiscpu这个宏就是当前CPU所属的CpuInfo结构。

下面是一些关于per-CPU state你需要知道的:

  • Per-CPU kernel stack
    因为多个CPU能够同时trap到内核当中,我们需要为每一个CPU都设置他们专属的内核来防止他们之间相互影响。二维数组percpu_kstacks[NCPU][KSTKSIZE]为每一个CPU都预留了栈的大小。
  • Per-CPU TSS and TSS descriptor:
    每一个CPU的TSS (task state segment)同样也需要指定每一个CPU的内核栈在哪。第i个CPU所属的TSS粗放在cpus[i].cpu_ts当中,并且与之对应的TSS descriptor在GDT中的位置为gdt[(GD_TSS0 >> 3) + i]。那个全局的ts(在kern/trap.c当中就不再使用了)
    PS:
    这一点应该很好懂。前面我说了每个CPU都有自己的内核栈,那么很自然的每个CPU都需要有自己的TSS。这样当中断发生的时候每个CPU才可以在自己的内核栈内完成栈切换,原程序的上下文保存的工作(context switch)
  • Per-CPU system registers
    所有的寄存器,包括系统寄存器,对于CPU来说也是私有的,初始化系统寄存器的指令,比如说lcr3(), lr(),lgdt()等等指令都需要在每一个CPU都运行过。env_init_percpu()trap_init_percpu()函数就是为了这个目的。

Exercise 3:

修改mem_init_mp()(在kern/pmap.c)中的代码,初始化好每一个CPU的栈。每一个栈的大小是KSTKSIZE加上SKTKGAP字节的未映射的栈。此时的代码可以通过测试点 check_kern_pgdir()

代码实现:
实现这道题的关键点要认真看下mem_init_mp()里面的说明以及memlayout.h中地址空间的分配。注释里面说到我的们栈分为两个部分:一个部分是用于普通使用的栈的,另外一部分的栈是用于作为 guard page的。前面说到我们已经为各个CPU预留了栈的内存,在数组percpu_kstacks[i]就表示第i个cpu的栈的大小。现在我们只需要从KSTKTOP开始逐渐往下为各个CPU的栈地址映射起来即可。

    for(int i = 0; i < NCPU; i++) {
        int kstacktop_i = KSTACKTOP - i * (KSTKSIZE + KSTKGAP);
        boot_map_region(kern_pgdir,
                        kstacktop_i - KSTKSIZE,
                        KSTKSIZE,
                        PADDR(&percpu_kstacks[i]),
                        PTE_P | PTE_W);
    }

Exercise 4:

trap_init_percpu()(在kern/init.c当中)为BSP初始化了TSS以及对应的TSS descriptor。但是它只能在lab3当中正常使用,现在CPU变多了就不管用了。修改代码让其对所有CPU都生效。

最开始的时候,我陷入了一个误区:我一开始以为需要在trap_init_percpu()中使用一个for循环来初始化。但是实际上不是的。理解这个代码怎么写,首先需要来过一些激活其他的处理器的流程:

  1. 在kern/init.c中调用了boot_aps()函数
  2. boot_aps()函数,为每个AP设置好entry code
  3. 然后boot_aps()接着调用调用lapic_startap()函数去发送STARTUP IPI来激活AP
  4. AP激活后,进入到entry code(mpentry.S)后,进行必要的设置后,在跳转到的mp_main()函数
  5. mp_main()函数中调用trap_init_percpu()函数,来设置每一个处理器的它自己的TSS等内容

过了一遍上面的流程,我们知道每一个CPU被激活后都要去调用trap_init_percpu()函数。我们并不需要在trap_init_percpu()里面加入for循环。注意在代码实现的时候,还需要结合注释来获得一些提示。

代码实现:
参考原来给的代码。应该可以理解我的代码的意思。我们要为每一个CPU设置好他自己的TSS。 还要设置好GDT。

    thiscpu->cpu_ts.ts_esp0 = KSTACKTOP -  cpunum() * (KSTKSIZE + KSTKGAP);
    thiscpu->cpu_ts.ts_ss0 = GD_KD;
    thiscpu->cpu_ts.ts_iomb= sizeof(struct Taskstate);
    gdt[(GD_TSS0 >> 3) + cpunum()] = SEG16(STS_T32A, 
                                    (uint32_t)(&thiscpu -> cpu_ts),
                                    sizeof(struct Taskstate) -1 ,
                                    0);
    gdt[(GD_TSS0 >> 3) + cpunum()].sd_s = 0;

    // ltr(GD_TSS0 + sizeof(struct Segdesc) * cpunum());
    ltr(((GD_TSS0 >> 3) + cpunum()) << 3);

实验结果:
完成上面的代码以后,运行make qemu CPUS=4,然后会看到下面的结果,因为在**mp_init()中最后有一个死循环所以,代码不会继续执行。但是我们可以看到此时所有的APs都被唤醒了。不过我图里面有多个ENV,这是我做了后面的内容忘了把代码注释掉的结果,问题不大。:

make qemu CPUS=4

Locking

我们当前的代码停在了mp_main()(这个函数里面有个死循环)。在让AP更进一步之前,我们首先需要解决多个CPU运行代码而带来的race condition问题。最简单的方法就是使用一个大的内核锁(big kernel lock)。所谓大锁就是当一个进程进入内核后就会持有他,当进程返回到用户态的时候在释放锁(user mode)。在这样的模型之下,多个用户进程可以在多个CPU上并行运行,但是只有一个用户进程可以进入到内核态,如果其他进程需要进入到内核则需要等待。
kern/spinlock.c声明了一个内核锁,叫做kernel_lock。他同样提供了lock_kernel()unlock_kernel()函数来获得锁以及释放锁。你应该将内核锁应用到下面几个函数去。

  • i386_init(), 在BSP唤醒其他APs之前获得锁。
  • mp_main()当中,在初始化AP之后获得锁,然后调用sched_yield()来运行进程。
  • trap()中,如果trap来自用户程序,那么就获得锁。对于如何判断trap是否来自用户,用tf_cs来判断。
    PS:
    如果对于保护模式有过经验对于如何判断是来自用户程序还是内核,这一点判断应该十分简单。我们在trap()函数中上锁,当别的程序也trap到内核的时候,若此时已经有进程进入了trap,那么新进入trap的进程就要等待了。**这样我们就达到了只有一个用户进程可以进入到内核态的目的。
  • env_run()当中,在返回到用户程序之前释放锁。不要太早也不要太晚释放锁。
    Exercie 5:

将lock_kernel()和unlock_kernel()应用到上述代码。

代码实现:
这个比较简单。注释中给我们的提示也比较多了,就不过多讲解了。

   //在kern/init.c中
    lock_kernel();
    // Starting non-boot CPUs
    boot_aps();

  //在kern/inic.c中的mp_main()当中
    lock_kernel();
    sched_yield();

//kern/trap.c中的trap()当中
    if ((tf->tf_cs & 3) == 3) {
        // Trapped from user mode.
        // Acquire the big kernel lock before doing any
        // serious kernel work.
        // LAB 4: Your code here.
        lock_kernel();
        assert(curenv);

//kern/env.c中的env_run()当中
    lcr3(PADDR(curenv->env_pgdir));
    unlock_kernel();
    // cprintf("eax:%d\n",curenv->env_tf.tf_regs.reg_eax);
    env_pop_tf(&(curenv->env_tf));

Question:

现在看起来内核锁保证了只有一个CPU可以运行内核代码。那我们为什么还需要对每一个CPU都设置一个栈呢?描述一下当多个CPU共享一个栈的时候会发生什么。

对于这个问题,一开始没有想明白如果使用共享的栈问题会出现在哪里。不过仔细一想,问题会出现在trap,并不是已进入trap就加锁,回想一下进入trap的过程,我们在_alltraps那里并没有加锁,这里会出现一个问题。前面实验我们知道,trap(struct Trapframe *tf),为了取得当前trap的进程,我们在_alltraps中执行了push esp指令。设想一下这样一个场景,多个CPU都执行了_alltraps的代码,当他们都进入trap以后。那么trap()的参数tf指向的是同一个tf,另外一个CPU所需要的tf没了。

Round-Robin Scheduling

下一个任务就是在JOS实现进程调度了,以Round-Robin的方式。Round-Robin调度简而言之就是每一个进程都有机会得到CPU,没有引入优先级的概念。多个进程轮流使用CPU。
Round-Robin 在JOS按照如下方式实现:

  • kern/sched.c中的函数sched_yield()函数负责选择下一个需要运行的进程。找到在当前正在运行的进程之后的状态为ENV_RUNNABLE的进程,然后调用env_run()来运行新的进程
  • sched_yield()不能将同一个进程运行在别的CPU上。通过判断当前进程的状态可以知道他是否运行在某个CPU上。
  • 我们实现了一个新的系统调用,sys_yield(),它通过调用sched_yield()来放弃CPU然后切换到新的进程。

Exercise 6:
在sched_yield()中实现round-robin调度,不要忘了在syscall()中加入sys_yield();
确保mp_main()中调用sched_yield()。
修改kern/init.c中的代码,创建三个进程都调用了user/yield.c程序。
运行make qemu和make qemu CPUS=2来测试结果

代码实现:
结合前面的描述以及sched_yiled()中的代码注释。实现这个代码应该不难。

    struct Env* current_proc = thiscpu->cpu_env; //当前cpu正在运行的进程
    int startid = (current_proc) ? ENVX(current_proc->env_id) : 0; //返回在当前CPU运行的进程ID
    int next_procid;
    for(int i = 1; i < NENV; i++) {
        // 找到当前进城之后第一个状态为RUNNABLE的进程
        next_procid = (startid+i) % NENV;
        if(envs[next_procid].env_status == ENV_RUNNABLE) {
            env_run(&envs[next_procid]); //运行新的进程
        }
    }

    //注释里面说到了,不能将当前正在运行的进程运行到别的CPU上。如果之前运行在当前CPU的进程仍然在运行
    //且没有其他可以runnable的进程,那么就继续运行原来的进程
    if(envs[startid].env_status == ENV_RUNNING && envs[startid].env_cpunum == cpunum()) {
        env_run(current_proc); //继续运行原来的进程
    }

注意,mp_main()的注释信息告诉我们要把那个死循环注释了。

void
mp_main(void)
{
    // We are in high EIP now, safe to switch to kern_pgdir 
    lcr3(PADDR(kern_pgdir));
    cprintf("SMP: CPU %d starting\n", cpunum());

    lapic_init();
    env_init_percpu();
    trap_init_percpu();
    xchg(&thiscpu->cpu_status, CPU_STARTED); // tell boot_aps() we're up

    // Now that we have finished some basic setup, call sched_yield()
    // to start running processes on this CPU.  But make sure that
    // only one CPU can enter the scheduler at a time!
    //
    // Your code here:
    lock_kernel();
    sched_yield();
    // Remove this after you finish Exercise 6
    // for (;;);
}

最后再加入几个新的进程,都调用yield程序。

    // Touch all you want.
    //ENV_CREATE(user_hello, ENV_TYPE_USER);
    // ENV_CREATE(user_primes, ENV_TYPE_USER);
     ENV_CREATE(user_yield, ENV_TYPE_USER);
     ENV_CREATE(user_yield, ENV_TYPE_USER);
     ENV_CREATE(user_yield, ENV_TYPE_USER);

Question:

在实现env_run()当中,我们使用了lcr3()。在调用lcr3()之前和之后,我们都使用参数传给env_run()的参数 e。在更新cr3寄存器之后,MMU中的东西就失效了。但是参数e还是有效的。为什么呢?

回答这个问题不难。还记得之前在xv 6book当中,它里面提到了任何一个进程的地址空间分为两部分。一部分称为用户程序部分,另外一部分称为内核部分。内核部分在任何一个进程当中都是相同的,所以就算发生了切换,但是并没有改变内核地址部分。所以这些参数还是可用的。

当内核从一个进程切换到另外一个进程,必须保证原来进程的寄存器要被保存下来以便于未来恢复这个进程的执行。 这一切是怎么发生的?

回答这个问题,只需要理一下切换进程的过程。(到目前为止,我们还没有引入时间片轮转,只是最简单的调度)。

  1. 调用系统调用sys_yield()使得当前进程主动放弃CPU。
  2. sys_yield()调用lib/syscall.c中的syscall()进入系统调用
  3. syscall()函数调用Int 指令来进入到trap
  4. trapentry.S中执行到_alltraps将必要的参数压入栈后
  5. 调用trap()函数,然后通过curenv->env_tf = *tf来将内核栈当中的用户程序的寄存器上下文Trapframe赋值给当前进程的env_tf结构。用于恢复将来恢复进程的运行。
  6. trap中调用trap_dispatch()函数最终执行到kern/syscall.c中的对应的中断处理函数。

PS:
curenv->env_tf = *tf这语句将原来的进程的上下文复制到它自己的env_tf。但是我不理解的是:内核中一个envs数组(定义在env.c当中)来持有所有的进程。为什么在这里看不到以envs[index]->env_tf这种方式来修改进程的寄存器信息。没想通,不过,有一点可以非常明白的是,在进入中断后,肯定需要从内核栈当中拿到用户进程的寄存器信息,然后需要赋值给对应进程的寄存器结构(struct Tramframe)

_alltraps我们将原来程序的寄存器压入到了栈当中,就这样保存了context。sched_yield()调度完成后,调用env_run()跳转到下一个要执行的程序(也可能恢复到原来的进程继续执行)。

System Calls For environment Creation

现在你的内核可以能够运行并且能够在多个用户进程之间切换了,不过目前仍然还是运行一些内核初始化好的进程。你现在需要实现JOS的一些系统调用来创建用户进程并且运行。
Unix提供了一个fork()这一进程创建原语。Unix fork()拷贝当前整个地址空间的内容(父进程)到
新创建的进程当中去(子进程)。两者唯一的区别就是他们的ID以及他们的父进程ID。在父进程中,fork()返回的是子进程ID,然而在子进程中fork()返回0。默认情况下,每一个进程的地址空间都是私有的。
我们需要实现下面的system calls来实现fork():

  • sys_exofork():
    这个系统调用创建了一个新的进程,但是没有映射地址空间中属于用户的那部分,而且此时这个进程还不是可运行的。当调用sys_exofork()函数的时候,新创建的进程与父进程有相同的寄存器的值。在父进程中,sys_exofork()返回新创建进程的ID。在进程中,返回0。(因为在最开始的时候子进程被标记为 not runnable,sys_fork()并不会直接返回知道父进程将子进程标记为可运行的状态,没懂没关系,看完代码就懂了)。
  • sys_env_status:
    设置指定进程的状态为ENV_RUNNABLE或者ENV_RUNNBLE。这个系统调用用于将一个进程标记为可运行的。
  • sys_page_alloc:
    分配一个物理页,并且将他映射到给出的虚拟地址去。
  • sys_page_map:
    复制当前页的映射到另外一个进程的地址空间去,这样一来两个进程都可以通过相同的虚拟地址访问相同的物理地址了。
  • sys_page_umap:
    取消所给出的虚拟地址到物理地址的映射。

上面所有的system calls都接受进程ID作为参数,JOS认为0表示当前正在运行的进程。这个是现在envid2nev()中。
Exercise 7:

实现上面描述的system calls,还要再kern/syscall.c中的syscall()加入他们。你需要很多kern/pmap.c和kern/env.c中的函数,尤其是envid2env()。还要记得给envid2env()传入一个1。最后运行dumbfork程序来判断有用。

下面先给出代码实现,最后再来分析一下fork() 的整个过程。注意代码实现结合他给的注释

sys_exofork():
此函数并不会真正的让一个进程直接可以运行。他只是分配一个新进程的地址空间。新分配的进程就是子进程,他有和父进程相同的寄存器的值,实现这一点只要把当前进程的trapframe复制给子进程就好了。我们用env_alloc()来创建新的进程,代码里面有一句child->env_tf.rf_regs.eax=0后面会解释的。

    struct Env* child;
    int ret_value;
    if((ret_value = env_alloc(&child,curenv->env_id)) < 0) {
        return ret_value ;
    }
    child->env_status = ENV_NOT_RUNNABLE;
    child->env_tf = curenv->env_tf;
    // cprintf("ip:%x\n",curenv->env_tf.tf_eip);
    child->env_tf.tf_regs.reg_eax = 0;
    return child->env_id;

sys_env_set_status():
这个函数用于设置进程的状态。通过注释我们知道我要判断进程是否属于ENV_RUNNABLE或者ENV_NOT_RUNNABLE状态。还要判断对应envid的进程是否存在。在最后,设置进程的状态。

    int ret_value;
    struct Env* proc;
    if ((ret_value = envid2env(envid,&proc,1)) < 0) {
        return -E_BAD_ENV;
    }
    if(status != ENV_NOT_RUNNABLE && status != ENV_RUNNABLE) {
        return -E_INVAL;
    }
    proc->env_status = status;
    return 0;

sys_page_alloc():
这道题的条件比较多,乍一看十分吓人。不过认真看代码上面的注释,可以理解每个条件的意思。不过多赘述。有一点就是,当我们判断perm是否合适的时候,用 &(和运算)就可以。

    if((uintptr_t)va >= UTOP || PGOFF(va)) {
        return -E_INVAL;
    }
    struct Env* env;
    int ret_value;
    if ((ret_value = envid2env(envid,&env,1)) < 0) {
        return -E_BAD_ENV;
    }
    if(!(perm & PTE_SYSCALL)) {
        return -E_INVAL;
    }
    struct PageInfo* new_page = page_alloc(ALLOC_ZERO);
    if(new_page == NULL) {
        return -E_NO_MEM;
    }
    if( (ret_value = page_insert(env->env_pgdir,new_page,va,perm)) < 0) {
        page_free(new_page);
        return ret_value;
    }
    return 0;

sys_page_map():
这个函数要做的工作是,将已经存在的进程的某一段物理与虚拟地址之间的映射关系复制到新的进程当中去。所以必然就涉及到页的查找以及插入。同样的上面也有一大堆的条件需要我们去判断。请仔细阅读注释信息。

    struct Env *src_env,*dst_env;
    int ret_value;
    pte_t*pg_table_entry;
    struct PageInfo* page;
    if( envid2env(srcenvid,&src_env,1) < 0 ||envid2env(dstenvid,&dst_env,1) < 0) {
        return -E_BAD_ENV;
    }
    if((uintptr_t)srcva >= UTOP || PGOFF(srcva) || (uintptr_t)dstva >= UTOP || PGOFF(dstva)) {
        return -E_INVAL;
    }
    if((perm | PTE_SYSCALL) != PTE_SYSCALL) {
        return -E_INVAL;
    }
    page = page_lookup(src_env->env_pgdir,srcva,&pg_table_entry);
    if(page == NULL) {
        return -E_INVAL;
    }
    if(page_insert(dst_env->env_pgdir,page,dstva,perm) < 0) {
        return -E_NO_MEM ;
    }
    return 0;

sys_page_umap():
这个函数要完成的工作是,就给出的虚拟地址取消映射。注释提示我们使用page_remove()。这个也不难,直接给出代码实现.

    struct Env* env;
    if(envid2env(envid,&env,1) < 0) {
        return -E_BAD_ENV;
    }
    if( (uintptr_t)va >= UTOP || PGOFF(va)) {
        return -E_INVAL;
    }
    page_remove(env->env_pgdir,va);
    return 0;

kern/syscall.c中的syscall():
最后我们要在syscall()中加入对应的case,这个好懂。代码如下:

    case SYS_page_alloc:
        return sys_page_alloc((envid_t)a1, (void * )a2, (int )a3);
    case SYS_page_map:
        return sys_page_map((envid_t) a1, (void *) a2, (envid_t) a3, (void *) a4, (int) a5);
        
    case SYS_page_unmap:
        return sys_page_unmap((envid_t) a1, (void *) a2);

    case SYS_exofork:
        // cprintf("sys_exofork()\n");
        return sys_exofork();

    case SYS_env_set_status:
        return sys_env_set_status((envid_t) a1, (int) a2);

完成上面的代码后,运行dumbfork。输入make run-dumbfork可以得到以下结果(部分):

上述代码的一点思考

  1. 内核是如何做到fork的时候,父进程返回子进程的ID,子进程返回0?
    接下来为了讲述清楚这个问题可能篇幅非常的长。
    上面这个问题说起来好像一句非常具有迷惑性的话调用一个fork()两个返回值。这是网上很多人都这么说的。乍一看好像是这么一回事,不过事实不是这样的,真正的是原因是两个返回值分别在子进程和父进程分别返回。

具体来看看是如何实现的。曾经我一直奇怪,如果子进程和父进程拥有相同的代码,那么岂不是无限套娃一直创建进程了?哈哈,想想就比较搞笑。实际上不是的,我们新创建的进程,虽然说在地址空间上拥有和父进程相同的东西。但是它的eip指向的是fork()之后的代码。比如说以下的代码:

int main {
  ...
  int pid = fork(); 
  printf("hello world");
  ...
  return 0
}

当我们创建一个子进程后,此时父子进程的eip都是指向printf("hello world"),不过这个只是宏观上的,具体的汇编代码稍微和这有些不一样。所以并不会无限套娃。

回到我们的dumbfork中的代码来解释。sys_exofork()是一个定义在lib.h中的inline函数。inline函数会在编译的时候直接被替换为对应的代码。进入到sys_exofork()来看看。

// This must be inlined.  Exercise for reader: why?
static inline envid_t __attribute__((always_inline))
sys_exofork(void)
{   
    
    envid_t ret;
    asm volatile("int %2"
             : "=a" (ret)
             : "a" (SYS_exofork), "i" (T_SYSCALL));
    // cprintf("ret:%d\n",ret);
    // cprintf("lib.h sysfork\n");
    return ret;
}

里面是GCC内联汇编,虽然说我也不是特别懂内联汇编。不过我们只需要稍微看看,可以发现我们调用int中断,把返回值放在了ret这个变量当中。实际上在汇编代码中,函数的返回值被放在了eax寄存器当中
下面是sys_exofork()对应的汇编代码,这个可以obj/user/dumbfork.asm中得到:

  8000d9:   b8 07 00 00 00          mov    $0x7,%eax
  8000de:   cd 30                   int    $0x30
  8000e0:   89 c3                   mov    %eax,%ebx

调用int 0x30中断就是调用我们创建新进程的中断,上面的eax=7表明了我们使用的是SYS_exofork。 此时在父进程的eip就指向0x8000e0了。接下来就跳转到了kern/syscall.c中的sys_exefork()中去执行了。

    struct Env* child;
    int ret_value;
    if((ret_value = env_alloc(&child,curenv->env_id)) < 0) {
        return ret_value ;
    }
    child->env_status = ENV_NOT_RUNNABLE;
    child->env_tf = curenv->env_tf;
    cprintf("ip:%x\n",curenv->env_tf.tf_eip);
    child->env_tf.tf_regs.reg_eax = 0;
    return child->env_id;

在这里我们创建了子进程,然后复制父进程的寄存器给他,所以子进程的eip也是指向0x8000e0。关键的一句就是child->env_tf.tf_regs.reg_eax = 0;我们修改了trapframe中的eax,我们知道eax是和返回值有关的。有一点疑问就是我们如何利用起来这个返回值?暂且先不管,到这里return 的代码就return 返回到父进程去了。网上有些人说的,子进程也执行了fork(),注意这个说法是错的。只有父进程执行了fork()。
为了验证一下这个结果,我们在kern/syscall.c中的syscall()的SYS_exofork对应的case加入一个cprintf,以及在kern/syscall.c中的sys_exorfork()中加入cpintf()。输出结果如下(这是部分输出结果):

[00000000] new env 00001000
sys_exofork()
syscall.c:enter sys_exofork 
[00001000] new env 00001001
ip:8000e0

可以看到,确实中断也只调用了一次,而且sys_exfork()也确确实实就执行了一次。接踵而来的问题就是既然就执行了一次那么是如何做到两个不同的返回值的呢?

还记得前面说到了,子进程被创建后就停在了0x0x8000e0,这句语句对应的eax存放的是返回值。所以我们只有在这句语句之前修改eax的值,那么代码中得到的返回值就被修改了。问题是如何修改返回值?答案很简单,前面我们设置了child的eax=0,那么只要在子进程一被调度算法调度,轮到他执行的时候,由于popal指令,会恢复trapframe中到对应的寄存器去,于是轮到他执行的时候eax就被修改为0了。
我们在dumfork()的sys_exofork()后面加上一句打印进程ID的语句如下:

    envid = sys_exofork();
    cprintf("id:%d\n",envid);

运行后得到结果:

[00000000] new env 00001000
[00001000] new env 00001001
id:4097
0: I am the parent! id:4097
id:0

可以看到子进程ID为4097返回到了父进程,子进程中返回了0。可以说我们的fork是正确的。有一点不解的是,为什么中间先打印一句0: I am the parent! id:4097,而且为什么子进程打印ID的语句会在它之后?。 注意,还记得我们新创建的进程并不是一开始就可以运行的,要到后面语句:

    if ((r = sys_env_set_status(envid, ENV_RUNNABLE)) < 0)
        panic("sys_env_set_status: %e", r);

设置为Runnable后才可以执行。此时我们没有引入时间片轮转,所以只有当进程主动放弃CPU的时候才可以调度别的进程。

void
umain(int argc, char **argv)
{
    envid_t who;
    int i;

    // fork a child process
    who = dumbfork();

    // print a message and yield to the other a few times
    for (i = 0; i < (who ? 10 : 20); i++) {
        cprintf("%d: I am the %s! id:%d\n", i, who ? "parent" : "child",who);
        sys_yield();
    }
}

到这里,for循环中首先打印出parent,然后父进程就放弃了CPU。接着关键点到了,调度程序发现子进程可以运行,就调度子进程去获取执行,然后在envv_run中恢复了子进程的各个寄存器的值。子进程开始运行此时eax寄存器已被修改,所以返回值变为了0,然后开始执行0x0x8000e0的代码,终于它来到它自己的printf(),输出了id:0。
所以比较关键的就是,子进程的返回值不是和父进程同时返回的。父进程先执行得到了自己的返回值,然后调度算法使得子进程运行,子进程中再得到返回值,只不过这个返回值已经被修改过了。

  1. 我们fork()后,为什么新创建的进程就直接到了envs数组当中去了?
    这个疑问最先来自于:我们好像没有显示的通过envs[i]的方式来将新创建的进程加入到envs数组当中去。回顾一下创建进程的流程就可以明白这个问题。
    首先在kern/env.c中,初始化了所有的envs,并且将它们用一个链表串起来,env_free_list是链表头:

然后我们在创建的时候,就更新链表。因为链表的作用是将所有空闲的进程slot串起来。所以更新了链表相当于就是往envs这个数组中加入数据了。
下面是env_alloc()中的代码,更新了链表。

一点思考

不得不说国内的操作系统课太不够意思了。学校开的不够实践性。就以进程来说,上课说进程有多个状态,比如说ready,dead,running。当切换进程的时候,将CPU的控制权还给内核,内核来调度程序。当时就在想内核也是进程,既然说调度程序是内核代码,内核又是一个进程,感觉很奇怪,难道调度程序也要调度内核吗?
经过本次partA的实践,可以了解到内核并不是严格意义上的进程,它没有那些running,block什么的状态。

你可能感兴趣的:(Mit6.828 lab4 Part A:Multiprocessor Support and Cooperative Multitasking)