ucore操作系统lab4实验报告(理论部分)

内核线程管理

一、关键数据结构 -- 进程控制块

1、首先简单说明一下内核线程与用户进程的区别:

内核线程只运行在内核态。用户进程会在在用户态和内核态交替运行所有内核线程共用ucore内核内存空间,不需为每个内核线程维护单独的内存空间,而用户进程需要维护各自的用户内存空间。


2、进程管理信息用struct proc_struct表示,在kern/process/proc.h中定义如下:
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; // 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; // 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
};
mm:内存管理的信息,包括内存映射列表、页表指针等。 mm 里有个很重要的项pgdir,记录的是该进程使用的一级页表的物理地址。
state:进程所处的状态。
parent:用户进程的父进程(创建它的进程)。在所有进程中,只有一个进程没有父进程,就是内核创建的第一个内核线程 idleproc。内核根据这个父子关系建立进程的树形结构,用于维护一些特殊的操作,例如确定哪些进程是否可以对另外一些进程进行什么样的操作等等。
context:进程的上下文,用于进程切换(参见switch.S)。 在ucore 中,所有的进程在内核中也是相对独立的(例如独立的内核堆栈以及上下文等等)。使用context保存寄存器的目的就在于在内核态中能够进行上下文之间的切换。实际利用context 进行上下文切换的函数是switch_to,kern/process/switch.中定义。
tf:中断帧的指针,总是指向内核栈的某个位置:当进程从用户空间跳到内核空间时,中断帧记录了进程在被中断前的状态。当内核需要跳回用户空间时,需要调整中断帧以恢复让进程继续执行的各寄存器值。除此之外,ucore 内核允许嵌套中断。因此为了保证嵌套中断发生时 tf 总是能够指向当前的 trapframe, ucore 在内核桟上维护了 tf 的链,可以参考 trap.c::trap 函数 做进一步的了解。
cr3: cr3 保存页表的物理地址,目的就是进程切换的时候方便直接使用 lcr3 实现页表切换,避免每次都根据 mm 来计算 cr3。 mm 数据结构是用来实现用户空间的虚存管理的,但是内核线程没有用户空间,它执行的只是内核中的一小段代码(通常是一小段函数),所以它没有 mm 结构,也就是 NULL。当某个进程是一个普通用户态进程的时候, PCB 中的 cr3 就是 mm 中页表( pgdir)的物理地址;而当它是内核线程的时候, cr3 等于 boot_cr3。 而 boot_cr3 指向了 ucore 启动时建立好的饿内核虚拟空间的页目录表首地址。
kstack:每个进程都有一个内核桟,并且位于内核地址空间的不同位置。对于内核线程, 该桟就是运行时的程序使用的桟;而对于普通进程,该桟是发生特权级改变的时候使保存被打断的硬件信息用的桟。 Ucore 在创建进程时分配了 2 个连续的物理页(参见 memlayout.h)作为内核栈的空间。这个桟很小,所以内核中的代码应该尽可能的紧凑,并且避免在桟上分配大的数据结构,以免桟溢出,导致系统崩溃。 kstack 记录了分配给该进程/线程的内核桟的位置。主要作用有以下几点。首先,当内核准备从一个进程切换到另一个的时候,需要根据 kstack 的值正确的设置好 tss (可以回顾一下在 lab1 中讲述的 tss 在中断处理过程中的作用),以便在进程切换以后再发生中断时能够使用正确的桟。其次,内核桟位于内核地址空间,并且是不共享的(每个进程/线程都拥有自己的内核桟),因此不受到 mm 的管理,当进程退出的时候,内核能够根据 kstack 的值快速定位桟的位置并进行回收。
为 了 管 理 系 统 中 所 有 的 进 程 控 制 块 , ucore维 护 了 如 下 全 局 变 量 ( 位 于kern/process/proc.c):
static struct proc *current; //当前占用CPU,处于“运行”状态进程控制块指针。通常这个变量是只读的,只有在进程切换的时候才进行修改, 并且整个切换和修改过程需要保证操作的原子性,目前至少需要屏蔽中断,可以参考 switch_to 的实现,后面也会介绍到。 linux 的实现很有意思,它将进程控制块放在进程内核桟的底部,这使得任何时候 current 都可以根据内核桟的位置计算出来的,而不用维护一个全局变量。这样使得一致性的维护以及多核的实现变得十分的简单和高效。感兴趣的同学可以参考 linux kernel 的代码。
static struct proc *initproc; //指向第一个用户态进程(proj10 以后)
static list_entry_t hash_list[HASH_LIST_SIZE]; //所有进程控制块的哈希表,这样proc_struct 中的域 hash_link 将基于 pid 链接入这个哈希表中。
list_entry_t proc_list;//所有进程控制块的双向线性列表,这样 proc_struct 中的域list_link将链接入这个链表中。


二、实现 -- 创建并执行内核线程

1、 第0个内核线程idleproc的创建
在init.c::kern_init函数调用了proc.c::proc_init函数。proc_init函数启动了创建内核线程的步骤。首先当前的执行上下文(从kern_init 启动至今)就可以看成是uCore内核(也可看做是内核进程)中的一个内核线程的上下文。为此uCore通过给当前执行的上下文分配一个进程控制块以及对它进行相应初始化,将其打造成第0个内核线程 -- idleproc。具体步骤如下:
首先调用alloc_proc函数来通过kmalloc函数获得proc_struct结构的一块内存块-,作为第0个进程控制块。并把proc进行初步初始化(即把proc_struct中的各个成员变量清零)。但有些成员变量设置了特殊的值,比如:
proc->state = PROC_UNINIT;  //设置进程为“初始”态
 proc->pid = -1;             //设置进程pid的未初始化值
 proc->cr3 = boot_cr3;       //使用内核页目录表的基址
 ...
接下来,proc_init函数对 idleproc内核线程进行进一步初始化:

idleproc->pid = 0; //第0个进程
idleproc->state = PROC_RUNNABLE;//准备状态,等待执行
idleproc->kstack = (uintptr_t)bootstack;//<span style="font-family: 宋体; font-size: 10pt;">内核栈的起始地址</span>
idleproc->need_resched = 1;//要此标志为 1,马上就调用 schedule 函数要求调度器切换其他进程执行
set_proc_name(idleproc, "idle");


2. 创建第 1 个内核线程 initproc
第0个内核线程主要工作是完成内核中各个子系统的初始化,然后就通过执行cpu_idle函数开始过退休生活了。所以uCore接下来还需创建其他进程来完成各种工作,但idleproc内核子线程自己不想做,于是就通过调用kernel_thread函数创建了一个内核线程init_main。下面我们来分析一下创建内核线程的函数kernel_thread:

kernel_thread(int (*fn)(void *), void *arg, uint32_t clone_flags)
{
    struct trapframe tf;
    memset(&tf, 0, sizeof(struct trapframe));
    tf.tf_cs = KERNEL_CS;
    tf.tf_ds = tf_struct.tf_es = tf_struct.tf_ss = KERNEL_DS;
    tf.tf_regs.reg_ebx = (uint32_t)fn;
    tf.tf_regs.reg_edx = (uint32_t)arg;
    tf.tf_eip = (uint32_t)kernel_thread_entry;
    return do_fork(clone_flags | CLONE_VM, 0, &tf);
}
注意,kernel_thread函数采用了局部变量tf来放置保存内核线程的临时中断帧,并把中断帧的指针传递给do_fork函数,而do_fork函数会调用copy_thread函数来在新创建的进程内核栈上专门给进程的中断帧分配一块空间。
给中断帧分配完空间后,就需要构造新进程的中断帧,具体过程是:首先给tf进行清零初始化,并设置中断帧的代码段(tf.tf_cs)和数据段(tf.tf_ds/tf_es/tf_ss)为内核空间的段(KERNEL_CS/KERNEL_DS),这实际上也说明了initproc内核线程在内核空间中执行。而initproc内核线程从哪里开始执行呢?tf.tf_eip的指出kernel_thread_entry(位于kern/process/entry.S中),kernel_thread_entry是entry.S中实现的汇编函数,它做的事情很简单:
kernel_thread_entry: # void kernel_thread(void)
pushl %edx # push arg
call *%ebx # call fn
pushl %eax # save the return value of fn(arg)
call do_exit # call do_exit to terminate current thread
do_fork函数主要做了以下6件事情:
分配并初始化进程控制块(alloc_proc函数);
分配并初始化内核栈(setup_stack函数);
根据clone_flag标志复制或共享进程内存管理结构(copy_mm函数);
设置进程在内核(将来也包括用户态)正常运行和调度所需的中断帧和执行上下文(copy_thread函数);
把设置好的进程控制块放入hash_list和proc_list两个全局进程链表中;
自此,进程已经准备好执行了,把进程状态设置为“就绪”态;
设置返回码为子进程的id号。
copy_thread函数做的事情比较多,代码如下:
static void
copy_thread(struct proc_struct *proc, uintptr_t esp, struct trapframe *tf) {
    //在内核堆栈的顶部设置中断帧大小的一块栈空间
    proc->tf = (struct trapframe *)(proc->kstack + KSTACKSIZE) - 1;
    *(proc->tf) = *tf; //拷贝在kernel_thread函数建立的临时中断帧的初始值
    proc->tf->tf_regs.reg_eax = 0;
    //设置子进程/线程执行完do_fork后的返回值
    proc->tf->tf_esp = esp; //设置中断帧中的栈指针esp
    proc->tf->tf_eflags |= FL_IF; //使能中断
    proc->context.eip = (uintptr_t)forkret;
    proc->context.esp = (uintptr_t)(proc->tf);
}
设置好中断帧后,最后就是设置initproc的进程上下文,(process context,也称执行现场)了。只有设置好执行现场后,一旦uCore调度器选择了initproc执行,就需要根据initproc->context中保存的执行现场来恢复initproc的执行。这里设置了initproc的执行现场中主要的两个信息:上次停止执行时的下一条指令地址context.eip和上次停止执行时的堆栈地址context.esp。其实initproc还没有执行过,所以这其实就是initproc实际执行的第一条指令地址和堆栈指针。可以看出,由于initproc的中断帧占用了实际给initproc分配的栈空间的顶部,所以initproc就只能把栈顶指针context.esp设置在initproc的中断帧的起始位置。根据context.eip的赋值,可以知道initproc实际开始执行的地方在forkret函数(主要完成do_fork函数返回的处理工作)处。至此,initproc内核线程已经做好准备执行了。

3、调度并执行内核线程 initproc

在uCore执行完proc_init函数后,就创建好了两个内核线程:idleproc和initproc,这时uCore当前的执行现场就是idleproc,等到执行到init函数的最后一个函数cpu_idle之前,uCore的所有初始化工作就结束了,idleproc将通过执行cpu_idle函数让出CPU,给其它内核线程执行,具体过程如下:
void
cpu_idle(void) {
    while (1) {
        if (current->need_resched) {
            schedule();
            ……
对于schedule函数。它的执行逻辑很简单:
①设置当前内核线程current->need_resched为0; 
②在proc_list队列中查找下一个处于“就绪”态的线程或进程next; 
③找到这样的进程后,就调用proc_run函数,保存当前进程current的执行现场(进程上下文),恢复新进程的执行现场,完成进程切换。
至此,新的进程next就开始执行了。由于在proc10中只有两个内核线程,且idleproc要让出CPU给initproc执行,我们可以看到schedule函数通过查找proc_list进程队列,只能找到一个处于“就绪”态的initproc内核线程。并通过proc_run和进一步的switch_to函数完成两个执行现场的切换,具体流程如下:
①让current指向next内核线程initproc;
②设置任务状态段ts中特权态0下的栈顶指针esp0为next内核线程initproc的内核栈的栈顶,即next->kstack + KSTACKSIZE ;
③设置CR3寄存器的值为next内核线程initproc的页目录表起始地址next->cr3,这实际上是完成进程间的页表切换;
由switch_to函数完成具体的两个线程的执行现场切换,即切换各个寄存器,当switch_to函数执行完“ret”指令后,就切换到initproc执行了。
switch_to函数的执行流程:

.globl switch_to
switch_to: # switch_to(from, to)
# 保存前一个进程的执行现场,前两条汇编指令(如下所示)保存了进程在返回switch_to函数后的指令地址到context.eip中
movl 4(%esp), %eax # eax points to from
popl 0(%eax) # esp--> return address, so save return addr in FROM’s
context
# 保存前一个进程的其他7个寄存器到context中的相应成员变量中。
movl %esp, 4(%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 4(%eax), %esp
pushl 0(%eax) # push TO’s context’s eip, so return addr = TO’s eip
ret # after ret, eip= TO’s eip

在对initproc进行初始化时,设置了initproc->context.eip = (uintptr_t)forkret,这样,当执行switch_to函数并返回后,initproc将执行其实际上的执行入口地址forkret。而forkret会调用位于kern/trap/trapentry.S中的forkrets函数执行,具体代码如下:
.globl __trapret
 __trapret:
 # restore registers from stack
 popal
 # restore %ds and %es
 popl %es
 popl %ds
 # get rid of the trap number and error code
 addl $0x8, %esp
 iret
 .globl forkrets
 forkrets:
 # set stack to this new process's trapframe
 movl 4(%esp), %esp //把esp指向当前进程的中断帧
 jmp __trapret

可以看出,forkrets函数首先把esp指向当前进程的中断帧,从_trapret开始执行到iret前,esp指向了current>tf.tf_eip,而如果此时执行的是initproc,则current->tf.tf_eip=kernel_thread_entry,initproc->tf.tf_cs = KERNEL_CS,所以当执行完iret后,就开始在内核中执行kernel_thread_entry函数了,而initproc->tf.tf_regs.reg_ebx = init_main,所以kernl_thread_entry中执行“call %ebx”后,就开始执行initproc的主体了。
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值