Ucore lab5 操作系统实验

LAB5实验报告


实验相关知识

(主要从教学ppt、gitbook、学堂在线上了解掌握并根据CSDN查询了解更加详细的信息)

在lab4中我们已经实现了内核线程的管理,本次实验我们将实现用户进程的管理,虽然操作系统对二者的管理体制是较为相似的,但是具体实现上还是有很大差别。基于系统的安全性和可扩展性,ucore要提供用户态进程的创建和执行机制,给应用程序执行提供一个用户态运行环境。

内核线程与用户进程的区别:

  • 内核线程只运行在内核态,用户进程会在在用户态和内核态交替运行
  • 所有内核线程共用ucore内核内存空间,不需为每个内核线程维护单独的内存空间;用户进程需要维护各自的用户内存空间

由于进程的执行空间扩展到了用户态空间,所以在进程管理和内存管理上有较大不同。

  • 内存管理方面,增加用户态虚拟内存的管理。限制用户进程可以访问的物理地址空间,且让各个用户进程之间的物理内存空间访问不重叠,这样可以保证不同用户进程之间不能相互破坏各自的内存空间,利用虚拟内存的功能(页换入换出),给用户进程提供了远大于实际物理内存空间的虚拟内存空间。具体实现时,对页表的内容进行扩展,能够把部分物理内存映射为用户态虚拟内存。如果某进程执行过程中,CPU在用户态下执行(在CS段寄存器最低两位包含有一个2位的优先级域,如果为0,表示CPU运行在特权态;如果为3,表示CPU运行在用户态。),则可以访问本进程页表描述的用户态虚拟内存,但由于权限不够,不能访问内核态虚拟内存。
  • 进程管理方面,主要涉及到的是进程控制块中与内存管理相关的部分,包括建立进程的页表和维护进程可访问空间(可能还没有建立虚实映射关系)的信息;加载一个ELF格式的程序到进程控制块管理的内存中的方法;在进程复制(fork)过程中,把父进程的内存空间拷贝到子进程内存空间的技术。另外一部分与用户态进程生命周期管理相关,包括让进程放弃CPU而睡眠等待某事件;让父进程等待子进程结束;一个进程杀死另一个进程;给进程发消息;建立进程的血缘关系链表。

在lab2的实验报告相关知识部分已经介绍了特权级以及相关转换,在此处再详述一下用户级向内核态的转换

通常操作系统会采用软中断或者叫做trap的方式完成。实际上,发生中断时已经实现了从用户态切换到内核态,为了实现这种切换,我们需要建立好中断门,中断门中的中断描述符表指出了中断发生后跳转至何处,并且发生中断时我们必须保存SS、ESP等信息。但是,中断会根据保存的这些信息返回到用户态中,为了实现停留在内核态,我们对CS进行修改,将其指向内核态的代码段,其次,我们将CS的CPL设为0,在此处还需要根据要执行的指令修改EIP,这样最后执行IRET指令时,CPU会将堆栈信息取出并返回到EIP以及CS所指内容去执行,从而便实现了从ring3到ring0的转换。
Ucore lab5 操作系统实验_第1张图片
为了实现特权级的切换,实际上还需要访问TSS(Task State Segment)任务状态段。简单来说,任务状态段就是内存中的一个数据结构。这个结构中保存着和任务相关的信息。当发生任务切换的时候会把当前任务用到的寄存器内容(CS/ EIP/ DS/SS/EFLAGS…)保存在TSS 中以便任务切换回来时候继续使用。

为了访问TSS,还需要访问全局描述符表。全局描述符表(GDT)保存者TSS的地址,TSS最终会被加载进内存中。其中有一个Task Register 的cache缓存,最终通过基址加上偏移来确定Task所在的具体位置。

实验流程

练习0:填写已有实验

本实验依赖实验1/2/3/4。请把你做的实验1/2/3/4的代码填入本实验中代码中有“LAB1”/“LAB2”/“LAB3”/“LAB4”的注释相应部分。注意:为了能够正确执行lab5的测试应用程序,可能需对已完成的实验1/2/3/4的代码进行进一步改进.

回答虽然作业要求不需要在报告中写实验0,但是这次实验0涉及的方面挺多的,有很多改动,故必须说明。

(实际上每次实验最烦的就是实验0…浪费很长时间,并且经常出现bug -.- )

实验0主要将之前4次实验的代码补充进来,包括 kdebug.c、trap.c、default_pmm.c、pmm.c、swap_fifo.c、vmm.c、proc.c七个文件的相关代码。除了直接补充外,有一些地方需要对代码进行修改或者改进:

  • alloc_proc函数:

    在lab4的基础上,又增添了 wait_state ,proc->cptr , proc->optr , proc->yptr 四个变量,实际上只需要将wait_state初始化为0,三个指针初始化为NULL即可。避免之后由于未定义或未初始化导致管理用户进程时出现错误。

    // alloc_proc - alloc a proc_struct and init all fields of proc_struct
    static struct proc_struct *
    alloc_proc(void) {
        struct proc_struct *proc = kmalloc(sizeof(struct proc_struct));
        if (proc != NULL) 
        {
            proc->state = PROC_UNINIT;//设置进程为未初始化状态
            proc->pid = -1;          //未初始化的进程id=-1
            proc->runs = 0;          //初始化时间片
            proc->kstack = 0;      //初始化内存栈的地址
            proc->need_resched = 0;   //是否需要调度设为不需要
            proc->parent = NULL;      //置空父节点
            proc->mm = NULL;      //置空虚拟内存
            memset(&(proc->context), 0, sizeof(struct context));//初始化上下文
            proc->tf = NULL;      //中断帧指针设置为空
            proc->cr3 = boot_cr3;      //页目录设为内核页目录表的基址
            proc->flags = 0;      //初始化标志位
            memset(proc->name, 0, PROC_NAME_LEN);//置空进程名
            proc->wait_state = 0;  //初始化进程等待状态  
            proc->cptr = proc->optr = proc->yptr = NULL;//进程相关指针初始化  
        }
        return proc;
    }
    
  • do_fork函数:

    do_fork函数整体未较大改动,主要修改部分为将子进程的父进程设置为 current process ,并且确保current process 的 wait_state 为0,因此我们可以用一个assert()实现该功能。还有就是插入新进程到进程哈希表和进程链表时,设置好相关进程的链接。设置链接的函数为 set_links这里较为坑的地方在于set_links函数中已经实现了将进程插入链表并将进程总数加1,因此需要删掉lab4中这两句代码。

    修改的代码:

    	//设置父节点为当前进程
        proc->parent = current;
    	//确保当前进程正在等待
        assert(current->wait_state == 0);
    //------------------------------------
        local_intr_save(intr_flag);
        {
            proc->pid = get_pid();
            hash_proc(proc);   //将新进程加入hash_list
            // 删除原来的 nr_process++ 和 加入链表 
            set_links(proc);   //执行set_links函数,实现设置相关进程链接
        }
        local_intr_restore(intr_flag);
    
  • trap_dispatch函数:

    主要修改地方在于 当分配给进程的时间片用完时,设置进程为需要被调度

    		ticks ++;
            if (ticks % TICK_NUM == 0)
            {
                assert(current != NULL);
                //时间片用完设置为需要调度
                //说明当前进程的时间片已经用完了
                current->need_resched = 1;
            }
    
    
  • idt_init函数:

    增添功能为:设置一个特定中断号的中断门,专门用于用户进程访问系统调用。

    设置一个特殊的中断描述符idt[T_SYSCALL],它的特权级设置为DPL_USER,中断向量处理地址在__vectors[T_SYSCALL]处。这样建立好这个中断描述符后,一旦用户进程执行“INTT_SYSCALL”后,由于此中断允许用户态进程产生(注意它的特权级设置为DPL_USER),所以CPU就会从用户态切换到内核态,保存相关寄存器,并跳转到__vectors[T_SYSCALL]处开始执行

       
    extern uintptr_t __vectors[];
        int i;
        for (i = 0; i < sizeof(idt) / sizeof(struct gatedesc); i ++) {
            SETGATE(idt[i], 0, GD_KTEXT, __vectors[i], DPL_KERNEL);
        }
         // 设置给用户态用的中断门 让用户态能够进行系统调用
        SETGATE(idt[T_SYSCALL], 1, GD_KTEXT, __vectors[T_SYSCALL], DPL_USER);
        lidt(&idt_pd);
    

补充之前的函数并对以上函数进行修改后,便可以开始接下来的实验。

练习1: 加载应用程序并执行(需要编码)

do_execv函数调用load_icode(位于kern/process/proc.c中)来加载并解析一个处于内存中的ELF执行文件格式的应用程序,建立相应的用户内存空间来放置应用程序的代码段、数据段等,且要设置好proc_struct结构中的成员变量trapframe中的内容,确保在执行此进程后,能够从应用程序设定的起始执行地址开始执行。需设置正确的trapframe内容。

首先我们了解一下两个函数的功能:

do_execv函数的主要功能和实现: 完成用户进程的创建工作

  • 为加载新的执行码做好用户态内存空间清空准备。如果mm不为NULL,则设置页表为内核空间页表,且进一步判断mm的引用计数减1后是否为0,如果为0,则表明没有进程再需要此进程所占用的内存空间,为此将根据mm中的记录,释放进程所占用户空间内存和进程页表本身所占空间。最后把当前进程的mm内存管理指针为空。由于此处的initproc是内核线程,所以mm为NULL,整个处理都不会做。
  • 加载应用程序执行码到当前进程的新创建的用户态虚拟空间中。这里涉及到读ELF格式的文件,申请内存空间,建立用户态虚存空间,加载应用程序执行码等。load_icode函数完成了整个复杂的工作。
//主要目的在于清理原来进程的内存空间,为新进程执行准备好空间和资源
int do_execve(const char *name, size_t len, unsigned char *binary, size_t size) 
{
    struct mm_struct *mm = current->mm;
    if (!user_mem_check(mm, (uintptr_t)name, len, 0)) {
        return -E_INVAL;
    }
    if (len > PROC_NAME_LEN) {
        len = PROC_NAME_LEN;
    }

    char local_name[PROC_NAME_LEN + 1];
    memset(local_name, 0, sizeof(local_name));
    memcpy(local_name, name, len);
//如果mm不为NULL,则不执行该过程
    if (mm != NULL) 
    {
        //将cr3页表基址指向boot_cr3,即内核页表
        lcr3(boot_cr3);
        if (mm_count_dec(mm) == 0) 
        {  
            //下面三步实现将进程的内存管理区域清空
            exit_mmap(mm);
            put_pgdir(mm);
            mm_destroy(mm);
        }
        current->mm = NULL;
    }
    int ret;
    //填入新的内容,load_icode会将执行程序加载,建立新的内存映射关系,从而完成新的执行
    if ((ret = load_icode(binary, size)) != 0) {
        goto execve_exit;
    }
    //给进程新的名字
    set_proc_name(current, local_name);
    return 0;

execve_exit:
    do_exit(ret);
    panic("already exit: %e.\n", ret);
}

load_icode函数的主要功能和实现:(gitbook上对此函数有非常详细的介绍)

load_icode函数的主要工作就是给用户进程建立一个能够让用户进程正常运行的用户环境。此函数有一百多行,完成了如下重要工作:

  1. 调用mm_create函数来申请进程的内存管理数据结构mm所需内存空间,并对mm进行初始化;
  2. 调用setup_pgdir来申请一个页目录表所需的一个页大小的内存空间,并把描述ucore内核虚空间映射的内核页表(boot_pgdir所指)的内容拷贝到此新目录表中,最后让mm->pgdir指向此页目录表,这就是进程新的页目录表了,且能够正确映射内核虚空间;
  3. 根据应用程序执行码的起始位置来解析此ELF格式的执行程序,并调用mm_map函数根据ELF格式的执行程序说明的各个段(代码段、数据段、BSS段等)的起始位置和大小建立对应的vma结构,并把vma插入到mm结构中,从而表明了用户进程的合法用户态虚拟地址空间;
  4. 调用根据执行程序各个段的大小分配物理内存空间,并根据执行程序各个段的起始位置确定虚拟地址,并在页表中建立好物理地址和虚拟地址的映射关系,然后把执行程序各个段的内容拷贝到相应的内核虚拟地址中,至此应用程序执行码和数据已经根据编译时设定地址放置到虚拟内存中了;
  5. 需要给用户进程设置用户栈,为此调用mm_mmap函数建立用户栈的vma结构,明确用户栈的位置在用户虚空间的顶端,大小为256个页,即1MB,并分配一定数量的物理内存且建立好栈的虚地址<—>物理地址映射关系;
  6. 至此,进程内的内存管理vma和mm数据结构已经建立完成,于是把mm->pgdir赋值到cr3寄存器中,即更新了用户进程的虚拟内存空间,此时的initproc已经被hello的代码和数据覆盖,成为了第一个用户进程,但此时这个用户进程的执行现场还没建立好;
  7. 先清空进程的中断帧,再重新设置进程的中断帧,使得在执行中断返回指令“iret”后,能够让CPU转到用户态特权级,并回到用户态内存空间,使用用户态的代码段、数据段和堆栈,且能够跳转到用户进程的第一条指令执行,并确保在用户态能够响应中断;

至此完成了用户环境的搭建。此时initproc将按产生系统调用的函数调用路径原路返回,执行中断返回指令iret后,将切换到用户进程程序的第一条语句位置_start处开始执行。

在这里需要补充的是proc_struct结构中tf结构体变量的设置,从而实现tf从内核态切换到用户态然后执行程序。

我们在memlayout.h中可以看到对虚拟内存空间的划分图:根据这个图我们可以确定tf_esp和tf_eip的设置:

/* *
 * Virtual memory map:                                          Permissions
 *                                                              kernel/user
 *
 *     4G ------------------> +---------------------------------+
 *                            |                                 |
 *                            |         Empty Memory (*)        |
 *                            |                                 |
 *                            +---------------------------------+ 0xFB000000
 *                            |   Cur. Page Table (Kern, RW)    | RW/-- PTSIZE
 *     VPT -----------------> +---------------------------------+ 0xFAC00000
 *                            |        Invalid Memory (*)       | --/--
 *     KERNTOP -------------> +---------------------------------+ 0xF8000000
 *                            |                                 |
 *                            |    Remapped Physical Memory     | RW/-- KMEMSIZE
 *                            |                                 |
 *     KERNBASE ------------> +---------------------------------+ 0xC0000000
 *                            |        Invalid Memory (*)       | --/--
 *     USERTOP -------------> +---------------------------------+ 0xB0000000
 *                            |           User stack            |
 *                            +---------------------------------+
 *                            |                                 |
 *                            :                                 :
 *                            |         ~~~~~~~~~~~~~~~~        |
 *                            :                                 :
 *                            |                                 |
 *                            ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
 *                            |       User Program & Heap       |
 *     UTEXT ---------------> +---------------------------------+ 0x00800000
 *                            |        Invalid Memory (*)       | --/--
 *                            |  - - - - - - - - - - - - - - -  |
 *                            |    User STAB Data (optional)    |
 *     USERBASE, USTAB------> +---------------------------------+ 0x00200000
 *                            |        Invalid Memory (*)       | --/--
 *     0 -------------------> +---------------------------------+ 0x00000000
 * (*) Note: The kernel ensures that "Invalid Memory" is *never* mapped.
 *     "Empty Memory" is normally unmapped, but user programs may map pages
 *     there if desired.
 *
 * */

再结合该文件中的宏定义:Ucore lab5 操作系统实验_第2张图片
结合以上以及注释,便大概可以知道应该如何初始化了:

  • 由于最终是在用户态下运行的,所以需要将段寄存器初始化为用户态的代码段、数据段、堆栈段;
  • esp应当指向先前的步骤中创建的用户栈的栈顶;
  • eip应当指向ELF可执行文件加载到内存之后的入口处;
  • eflags中应当初始化为中断使能,注意eflags的第1位是恒为1的;
  • 设置ret为0,表示正常返回;

代码如下:

 	tf->tf_cs = USER_CS;
    tf->tf_ds = tf->tf_es = tf->tf_ss = USER_DS;
    tf->tf_esp = USTACKTOP;//0xB0000000
    tf->tf_eip = elf->e_entry;
    tf->tf_eflags = FL_IF;//FL_IF为中断打开状态 
    ret = 0;

问题1.1:用户进程执行

请在实验报告中描述当创建一个用户态进程并加载了应用程序后,CPU是如何让这个应用程序最终在用户态执行起来的。即这个用户态进程被ucore选择占用CPU执行(RUNNING态)到具体执行应用程序第一条指令的整个经过。

  • 调用schedule函数,调度器占用了CPU的资源之后,用户态进程调用了exec系统调用,从而转入到了系统调用的处理例程;
  • 之后进行正常的中断处理例程,然后控制权转移到了syscall.c中的syscall函数,然后根据系统调用号转移给了sys_exec函数,在该函数中调用了do_execve函数来完成指定应用程序的加载;
  • 在do_execve中进行了若干设置,包括推出当前进程的页表,换用内核的PDT,调用load_icode函数完成对整个用户线程内存空间的初始化,包括堆栈的设置以及将ELF可执行文件的加载,之后通过current->tf指针修改了当前系统调用的trapframe,使得最终中断返回的时候能够切换到用户态,并且同时可以正确地将控制权转移到应用程序的入口处;
  • 在完成了do_exec函数之后,进行正常的中断返回的流程,由于中断处理例程的栈上面的eip已经被修改成了应用程序的入口处,而CS上的CPL是用户态,因此iret进行中断返回的时候会将堆栈切换到用户的栈,并且完成特权级的切换,并且跳转到要求的应用程序的入口处;
  • 开始执行应用程序的第一条代码;

练习2: 父进程复制自己的内存空间给子进程(需要编码)

创建子进程的函数do_fork在执行中将拷贝当前进程(即父进程)的用户内存地址空间中的合法内容到新进程中(子进程),完成内存资源的复制。具体是通过copy_range函数(位于kern/mm/pmm.c中)实现的,请补充copy_range的实现,确保能够正确执行。

请在实验报告中简要说明如何设计实现”Copy on Write 机制“,给出概要设计,鼓励给出详细设计。

Copy-on-write(简称COW)的基本概念是指如果有多个使用者对一个资源A(比如内存块)进行读操作,则每个使用者只需获得一个指向同一个资源A的指针,就可以该资源了。若某使用者需要对这个资源A进行写操作,系统会对该资源进行拷贝操作,从而使得该“写操作”使用者获得一个该资源A的“私有”拷贝—资源B,可对资源B进行写操作。该“写操作”使用者对资源B的改变对于其他的使用者而言是不可见的,因为其他使用者看到的还是资源A。

答:首先叙述一下do_fock函数,do_fork是一个内核函数,用于父进程对子进程的复制,具体来说就是在执行中将拷贝当前进程(即父进程)的用户内存地址空间中的合法内容到新进程中(子进程),完成内存资源的复制。

do_fork(uint32_t clone_flags, uintptr_t stack, struct trapframe *tf)
 //该函数的三个参数
 //    clone_flags用于copy_mm()调用,与memory相关
 //    stack表示当前用户态esp的值,copy_thread()用到
 //     tf表示父进程的trapframe,copy_thread()用到

do_fork将创建进程控制块,之后分配kernel stack,包括分配memory以及虚地址。之后将复制父进程内存,在这里会调用copy_mm()为新进程创建新的虚拟空间和调用copy_range()函数拷贝父进程的内存到新进程。之后会设置trapframe和context,之后便将新创建好的子进程放到进程队列中去,便可以等待执行。

copy_range()将是把实际的代码段和数据段搬到新的子进程里面去,再设置好页表的相关内容,使得子进程有自己的内存管理架构。具体代码如下:

//将实际的代码段和数据段搬到新的子进程里面去,再设置好页表的相关内容
int
copy_range(pde_t *to, pde_t *from, uintptr_t start, uintptr_t end, bool share) {
    //确保start和end可以整除PGSIZE
   	assert(start % PGSIZE == 0 && end % PGSIZE == 0);
    assert(USER_ACCESS(start, end));
    //以页为单位进行复制
    do {
     //得到A&B的pte地址
        pte_t *ptep = get_pte(from, start, 0), *nptep;
        if (ptep == NULL) 
        {
            start = ROUNDDOWN(start + PTSIZE, PTSIZE);
            continue ;
        }

        if (*ptep & PTE_P) {
            if ((nptep = get_pte(to, start, 1)) == NULL) {
                return -E_NO_MEM;
            }
        uint32_t perm = (*ptep & PTE_USER);
        //get page from ptep
        struct Page *page = pte2page(*ptep);
        //为B分一个页的空间
        struct Page *npage=alloc_page();
        assert(page!=NULL);
        assert(npage!=NULL);
        int ret=0;
        /* LAB5:EXERCISE2 YOUR CODE
         * (1) find src_kvaddr: the kernel virtual address of page
         * (2) find dst_kvaddr: the kernel virtual address of npage
         * (3) memory copy from src_kvaddr to dst_kvaddr, size is PGSIZE
         * (4) build the map of phy addr of  nage with the linear addr start
         */
       //1.找寻父进程的内核虚拟页地址
        void * kva_src = page2kva(page);
       //2.找寻子进程的内核虚拟页地址   
        void * kva_dst = page2kva(npage);
        //3.复制父进程内容到子进程 
        memcpy(kva_dst, kva_src, PGSIZE);
       //4.建立物理地址与子进程的页地址起始位置的映射关系
        ret = page_insert(to, npage, start, perm);
        assert(ret == 0);
        }
        start += PGSIZE;
    } while (start != 0 && start < end);
    return 0;
}
问题2.1:”Copy on Write 机制“

如何设计实现”Copy on Write 机制“,给出概要设计,鼓励给出详细设计。

(参考清华大学学堂在线关于该机制的讲解、CSDN上关于LINUX上的该机制的原理介绍)

首先,Copy on Write 是在复制一个对象的时候并不是真正的把原先的对象复制到内存的另外一个位置上,而是在新对象的内存映射表中设置一个指针,指向源对象的位置,并把那块内存的Copy-On-Write位设置为1。通俗来说一下这样做的好处:如果复制的对象只是对内容进行"读"操作,那其实不需要真正复制,这个指向源对象的指针就能完成任务,这样便节省了复制的时间并且节省了内存。但是问题在于,如果复制的对象需要对内容进行写的话,单单一个指针可能满足不了要求,因为这样对内容的修改会影响其他进程的正确执行,所以就需要将这块区域复制一下,当然不需要全部复制,只需要将需要修改的部分区域复制即可,这样做大大节约了内存并提高效率。

因为如果设置原先的内容为只可读,则在对这段内容进行写操作时候便会引发Page Fault,这时候我们便知道这段内容是需要去写的,在Page Fault中进行相应处理即可。也就是说利用Page Fault来实现权限的判断,或者说是真正复制的标志。

基于原理和之前的用户进程创建、复制、运行等机制进行分析,设计思想:

  • 设置一个标记位,用来标记某块内存是否共享,实际上dup_mmap函数中有对share的设置,因此首先需要将share设为1,表示可以共享。
  • 在pmm.c中为copy_range添加对共享页的处理,如果share为1,那么将子进程的页面映射到父进程的页面即可。由于两个进程共享一个页面之后,无论任何一个进程修改页面,都会影响另外一个页面,所以需要子进程和父进程对于这个共享页面都保持只读。
  • 当程序尝试修改只读的内存页面的时候,将触发Page Fault中断,这时候我们可以检测出是超出权限访问导致的中断,说明进程访问了共享的页面且要进行修改,因此内核此时需要重新为进程分配页面、拷贝页面内容、建立映射关系

基本设计思想即为此,基于此,可以实现较为简单的"Copy on Write"机制。

练习3: 阅读分析源代码,理解进程执行 fork/exec/wait/exit 的实现,以及系统调用的实现(不需要编码)

请在实验报告中简要说明你对 fork/exec/wait/exit函数的分析。

答:基于函数以及函数注释较为容易这些进程执行的实现过程:

**fork:**完成进程的拷贝,由do_fork函数完成,主要过程如下:

  • 首先检查当前总进程数目是否到达限制,如果到达限制,那么返回E_NO_FREE_PROC
  • 调用alloc_proc来创建并初始化一个进程控制块;
  • 调用setup_kstack为内核进程(线程)建立栈空间、分配内核栈;
  • 调用copy_mm拷贝或者共享内存空间;
  • 调用copy_thread复制父进程的中断帧和上下文信息;
  • 调用get_pid()为进程分配一个PID;
  • 将进程控制块加入哈希表和链表,并实现相关进程的链接;
  • 最后返回进程的PID

**exec:**完成用户进程的创建工作,同时使用户进程进入执行。由do_exec函数完成,主要过程如下:

  • 检查进程名称的地址和长度是否合法,如果合法,那么将名称暂时保存在函数栈中,否则返回E_INVAL
  • 将cr3页表基址指向内核页表,然后实现对进程的内存管理区域的释放;
  • 调用load_icode将代码加载进内存并建立新的内存映射关系,如果加载错误,那么调用panic报错;
  • 调用set_proc_name重新设置进程名称。

wait: 完成对子进程的内核栈和进程控制块所占内存空间的回收。由do_wait函数完成,主要过程如下:

  • 首先检查用于保存返回码的code_store指针地址位于合法的范围内;
  • 根据PID找到需要等待的子进程PCB,循环询问正在等待的子进程的状态,直到有子进程状态变为ZOMBIE:
    • 如果没有需要等待的子进程,那么返回E_BAD_PROC
    • 如果子进程正在可执行状态中,那么将当前进程休眠,在被唤醒后再次尝试;
    • 如果子进程处于僵尸状态,那么释放该子进程剩余的资源,即完成回收工作。

exit: 完成当前进程执行退出过程中的部分资源回收。 由do_exit函数完成,主要过程如下:

  • 释放进程的虚拟内存空间;
  • 设置当期进程状态为PROC_ZOMBIE即标记为僵尸进程
  • 如果父进程处于等待当期进程退出的状态,则将父进程唤醒;
  • 如果当前进程有子进程,则将子进程设置为initproc的子进程,并完成子进程中处于僵尸状态的进程的最后的回收工作
  • 主动调用调度函数进行调度,选择新的进程去执行。

关于系统调用的定义主要在syscall.c中,在这里定义了许多系统调用函数,包括sys_exitsys_forksys_waitsys_exec等。在ucore初始化函数kern_init中调用了idt_init函数来初始化中断描述符表,并设置一个特定中断号的中断门,专门用于用户进程访问系统调用。

在proc.c的注释中也给出了系统调用的含义和实现时完成服务的函数:Ucore lab5 操作系统实验_第3张图片

  • 对于用户进程来说,为了使用系统调用,用户进程需要将需要使用的系统调用编号放入EAX寄存器,系统调用最多支持5个参数,分别放在EDX、ECX、EBX、EDI、ESI这5个寄存器中,然后使用INT 0x80指令进入内核态。
  • 对于内核线程来说,操作系统根据中断号0x80得知是系统调用时,根据系统调用号和参数执行相应的操作。

自己根据操作系统理论课的知识回答:当发生系统调用时,首先会将系统调用前的现场进行保存,也就是保存上下文,并且保存当前进程的trapframe。然后执行系统调用指令,CPU通过操作系统建立的中断描述符,转入内核态,完成系统调用的具体执行过程。完成后,CPU根据之前保存的上下文以及trapframe完成现场的恢复,并将EIP指向tf_eip,至此系统调用基本完成。

回答如下问题:

问题3.1:请分析fork/exec/wait/exit在实现中是如何影响进程的执行状态的?
  • fork将创建新的子线程,将子线程的状态由UNINIT态变为RUNNABLE态,不改变父进程的状态
  • exec完成用户进程的创建工作,同时使用户进程进入执行,不改变进程状态
  • wait完成子进程资源回收,如果有已经结束的子进程或者没有子进程,那么调用会立刻结束,不影响进程状态;否则,进程需要等待子进程结束,进程从RUNNIG态变为SLEEPING态。
  • exit完成对资源的回收,进程从RUNNIG态变为ZOMBIE态。
问题3.2:请给出ucore中一个用户态进程的执行状态生命周期图(包执行状态,执行状态之间的变换关系,以及产生变换的事件或函数调用)。(字符方式画即可)

在ucore中,进程共四种状态:("初始"状态、睡眠状态、可运行状态、僵尸状态)

// process's state in his life cycle
enum proc_state {
    PROC_UNINIT = 0,  // uninitialized
    PROC_SLEEPING,    // sleeping
    PROC_RUNNABLE,    // runnable(maybe running)
    PROC_ZOMBIE,      // almost dead, and wait parent proc to reclaim his resource
};

状态转移图:(好吧。画一个好看的字符画图好难。)

	     创建进程     +---wait()---------RUNNING----------------+
           |        |                   ^ |                  |
           |        |                   | |                  |
      alloc_page()  |                proc_run()            exit()  
           |        |                   | |                  |
           V        |                   | v                  v
         UNINIT ------wakeup_proc()--> RUNNABLE --exit()--> ZOMBIE--父进程调用wait()-->
                    |                    ^                   ^
                    |                    |                   |
                    |                子进程调用exit()        exit()
                    |                    |                   |
                    |                    |                   |
                    +---wait()------>SLEEPING----------------+

需要注意的是,从RUNNABLE到RUNNING时,进程被proc_run函数作为参数调用,从RUNNING到RUNABLE时,是进程主动调用proc_run函数

至此,实验基本完成,执行make qemu以及make grade,查看是否正确:Ucore lab5 操作系统实验_第4张图片
执行make grade:
Ucore lab5 操作系统实验_第5张图片
因此可知以上练习无误,之后进行challenge

扩展练习 Challenge :实现 Copy on Write (COW)机制

给出实现源码,测试用例和设计报告(包括在cow情况下的各种状态转换(类似有限状态自动机)的说明)。

这个扩展练习涉及到本实验和上一个实验“虚拟内存管理”。在ucore操作系统中,当一个用户父进程创建自己的子进程时,父进程会把其申请的用户空间设置为只读,子进程可共享父进程占用的用户内存空间中的页面(这就是一个共享的资源)。当其中任何一个进程修改此用户内存空间中的某页面时,ucore会通过page fault异常获知该操作,并完成拷贝内存页面,使得两个进程都有各自的内存页面。这样一个进程所做的修改不会被另外一个进程可见了。请在ucore中实现这样的COW机制。

答:在练习2中我回答了COW机制实现的思想:

基于原理和之前的用户进程创建、复制、运行等机制进行分析,设计思想:

  • 设置一个标记位,用来标记某块内存是否共享,实际上dup_mmap函数中有对share的设置,因此首先需要将share设为1,表示可以共享。
  • 在pmm.c中为copy_range添加对共享页的处理,如果share为1,那么将子进程的页面映射到父进程的页面即可。由于两个进程共享一个页面之后,无论任何一个进程修改页面,都会影响另外一个页面,所以需要子进程和父进程对于这个共享页面都保持只读。
  • 当程序尝试修改只读的内存页面的时候,将触发Page Fault中断,这时候我们可以检测出是超出权限访问导致的中断,说明进程访问了共享的页面且要进行修改,因此内核此时需要重新为进程分配页面、拷贝页面内容、建立映射关系

基于该思想,对代码进行修改

首先,将vmm.c中的dup_mmap函数中队share变量的设置进行修改,因为dup_mmap函数中会调用range函数,range函数有一个参数为share,因此修改share为1标志着启动了共享机制。

int dup_mmap(struct mm_struct *to, struct mm_struct *from) {
		...
        bool share = 1;
		if (copy_range(to->pgdir, from->pgdir, vma->vm_start, vma->vm_end, share)!= 0) 			{
            return -E_NO_MEM;
         }
        ...
}

之后,在pmm.c中为copy_range添加对共享页的处理,如果share为1,那么将子进程的页面映射到父进程的页面即可。由于两个进程共享一个页面之后,无论任何一个进程修改页面,都会影响另外一个页面,所以需要子进程和父进程对于这个共享页面都保持只读。

代码如下:(之前已经对该函数有详细解释,故此处只对修改部分解释)

//在这里进行修改,令子进程和父进程共享一个页面,但是保持二者为只读
int
copy_range(pde_t *to, pde_t *from, uintptr_t start, uintptr_t end, bool share) {
    assert(start % PGSIZE == 0 && end % PGSIZE == 0);
    assert(USER_ACCESS(start, end));
    do {
        //call get_pte to find process A's pte according to the addr start
        pte_t *ptep = get_pte(from, start, 0), *nptep;
        if (ptep == NULL) {
            start = ROUNDDOWN(start + PTSIZE, PTSIZE);
            continue ;
        }
   if (*ptep & PTE_P) {
            if ((nptep = get_pte(to, start, 1)) == NULL) {
                return -E_NO_MEM;
            }
            uint32_t perm = (*ptep & PTE_USER);
            //get page from ptep
            struct Page *page = pte2page(*ptep);
            assert(page!=NULL);
  //修改的地方主要在这里:
            int ret=0;
            //由于之前设置了可分享,故这里将继续执行if语句
            if (share) {	
              	// 完成页面分享
                page_insert(from, page, start, perm & (~PTE_W));
                ret = page_insert(to, page, start, perm & (~PTE_W));
            } else {
                //如果不分享的话 就正常分配页面
                struct Page *npage=alloc_page();
                assert(npage!=NULL);
                uintptr_t src_kvaddr = page2kva(page);
                uintptr_t dst_kvaddr = page2kva(npage);
                memcpy(dst_kvaddr, src_kvaddr, PGSIZE);
                ret = page_insert(to, npage, start, perm);
            }
            assert(ret == 0);
        }
        start += PGSIZE;
    } while (start != 0 && start < end);
    return 0;
}

完成这里的话,已经实现了读的共享,但是并没有对写做处理,因此需要对由于写了只能读的页面导致的页错误进行处理:当程序尝试修改只读的内存页面的时候,将触发Page Fault中断,这时候我们可以检测出是超出权限访问导致的中断,说明进程访问了共享的页面且要进行修改,因此内核此时需要重新为进程分配页面、拷贝页面内容、建立映射关系。

这些步骤主要在于do_pgfault中,在其中我们检测到该错误并做相应处理即可。代码如下:(只有修改部分)

  int do_pgfault(struct mm_struct *mm, uint32_t error_code, uintptr_t addr)
  {
     .....
   
    if (*ptep == 0)
 	{ //如果物理页不存在的话,分配物理页并建立好相关的映射关系
        if (pgdir_alloc_page(mm->pgdir, addr, perm) == NULL) 
        {
            cprintf("pgdir_alloc_page in do_pgfault failed\n");
            goto failed;
        }
    } 
     //通过error_code & 3==3判断得到是COW导致的错误
    else if (error_code & 3 == 3)
    {	
        //因此在这里就需要完成物理页的分配,并实现代码和数据的复制
        //实际上,我们将之前的copy_range过程放在了这里执行,只有必须执行时才执行该过程
        struct Page *page = pte2page(*ptep);
        struct Page *npage = pgdir_alloc_page(mm->pgdir, addr, perm);
        uintptr_t src_kvaddr = page2kva(page);
        uintptr_t dst_kvaddr = page2kva(npage);
        memcpy(dst_kvaddr, src_kvaddr, PGSIZE);
    } 
    else 
    {
		...
   	}
	...
} 
   

由于临近期末,故不在此处写较为复杂的检测程序详细检测

在此处执行make qemu进行初步检测(可以检测出能否正确运行):
Ucore lab5 操作系统实验_第6张图片实际上,如果对之前对pagefault的处理和这个lab的练习1练习2掌握较好的话,该challenge较为容易,因为逻辑关系较为简单,代码量也不大

参考资料

gitbook上相关内容,其中对很多知识点的解释非常详细,认真看收获非常大,对完成实验内容帮助很大

清华大学学堂在线操作系统教学视频,该视频对理论进行了很好、很详细的讲解,并对实验部分进行了大致介绍

CSDN上与进程和线程、进程状态转移、进程基本调度、进程的创建与复制等相关的内容

CSDN上关于COW的相关知识以及Linux如何实现COW的相关知识

操作系统理论课书籍

遇到的一个问题

在完成试验后,执行make grade并没有得150分,而是只得了136分:
Ucore lab5 操作系统实验_第7张图片
之后我检查了grade.sh函数,发现自己的输出与要求输出是一致的,故问题应该出现在别的方面。

在此处我直接执行 make run-forktest 以及make run-forktree ,显示结果为出现assert断言错误。Ucore lab5 操作系统实验_第8张图片
因此可知问题很可能出现在这里。实际上这一句assert语句我并不懂具体是在检测什么的,具体调试forktestforktree,发现在initmain开头时空闲页数目nr_free_pages_store = 31827,但在initmain结尾处调 用nr_free_pages求到的空闲页数目为31825,少了2页。因此导致这个断言产生assert错误的,但是通过对fork和wait函数的检查,未发现具体申请空间时的问题,因此在此处并未解决根源的问题所在。

由于该错误对试验整体影响不大,故在此处将该assert注释掉了,然后可以make grade 150分。后来听说一些同学的proc.c中本身就没有这句assert,所以应该无影响。

若之后有时间,将更加细致的debug,找到缺失两页的问题所在。

如果给你带来了帮助,可点击关注,博主将继续努力推出好文。

你可能感兴趣的:(操作系统实验ucore)