ucore在lab4中實現了進程/線程機制,能夠創建并進行內核線程的調度。通過上下文的切換令線程分時的獲得CPU,使得不同線程能夠并發的運行。
在lab5中需要更進一步,實現我們平常開發接觸到的、運行在用戶態的進程/線程機制。用戶線程通常用于承載和運行應用程序,為了保護操作系統內核,避免其被不夠魯棒的應用程序破壞。應用程序都運行在低特權級中,無法直接訪問高特權級的內核數據結構,也無法通過程序指令直接的訪問各種外設。
但應用程序訪問高特權級數據、外設的需求是不可避免的(即使簡單的打印數據到控制臺中也是在對顯卡這一外設進行控制),因此 ucore在lab5中也實現了系統調用機制。應用程序平常運行在用戶態,在有需要時可以通過系統調用的方式間接的訪問外設等受到保護的資源。
系統調用介紹
系統調用是操作系統提供的一種特殊api接口,底層是通過中斷實現的。應用程序調用系統中斷時,其CPL特權級會被暫時的提升到ring0字段默認值為系統當前日期,因此便獲得了訪問外設、內核數據的能力。這一提升CPL特權級從外層用戶態到里層內核態的過程,也被稱為陷入內核(系統調用會陷入內核,但是陷入內核的方式除了系統調用外,還包括觸發保護異常等)。
由于系統調用是操作系統的開發人員精心設計的,且對傳入的參數等等有著很嚴格的控制,確保了系統調用不會對內核造成破壞。同時,在系統調用中斷返回時,也會將其CPL特權級對應用程序透明的還原到用戶態。在ucore的lab5中,提供了一些用戶態的demo應用程序,并在內核實現了諸如fork、exit、kill、yield、wait等等系統調用功能以及C實現的應用程序系統調用庫。
通過lab5的學習,可以更深入的了解操作系統中用戶態應用程序的加載、運行和退出機制,以及系統調用的工作原理。
lab5是建立在之前實驗的基礎之上的,需要先理解之前的實驗內容才能順利理解lab5的內容。
可以參考一下我關于前面實驗的博客:
下面通過解析lab5實驗的源碼,進一步分析在ucore中加載、并運行一個用戶態應用程序的機制,以及系統調用是如何實現的。
2.1 lab5中線程控制塊的變化
lab5中引入了用戶進程/線程機制,用戶進程/線程會隨著程序的執行不斷的被創建、退出并銷毀。同時也引入了父子進程的概念,在子進程退出時,由于其內核棧和子進程自己的進程控制塊無法進行自我回收,因此需要通知其父進程進行最后的回收工作。
為此,ucore在lab5中在進程控制塊中加入了 、 以及標識線程之間父子關系的鏈表節點 cptr, yptr, *optr。
lab5中:
2.2 系統調用的實現原理
系統調用貫穿著整個lab5實驗的內容,只有理解了系統調用的實現原理后才能更進一步的理解lab5給出的demo程序中應用程序加載、運行及退出的機制。
系統調用是提供給運行在用戶態的應用程序使用的,且由于需要進行CPL特權級的提升,因此是通過硬件中斷來實現的。
ucore在初始化中斷描述符表的/trap/trap.c的函數中,在前面實驗的基礎上額外設置了一個用于系統調用的中斷描述符。
實現:
可以看到在中,額外的設置了一個中斷號為(0x80)的中斷描述符用于處理系統調用,且其DPL特權級為用戶態,使得用戶程序可以在用戶態主動的發起該中斷,獲得操作系統內核提供的服務。
對應的,在lab5的/trap/trap.c的中斷處理分發邏輯函數中,也實現了對應的中斷服務例程用于處理系統調用中斷。
函數:
函數(內核中系統調用處理邏輯):
ucore構造了一個當前系統所支持的系統調用處理表(數組),根據系統調用中斷時傳入的系統調用號(保存在eax中),跳轉執行對應的系統調用邏輯。
ucore的系統調用允許通過edx、ecx、ebx、edi和esi這5個寄存器傳遞最多5個參數,并且在對應的系統調用邏輯完成后通過eax寄存器存放系統調用的返回值。另一方面,可以從ucore提供的在用戶態執行的系統調用庫函數實現中看到應用程序是如何發起系統調用的,主要邏輯位于/user/lib/.c中。
庫函數.c中不同的系統調用最終都通過函數進行統一處理,通過內聯匯編的形式,執行了int i 0x80()以發起系統調用中斷。系統調用號num賦值給了eax(a),且將需要傳遞的參數賦值(a[i])分別給了edx(d)、ecx(c)、ebx(b)、edi(D)和esi(S),且令返回值ret等于中斷返回后的eax(=a ret)。
可以通過互相比對調用方用戶態.c庫函數以及內核中的實現,加深對系統調用工作機制的理解。
.c(用戶態的系統調用函數庫):
在前面的實驗中提到過,如果包括系統調用在內的中斷發生時,將會在中斷棧幀中壓入中斷發生前一刻的CS的值。如果是位于ring3用戶態的應用程序發起的系統調用中斷,那么內核在接受中斷棧幀時其CS的CPL將會是ring3,并在執行中斷服務例程時被臨時的設置CS的CPL特權級為ring0以提升特權級,獲得訪問內核數據、外設的權限。
在系統調用處理完畢返回后,iret指令會將之前CPU硬件自動壓入的cs(ring3的CPL)彈出。系統調用處理完畢中斷返回時,應用程序便自動無感知的回到了ring3這一低特權級中。
2.3 應用程序的加載
lab4在總控函數中通過創建了兩個內核線程 和 ,其中只是單單在控制臺中打印了hello world。
但lab5中在中fork了一個內核線程執行,在中執行了系統調用,用于執行BIOS引導時與ucore內核一起被加載到內存中的用戶程序/user/exit.c,讓exit.c在用戶態中執行。(之所以要以這種方式加載exit.c是因為ucore目前還沒有實現文件系統,無法以文件的形式去加載二進制的應用程序)
實現(執行邏輯):
實現:
在中,通過函數,在內核態發起了系統調用號為的系統調用。通過前面對系統調用機制的介紹,函數發起系統調用號為編號的系統調用后,最終邏輯會執行到系統調用處理表中的對應的函數中。
接受四個參數,arg[0]和arg[1]用于指定所要創建、執行的線程名以及其字符串長度;arg[2]和arg[3]用于指定對應elf格式的二進制程序在內存中的地址以及程序的大小。在的核心邏輯位于其調用的 中。
函數:
函數解析:
在視頻的實驗公開課中提到,實現的大致原理就是令被加載的二進制程序借著當前線程的殼,用被加載的二進制程序的內存空間替換掉之前線程的內存空間,達到騰籠換鳥的目的。
具體到代碼中來,可以看到在進行了基礎的校驗之后,首先一步就是將當前線程對應的替換掉,且如果發現被替換掉的沒有再被其它線程共享,則將其徹底銷毀釋放。之后通過一個較為復雜的函數,解析對應的二進制程序,生成一個完全不同的,屬于被加載程序的。
對當前線程對應的內存總管理器的一刪一增,導致當前線程所執行的代碼段、所屬的數據段和之前完全不同,巧妙的實現了借著之前線程的殼,重新執行一個截然不同的程序的目標。從這里也能更深入的體會到靜態的程序與動態的進程/線程的關系。
實現:
函數解析:
函數是lab5中十分核心、重要的一個部分。在中,按順序執行以下幾個步驟,為需要加載運行的新程序構造了完整的運行環境。
1. 為當前進程創建一個新的mm結構。
2. 為新的mm分配并設置一個新的頁目錄表。
3. 在二進制數據空間中分配內存(虛擬、物理內存空間),從elf格式的二進制程序中復制出對應的代碼/數據段,并初始化BSS段(需要對ELF格式的文件有一定的了解)。
4. 為當前進程創建、分配對應的用戶棧。
5. 將前面構造好的新的結構與當前線程關聯起來。
6. 為了令當前應用程序能夠在加載后順利的回到用戶態執行,需要構造對應的中斷棧幀(設置中斷返回時的應用程序入口以及用戶態權限的代碼段寄存器、數據段、棧段寄存器等等)。在中斷返回后欺騙CPU,令CPU以為當前線程在之前發生了一次特權級切換的中斷(其實已經是一個和之前線程完全不同的新程序了)。
2.4 exit.c應用程序執行流程分析
在、函數返回,整個系統中斷的服務例程完成之后。根據構造好的中斷棧幀,執行iret指令中斷返回后,CPU的指令指針寄存器eip將指向elf->entry,即用戶程序的執行入口(exit.c的main函數入口)。且其CS代碼段寄存器、DS/ES/SS等數據段寄存器的特權級均處于ring3用戶態。
在exit.c中,main函數中通過ulib.h中提供的用戶庫,執行了fork系統調用。fork完成后將會出現父子兩個線程,其中子線程在進行了多次yield后調用了exit庫函數進行了線程退出操作。而父線程則在fork完畢后通過調用的庫函數等待對應的子線程退出,將其最終回收。而作為應用程序的父線程在另一方面也是內核線程創建的子線程(函數)。當父線程的main函數執行完畢后,函數中也調用了函數在等待用戶態的線程最終退出。
exit.c應用程序:
函數:
執行的結果截圖:
可以看到,exit.c中的打印語句和中的打印語句按照各自父子線程的退出順序,依次的被執行了。
下面我們分析exit.c應用程序執行的流程,分析為什么會以這樣的順序執行打印語句?
首先,前面分析了內核線程在里在內核狀態下發起了系統調用,解析并加載了應用程序exit.c。在系統調用中斷返回后,exit.c回到了用戶態并跳轉到main函數入口處開始執行。
在exit.c的main函數中,首先執行了語句”(“I am the . the child…\n”);”,然后便通過用戶庫執行了fork系統調用,fork系統調用最終的處理邏輯在內核的.c中定義的處。中調用函數用于fork復制一個子線程。
函數:
函數:
函數在lab4中有著比較詳細的介紹,而在lab5中需要注意的一點是:由于fork函數會在復制創建新的子線程的那一瞬間將父線程對應的進行復制,因此其指令指針在fork的一瞬間是一致的。對于父線程而言,執行執行完函數之后,會繼續執行函數的后續邏輯。
如果父線程的fork流程執行過程中沒有出錯最終會執行到ret = proc->pid,系統調用再到最終用戶庫的fork函數便會返回子線程的pid,一個大于0的值。
子線程則由于在的邏輯中字段默認值為系統當前日期,通過”proc->tf->. = 0;”設置了中斷棧幀的eax的值為0,且由于其陷入內核時的中斷棧幀中指令指針和父線程一樣(函數中傳入的中斷棧幀),在iret返回到用戶態時,也會進行fork函數的調用返回。但返回時用戶態中斷棧幀復原時eax的值為0,導致exit.c中子線程fork函數的返回值為0。
父線程fork時返回的是子線程的pid,而子線程fork的返回值為0。可以通過判斷fork函數的返回值是否為0,編寫對應父子線程的不同處理邏輯。
函數:
在父線程fork完成之后,fork的返回值不為0,因此會執行else塊的邏輯打印(I am , fork a child pid %d),以及打印(I am the , now..)。子線程則會打印(I am the child)。
在exit子線程的邏輯中,最終會通過exit,執行系統調用,用于退出當前線程。
在ucore lab5的參考示例代碼中,線程的pid為0、線程的pid為1;而執行的父線程pid為2,其fork出的子線程pid為3。
實現:
在的實現中,由于線程需要退出,當前需要退出的線程會盡可能的將自己持有的包括占用的內存空間的等各種資源釋放。 但是由于要執行指令,需要退出的線程的內核棧是無法自己回收的;當前退出線程的線程控制塊由于調度的需要,也是無法自己回收掉的。線程退出最終的回收工作需要其父線程來完成。
因此,在函數的最后,當前線程會將自己的線程狀態設置為僵尸態,并且嘗試著喚醒可能在等待子線程退出而被阻塞的父線程。
如果當前退出的子線程還擁有著自己的子線程,那么還需要將其托管給內核的第一個線程,令代替被退出線程,成為這些子線程的父線程,以完成后續的子線程回收工作(作為后續創建的其它線程的祖先,是用來兜底的)。
在exit父線程的邏輯中,會調用同步阻塞等待對應pid的子線程退出,完成最終的回收工作。最終會執行系統調用,主要邏輯位于內核的函數中。
實現:
的核心邏輯在函數中,根據傳入的參數pid決定是回收指定pid的子線程還是任意一個子線程。
1. 如果當前線程不存在任何可以回收的子線程,會直接返回一個錯誤碼。
2. 如果調用時當前線程能夠找到一個符合要求的,且可以立即回收的僵尸態子線程,那么就會將其回收(一次只會回收一個子線程)。
3. 如果調用時當前線程存在可以回收的子線程,但是卻不是僵尸態。那么當前線程將進入阻塞態,等待可以回收的子線程退出(前面介紹的中就有子線程退出時對應的父線程喚醒機制)。
在exit.c中fork完畢的子線程通過成功退出后,的父線程也隨之被喚醒進行對應子線程的回收工作。依照順序執行打印語句 “ %d ok.”和 “exit pass.\n”(由于子線程在exit之后就不會得到CPU了,因此不會打印這些)。
之后執行exit.c的父線程main函數也執行完畢并返回,執行流就到了lab4中提到的/kern//entry.S中。在entry.S中,最終也是調用了內核的函數令當前線程退出。
entry.S:
在執行exit.c的父線程也退出后,最初加載exit.c應用程序的作為它的父線程被喚醒并通過進行了最終的回收工作(函數)。最終打印出了 “all user-mode have quit.”。
至此,lab5中整個實驗細節的分析就結束了。
ucore在lab5中實現了運行在用戶態的進程/線程機制。用戶態的進程通常用于執行權限受到限制的應用程序,避免應用程序的編寫者有意或無意的破壞操作系統內核。同時也實現了系統調用機制,為應用程序提供安全可靠的服務的同時也避免受到內核保護的各種資源被破壞。在實現了較為完善的進程/線程機制之后,也為后續的lab6、lab7中的線程CPU調度以及線程并發控制機制打下了基礎。
通過lab5的學習,使我學到了很多:
1. 了解了系統調用的實現原理。意識到通過中斷實現的系統調用效率是很低的,這也是為什么在應用程序的開發中要盡量避免使用系統調用而陷入內核的主要原因(比如陷入內核阻塞線程的操作系統級的悲觀鎖以及用戶態輪詢重試的樂觀鎖)。
2. 了解了線程是如何去加載并執行一個應用程序的。對動態的進程/線程與靜態的程序之間的關系有了更深的理解。
3. 了解了諸如fork、exit、wait等系統調用的大致工作原理。
希望我的博客能幫助到對操作系統、ucore os感興趣的人。存在許多不足之處,還請多多指教。
:
: 小熊餐館
Title: ucore操作系統學習(五) ucore lab5用戶進程管理