Lab5 report
练习0:填写已有实验
用meld对比修改了以下文件:
kdebug.c
trap.c
default_pmm.c
pmm.c
swap_fifo.c
vmm.c
proc.c
其中需要对trap.c和proc.c中以前实验完成的部分做如下改动:
trap.c
idt_init()函数:
void
idt_init(void) {
......
//SETGATE(idt[T_SWITCH_TOK], 0, GD_KTEXT, __vectors[T_SWITCH_TOK], DPL_USER);
SETGATE(idt[T_SYSCALL], 1, GD_KTEXT, __vectors[T_SYSCALL], DPL_USER); //设置相应的中断门
......
}
trap_dispatch函数:
static void
trap_dispatch(struct trapframe *tf) {
......
if (ticks % TICK_NUM == 0) {
//print_ticks();
assert(current != NULL);
current->need_resched = 1;//主要是将时间片设置为需要调度
}
......
}
proc.c
alloc_proc()函数:
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;
}
do_fork()函数:
int
do_fork(uint32_t clone_flags, uintptr_t stack, struct trapframe *tf) {
......
assert(current->wait_state == 0); //确保当前进程正在等待
......
//nr_process ++;
//list_add(&proc_list, &(proc->list_link));
set_links(proc);//将原来简单的计数改成来执行set_links函数
......
}
练习1: 加载应用程序并执行(需要编码)
实验思路
load_icode()函数可以加载处于内存中的ELF文件。根据注释我们知道,需要完成的是proc_struct结构中tf结构体变量的设置。
因为我们要设置tf以便于从内核态切换到用户态然后执行程序,所以将tf_cs即代码段设置为USER_CS,而将tf->tf_ds、tf->tf_es、tf->tf_ss均设置为USER_DS。
然后将tf->tf_esp、tf->tf_eip和tf->tf_eflags按照注释进行设置。
实验过程
load_icode()函数:
static int
load_icode(unsigned char *binary, size_t size) {
......
tf->tf_cs = USER_CS;
tf->tf_ds = tf->tf_es = tf->tf_ss = USER_DS;
tf->tf_esp = USTACKTOP;
tf->tf_eip = elf->e_entry;
tf->tf_eflags = FL_IF; //打开中断
......
}
思考题
请描述当创建一个用户态进程并加载了应用程序后,CPU是如何让这个应用程序最终在用户态执行起来的。即这个用户态进程被ucore选择占用CPU执行(RUNNING态)到具体执行应用程序第一条指令的整个经过。
在函数load_icode()之后,用户进程的用户环境已经搭建完毕,此时,initproc将按产生系统调用的函数调用路径返回,中断执行iret,将切换到用户进程的第一条语句(根据tf_eip的值)。
练习2: 父进程复制自己的内存空间给子进程(需要编码)
实验思路
这个具体的调用过程是:由do_fork函数调用copy_mm函数,然后copy_mm函数调用dup_mmap函数,最后由这个dup_mmap函数调用copy_range函数。
实现过程
copy_range()函数:
int
copy_range(pde_t *to, pde_t *from, uintptr_t start, uintptr_t end, bool share) {
......
void * kva_src = page2kva(page); //返回父进程的内核虚拟页地址
void * kva_dst = page2kva(npage); //返回子进程的内核虚拟页地址
memcpy(kva_dst, kva_src, PGSIZE); //复制父进程到子进程
ret = page_insert(to, npage, start, perm); //建立子进程页地址起始位置与物理地址的映射关系(prem是权限)
......
}
思考题
简要说明如何设计实现”Copy on Write 机制“,给出概要设计,鼓励给出详细设计。
在copy_range时,并不进行复制,只是让pde_t *to
赋值为pde_t *from
,并将该页的COPY_ON_WRITE位置1(只读),在需要写时会产生缺页错误,此时才将内存中的内容进行复制。
练习3: 阅读分析源代码,理解进程执行 fork/exec/wait/exit 的实现,以及系统调用的实现(不需要编码)
分析
对进程执行 fork/exec/wait/exit 的实现一个一个进行分析:
fork
首先当程序执行fork时,fork使用了系统调用SYS_fork,而系统调用SYS_fork则主要是由do_fork和wakeup_proc来完成的。主要是完成了以下工作:
分配并初始化进程控制块(alloc_proc 函数);
分配并初始化内核栈(setup_stack 函数);
根据clone_flag标志复制或共享进程内存管理结构(copy_mm 函数);
设置进程在内核(将来也包括用户态)正常运行和调度所需的中断帧和执行上下文(copy_thread 函数);
把设置好的进程控制块放入hash_list 和 proc_list 两个全局进程链表中;
自此,进程已经准备好执行了,把进程状态设置为“就绪”态;
设置返回码为子进程的 id 号。
exec
当应用程序执行的时候,会调用SYS_exec系统调用,而当ucore收到此系统调用的时候,则会使用do_execve()函数来实现,因此这里我们主要介绍do_execve()函数的功能,函数主要时完成用户进程的创建工作,同时使用户进程进入执行。 主要完成了以下工作:
首先为加载新的执行码做好用户态内存空间清空准备。如果mm不为NULL,则设置页表为内核空间页表,且进一步判断mm的引用计数减1后是否为0,如果为0,则表明没有进程再需要此进程所占用的内存空间,为此将根据mm中的记录,释放进程所占用户空间内存和进程页表本身所占空间。最后把当前进程的mm内存管理指针为空。
接下来是加载应用程序执行码到当前进程的新创建的用户态虚拟空间中。之后就是调用load_icode从而使之准备好执行.
wait
当执行wait功能的时候,会调用系统调用SYS_wait,而该系统调用的功能则主要由do_wait函数实现,主要工作就是父进程如何完成对子进程的最后回收工作,即:
如果 pid!=0,表示只找一个进程 id 号为 pid 的退出状态的子进程,否则找任意一个处于退出状态的子进程;
如果此子进程的执行状态不为PROC_ZOMBIE,表明此子进程还没有退出,则当前进程设置执行状态为PROC_SLEEPING(睡眠),睡眠原因为WT_CHILD(即等待子进程退出),调用schedule()函数选择新的进程执行,自己睡眠等待,如果被唤醒,则重复跳回步骤 1 处执行;
如果此子进程的执行状态为 PROC_ZOMBIE,表明此子进程处于退出状态,需要当前进程(即子进程的父进程)完成对子进程的最终回收工作,即首先把子进程控制块从两个进程队列proc_list和hash_list中删除,并释放子进程的内核堆栈和进程控制块。自此,子进程才彻底地结束了它的执行过程,它所占用的所有资源均已释放。
exit
当执行exit功能的时候,会调用系统调用SYS_exit,而该系统调用的功能主要是由do_exit函数实现。具体过程如下:
先判断是否是用户进程,如果是,则开始回收此用户进程所占用的用户态虚拟内存空间;(具体的回收过程不作详细说明)
设置当前进程的中hi性状态为PROC_ZOMBIE,然后设置当前进程的退出码为error_code。表明此时这个进程已经无法再被调度了,只能等待父进程来完成最后的回收工作(主要是回收该子进程的内核栈、进程控制块)
如果当前父进程已经处于等待子进程的状态,即父进程的wait_state被置为WT_CHILD,则此时就可以唤醒父进程,让父进程来帮子进程完成最后的资源回收工作。
如果当前进程还有子进程,则需要把这些子进程的父进程指针设置为内核线程init,且各个子进程指针需要插入到init的子进程链表中。如果某个子进程的执行状态是 PROC_ZOMBIE,则需要唤醒 init来完成对此子进程的最后回收工作。
执行schedule()调度函数,选择新的进程执行。
所以说该函数的功能简单的说就是,回收当前进程所占的大部分内存资源,并通知父进程完成最后的回收工作。
罗列目前ucore所有的系统调用如下:
[SYS_exit] sys_exit,
[SYS_fork] sys_fork,
[SYS_wait] sys_wait,
[SYS_exec] sys_exec,
[SYS_yield] sys_yield,
[SYS_kill] sys_kill,
[SYS_getpid] sys_getpid,
[SYS_putc] sys_putc,
[SYS_pgdir] sys_pgdir,
一般来说,用户进程只能执行一般的指令,无法执行特权指令。采用系统调用机制为用户进程提供一个获得操作系统服务的统一接口层,简化用户进程的实现。
应用程序调用的库函数最终都会调用syscall函数,只是调用的参数不同而已。
当应用程序调用系统函数时,一般执行INT T_SYSCALL指令后,CPU 根据操作系统建立的系统调用中断描述符,转入内核态,然后开始了操作系统系统调用的执行过程,在内核函数执行之前,会保留软件执行系统调用前的执行现场,然后保存当前进程的tf结构体中,之后操作系统就可以开始完成具体的系统调用服务,完成服务后,调用IRET返回用户态,并恢复现场。这样整个系统调用就执行完毕了。
思考题
请分析fork/exec/wait/exit在实现中是如何影响进程的执行状态的?
当程序执行fork(),exec(),wait(),exit()函数时,会调用sys_xxxx()函数,这些函数又会调用syscall()函数,syscall()函数嵌入了内联汇编指令,int产生系统调用与中断。
而kernel则会对这些系统调用进行统一的处理。这些函数会产生中断,如果此时current -> need_resched ==1,则会进行调度。
请给出ucore中一个用户态进程的执行状态生命周期图(包执行状态,执行状态之间的变换关系,以及产生变换的事件或函数调用)。
进程创建(fork()函数) -> 进程就绪(proc -> state == RUNNABLE)-> 进程执行(schedule()函数) -> 进程退出(do_exit()) -> 进程结束(do_wait()回收kstack和proc_struct)