這里要實現的就是UNIX標准系統調用中的fork,核心當然是copy on write技術
至於為什么采用copy on write,是因為子進程在被創建之后很可能立刻執行exec()了,之前做的一系列的拷貝是無用功
所以說,當創建一個新的子進程的時候,只需要拷貝父進程的內存映射(頁表)就可以了,而且將父進程所有的內存映射頁都標記為只讀的,這樣,當子進程或者父進程嘗試去讀的時候是安全的,而當嘗試去寫的時候,就會出發page fault,而在page fault處理例程中,單獨將被寫入的頁(比如說棧)拷貝一份,修改掉發出寫行為的進程的頁表相應的映射就可以了
所以說,第一步應該先規定或者確立一個page fault處理例程
每個進程需要向內核注冊這個處理例程,只需要傳遞一個函數指針即可
sys_env_set_pgfault_upcall函數
將當前進程的page fault處理例程設置為func指向的函數
Normal and exception stacks in user environments
這里出現了第三個棧,即異常棧
在用戶程序正常運行時,出在正常的用戶棧上,用戶棧頂位於USTACKTOP處
而異常棧則是為了上面設置的異常處理例程設立的。當異常發生時,而且該用戶進程注冊了該異常的處理例程,那么就會轉到異常棧上,運行異常處理例程
到目前位置出現了三個棧:
- [KSTACKTOP, KSTACKTOP-KSTKSIZE]
內核態系統棧
- [UXSTACKTOP, UXSTACKTOP - PGSIZE]
用戶態錯誤處理棧
- [USTACKTOP, UTEXT]
用戶態運行棧
- 對於內核態系統棧
是運行內核相關程序的棧,在有中斷被觸發之后,CPU會將棧自動切換到內核棧上來,而內核棧的設置是在kern/trap.c的trap_init_percpu()中設置的
可以看到內核棧的大小是固定了的,在系統初始化的時候就設定好了的
在中斷觸發之后,進入kern/trapentry.S代碼時,此時所處的棧就已經切換到了內核棧了,而且會在內核棧上壓入一系列的內容,手動形成一個trapframe
- 用戶運行時棧
是用戶運行中使用的棧,是在用戶進程創建之初初始化的
實際中的應用進程在初始化的情況下只有一頁的大小,如果發生了stack overflow,就會觸發頁錯誤,但是用戶空間中的頁錯誤是有處理程序的,會將棧頂下方一頁映射到當前內存空間
所以說,用戶運行棧是一邊運行一邊生長的,而內核棧是固定大小的,錯誤棧也是
- 用戶態錯誤棧
用戶定義注冊了自己的中斷處理程序之后,相應的例程運行時的棧
這個過程如下
- 首先陷入到內核,棧位置從用戶運行棧切換到內核棧,進入到trap中,進行中斷處理分發,進入到page_fault_handler()
- 當確認是用戶程序觸發的page fault的時候(內核觸發的直接panic了),為其在用戶錯誤棧里分配一個UTrapframe的大小
- 把棧切換到用戶錯誤棧,運行響應的用戶中斷處理程序
- 中斷處理程序可能會觸發另外一個同類型的中斷,這個時候就會產生遞歸式的處理
- 處理完成之后,返回到用戶運行棧
Invoking the user page fault handler
當用戶進程運行出錯,而且對於這個錯誤,用戶是定義了自己的異常處理例程的時候,按照前面的說法,是需要切換到異常棧上去執行的
那么如何切換過去呢?
可以將用戶自己定義的用戶處理進程當作是一次函數調用看待,當錯誤發生的時候,調用一個函數,但實際上還是當前這個進程,並沒有發生變化
所以當切換到異常棧的時候,依然運行當前進程,但只是運行的中斷處理函數,所以說此時的棧指針發生了變化,而且程序計數器eip也發生了變化,同時還需要知道的是引發錯誤的地址在哪。這些都是要在切換到異常棧的時候需要傳遞的信息
和之前從用戶棧切換到內核棧一樣,這里是通過在棧上構造結構體,傳遞指針完成的
這里新定義了一個結構體用來記錄出現用戶定義錯誤時候的信息Utrapframe
相比於UTrapframe,這里多了utf_fault_va,因為要記錄觸發錯誤的內存地址
同時還少了es,ds,ss等。因為從用戶態棧切換到異常棧,或者從異常棧再切換回去,實際上都是一個用戶進程,所以不涉及到段的切換,不用記錄
在實際使用中,Trapframe是作為記錄進程完整狀態的結構體存在的,也作為函數參數進行傳遞;而UTrapframe只在處理用戶定義錯誤的時候用到
整體上講,當正常執行過程中發生了頁錯誤,那么棧的切換是
用戶運行棧--->內核棧--->異常棧
而如果在異常處理程序中發生了也錯誤,那么棧的切換是
異常棧--->內核棧--->異常棧
接下來就是要實現page_fault_handler函數
如果當前已經在用戶錯誤棧上了,那么需要留出4個字節,否則不需要,具體和跳轉機制有關系
簡單說就是在當前的錯誤棧頂的位置向下留出保存UTrapframe的空間,然后將tf中的參數復制過來
修改當前進程的程序計數器和棧指針,然后重啟這個進程,此時就會在用戶錯誤棧上運行中斷處理程序了
當然,中斷處理程序運行結束之后,需要再回到用戶運行棧中,這個就是異常處理程序需要做的了
這里還有一個問題
如果異常棧發生了overflow怎么辦?
看一下memlayout.h就知道了
可以看到,用戶異常棧就一頁的大小,一旦溢出,訪問的就是內核都沒有訪問權限的空間,會發生內核空間中的page fault,此時會直接panic,不會造成更嚴重的后果
接下來就是寫匯編程序的一部分了,主要實現的功能是:當從用戶定義的處理函數返回之后,如何從用戶錯誤棧直接返回到用戶運行棧。這部分我比較頭疼,直接參考了張弛師兄的代碼
還有就是set_pgfault_handler()函數了,主要是為進程設定處理歷程,同時分配錯誤棧
接下來就是最重要的部分:實現copy-on-write fork
與之前的dumbfork不同,fork出一個子進程之后,首先要進行的就是將父進程的頁表的全部映射拷貝到子進程的地址空間中去。
這個時候物理頁會被兩個進程同時映射,但是在寫的時候是應該隔離的。采取的方法是,在子進程映射的時候,將父進程空間中所有可以寫的頁表的部分全部標記為可讀,且COW
而當父進程或者子進程任意一個發生了寫的時候,因為頁表現在都是不可寫的,所以會觸發異常,進入到我們設定的page fault處理例程,當檢測到是對COW頁的寫操作的情況下,就可以將要寫入的頁的內容全部拷貝一份,重新映射。
一開始我還在想,在進入到page fault處理例程的時候,在拷貝一份之后,就可以將這一頁標記為可以寫了啊,這樣另外一個進程就不在寫的時候觸發異常了。但是我還是naive啊,因為fork操作是可以嵌套多層的,所以不知道有多少個進程有向這個頁面的映射。
所以可以采取的方法是,當進程觸發了page fault,完成了相應頁的拷貝之后,需要解出對原來頁的映射,此時可以調用page_remove,而這個函數會根據頁的引用計數決定是否回收,所以不會出現內存泄漏的情況。
首先看page fault處理例程
這個邏輯還是挺明朗的,這里借用了一個一定不會被用到的位置PFTEMP,專門用來發生page fault的時候拷貝內容用的。
需要注意的是,在map之前一定要先unmap,否則的話,如果子進程和父進程都對同一位置進行了寫操作,都復制了一份出來,那么原來的一份就沒用了,而又沒有進行unmap操作的話,這一頁的引用計數永遠不歸零。
然后再看duppage函數
作用就是將當前進程的第pn頁對應的物理頁的映射到envid的第pn頁上去,同時將這一頁都標記為COW
fork函數
需要將映射拷貝過去,這里需要考慮的地址范圍就是從UTEXT到UXSTACKTOP為止,而在此之上的范圍因為都是相同的,在env_alloc的時候已經設置好了,所以不需要考慮了
首先需要為父進程設定錯誤處理例程,這里調用set_pgfault_handler函數是因為當前並不知道父進程是否已經建立了錯誤棧,沒有的話就會建立一個
而sys_env_set_pgfault_upcall則不會建立錯誤站
調用sys_exofork准備出一個和父進程狀態相同的子進程,狀態暫時設置為ENV_NOT_RUNNABLE
然后進行拷貝映射的部分,在當前進程的頁表中所有標記為PTE_P的頁的映射都需要拷貝到子進程空間中去
但是有一個例外,是必須要新申請一頁來拷貝內容的,就是用戶錯誤棧
因為copy-on-write就是依靠用戶錯誤棧實現的,所以說這個棧要在fork完成的時候每個進程都有一個,所以要硬拷貝過來
這里看着比較別扭,流程就是:
- 申請新的物理頁,映射到子進程的(UXSTACKTOP-PGSIZE)位置上去
- 父進程的PFTEMP位置也映射到子進程新申請的物理頁上去,這樣父進程也可以訪問這一頁了
- 在父進程空間中,將用戶錯誤棧全部拷貝到子進程的錯誤棧上去,也就是剛剛申請的那一頁
- 然后父進程解除對PFTEMP的映射
最后把子進程的狀態設置為可運行就可以了













