实验目的:
1.了解第一个用户进程创建过程
2.了解系统调用框架的实现机制
3.了解ucore如何实现系统调用sys_fork/sys_exec/sys_exit/sys_wait来进行进程管理
练习0 填写已有实验
利用meld软件进行对比,截图如下
找到要修改的地方:
proc.c
default_pmm.c
pmm.c
swap_fifo.c
vmm.c
trap.c
kdebug.c
题目并没有结束,还要将代码进一步改进
idt_init函数
/* LAB5 YOUR CODE */
//you should update your lab1 code (just add ONE or TWO lines of code), let user app to use syscall to get the service of ucore
//so you should setup the syscall interrupt gate in here
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);
改进SETGATE(idt[T_SYSCALL], 1, GD_KTEXT, __vectors[T_SYSCALL], DPL_USER);//设置相应中断门即可
在上述代码中,可以看到在执行加载中断描述符表lidt指令前,专门设置了一个特殊的中断描述符idt[T_SYSCALL],它的特权级设置为DPL_USER,中断向量处理地址在__vectors[T_SYSCALL]处。这样建立好这个中断描述符后,一旦用户进程执行“INT T_SYSCALL”后,由于此中断允许用户态进程产生(注意它的特权级设置为DPL_USER),所以CPU就会从用户态切换到内核态,保存相关寄存器,并跳转到__vectors[T_SYSCALL]处开始执行,形成如下执行路径:
vector128(vectors.S)--\>
\_\_alltraps(trapentry.S)--\>trap(trap.c)--\>trap\_dispatch(trap.c)----\>syscall(syscall.c)-
在syscall中,根据系统调用号来完成不同的系统调用服务。
trip_dispatch函数
/* LAB5 YOUR CODE */
/* you should upate you lab1 code (just add ONE or TWO lines of code):
* Every TICK_NUM cycle, you should set current process's current->need_resched = 1
*/
ticks ++;
if (ticks % TICK_NUM == 0) {
assert(current != NULL);
current->need_resched = 1;//时间片用完设置为需要调度
}
代码改进current->need_resched = 1;//时间片用完设置为需要调度
alloc_proc函数
//LAB5 YOUR CODE : (update LAB4 steps)
/*
* below fields(add in LAB5) in proc_struct need to be initialized
* uint32_t wait_state; // waiting state
* struct proc_struct *cptr, *yptr, *optr; // relations between processes
*/
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;//进程相关指针初始化
代码改进
proc->wait_state = 0; //初始化进程等待状态
proc->cptr = proc->optr = proc->yptr = NULL;//进程相关指针初始化
实验涉及到用户进程,所以会用涉及调度问题,进程相关指针会被初始化
do_fork函数
//LAB5 YOUR CODE : (update LAB4 steps)
/* Some Functions
* set_links: set the relation links of process. ALSO SEE: remove_links: lean the relation links of process
* -------------------
* update step 1: set child proc's parent to current process, make sure current process's wait_state is 0
* update step 5: insert proc_struct into hash_list && proc_list, set the relation links of process
*/
if ((proc = alloc_proc()) == NULL) {
goto fork_out;
}
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);
set_links(proc);//设置进程的相关链接
}
local_intr_restore(intr_flag);
wakeup_proc(proc);
ret = proc->pid;
代码改进
assert(current->wait_state == 0); //确保当前进程为等待进程
set_links(proc);//设置进程的相关链接
练习1:加载应用程序并执行
do_execv函数调用load_icode(位于kern/process/proc.c中)来加载并解析一个处于内存中的ELF执行文件格式的应用程序,建立相应的用户内存空间来放置应用程序的代码段、数据段等,且要设置好proc_struct结构中的成员变量trapframe中的内容,确保在执行此进程后,能够从应用程序设定的起始执行地址开始执行。需设置正确的trapframe内容。
load_icode函数执行流程分析
①调用mm_create函数来申请进程的内存管理数据结构mm所需内存空间,并对mm进行初始化;
②调用setup_pgdir来申请一个页目录表所需的一个页大小的内存空间,并把描述ucore内核虚空间映射的内核页表(boot_pgdir所指)的内容拷贝到此新目录表中,最后让mm->pgdir指向此页目录表,这就是进程新的页目录表了,且能够正确映射内核虚空间;
③根据应用程序执行码的起始位置来解析此ELF格式的执行程序,并调用mm_map函数根据ELF格式的执行程序说明的各个段(代码段、数据段、BSS段等)的起始位置和大小建立对应的vma结构,并把vma插入到mm结构中,从而表明了用户进程的合法用户态虚拟地址空间;
④调用根据执行程序各个段的大小分配物理内存空间,并根据执行程序各个段的起始位置确定虚拟地址,并在页表中建立好物理地址和虚拟地址的映射关系,然后把执行程序各个段的内容拷贝到相应的内核虚拟地址中,至此应用程序执行码和数据已经根据编译时设定地址放置到虚拟内存中了;
⑤需要给用户进程设置用户栈,为此调用mm_mmap函数建立用户栈的vma结构,明确用户栈的位置在用户虚空间的顶端,大小为256个页,即1MB,并分配一定数量的物理内存且建立好栈的虚地址<–>物理地址映射关系;
⑥至此,进程内的内存管理vma和mm数据结构已经建立完成,于是把mm->pgdir赋值到cr3寄存器中,即更新了用户进程的虚拟内存空间,此时的initproc已经被程序的代码和数据覆盖,成为了第一个用户进程,但此时这个用户进程的执行现场还没建立好;
⑦先清空进程的中断帧,再重新设置进程的中断帧,使得在执行中断返回指令“iret”后,能够让CPU转到用户态特权级,并回到用户态内存空间,使用用户态的代码段、数据段和堆栈,且能够跳转到用户进程的第一条指令执行,并确保在用户态能够响应中断;
至此,用户进程的用户环境已经搭建完毕。此时initproc将按产生系统调用的函数调用路径原路返回,执行中断返回指令“iret”(位于trapentry.S的最后一句)后,将切换到用户进程hello的第一条语句位置_start处(位于user/libs/initcode.S的第三句)开始执行。
代码填写
#define KSTACKPAGE 2 // # of pages in kernel stack
#define KSTACKSIZE (KSTACKPAGE * PGSIZE) // sizeof kernel stack
#define USERTOP 0xB0000000
#define USTACKTOP USERTOP
#define USTACKPAGE 256 // # of pages in user stack
#define USTACKSIZE (USTACKPAGE * PGSIZE) // sizeof user stack
#define USERBASE 0x00200000
#define UTEXT 0x00800000 // where user programs generally begin
#define USTAB USERBASE
/* LAB5:EXERCISE1 YOUR CODE
* should set tf_cs,tf_ds,tf_es,tf_ss,tf_esp,tf_eip,tf_eflags
* NOTICE: If we set trapframe correctly, then the user level process can return to USER MODE from kernel. So
* tf_cs should be USER_CS segment (see memlayout.h)
* tf_ds=tf_es=tf_ss should be USER_DS segment
* tf_esp should be the top addr of user stack (USTACKTOP)
* tf_eip should be the entry point of this binary program (elf->e_entry)
* tf_eflags should be set to enable computer to produce Interrupt
*/
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;//FL_IF为中断打开状态
ret = 0;
out:
return ret;
bad_cleanup_mmap:
exit_mmap(mm);
bad_elf_cleanup_pgdir:
put_pgdir(mm);
bad_pgdir_cleanup_mm:
mm_destroy(mm);
bad_mm:
goto out;
}
题目中要求对trapframe,确保在执行此进程后,能够从应用程序设定的起始执行地址开始执行。这里其实一个保存现场的作用,当执行完程序之后能够回到原有的地方。
具体的使用是
在执行trap函数前,软件还需进一步保存执行系统调用前的执行现场,即把与用户进程继续执行所需的相关寄存器等当前内容保存到当前进程的中断帧trapframe中(注意,在创建进程是,把进程的trapframe放在给进程的内核栈分配的空间的顶部)。软件做的工作在vector128和__alltraps的起始部分:
vectors.S::vector128起始处:
pushl $0
pushl $128
......
trapentry.S::__alltraps起始处:
pushl %ds
pushl %es
pushal
……
自此,用于保存用户态的用户进程执行现场的trapframe的内容填写完毕,操作系统可开始完成具体的系统调用服务。在sys_getpid函数中,简单地把当前进程的pid成员变量做为函数返回值就是一个具体的系统调用服务。完成服务后,操作系统按调用关系的路径原路返回到__alltraps中。然后操作系统开始根据当前进程的中断帧内容做恢复执行现场操作。其实就是把trapframe的一部分内容保存到寄存器内容。恢复寄存器内容结束后,调整内核堆栈指针到中断帧的tf_eip处,这是内核栈的结构如下:
/* below here defined by x86 hardware */
uintptr_t tf_eip;
uint16_t tf_cs;
uint16_t tf_padding3;
uint32_t tf_eflags;
/* below here only when crossing rings */
uintptr_t tf_esp;
uint16_t tf_ss;
uint16_t tf_padding4;
这时执行“IRET”指令后,CPU根据内核栈的情况回复到用户态,并把EIP指向tf_eip的值,即“INT T_SYSCALL”后的那条指令。这样整个系统调用就执行完毕了。
堆栈情况
将上述代码结合着下面的堆栈图可以很容易的理解 填写内容
/* *
* 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.
*
* */
练习2: 父进程复制自己的内存空间给子进程
创建子进程的函数do_fork在执行中将拷贝当前进程(即父进程)的用户内存地址空间中的合法内容到新进程中(子进程),完成内存资源的复制。具体是通过copy_range函数(位于kern/mm/pmm.c中)实现的,请补充copy_range的实现,确保能够正确执行。
如题,这个工作的完整由do_fork函数完成,具体是调用copy_range 函数,而这里我们的任务就是补全这个函数。
这个具体的调用过程是由do_fork函数调用copy_mm函数,然后copy_mm函数调用dup_mmap函数,最后由这个dup_mmap函数调用copy_range函数。
即
do_fork()---->copy_mm()---->dup_mmap()---->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是权限)
......
......
}
练习3 阅读分析源代码,理解进程执行fork/exec/wait/exit的实现,以及系统调用的实现
fork
首先当程序执行fork时,fork使用了系统调用SYS_fork,而系统调用SYS_fork则主要是由do_fork和wakeup_proc来完成的。do_fork()完成的工作在lab4的时候已经做过详细介绍,这里再简单说一下,主要是完成了以下工作:
1、分配并初始化进程控制块(alloc_proc 函数);
2、分配并初始化内核栈(setup_stack 函数);
3、根据 clone_flag标志复制或共享进程内存管理结构(copy_mm 函数);
4、设置进程在内核(将来也包括用户态)正常运行和调度所需的中断帧和执行上下文(copy_thread 函数);
5、把设置好的进程控制块放入hash_list 和 proc_list 两个全局进程链表中;
6、自此,进程已经准备好执行了,把进程状态设置为“就绪”态;
7、设置返回码为子进程的 id 号。
而wakeup_proc函数主要是将进程的状态设置为等待,即proc->wait_state = 0,此处不赘述。
exec
当应用程序执行的时候,会调用SYS_exec系统调用,而当ucore收到此系统调用的时候,则会使用do_execve()函数来实现,因此这里我们主要介绍do_execve()函数的功能,函数主要时完成用户进程的创建工作,同时使用户进程进入执行。
主要工作如下:
1、首先为加载新的执行码做好用户态内存空间清空准备。如果mm不为NULL,则设置页表为内核空间页表,且进一步判断mm的引用计数减1后是否为0,如果为0,则表明没有进程再需要此进程所占用的内存空间,为此将根据mm中的记录,释放进程所占用户空间内存和进程页表本身所占空间。最后把当前进程的mm内存管理指针为空。
2、接下来是加载应用程序执行码到当前进程的新创建的用户态虚拟空间中。之后就是调用load_icode从而使之准备好执行。(具体load_icode的功能在练习1已经介绍的很详细了,这里不赘述了)
wait
当执行wait功能的时候,会调用系统调用SYS_wait,而该系统调用的功能则主要由do_wait函数实现,完成对子进程的最后回收工作,即回收子进程的内核栈和进程控制块所占内存空间。
具体的功能实现如下:
1、 如果 pid!=0,表示只找一个进程 id 号为 pid 的退出状态的子进程,否则找任意一个处于退出状态的子进程;
2、 如果此子进程的执行状态不为PROC_ZOMBIE,表明此子进程还没有退出,则当前进程设置执行状态为PROC_SLEEPING(睡眠),睡眠原因为WT_CHILD(即等待子进程退出),调用schedule()函数选择新的进程执行,自己睡眠等待,如果被唤醒,则重复跳回步骤 1 处执行;
3、 如果此子进程的执行状态为 PROC_ZOMBIE,表明此子进程处于退出状态,需要当前进程(即子进程的父进程)完成对子进程的最终回收工作,即首先把子进程控制块从两个进程队列proc_list和hash_list中删除,并释放子进程的内核堆栈和进程控制块。自此,子进程才彻底地结束了它的执行过程,它所占用的所有资源均已释放。
exit
当执行exit功能的时候,会调用系统调用SYS_exit,而该系统调用的功能主要是由do_exit函数实现。具体过程如下:
1、先判断是否是用户进程,如果是,则开始回收此用户进程所占用的用户态虚拟内存空间;(具体的回收过程不作详细说明)
2、设置当前进程的中hi性状态为PROC_ZOMBIE,然后设置当前进程的退出码为error_code。表明此时这个进程已经无法再被调度了,只能等待父进程来完成最后的回收工作(主要是回收该子进程的内核栈、进程控制块)
3、如果当前父进程已经处于等待子进程的状态,即父进程的wait_state被置为WT_CHILD,则此时就可以唤醒父进程,让父进程来帮子进程完成最后的资源回收工作。
4、如果当前进程还有子进程,则需要把这些子进程的父进程指针设置为内核线程init,且各个子进程指针需要插入到init的子进程链表中。如果某个子进程的执行状态是 PROC_ZOMBIE,则需要唤醒 init来完成对此子进程的最后回收工作。
5、执行schedule()调度函数,选择新的进程执行。
所以说该函数的功能简单的说就是,回收当前进程所占的大部分内存资源,并通知父进程完成最后的回收工作。
关于系统调用
ucore所有的系统调用
与用户态的函数库调用执行过程相比,系统调用执行过程的有四点主要的不同:
不是通过“CALL”指令而是通过“INT”指令发起调用;
不是通过“RET”指令,而是通过“IRET”指令完成调用返回;
当到达内核态后,操作系统需要严格检查系统调用传递的参数,确保不破坏整个系统的安全性;
执行系统调用可导致进程等待某事件发生,从而可引起进程切换;
根据之前的分析,应用程序调用的 exit/fork/wait/getpid 等库函数最终都会调用 syscall 函数,只是调用的参数不同而已(分别是 SYS_exit / SYS_fork / SYS_wait / SYS_getid )
当调用系统函数时,一般执行INT T_SYSCALL指令后,CPU 根据操作系统建立的系统调用中断描述符,转入内核态,然后开始了操作系统系统调用的执行过程,在执行之前,会保留系统调用前的执行现场,然后保存当前进程的trapframe中,之后操作系统就可以开始完成具体的系统调用服务,完成服务后,调用IRET,CPU根据内核栈的情况恢复到用户态,并把EIP指向tf_eip的值。这样整个系统调用就执行完毕了。
实验成功
实验心得
通过本次实验,我了解到了用户进程的创建过程,同时了解了系统调用框架的实现机制。知道了系统调用sys_fork/sys_exec/sys_exit/sys_wait
的实现,一开始对系统进程的切换比较模糊,通过实验实践了解到了它的具体实现过程,可以说收货还是很多的。通过跟踪程序流了解了其中的调用顺序和实现机制,如果在试验中能够多一些图解感觉会比较容易理解一点。