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感興趣的人。存在許多不足之處,還請多多指教。


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM