操作系统实验五:用户进程管理
(建议配合实验源码看,超详细)
一、 实验目的
- 了解第一个用户进程创建过程
- 了解系统调用框架的实现机制
- 了解ucore如何实现系统调用sys_fork/sys_exec/sys_exit/sys_wait来进行进程管理
二、 实验任务
实验4完成了内核线程,但到目前为止,所有的运行都在内核态执行。
实验5将创建用户进程,让用户进程在用户态执行,且在需要ucore支持时,可通过系统调用来让ucore提供服务。
为此需要构造出第一个用户进程,并通过系统调用sys_fork/sys_exec/sys_exit/sys_wait来支持运行不同的应用程序,完成对用户进程的执行过程的基本管理。相关原理介绍可看附录B。
三、 实验准备
在实验指导书的练习0中提到了注意点如下:
注意:为了能够正确执行lab5的测试应用程序,可能需对已完成的实验1/2/3/4的代码进行进一步改进。
根据试验要求,我们需要对部分代码进行改进,这里需要改进的地方的代码和说明如下:
①在初始化 IDT 的时候,设置系统调用对应的中断描述符,使其能够在用户态下被调用,并且设置为 trap
类型。(事实上这个部分已经在LAB1的实验中顺手被完成了) ②在时钟中断的处理部分,每过 TICK_NUM
个中断,就将当前的进程设置为可以被重新调度的,这样使得当前的线程可以被换出,从而实现多个线程的并发执行; ③在 do_fork 函数中,使用
set_links 函数来完成将 fork 的线程添加到线程链表中的过程,值得注意的是,该函数中就包括了对进程总数加 1
这—操作,因此需要将原先的这个操作给删除掉;
因此需要进行修改的代码如下:
1.alloc_proc() 函数
在原来的实验基础上,新增了 2 行代码:
proc->wait_state = 0;//PCB 进程控制块中新增的条目,初始化进程等待状态
proc->cptr = proc->optr = proc->yptr = NULL;//进程相关指针初始化
这两行代码主要是初始化进程等待状态、和进程的相关指针,例如父进程、子进程、同胞等等。新增的 几个 proc 指针给出相关的解释如下:
parent: proc->parent (proc is children)
children: proc->cptr (proc is parent)
older sibling: proc->optr (proc is younger sibling)
younger sibling: proc->yptr (proc is older sibling)
因为这里涉及到了用户进程,自然需要涉及到调度的间题,所以进程等待状态和各种指针需要被初始 化。
所以改进后alloc_proc函数如下:
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;//PCB 进程控制块中新增的条目,初始化进程等待状态
proc->cptr = proc->optr = proc->yptr = NULL;//进程相关指针初始化
}
return proc;
}
2.do_fork() 函数
在原来的实验基础上,新增 2 行代码:
assert(current->wait_state == 0); //确保当前进程正在等待
set_links(proc); //将原来简单的计数改成来执行 set_links 函数,从而实现设置进程的相关链接
第—行是为了确定当前的进程正在等待,我们在 alloc_proc 中初始化 wait_state 为0。第二行是将原
的计数换成了执行—个set_link函数,因为要涉及到进程的调度,所以简单的计数是不行的。
我们可以看看 set_links 函数:
static void set_links(struct proc_struct *proc) {
list_add(&proc_list,&(proc->list_link));//进程加入进程链表proc->yptr = NULL; //当前进程的 younger sibling 为空
if ((proc->optr = proc->parent->cptr) != NULL) {
proc->optr->yptr = proc; //当前进程的 older sibling 为当前进程
}
proc->parent->cptr = proc; //父进程的子进程为当前进程nr_process ++; //进程数加—
}
可以看出,set_links 函数的作用是设置当前进程的 process relations,这里要注意是屏蔽中断。
所以改进后的dofork函数添加部分如下:
int do_fork(uint32_t clone_flags, uintptr_t stack, struct trapframe *tf)
{ ...
local_intr_save(intr_flag); //屏蔽中断,intr_flag 置为 1
{
proc->pid = get_pid(); //获取当前进程 PID hash_proc(proc); //建立 hash 映射
set_links(proc);//将原来简单的计数改成来执行set_links函数,从而实现设置进程的相关链接
}
local_intr_restore(intr_flag); //恢复中断
...
}
3.idt_init() 函数
在原来的实验基础上,新增了 1 行代码:
SETGATE(idt[T_SYSCALL], 1, GD_KTEXT, vectors[T_SYSCALL], DPL_USER);//这里主要是设置相应的中断门
所以改进后的idt_init如下:
void idt_init(void) {
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);
}
设置—个特定中断号的中断门,专门用千用户进程访间系统调用。在上述代码中,可以看到在执行加载中断描述符表 lidt 指令前,专门设置了—个特殊的中断描述符 idt[T_SYSCALL],它的特权级设置为DPL_USER,中断向量处理地址在 vectors[T_SYSCALL]处。这样建立好这个中断描述符后,一旦用户进程执行 INT T_SYSCALL 后,由于此中断允许用户态进程产生(它的特权级设置为 DPL_USER),所以CPU就会从用户态切换到内核态,保存相关寄存器,并跳转到 vectors[T_SYSCALL]执行,所以 CPU 就会从用户态切换到内核态形成如下执行路:
vector128(vectors.S)--\>
\_\_alltraps(trapentry.S)--\>trap(trap.c)--\>trap\_dispatch(trap.c)---->syscall(syscall.c)-
在syscall中,根据系统调用号来完成不同的系统调用服务。
4.trap_dispatch() 函数
我们在原来的实验基础上,新增了 1 行代码:
current->need_resched = 1;//时间片用完设置为需要调度
这里主要是将时间片设置为需要调度,说明当前进程的时间片已经用完了。
改进后的trap_dispatch()如下:
ticks ++;
if (ticks % TICK_NUM == 0) {
assert(current != NULL);
current->need_resched = 1;//时间片用完设置为需要调度
}
至此,前面的实验欠缺的部分就补充完整了,接下来进入实验5的填充部分。
四、 实验步骤
(一) 练习0:填写已有实验
本实验依赖实验1/2/3/4。请把你做的实验1/2/3/4的代码填入本实验中代码中有“LAB1”/“LAB2”/“LAB3”/“LAB4”的注释相应部分。
lab5依赖于前面做过的四个实验,这里需要将前面的实验中填过的代码复制到lab5对应的位置中,这里因为积累了太多的代码补齐,所以我学习了meld的使用方法。这里直接将lab4与lab5的文件夹导入到meld中接着直接比较即可。
然后软件就会自动分析两份代码的不同,然后就—个个比较比较复制过去就行了,在软件里面是可以支持对比复制了,点击copy right即可。需要修改的地方主要有以下七个文件,通过对比复制完成即可:
kdebug.c、trap.c、default_pmm.c、pmm.c、swap_fifo.c、vmm.c、proc.c。
(二) 练习1: 加载应用程序并执行
do_execv函数调用load_icode(位于kern/process/proc.c中)来加载并解析一个处于内存中的ELF执行文件格式的应用程序,建立相应的用户内存空间来放置应用程序的代码段、数据段等,且要设置好proc_struct结构中的成员变量trapframe中的内容,确保在执行此进程后,能够从应用程序设定的起始执行地址开始执行。需设置正确的trapframe内容。请在实验报告中简要说明你的设计实现过程。
1.设计分析
根据实验说明书,我们需要完善的函数是 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
寄存器中,即更新了用户进程的虚拟内存空间,此时的 init 已经被 exit
的代码和数据覆盖,成为了第—个用户进程,但此时这个用户进程的执行现场还没建立好;
⑦先清空进程的中断帧,再重新设置进程的中断帧,使得在执行中断返回指令 iret
后,能够让cpu转到用户态特权级,并回到用户态内存空间,使用用户态的代码段、数据段和堆栈,且能够跳转到用户进程的第—条指令执行,并确保在用户态能够晌应中断;
简单的说,该 load_icode 函数的主要工作就是给用户进程建立—个能够让用户进程正常运行的用户环境。
我们可以观察 do_execve 函数:
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);
//为加载新的执行码做好用户态内存空间清空准备
if (mm != NULL) {
lcr3(boot_cr3); //设置页表为内核空间页表
if (mm_count_dec(mm) == 0) { //如果没有进程再需要此进程所占用的内存空间
exit_mmap(mm); //释放进程所占用户空间内存和进程页表本身所占空间
put_pgdir(mm);
mm_destroy(mm);
}
current->mm = NULL; //把当前进程的 mm 内存管理指针为空
}
int ret;
// 加载应用程序执行码到当前进程的新创建的用户态虚拟空间中。这里涉及到读 ELF 格式的文件, 申请内存空间,建立用户态虚存空间,加载应用程序执行码等。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,用新的程序覆盖内存空间,形成—个执行新程序的新进程
。
至此,用户进程的用户环境已经搭建完毕。此时 initproc 将按产生系统调用的函数调用路径原路返回,执行中断返回指令 iret 后,将切换到用户进程程序的第—条语句位置_start处开始执行。
2.设计实现
2.1. load_icode 函数分析:
**该函数的功能主要分为 6 个部分,而我们需要填写的是第 6 个部分,就是伪造中断返回现场,使得系统调用返回之后可以正确跳转到需要运行的程序入口,并正常运行;**而 1-5 部分则是—系列对用户内存空间的初始化;与 LAB1 的challenge 类似的,第 6个部分是在进行中断处理的栈(此时应当是内核栈)上伪造—个中断返回现场,使得中断返回的时候可以正确地切换到需要的执行程序入口处;在这个部分中需要对 tf 进行设置, 不妨通过代码分析来确定这个 tf 变量究竟指到什么位置,该 tf 变量与 current->tf 的数值—致, 而 current->tf 是在进行中断服务里程的 trap 函数中被设置为当前中断的中断帧,也就是说这个 tf 最终指向了当前系统调用 exec 产生的中断帧处;
2.2. 注释分析
/* load_icode - load the content of binary program(ELF format) as the new content of current process*/
static int load_icode(unsigned char *binary, size_t size) {
... //省略前面的1-5部分
//(6) setup trapframe for user environment
/* 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
*/
进程切换总是在内核态中发生,当内核选择—个进程执行的时候,首先切换内核态的上下文(EBX、ECX、
EDX、ESI、EDI、ESP、EBP、EIP 八个寄存器)以及内核栈。完成内核态切换之后,内核需要使用
IRET 指令将 trapframe 中的用户态上下文恢复出来,返回到进程态,在用户态中执行进程。
实现思路:
- 由于最终是在用户态下运行的,所以需要将段寄存器初始化为用户态的代码段、数据段、堆栈段;
- esp 应当指向先前的步骤中创建的用户栈的栈顶;
- eip 应当指向 ELF 可执行文件加载到内存之后的入口处;
- eflags 中应当初始化为中断使能,注意 eflags 的第 1 位是恒为 1 的;
- 设置 ret 为 0,表示正常返回;
2.3. 代码实现
load_icode 函数需要填写的部分为:
- 将 trapframe 的代码段设为 USER_CS;
- 将 trapframe 的数据段、附加段、堆栈段设为 USER_DS;
- 将 trapframe 的栈顶指针设为 USTACKTOP;
- 将 trapframe 的代码段指针设为 ELF 的入口地址 elf->e_entry;
- 将 trapframe 中 EFLAGS 的 IF 置为 1。
/*code*/
// 代码段
tf->tf_cs = USER_CS;
// 数据段、栈段、附加数据段
tf->tf_ds = tf->tf_ss = tf->tf_es = USER_DS;
// 设置用户堆栈值
tf->tf_esp = (uintptr_t)USTACKTOP;
// 用户函数入口
tf->tf_eip = (uintptr_t)elf->e_entry;
// 设置允许中断
tf->tf_eflags |= FL_IF;
...
}
3.问题简答
分析在创建了用户态进程并且加载了应用程序之后,其占用 CPU 执行到具体执行应用程序的整个经过:
①在经过调度器占用了 CPU 的资源之后,用户态进程调用了 exec 系统调用,从而转入到了系统调用的处理例程;
②在经过了正常的中断处理例程之后,最终控制权转移到了 syscall.c 中的 syscall 函数,然后根据系统调用号转移给了 sys_exec 函数,在该函数中调用了上文中提及的 do_execve 函数来完成指定应用程序的加载;
③在do_execve中进行了若干设置,包括推出当前进程的页表,换用 kernel 的 PDT 之后,使用load_icode 函数,完成了对整个用户线程内存空间的初始化,包括堆栈的设置以及将 ELF 可执行文件的加载,之后通过 current->tf 指针修改了当前系统调用的 trapframe,使得最终中断返回的时候能够切换到用户态,并且同时可以正确地将控制权转移到应用程序的入口处;
④在完成了 do_exec 函数之后,进行正常的中断返回的流程,由千中断处理例程的栈上面的 eip 已经被修改成了应用程序的入口处,而 CS 上的 CPL 是用户态,因此 iret 进行中断返回的时候会将堆栈切换到用户的栈,并且完成特权级的切换,并且跳转到要求的应用程序的入口处;
⑤接下来开始具体执行应用程序的第—条指令;
(三) 练习2: 父进程复制自己的内存空间给子进程
创建子进程的函数do_fork在执行中将拷贝当前进程(即父进程)的用户内存地址空间中的合法内容到新进程中(子进程),完成内存资源的复制。具体是通过copy_range函数(位于kern/mm/pmm.c中)实现的,请补充copy_range的实现,确保能够正确执行。
1.函数调用分析
这个具体的调用过程是由 do_fork 函数调用 copy_mm 函数,然后 copy_mm 函数调用 dup_mmap 函数,最后由这个 dup_mmap 函数调用 copy_range 函数。
即
do_fork()---->copy_mm()---->dup_mmap()---->copy_range()
由于 do_fork 函数中调用了 copy_mm 函数,这部分是我们在 lab4 中未实现的部分,这部分的内容就是将父进程的内存空间复制给子进程,其中创建mm的部分在copy_mm中,而复制部分是依靠dup_mmap实现的,我们可以看看这部分函数是如何实现的:
/*
file_path:kern/mm/vmm.c
*/
int dup_mmap(struct mm_struct *to, struct mm_struct *from) {
assert(to != NULL && from != NULL); //必须非空
// mmap_list 为虚拟地址空间的首地址
list_entry_t *list = &(from->mmap_list), *le = list;
while ((le = list_prev(le)) != list) { //遍历所有段
struct vma_struct *vma, *nvma;
vma = le2vma(le, list_link); //获取某—段
nvma = vma_create(vma->vm_start, vma->vm_end, vma->vm_flags);
if (nvma == NULL) {
return -E_NO_MEM;
}
insert_vma_struct(to, nvma); //向新进程插入新创建的段
bool share = 0;
//调用 copy_range 函数
if (copy_range(to->pgdir, from->pgdir, vma->vm_start, vma->vm_end, share) != 0) {
return -E_NO_MEM;
}
}
return 0;
}
可以看到它是通过遍历父进程的所有合法段,创建子进程的新的段之后调用copy_range函数来实现复制的;copy_range这部分函数就是我们要实现的。
2.代码实现
2.1. 实现思路:
copy_range 函数的具体执行流程是遍历父进程指定的某—段内存空间中的每—个虚拟页,如果这个虚拟页是存在的话,为子进程对应的同—个地址(但是页目录表是不—样的,因此不是—个内存空间)也申请分配—个物理页,然后将前者中的所有内容复制到后者中去,然后为子进程的这个物理页和对应的虚拟地址
(事实上是线性地址)建立映射关系;而在本练习中需要完成的内容就是内存的复制和映射的建立,具体流程如下:
- 找到父进程指定的某—物理页对应的内核虚拟地址;
- 找到需要拷贝过去的子进程的对应物理页对应的内核虚拟地址;
- 将前者的内容拷贝到后者中去; 为子进程当前分配这—物理页映射上对应的在子进程虚拟地址空间里的—个虚拟页;
2.2. 代码实现
/*code*/
void * kva_src = page2kva(page); // 找到父进程需要复制的物理页在内核地址空间中的虚拟地址,这是由千这个函数执行的时候使用的时内核的地址空间
void * kva_dst = page2kva(npage); // 找到子进程需要被填充的物理页的内核虚拟地
址
memcpy(kva_dst, kva_src, PGSIZE); // 将父进程的物理页的内容复制到子进程中去映射关系
ret = page_insert(to, npage, start, perm); // 建立子进程的物理页与虚拟页的
assert(ret == 0);
3.问题简答
请在实验报告中简要说明如何设计实现 ”Copy on Write 机制“,给出概要设计,鼓励给出详细设计。
Copy-on-write(简称COW)的基本概念是指如果有多个使用者对一个资源A(比如内存块)进行读操作,则每个使用者只需获得一个指向同一个资源A的指针,就可以该资源了。若某使用者需要对这个资源A进行写操作,系统会对该资源进行拷贝操作,从而使得该“写操作”使用者获得一个该资源A的“私有”拷贝—资源B,可对资源B进行写操作。该“写操作”使用者对资源B的改变对于其他的使用者而言是不可见的,因为其他使用者看到的还是资源A。
3.1. 概要设计
“Copy on Write” 机制的主要思想为使得进程执行 fork 系统调用进行复制的时候,父进程不会简单地将整个内存中的内容复制给子进程,而是暂时共享相同的物理内存页;而当其中—个进程需要对内存进行修改的时候,再额外创建—个自己私有的物理内存页,将共享的内容复制过去,然后在自己的内存页中进行修改;
3.2. 详细设计
根据上述分析,主要对实验框架的修改应当主要有两个部分,—个部分在进行 fork 操作的时候不直接复制内存,另外—个处理在出现了内存页访间异常的时候,会将共享的内存页复制—份,然后在新的内存页进行修改,具体的修改部分如下:
-
do fork 部分:在进行内存复制的部分,比如 copy_range 函数内部,不实际进行内存的复制,而是将子进程和父进程的虚拟页映射上同—个物理页面,然后在分别在这两个进程的虚拟页对应的PTE 部分将这个页置成是不可写的,同时利用 PTE 中的保留位将这个页设置成共享的页面,这样的话如果应用程序试图写某—个共享页就会产生页访间异常,从而可以将控制权交给操作系统进行处理;
-
page fault 部分:在 page fault 的 ISR 部分,新增加对当前的异常是否由千尝试写了某—个共享页面引起的,如果是的话,额外申请分配—个物理页面,然后将当前的共享页的内容复制过去,建立 出错的线性地址与新创建的物理页面的映射关系,将 PTE 设置设置成非共享的;然后查询原先共享的物理页面是否还是由多个其它进程共享使用的,如果不是的话,就将对应的虚地址的 PTE 进行修改,删掉共享标记,恢复写标记;这样的话 page fault 返回之后就可以正常完成对虚拟内存(原想的共享内存)的写操作了;
上述实现有—个较小的缺陷,在千在 do fork 的时候需要修改所有的 PTE,会有—定的时间效率上的损失;可以考虑将共享的标记加在 PDE 上,然后—旦访问了这个 PDE 之后再将标记下传给对应的 PTE, 这样的话就起到了标记延迟和潜在的标记合并的左右,有利千提升时间效率。
(四) 练习3: 阅读分析源代码,理解进程fork/exec/wait/exit 的实现,以及系统调用的实现
• 请分析fork/exec/wait/exit在实现中是如何影响进程的执行状态的?
• 请给出ucore中一个用户态进程的执行状态生命周期图(包执行状态,执行状态之间的变换关系,以及产生变换的事件或函数调用)。(字符方式画即可)
1.系统调用分析
—般来说,用户进程只能执行—般的指令,无法执行特权指令。采用系统调用机制为用户进程提供—个获得操作系统服务的统—接口层,简化用户进程的实现。
根据之前的分析,应用程序调用的 exit/fork/wait/getpid 等库函数最终都会调用 syscall 函数,只是调用的参数不同而已(分别是 SYS_exit / SYS_fork / SYS_wait / SYS_getid )
当应用程序调用系统函数时,—般执行 INT T_SYSCALL 指令后,CPU 根据操作系统建立的系统调用中断描述符,转入内核态,然后开始了操作系统系统调用的执行过程,在内核函数执行之前,会保留软件 执行系统调用前的执行现场,然后保存当前进程的 tf 结构体中,之后操作系统就可以开始完成具体的系统调用服务,完成服务后,调用 IRET 返回用户态,并恢复现场。这样整个系统调用就执行完毕了。
接下来对 fork/exec/wait/exit 四个系统调用进行分析:
1.1. fork
调用过程为:fork-> SYS_fork-> do_fork+wakeup_proc
首先当程序执行 fork 时,fork 使用了系统调用 SYS_fork,而系统调用 SYS_fork 则主要是由 do_fork 和
wakeup_proc 来完成的。do_fork() 完成的工作在练习 2 及 lab4 中已经做过详细介绍,这里再简单说
—下,主要是完成了以下工作:
①分配并初始化进程控制块( alloc_proc 函数); ②分配并初始化内核栈,为内核进程(线程)建立栈空间( setup_stack
函数); ③根据 clone_flag 标志复制或共享进程内存管理结构( copy_mm 函数);
④设置进程在内核(将来也包括用户态)正常运行和调度所需的中断帧和执行上下文 ( copy_thread 函数); ⑤为进程分配—个 PID(
get_pid() 函数); ⑥把设置好的进程控制块放入 hash_list 和 proc_list 两个全局进程链表中;
⑦自此,进程已经准备好执行了,把进程状态设置为“就绪”态; ⑧设置返回码为子进程的 PID 号。
而 wakeup_proc 函数主要是将进程的状态设置为等待,即 proc->wait_state = 0。
1.2. exec
调用过程为:
SYS_exec->do_execve
当应用程序执行的时候,会调用 SYS_exec 系统调用,而当 ucore 收到此系统调用的时候,则会使用
do_execve() 函数来实现,因此这里我们主要介绍 do_execve() 函数的功能,函数主要时完成用户进程的创建工作,同时使用户进程进入执行。
主要工作如下:
①首先为加载新的执行码做好用户态内存空间清空准备。如果 mm 不为 NULL,则设置页表为内核空间页表,且进—步判断 mm 的引用计数减 1
后是否为 0,如果为 0,则表明没有进程再需要此进程所占用的内存空间,为此将根据 mm
中的记录,释放进程所占用户空间内存和进程页表本身所占空间。最后把当前进程的 mm 内存管理指针为空。
②接下来是加载应用程序执行码到当前进程的新创建的用户态虚拟空间中。之后就是调用 load_icode 从而使之准备好执行。(具体
load_icode 的功能在练习 1 已经介绍的很详细了,这里不赘述了)
1.3. wait
调用过程为:
SYS_wait->do_wait
do_wait 函数的实现过程:
当执行 wait 功能的时候,会调用系统调用 SYS_wait,而该系统调用的功能则主要由 do_wait 函数实现,主要工作就是父进程如何完成对子进程的最后回收工作,具体的功能实现如下:
①如果 pid!=0,表示只找—个进程 id 号为 pid 的退出状态的子进程,否则找任意—个处千退出状态的子进程; ②
如果此子进程的执行状态不为 PROC_ZOMBIE,表明此子进程还没有退出,则当前进程设置执行状态为
PROC_SLEEPING(睡眠),睡眠原因为 WT_CHILD (即等待子进程退出),调用 schedule()
函数选择新的进程执行,自己睡眠等待,如果被唤醒,则重复跳回步骤 1 处执行; ③ 如果此子进程的执行状态为
PROC_ZOMBIE,表明此子进程处千退出状态,需要当前进程(即子进程的父进程)完成对子进程的最终回收工作,即首先把子进程控制块从两个进程队列
proc_list 和 hash_list
中删除,并释放子进程的内核堆栈和进程控制块。自此,子进程才彻底地结束了它的执行过程,它所占用的所有资源均已释放。
1.4. exit
调用过程为:
SYS_exit->exit
do_exit 函数的实现过程:
当执行 exit 功能的时候,会调用系统调用 SYS_exit,而该系统调用的功能主要是由 do_exit 函数实现。具体过程如下:
①先判断是否是用户进程,如果是,则开始回收此用户进程所占用的用户态虚拟内存空间;(具体 的回收过程不作详细说明)
②设置当前进程的中hi性状态为 PROC_ZOMBIE,然后设置当前进程的退出码为
error_code。表明此时这个进程已经无法再被调度了,只能等待父进程来完成最后的回收工作(主要是回收该子进 程的内核栈、进程控制块)
③如果当前父进程已经处千等待子进程的状态,即父进程的 wait_state 被置为
WT_CHILD,则此时就可以唤醒父进程,让父进程来帮子进程完成最后的资源回收工作。
④如果当前进程还有子进程,则需要把这些子进程的父进程指针设置为内核线程 init,且各个子进程指针需要插入到 init
的子进程链表中。如果某个子进程的执行状态是 PROC_ZOMBIE,则需要唤醒 init 来完成对此子进程的最后回收工作。 ⑤执行
schedule() 调度函数,选择新的进程执行。
所以说该函数的功能简单的说就是,回收当前进程所占的大部分内存资源,并通知父进程完成最后的回收工作。
2. 问题回答
2.1. 请分析fork/exec/wait/exit在实现中是如何影响进程的执行状态的?
- fork 执行完毕后,如果创建新进程成功,则出现两个进程,—个是子进程,—个是父进程。在子进程中,fork 函数返回 0,在父进程中,fork 返回新创建子进程的进程 ID。我们可以通过 fork 返回的值来判断当前进程是子进程还是父进程。fork 不会影晌当前进程的执行状态,但是会将子进程的状态标记为RUNNALB,使得可以在后续的调度中运行起来;
- exec 完成用户进程的创建工作。首先为加载新的执行码做好用户态内存空间清空准备。接下来的
- —步是加载应用程序执行码到当前进程的新创建的用户态虚拟空间中。exec 不会影晌当前进程的执行状态,但是会修改当前进程中执行的程序;
- wait 是等待任意子进程的结束通知。wait_pid 函数等待进程 id 号为 pid 的子进程结束通知。这两个函数最终访间 sys_wait 系统调用接口让 ucore 来完成对子进程的最后回收工作。wait 系统调用取决千是否存在可以释放资源(ZOMBIE)的子进程,如果有的话不会发生状态的改变,如果没有 的话会将当前进程置为 SLEEPING 态,等待执行了 exit 的子进程将其唤醒;
- exit 会把—个退出码 error_code 传递给 ucore,ucore 通过执行内核函数 do_exit 来完成对当前进程的退出处理,主要工作简单地说就是回收当前进程所占的大部分内存资源,并通知父进程完成 最后的回收工作。exit 会将当前进程的状态修改为 ZOMBIE 态,并且会将父进程唤醒(修改为RUNNABLE),然后主动让出 CPU 使用权;
2.2. 请给出ucore中一个用户态进程的执行状态生命周期图
根据上述分析过程,我们可以画出执行状态图如下所示:
3.运行结果
在一开始的时候通过make grade进行检查发现总是会有错误:
导致无法得到全部OK,刚开始怀疑是自己的代码的问题,后来发现大家都有这样的问题,那就应该是ucore出了bug;
经过检查判断,只有init_main函数也就是init内核线程的主函数有输出’init check memory pass’这一句话;
因此初步判定是这个函数中的某些函数有bug,没有输出这段字符串说明它前面的assert成立了一个,这里初步判定是kallocated函数出错(其它的似乎都没有什么错误,这个函数又很少用到);
将这一行断言注释掉得到:
再次make grade得到:
依然未解决问题,那么说明问题出在上面的断言中,最后尝试了注释掉前一句nr_free_pages():
make grade结果如下:
说明是这一句nr_free_pages出了问题!
那么到底是什么问题呢?
在pmm_manager中nr_free_pages其实就是
返回空闲页的数目
,那么这里断言为假说明经过生成用户进程与回收所有用户进程后它并没有回收完所有的物理页(产生了没回收的僵死进程),出现错误,具体的地方暂时没有找到是哪里没有回收,可以确定的是ucore代码稍微有些问题。
lab5完成!
(五) 扩展练习 Challenge :实现 Copy on Write 机制
其实在上面的练习2的问题简述中已经很详细的描述了相关设计,下面代码实现这些设计:
1. 设置共享标志
在 vmm.c 中将 dup_mmap 中的 share 变量的值改为 1,启用共享:
int dup_mmap(struct mm_struct *to, struct mm_struct *from) {
...
bool share = 1;
...
}
2. 映射共享页面
在 pmm.c 中为 copy_range 添加对共享的处理,如果 share 为 1,那么将子进程的页面映射到父进程的页面。由千两个进程共享—个页面之后,无论任何—个进程修改页面,都会影晌另外—个页面,所以 需要子进程和父进程对千这个共享页面都保持只读。
int copy_range(pde_t *to, pde_t *from, uintptr_t start, uintptr_t end, bool share) {
...
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(share) {
//共享标志打开
// share page
page_insert(from, page, start, perm & (~PTE_W));
ret = page_insert(to, page, start, perm & (~PTE_W));
} else {
// alloc a page for process B 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);
3 修改时拷贝
当程序尝试修改只读的内存页面的时候,将触发Page Fault中断,在错误代码中 P=1,W/R=1[OSDev]。因此,当错误代码最低两位都为 1 的时候,说明进程访间了共享的页面,内核需要重新分配页面、拷页面内容、建立映射关系:
int do_pgfault(struct mm_struct *mm, uint32_t error_code, uintptr_t addr) {
...
if (*ptep == 0) {
...
} else if (error_code & 3 == 3) {
// copy on write
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 {
...
}
...
}
五、 实验总结
本次实验较上一个实验要更加难一些,因为上个实验仅仅是在内核态进行两个内核线程的构建运行操作,但是本次实验扩展到了用户态的相关操作,当然如果仔细学习了lab5中相关的代码的实现的话还是可以理解的。
此外我的challenge部分实现中有—个缺陷,在 do fork 的时候需要修改所有的 PTE,会有—定的时间效率上的损失;可以考虑将共享的标记加在 PDE 上,然后访问了这个 PDE 之后再将标记下传给对应的 PTE, 这样的话就起到了标记延迟和潜在的标记合并的作用,有利于提升时间效率。
实验内容不多但却涉及到了很多知识点,尤其是用户进程进行系统调用这一部分,将fork、exec、wait、exit都涉及到了,加深了我对它们的理解。