实验五 用户进程管理-实验部分

目录

一、知识点

1.Unix进程创建API

1.1.创建新进程API

1.2.fork()地址空间的复制

1.4.shell中调用fork()图示

1.5.fork()创建进程个数示例

1.6.fork()与exec()的比较

2.实验执行流程概述

3.创建用户进程

3.1.应用程序的组成和编译

3.2.用户进程的虚拟地址空间

3.3.创建并执行用户进程

4.进程的退出和等待进程

4.1.回收此用户进程所占用的用户态虚拟内存空间

4.2.父进程做最后的回收工作

4.3.唤醒initproc做回收工作

4.4.执行schedule()函数,选择新的进程执行

5.系统调用实现

5.1.初始化系统调用对应的中断描述符

5.2.建立系统调用的用户库准备

5.3.与用户进程相关的系统调用

5.4.系统调用的执行过程

二、练习解答

1.实验0

2.实验1

3.实验2

4.练习3


一、知识点

本章节介绍实验五涉及的关键知识点。


1.Unix进程创建API

1.1.创建新进程API

在Unix中,采用fork/exec创建进程。其中fork()把一个进程复制成两个进程,parent(old PID),child(new PID)。其中exec()用新的程序来重写当前的进程,PID不变。

以下是一个在用fork和exec创建进程的示例代码。在这个代码中,fork创建一个继承的子进程。这个子进程会复制父进程的所有变量和内存,它也会复制父进程的所有CPU寄存器(有一个寄存器除外)。

int pid = fork();		// 创建子进程
if(pid == 0) {			// 子进程在这里继续
     // Do anything (unmap memory, close net connections…)
	exec(“program”, argc, argv0, argv1, …);
}

在上面的程序中,fork()的返回值并不同,对于子进程fork()返回0,对于父进程fork()返回子进程标识符。fork()返回值可方便后续的使用。


1.2.fork()地址空间的复制

fork()执行过程对于子进程而言,是在调用时对父进程地址空间的一次复制。对于父进程fork()返回child PID,对于子进程返回0。以下是这个过程的图形解释。


1.3.exec()加载新进程

系统调用exec()加载新程序取代当前运行进程。

void main(){
…
    int pid = fork();			// 创建子进程
    if (pid == 0) {			// 子进程在这里继续
        exec_status = exec(“calc”, argc, argv0, argv1, …);
        printf(“Why would I execute?”);
    }  else {				// 父进程在这里继续
        printf(“Whose your daddy?”);
        …
        child_status = wait(pid);
    }
    if (pid < 0) 
    { /* error occurred */}
}

1.4.shell中调用fork()图示

以下是用户态看到的用户程序和内核态看到的进程控制块。

以下是用户态看到的进程堆栈和内核态看到的进程控制块。


1.5.fork()创建进程个数示例

以下的代码是利用fork()创建进程,请分析当i=2时,请问一共有几个子进程?

int  main()
{
     pid_t  pid;
      int  i;
      for  (i=0;  i<LOOP;  i++)
      {
           /* fork  another  process  */
           pid = fork();
           if  (pid < 0) { /*error  occurred  */
                fprintf(stderr, “Fork Failed”);
                exit(-1);
           }
           else if (pid == 0) { /* child process */
                fprintf(stdout,  “i=%d,  pid=%d,  parent  pid=%d\n”,I,      
                             getpid() ,getppid());
           }   
      }
      wait(NULL);
      exit(0);
      return 0;
} 

一共7个子进程,分析过程如下。


1.6.fork()与exec()的比较

fork()的实现开销,需要对子进程分配内存,复制父进程的内存和CPU寄存器到子进程里,这样的开销十分昂贵。在99%的情况下,我们调用fork()之后,调用exec()。在fork()操作中内存复制时没有用的,子进程将可能关闭打开的文件和链接。

exec()允许进程“加载”一个完全不同的程序,并从main开始执行。允许进程加载时指定启动参数(argc,argv)。在exec中,代码段、堆栈和堆等完全重写。

2.实验执行流程概述

由于进程的执行空间扩展到了用户态空间,且出现了创建子进程执行应用程序等与lab4有较大不同的地方,所以具体实现的不同主要集中在进程管理和内存管理部分。

在内存管理部分,与lab4最大的区别是增加了用户态虚拟内存的管理。为了管理用户态的虚拟内存,需要对页表的内容进行扩展,能够把部分的物理内存映射成用户虚拟内存。如果某进程执行的过程中,CPU在用户态下执行,则可以访问本进程页表描述的用户态虚拟内存(如下代码的cr3),由于权限不够,不能访问内核态虚拟内存。

// proc_run - make process "proc" running on cpu
// NOTE: before call switch_to, should load  base addr of "proc"'s new PDT
void
proc_run(struct proc_struct *proc) {
    if (proc != current) {
        bool intr_flag;
        struct proc_struct *prev = current, *next = proc;
        local_intr_save(intr_flag);
        {
            current = proc;
            load_esp0(next->kstack + KSTACKSIZE);
            lcr3(next->cr3);//访问进程的页表,采用的是lcr3命令
            switch_to(&(prev->context), &(next->context));
        }
        local_intr_restore(intr_flag);
    }
}

另一方面,不同的进程有各自的页表,所以即使不同进程的用户态虚拟地址相同,但由于页表把虚拟页映射到了不同的物理页帧,所以不同进程的虚拟地址内存空间是被隔离开的,相互之间无法访问。用户态内存空间和内核态空间之间需要拷贝数据,让CPU处于内核态才能完成对用户空间的读或写,为此需要设计专门的拷贝函数(copy_from_user和copy_to_user)完成。但反之则会导致CPU的权限管理,导致内存访问异常。

在进程管理方面,主要是进程控制块与内存管理的部分,包括建立进程的页表和维护进程的可访问的空间信息(建立虚实映射关系);加载一个ELF格式的程序到进程控制块管理的内存中;在fork()过程中,把父进程的内存空间拷贝到子进程内存空间的技术。

当实现了上述内存管理和进程管理的需求后,接下来ucore的用户进程管理工作比较简单了。首先,“硬”构造出第一个进程(lab4中有描述),它是所有进程的祖先;然后,在proc_init函数中,通过alloc把当前ucore的执行环境转变成idle内核线程的执行现场;然后调用kernel_thread来创建第二个内核线程init_main,而init_main内核线程有创建了user_main线程。到此,内核线程创建完毕,应该开始用户进程的创建过程,这第一步实际上是通过user_main函数调用kernel_thread创建子进程,通过kernel_execve调用来把某一具体程序的执行内容放在内存。具体的放置方式是根据ld在此文件上的地址分配为基本原则,把程序的不同部分放到某进程的用户空间中,而通过此进程来完成程序描述的任务。一旦执行了这一程序对应的进程,就会从内核态切换到用户态继续执行。依次类推CPU在用户空间执行用户进程,其地址空间不会被其他的用户进程影响,但由于系统调用(用户进程直接获取操作系统服务的唯一通道)、外设中断和异常中断会随时产生,从而间接推动了用户态到内核态的切换工作。

3.创建用户进程

在实验四中,我们已经完成了对内核线程的创建,但与用户进程的创建过程相比,创建内核线程的过程还远远不够。而这两个创建过程的差异本质上就是用户进程和内核线程的差异决定的。


3.1.应用程序的组成和编译

我们首先来看一个应用程序,这里我们假定是hello应用程序,在user/hello.c中实现,代码如下:

#include <stdio.h>
#include <ulib.h>
int main(void) {
	cprintf("Hello world!!.\n");
	cprintf("I am process %d.\n", getpid());
	cprintf("hello pass.\n");
	return 0;
}

首先,我们需要了解ucore操作系统如何能够找到hello应用程序。这需要分析ucore和hello是如何编译的。修改Makefile,把第六行注释掉。然后在本实验原码目录下执行make,可以得到如下的输出:

……

+ cc user/hello.c

gcc -Iuser/ -fno-builtin -Wall -ggdb -m32 -gstabs -nostdinc -fno-stack-protector -Ili

bs/ -Iuser/include/ -Iuser/libs/ -c user/hello.c -o obj/user/hello.o

ld -m elf_i386 -nostdlib -T tools/user.ld -o obj/__user_hello.out obj/user/libs/in

itcode.o obj/user/libs/panic.o obj/user/libs/stdio.o obj/user/libs/syscall.o obj/user/

libs/ulib.o obj/user/libs/umain.o obj/libs/hash.o obj/libs/printfmt.o obj/libs/rand.o

obj/libs/string.o obj/user/hello.o

……

ld -m elf_i386 -nostdlib -T tools/kernel.ld -o bin/kernel obj/kern/init/entry.o ob

j/kern/init/init.o …… -b binary …… obj/__user_hello.out

……

从中可以看出,hello应用程序不仅仅是hello.c,还包含了支持hello应用程序的用户态库:

user/libs/initcode.S:所有的应用程序的起始用户态执行地址“_start”,调整了EBP和ESP后,调用umain函数。

user/libs/umain.c:实现了umain函数,这是所有应用程序执行的第一个C函数,它将调用应用程序的main函数,并在main函数结束后调用exit函数,而exit函数最终将调用sys_exit系统调用,让操作系统回收进程资源。

user/libx/ulib.c:实现了最小的C函数库,除了一些与系统调用无关的函数,其他的函数是对访问系统调用的包装。

user/libx/syscall.c:用户层发出系统调用的具体实现。

user/libs/staio.c:实现cprintf函数,通过系统调用sys_puts来完成字符输出。

user/libs/panic.c:实现__panic/__warn函数,通过系统调用sys_exit完成用户进程的退出。

除了这些用户态库函数实现外,还有一些libs/*.[ch]是操作系统内核和应用程序共同的函数实现。这些用户库函数其实在本质上与UNIX系统中的标准libc没有区别,只是实现得很简单,但hello应用程序的正确执行离不开这些库函数。

【注意】libs/*.[ch]、user/libs/*.[ch]、user/*.[ch]的源码中没有任何特权指令。

在make的最后一步执行了一个ld命令,把hello应用程序的执行码obj/__user_hello.out连接在了ucore kernel的尾部。且ld命令会在kernel中会把__user_hello.out的位置和大小记录在全局变量_binary_obj___user_hello_out_start和_binary_obj___user_hello_out_size中,这样这个hello用户程序就能够和ucore内核一起被bootloader加载到内存中,并且通过这两个全局变量定位hello用户执行码的开始位置和大小。而到了与文件系统相关的实验后,ucore会提供一个简单的文件系统,那时候所有的用户程序都不用这种方式加载了,而可以使用大家熟悉的文件方式加载了。

在这里有个疑问,_binary_obj___user_hello_out_start\_binary_obj___user_hello_out_size是extern变量,只有声明没有定义,那么他们的初值是从哪里获得的?


3.2.用户进程的虚拟地址空间

在tools/user.ld描述了用户程序的用户虚拟空间的执行入口虚拟地址:

SECTIONS {

/* Load programs at this address: "." means the current address */

. = 0x800020;

在tools/kernel.ld描述了操作系统的内核虚拟空间的起始入口虚拟地址:

SECTIONS {

/* Load the kernel at this address: "." means the current address */

. = 0xC0100000;

这样ucore把用户进程的虚拟地址空间分成了两块,一块与内核线程一样,是所有用户进程都共享的内核虚拟地址空间,映射到同样的物理内存内存空间中,这样在物理内存中只需放置一份内核代码,使得用户进程从用户态进入核心态时,内核代码可以统一应对不同的内核程序;另外一块是用户虚拟地址空间,虽然虚拟地址范围一样,但映射到不同且没有交集的物理内存空间中。这样当ucore把用户进程的执行代码(即应用程序的执行代码)和数据(应用程序的全局变量等)放到用户虚拟地址空间中时,确保了各个进程不会“非法”访问到其他进程的物理内存空间。

这样,ucore给一个用户进程具体设定的虚拟内存空间(kern/mm/mmlayout.h)如下所示:

有关内存虚拟地址布局的内容,更多细节见《关于Lab5用户进程管理内存布局解析》,有关ucore调试的过程,更多的细节见《QEMU环境调试方法》。


3.3.创建并执行用户进程

在确定了用户进程的执行代码和数据,以及用户进程的虚拟空间布局后,我们可以来创建用户进程了。在本实验中第一个用户进程是由第二个内核线程initproc通过把hello应用程序执行码覆盖到initproc的用户虚拟内存空间来创建的,相关代码如下所示:

// kernel_execve - do SYS_exec syscall to exec a user program called by user_main kernel_thread
static int
kernel_execve(const char *name, unsigned char *binary, size_t size) {
    int ret, len = strlen(name);
    asm volatile (
        "int %1;"
        : "=a" (ret)
        : "i" (T_SYSCALL), "0" (SYS_exec), "d" (name), "c" (len), "b" (binary), "D" (size)
        : "memory");
    return ret;
}

#define __KERNEL_EXECVE(name, binary, size) ({                          \
            cprintf("kernel_execve: pid = %d, name = \"%s\".\n",        \
                    current->pid, name);                                \
            kernel_execve(name, binary, (size_t)(size));                \
        })

#define KERNEL_EXECVE(x) ({                                             \
            extern unsigned char _binary_obj___user_##x##_out_start[],  \
                _binary_obj___user_##x##_out_size[];                    \
            __KERNEL_EXECVE(#x, _binary_obj___user_##x##_out_start,     \
                            _binary_obj___user_##x##_out_size);         \
        })

#define __KERNEL_EXECVE2(x, xstart, xsize) ({                           \
            extern unsigned char xstart[], xsize[];                     \
            __KERNEL_EXECVE(#x, xstart, (size_t)xsize);                 \
        })

#define KERNEL_EXECVE2(x, xstart, xsize)        __KERNEL_EXECVE2(x, xstart, xsize)

// user_main - kernel thread used to exec a user program
static int
user_main(void *arg) {
#ifdef TEST
    KERNEL_EXECVE2(TEST, TESTSTART, TESTSIZE);
#else
    KERNEL_EXECVE(exit);
#endif
    panic("user_main execve failed.\n");
}

对于上述代码,我们需要从后向前按照函数、宏的实现一个一个分析。initproc的执行主体是init_mian函数,这个函数在缺省情况下是执行宏KERNEL_EXECV(hello),而这个宏最终是调用kernel_execve函数来调用SYS_execv系统调用,由于ld在连接hello应用程序执行码时定义了两个全局变量:

  • _binary_obj___user_hello_out_start: hello执行码的起始位置
  • _binary_obj___user_hello_out_size中: hello执行码的大小

kernel_execve把这两个变量作为SYS_exec系统调用的参数,让ucore来创建此用户进程。当ucore收到此系统调用后,将依次调用如下函数

vector128(vectors.S)--\>

\_\_alltraps(trapentry.S)--\>trap(trap.c)--\>trap\_dispatch(trap.c)--

--\>syscall(syscall.c)--\>sys\_exec( syscall.c) --\>do\_execve(proc.c)

最终通过do_execve函数来完成用户进程的创建工作。此函数的主要工作流程如下:

  • 首先为加载新的执行码做好用户态内存空间清空准备。如果mm不是NULL,则设置页表为内核空间页表,且进一步判断mm的引用计数减1后是否为0。如果为0,则表明没有进程再需要此进程占用的内存空间,为此将根据mm中的记录,释放进程所占用用户空间内存和进程页表本身所占用的内存空间。最后把当前进程的mm内存管理指针设为空。由于此处的initproc是内核线程,所以mm为NULL,整个处理都不会做。
  • 接下来的一步是加载应用程序执行码到当前进程的新创建的用户态虚拟空间中。这里涉及读取ELF格式的文件,申请内存空间,建立用户态虚拟空间,加载应用程序执行码等。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转到用户态特权级,并回到用户态内存空间,使用用户态的代码段、数据段和堆栈,且能够跳转到用户进程的第一条指令执行,并确保在用户态能够响应中断;

至此,用户进程的用户环境已经搭建完毕。此时initproc将按照产生系统调用的函数调用路径原路返回,执行中断返回指令“iret”(位于trapentry.S的最后一句)后,将切换到用户进程hello的第一条语句位置_start处(位于user/libs/initcode.S的第三句)开始执行。

4.进程的退出和等待进程

当进程执行完它的工作后,就需要执行退出的操作,释放进程占用的资源。ucore分了两个步骤完成这个工作,首先由进程本身完成大部分资源的占用内存回收工作,然后由此进程的父进程完成剩余资源占用内存的回收工作。为何不让进程本身完成所有的资源回收工作呢?这是因为进程要执行回收操作,就表明此进程还存在,还在执行指令,这就需要内核栈的空间不能释放,且表示进程存在的进程控制块不能释放。所以需要父进程来帮忙释放子进程无法完成的这两个资源回收工作。

为此在用户态的函数库中提供了exit函数,此函数最终访问sys_exit系统调用接口让操作系统来帮助当前进程执行退出过程中的部分资源回收。我们来看看ucore是如何做进程退出工作的。

首先,exit函数会把一个退出码error_code传递给ucore,ucore通过执行内核函数do_exit来完成对当前进程的退出处理,主要工作简单的说就是回收当前进程所占的大部分内存资源,并通知父进程完成最后的回收工作,具体流程如下:

proc.c
int
do_exit(int error_code){
...
    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);
        }
        current->mm = NULL;
    }
    current->state = PROC_ZOMBIE;
current->exit_code = error_code;
...
}
4.1.回收此用户进程所占用的用户态虚拟内存空间

如果current->mm!=NULL,表示是用户进程,则开始回收此用户进程所占用的用户态虚拟内存空间;
a)首先执行“lcr3(boot_cr3)”,切换到内核态的页表上,这样当前用户进程目前只能在内核虚拟地址空间执行了,这是为了确保后续释放用户态内存和进程页表的工作能够正常执行;
b)如果当前进程控制块的成员变量mm的成员变量mm_count减1后是0(表明这个mm没有再被其他进程共享,可以彻底释放进程所占的用户虚拟空间了),则开始回收用户进程所占的内存资源。

i)调用exit_mmap函数释放current->mm->vma链表中每个vma描述的进程合法空间中实际分配的内存,然后把对应的页表项内容清空,最后还把页表所占用的空间释放并把对应的页目录表项清空;

pmm.c/unmap_range()-->page_remove_pte()
static inline void
page_remove_pte(pde_t *pgdir, uintptr_t la, pte_t *ptep) {
	//(1) check if ptep is valid
	if(*ptep&PTE_P)
	{
		struct Page *page = NULL;
		//(2) find corresponding page
		page = pte2page(*ptep);
		//(3) decrease page reference
		if(page_ref_dec(page)==0)
		{
			//(4) and free this page when page reference reachs 0
			free_page(page);//释放vma描述的进程合法空间中实际分配的内存
		}
		//(5) clear second page table entry
		*ptep = 0;//清空vma描述的进程对应的页表项内容
		//(6) flush tlb,TLB must is the same as page table
		tlb_invalidate(pgdir,la);
	}
}
pmm.c/exit_range()-->page_remove_pte()
void
exit_range(){
...
    start = ROUNDDOWN(start, PTSIZE);
    do {
        int pde_idx = PDX(start);
        if (pgdir[pde_idx] & PTE_P) {
            free_page(pde2page(pgdir[pde_idx]));//把页表所占用的空间释放
            pgdir[pde_idx] = 0;//把页表所占用的空间对应的页目录表项清空
        }
        start += PTSIZE;
    } while (start != 0 && start < end);
}

ii)调用put_pgdir函数释放当前进程的页目录所占用的内存;
iii)调用mm_destory函数释放mm中的vma;

c)此时设置current->mm为NULL,表明与当前进程相关的用户虚拟内存空间和对应的内存管理成员变量所占的内存虚拟内存空间已经回收完毕;

4.2.父进程做最后的回收工作

这时,设置当前进程的执行状态current->state=PROC_ZOMBIE,当前进程的退出码current->exit_code=error_code。此时当前进程已经不能被调度了,需要此进程的父进程来做最后的回收工作(即回收描述此进程的内核栈和进程控制块);
如果当前进程的父进程current->parent处于等待子进程状态:current->parent->wait_state==WT_CHILD,则唤醒父进程(即执行“wakeup_proc(current->parent)”),让父亲进程帮忙自己完成最后资源的回收。

proc.c/do_wait()
int
do_wait(int pid, int *code_store){
...
repeat:
    haskid = 0;
    if (pid != 0) {
        proc = find_proc(pid);
        if (proc != NULL && proc->parent == current) {
            haskid = 1;
            if (proc->state == PROC_ZOMBIE) {
                goto found;
            }
        }
    }
    else {
        proc = current->cptr;
        for (; proc != NULL; proc = proc->optr) {
            haskid = 1;//找到孩子
            if (proc->state == PROC_ZOMBIE) {
                goto found;//孩子进程已经是僵尸进程,正在等父亲进程回收
            }
        }
    }
found:
    if (proc == idleproc || proc == initproc) {
        panic("wait idleproc or initproc.\n");
    }
    if (code_store != NULL) {
        *code_store = proc->exit_code;
    }
    local_intr_save(intr_flag);
    {
        unhash_proc(proc);//从进程队列中移除子进程的进程控制块
        remove_links(proc);
    }
    local_intr_restore(intr_flag);
    put_kstack(proc);//释放该子进程的内核栈
    kfree(proc);//释放该子进程的进程控制块
}
4.3.唤醒initproc做回收工作

如果当前进程还有子进程,则需要把这些子进程的父进程指针设置成内核线程initproc,且各个子进程指针需要插入到initproc的子进程链表中。如果某个子进程的执行状态是PROC_ZOMBIE,则需要唤醒initproc来完成对此进程的最后回收工作。


4.4.执行schedule()函数,选择新的进程执行

那么父进程如何完成对子进程的最后回收工作呢?这要求父进程要执行wait用户函数或wait_pid用户函数,这两个函数的区别是,wait函数等待任意子进程的结束通知,而wait_pid函数等待进程id为pid的子进程结束通知。这两个函数最终访问sys_wait系统调用接口让ucore来完成对子进程的最后回收工作,即回收子进程的内核栈和进程控制块所占内存空间,具体的流程如下:

1)如果pid!=0,表示只找到一个进程id号是pid的退出状态的子进程,否则找任意一个处于退出状态的子进程;

2)如果此子进程的执行状态不是PROC_ZOMBIE,表明此进程还没有退出,则当前进程只好设置自己的执行状态为PROC_SLEEPING,睡眠的原因是WT_CHILD(即等待子进程退出),调用schedule()函数选择新的进程执行,自己睡眠等待,如果被唤醒,则重复调回步骤1)处执行。

3)如果此进程的执行状态是PROC_ZOMBIE,则表明此进程处于退出状态,需要当前进程(即子进程的父进程)完成对子进程的最终回收工作,即首先把子进程控制块从两个进程队列proc_list和hash_list中删除,并释放子进程的内核堆栈和进程控制块。自此,子进程才彻底地结束了它的执行,消除了他所占用的所有资源。

5.系统调用实现

系统调用的英文名字是System Call。操作系统为什么需要实现系统调用呢?其实这是实现了用户进程后,自然引申出需要实现的操作系统功能。用户进程只能在操作系统给它圈定好的“用户环境”中执行,但“用户环境”限制了用户进程能够执行的指令,即用户进程只能执行一般的执行,无法执行特权指令。如果用户进程想执行一些需要特权执行的任务,比如网卡发网络包等,只能让操作系统来代劳了。于是需要一种机制来确保用户进程不能执行特权指令,但是能够请操作系统“帮忙”完成需要特权指令的任务,这种机制就是系统调用。

采用系统调用机制为用户进程提供一种获得操作系统服务的统一接口层,这样一来可简化用户进程的实现,把一些共性的、繁琐的、与硬件相关的、与特权指令相关的任务放在操作系统层实现,但提供一个简洁的接口给用户进程调用;二来这层接口事先可规定好,且严格检查用户进程传递进来的参数和操作系统要返回的数据,使得操作系统给用户进程服务的同时,保护操作系统不会被用户进程破坏。

从硬件层面上看,需要硬件能够支持在用户态的用户进程通过某种机制切换到内核态。实验一讲述的中断硬件就可以用来完成系统调用所需的软件硬件支持。

5.1.初始化系统调用对应的中断描述符

在ucore初始化函数kern_init中调用了idt_init函数来初始化中断描述符表,并设置一个特定中断号的中断们,专门用于用户进程访问系统调用。此事由ide_init函数完成:

void
idt_init(void) {
	extern uintptr_t __vectors[];
	int i;
	for (i = 0; i < sizeof(idt) / sizeof(struct gatedesc); i ++) {
		SETGATE(idt[i], 1, 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”后,由于此中断允许用户态进程残生,所以CPU就能从用户态切换到内核态,保存相关寄存器,并跳转到__vectors[T_SYSCALL]处开始执行,形成如下执行路径:

vector128(vectors.S)--\>

\_\_alltraps(trapentry.S)--\>trap(trap.c)--\>trap\_dispatch(trap.c)----\>syscall(syscall.c)

在syscall中,根据系统调用号来完成不同的系统调用服务。

5.2.建立系统调用的用户库准备

在操作系统中初始化化好系统调用相关的中断描述符、中断处理起始地址等后,还需要用户态的应用程序中初始化好相关的工作,简化应用程序访问系统调用的复杂性。为此用户态建立了一个中间层,即简化的libc实现,在user/libs/ulib.[ch]和user/libs/syscall.[ch]中完成了对访问系统调用的封装。用户态最终的访问系统调用函数是syscall,实现如下:

static inline int
syscall(int num, ...) {
    va_list ap;
    va_start(ap, num);
    uint32_t a[MAX_ARGS];
    int i, ret;
    for (i = 0; i < MAX_ARGS; i ++) {
        a[i] = va_arg(ap, uint32_t);
    }
    va_end(ap);

    asm volatile (
        "int %1;"
        : "=a" (ret)
        : "i" (T_SYSCALL),
          "a" (num),
          "d" (a[0]),
          "c" (a[1]),
          "b" (a[2]),
          "D" (a[3]),
          "S" (a[4])
        : "cc", "memory");
    return ret;
}

从中可以看出,应用程序调用的exit/fork/wait/getpid等库函数最终都会调用syscall函数,只是调用的参数不同而已,如果看最终的汇编代码更加清楚:

……

34: 8b 55 d4 mov -0x2c(%ebp),%edx

37: 8b 4d d8 mov -0x28(%ebp),%ecx

3a: 8b 5d dc mov -0x24(%ebp),%ebx

3d: 8b 7d e0 mov -0x20(%ebp),%edi

40: 8b 75 e4 mov -0x1c(%ebp),%esi

43: 8b 45 08 mov 0x8(%ebp),%eax

46: cd 80 int $0x80

48: 89 45 f0 mov %eax,-0x10(%ebp)

……

可以看到其实是把系统调用号放在eax,其他5个参数a[0]~a[4]分别保存到EDX/ECX/EBX/EDI/ESI五个寄存器中,及最多6个寄存器来传递系统调用的参数,且系统调用的返回结果是EAX。比如对于getpid库函数而言,系统调用号(SYS_getpid=18)是保存在EAX中,返回值(调用此库函数的当前进程号pid)也在EAX中。


5.3.与用户进程相关的系统调用

在本实验中,与进程相关的各个系统调用属性如下所示:

系统调用名

含义

具体完成服务的函数

SYS_exit

process exit

do_exit

SYS_fork

create child process,dup mm

do_fork-->wakeup_proc

SYS_wait

wait child process

do_wait

SYS_exec

after fork,process execute a new program

load a program and refresh the mm

SYS_yield

process flag itself need rescheduling

proc->need_shced=1,then scheduler will reschedule this process

SYS_kill

kill process

do_kill->proc->flags|=PF_EXITING,

->wakeup_proc->do_wait

SYS_getpid

get the process’s pid

通过这些系统调用,可以方便的完成从进程创建到退出的整个过程。

与用户态的函数库调用执行过程相比,系统调用执行过程有四点主要的不同:


5.4.系统调用的执行过程

与用户态的函数库调用执行过程相比,系统调用执行过程有四点主要的不同:

  • 不是通过“CALL”指令而是通过“INT”指令发起调用;
  • 不是通过“RET”指令, 而是通过“IRET”指令完成调用返回;
  • 当到达内核态后, 操作系统需要严格检查系统调用传递的参数, 确保不破坏整个系统的系统调用实现安全性;
  • 执行系统调用可导致进程等待某事件发生, 从而可引起进程切换

下面我们以getpid系统调用的执行过程大致看看操作系统是如何完成整个执行过程的。当用户进程调用getpid函数,最终执行到“INT T_SYSCALL”指令后,CPU根据操作系统建立的系统调用中断描述符,转入内核态,并跳转到vector128处(kern/trap/vector.S),开始可操作系统的系统调用执行过程,函数调用和返回操作的关系如下:

vector128(vectors.S)--\>

\_\_alltraps(trapentry.S)--\>trap(trap.c)--\>trap\_dispatch(trap.c)--

--\>syscall(syscall.c)--\>sys\_getpid(syscall.c)--\>……--\>\_\_trapret(trapentry.S)

在执行trap函数前,软件还需进一步保存执行系统调用前的执行现场,即把与用户进程执行所需的相关寄存器等当前内容保存到当前进程的中断帧trapframe中(注意,在创建进程时,把进程的trapframe放在进程的内核栈分配的空间顶部)。

自此,用户保存用户态的用户进程执行现场的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”后的那条指令。 这样整个系统调用就执行完毕了。

更加详细的解析见《X86中断栈执行过程分析》。


二、练习解答


1.实验0

填写Lab1/Lab2/Lab3/Lab4代码

在proc.c/alloc_proc()函数中,需要补充相关的代码如下:

// alloc_proc - alloc a proc_struct and init all fields of proc_struct
static struct proc_struct *
alloc_proc(void) {
    struct proc_struct *proc = kmalloc(sizeof(struct proc_struct));
    if (proc != NULL) {
	......
     //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
	 */
		//no wait for child state
		proc->wait_state = 0;
		//no child list
		proc->cptr = NULL;//该进程的孩子节点列表中的头指针
		proc->optr = NULL;//比该进程更加年长的兄弟进程
		proc->yptr = NULL;//比该进程更加年轻的兄弟进程
    }
    return proc;
}

在proc.c/do_fork()函数中,需要补充相关的代码如下:

int
do_fork(uint32_t clone_flags, uintptr_t stack, struct trapframe *tf) {
	//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
    */
	//    1. call alloc_proc to allocate a proc_struct
    proc = alloc_proc();
	//update step 1: set child proc's parent to current process
	proc->parent = current;
	assert(current->wait_state == 0);
	.....
		//    5. insert proc_struct into hash_list && proc_list
	//	5.1. write global share parameters
	bool intr_flag;
	local_intr_save(intr_flag);
	{
		proc->pid = get_pid();
	//insert proc_struct into hash_list
		hash_proc(proc);
	//insert proc_struct into hash_list,set the relation links of process
		set_links(proc);
	}
	local_intr_restore(intr_flag);
	......
}

在trap.c/idt_init()函数中,需要补充相关的代码如下:

void
idt_init(void) {
    ......
	/* 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
	//3)let cpu know where is the IDT
	//set user interrupt,ring 3
	//let user app to use syscall to get the service of ucore
	SETGATE(idt[T_SYSCALL], 1, GD_KTEXT, __vectors[T_SYSCALL], DPL_USER);
	//load all user/kernel interrupt description
	lidt(&idt_pd);
}

代码表示配置idt[T_SYSCALL] 表示trap门描述符;1表示trap;GD_KTEXT表示内核代码段选择子,当发生系统调用用户态切换到内核态,在内核态堆栈上进行代码执行,内核的页表已经加载了;__vectors[T_SYSCALL]表示代码段选择子中的T_SYSCALL中段向量处理函数地址;DPL_USER表示用户态特权级。

在trap.c/trap_dispatch()函数中,需要补充相关的代码如下:

static void
trap_dispatch(struct trapframe *tf) {
.....
    case IRQ_OFFSET + IRQ_TIMER:
	        /* 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++;//0.01s is one clock interrupt
		if(ticks%TICK_NUM==0){
			assert(current!=NULL);
			current->need_resched = 1;
		}
        break;
......
}

current->need_resched = 1;表示每个时间片都触发一次进程处理器调度,否则进程将不会并发执行。

2.实验1

加载应用程序并执行(需要编码)

do_execv函数调用load_icode(位于kern/process/proc.c中)来加载并解析一个处于内存中的ELF执行文件格式的应用程序,建立相应的用户内存空间来放置应用程序的代码段、数据段等,且要设置好proc_struct结构中的成员变量trapframe中的内容,确保在执行此进程后,能够从应用程序设定的起始执行地址开始执行。这个步骤的保证在《实验四 内核线程管理-实验部分》进行了详细的分析。

本实验中,需要设置用户态的代码段、数据段、堆栈段,具体的代码如下:

3.实验2

父进程复制自己的内存空间给子进程(需要编码)

创建子进程的函数do_fork在执行中将拷贝当前进程(即父进程)的用户内存地址空间中的合法内容到新进程中(子进程),完成内存资源的复制。具体是通过copy_range函数(位于kern/mm/pmm.c中)实现的,请补充copy_range的实现,确保能够正确执行。

在proc.c/do_fork()-->proc.c/dup_mmap()-->vmm.c/dup_mmap()-->pmm.c/copy_range()中

int
copy_range(pde_t *to, pde_t *from, uintptr_t start, uintptr_t end, bool share){
......
	// copy content by page unit.
    do {
......
		/* LAB5:EXERCISE2 YOUR CODE
         * replicate content of page to npage, build the map of phy addr of nage with the linear addr start
         *
         * Some Useful MACROs and DEFINEs, you can use them in below implementation.
         * MACROs or Functions:
         *    page2kva(struct Page *page): return the kernel vritual addr of memory which page managed (SEE pmm.h)
         *    page_insert: build the map of phy addr of an Page with the linear addr la
         *    memcpy: typical memory copy function
         *
         * (1) find src_kvaddr: the kernel virtual address of page
         * (2) find dst_kvaddr: the kernel virtual address of npage
         * (3) memory copy from src_kvaddr to dst_kvaddr, size is PGSIZE
         * (4) build the map of phy addr of  nage with the linear addr start
         */
         //(1) find src_kvaddr: the kernel virtual address of page
         void *src_kvaddr = page2kva(page);
         //(2) find dst_kvaddr: the kernel virtual address of npage
         void *dst_kvaddr = page2kva(npage);
         //(3) memory copy from src_kvaddr to dst_kvaddr, size is PGSIZE
         memcpy(dst_kvaddr, src_kvaddr, PGSIZE);
         //(4) build the map of phy addr of  nage with the linear addr start
         ret = page_insert(to,npage,start,perm);
......
		start += PGSIZE;
    } while (start != 0 && start < end);
......
}

该函数将process A's Page拷贝到process B's Page,并在页表to中建立物理地址npage隐射到线性地址start上。

4.练习3

理解进程执行fork/exec/wait/exit的实现,以及系统调用的实现(不需要编码)

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

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

执行:make grade。如果所显示的应用程序检测都输出ok,则基本正确。(使用的是qemu-1.0.1)。

这些系统调用均是采用中断进行的,把内核想象成一个库,交互的方式采用中断方式,进行参数传入和参数传出,具体流程如下。

  • 1
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值