ucore操作系统学习(四) ucore lab4内核线程管理

1. ucore lab4介绍

什么是进程?

  现代操作系统为了满足人们对于多道编程的需求,希望在计算机系统上能并发的同时运行多个程序,且彼此间互相不干扰。当一个程序受制于等待I/O完成等事件时,可以让出CPU给其它程序使用,令宝贵的CPU资源得到更充分的利用。

  操作系统作为大总管需要协调管理各个程序对CPU资源的使用,为此抽象出了进程(Process)的概念。进程顾名思义就是进行中、执行中的程序。

  物理层面上,一个CPU核心同一时间只能运行一个程序,或者说一个CPU核心某一时刻只能归属于一个特定进程。但逻辑层面上,操作系统可以进行进程调度,既可以为进程分配CPU资源,令其执行,也可以在发生等待外设I/O时,避免CPU空转而暂时挂起当前进程,令其它进程获得CPU。

  进程能够随时在执行与挂起中切换,且每次恢复运行时都能够接着上次被打断挂起的地方接着执行。这就需要操作系统有能力保留进程在被挂起时的CPU寄存器上下文快照,当CPU中的寄存器被另外的进程给覆盖后,在恢复时能正确的还原之前被打断时的执行现场。新老进程在CPU上交替时,新调度线程上下文的恢复和被调度线程上下文的保存行为被称作进程的上下文切换。

什么是线程?

  进程是一个独立的程序,与其它进程的内存空间是相互隔离的,也作为一个CPU调度的单元工作着,似乎很好的满足了需求。但有时候也存在一些场景,比如一个文件处理程序一方面需要监听并接受来自用户的输入,另一方面也要对用户的输入内容进行复杂,耗费大量时间的数据处理工作。人们希望在一个程序中既能处理耗时的复杂操作(例如定时存盘等大量的磁盘I/O),同时不能阻塞避免其无法及时的响应用户指令。

  由于响应用户输入指令的程序与批处理程序都需要访问同样的内容,虽然操作系统提供了各式各样的进程间通信手段,但依然效率不高,为此,计算机科学家提出了线程(Thread)的概念。

  线程是属于进程的,同一进程下所有线程都共享进程拥有的同一片内存空间,没有额外的访问限制;但每个线程有着自己的执行流和调度状态,包括程序计数器在内的CPU寄存器上下文是线程间独立的。这样上述的需求就能通过在文件处理进程中开启两个线程分别提供用户服务和后台批处理服务来实现。通过操作系统合理的调度,既能实时的处理用户指令,又不耽误后台的批处理任务。

lab4相对于lab3的主要改进

  1. 在/kern/process/proc.[ch]中实现了进程/线程的创建、初始化、退出以及控制线程的运行状态等功能。

  2. 在/kern/process/switch.S中实现了线程的上下文切换功能。

  3. 在/kern/trap/trapentry.S中实现了forkrets,用于do_forks创建子线程后调用的返回处理。

  4. 在/kern/schedule/sched.[ch]中实现了一个最基本的FIFO的线程CPU调度算法。

  5. 参考linux引入了slab分配器,修改了之前实验中对于物理内存分配/回收的逻辑(如果不是学有余力,可以暂时不用理会,当做黑盒子看待就行)。

  lab4是建立在之前实验的基础之上的,需要先理解之前的实验内容才能顺利理解lab4的内容。

可以参考一下我关于前面实验的博客:

  1. ucore操作系统学习(一) ucore lab1系统启动流程分析

  2. ucore操作系统学习(二) ucore lab2物理内存管理分析

  3. ucore操作系统学习(三) ucore lab3虚拟内存管理分析

2. ucore lab4实验细节分析

  得益于ucore在lab2、lab3中建立起了较为完善的物理、虚拟内存管理机制,得以在lab4实验中建立起内存空间独立的进程机制,以及执行流独立的线程功能。

  在ucore中,并不显式的区分进程与线程,都使用同样的数据结构proc_struct进程/线程管理块进行管理。当不同的线程控制块对应的页表(cr3)相同时,ucore认为是同一进程下的不同线程。

proc_struct结构:

// process's state in his life cycle
// 进程状态
enum proc_state {
    // 未初始化
    PROC_UNINIT = 0,  // uninitialized
    // 休眠、阻塞状态
    PROC_SLEEPING,    // sleeping
    // 可运行、就绪状态
    PROC_RUNNABLE,    // runnable(maybe running)
    // 僵尸状态(几乎已经终止,等待父进程回收其所占资源)
    PROC_ZOMBIE,      // almost dead, and wait parent proc to reclaim his resource
};

/**
 * 进程控制块结构(ucore进程和线程都使用proc_struct进行管理)
 * */
struct proc_struct {
    // 进程状态
    enum proc_state state;                      // Process state
    // 进程id
    int pid;                                    // Process ID
    // 被调度执行的总次数
    int runs;                                   // the running times of Proces
    // 当前进程内核栈地址
    uintptr_t kstack;                           // Process kernel stack
    // 是否需要被重新调度,以使当前线程让出CPU
    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
    // 当前进程页表基地址寄存器cr3(指向当前进程的页表物理地址)
    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
};

2.1 线程的创建与初始化

  ucore在lab4中建立了进程/线程的机制,在总控函数kern_init中,通过pmm_init创建了常驻内核的第0号线程idle_proc和第1号线程init_proc

  整个ucore内核可以被视为一个进程(内核进程),而上述两个线程的cr3指向内核页表boot_cr3,且其代码段、数据段选择子特权级都处于内核态,属于内核线程。

proc_init函数:

// proc_init - set up the first kernel thread idleproc "idle" by itself and 
//           - create the second kernel thread init_main
// 初始化第一个内核线程 idle线程、第二个内核线程 init_main线程
void
proc_init(void) {
    int i;

    // 初始化全局的线程控制块双向链表
    list_init(&proc_list);
    // 初始化全局的线程控制块hash表
    for (i = 0; i < HASH_LIST_SIZE; i ++) {
        list_init(hash_list + i);
    }

    // 分配idle线程结构
    if ((idleproc = alloc_proc()) == NULL) {
        panic("cannot alloc idleproc.\n");
    }

    // 为idle线程进行初始化
    idleproc->pid = 0; // idle线程pid作为第一个内核线程,其不会被销毁,pid为0
    idleproc->state = PROC_RUNNABLE; // idle线程被初始化时是就绪状态的
    idleproc->kstack = (uintptr_t)bootstack; // idle线程是第一个线程,其内核栈指向bootstack
    idleproc->need_resched = 1; // idle线程被初始化后,需要马上被调度
    // 设置idle线程的名称
    set_proc_name(idleproc, "idle");
    nr_process ++;

    // current当前执行线程指向idleproc
    current = idleproc;

    // 初始化第二个内核线程initproc, 用于执行init_main函数,参数为"Hello world!!"
    int pid = kernel_thread(init_main, "Hello world!!", 0);
    if (pid <= 0) {
        // 创建init_main线程失败
        panic("create init_main failed.\n");
    }

    // 获得initproc线程控制块
    initproc = find_proc(pid);
    // 设置initproc线程的名称
    set_proc_name(initproc, "init");

    assert(idleproc != NULL && idleproc->pid == 0);
    assert(initproc != NULL && initproc->pid == 1);
}

  在proc_init函数可以看到,ucore中要创建一个新的内核线程(init_proc),是通过kernel_thread实现的。创建内核线程时,新线程相当于是current当前线程fork出的一个子线程。

  调用kernel_thread函数时,需要指定线程的执行入口(例如:init_main),入口函数的参数(例如:"Hello world!"),以及指定是否需要采取写时复制的机制进行fork时父子进程的内存映射。

kern_init函数:

// kernel_thread - create a kernel thread using "fn" function
// NOTE: the contents of temp trapframe tf will be copied to 
//       proc->tf in do_fork-->copy_thread function
// 创建一个内核线程,并执行参数fn函数,arg作为fn的参数
int
kernel_thread(int (*fn)(void *), void *arg, uint32_t clone_flags) {
    struct trapframe tf;
    // 构建一个临时的中断栈帧tf,用于do_fork中的copy_thread函数(因为线程的创建和切换是需要利用CPU中断返回机制的)
    memset(&tf, 0, sizeof(struct trapframe));
    // 设置tf的值
    tf.tf_cs = KERNEL_CS; // 内核线程,设置中断栈帧中的代码段寄存器CS指向内核代码段
    tf.tf_ds = tf.tf_es = tf.tf_ss = KERNEL_DS; // 内核线程,设置中断栈帧中的数据段寄存器指向内核数据段
    tf.tf_regs.reg_ebx = (uint32_t)fn; // 设置中断栈帧中的ebx指向fn的地址
    tf.tf_regs.reg_edx = (uint32_t)arg; // 设置中断栈帧中的edx指向arg的起始地址
    tf.tf_eip = (uint32_t)kernel_thread_entry; // 设置tf.eip指向kernel_thread_entry这一统一的初始化的内核线程入口地址
    return do_fork(clone_flags | CLONE_VM, 0, &tf);
}

do_fork函数:

/* do_fork -     parent process for a new child process
 * @clone_flags: used to guide how to clone the child process
 * @stack:       the parent's user stack pointer. if stack==0, It means to fork a kernel thread.
 * @tf:          the trapframe info, which will be copied to child process's proc->tf
 */
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;
    //LAB4:EXERCISE2 YOUR CODE
    /*
     * Some Useful MACROs, Functions and DEFINEs, you can use them in below implementation.
     * MACROs or Functions:
     *   alloc_proc:   create a proc struct and init fields (lab4:exercise1)
     *   setup_kstack: alloc pages with size KSTACKPAGE as process kernel stack
     *   copy_mm:      process "proc" duplicate OR share process "current"'s mm according clone_flags
     *                 if clone_flags & CLONE_VM, then "share" ; else "duplicate"
     *   copy_thread:  setup the trapframe on the  process's kernel stack top and
     *                 setup the kernel entry point and stack of process
     *   hash_proc:    add proc into proc hash_list
     *   get_pid:      alloc a unique pid for process
     *   wakeup_proc:  set proc->state = PROC_RUNNABLE
     * VARIABLES:
     *   proc_list:    the process set's list
     *   nr_process:   the number of process set
     */

    //    1. call alloc_proc to allocate a proc_struct
    //    2. call setup_kstack to allocate a kernel stack for child process
    //    3. call copy_mm to dup OR share mm according clone_flag
    //    4. call copy_thread to setup tf & context in proc_struct
    //    5. insert proc_struct into hash_list && proc_list
    //    6. call wakeup_proc to make the new child process RUNNABLE
    //    7. set ret vaule using child proc's pid

    // 分配一个未初始化的线程控制块
    if ((proc = alloc_proc()) == NULL) {
        goto fork_out;
    }
    // 其父进程属于current当前进程
    proc->parent = current;

    // 设置,分配新线程的内核栈
    if (setup_kstack(proc) != 0) {
        // 分配失败,回滚释放之前所分配的内存
        goto bad_fork_cleanup_proc;
    }
    // 由于是fork,因此fork的一瞬间父子线程的内存空间是一致的(clone_flags决定是否采用写时复制)
    if (copy_mm(clone_flags, proc) != 0) {
        // 分配失败,回滚释放之前所分配的内存
        goto bad_fork_cleanup_kstack;
    }
    // 复制proc线程时,设置proc的上下文信息
    copy_thread(proc, stack, tf);

    bool intr_flag;
    local_intr_save(intr_flag);
    {
        // 生成并设置新的pid
        proc->pid = get_pid();
        // 加入全局线程控制块哈希表
        hash_proc(proc);
        // 加入全局线程控制块双向链表
        list_add(&proc_list, &(proc->list_link));
        nr_process ++;
    }
    local_intr_restore(intr_flag);
    // 唤醒proc,令其处于就绪态PROC_RUNNABLE
    wakeup_proc(proc);

    ret = proc->pid;
fork_out:
    return ret;

bad_fork_cleanup_kstack:
    put_kstack(proc);
bad_fork_cleanup_proc:
    kfree(proc);
    goto fork_out;
}

copy_thread函数:

// copy_thread - setup the trapframe on the  process's kernel stack top and
//             - setup the kernel entry point and stack of process
static void
copy_thread(struct proc_struct *proc, uintptr_t esp, struct trapframe *tf) {
    // 令proc-tf 指向proc内核栈顶向下偏移一个struct trapframe大小的位置
    proc->tf = (struct trapframe *)(proc->kstack + KSTACKSIZE) - 1;
    // 将参数tf中的结构体数据复制填入上述proc->tf指向的位置(正好是上面struct trapframe指针-1腾出来的那部分空间)
    *(proc->tf) = *tf;
    proc->tf->tf_regs.reg_eax = 0;
    proc->tf->tf_esp = esp;
    proc->tf->tf_eflags |= FL_IF;

    // 令proc上下文中的eip指向forkret,切换恢复上下文后,新线程proc便会跳转至forkret
    proc->context.eip = (uintptr_t)forkret;
    // 令proc上下文中的esp指向proc->tf,指向中断返回时的中断栈帧
    proc->context.esp = (uintptr_t)(proc->tf);
}

2.2 ucore线程调度时线程上下文的切换

  由于在proc_init中,令全局变量current指向了idle_proc,代表当前占用CPU的是线程idel_proc,设置idel_proc的need_resched为1。proc_init函数返回,总控函数kern_init完成了一系列初始化工作后,最终执行了cpu_idle函数。

  cpu_idle函数可以视为idle_proc的执行流,在其中进行了一个while(1)的无限循环,当发现自己需要被调度时,调用schedule函数进行一次线程的调度。

cpu_idle函数:

// cpu_idle - at the end of kern_init, the first kernel thread idleproc will do below works
void
cpu_idle(void) {
    while (1) {
        // idle线程执行逻辑就是不断的自旋循环,当发现存在有其它线程可以被调度时
        // idle线程,即current.need_resched会被设置为真,之后便进行一次schedule线程调度
        if (current->need_resched) {
            schedule();
        }
    }
}

  schedule函数中,会先关闭中断,避免调度的过程中被中断再度打断而出现并发问题。然后从ucore的就绪线程队列中,按照某种调度算法选择出下一个需要获得CPU的就绪线程。

  通过proc_run函数,令就绪线程的状态从就绪态转变为运行态,并切换线程的上下文,保存current线程(例如:idle_proc)的上下文,并在CPU上恢复新调度线程(例如:init_proc)的上下文。

schedule函数:

/**
 * 进行CPU调度
 * */
void
schedule(void) {
    bool intr_flag;
    list_entry_t *le, *last;
    struct proc_struct *next = NULL;
    // 暂时关闭中断,避免被中断打断,引起并发问题
    local_intr_save(intr_flag);
    {
        // 令current线程处于不需要调度的状态
        current->need_resched = 0;
        // lab4中暂时没有更多的线程,没有引入线程调度框架,而是直接先进先出的获取init_main线程进行调度
        last = (current == idleproc) ? &proc_list : &(current->list_link);
        le = last;
        do {
            if ((le = list_next(le)) != &proc_list) {
                next = le2proc(le, list_link);
                // 找到一个处于PROC_RUNNABLE就绪态的线程
                if (next->state == PROC_RUNNABLE) {
                    break;
                }
            }
        } while (le != last);
        if (next == NULL || next->state != PROC_RUNNABLE) {
            // 没有找到,则next指向idleproc线程
            next = idleproc;
        }
        // 找到的需要被调度的next线程runs自增
        next->runs ++;
        if (next != current) {
            // next与current进行上下文切换,令next获得CPU资源
            proc_run(next);
        }
    }
    // 恢复中断
    local_intr_restore(intr_flag);
}

 proc_run函数:

// proc_run - make process "proc" running on cpu
// NOTE: before call switch_to, should load  base addr of "proc"'s new PDT
// 进行线程调度,令当前占有CPU的让出CPU,并令参数proc指向的线程获得CPU控制权
void
proc_run(struct proc_struct *proc) {
    if (proc != current) {
        // 只有当proc不是当前执行的线程时,才需要执行
        bool intr_flag;
        struct proc_struct *prev = current, *next = proc;

        // 切换时新线程任务时需要暂时关闭中断,避免出现嵌套中断
        local_intr_save(intr_flag);
        {
            current = proc;
            // 设置TSS任务状态段的esp0的值,令其指向新线程的栈顶
            // ucore参考Linux的实现,不使用80386提供的TSS任务状态段这一硬件机制实现任务上下文切换,ucore在启动时初始化TSS后(init_gdt),便不再对其进行修改。
            // 但进行中断等操作时,依然会用到当前TSS内的esp0属性。发生用户态到内核态中断切换时,硬件会将中断栈帧压入TSS.esp0指向的内核栈中
            // 因此ucore中的每个线程,需要有自己的内核栈,在进行线程调度切换时,也需要及时的修改esp0的值,使之指向新线程的内核栈顶。
            load_esp0(next->kstack + KSTACKSIZE);
            // 设置cr3寄存器的值,令其指向新线程的页表
            lcr3(next->cr3);
            // switch_to用于完整的进程上下文切换,定义在统一目录下的switch.S中
            // 由于涉及到大量的寄存器的存取操作,因此使用汇编实现
            switch_to(&(prev->context), &(next->context));
        }
        local_intr_restore(intr_flag);
    }
}

2.3 什么是线程的上下文?

  在proc_run中,调用了switch_to函数。switch_to是汇编实现的函数(子过程),其参数是两个struct context结构体的指针。

  第一个参数from代表着当前线程的上下文,第二个参数to代表着新线程的上下文,switch_to的功能就是保留current线程的上下文至from上下文结构中,并将to上下文结构中的内容加载到CPU的各个寄存器中,恢复新线程的执行流上下文现场。

struct context:

// Saved registers for kernel context switches.
// Don't need to save all the %fs etc. segment registers,
// because they are constant across kernel contexts.
// Save all the regular registers so we don't need to care
// which are caller save, but not the return register %eax.
// (Not saving %eax just simplifies the switching code.)
// The layout of context must match code in switch.S.
// 当进程切换时保存的当前寄存器上下文
struct context {
    uint32_t eip;
    uint32_t esp;
    uint32_t ebx;
    uint32_t ecx;
    uint32_t edx;
    uint32_t esi;
    uint32_t edi;
    uint32_t ebp;
};

switch_to函数定义:

void switch_to(struct context *from, struct context *to);

switch_to实现: 

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

    # save from registers
    # 令eax保存第一个参数from(context)的地址
    movl 4(%esp), %eax          # eax points to from
    # from.context 保存eip、esp等等寄存器的当前快照值
    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 registers
    # 令eax保存第二个参数next(context)的地址,因为之前popl了一次,所以4(%esp)目前指向第二个参数
    movl 4(%esp), %eax          # not 8(%esp): popped return address already
                                # eax now points to to
    # 恢复next.context中的各个寄存器的值
    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时栈上的eip为next(context)中设置的值(fork时,eip指向 forkret,esp指向分配好的trap_frame)
    ret

  由于函数调用时是先调用后返回的,整个执行流程体现出一种先进后出的结构,因此普遍采用栈来实现函数调用,且不同执行流之间的栈是互相隔离的。ucore中,线程的上下文除了各个通用寄存器、段寄存器、指令指针寄存器等寄存器上下文之外,还需要额外的维护各自的栈结构。当然如果发生了进程间的切换,还需要切换页表。

  80386由于引入了特权级机制,为了避免不同特权级之间栈上数据的互相干扰,要求一个程序(线程)在不同特权级下维护不同的栈。具体的各个特权级栈指针存储在当前程序的TSS任务状态段中,由TR寄存器控制。80386的设计者希望操作系统的设计者通过TSS任务状态段机制,由硬件来处理不同任务(线程执行流)的上下文切换。

  也许是出于对操作系统与硬件耦合性以及性能的影响,Linux内核并没有充分的利用80386提供的任务切换机制。

linux不使用任务门(转载):

  Intel的这种设计确实很周到,也为任务切换提供了一个非常简洁的机制。但是,由于i386的系统结构基本上是CISC的,通过JMP指令或CALL(或中断)完成任务的过程实际上是“复杂指令”的执行过程,其执行过程长达300多个CPU周期(一个POP指令占12个CPU周期),因此,Linux内核并不完全使用i386CPU提供的任务切换机制。

  由于i386CPU要求软件设置TR及TSS,Linux内核只不过“走过场”地设置TR及TSS,以满足CPU的要求。但是,内核并不使用任务门,也不使用JMP或CALL指令实施任务切换。内核只是在初始化阶段设置TR,使之指向一个TSS,从此以后再不改变TR的内容了。也就是说,每个CPU(如果有多个CPU)在初始化以后的全部运行过程中永远使用那个初始的TSS。同时,内核也不完全依靠TSS保存每个进程切换时的寄存器副本,而是将这些寄存器副本保存在各个进程自己的内核栈中。

  这样一来,TSS中的绝大部分内容就失去了原来的意义。那么,当进行任务切换时,怎样自动更换堆栈?我们知道,新任务的内核栈指针(SS0和ESP0)应当取自当前任务的TSS,可是,Linux中并不是每个任务就有一个TSS,而是每个CPU只有一个TSS。Intel原来的意图是让TR的内容(即TSS)随着任务的切换而走马灯似地换,而在Linux内核中却成了只更换TSS中的SS0和ESP0,而不更换TSS本身,也就是根本不更换TR的内容。这是因为,改变TSS中SS0和ESP0所花费的开销比通过装入TR以更换一个TSS要小得多。因此,在Linux内核中,TSS并不是属于某个进程的资源,而是全局性的公共资源。在多处理机的情况下,尽管内核中确实有多个TSS,但是每个CPU仍旧只有一个TSS。

为什么线程切换时要修改esp0?

  ucore在设计上大量参考了早期32位linux内核的设计,因此和Linux一样也没有完全利用硬件提供的任务切换机制。整个OS周期只在内核初始化时设置了TR寄存器和TSS段的内容(gdt_init函数中),之后便不再对其进行大的修改,而是仅仅在线程上下文切换时,令TSS段中的esp0指向当前线程的内核栈顶(proc_run)。这么做的原因一是ucore只使用了ring0和ring3两个特权级,所有线程的ring0内核栈是由ucore全盘控制的,而在后续lab5之后的用户态线程其ring3栈则是由应用程序自己控制的;二是由于在发生特权级切换的中断时,80386CPU会将中断参数压入新特权级对应的栈上,如果发生用户态->内核态的切换时,esp0必须指向当前线程自己的内核栈,否则将会出现不同线程内核栈数据的混乱,造成严重后果。

2.4 init_proc线程生命周期全过程分析

  分析到switch_to之后,init_proc线程似乎已经完成了从创建并初始化并进行上下文切换,获得并占用CPU的全过程。但实际上还剩下了关键的一环没有分析。

  在idle_proc和init_proc上下文切换switch_to返回时,CPU中的各个寄存器已经被init_proc线程的context上下文覆盖了,此时switch_to的ret返回将会返回到哪里呢?

  答案就在copy_thread函数中通过语句proc->context.eip = (uintptr_t)forkret处,switch_to返回后将会跳转到forkret这一所有线程完成初始化后统一跳转的入口;在copy_thread中同时也设置了当前的栈顶指针esp指向proc->tf。

forkret函数:

// forkrets定义在/kern/trap/trapentry.S中的
void forkrets(struct trapframe *tf);

// forkret -- the first kernel entry point of a new thread/process
// NOTE: the addr of forkret is setted in copy_thread function
//       after switch_to, the current proc will execute here.
static void
forkret(void) {
    forkrets(current->tf);
}

  forkrets中令栈顶指针指向了前面设置好的trap_frame首地址后,便跳转至__trapret,进行了中断返回操作。

  在__trapret中,会依次将前面设置好的临时trap_frame中断栈帧中的各个数据依次还原,执行iret,完成中断返回。

trapentry.S(部分):

.globl __trapret
__trapret:
    # restore registers from stack
    popal

    # restore %ds, %es, %fs and %gs
    popl %gs
    popl %fs
    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
    jmp __trapret

  中断返回时,其cs、eip会依次从中断栈帧中还原,中断栈帧中eip是通过kern_thread中的语句(tf.tf_eip = (uint32_t)kernel_thread_entry; ),指向了kernel_thread_entry。因此中断返回后会跳转到kernel_thread_entry函数入口处执行。

kernel_thread_entry定义:

// kernel_thread_entry定义在/kern/process/entry.S中
void kernel_thread_entry(void);

kernel_thread_entry实现:

.text
.globl kernel_thread_entry
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

  kernel_thread_entry中,将寄存器edx中的数据压入栈中,并跳转至ebx指向的程序入口。那么edx和ebx到底是什么呢?edx和ebx都是在前面中断返回时通过__traprets的popal指令,从init_proc创建时构造的临时中断栈帧中弹出的数据。

  回顾一下kern_thread,其中ebx保存的就是传入的fn,即init_main函数的地址,而edx则保存了arg参数,即"Hello world!!"字符串。

  因此当init_proc执行到kernel_thread_entry时,实际上就是将参数"Hello world!!"地址压入了栈中,并且调用init_main函数,传入栈上参数"Hello world"的地址并将其打印在标准输出控制台上。

  随后,init_main函数执行完毕并返回,保留了返回值eax的值之后,kernel_thread_entry简单的调用了do_exit函数,终止了init_proc当前线程。

kern_thread函数:

// kernel_thread - create a kernel thread using "fn" function
// NOTE: the contents of temp trapframe tf will be copied to 
//       proc->tf in do_fork-->copy_thread function
// 创建一个内核线程,并执行参数fn函数,arg作为fn的参数
int
kernel_thread(int (*fn)(void *), void *arg, uint32_t clone_flags) {
    struct trapframe tf;
    // 构建一个临时的中断栈帧tf,用于do_fork中的copy_thread函数(因为线程的创建和切换是需要利用CPU中断返回机制的)
    memset(&tf, 0, sizeof(struct trapframe));
    // 设置tf的值
    tf.tf_cs = KERNEL_CS; // 内核线程,设置中断栈帧中的代码段寄存器CS指向内核代码段
    tf.tf_ds = tf.tf_es = tf.tf_ss = KERNEL_DS; // 内核线程,设置中断栈帧中的数据段寄存器指向内核数据段
    tf.tf_regs.reg_ebx = (uint32_t)fn; // 设置中断栈帧中的ebx指向fn的地址
    tf.tf_regs.reg_edx = (uint32_t)arg; // 设置中断栈帧中的edx指向arg的起始地址
    tf.tf_eip = (uint32_t)kernel_thread_entry; // 设置tf.eip指向kernel_thread_entry这一统一的初始化的内核线程入口地址
    return do_fork(clone_flags | CLONE_VM, 0, &tf);
}

init_proc线程的整个生命周期:

  1. 通过kernel_thread函数,构造一个临时的trap_frame栈帧,其中设置了cs指向内核代码段选择子、ds/es/ss等指向内核的数据段选择子。令中断栈帧中的tf_regs.ebx、tf_regs.edx保存参数fn和arg,tf_eip指向kernel_thread_entry。

  2. 通过do_fork分配一个未初始化的线程控制块proc_struct,设置并初始化其一系列状态。将init_proc加入ucore的就绪队列,等待CPU调度。

  3. 通过copy_thread中设置用户态线程/内核态进程通用的中断栈帧数据,设置线程上下文struct context中eip、esp的值,令上下文切换switch返回后跳转到forkret处。

  4. idle_proc在cpu_idle中触发schedule,将init_proc线程从就绪队列中取出,执行switch_to进行idle_proc和init_proc的context线程上下文的切换。

  5. switch_to返回时,CPU开始执行init_proc的执行流,跳转至之前构造好的forkret处。

  6. fork_ret中,进行中断返回。将之前存放在内核栈中的中断栈帧中的数据依次弹出,最后跳转至kernel_thread_entry处。

  7.kernel_thread_entry中,利用之前在中断栈中设置好的ebx(fn),edx(arg)执行真正的init_proc业务逻辑的处理(init_main函数),在init_main返回后,跳转至do_exit终止退出。

为什么在switch_to上下文切换后,还需要进行一次中断返回?

  相信不少初学者和当初的我一样,会产生一个问题:为什么在init_proc线程上下文切换时,不直接控制流跳转至init_main函数,而是绕了一个大弯,非要通过中断间接实现?

  这是因为ucore在lab4中需要为后续的用户态进程/线程的创建打好基础。由于目前我们所有的程序逻辑都是位于内核中的,拥有ring0的最高优先级,所以暂时感受不到通过中断间接切换线程上下文的好处。但是在后面引入用户态进程/线程概念后,这一机制将显得十分重要。

  当应用程序申请创建一个用户态进程时,需要ucore内核为其分配各种内核数据结构。由于特权级的限制,需要令应用程序通过一个调用门陷入内核(执行系统调用),令其CPL特权级从ring3提升到ring0。但是当用户进程被初始化完毕后,进入调度执行状态后,为了内核的安全就不能允许用户进程继续处于内核态了,否则操作系统的安全性将得不到保障。而要令一个ring0的进程回到ring3的唯一方法便是使用中断返回机制,在用户进程/线程创建过程中“伪造”一个中断栈帧,令其中断返回到ring3的低特权级中,开始执行自己的业务逻辑。

  以上述init_proc的例子来说,如果init_proc不是一个内核线程,那么在构造临时的中断栈帧时,其cs、ds/es/ss等段选择子将指向用户态特权级的段选择子。这样中断返回时通过对栈上临时中断栈帧数据的弹出,进行各个寄存器的复原。当跳转至用户态线程入口时,应用程序已经进入ring3低特权级了。这样既实现了用户线程的创建,也使得应用程序无法随意的访问内核数据而破坏系统内核。

3. 总结

  ucore通过lab4、lab5建立起了进程/线程机制,能够通过线程的上下文切换,交替的处理不同线程的工作流在一个CPU核心上并发的执行。

  通过ucore lab4实验的学习,了解到操作系统创建线程,维护线程都存在一定的时间、空间上的开销,线程的上下文切换也是一个较为繁琐、耗时的操作。

  这也是为什么在应用程序中都推荐使用线程池将所申请的内核线程缓存起来,减少反复创建、销毁内核级线程的额外开销以提高效率。另一方面,也意识到为什么即使内存空间足够,像web服务器这样的I/O密集型应用程序也无法单纯的依靠增加线程的数量来应对上万甚至更多的并发请求。因为陷入内核的系统级线程上下文切换是如此的消耗CPU资源,以至于当并发连接过高时,几乎所有的CPU资源都消耗在了内核线程上下文切换上,而无暇处理业务逻辑。这也是I/O多路复用技术被广泛使用的原因。

  区别于最原始的一个线程阻塞式的负责处理一个I/O套接字,web服务器通过维护一个线程池来并发处理请求的传统阻塞式I/O模式。操作系统内核提供的I/O多路复用功能,使得一个线程可以同时维护、处理多个I/O套接字,通过事件通知机制处理业务逻辑。I/O多路复用允许一个线程同时处理成百上千的并发连接,减少了内核级线程上下文切换的次数,极大的提高了web服务器这样I/O密集型应用的性能。这也是nginx、redis、netty、nodeJS等以高性能著称的应用程序普遍以I/O多路复用技术作为其核心的重要原因。

  这篇博客的完整代码注释在我的github上:https://github.com/1399852153/ucore_os_lab (fork自官方仓库)中的lab4_answer。

  希望我的博客能帮助到对操作系统、ucore os感兴趣的人。存在许多不足之处,还请多多指教。

posted on 2020-11-01 15:15  小熊餐馆  阅读(2267)  评论(0编辑  收藏  举报