Mit6.828/6.S081 fall 2019的Lab1是Unix utilities,主要內容為利用xv6的系統呼叫實現sleep、pingpong、primes、find和xargs等工具。本文對各程式的實現思路及xv6的系統呼叫流程進行詳細介紹。
前言
在實驗之前,推薦閱讀一下官網LEC1中提供的資料。其中Introduction是對該課程的的概述,examples則是幾個系統程式設計的樣例,這兩部分快速瀏覽一遍即可。對於xv6 book的第一章,則建議稍微細緻地閱讀一遍,特別是對fork()、exec()、pipe()、dup()這幾個系統呼叫的介紹,會在後面實驗中用到。
實驗環境搭建參考上一篇文章。進入xv6-riscv-fall19專案後可以看到兩個比較重要的目錄:kernel為xv6核心原始碼,裡面除了os工作的核心程式碼(如程式排程),還有向外提供的介面(system call);user中則是使用者程式,如我們熟悉的ls,echo命令等。本次實驗的目的就是在user中增加使用者程式,藉助kernel中提供的system call來實現所需的功能。
實驗思路
每一個Lab需要在對應的分支編寫程式碼,進入xv6-riscv-fall19目錄下,使用git checkout util
切換到util分支,即可開始編寫我們的程式。下面主要提供實現思路,具體實驗程式碼請參考Github。
實驗完成後使用make grade
可以執行單元測試進行評分,會以gdb-server模式啟動qemu,並在gradelib.py中模擬gdb-client對我們的程式進行測試。如果在make grade時報錯Timeout! Failed to connect to QEMU
,可以將gradelib.py的325行改為self.sock.connect(("127.0.0.1", port))
。
sleep
sleep功能為使程式睡眠若干個時鐘週期(xv6中一個tick為100ms),首先建立user/sleep.c原始檔,引入user.h標頭檔案,系統呼叫和工具函式都定義在該檔案裡。核心程式碼如下:
sleep(atoi(argv[1]));
完成編寫後,在Makefile的UPROGS中追加一行$U/_sleep\
。輸入make qemu
進行編譯,成功後進入shell,輸入sleep 10
,如果程式睡眠了大約1s,則表示程式編寫正確。
pingpong
功能是父程式通過管道向子程式傳送1位元組,子程式收到後向父程式回覆1位元組。
由於管道是單向流動的,所以兩次呼叫pipe()
建立兩個管道,分別對應兩個方向。使用fork()
建立子程式,在子程式中先從管道1read()
再向管道2write()
,父程式中則與之相反。
primes
primes的功能是輸出2~35之間的素數,實現方式是遞迴fork程式並使用管道連結,形成一條pipeline來對素數進行過濾。
每個程式收到的第一個數p一定是素數,後續的數如果能被p整除則之間丟棄,如果不能則輸出到下一個程式,詳細介紹可參考文件。虛擬碼如下:
void primes() {
p = read from left // 從左邊接收到的第一個數一定是素數
if (fork() == 0):
primes() // 子程式,進入遞迴
else:
loop:
n = read from left // 父程式,迴圈接收左邊的輸入
if (p % n != 0):
write n to right // 不能被p整除則向右輸出
}
還需要注意兩點:
-
檔案描述符溢位: xv6限制fd的範圍為0~15,而每次pipe()都會建立兩個新的fd,如果不及時關閉不需要的fd,會導致檔案描述符資源用盡。這裡使用重定向到標準I/O的方式來避免生成新的fd,首先close()關閉標準I/O的fd,然後使用dup()複製所需的管道fd(會自動複製到序號最小的fd,即關閉的標準I/O),隨後對pipe兩側fd進行關閉(此時只會移除描述符,不會關閉實際的file物件)。
-
pipeline關閉: 在完成素數輸出後,需要依次退出pipeline上的所有程式。在退出父程式前關閉其標準輸入fd,此時read()將讀取到eof(值為0),此時同樣關閉子程式的標準輸入fd,退出程式,這樣程式鏈上的所有程式就可以退出。
find
find功能是在目錄中匹配檔名,實現思路是遞迴搜尋整個目錄樹。
使用open()開啟當前fd,用fstat()
判斷fd的type,如果是檔案,則與要找的檔名進行匹配;如果是目錄,則迴圈read()到dirent結構,得到其子檔案/目錄名,拼接得到當前路徑後進入遞迴呼叫。注意對於子目錄中的.
和..
不要進行遞迴。
xargs
xargs的功能是將標準輸入轉為程式的命令列引數。可配合管道使用,讓原本無法接收標準輸入的命令可以使用標準輸入作為引數。
根據lab中的使用例子可以看出,xv6的xargs每次回車都會執行一次命令並輸出結果,直到ctrl+d時結束;而linux中的實現則是一直接收輸入,收到ctrl+d時才執行命令並輸出結果。
思路是使用兩層迴圈讀取標準輸入:
- 內層迴圈依次讀取每一個字元,根據空格進行引數分割,將引數字串存入二維陣列中,當讀取到'\n'時,退出當前迴圈;當接收到ctrl+d(read返回的長度<0)時退出程式。
- 外層迴圈對每一行輸入
fork()
出子程式,呼叫exec()
執行命令。注意exec接收的二維引數陣列argv,第一個引數argv[0]必須是該命令本身,最後一個引數argv[size-1]必須為0,否則將執行失敗。
xv6系統呼叫流程
Lab中對system call的使用很簡單,看起來和普通函式呼叫並沒有什麼區別,但實際上的呼叫流程是較為複雜的。我們很容易產生一些疑問:系統呼叫的整個生命週期具體是什麼樣的?使用者程式和核心程式之間是如何切換上下文的?系統呼叫的函式名、引數和返回值是如何在使用者程式和核心程式之間傳遞的?
1.使用者態呼叫
在使用者空間,所有system call的函式宣告寫在user.h
中,呼叫後會進入usys.S
執行彙編指令:將對應的系統呼叫號(system call number)置於暫存器a7中,並執行ecall指令進行系統呼叫,其中函式引數存在a0~a5這6個暫存器中。ecall指令將觸發軟中斷,cpu會暫停對使用者程式的執行,轉而執行核心的中斷處理邏輯,陷入(trap)核心態。
2.上下文切換
中斷處理在kernel/trampoline.S
中,首先進行上下文的切換,將user程式在暫存器中的資料save到記憶體中(保護現場),並restore(恢復)kernel的暫存器資料。核心中會維護一個程式陣列(最多容納64個程式),儲存每個程式的狀態資訊,proc結構體定義在proc.h,這也是xv6對PCB(Process Control Block)的實現。使用者程式的暫存器資料將被暫時儲存到proc->trapframe
結構中。
3.核心態執行
完成程式切換後,呼叫trap.c/usertrap()
,接著進入syscall.c/syscall()
,在該方法中根據system call number拿到陣列中的函式指標,執行系統呼叫函式。函式引數從使用者程式的trapframe
結構中獲取(a0~a5),函式執行的結果則儲存於trapframe的a0欄位中。完成呼叫後同樣需要程式切換,先save核心暫存器到trapframe->kernel_*
,再將trapframe中暫存的user程式資料restore到暫存器,重新回到使用者空間,cpu從中斷處繼續執行,從暫存器a0中拿到函式返回值。
至此,系統呼叫完成,共經歷了兩次程式上下文切換:使用者程式 -> 核心程式 -> 使用者程式,同時伴隨著兩次CPU工作狀態的切換:使用者態 -> 核心態 -> 使用者態。