实验4完成了内核线程,但到目前为止,所有的运行都在内核态执行。实验5将创建用户进程,让用户进程在用户态执行,且在需要ucore支持时,可通过系统调用来让ucore提供服务。为此需要构造出第一个用户进程,并通过系统调用sys_fork/sys_exec/sys_exit/sys_wait来支持运行不同的应用程序,完成对用户进程的执行过程的基本管理。
首先利用meld软件进行对比,把lab1~4的内容复制过来,这里不再多余赘述。
但是为了能够正确执行lab5的测试应用程序,可能需对已完成的实验1/2/3/4的代码进行进一步改进。
下面是对部分代码的改进。
改进alloc_proc函数:
在原来的基础上,新增了2行代码。
这两行代码主要是初始化进程等待状态、和进程的相关指针,例如父进程、子进程、同胞等等。其中的wait_state是进程控制块中新增的条目。
因为这里涉及到了用户进程,自然需要涉及到调度的问题,所以进程等待状态和各种指针需要被初始化。
改进do_fork函数:
插入assert是为了确保进程在等待,set_links是设置进程链接。
assert需要确保当前进程正在等待,我们在alloc_proc中初始化wait_state为0。
set_links函数的作用就是设置当前进程的process relations。
改进 idt_init 函数:
改进trap_dispatch函数:
这里主要是将时间片设置为需要调度,说明当前进程的时间片已经用完了。
do_execv函数调用load_icode(位于kern/process/proc.c中)来加载并解析一个处于内存中的ELF执行文件格式的应用程序,建立相应的用户内存空间来放置应用程序的代码段、数据段等,且要设置好proc_struct结构中的成员变量trapframe中的内容,确保在执行此进程后,能够从应用程序设定的起始执行地址开始执行。需设置正确的trapframe内容。
根据proc.c中的代码注释我们很容易写出需要补完的代码
每一行代码对应的内容如下:
切换至用户态代码段
es,es,ss,全部设置至用户态的数据段
堆顶指针重新设置
指向程序的入口地址
重新设置标志位。允许中断
设计实现过程:主要是考虑到中断帧的指针作用,当进程从内核空间切换到用户空间时,需要恢复之前保存的各个寄存器的状态,因此,在这里,只需要依次将代码段寄存器,数据段寄存器等待恢复即可。最后需要注意的是,程序的入口地址也需要重新设置,同时,应该打开中断。
对于这个问题,完整阅读proc.c文件以及实验指导书中对于创建并且执行用户进程的部分,我们可以得到具体执行的经过:
do_execve函数部分执行用户进程的创建工作。
接下来是由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已经被hello的代码和数据覆盖,成为了第一个用户进程,但此时这个用户进程的执行现场还没建立好;
先清空进程的中断帧,再重新设置进程的中断帧,使得在执行中断返回指令“iret”后,能够让CPU转到用户态特权级,并回到用户态内存空间,使用用户态的代码段、数据段和堆栈,且能够跳转到用户进程的第一条指令执行,并确保在用户态能够响应中断;
至此,用户进程的用户环境已经搭建完毕。此时initproc将按产生系统调用的函数调用路径原路返回,执行中断返回指令“iret”(位于trapentry.S的最后一句)后,将切换到用户进程的第一条语句位置_start处开始执行。
创建子进程的函数do_fork在执行中将拷贝当前进程(即父进程)的用户内存地址空间中的合法内容到新进程中(子进程),完成内存资源的复制。具体是通过copy_range函数(位于kern/mm/pmm.c中)实现的,请补充copy_range的实现,确保能够正确执行。
根据提示,补全代码如下:
1.找到父进程的内核虚拟页地址
2.找到子进程的内核虚拟地址
3.将父进程的内容复制到子进程上
4.建立物理地址和子进程页地址的起始位置间的映射
5.保证函数返回值正常
答:在创建子进程时,将父进程的PDE直接赋值给子进程的PDE,但是需要将允许写入的标志位置0;当子进程需要进行写操作时,再次出发中断调用do_pgfault(),此时应给子进程新建PTE,并取代原先PDE中的项,然后才能写入。
fork调用过程:
fork->SYS_fork->do_fork+wakeup_proc
do_fork函数,主要工作:
1、分配并初始化进程控制块(alloc_proc 函数);
2、分配并初始化内核栈(setup_stack 函数);
3、根据 clone_flag标志复制或共享进程内存管理结构(copy_mm 函数);
4、设置进程在内核(将来也包括用户态)正常运行和调度所需的中断帧和执行上下文(copy_thread 函数);
5、把设置好的进程控制块放入hash_list 和 proc_list 两个全局进程链表中;
6、自此,进程已经准备好执行了,把进程状态设置为“就绪”态;
7、设置返回码为子进程的 id 号。
wakeup_proc函数主要是将进程的状态设置为等待。
exec:
调用过程:
SYS_exec->do_execve
do_execve函数的主要工作:
1、首先为加载新的执行码做好用户态内存空间清空准备。如果mm不为NULL,则设置页表为内核空间页表,且进一步判断mm的引用计数减1后是否为0,如果为0,则表明没有进程再需要此进程所占用的内存空间,为此将根据mm中的记录,释放进程所占用户空间内存和进程页表本身所占空间。最后把当前进程的mm内存管理指针为空。
2、接下来是加载应用程序执行码到当前进程的新创建的用户态虚拟空间中。之后就是调用load_icode从而使之准备好执行。
wait调用过程:
SYS_wait->do_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调用过程:
SYS_exit->exit
do_exit的主要工作:
1、先判断是否是用户进程,如果是,则开始回收此用户进程所占用的用户态虚拟内存空间;(具体的回收过程不作详细说明)
2、设置当前进程的中hi性状态为PROC_ZOMBIE,然后设置当前进程的退出码为error_code。表明此时这个进程已经无法再被调度了,只能等待父进程来完成最后的回收工作(主要是回收该子进程的内核栈、进程控制块)
3、如果当前父进程已经处于等待子进程的状态,即父进程的wait_state被置为WT_CHILD,则此时就可以唤醒父进程,让父进程来帮子进程完成最后的资源回收工作。
4、如果当前进程还有子进程,则需要把这些子进程的父进程指针设置为内核线程init,且各个子进程指针需要插入到init的子进程链表中。如果某个子进程的执行状态是 PROC_ZOMBIE,则需要唤醒 init来完成对此子进程的最后回收工作。
5、执行schedule()调度函数,选择新的进程执行。
fork:执行完毕后,如果创建新进程成功,则出现两个进程,一个是子进程,一个是父进程。在子进程中,fork函数返回0,在父进程中,fork返回新创建子进程的进程ID。我们可以通过fork返回的值来判断当前进程是子进程还是父进程
exit:会把一个退出码error_code传递给ucore,ucore通过执行内核函数do_exit来完成对当前进程的退出处理,主要工作简单地说就是回收当前进程所占的大部分内存资源,并通知父进程完成最后的回收工作。
execve:完成用户进程的创建工作。首先为加载新的执行码做好用户态内存空间清空准备。接下来的一步是加载应用程序执行码到当前进程的新创建的用户态虚拟空间中。
wait:等待任意子进程的结束通知。wait_pid函数等待进程id号为pid的子进程结束通知。这两个函数最终访问sys_wait系统调用接口让ucore来完成对子进程的最后回收工作
执行make grade后结果如下:
通过本次实验,我了解到了用户进程的创建过程,同时了解了系统调用框架的实现机制。知道了系统调用sys_fork/sys_exec/sys_exit/sys_wait的实现,一开始对系统进程的切换比较模糊,通过实验实践了解到了它的具体实现过程,可以说收货还是很多的。通过跟踪程序流了解了其中的调用顺序和实现机制,如果在试验中能够多一些图解感觉会比较容易理解一点。