当前位置:网站首页>ucore lab5用户进程管理 实验报告

ucore lab5用户进程管理 实验报告

2022-07-06 09:24:00 芜湖韩金轮

实验目的

  • 了解第一个用户进程创建过程
  • 了解系统调用框架的实现机制
  • 了解ucore如何实现系统调用sys_fork/sys_exec/sys_exit/sys_wait来进行进程管理

实验内容

实验4完成了内核线程,但到目前为止,所有的运行都在内核态执行。实验5将创建用户进程,让用户进程在用户态执行,且在需要ucore支持时,可通过系统调用来让ucore提供服务。为此需要构造出第一个用户进程,并通过系统调用sys_fork/sys_exec/sys_exit/sys_wait来支持运行不同的应用程序,完成对用户进程的执行过程的基本管理。相关原理介绍可看附录B。

练习

练习0:填写已有实验

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

实验0主要将之前4次实验的代码补充进来,包括

  • kdebug.c
  • trap.c
  • default_pmm.c
  • pmm.c
  • swap_fifo.c
  • vmm.c
  • proc.c

七个文件的相关代码。除了直接补充外,有一些地方需要对代码进行修改或者改进

建议可以直接复制answer的文件

alloc_proc函数:

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

static struct proc_struct *
alloc_proc(void) {
    
    struct proc_struct *proc = kmalloc(sizeof(struct proc_struct));
    if (proc != NULL) {
    
        // .....
        //以下两句为新加部分
        proc->wait_state = 0;
        proc->cptr = proc->optr = proc->yptr = NULL;
    }
    return proc;
}

这两行代码主要是初始化进程等待状态、和进程的相关指针,例如父进程、子进程、同胞等等。其中的wait_state是进程控制块中新增的条目。

因为这里涉及到了用户进程,自然需要涉及到调度的问题,所以进程等待状态和各种指针需要被初始化。

do_fork函数

添加对当前进程等待状态的检查,以及使用set_links函数来设置进程之间的关系。

添加和删除的代码为:

    proc->parent = current;
    //下面一句为添加的代码
    assert(current->wait_state == 0);
    if (setup_kstack(proc) != 0) {
    
        goto bad_fork_cleanup_proc;
    }
    if (copy_mm(clone_flags, proc) != 0) {
    
        goto bad_fork_cleanup_kstack;
    }
    copy_thread(proc, stack, tf);

    bool intr_flag;
    local_intr_save(intr_flag);
    {
    
        proc->pid = get_pid();
        hash_proc(proc);
        //list_add(&proc_list, &(proc->list_link));
        //nr_process ++;这两句为lab4中要删去的
        //下面一句为要添加的
        set_links(proc);
    }
    local_intr_restore(intr_flag);

trap_dispatch函数:

设置每100次时间中断后,当前正在执行的进程准备被调度。同时,注释掉原来的"100ticks"输出

ticks ++;
        if (ticks % TICK_NUM == 0) {
    
            assert(current != NULL);
            current->need_resched = 1;
            //原代码中的print_ticks();改成上面两句
        }
        break;

idt_init函数

设置一个特定中断号的中断门,专门用于用户进程访问系统调用,即设置中断T_SYSCALL的触发特权级为DPL_USER

oid idt_init(void) {
    
    // ......
    // 新添下面这一句即可
    SETGATE(idt[T_SYSCALL], 1, GD_KTEXT, __vectors[T_SYSCALL], DPL_USER);
    // ......
}

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

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

请在实验报告中简要说明你的设计实现过程。

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

do_execve函数

do_execve函数调用load_icode来加载并解析一个处于内存中的ELF执行文件格式的应用程序

//主要目的在于清理原来进程的内存空间,为新进程执行准备好空间和资源
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);
}

do_execve函数主要做的工作就是先回收自身所占用户空间,然后调用load_icode用新的程序覆盖内存空间,形成一个执行新程序的新进程。

接下来分析一下load_icode函数

load_icode函数

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转到用户态特权级,并回到用户态内存空间,使用用户态的代码段、数据段和堆栈,且能够跳转到用户进程的第一条指令执行,并确保在用户态能够响应中断;

具体实现部分如下:

/* load_icode - 加载二进制程序的内容(ELF格式)作为当前进程的新内容 1. @binary: 二进制程序内容的内存地址 2. @size: 二进制程序内容的大小 */
static int
load_icode(unsigned char *binary, size_t size) {
    
...
     /* tf_cs设置为用户态 tf_ds=tf_es=tf_ss=用户态 tf_esp设置为用户栈的栈顶 tf_eip设置为二进制程序的入口 tf_eflags设置为打开中断 */
    //下面6句为根据注释补充的部分
    tf->tf_cs = USER_CS;//将tf_cs设置为用户态
    tf->tf_ds = tf->tf_es = tf->tf_ss = USER_DS;//tf_ds=tf_es=tf_ss也需要设置为用户态
    tf->tf_esp = USTACKTOP;//需要将esp设置为用户栈的栈顶,直接使用之前建立用户栈时的参数USTACKTOP就可以。
    tf->tf_eip = elf->e_entry;//eip是程序的入口,elf类的e_entry函数直接声明了,直接使用。
    tf->tf_eflags = FL_IF;//FL_IF打开中断
    ret = 0;
...
}

回答问题

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

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

练习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。

我们首先分析父进程调用fork系统调用生成子进程的过程:

  1. 父进程调用fork系统调用,进入正常的中断处理机制,最终交由syscall函数进行处理
  2. syscall函数中,根据系统调用,交由sys_fork函数处理
  3. 该函数进一步调用了do_fork函数,这个函数是主要的创建子进程、并且将父进程的内存空间复制给子进程的逻辑所在
  4. do_fork函数中,调用copy_mm进行内存空间的复制,在该函数中,进一步调用了dup_mmap,在这个函数中,遍历了父进程的所有合法虚拟内存空间,并且将这些空间的内容复制到子进程的内存空间中去,具体进行内存复制的函数就是我们在本次练习中需要完善的copy_range
  5. copy_range函数中,对需要复制的内存空间按照页为单位从父进程的内存空间复制到子进程的内存空间中去

总结就是:copy_range函数的调用过程:do_fork()---->copy_mm()---->dup_mmap()---->copy_range()

(1)do_fork()函数

if (copy_mm(clone_flags, proc) != 0) {
    //3.调用copy_mm()函数复制父进程的内存信息到子进程
        goto bad_fork_cleanup_kstack;

do_fork函数调用的copy_mm函数,创建一个进程,并放入CPU中调度,而本次我们主要关注的是父子进程之间如何拷贝内存。

(2)copy_mm()函数

 lock_mm(oldmm);//打开互斥锁,避免多个进程同时访问内存
    {
    
        ret = dup_mmap(mm, oldmm);//调用dup_mmap函数
    }
    unlock_mm(oldmm);//释放互斥锁

互斥锁,用于避免多个进程同时访问内存,在这里进行了下一层调用:即调用了dup_mmap()函数。

(3)dup_mmap()函数

bool share = 0;
        if (copy_range(to->pgdir, from->pgdir, vma->vm_start, vma->vm_end, share) != 0) {
    //调用copy_range函数
            return -E_NO_MEM;
        }

首先看传入的参数,是两个内存mm,在上一个函数copy_mm中,传入的两个内存叫做mmoldmm,其中,第一个mm只是调用了mm_create()声明,但没有初始化,更没有分配内容;第二个oldmmcurrent进程的内存空间,由此可见,前一个mm是待复制的内存,而复制的源内容在oldmm(父进程)内容中。

(4)实现copy_range函数

copy_range函数的具体执行流程如下:遍历父进程指定的某一段内存空间中的每一个虚拟页,如果这个虚拟页是存在的话,为子进程对应的同一个地址(但是页目录表是不一样的,因此不是一个内存空间)也申请分配一个物理页,然后将前者中的所有内容复制到后者中去,然后为子进程的这个物理页和对应的虚拟地址(事实上是线性地址)建立映射关系;而在本练习中需要完成的内容就是内存的复制和映射的建立,具体流程如下:

  1. 找到父进程指定的某一物理页对应的内核虚拟地址
  2. 找到需要拷贝过去的子进程的对应物理页对应的内核虚拟地址
  3. 将前者的内容拷贝到后者中去
  4. 为子进程当前分配这一物理页映射上对应的在子进程虚拟地址空间里的一个虚拟页

完整代码为:

//将实际的代码段和数据段搬到新的子进程里面去,再设置好页表的相关内容
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;
       //下面四句代码为本练习实现部分
       //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问题在challenge中回答

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

请在实验报告中简要说明你对 fork/exec/wait/exit函数的分析。并回答如下问题:

  • 请分析fork/exec/wait/exit在实现中是如何影响进程的执行状态的?
  • 请给出ucore中一个用户态进程的执行状态生命周期图(包执行状态,执行状态之间的变换关系,以及产生变换的事件或函数调用)。(字符方式画即可)

fork

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

  1. 首先检查当前总进程数目是否到达限制,如果到达限制,那么返回E_NO_FREE_PROC
  2. 分配并初始化进程控制块(alloc_proc 函数)
  3. 分配并初始化内核栈(setup_stack 函数)
  4. 根据 clone_flag标志复制或共享进程内存管理结构(copy_mm 函数)
  5. 设置进程在内核(将来也包括用户态)正常运行和调度所需的中断帧和执行上下文(copy_thread 函数)
  6. 把设置好的进程控制块放入hash_listproc_list 两个全局进程链表中
  7. 自此,进程已经准备好执行了,把进程状态设置为“就绪”态
  8. 设置返回码为子进程的 id号
    int ret = -E_NO_FREE_PROC;//1.首先检查当前总进程数目是否到达限制
if ((proc = alloc_proc()) == NULL) {
    //2.调用alloc_proc()函数申请内存块
        goto fork_out;
    }

    proc->parent = current;//将子进程的父节点设置为当前进程
    assert(current->wait_state == 0);//确保当前进程正在等待
    if (setup_kstack(proc) != 0) {
    //3.调用setup_stack()函数为进程分配一个内核栈
        goto bad_fork_cleanup_proc;
    }
    if (copy_mm(clone_flags, proc) != 0) {
    //4.调用copy_mm()函数复制父进程的内存信息到子进程
        goto bad_fork_cleanup_kstack;
    }
    copy_thread(proc, stack, tf);//5.调用copy_thread()函数复制父进程的中断帧和上下文信息
    //6.将新进程添加到进程的(hash)列表中
    bool intr_flag;
    local_intr_save(intr_flag);//屏蔽中断,intr_flag置为1
    {
    
        proc->pid = get_pid();//获取当前进程PID
        hash_proc(proc); //建立hash映射
        set_links(proc);//设置进程链接
    }
    local_intr_restore(intr_flag);//恢复中断

    wakeup_proc(proc);//7.唤醒新进程

    ret = proc->pid;//8.返回当前进程的PID

exec

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

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

完整的代码在练习1已经实现了

wait

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

  • 首先检查用于保存返回码的code_store指针地址位于合法的范围内
  • 根据PID找到需要等待的子进程PCB,循环询问正在等待的子进程的状态,直到有子进程状态变为ZOMBIE
    • 如果没有需要等待的子进程,那么返回E_BAD_PROC
    • 如果子进程正在可执行状态中,那么将当前进程休眠,在被唤醒后再次尝试
    • 如果子进程正在可执行状态中,那么将当前进程休眠,在被唤醒后再次尝试
if (pid != 0) {
    //如果pid!=0,则找到进程id为pid的处于退出状态的子进程 
        proc = find_proc(pid);
        if (proc != NULL && proc->parent == current) {
    
            haskid = 1;
            if (proc->state == PROC_ZOMBIE) {
    
                goto found;//找到进程
            }
        }
    }
    else {
    //如果pid==0,则随意找一个处于退出状态的子进程
        proc = current->cptr;
        for (; proc != NULL; proc = proc->optr) {
    
            haskid = 1;
//如果子进程不处于僵尸状态,那么会变成睡眠状态,因为需要等待子进程退出,之后调用schedule函数挂起自己,选择其他进程执行。如果为僵尸状态,那么会清除该进程。
            if (proc->state == PROC_ZOMBIE) {
    
                goto found;
            }
        }
    }
    if (haskid) {
    //如果没找到,则父进程重新进入睡眠,并重复寻找的过程
        current->state = PROC_SLEEPING;
        current->wait_state = WT_CHILD;
        schedule();
        if (current->flags & PF_EXITING) {
    
            do_exit(-E_KILLED);
        }
        goto repeat;
    }
    return -E_BAD_PROC

exit

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

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

代码如下:

do_exit(int error_code) {
    
    if (current == idleproc) {
    
        panic("idleproc exit.\n");
    }
    if (current == initproc) {
    
        panic("initproc exit.\n");
    }
    
    struct mm_struct *mm = current->mm;
    if (mm != NULL) {
    //如果该进程是用户进程,准备回收内存,首先它应该不会为空
        lcr3(boot_cr3);//切换到内核态的页表,从用户模式切换到内核模式
        if (mm_count_dec(mm) == 0) {
    
            exit_mmap(mm);

            put_pgdir(mm);//释放页目录占用的内存 
            mm_destroy(mm);//释放mm占用的内存,回收页目录、释放内存
        }
        current->mm = NULL;//最后将它的内存地址指向空,完成内存的回收
    }
    current->state = PROC_ZOMBIE;//设置僵尸状态,等待父进程回收
    current->exit_code = error_code;//等待父进程做最后的回收
    
    bool intr_flag;
    struct proc_struct *proc;
    local_intr_save(intr_flag);
    {
    
        proc = current->parent;
        if (proc->wait_state == WT_CHILD) {
    
            wakeup_proc(proc);//如果父进程在等待子进程,则唤醒
        }
        while (current->cptr != NULL) {
    
/*如果当前进程还有子进程(孤儿进程),则需要把这些子进程的父进程指针设置为内核线程initproc,且各个子进程指针需要插入 到initproc的子进程链表中。如果某个子进程的执行状态是PROC_ZOMBIE,则需要唤醒initproc来完成对此子进程的最后回收工作。*/
            proc = current->cptr;
            current->cptr = proc->optr;
    
            proc->yptr = NULL;
            if ((proc->optr = initproc->cptr) != NULL) {
    
                initproc->cptr->yptr = proc;
            }
            proc->parent = initproc;
            initproc->cptr = proc;
            if (proc->state == PROC_ZOMBIE) {
    
                if (initproc->wait_state == WT_CHILD) {
    
                    wakeup_proc(initproc);
                }
            }
        }
    }
    local_intr_restore(intr_flag);
    
    schedule();//选择新的进程执行
    panic("do_exit will not return!! %d.\n", current->pid);
}

syscall系统调用

关于系统调用的定义主要在syscall.c中,在这里定义了许多系统调用函数,包括sys_exitsys_forksys_waitsys_exec等。syscall是内核程序为用户程序提供内核服务的一种方式。

在中断处理例程中,程序会根据中断号,执行syscall函数(注意该syscall函数为内核代码,非用户库中的syscall函数)。内核syscall函数会一一取出六个寄存器的值,并根据系统调用号来执行不同的系统调用。而那些系统调用的实质就是其他内核函数的wrapper。以下为syscall函数实现的代码:

COPYvoid syscall(void) {
    
    struct trapframe *tf = current->tf;
    uint32_t arg[5];
    int num = tf->tf_regs.reg_eax;
    if (num >= 0 && num < NUM_SYSCALLS) {
    
        if (syscalls[num] != NULL) {
    
            arg[0] = tf->tf_regs.reg_edx;
            arg[1] = tf->tf_regs.reg_ecx;
            arg[2] = tf->tf_regs.reg_ebx;
            arg[3] = tf->tf_regs.reg_edi;
            arg[4] = tf->tf_regs.reg_esi;
            tf->tf_regs.reg_eax = syscalls[num](arg);
            return ;
        }
    }
    print_trapframe(tf);
    panic("undefined syscall %d, pid = %d, name = %s.\n",
            num, current->pid, current->name);
}

等相应的内核函数结束后,程序通过之前保留的trapframe返回用户态。一次系统调用结束。

回答问题

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

在这里插入图片描述
需要注意的是,从RUNNABLERUNNING时,进程被proc_run函数作为参数调用,从RUNNINGRUNABLE时,是进程主动调用proc_run函数

至此练习完成,运行make qemu查看运行结果:
在这里插入图片描述
运行make grade查看成绩
在这里插入图片描述

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

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

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

由于COW实现比较复杂,容易引入bug,请参考 https://dirtycow.ninja/ 看看能否在ucore的COW实现中模拟这个错误和解决方案。需要有解释。

这是一个big challenge.

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

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

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

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

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

首先,将vmm.c中的dup_mmap函数中队share变量的设置进行修改,因为dup_mmap函数中会调用copy_range函数,copy_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) {
    	
              	// 如果share=1,完成页面分享
                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查看运行结果
在这里插入图片描述
这里有另一种COW实现

实验总结

本次实验主要涉及进程的一些进程的知识,比如创建,管理,切换到用户态进程的具体实现;加载ELF可执行文件的具体实现;对系统调用机制的具体实现。并学习到通过系统调用sys_fork/sys_exec/sys_exit/sys_wait来支持运行不同的应用程序,完成对用户进程的执行过程的基本管理。在学习中,也有不理解的地方。对虚拟内存空间不是非常理解,不太懂代码中tf->tf_cs = USER_CS;等相关参数的构造,并且针对于中断的相关知识掌握的不到位。以后会加大对中断方面的学习理解,回顾试验中不理解的地方。

理论课老师就讲过进程管理是操作系统很重要的一个知识点,也特别提到了进程五状态,这一点也在实验中有体现。几个练习也基本上有注释提示,不是很复杂,但工作量也不小(这应该是我写过最长的实验报告了)。challenge实现的COW,在Linux上也有体现所以相关资料很多,讲的十分详细,对challenge的实现有很大帮助,而且challenge也有注释提示。

做完这次实验,对进程管理有了更深的认识,同时对理论课上学到的知识有了更进一步的理解,还能查漏补缺,发现自己遗忘或者没学好的知识点。

原网站

版权声明
本文为[芜湖韩金轮]所创,转载请带上原文链接,感谢
https://blog.csdn.net/qq_51684393/article/details/124916009