ucoreOS_lab4 實驗報告

所有的實驗報告將會在 Github 同步更新,更多內容請移步至Github:https://github.com/AngelKitty/review_the_national_post-graduate_entrance_examination/blob/master/books_and_notes/professional_courses/operating_system/sources/ucore_os_lab/docs/lab_report/

練習0:填寫已有實驗

lab4 會依賴 lab1、lab2lab3,我們需要把做的 lab1、lab2lab3 的代碼填到 lab4 中缺失的位置上面。練習 0 就是一個工具的利用。這裏我使用的是 Linux 下的系統已預裝好的 Meld Diff Viewer 工具。和 lab3 操作流程一樣,我們只需要將已經完成的 lab1、lab2lab3 與待完成的 lab4 (由於 lab4 是基於 lab1、lab2、lab3 基礎上完成的,所以這裏只需要導入 lab3 )分別導入進來,然後點擊 compare 就行了。

compare

然後軟件就會自動分析兩份代碼的不同,然後就一個個比較比較複製過去就行了,在軟件裏面是可以支持打開對比複製了,點擊 Copy Right 即可。當然 bin 目錄和 obj 目錄下都是 make 生成的,就不用複製了,其他需要修改的地方主要有以下五個文件,通過對比複製完成即可:

default_pmm.c
pmm.c
swap_fifo.c
vmm.c
trap.c

練習1:分配並初始化一個進程控制塊(需要編碼)

內核線程是一種特殊的進程,內核線程與用戶進程的區別有兩個:

  • 內核線程只運行在內核態,而用戶進程會在在用戶態和內核態交替運行;
  • 所有內核線程直接使用共同 ucore 內核內存空間,不需爲每個內核線程維護單獨的內存空間,而用戶進程需要維護各自的用戶內存空間。

這裏主要是從 kern_init 函數的物理內存管理初始化開始的,按照函數的次序做了一個簡單的總結:

  • 1、pmm_init()
    • (1) 初始化物理內存管理器。
    • (2) 初始化空閒頁,主要是初始化物理頁的 Page 數據結構,以及建立頁目錄表和頁表。
    • (3) 初始化 boot_cr3 使之指向了 ucore 內核虛擬空間的頁目錄表首地址,即一級頁表的起始物理地址。
    • (4) 初始化第一個頁表 boot_pgdir。
    • (5) 初始化 GDT,即全局描述符表。
  • 2、pic_init()
    • 初始化 8259A 中斷控制器
  • 3、idt_init()
    • 初始化 IDT,即中斷描述符表
  • 4、vmm_init()
    • 主要就是實驗了一個 do_pgfault() 函數達到頁錯誤異常處理功能,以及虛擬內存相關的 mm,vma 結構數據的創建/銷燬/查找/插入等函數
  • 5、proc_init()
    • 這個函數啓動了創建內核線程的步驟,完成了 idleproc 內核線程和 initproc 內核線程的創建或複製工作,這是本次實驗分析的重點,後面將詳細分析。
  • 6、ide_init()
    • 完成對用於頁換入換出的硬盤(簡稱 swap 硬盤)的初始化工作
  • 7、swap_init()
    • swap_init() 函數首先建立完成頁面替換過程的主要功能模塊,即 swap_manager,其中包含了頁面置換算法的實現

操作系統是以進程爲中心設計的,所以其首要任務是爲進程建立檔案,進程檔案用於表示、標識或描述進程,即進程控制塊。這裏需要完成的就是一個進程控制塊的初始化。

而這裏我們分配的是一個內核線程的 PCB,它通常只是內核中的一小段代碼或者函數,沒有用戶空間。而由於在操作系統啓動後,已經對整個核心內存空間進行了管理,通過設置頁表建立了核心虛擬空間(即 boot_cr3 指向的二級頁表描述的空間)。所以內核中的所有線程都不需要再建立各自的頁表,只需共享這個核心虛擬空間就可以訪問整個物理內存了。

首先在 kern/process/proc.h 中定義了 PCB,即進程控制塊的結構體 proc_struct,如下:

struct proc_struct {             //進程控制塊
    enum proc_state state;       //進程狀態
    int pid;                     //進程ID
    int runs;                    //運行時間
    uintptr_t kstack;            //內核棧位置
    volatile bool need_resched;  //是否需要調度
    struct proc_struct *parent;  //父進程
    struct mm_struct *mm;        //進程的虛擬內存
    struct context context;      //進程上下文
    struct trapframe *tf;        //當前中斷幀的指針
    uintptr_t cr3;               //當前頁表地址
    uint32_t flags;              //進程
    char name[PROC_NAME_LEN + 1];//進程名字
    list_entry_t list_link;      //進程鏈表       
    list_entry_t hash_link;      //進程哈希表            
};

這裏簡單介紹下各個參數:

  • state:進程所處的狀態。
    • PROC_UNINIT // 未初始狀態
    • PROC_SLEEPING // 睡眠(阻塞)狀態
    • PROC_RUNNABLE // 運行與就緒態
    • PROC_ZOMBIE // 僵死狀態
  • pid:進程 id 號。
  • kstack:記錄了分配給該進程/線程的內核桟的位置。
  • need_resched:是否需要調度
  • parent:用戶進程的父進程。
  • mm:即實驗三中的描述進程虛擬內存的結構體
  • context:進程的上下文,用於進程切換。
  • tf:中斷幀的指針,總是指向內核棧的某個位置。中斷幀記錄了進程在被中斷前的狀態。
  • cr3:記錄了當前使用的頁表的地址

而這裏要求我們完成一個 alloc_proc 函數來負責分配一個新的 struct proc_struct 結構,根據提示我們需要初始化一些變量,具體的代碼如下:

* 實現思路:
該函數的具體含義爲創建一個新的進程控制塊,並且對控制塊中的所有成員變量進行初始化,根據實驗指導書中的要求,除了指定的若干個成員變量之外,其他成員變量均初始化爲0,取特殊值的成員變量如下所示:
proc->state = PROC_UNINIT;
proc->pid = -1;
proc->cr3 = boot_cr3; // 由於是內核線程,共用一個虛擬內存空間
對於其他成員變量中佔用內存空間較大的,可以考慮使用 memset 函數進行初始化。
--------------------------------------------------------------------------------------------
/*code*/
static struct proc_struct *alloc_proc(void) {
    struct proc_struct *proc = kmalloc(sizeof(struct proc_struct));
    if (proc != NULL) {
        proc->state = PROC_UNINIT;  //設置進程爲未初始化狀態
        proc->pid = -1;             //未初始化的的進程id爲-1
        proc->runs = 0;             //初始化時間片
        proc->kstack = 0;           //內存棧的地址
        proc->need_resched = 0;     //是否需要調度設爲不需要
        proc->parent = NULL;        //父節點設爲空
        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);//進程名
    }
    return proc;
}

請說明 proc_struct 中 struct context contextstruct trapframe *tf 成員變量含義和在本實驗中的作用是啥?(提示通過看代碼和編程調試可以判斷出來)

首先不妨查看 struct context 結構體的定義,可以發現在結構體中存儲這除了 eax 之外的所有通用寄存器以及 eip 的數值,這就提示我們這個線程控制塊中的 context 很有可能是保存的線程運行的上下文信息;
  
接下來使用 find grep 命令查找在 ucore 中對 context 成員變量進行了設置的代碼,總共可以發現兩處,分別爲 Swtich.S 和 proc.c 中的 copy_thread 函數中,在其他部分均沒有發現對 context 的引用和定義(除了初始化);那麼根據 Swtich 中代碼的語義,可以確定 context 變量的意義就在於內核線程之間進行切換的時候,將原先的線程運行的上下文保存下來這一作用。

那麼爲什麼沒有對 eax 進行保存呢?注意到在進行切換的時候調用了 switch_to 這一個函數,也就是說這個函數的裏面纔是線程之間切換的切換點,而在這個函數裏面,由於 eax 是一個 caller-save 寄存器,並且在函數裏 eax 的數值一直都可以在棧上找到對應,因此沒有比較對其進行保存。

在 context 中保存着各種寄存器的內容,主要保存了前一個進程的現場(各個寄存器的狀態),是進程切換的上下文內容,這是爲了保存進程上下文,用於進程切換,爲進程調度做準備。

在 ucore 中,所有的進程在內核中也是相對獨立的。使用 context 保存寄存器的目的就在於在內核態中能夠進行上下文之間的切換。實際利用 context 進行上下文切換的函數是在 kern/process/switch.S 中定義 switch_to 函數。
--------------------------------------------------------------------------------------------
/*code*/
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;
};
--------------------------------------------------------------------------------------------
接下來同樣在代碼中尋找對 tf 變量進行了定義的地方,最後可以發現在 copy_thread 函數中對 tf 進行了設置,但是值得注意的是,在這個函數中,同時對 context 變量的 esp 和 eip 進行了設置。前者設置爲 tf 變量的地址,後者設置爲 forkret 這個函數的指針。接下來觀察 forkret 函數,發現這個函數最終調用了 __trapret 進行中斷返回,這樣的話,tf 變量的作用就變得清晰起來了。

tf 變量的作用在於在構造出了新的線程的時候,如果要將控制權交給這個線程,是使用中斷返回的方式進行的(跟lab1中切換特權級類似的技巧),因此需要構造出一個僞造的中斷返回現場,也就是 trapframe,使得可以正確地將控制權轉交給新的線程;具體切換到新的線程的做法爲:
* 調用switch_to函數。
* 然後在該函數中進行函數返回,直接跳轉到 forkret 函數。
* 最終進行中斷返回函數 __trapret,之後便可以根據 tf 中構造的中斷返回地址,切換到新的線程了。

trapframe 保存着用於特權級轉換的棧 esp 寄存器,當進程發生特權級轉換的時候,中斷幀記錄了進入中斷時任務的上下文。當退出中斷時恢復環境。

tf 是一箇中斷幀的指針,總是指向內核棧的某個位置:
* 當進程從用戶空間跳到內核空間時,中斷幀記錄了進程在被中斷前的狀態。
* 當內核需要跳回用戶空間時,需要調整中斷幀以恢復讓進程繼續執行的各寄存器值。
* 除此之外,ucore 內核允許嵌套中斷,因此爲了保證嵌套中斷髮生時 tf 總是能夠指向當前的 trapframe,ucore 在內核棧上維護了 tf 的鏈。
--------------------------------------------------------------------------------------------
/*code*/
struct trapframe {
    struct pushregs {
        uint32_t reg_edi;
        uint32_t reg_esi;
        uint32_t reg_ebp;
        uint32_t reg_oesp;          /* Useless */
        uint32_t reg_ebx;
        uint32_t reg_edx;
        uint32_t reg_ecx;
        uint32_t reg_eax;
    };
    uint16_t tf_gs;
    uint16_t tf_padding0;
    uint16_t tf_fs;
    uint16_t tf_padding1;
    uint16_t tf_es;
    uint16_t tf_padding2;
    uint16_t tf_ds;
    uint16_t tf_padding3;
    uint32_t tf_trapno;
    /* below here defined by x86 hardware */
    uint32_t tf_err;
    uintptr_t tf_eip;
    uint16_t tf_cs;
    uint16_t tf_padding4;
    uint32_t tf_eflags;
    /* below here only when crossing rings, such as from user to kernel */
    uintptr_t tf_esp;
    uint16_t tf_ss;
    uint16_t tf_padding5;
} __attribute__((packed));

根據這張圖可以看出,內核態和用戶態的轉換首先是留下 SS 和 ESP 的位置,然後調用中斷,改中斷棧裏面的內容, 然後退出中斷的時候跳到內核態中,最後將 ebp 賦給 esp 修復 esp 的位置。

pcb

練習2:爲新創建的內核線程分配資源(需要編碼)

alloc_proc 實質只是找到了一小塊內存用以記錄進程的必要信息,並沒有實際分配這些資源,而練習 2 完成的 do_fork 纔是真正完成了資源分配的工作,當然,do_fork 也只是創建當前內核線程的一個副本,它們的執行上下文、代碼、數據都一樣,但是存儲位置不同。

根據提示及閱讀源碼可知,它完成的工作主要如下:

  • 1、分配並初始化進程控制塊( alloc_proc 函數);
  • 2、分配並初始化內核棧,爲內核進程(線程)建立棧空間( setup_stack 函數);
  • 3、根據 clone_flag 標誌複製或共享進程內存管理結構( copy_mm 函數);
  • 4、設置進程在內核(將來也包括用戶態)正常運行和調度所需的中斷幀和執行上下文
    ( copy_thread 函數);
  • 5、爲進程分配一個 PID( get_pid() 函數);
  • 6、把設置好的進程控制塊放入 hash_list 和 proc_list 兩個全局進程鏈表中;
  • 7、自此,進程已經準備好執行了,把進程狀態設置爲“就緒”態;
  • 8、設置返回碼爲子進程的 PID 號。

實現過程如下:

* 實現思路:
該函數的語義爲爲內核線程創建新的線程控制塊,並且對控制塊中的每個成員變量進行正確的設置,使得之後可以正確切換到對應的線程中執行。
proc = alloc_proc(); // 爲要創建的新的線程分配線程控制塊的空間
if (proc == NULL) goto fork_out; // 判斷是否分配到內存空間
assert(setup_kstack(proc) == 0);  // 爲新的線程設置棧,在本實驗中,每個線程的棧的大小初始均爲 2 個 Page,即 8KB
assert(copy_mm(clone_flags, proc) == 0);  // 對虛擬內存空間進行拷貝,由於在本實驗中,內核線程之間共享一個虛擬內存空間,因此實際上該函數不需要進行任何操作
copy_thread(proc, stack, tf); // 在新創建的內核線程的棧上面設置僞造好的中端幀,便於後文中利用 iret 命令將控制權轉移給新的線程
proc->pid = get_pid(); // 爲新的線程創建 pid
hash_proc(proc); // 將線程放入使用 hash 組織的鏈表中,便於加速以後對某個指定的線程的查找
nr_process ++; // 將全局線程的數目加 1
list_add(&proc_list, &proc->list_link); // 將線程加入到所有線程的鏈表中,便於進行調度
wakeup_proc(proc); // 喚醒該線程,即將該線程的狀態設置爲可以運行
ret = proc->pid; // 返回新線程的pid
--------------------------------------------------------------------------------------------
/*code*/
/* 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) { //分配進程數大於 4096,返回
        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) { //調用 alloc_proc() 函數申請內存塊,如果失敗,直接返回處理
        goto fork_out;//返回
    }

    proc->parent = current; //將子進程的父節點設置爲當前進程

    if (setup_kstack(proc) != 0) { //調用 setup_stack() 函數爲進程分配一個內核棧
        goto bad_fork_cleanup_proc; //返回
    }
    if (copy_mm(clone_flags, proc) != 0) { //調用 copy_mm() 函數複製父進程的內存信息到子進程
        goto bad_fork_cleanup_kstack; //返回
    }
    copy_thread(proc, stack, tf); //調用 copy_thread() 函數複製父進程的中斷幀和上下文信息
    //將新進程添加到進程的 hash 列表中
    bool intr_flag;
    local_intr_save(intr_flag); //屏蔽中斷,intr_flag 置爲 1
    {
        proc->pid = get_pid(); //獲取當前進程 PID
        hash_proc(proc); //建立 hash 映射
        list_add(&proc_list, &(proc->list_link)); //將進程加入到進程的鏈表中
        nr_process ++; //進程數加 1
    }
    local_intr_restore(intr_flag); //恢復中斷

    wakeup_proc(proc); //一切就緒,喚醒子進程

    ret = proc->pid; //返回子進程的 pid
fork_out: //已分配進程數大於 4096
    return ret;

bad_fork_cleanup_kstack: //分配內核棧失敗
    put_kstack(proc);
bad_fork_cleanup_proc:
    kfree(proc);
    goto fork_out;
}

請說明 ucore 是否做到給每個新 fork 的線程一個唯一的 id?請說明你的分析和理由。

可以。保證每個 fork 的線程給的 ID 唯一,調用的 get_pid() 函數,每次都從進程控制塊鏈表中找到合適的 ID。線程的 PID 由 get_pid 函數產生,該函數中包含了兩個靜態變量 last_pid 以及 next_safelast_pid 變量保存上一次分配的 PID,而 next_safe 和 last_pid 一起表示一段可以使用的 PID 取值範圍 ,同時要求 PID 的取值範圍爲 last_pidnext_safe 被初始化爲 MAX_PID。每次調用 get_pid 時,除了確定一個可以分配的 PID 外,還需要確定 next_safe 來實現均攤以此優化時間複雜度,PID 的確定過程中會檢查所有進程的 PID,來確保 PID 是唯一的。

接下來不妨分析該函數的內容:

* 在該函數中使用到了兩個靜態的局部變量 next_safe 和 last_pid,根據命名推測,在每次進入 get_pid 函數的時候,這兩個變量的數值之間的取值均是合法的 pid(也就是說沒有被使用過),這樣的話,如果有嚴格的 next_safe > last_pid + 1,那麼久可以直接取 last_pid + 1 作爲新的 pid(需要 last_pid 沒有超出 MAX_PID 從而變成 1);

* 如果在進入函數的時候,這兩個變量之後沒有合法的取值,也就是說 next_safe > last_pid + 1 不成立,那麼進入循環,在循環之中首先通過 if (proc->pid == last_pid) 這一分支確保了不存在任何進程的 pid 與 last_pid 重合,然後再通過 if (proc->pid > last_pid && next_safe > proc->pid) 這一判斷語句保證了不存在任何已經存在的 pid 滿足:last_pid < pid < next_safe,這樣就確保了最後能夠找到這麼一個滿足條件的區間,獲得合法的 pid;

* 之所以在該函數中使用瞭如此曲折的方法,維護一個合法的 pid 的區間,是爲了優化時間效率,如果簡單的暴力的話,每次需要枚舉所有的 pid,並且遍歷所有的線程,這就使得時間代價過大,並且不同的調用 get_pid 函數的時候不能利用到先前調用這個函數的中間結果;
--------------------------------------------------------------------------------------------
/*code*/
// get_pid - alloc a unique pid for process
static int get_pid(void) {
    static_assert(MAX_PID > MAX_PROCESS);
    struct proc_struct *proc;
    list_entry_t *list = &proc_list, *le;
    static int next_safe = MAX_PID, last_pid = MAX_PID;
    if (++ last_pid >= MAX_PID) {
        last_pid = 1;
        goto inside;
    }
    if (last_pid >= next_safe) {
    inside:
        next_safe = MAX_PID;
    repeat:
        le = list;
        while ((le = list_next(le)) != list) {
            proc = le2proc(le, list_link);
            if (proc->pid == last_pid) {
                if (++ last_pid >= next_safe) {
                    if (last_pid >= MAX_PID) {
                        last_pid = 1;
                    }
                    next_safe = MAX_PID;
                    goto repeat;
                }
            }
            else if (proc->pid > last_pid && next_safe > proc->pid) {
                next_safe = proc->pid;
            }
        }
    }
    return last_pid;
}

練習3:閱讀代碼,理解 proc_run 函數和它調用的函數如何完成進程切換的。(無編碼工作)

這裏我從 proc_init() 函數開始說起的。由於之前的 proc_init() 函數已經完成了 idleproc 內核線程和 initproc 內核線程的初始化。所以在 kern_init() 最後,它通過 cpu_idle() 喚醒了 0 號 idle 進程,在分析 proc_run 函數之前,我們先分析調度函數 schedule() 。

schedule() 代碼如下:

/* 
宏定義:
   #define le2proc(le, member)         \
    to_struct((le), struct proc_struct, member)
*/
void schedule(void) {
    bool intr_flag; //定義中斷變量
    list_entry_t *le, *last; //當前list,下一list
    struct proc_struct *next = NULL; //下一進程
    local_intr_save(intr_flag); //中斷禁止函數
    {
        current->need_resched = 0; //設置當前進程不需要調度
      //last是否是idle進程(第一個創建的進程),如果是,則從表頭開始搜索
      //否則獲取下一鏈表
        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; //找到一個可以調度的進程,break
                }
            }
        } while (le != last); //循環查找整個鏈表
        if (next == NULL || next->state != PROC_RUNNABLE) {
            next = idleproc; //未找到可以調度的進程
        }
        next->runs ++; //運行次數加一
        if (next != current) {
            proc_run(next); //運行新進程,調用proc_run函數
        }
    }
    local_intr_restore(intr_flag); //允許中斷
}

可以看到 ucore 實現的是 FIFO 調度算法:

  • 1、調度開始時,先屏蔽中斷,設置當前內核線程 current->need_resched 爲 0。
  • 2、在進程鏈表中,查找第一個可以被調度的程序,即在 proc_list 隊列中查找下一個處於就緒態的線程或進程 next。
  • 3、找到這樣的進程後,就調用 proc_run 函數,保存當前進程 current 的執行現場(進程上下文),恢復新進程的執行現場,運行新進程,允許中斷,完成進程切換。

schedule 函數通過查找 proc_list 進程隊列,在這裏只能找到一個處於就緒態的 initproc 內核線程。於是通過 proc_run 和進一步的 switch_to 函數完成兩個執行現場的切換。

再分析 switch_to 函數

* 實現思路:
switch_to 函數主要完成的是進程的上下文切換,先保存當前寄存器的值,然後再將下一進程的上下文信息保存到對於寄存器中。
  1. 首先,保存前一個進程的執行現場,即 movl 4(%esp), %eax 和 popl 0(%eax) 兩行代碼。
  2. 然後接下來的七條指令如下:
     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)
  這些指令完成了保存前一個進程的其他 7 個寄存器到 context 中的相應域中。至此前一個進程的執行現場保存完畢。
  3. 再往後是恢復向一個進程的執行現場,這其實就是上述保存過程的逆執行過程,即從 context 的高地址的域 ebp 開始,逐一把相關域的值賦值給對應的寄存器。
  4. 最後的 pushl 0(%eax) 其實是把 context 中保存的下一個進程要執行的指令地址 context.eip 放到了堆棧頂,這樣接下來執行最後一條指令 “ret” 時,會把棧頂的內容賦值給 EIP 寄存器,這樣就切換到下一個進程執行了,即當前進程已經是下一個進程了,從而完成了進程的切換。
--------------------------------------------------------------------------------------------
/*code*/
switch_to:                      # switch_to(from, to)
    # save from's registers
    movl 4(%esp), %eax          #保存from的首地址
    popl 0(%eax)                #將返回值保存到context的eip
    movl %esp, 4(%eax)          #保存esp的值到context的esp
    movl %ebx, 8(%eax)          #保存ebx的值到context的ebx
    movl %ecx, 12(%eax)         #保存ecx的值到context的ecx
    movl %edx, 16(%eax)         #保存edx的值到context的edx
    movl %esi, 20(%eax)         #保存esi的值到context的esi
    movl %edi, 24(%eax)         #保存edi的值到context的edi
    movl %ebp, 28(%eax)         #保存ebp的值到context的ebp

    # restore to's registers
    movl 4(%esp), %eax          #保存to的首地址到eax
    movl 28(%eax), %ebp         #保存context的ebp到ebp寄存器
    movl 24(%eax), %edi         #保存context的ebp到ebp寄存器
    movl 20(%eax), %esi         #保存context的esi到esi寄存器
    movl 16(%eax), %edx         #保存context的edx到edx寄存器
    movl 12(%eax), %ecx         #保存context的ecx到ecx寄存器
    movl 8(%eax), %ebx          #保存context的ebx到ebx寄存器
    movl 4(%eax), %esp          #保存context的esp到esp寄存器
    pushl 0(%eax)               #將context的eip壓入棧中
    ret

最後分析一下 proc_run 函數

4、由 switch_to函數完成具體的兩個線程的執行現場切換,即切換各個寄存器,當 switch_to 函數執行完“ret”指令後,就切換到 initproc 執行了。

proc_run 的執行過程爲:

  • 保存 IF 位並且禁止中斷;
  • 將 current 指針指向將要執行的進程;
  • 更新 TSS 中的棧頂指針;
  • 加載新的頁表;
  • 調用 switch_to 進行上下文切換;
  • 當執行 proc_run 的進程恢復執行之後,需要恢復 IF 位。

以下是對 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 執行了。

* 當前進程/線程 切換到 proc 這個進程/線程
* 注意到在本實驗框架中,唯一調用到這個函數是在線程調度器的 schedule 函數中,也就是可以推測 proc_run 的語義就是將當前的 CPU 的控制權交給指定的線程;
* 可以看到 proc_run 中首先進行了 TSS 以及 cr3 寄存器的設置,然後調用到了 swtich_to 函數來切換線程,根據上文中對 switch_to 函數的分析可以知道,在調用該函數之後,首先會恢復要運行的線程的上下文,然後由於恢復的上下文中已經將返回地址( copy_thread 函數中完成)修改成了 forkret 函數的地址(如果這個線程是第一運行的話,否則就是切換到這個線程被切換出來的地址),也就是會跳轉到這個函數,最後進一步跳轉到了 __trapsret 函數,調用 iret ,最終將控制權切換到新的線程;
--------------------------------------------------------------------------------------------
/*code*/
void proc_run(struct proc_struct *proc) {
    if (proc != current) { // 判斷需要運行的線程是否已經運行着了
        bool intr_flag;
        struct proc_struct *prev = current, *next = proc;
        local_intr_save(intr_flag); // 關閉中斷
        {
            current = proc; // 將當前進程換爲 要切換到的進程
            // 設置任務狀態段 tss 中的特權級 0 下的 esp0 指針爲 next 內核線程 的內核棧的棧頂
            load_esp0(next->kstack + KSTACKSIZE); // 設置 TSS
            lcr3(next->cr3); // 重新加載 cr3 寄存器(頁目錄表基址) 進行進程間的頁表切換,修改當前的 cr3 寄存器成需要運行線程(進程)的頁目錄表
            switch_to(&(prev->context), &(next->context)); // 調用 switch_to 進行上下文的保存與切換,切換到新的線程
        }
        local_intr_restore(intr_flag);
    }
}

在本實驗的執行過程中,創建且運行了幾個內核線程?

總共創建了兩個內核線程,分別爲:

  • idle_proc,爲第 0 個內核線程,在完成新的內核線程的創建以及各種初始化工作之後,進入死循環,用於調度其他進程或線程;
  • init_proc,被創建用於打印 "Hello World" 的線程。本次實驗的內核線程,只用來打印字符串。

語句 local_intr_save(intr_flag);....local_intr_restore(intr_flag); 在這裏有何作用?請說明理由。

在進行進程切換的時候,需要避免出現中斷干擾這個過程,所以需要在上下文切換期間清除 IF 位屏蔽中斷,並且在進程恢復執行後恢復 IF 位。

  • 該語句的左右是關閉中斷,使得在這個語句塊內的內容不會被中斷打斷,是一個原子操作;
  • 這就使得某些關鍵的代碼不會被打斷,從而不會一起不必要的錯誤;
  • 比如說在 proc_run 函數中,將 current 指向了要切換到的線程,但是此時還沒有真正將控制權轉移過去,如果在這個時候出現中斷打斷這些操作,就會出現 current 中保存的並不是正在運行的線程的中斷控制塊,從而出現錯誤;

運行結果如下:

make_qemu

擴展練習Challenge:實現支持任意大小的內存分配算法

通過少量的修改,即可使用實驗2擴展練習實現的 Slub 算法。

  • 初始化 Slub 算法:在初始化物理內存最後初始化 Slub ;
void pmm_init(void) {
    ...
    kmem_int();
}
  • 在 vmm.c 中使用 Slub 算法:

爲了使用Slub算法,需要聲明倉庫的指針。

struct kmem_cache_t *vma_cache = NULL;
struct kmem_cache_t *mm_cache = NULL;

在虛擬內存初始化時創建倉庫。

void vmm_init(void) {
    mm_cache = kmem_cache_create("mm", sizeof(struct mm_struct), NULL, NULL);
    vma_cache = kmem_cache_create("vma", sizeof(struct vma_struct), NULL, NULL);
    ...
}

在 mm_create 和 vma_create 中使用 Slub 算法。

struct mm_struct *mm_create(void) {
    struct mm_struct *mm = kmem_cache_alloc(mm_cache);
    ...
}

struct vma_struct *vma_create(uintptr_t vm_start, uintptr_t vm_end, uint32_t vm_flags) {
    struct vma_struct *vma = kmem_cache_alloc(vma_cache);
    ...
}

在 mm_destroy 中釋放內存。

void
mm_destroy(struct mm_struct *mm) {
    ...
    while ((le = list_next(list)) != list) {
        ...
        kmem_cache_free(mm_cache, le2vma(le, list_link));  //kfree vma        
    }
    kmem_cache_free(mm_cache, mm); //kfree mm
    ...
}
  • 在 proc.c 中使用 Slub 算法:

聲明倉庫指針。

struct kmem_cache_t *proc_cache = NULL;

在初始化函數中創建倉庫。

void proc_init(void) {
    ...
    proc_cache = kmem_cache_create("proc", sizeof(struct proc_struct), NULL, NULL);
    ...
}

在 alloc_proc 中使用 Slub 算法。

static struct proc_struct *alloc_proc(void) {
    struct proc_struct *proc = kmem_cache_alloc(proc_cache);
    ...
}

本實驗沒有涉及進程結束後 PCB 回收,不需要回收內存。

參考文獻

  • https://en.wikipedia.org/wiki/SLOB
  • https://lwn.net/Articles/157944/
  • https://www.ibm.com/developerworks/cn/linux/l-linux-slab-allocator/
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章