XV6第一个进程
第一個進程
本章通過第一個進程的創建來解釋 xv6 是如何開始運行的,讓我們得以一窺 xv6 提供的各個抽象是如何實現和交互的。xv6 盡量復用了普通操作的代碼來建立第一個進程,避免單獨為其撰寫代碼。接下來的各小節中,我們將詳細探索其中的奧秘。
xv6 可以運行在搭載 Intel 80386 及其之后(即"x86")處理器的 PC 上,因而許多底層功能(例如虛存的實現)是 x86 處理器專有的。本書假設讀者已有些許在一些體系結構上進行機器級編程的經驗。我們將在有關 x86 專有概念出現時,對其進行介紹。另外,附錄 A 中簡要地描述了 PC 平臺的整體架構。
進程概覽
進程是一個抽象概念,它讓一個程序可以假設它獨占一臺機器。進程向程序提供"看上去"私有的,其他進程無法讀寫的內存系統(或地址空間),以及一顆"看上去"僅執行該程序的CPU。
xv6 使用頁表(由硬件實現)來為每個進程提供其獨有的地址空間。頁表將虛擬地址(x86 指令所使用的地址)翻譯(或說"映射")為物理地址(處理器芯片向主存發送的地址)。
xv6 為每個進程維護了不同的頁表,這樣就能夠合理地定義進程的地址空間了。如圖表1-1所示,一片地址空間包含了從虛擬地址0開始的用戶內存。它的地址最低處放置進程的指令,接下來則是全局變量,棧區,以及一個用戶可按需拓展的"堆"區(malloc 用)。
和上面提到的用戶內存一樣,內核的指令和數據也會被進程映射到每個進程的地址空間中。當進程使用系統調用時,系統調用實際上會在進程地址空間中的內核區域執行。這種設計使得內核的系統調用代碼可以直接指向用戶內存。為了給用戶留下足夠的內存空間,xv6 將內核映射到了地址空間的高地址處,即從 0x80100000 開始。
xv6 使用結構體 struct proc 來維護一個進程的狀態,其中最為重要的狀態是進程的頁表,內核棧,當前運行狀態。我們接下來會用 p->xxx 來指代 proc 結構中的元素。
每個進程都有一個運行線程(或簡稱為線程)來執行進程的指令。線程可以被暫時掛起,稍后再恢復運行。系統在進程之間切換實際上就是掛起當前運行的線程,恢復另一個進程的線程。線程的大多數狀態(局部變量和函數調用的返回地址)都保存在線程的棧上。
每個進程都有用戶棧和內核棧(p->kstack)。當進程運行用戶指令時,只有其用戶棧被使用,其內核棧則是空的。然而當進程(通過系統調用或中斷)進入內核時,內核代碼就在進程的內核棧中執行;進程處于內核中時,其用戶棧仍然保存著數據,只是暫時處于不活躍狀態。進程的線程交替地使用著用戶棧和內核棧。要注意內核棧是用戶代碼無法使用的,這樣即使一個進程破壞了自己的用戶棧,內核也能保持運行。
當進程使用系統調用時,處理器轉入內核棧中,提升硬件的特權級,然后運行系統調用對應的內核代碼。當系統調用完成時,又從內核空間回到用戶空間:降低硬件特權級,轉入用戶棧,恢復執行系統調用指令后面的那條用戶指令。線程可以在內核中"阻塞",等待 I/O, 在 I/O 結束后再恢復運行。
p->state 指示了進程的狀態:新建、準備運行、運行、等待 I/O 或退出狀態中。
p->pgdir 以 x86 硬件要求的格式保存了進程的頁表。xv6 讓分頁硬件在進程運行時使用 p->pgdir。進程的頁表還記錄了保存進程內存的物理頁的地址。
代碼:第一個地址空間
當 PC 開機時,它會初始化自己然后從磁盤中載入 boot loader 到內存并運行。附錄 B 介紹了其具體細節。然后,boot loader 把 xv6 內核從磁盤中載入并從 entry(1040)開始運行。x86 的分頁硬件在此時還沒有開始工作;所以這時的虛擬地址是直接映射到物理地址上的。
boot loader 把 xv6 內核裝載到物理地址 0x100000 處。之所以沒有裝載到內核指令和內核數據應該出現的 0x80100000,是因為小型機器上很可能沒有這么大的物理內存。而之所以在 0x100000 而不是 0x0 則是因為地址 0xa0000 到 0x100000 是屬于 I/O 設備的。
為了讓內核的剩余部分能夠運行,entry 的代碼設置了頁表,將 0x80000000(稱為 KERNBASE(0207))開始的虛擬地址映射到物理地址 0x0 處。注意,頁表經常會這樣把兩段不同的虛擬內存映射到相同的一段物理內存,我們將會看到更多類似的例子。
entry 中的頁表的定義在 main.c(1311)中。我們將在第 2 章討論頁表的細節,這里簡單地說明一下,頁表項 0 將虛擬地址 0:0x400000 映射到物理地址 0:0x400000。只要 entry 的代碼還運行在內存的低地址處,我們就必須這樣設置,但最后這個頁表項是會被移除的。頁表項 512(譯注:原文中似乎誤寫為960)將虛擬地址的 KERNBASE:KERNBASE+0x400000 映射到物理地址 0:0x400000。這個頁表項將在 entry 的代碼結束后被使用;它將內核指令和內核數據應該出現的高虛擬地址處映射到了 boot loader 實際將它們載入的低物理地址處。這個映射就限制內核的指令+代碼必須在 4mb 以內。
讓我們回到 entry 中繼續頁表的設置工作,它將 entrypgdir 的物理地址載入到控制寄存器 %cr3 中。分頁硬件必須知道 entrypgdir 的物理地址,因為此時它還不知道如何翻譯虛擬地址;它也還沒有頁表。entrypgdir 這個符號指向內存的高地址處,但只要用宏 V2P_WO(0220)減去 KERNBASE 便可以找到其物理地址。為了讓分頁硬件運行起來, xv6 會設置控制寄存器 %cr0 中的標志位 CR0_PG。
現在 entry 就要跳轉到內核的 C 代碼,并在內存的高地址中執行它了。首先它將棧指針 %esp 指向被用作棧的一段內存(1054)。所有的符號包括 stack 都在高地址,所以當低地址的映射被移除時,棧仍然是可用的。最后 entry 跳轉到高地址的 main 代碼中。我們必須使用間接跳轉,否則匯編器會生成 PC 相關的直接跳轉(PC-relative direct jump),而該跳轉會運行在內存低地址處的 main。 main 不會返回,因為棧上并沒有返回 PC 值。好了,現在內核已經運行在高地址處的函數 main(1217)中了。
代碼:創建第一個進程
在 main 初始化了一些設備和子系統后,它通過調用 userinit(1239)建立了第一個進程。userinit 首先調用 allocproc。allocproc(2205)的工作是在頁表中分配一個槽(即結構體 struct proc),并初始化進程的狀態,為其內核線程的運行做準備。注意一點:userinit 僅僅在創建第一個進程時被調用,而 allocproc 創建每個進程時都會被調用。allocproc 會在 proc 的表中找到一個標記為 UNUSED(2211-2213)的槽位。當它找到這樣一個未被使用的槽位后,allocproc 將其狀態設置為 EMBRYO,使其被標記為被使用的并給這個進程一個獨有的 pid(2201-2219)。接下來,它嘗試為進程的內核線程分配內核棧。如果分配失敗了,allocproc 會把這個槽位的狀態恢復為 UNUSED 并返回0以標記失敗。
現在 allocproc 必須設置新進程的內核棧,allocproc 以巧妙的方式,使其既能在創建第一個進程時被使用,又能在 fork 操作時被使用。allocproc 為新進程設置好一個特別準備的內核棧和一系列內核寄存器,使得進程第一次運行時會"返回"到用戶空間。準備好的內核棧就像圖表1-3展示的那樣。allocproc 通過設置返回程序計數器的值,使得新進程的內核線程首先運行在 forkret 的代碼中,然后返回到 trapret(2236-2241)中運行。
內核線程會從 p->context 中拷貝的內容開始運行。所以我們可以通過將 p->context->eip 指向 forkret 從而讓內核線程從 forkret(2533)的開頭開始運行。這個函數會返回到那個時刻棧底的地址。context switch(2708)的代碼把棧指針指向 p->context 結尾。allocproc 又將 p->context 放在棧上,并在其上方放一個指向 trapret 的指針;這樣運行完的 forkret 就會返回到 trapret 中了。 trapret 接著從棧頂恢復用戶寄存器然后跳轉到 process(3027)的代碼。
這樣的設置對于普通的 fork 和建立第一個進程都是適用的,雖然后一種情況進程會從用戶空間的地址0處開始執行而非真正的從 fork 返回。
我們將會在第3章看到,將控制權從用戶轉到內核是通過中斷機制實現的,具體地說是系統調用、中斷和異常。每當進程運行中要將控制權交給內核時,硬件和 xv6 的 trap entry 代碼就會在進程的內核棧上保存用戶寄存器。 userinit 把值寫在新建的棧的頂部,使之就像進程是通過中斷進入內核的一樣(2264-2270)。所以用于從內核返回到用戶代碼區的通用代碼也能適用于第一個進程。這些保存的值就構成了一個結構體 struct trapframe,其中保存的是用戶寄存器。現在如圖表1-3所示,進程的內核已經完全準備好了。
第一個進程會先運行一個小程序(initcode.S(7700)),于是進程需要找到物理內存來保存這段程序。程序不僅需要被拷貝到內存中,還需要頁表來指向那段內存。
userinit 調用 setupkvm(1737)來為進程創建一個(最初)只映射內核區的頁表。我們將在第2章學習該函數的具體細節,概括地說,setupkvm 和 userinit 創建了圖表1-1所示的地址空間。
第一個進程內存中的初始內容是由 initcode.S 匯編得到的;作為建立內核的進程的一部分,鏈接器將這段二進制代碼嵌入內核中并定義兩個特殊的符號:_binary_initcode_start 和 _binary_initcode_size,表示了這段二進制碼的位置和大小。userinit 調用 inituvm,分配一頁物理內存,將虛擬地址0映射到那一段內存,并把二進制碼拷貝到那一頁中(1803)。
接下來,userinit 把 trap frame(0602)設置為初始的用戶模式狀態:%cs 寄存器保存著一個段選擇器, 指向段 SEG_UCODE 并處于特權級 DPL_USER(即在用戶模式而非內核模式)。類似的,%ds, %es, %ss 的段選擇器指向段 SEG_UDATA 并處于特權級 DPL_USER。%eflags 的 FL_IF 位被設置為允許硬件中斷;我們將在第3章回頭看這段代碼。
棧指針 %esp 被設為了進程的最大有效虛擬內存,即 p->sz。指令指針則指向初始化代碼的入口點,即地址0。
函數 userinit 把 p->name 設置為 initcode,這主要是為了方便調試。還要將 p->cwd 設置為進程當前的工作目錄;我們將在第6章回過頭來查看 namei 的細節。
一旦進程初始化完畢,userinit 將 p->state 設置為 RUNNABLE,使進程能夠被調度。
運行第一個進程
現在第一個進程的狀態已經被設置好了,讓我們來運行它。在 main 調用了 userinit 之后, mpmain 調用 scheduler 開始運行進程(1267)。scheduler(2458)會找到為一個 p->state 為 RUNNABLE 的進程 initproc,然后將 per-cpu 的變量 proc 為該進程,接著調用 switchuvm 通知硬件開始使用目標進程的頁表(1768)。注意,由于 setupkvm 使得所有的進程的頁表都有一份相同的映射,指向內核的代碼和數據,所以當內核運行時我們改變頁表是沒有問題的。switchuvm 同時還設置好任務狀態段 SEG_TSS,讓硬件在進程的內核棧中執行系統調用與中斷。我們將在第3章研究任務狀態段。
scheduler 接著把進程的 p->state 設置為 RUNNING,調用 swtch(2708),切換上下文到目標進程的內核線程中。swtch 會保存當前的寄存器,并把目標內核線程中保存的寄存器(proc->context)載入到 x86 的硬件寄存器中,其中也包括棧指針和指令指針。當前的上下文并非是進程的,而是一個特殊的 per-cpu 調度器的上下文。所以 scheduler 會讓 swtch 把當前的硬件寄存器保存在 per-cpu 的存儲(cpu->scheduler)中,而非進程的內核線程上下文中。我們將在第5章討論 swtch 的細節。最后的 ret(2727)指令從棧中彈出目標進程的 %eip,從而結束上下文切換工作。現在處理器就運行在進程 p 的內核棧上了。
allocproc 通過把 initproc 的 p->context->eip 設置為 forkret 使得 ret 開始執行 forkret 的代碼。第一次被使用(就是這一次)時,forkret(2533)會調用一些初始化函數。注意,我們不能在 main 中調用它們,因為它們必須在一個擁有自己的內核棧的普通進程中運行。接下來 forkret 返回。由于 allocproc 的設計,目前棧上在 p->context 之后即將被彈出的字是 trapret,因而接下來會運行 trapret,此時 %esp 保存著 p->tf。trapret(3027)用彈出指令從 trap frame(0602行)中恢復寄存器,就像 swtch 對內核上下文的操作一樣: popal 恢復通用寄存器,popl 恢復 %gs,%fs,%es,%ds。addl 跳過 trapno 和 errcode 兩個數據,最后 iret 彈出 %cs,%eip,%flags,%esp,%ss。trap frame 的內容已經轉移到 CPU 狀態中,所以處理器會從 trap frame 中 %eip 的值繼續執行。對于 initproc 來說,這個值就是虛擬地址0,即 initcode.S 的第一個指令。
這時 %eip 和 %esp 的值為0和4096,這是進程地址空間中的虛擬地址。處理器的分頁硬件會把它們翻譯為物理地址。allocuvm 為進程建立了頁表,所以現在虛擬地址0會指向為該進程分配的物理地址處。allocuvm 還會設置標志位 PTE_U 來讓分頁硬件允許用戶代碼訪問內存。userinit 設置了 %cs 的低位,使得進程的用戶代碼運行在 CPL = 3 的情況下,這意味著用戶代碼只能使用帶有 PTE_U 設置的頁,而且無法修改像 %cr3 這樣的敏感硬件寄存器。這樣,處理器就受限只能使用自己的內存了。
第一個系統調用:exec
initcode.S 干的第一件事是觸發 exec 系統調用。就像我們在第0章看到的一樣,exec 用一個新的程序來代替當前進程的內存和寄存器,但是其文件描述符、進程 id 和父進程都是不變的。
initcode.S(7708)剛開始會將 $argv,$init,$0 三個值推入棧中,接下來把 %eax 設置為 SYS_exec 然后執行 int T_SYSCALL:這樣做是告訴內核運行 exec 這個系統調用。如果運行正常的話,exec 不會返回:它會運行名為 $init 的程序,$init 是一個以空字符結尾的字符串,即 /init(7721-7723)。如果 exec 失敗并且返回了,initcode 會不斷調用一個不會返回的系統調用 exit 。
系統調用 exec 的參數是 $init、$argv。最后的0讓這個手動構建的系統調用看起來就像普通的系統調用一樣,我們會在第3章詳細討論這個問題。和之前的代碼一樣,xv6 努力避免為第一個進程的運行單獨寫一段代碼,而是盡量使用通用于普通操作的代碼。
第2章講了 exec 的具體實現,概括地講,它會用從文件系統中獲取的 /init 的二進制代碼代替 initcode 的代碼。現在 initcode 已經執行完了,進程將要運行 /init。 init(7810行)會在需要的情況下創建一個新的控制臺設備文件,然后把它作為描述符0,1,2打開。接下來它將不斷循環,開啟控制臺 shell,處理沒有父進程的僵尸進程,直到 shell 退出,然后再反復。系統就這樣建立起來了。
現實情況
大多操作系統都采用了進程這個概念,而大多的進程都和 xv6 的進程類似。但是真正的操作系統會利用一個顯式的鏈表在常數時間內找到空閑的 proc,而不像 allocproc 中那樣花費線性時間;xv6 使用的是樸素的線性搜索(找第一個空閑的 proc)。
xv6 的地址空間結構有一個缺點,即無法使用超過 2GB 的物理 RAM。當然我們可以解決這個問題,不過最好的解決方法還是使用64位的機器。
轉載于:https://www.cnblogs.com/kexinxin/p/9939178.html
超強干貨來襲 云風專訪:近40年碼齡,通宵達旦的技術人生總結
- 上一篇: Effective Modern C++
- 下一篇: #pragma multi_compil