清華大學作業系統課程 ucore Lab4 核心執行緒管理 實驗報告
作業系統 Lab4 核心執行緒管理 實驗報告
課程資訊所在網址: https://github.com/chyyuu/os_course_info
-
- 練習1:分配並初始化一個程序控制塊(需要編碼)
- 練習2:為新建立的核心執行緒分配資源(需要編碼)
- 練習3:閱讀程式碼,理解 proc_run 函式和它呼叫的函式如何完成 程序切換的。(無編碼工作)
- 實驗中涉及的知識點列舉
- 實驗中未涉及的知識點列舉
實驗目的
- 瞭解核心執行緒建立/執行的管理過程
- 瞭解核心執行緒的切換和基本排程過程
實驗內容
- 實現核心執行緒的建立與管理;
基本練習
練習0:填寫已有實驗
在本練習中將LAB1/2/3的實驗內容移植到了LAB4的實驗框架內,由於手動進行內容移植比較煩雜,因此考慮使用diff和patch工具進行自動化的移植,具體使用的命令如下所示:(對於patch工具進行合併的時候產生衝突的少部分內容,則使用*.rej, *.orig檔案來手動解決衝突問題)
diff -r -u -P lab2_origin lab3 > lab3.patch cd lab4 patch -p1 -u < ../lab3.patch
練習1:分配並初始化一個程序控制塊(需要編碼)
alloc_proc函式(位於kern/process/proc.c中)負責分配並返回一個新的struct proc_struct結 構,用於儲存新建立的核心執行緒的管理資訊。ucore需要對這個結構進行最基本的初始化,你 需要完成這個初始化過程。
設計實現
本練習的編碼工作集中在proc.c中的alloc_proc函式中,該函式的具體含義為建立一個新的程序控制塊,並且對控制塊中的所有成員變數進行初始化,根據實驗指導書中的要求,除了指定的若干個成員變數之外,其他成員變數均初始化為0,取特殊值的成員變數如下所示:
proc->state = PROC_UNINIT; proc->pid = -1; proc->cr3 = boot_cr3; // 由於是核心執行緒,共用一個虛擬記憶體空間
對於其他成員變數中佔用記憶體空間較大的,可以考慮使用memset函式進行初始化,最終初始化所使用的程式碼如下所示:
struct proc_struct *proc = kmalloc(sizeof(struct proc_struct)); // 為執行緒控制塊分配空間 if (proc != NULL) { proc->state = PROC_UNINIT; // 初始化具有特殊值的成員變數 proc->cr3 = boot_cr3; proc->pid = -1; proc->runs = 0; // 對其他成員變數清零處理 proc->kstack = 0; proc->need_resched = 0; proc->parent = NULL; proc->mm = NULL; memset(&proc->context, 0, sizeof(struct context)); // 使用memset函式清零佔用空間較大的成員變數,如陣列,結構體等 proc->tf = NULL; proc->flags = 0; memset(proc->name, 0, PROC_NAME_LEN); }
至此,本練習中的編碼任務完成;
問題回答
-
請說明proc_struct中struct context context和struct trapframe *tf成員變數含義和在本實驗中的作用是啥?(提示通過看程式碼和程式設計除錯可以判斷出來)
- struct context context的作用:
- 首先不妨檢視struct context結構體的定義,可以發現在結構體中儲存這除了eax之外的所有通用暫存器以及eip的數值,這就提示我們這個執行緒控制塊中的context很有可能是儲存的執行緒執行的上下文資訊;
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; };
- 接下來使用find grep命令查詢在ucore中對context成員變數進行了設定的程式碼,總共可以發現兩處,分別為Swtich.S和proc.c中的copy_thread函式中,在其他部分均沒有發現對context的引用和定義(除了初始化);那麼根據Swtich中程式碼的語義,可以確定context變數的意義就在於核心執行緒之間進行切換的時候,將原先的執行緒執行的上下文儲存下來這一作用,那麼為什麼沒有對eax進行儲存呢?注意到在進行切換的時候呼叫了switch_to這一個函式,也就是說這個函式的裡面才是執行緒之間切換的切換點,而在這個函式裡面,由於eax是一個caller-save暫存器,並且在函式裡eax的數值一直都可以在棧上找到對應,因此沒有比較對其進行儲存。
- struct trapframe *tf的作用:
- 接下來同樣在程式碼中尋找對tf變數進行了定義的地方,最後可以發現在copy_thread函式中對tf進行了設定,但是值得注意的是,在這個函式中,同時對context變數的esp和eip進行了設定,前者設定為tf變數的地址、後者設定為forkret這個函式的指標,接下來觀察forkret函式,發現這個函式最終呼叫了__trapret進行中斷返回,這樣的話tf變數的作用就變得清晰起來了:tf變數的作用在於在構造出了新的執行緒的時候,如果要將控制權交給這個執行緒,是使用中斷返回的方式進行的(跟lab1中切換特權級類似的技巧),因此需要構造出一個偽造的中斷返回現場,也就是trapframe,使得可以正確地將控制權轉交給新的執行緒;具體切換到新的執行緒的做法為,呼叫switch_to函式,然後在該函式中進行函式返回,直接跳轉到forkret函式,最終進行中斷返回函式__trapret,之後便可以根據tf中構造的中斷返回地址,切換到新的執行緒了;
- struct context context的作用:
練習2:為新建立的核心執行緒分配資源(需要編碼)
建立一個核心執行緒需要分配和設定好很多資源。kernel_thread函式通過呼叫do_fork函式完成 具體核心執行緒的建立工作。do_kernel函式會呼叫alloc_proc函式來分配並初始化一個程序控 制塊,但alloc_proc只是找到了一小塊記憶體用以記錄程序的必要資訊,並沒有實際分配這些資 源。ucore一般通過do_fork實際建立新的核心執行緒。do_fork的作用是,建立當前核心執行緒的 一個副本,它們的執行上下文、程式碼、資料都一樣,但是儲存位置不同。在這個過程中,需 要給新核心執行緒分配資源,並且複製原程序的狀態。你需要完成在kern/process/proc.c中的 do_fork函式中的處理過程。
設計實現
在本次練習中,主要需要實現的程式碼位於proc.c的do_fork函式中,該函式的語義為為核心執行緒建立新的執行緒控制塊,並且對控制塊中的每個成員變數進行正確的設定,使得之後可以正確切換到對應的執行緒中執行;接下來將結合具體的程式碼來說明本次練習的具體實現過程:
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
- 至此,本練習中需要的編碼任務全部完成;
問題回答
- 請說明ucore是否做到給每個新fork的執行緒一個唯一的id?請說明你的分析和理由。
- 可以。ucore中為新的fork的執行緒分配pid的函式為get_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函式的時候不能利用到先前呼叫這個函式的中間結果;
- 可以。ucore中為新的fork的執行緒分配pid的函式為get_pid,接下來不妨分析該函式的內容:
練習3:閱讀程式碼,理解 proc_run 函式和它呼叫的函式如何完成 程序切換的。(無編碼工作)
分析
接下來對proc_run函式進行分析:
- 首先注意到在本實驗框架中,唯一呼叫到這個函式是線上程排程器的schedule函式中,也就是可以推測proc_run的語義就是將當前的CPU的控制權交給指定的執行緒;
- 接下來結合程式碼分析函式的內部構成:
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; load_esp0(next->kstack + KSTACKSIZE); // 設定TSS lcr3(next->cr3); // 修改當前的cr3暫存器成需要執行執行緒(程序)的頁目錄表 switch_to(&(prev->context), &(next->context)); // 切換到新的執行緒 } local_intr_restore(intr_flag); } }
- 可以看到proc_run中首先進行了TSS以及cr3暫存器的設定,然後呼叫到了swtich_to函式來切換執行緒,根據上文中對switch_to函式的分析可以知道,在呼叫該函式之後,首先會恢復要執行的執行緒的上下文,然後由於恢復的上下文中已經將返回地址(copy_thread函式中完成)修改成了forkret函式的地址(如果這個執行緒是第一執行的話,否則就是切換到這個執行緒被切換出來的地址),也就是會跳轉到這個函式,最後進一步跳轉到了__trapsret函式,呼叫iret最終將控制權切換到新的執行緒;
問題回答
- 在本實驗的執行過程中,建立且運行了幾個核心執行緒?
- 總共建立了兩個核心執行緒,分別為:
- idleproc: 最初的核心執行緒,在完成新的核心執行緒的建立以及各種初始化工作之後,進入死迴圈,用於排程其他執行緒;
- initproc: 被建立用於列印"Hello World"的執行緒;
- 總共建立了兩個核心執行緒,分別為:
- 語句 local_intr_save(intr_flag);....local_intr_restore(intr_flag);說明理由在這裡有何作用? 請說明理由。
- 該語句的左右是關閉中斷,使得在這個語句塊內的內容不會被中斷打斷,是一個原子操作;
- 這就使得某些關鍵的程式碼不會被打斷,從而不會一起不必要的錯誤;
- 比如說在proc_run函式中,將current指向了要切換到的執行緒,但是此時還沒有真正將控制權轉移過去,如果在這個時候出現中斷打斷這些操作,就會出現current中儲存的並不是正在執行的執行緒的中斷控制塊,從而出現錯誤;
實驗結果
最終的實驗結果符合預期,並且能夠通過make grade指令碼的檢查,如下圖所示:

result.png
參考答案分析
- 首先比較本實驗中在練習1中的實現與參考答案的區別,由於該編碼任務的內容僅僅是初始化TCB,比較簡單,因此內容與參考答案基本一致;
- 接下來比較練習2中實現與參考答案的區別:
- 參考答案多了在分配記憶體、設定棧的過程中失敗的錯誤處理;
- 參考答案中設定fork的執行緒的關鍵資訊以及將其加入proc列表的時候關閉了中斷,防止被中斷打斷操作,從而導致儲存執行緒的資訊的不一致;
- 通過與參考答案的對比,可以發現本實驗中的實現還是忽略了許多必要的錯誤處理,以及沒有考慮到中斷打斷當前的操作是否會帶來錯誤,這使得本實驗的實現的魯棒性不如參考答案,需要吸取教訓;
實驗中涉及的知識點列舉
在本次實驗中設計到的知識點有:
- 執行緒控制塊的概念以及組成;
- 切換不同執行緒的方法;
對應到的OS中的知識點有:
- 對核心執行緒的管理;
- 對核心執行緒之間的切換;
這兩者之間的關係為,前者為後者在OS中的具體實現提供了基礎;
實驗中未涉及的知識點列舉
在本次實驗中未涉及的知識點有:
- OS的啟動過程;
- OS中對物理、虛擬記憶體的管理;
- OS中對使用者程序的管理;
- OS中對執行緒/程序的排程;
實驗程式碼
https://github.com/AmadeusChan/ucore_os_lab/tree/master/lab4