操作系统实验ucore lab4

阅读前注意事项:

1、我的博客从lab2之后,如果没有特殊说明,所有标注的代码行数位置,以labcodes_answer(答案包)里的文件为准!!!因为你以后会发现做实验用meld软件比较费时费力,对于咱们学校的验收不如直接对着答案来;

2、感谢网上的各路前辈大佬们,本人在这学期初次完成实验的过程中,各位前辈们的博客给了我很多有用的指导;本人的博客内容在现有的内容上,做了不少细节的增补内容,有些地方属个人理解,如果有错在所难免,还请各位大佬们批评指正;

3、所有实验的思考题,我把它规整到了文章最后;

4、所有实验均默认不做challenge,对实验评分无影响;

5、湖南大学的实验顺序为1 4 5 6 7 2 3 8,在实验4-7过程中涉及到实验二三的页表虚存问题,当做黑盒处理,没有过多探索。

 

一、实验内容

当一个程序加载到内存中运行时, 首先通过ucore OS的内存管理子系统分配合适的空间, 然后就需要考虑如何分时使用CPU来“并发”执行多个程序, 让每个运行的程序(这里用线程或进程表示) “感到”它们各自拥有“自己”的CPU。

本次实验将首先接触的是内核线程的管理。 内核线程是一种特殊的进程, 内核线程与用户进程的区别有两个:内核线程只运行在内核态,用户进程会在在用户态和内核态交替运行,所有内核线程共用ucore内核内存空间, 不需为每个内核线程维护单独的内存空间,而用户进程需要维护各自的用户内存空间。

 

二、目的

了解内核线程创建/执行的管理过程

了解内核线程的切换和基本调度过程

 

三、实验设计思想和流程

 

练习1:分配并初始化一个进程控制块(需要编码)

alloc_proc函数(位于kern/process/proc.c86行中,参考proc.h文件)负责分配并返回一个新的struct proc_struct结构,用于存储新建立的内核线程的管理信息。ucore需要对这个结构进行最基本的初始化,你需要完成这个初始化过程。

【提示】在alloc_proc函数的实现中,需要初始化的proc_struct结构中的成员变量至少包括:state/pid/runs/kstack/need_resched/parent/mm/context/tf/cr3/flags/name。

 

首先找到kern/process/proc.c,需要填写的alloc_proc函数中有这么一段注释,它主要定义了一个结构体proc_struct,它也说,我们需要初始化这样一个结构体的一个对象并返回它。

这个结构体的详细定义在同一文件夹下的proc.h中的42——57行也能找到:

struct proc_struct {
    enum proc_state state;	        // Process state
    int pid;		                // Process ID
    int runs;		                // the running times of Proces
    uintptr_t kstack;		        // Process kernel stack
    volatile bool need_resched;		// bool value: need to be rescheduled to release CPU?
    struct proc_struct *parent;		// the parent process
    struct mm_struct *mm;			// Process's memory management field
    struct context context;			// Switch here to run process
    struct trapframe *tf;			// Trap frame for current interrupt
    uintptr_t cr3;				// CR3 register: the base addr of Page Directroy Table(PDT)
    uint32_t flags;			        // Process flag
    char name[PROC_NAME_LEN + 1];		// Process name
    list_entry_t list_link;			// Process link list 
    list_entry_t hash_link;			// Process hash list
};

alloc_proc,这个函数的返回语句是“return proc”,其中proc就是这个proc_struct的一个对象。

其实这里我们需要初始化的一个东西就是proc_struct的一个对象,分配的是一个内核线程的PCB,它通常只是内核中的一小段代码或者函数,没有用户空间。而由于在操作系统启动后,已经对整个核心内存空间进行了管理,所以内核中的所有线程都不需要再建立各自的页表,只需共享这个核心虚拟空间就可以访问整个物理内存了。(这里需要指出的一个重点是,虽然名字叫做内核线程,但是内核线程是一种特殊的进程,来自指导书P167)

操作系统实验ucore lab4_第1张图片

PCB包括进程状态,进程编号,程序计数器、寄存器等各种参数,和上面这个结构体一比较,可以确定这就是一个标准的PCB,我们需要做的东西就是初始化它,也可以看做给一个新开辟的内核线程(进程)做初始化。

反过来看这个结构体里面定义的PCB参数,经过分析我们可以得到以下含义:

 

state:进程所处的状态,这个在proc.h的第11行——15行有定义,具体如下:

PROC_UNINIT		//未初始状态
PROC_SLEEPING 		//睡眠(阻塞)状态
PROC_RUNNABLE		//运行与就绪态
PROC_ZOMBIE		//僵尸状态

pid:进程id号。这个非常熟悉了。

runs:进程运行的时间,既然任务是初始化,那么runs必须是零。

kstack:记录了分配给该进程/线程的内核桟的位置。这个地方,在没有基于前三个实验代码补全lab4的源代码之前,似乎运行会出问题,如果补全了,运行就不会报错。因为这里记录的是分配给该进程在内存中的栈位置,推测相关操作应该和lab2和lab3有关。

need_resched:是否需要调度,目前实验未到这一步,暂时不管。

parent:用户进程的父进程,这是一个指针变量,记录它的父进程是谁。在所有进程中,只有一个进程没有父进程,就是内核创建的第一个内核线程idleproc。

mm:注释说它负责管理进程的虚拟memory,其实就是内存管理的信息,包括内存映射列表、页表指针等。mm成员变量在lab3中用于虚存管理。但在实际OS中,内核线程常驻内存,不需要考虑swap page问题,mm应该和23有关,暂时不管。

 

context:Switch here to run process,推测应该是进程的上下文,用于进程切换。使用Switch.S汇编文件中的定义。

tf:中断帧的指针,指导书上说,它是中断帧的指针,总是指向内核栈的某个位置:当进程从用户空间跳到内核空间时,中断帧记录了进程在被中断前的状态。当内核需要跳回用户空间时,需要调整中断帧以恢复让进程继续执行的各寄存器值。除此之外,uCore内核允许嵌套中断。我的理解是,它总是指向内核栈的某个位置,中断帧记录了进程在被中断前的状态,应该也和23实验有关,不深究。

cr3:翻译注释的意思是:记录了当前使用的页表的地址,也是和23有关,不管。

name[PROC_NAME_LEN + 1],这是内核线程(进程)的名称,比如有一次作业里面要画进程数,输入了一个-lm指令,就能看到进程的名字pid等一些信息。

 

主要的初始化部分如下:

proc->state = PROC_UNINIT;	        //给进程设置为未初始化状态
proc->pid = -1;				//未初始化的进程,其pid为-1
proc->runs = 0;				//刚刚初始化的进程,运行时间一定为零		
proc->kstack = 0;			//为该进程分配的地址为0,因为还没有执行,也没有被重定位,因为默认地址都是从0开始的。
(上个学期计组课里面学过,刚刚编译出来的代码地址都是从0开始,只有在重定位之后,地址才会分配到内存里的0x8048......这样子的地方)	
proc->need_resched = 0;		    //刚刚分配出来的进程,都还没有进入CPU,还需要提什么分配么?
proc->parent = NULL;		    //同样的道理,父进程,虚拟memory,这些东西刚刚分出来,都是不存在的
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);		//初始化进程名字为空

练习2:为新创建的内核线程分配资源(需要编码)

 

这个练习核心部分是完成在kern/process/proc.c中的do_fork函数中的处理过程。ucore一般通过do_fork实际创建新的内核线程。do_fork的作用是,创建当前内核线程的一个副本,它们的执行上下文、代码、数据都一样,但是存储位置不同。在这个过程中,需要给新内核线程分配资源,并且复制原进程的状态。

 

这一点和linux里面的fork()函数的处理完全相同。

 

(来自于指导书)它的大致执行步骤包括:

1、调用alloc_proc,首先获得一块用户信息块。

2、为进程分配一个内核栈。

3、复制原进程的内存管理信息到新进程(但内核线程不必做此事)

4、复制原进程上下文到新进程

5、将新进程添加到进程列表

6、唤醒新进程

7、返回新进程号

 

在练习一中的alloc_proc实质只是找到了一小块内存用以记录进程的必要信息并且初始化PCB,并没有分配这些资源,而练习2完成的do_fork才是真正完成了资源分配的工作,但是这个do_fork的功能也是有限的,它fork出来的两个内核线程之间的的执行上下文、代码、数据都一样,但是存储位置不同。

以下是实现过程:

 

do_fork()位于kern/process/proc.c文件中279行

int do_fork(uint32_t clone_flags, uintptr_t stack, struct trapframe *tf) {

    int ret = -E_NO_FREE_PROC;

    struct proc_struct *proc;

    if (nr_process >= MAX_PROCESS) {

        goto fork_out;

    }

ret = -E_NO_MEM;

//上面的部分已经给出,不需要自己实现

第一步:调用alloc_proc()函数申请内存块,如果失败,直接返回处理,相关的解释是,alloc_proc()函数在练习一中实现过,如果分配进程PCB失败,也就是说,进程一开始就是NULL,那么就会被if(proc!=NULL)判定为否,那么就不会分配初始化资源,连初始化资源都没有了,那么就会返回NULL,因此第一步这么处理,代码实现如下:

    if ((proc = alloc_proc()) == NULL) {

        goto fork_out;

}

第二步:将子进程的父节点设置为当前进程,这个没什么好解释的,直接用就可以,只需要注意一点,就是代表当前进程的变量current已经在全局定义(第76行),因此代码实现如下:

proc->parent = current;

第三步:调用setup_stack()函数(proc.c235行)为进程分配一个内核栈,根据注释,我们需要使用setup_kstack这个函数,它的解释:alloc pages with size KSTACKPAGE as process kernel stack,正是一个为函数分配一个内核栈的调用,因此,我们找到代码中对应的函数有如下分析:

static int setup_kstack(struct proc_struct *proc) {  //246行

    struct Page *page = alloc_pages(KSTACKPAGE);

    if (page != NULL) {

        proc->kstack = (uintptr_t)page2kva(page);

        return 0;

    }

    return -E_NO_MEM;

}

我们看到,如果页不为空的时候,会return 0,也就是说分配内核栈成功了(这样推测的根据在于,最后一个return -E_NO_MEM,大概推测就是一个初始化的或者错误的状态,因为在这个函数最开始不需要实现的部分,这个值就赋值给了ret),那么就会返回0,否则返回一个奇怪的东西。

因此,我们调用该函数分配一个内核栈空间,并判断是否分配成功,代码实现如下:

    if (setup_kstack(proc) != 0) {

        goto bad_fork_cleanup_proc;

}

第四步:调用copy_mm()函数(proc.c253行)复制父进程的内存信息到子进程,那么首先来看copy函数如下:

copy_mm(uint32_t clone_flags, struct proc_struct *proc) {  //253行

    assert(current->mm == NULL);

    /* do nothing in this project */

    return 0;

}

这个函数的注释解释是:进程proc复制还是共享当前进程current,是根据clone_flags来决定的,如果是clone_flags & CLONE_VM(为真),那么就可以拷贝。这个函数里面似乎没有做任何事情,仅仅是确定了一下current当前进程的虚拟内存是否为空,那么具体的操作,只需要传入它所需要的clone_flag就可以,其余事情不需要我们去做,代码实现如下:

    if (copy_mm(clone_flags, proc) != 0) {

        goto bad_fork_cleanup_kstack;

}

第五步:调用copy_thread()函数复制父进程的中断帧和上下文信息,那么观察相应的函数:

copy_thread(struct proc_struct *proc, uintptr_t esp, struct trapframe *tf) {

    proc->tf = (struct trapframe *)(proc->kstack + KSTACKSIZE) - 1;

    *(proc->tf) = *tf;

    proc->tf->tf_regs.reg_eax = 0;

    proc->tf->tf_esp = esp;

    proc->tf->tf_eflags |= FL_IF;

    proc->context.eip = (uintptr_t)forkret;

    proc->context.esp = (uintptr_t)(proc->tf);

}

需要传入的三个参数,第一个是比较熟悉,练习一中已经实现的PCB模块proc结构体的对象,第二个参数,是一个栈,判断的依据是它的数据类型,在练习一中的PCB模块中,为栈定义的数据类型就是uintptr_t,第三个参数也很熟悉,它是练习一PCB中的中断帧的指针,因为这些内容都和实验23相关,故这个函数只要调用,不再深究内部原理。

 

第六步:将新进程添加到进程的(hash)列表中,我们看到题目中注释给出了提示:

hash_proc:    add proc into proc hash_list,意思是调用这个函数可以将当前的新进程添加到进程的哈希列表中,分析hash函数的特点,直接调用hash(proc)即可:

hash_proc(struct proc_struct *proc) {

    list_add(hash_list + pid_hashfn(proc->pid), &(proc->hash_link));

}

函数的实现如下:(local intr函数的作用,在后面解释)   

bool intr_flag;

local_intr_save(intr_flag);

    {

        proc->pid = get_pid();

        hash_proc(proc); //建立映射

        nr_process ++;  //进程数加1

        list_add(&proc_list, &(proc->list_link));//将进程加入到进程的链表中

    }

local_intr_restore(intr_flag);

步骤七:唤醒子进程

wakeup_proc(proc);

步骤八:返回子进程的pid

ret = proc->pid;
//下面的部分已经给出,不需要自己实现

fork_out:

    return ret;

bad_fork_cleanup_kstack:

    put_kstack(proc);

bad_fork_cleanup_proc:

    kfree(proc);

    goto fork_out;

}

完整的do_fork函数如下:

int do_fork(uint32_t clone_flags, uintptr_t stack, struct trapframe *tf) {
    int ret = -E_NO_FREE_PROC;
    struct proc_struct *proc;
    if (nr_process >= MAX_PROCESS) {
        goto fork_out;
    }
ret = -E_NO_MEM;
//上面的部分已经给出,不需要自己实现
//第一步:调用alloc_proc()函数申请内存块,
    if ((proc = alloc_proc()) == NULL) {
        goto fork_out;
}
//第二步:将子进程的父节点设置为当前进程,
proc->parent = current;
//第三步:调用setup_stack()函数为进程分配一个内核栈
    if (setup_kstack(proc) != 0) {
        goto bad_fork_cleanup_proc;
}
//第四步:调用copy_mm()函数复制父进程的内存信息到子进程
    if (copy_mm(clone_flags, proc) != 0) {
        goto bad_fork_cleanup_kstack;
}
//第五步:调用copy_thread()函数复制父进程的中断帧和上下文信息
copy_thread(proc, stack, tf);
//第六步:将新进程添加到进程的(hash)列表中
    bool intr_flag;
local_intr_save(intr_flag);
    {
        proc->pid = get_pid();
        hash_proc(proc); //建立映射
        nr_process ++;  //进程数加1
        list_add(&proc_list, &(proc->list_link));//将进程加入到进程的链表中
    }
local_intr_restore(intr_flag);
//步骤七:唤醒子进程
wakeup_proc(proc);
//步骤八:返回子进程的pid
ret = proc->pid;
//下面的部分已经给出,不需要自己实现
fork_out:
    return ret;
bad_fork_cleanup_kstack:
    put_kstack(proc);
bad_fork_cleanup_proc:
    kfree(proc);
    goto fork_out;
}

练习3:阅读代码,理解proc_run函数和它调用的函数如何完成进程切换的。

 

阅读了相关代码,proc_run是如何被调用的呢?

操作系统实验ucore lab4_第2张图片

首先,在初始化内核的kern_init(kern/init/init.c)函数中,调用了cpu_idle(process/proc.c);

 

在init.c::kern_init函数调用了proc.c::proc_init函数。proc_init函数启动了创建内核线程的步骤。首先当前的执行上下文(从kern_init 启动至今)就可以看成是uCore内核(也可看做是内核进程)中的一个内核线程的上下文。为此uCore通过给当前执行的上下文分配一个进程控制块以及对它进行相应初始化,将其打造成第0个内核线程 -- idle。

操作系统实验ucore lab4_第3张图片

具体流程在proc.c的第369——400行:

 

首先,idleproc = alloc_proc()),为第一个进程进行分配PCB初始化,那么就有:

proc->state = PROC_UNINIT;
proc->pid = -1;
proc->cr3 = boot_cr3;
//等等

这些之前写过的初始化语句,分配给了第一个进程,但是这样不行,第一个进程需要运行,那么此时proc_init(proc.c倒数第二个函数)就会对它进行初始化:

    idleproc->pid = 0;
    idleproc->state = PROC_RUNNABLE;
    idleproc->kstack = (uintptr_t)bootstack;
    idleproc->need_resched = 1;
    set_proc_name(idleproc, "idle");
    nr_process ++;

首先设置第一个进程id等于0,当前正在运行,分配了栈空间,需要调度,名字叫做idle。

 

第0个内核线程主要工作是完成内核中各个子系统的初始化,所以uCore接下来还需创建其他进程来完成各种工作,但idleproc内核子线程自己不想做,于是就通过调用kernel_thread函数创建了一个内核线程init_main。

操作系统实验ucore lab4_第4张图片

然后,在proc_init()函数完成了 idleproc 和 initproc 内核线程的初始化。

 

所以在kern_init() 最后,它通过 cpu_idle()唤醒了0号idle 进程,cpu_idle(proc.c最后)查找到一个需要调度的线程,开始调度它,调用schedule,接着分析调度函数 schedule() 。

 

接下来,继续分析schedule调度函数:kern/schedule/schedule.c

void schedule(void) {
    bool intr_flag;
    list_entry_t *le, *last;
    struct proc_struct *next = NULL;
    local_intr_save(intr_flag);
    {
        current->need_resched = 0;
        last = (current == idleproc) ? &proc_list : &(current->list_link);
        le = last;
        do {
            if ((le = list_next(le)) != &proc_list) {
                next = le2proc(le, list_link);
                if (next->state == PROC_RUNNABLE) {
                    break;
                }
            }
        } while (le != last);
        if (next == NULL || next->state != PROC_RUNNABLE) {
            next = idleproc;
        }
        next->runs ++;
        if (next != current) {
            proc_run(next);
        }
    }
    local_intr_restore(intr_flag);
}

这个函数所做的工作主要就是调配调度,具体逻辑大致如下:

 

1、设置当前内核线程 current->need_resched 为 0(即练习一中的PCB “是否需要调度”);

2、遍历进程hash队列,在proc_list 队列中查找下一个处于就绪态的线程或进程next;(比如,这里有一句:next state=runnable)

3、找到这样的进程后,就调用 proc_run函数,保存当前进程current的执行现场(进程上下文),恢复新进程的执行现场,完成进程切换。

 

最后,通过proc_run函数,就可以跑当前被调度选出的进程,从runable状态正式开始运行。

 

最后,来到了proc_run函数:proc.c文件

void proc_run(struct proc_struct *proc) { //174行
    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);
            switch_to(&(prev->context), &(next->context));
        }
        local_intr_restore(intr_flag);
    }
}

proc_run函数的基本思路是:

 

1、让 current指向 next内核线程initproc;

2、设置任务状态ts中特权态0下的栈顶指针esp0 为 next 内核线程 initproc 的内核栈的栈顶,即 next->kstack + KSTACKSIZE ;

3、设置 CR3 寄存器的值为 next 内核线程 initproc 的页目录表起始地址 next->cr3,这实际上是完成进程间的页表切换;

4、由 switch_to函数完成具体的两个线程的执行现场切换,即切换各个寄存器,当 switch_to 函数执行完“ret”指令后,就切换到initproc执行了。

发现函数中调用了switch函数,switch函数写在汇编文件switch.S中:

.text
.globl switch_to
switch_to:                      # switch_to(from, to)

    # save from's registers
    movl 4(%esp), %eax          # eax points to from
    popl 0(%eax)                # save eip !popl
    movl %esp, 4(%eax)
    movl %ebx, 8(%eax)
    movl %ecx, 12(%eax)
    movl %edx, 16(%eax)
    movl %esi, 20(%eax)
    movl %edi, 24(%eax)
    movl %ebp, 28(%eax)

    # restore to's registers
    movl 4(%esp), %eax          # not 8(%esp): popped return address already
                                # eax now points to to
    movl 28(%eax), %ebp
    movl 24(%eax), %edi
    movl 20(%eax), %esi
    movl 16(%eax), %edx
    movl 12(%eax), %ecx
    movl 8(%eax), %ebx
    movl 4(%eax), %esp

    pushl 0(%eax)               # push eip

ret

很容易发现保存前一个进程的其他 7 个寄存器到 context 中的相应域中,而后7条指令就是前7条的逆操作,前一部分用于保留上下文切换的现场,便于进程下一次执行;后一部分用于读取之前的进程现场,继续之前之前被调度下去的进程。

 

四、思考题

 

1、请说明proc_struct中 struct context context 和 struct trapframe *tf 成员变量含义和在本实验中的作用是啥?(提示通过看代码和编程调试可以判断出来)

 

答:tf是中断帧的指针,指导书上说,它是中断帧的指针,总是指向内核栈的某个位置:当进程从用户空间跳到内核空间时,中断帧记录了进程在被中断前的状态。当内核需要跳回用户空间时,需要调整中断帧以恢复让进程继续执行的各寄存器值。除此之外,uCore内核允许嵌套中断。我的理解是,它总是指向内核栈的某个位置,中断帧记录了进程在被中断前的状态,应该也和23实验有关,不深究。

 

2、请说明ucore是否做到给每个新fork的线程一个唯一的id?请说明你的分析和理由。

 

答:在使用 fork 或 clone 系统调用时产生的进程均会由内核分配一个新的唯一的PID值。因为它的实现放在了互斥锁中。

 

3、在本实验的执行过程中,创建且运行了几个内核线程?

 

答:两个,如上面的分析:idleproc:ucore第一个内核进程,完成内核中各个子系统的初始化,之后立即调度,执行其他进程。initproc:用于完成实验的功能而调度的内核进程。

 

4、解释语句 local_intr_save(intr_flag);....local_intr_restore(intr_flag); 在这里有何作用?并说明理由。

 

答:是保护进程切换不会被中断,以免进程切换时其他进程再进行调度,相当于互斥锁。之前在第六步添加进程到列表的时候也需要有这个操作,是因为进程进入列表的时候,可能会发生一系列的调度事件,比如我们所熟知的抢断等,加上这么一个保护机制可以确保进程执行不被打乱。

 

五、运行结果

输出以下进程信息:

操作系统实验ucore lab4_第5张图片

 

你可能感兴趣的:(操作系统实验,ucore,lab)