米鼠商城

多快好省,买软件就上米鼠网

最新项目

人才服务

靠谱的IT人才垂直招聘平台

uCore操作系统编程实验手记(五)

  • xuxiang
  • 7
  • 2019-08-30 20:15

再谈uCore操作系统对进程的使用和管理

在实验四中,我曾记下uCore操作系统对进程的使用和管理的若干细节。实际上,这个阐述更多地适用于内核线程,对用户进程的细节关注的不够。具体看,本次实验实现的用户进程和内核线程的差别主要是: 1.进程是拥有资源的基本单位,而线程不是。进程的实现不能像线程那么简单。对于用户进程,它要有自己的地址空间,不能再像内核线程一样共享boot_cr3就行了。本次的用户进程,要解析ELF可执行程序文件而不是一个函数,然后加载到内存中。 2.用户进程是工作在用户态的进程,而内核线程是工作在内核态的。要实现这一点,就要借助中断返回。而这个过程,在实验一的challenge1中就已经完成了。下面讨论两个点,重点是用户进程的创建流程,其次是向用户态切换的过程。

用户进程的创建

当引入用户进程的时候,uCore操作系统的地址空间划分与安排就不像实验二那么简单了,0~0xBFFFFFFF这一大段就要派上用场了。下面是uCore对4G地址空间的安排:

//kern/mm/memlayout.h
/* *
 * 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.
 *
 * */

看uCore中第一个用户进程的创建流程,首先看kern_init() -> proc_init()中的一句:

int pid = kernel_thread(init_main, NULL, 0);

这句代码创建了内核线程init_main,执行流就是init_main()函数。 而后,在init_main()函数中,又有这么一句:

int pid = kernel_thread(user_main, NULL, 0);

这句代码创建了另一个内核线程user_main,执行流是user_main()函数。而user_main()函数是这样的,作用是exec一个用户程序:

static int user_main(void *arg) {
#ifdef TEST
    KERNEL_EXECVE2(TEST, TESTSTART, TESTSIZE);
#else
    KERNEL_EXECVE(exit);
#endif
    panic("user_main execve failed.\n");
}

这里面的TEST是什么意思呢?当define了TEST后,才会执行KERNEL_EXECVE2,否则就exit。观察工程Makefile中的一句:

build-%: touch
	$(V)$(MAKE) $(MAKEOPTS) "DEFS+=-DTEST=$* -DTESTSTART=$(RUN_PREFIX)$*_out_start -DTESTSIZE=$(RUN_PREFIX)$*_out_size"

可见在编译时定义了宏(-D选项),这样条件编译语句就会自动转入编译TEST。这句代码还定义了和_out_start及_out_size(预设的用户程序在地址空间中的位置)有关的宏,在本实验中也有应用。再回来接着看,KERNEL_EXECVE2(TEST, TESTSTART, TESTSIZE)做了什么。

#define KERNEL_EXECVE2(x, xstart, xsize)        __KERNEL_EXECVE2(x, xstart, xsize)
#define __KERNEL_EXECVE2(x, xstart, xsize) ({                           \
            extern unsigned char xstart[], xsize[];                     \
            __KERNEL_EXECVE(#x, xstart, (size_t)xsize);                 \
        })
#define __KERNEL_EXECVE(name, binary, size) ({                          \
            cprintf("kernel_execve: pid = %d, name = \"%s\".\n",        \
                    current->pid, name);                                \
            kernel_execve(name, binary, (size_t)(size));                \
        })

可见最终还是到了kernel_execve()这个函数来。下面看这个函数的定义:

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;
}

从内嵌汇编语言中可以看出,这个函数通过T_SYSCALL调用了sys_exec()系统调用。最终的结果是创建用户进程,返回值在EAX(ret)中。 下面是sys_exec()的内容,该函数进一步调用do_exec()函数:

static int sys_exec(uint32_t arg[]) {
    const char *name = (const char *)arg[0];
    size_t len = (size_t)arg[1];
    unsigned char *binary = (unsigned char *)arg[2];
    size_t size = (size_t)arg[3];
    return do_execve(name, len, binary, size);
}

下面是do_exec()函数的内容,该函数又进一步调用load_icode()函数:

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;
    }
    int ret;
    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);
}

最后是load_icode()函数的内容,这个函数很长,而且也是实验需要补代码的地方,先不贴了。其功能类似于do_fork()函数,装载二进制ELF程序中的内容。具体地如下,一看就知道他的功能了(源自实验指导书): 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转到用户态特权级,并回到用户态内存空间,使用用户态的代码段、数据段和堆栈,且能够跳转到用户进程的第一条指令执行,并确保在用户态能够响应中断(这部分是实验的任务)。 至此,一个用户进程的基本创建流程就是这样了。总结一下,uCore实现这个过程的流程是:kernthread_initmain -> kernel_thread(user_main, NULL, 0) -> (转到user_main内核线程,这个线程再创建进程) -> KERNEL_EXECVE2 -> __KERNEL_EXECVE2 -> __KERNEL_EXECVE -> kernel_execve() -> sys_exec() -> do_execve() ( -> load_icode() )。可见,init_main内核线程中创建user_main内核线程,user_main内核线程最终自演变成用户进程。init_main进程是所有用户进程之父应该就是这个意思。 回顾一下 ,内核线程的创建流程是:kernel_thread() -> do_fork() -> copy_thread()等。

转入执行用户态下进程的过程

与内核线程不同,内核线程只需实现iret时找到正确的代码入口即可,不涉及特权级的转换。而用户进程需要进行特权级的转换,在iret时返回到用户态。回忆一下,这个目的是通过修改中断帧来达到的。 在uCore中,是load_icode()函数的最后一步完成这个工作。起初的代码是:

//(6) setup trapframe for user environment
struct trapframe *tf = current->tf;
memset(tf, 0, sizeof(struct trapframe));

定义一个指针,指向当前“进程”的中断帧位置,把中断帧里面所有的数据都清零,然后重新设置。设置时,注意CS/DS/ES/FS/GS/SS寄存器的设置,要写入用户态的段选择子,不可弄错。先到这里,具体在实验中体现。

进程退出和等待进程

进程由于各种原因,需要退出时,要执行相应的退出操作。uCore中的进程退出区分为2步。 一步是由进程本身完成大部分资源的占用内存回收工作。进程本身调用exit()用户态函数,进而执行sys_exit()系统调用让操作系统完成部分资源回收。回收过程如何?首先释放所有能释放的内存占用,如vma描述的已分配内存、清空页表项页目录项、释放页表页目录、释放vma及mm等。此时,子进程状态为PROC_ZOMBIE僵尸进程,已经不能再被调度、需要父进程进一步回收。如果父进程current->parent处于等待子进程状态,即current->parent->wait_state==WT_CHILD,则唤醒父进程去完成资源回收。最后,执行schedule()函数,选择新的进程执行。另外,如果当前要退出的进程还有子进程,则要把这些子进程的父进程指针设置为内核线程initproc! 二步是由此进程的父进程完成剩余资源占用内存的回收工作。父进程收到子进程需要exit后,要执行wait()用户函数/wait_pid()用户函数。两个函数最终访问sys_wait()系统调用接口让ucore来完成对子进程的最后回收工作,即回收子进程的内核栈和进程控制块所占内存空间。回收过程如何?如果此子进程的执行状态不为PROC_ZOMBIE,表明此子进程还没有退出,父进程就设置自己为睡眠状态,后执行schedule()函数,选择别的进程执行。唤醒时重复此步骤。如果此子进程的执行状态为PROC_ZOMBIE,则父进程把子进程控制块从两个进程队列proc_list和hash_list中删除,并释放子进程的内核堆栈和进程控制块。至此,子进程已经完全退出了。

Lab5

下面开始lab5的实验内容。

加载应用程序并执行

do_execv()函数调用load_icode()函数,此任务要求在该函数中补全、设置好proc_struct结构中的成员变量trapframe中的内容。以下是我的代码:

struct trapframe *tf = current->tf;
memset(tf, 0, sizeof(struct trapframe));
tf->tf_cs = USER_CS;//返回时选择用户代码段
tf->tf_ds = tf->tf_es = tf->tf_ss = USER_DS;//返回时选择用户数据段
tf->tf_esp = USTACKTOP;//返回时用户栈的栈指针位置为USTACKTOP
tf->tf_eip = elf->e_entry;//struct elfhdr *elf = (struct elfhdr *)binary;
						  //后者是上级函数的参数
tf->tf_eflags |= FL_IF;//返回后要开中断,否则就不能响应中断了!!!

在指导书的提示下,这个任务看似很简单。但是仍有一些问题要弄明白。 首先,用户栈指针设为了USTACKTOP,这个宏是什么意思?还记得系统地址空间布局图中有这么一段:

 *     USERTOP -------------> +---------------------------------+ 0xB0000000
 *                            |           User stack            |
 *                            +---------------------------------+

在uCore中,用户栈被安排到了用户空间的最顶端(至少目前是)。其范围是,??~0xB0000000。这个USTACKTOP就是0xB0000000,也就是整个栈的最高地址部分。由于栈是由高到低发展的,所以这个USTACKTOP代表的就是初始的栈底,显然这样设置时理所当然的。 另外一个问题,也是实验指导书中要求回答的问题,描述当创建一个用户态进程并加载了应用程序后,CPU是如何让这个应用程序最终在用户态执行起来的。即这个用户态进程被ucore选择占用CPU执行(RUNNING态)到具体执行应用程序第一条指令的整个经过? 答:当创建了一个应用程序并加载以后,此时在内存中已经有进程的代码了。回顾用户进程的创建过程最初是在user_main线程中,而该线程仅完成用户进程的创建工作。最开始使用宏代码写的,嵌套几层宏以后,调用了kernel_execve()函数。而kernel_execve()函数只有一条系统调用指令,也就是在这里创建了陷入用的中断帧。在层层调用后,修改了这个中断帧的内容(如上)。当这些调用在返回时,又回到了这个中断帧,这个中断帧将引导系统选择用户态段选择子,进入用户态。紧接着执行用户程序的第一条指令,elf->e_entry。

父进程复制自己的内存空间给子进程

创建子进程的函数do_fork()在执行中将拷贝当前进程(即父进程)的用户内存地址空间中的合法内容到新进程中(子进程),完成内存资源的复制。具体是通过copy_range()函数(kern/mm/pmm.c)实现的,补充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) {
    assert(start % PGSIZE == 0 && end % PGSIZE == 0);
    assert(USER_ACCESS(start, end));
    // copy content by page unit.
    do {
        //call get_pte to find process A's pte according to the addr start
        pte_t *ptep = get_pte(from, start, 0), *nptep;
        if (ptep == NULL) {
            start = ROUNDDOWN(start + PTSIZE, PTSIZE);
            continue ;
        }
        //call get_pte to find process B's pte according to the addr start. If pte is NULL, just alloc a PT
        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);//指向源页
        // alloc a page for process B
        struct Page *npage=alloc_page();//指向目标页
        assert(page!=NULL);
        assert(npage!=NULL);
        int ret=0;
        /* LAB5:EXERCISE2 YOUR CODE
         */ //以下是复制并建立一个页
        void * src_kvaddr = page2kva(page);//获取源页的虚拟地址
        void * dst_kvaddr = page2kva(npage);//获取目的页的虚拟地址
        memcpy(dst_kvaddr, src_kvaddr, PGSIZE);//从源页向目的页复制一个页的大小的数据
        ret = page_insert(to, npage, start, perm);//建立这个页的映射,ret做返回值
        						//注意这里面虚拟地址参数la要使用start而不是别的
        assert(ret == 0);
        }
        start += PGSIZE;
    } while (start != 0 && start < end);
    return 0;
}

简要说明如何设计实现”Copy on Write 机制“,给出概要设计。 简而言之,在copy_range()中并不进行复制,只把父进程的页目录页表复制过来,并把用户地址空间部分的读写位置为1(只读)。当用户进程需要写某个页时,会触发页访问异常,此时再把页复制到子进程中来,并修改子进程的页表项。

理解系统调用的实现

首先回答fork/exec/wait/exit在实现中是如何影响进程的执行状态的? 答:fork()函数通过系统调用最终要执行到do_fork()函数。这个函数对进程做最初的初始化,而后设置进程的状态被设置为UNINIT。 exec()函数通过系统调用最终要执行到sys_exec()函数。这个函数及其调用函数对进程做进一步设置,加载程序代码、设置内存空间、设置页表等。经历这个函数后,进程的状态为RUNNABLE。 wait()函数通过系统调用最终要执行到sys_wait()函数。这个函数用于父进程执行,执行完毕后,若子进程未完成部分资源回收,父进程就进入睡眠状态。 exit()函数通过系统调用最终要执行到sys_exit()函数。这个函数用于子进程执行,执行完毕后,子进程将释放它所能释放的所有资源,变为僵尸进程态,等待父进程做最后一步的回收。 关于ucore中一个用户态进程的执行状态生命周期图,上面已经进行了描述,图就不画了。补充一点是,schedule()可以把在运行的进程换下,变成RUNNABLE,也可以把就绪队列里的RUNNABLE进程变成RUNNING的状态。

★系统调用的实现过程: 系统调用是系统为应用提供服务的接口。 要实现系统调用,需要在用户态和内核态两个方面做工作。 首先看简单一点的用户层工作:一是实现系统调用接口函数。比如,实现获取进程ID的函数,就要实现一个get_pid()函数。要定义清楚参数是什么?返回了什么?在这个函数内部怎么实现呢。这就是,二是实现系统调用汇编接口。由于系统调用需要传递的参数个数不确定,这里可以用GNU的可变参数实现,也可以分别定义几个不同的宏,这些宏展开的参数个数不同。宏/函数内部,利用内嵌汇编语言执行“int $0xNNN”指令,同时注意向EAX传入系统调用表的索引(也用来保存返回值),向其他寄存器(uCore用的是EDX、ECX、EBX、EDI等)传入系统调用函数需要的参数。 而后是内核层的工作:一是实现系统调用的实际执行函数。这个其实不必说了,实现什么功能就怎么实现。二是实现系统调用表。在用户层EAX保存了系统调用表的索引,可以先实现一个统一的中断处理函数,根据EAX的值寻找系统调用表syscall_table[]中的函数入口执行。这个表中保存的都是系统调用执行函数的函数指针。

至此,系统调用的过程说明完毕,以get_pid()为例,给出其调用链(与uCore不同): USER LEVEL: get_pid(void) -> _syscall(SYS_GETPID) -> int $0xNN + SYS_GETPID KERNEL LEVEL: syscall_handler + EAX -> syscall_table[SYS_GETPID] -> sys_getpid()

同时,我再加一个syscall_handler()系统调用统一中断处理函数的代码,方便理解:

[bits 32]//XXXkernel.S
extern syscall_table//引入外部变量,系统调用表
section .text//定义一个代码段
global syscall_handler//syscall_handler定义为global变量,后面注册中断处理函数会用到
syscall_handler:
	push 0//以下为了统一中断帧的格式,在INT指令后,硬件对有关寄存器做了保存以后
	push 0x80//上面相当于为错误码占位,这个是为中断向量号占位
	push ds//这里执行若干条指令把整个中断帧补齐
	push es
	push fs
	push gs
	pushad//入栈顺序:EAX、ECX、EDX、EBX、ESP、EBP、ESI、EDI
	push edx//一次压入所有参数备用,不管这个寄存器是否都真的被用到
	push ecx
	push ebx
	call [syscall_table + eax * 4]//根据EAX索引,调用系统调用表中的函数指针
	add esp, 12//函数调用结束并返回至此,对压入的3个参数栈平衡
	mov [esp + 8 * 4], eax//把当前EAX(返回值)保存到中断帧中,中断返回时就恢复到EAX中
	jmp intr_exit//调到中断返回过程

至此,本次实验的内容就完成了。

2019-07-27 22:15



这里给大家推荐一个在线软件复杂项交易平台:米鼠网 https://www.misuland.com

米鼠网自成立以来一直专注于从事软件项目人才招聘软件商城等,始终秉承“专业的服务,易用的产品”的经营理念,以“提供高品质的服务、满足客户的需求、携手共创双赢”为企业目标,为中国境内企业提供国际化、专业化、个性化、的软件项目解决方案,我司拥有一流的项目经理团队,具备过硬的软件项目设计和实施能力,为全国不同行业客户提供优质的产品和服务,得到了客户的广泛赞誉。



如有侵权请联系邮箱(service@misuland.com)

猜你喜欢

评论留言