實驗1須要咱們調用unix操做系統保持出的接口,所以首先須要瞭解unix操做系統有關的知識。html
操做系統的任務是在多個程序之間共享一臺計算機,並提供比單獨的硬件所支持的更爲有用的服務集。node
操做系統管理和抽象化低級硬件,所以,例如,文字處理器沒必要擔憂本身正在使用哪一種類型的硬件。shell
操做系統容許多個程序之間共享硬件,以便它們能夠併發運行。編程
最後,操做系統提供了程序交互的方式,以便它們能夠共享數據,協同工做。數組
操做系統爲用戶程序提供接口去調用。設計一個好的接口操做系統接口很是困難。網絡
一方面但願簡單,一方面又但願實現複雜的功能。併發
一種好的設計思路是接口之間能夠經過某種機制組合起來以實現複雜的操做。app
本實驗中,使用了xv6 操做系統。其提供了unix的基本操做系統接口,而且模仿了Unix的內部設計。編程語言
unix提供的接口不多,可是因爲其能夠組合的機制,提供了不可思議的通用性。該接口很是成功,以致於現代操做系統(BSD,Linux,Mac OS X,Solaris,甚至在較小程度上是Microsoft Windows)都具備相似Unix的接口。瞭解xv6是瞭解這些系統和許多其餘系統的良好起點。ide
內核是操做系統的核心,爲運行的程序提供服務。 每一個正在運行的程序(稱爲進程)都具備包含指令,數據和堆棧的內存。
指令說明了程序的運行邏輯。數據是指令運行所須要的變量。堆棧組織程序的過程調用。
當進程須要調用內核服務時,它將經過操做系統提供的接口進行過程調用。 這樣的過程稱爲系統調用。
內核使用CPU的硬件保護機制來確保在用戶空間中執行的每一個進程只能訪問其本身的內存。
用戶程序調用操做系統接口後,硬件提升權限級別,並開始在內核中執行預先安排的功能。
shell是一個普通程序,可讀取用戶命令並執行命令。 shell是用戶程序而不是內核的一部分,這一事實說明了操做系統接口的強大功能。shell沒有什麼特別之處,這也意味着shell易於更換;現代Unix系統有多種shell可供選擇,每種shell都有其本身的用戶界面和腳本功能。 xv6 shell是Unix Bourne shell的簡單實現。 能夠在(user / sh.c:1)中找到其實現。
xv6進程由用戶空間內存(指令,數據和堆棧)和內核專有的每一個進程狀態組成。
xv6保證進程的併發執行,在多個進程之間切換CPU能力。
當某個進程未執行時,xv6保存其CPU寄存器,並在下次運行該進程時恢復它們。 內核將進程標識符或pid與每一個進程相關聯。
進程可使用fork系統調用來建立新進程。 Fork建立一個稱爲子進程的新進程,該進程與父進程的內存徹底相同。
Fork在子進程與父進程中都會返回。
在父進程中,fork返回子進程的pid; 在子進程中,它返回零。
例如,考慮如下用C編程語言編寫的程序片斷:
int pid = fork(); if(pid > 0){ printf("parent: child=%d\en", pid); pid = wait(0); printf("child %d is done\en", pid); } else if(pid == 0){ printf("child: exiting\en"); exit(0); } else { printf("fork error\en"); }
exit致使調用進程中止執行並釋放資源,例如內存和打開的文件。
exit接受一個整數狀態參數,一般0表示成功,1表示失敗。
wait系統調用返回當前進程已退出子進程的pid,並將該子進程的退出狀態傳遞給wait。
若是子進程都沒有退出,一直會等待。
若是父進程不在意子進程的退出狀態,則能夠傳遞狀態0。
在下面的例子中,輸出是:
parent: child=1234
child: exiting
也可能出現另外的狀況,具體取決於父進程仍是子進程首先進入其printf調用。
子進程退出後,父進程的wait返回,致使父進程打印出:
parent: child 1234 is done。
儘管子進程最初具備與父進程相同的內存內容,可是父進程和子進程執行時使用的是不一樣的內存和不一樣的寄存器。
更改一個變量不會影響另外一個變量。
例如,當wait的返回值在父進程中存儲到pid中時,它不會更改子進程中的pid。 子進程中的pid的值仍爲零。
exec系統調用使用從文件系統中存儲的文件加載的新的內存映像替換調用進程的內存。
該文件必須具備特定的格式,該格式指定文件的哪一部分包含指令,哪一部分是數據,從哪條指令開始等。
xv6使用ELF格式,第3章將對此進行詳細討論。
當exec成功執行時,它不會返回到調用程序。從文件加載的指令在ELF標頭中聲明的入口點開始執行。
Exec接受兩個參數:包含可執行文件的文件名和一個字符串參數數組。 例如:
char *argv[3]; argv[0] = "echo"; argv[1] = "hello"; argv[2] = 0; exec("/bin/echo", argv); printf("exec error\en");
該片斷將調用程序以程序/ bin / echo的實例替換,參數列表爲echo hello。 大多數程序會忽略第一個參數,這一般是程序的名稱
xv6 Shell使用上述調用執行用戶運行的程序。
xv6 Shell使用上述調用表明用戶運行程序。
shell的主要結構很簡單; 參見main(user / sh.c:145)。
主循環使用getcmd從用戶讀取一行輸入。 而後,它將調用fork,這將建立shell進程的副本。
父進程調用wait,而子進程運行命令。 例如,若是用戶鍵入「 echo hello」給shell,則將以「 echo hello」做爲參數調用runcmd。 runcmd(user / sh.c:58)運行實際命令
對於「 echo hello」,它將調用exec(user / sh.c:78)
若是exec成功,則子進程將從echo執行指令,而不是runcmd。
在某些時候,echo將調用exit,這將致使父進程從main(user / sh.c:145)的wait中返回。
您可能想知道爲何fork和exec不能在單個調用中組合? 稍後咱們將看到,用於建立進程和加載程序的單獨調用在Shell中用於I / O重定向的用法很巧妙。
爲了不建立重複進程而後當即替換它的浪費,運行中的內核經過使用虛擬內存技術(如copy-on-write 寫時複製)來針對此用例優化fork的實現。
Xv6隱式分配了大多數用戶空間內存:fork分配了子進程複製父進程所需的內存,而exec分配了足夠的內存來保存可執行文件。
一個在運行時須要更多內存的進程(多是malloc)能夠調用sbrk(n)將其數據內存增長n個字節。 sbrk返回新內存的位置。
Xv6沒有提供用戶概念來保護一個用戶免受另外一個用戶侵害;用Unix術語,全部xv6進程都以root身份運行。
* 文件描述符是一個小的整數,表示進程能夠從中讀取或寫入的內核管理的對象。 進程能夠經過打開文件,目錄或設備,或經過建立管道,或經過複製現有描述符來獲取文件描述符。 爲簡單起見,咱們一般將文件描述符所指的對象稱爲「文件」; 文件描述符接口抽象了文件,管道和設備之間的差別,使它們看起來都像字節流。 * 在內部,xv6內核使用文件描述符做爲每一個進程表的索引,所以每一個進程都有一個從零開始的文件描述符專用空間。 按照慣例,進程從文件描述符0(標準輸入)讀取,將輸出寫入文件描述符1(標準輸出),並將錯誤消息寫入文件描述符2(標準錯誤)。 就像咱們將看到的那樣,shell利用約定來實現I / O重定向(redirection)和管道(pipelines)。 shell確保始終打開三個文件描述符(user / sh.c:151),默認狀況下,這三個文件描述符是控制檯(console)的文件描述符。
read系統調用從文件描述符讀取字節。
write系統調用從文件描述符寫入字節。
調用read(fd,buf,n)最多從文件描述符fd中讀取n個字節,將它們複製到buf中,並返回讀取的字節數。 引用文件的每一個文件描述符都有一個與之關聯的偏移量。read從當前文件偏移量讀取數據,隨着讀取到的數據增長,文件的偏移量隨之增長。當沒有更多字節能夠讀取時,read將返回零以指示文件末尾。
調用write(fd,buf,n)將buf中的n個字節寫入文件描述符fd,並返回寫入的字節數。 僅在發生錯誤時才寫入少於n個字節。 與讀操做相似,寫操做會在當前文件偏移量處寫入數據,而後將偏移量增長寫入的字節數:每次寫操做都從上次停止的位置開始。
如下程序片斷(cat命令的功能)將數據從其標準輸入複製到其標準輸出。 若是發生錯誤,它將向標準錯誤寫入一條消息。
char buf[512]; int n; for(;;){ n = read(0, buf, sizeof buf); if(n == 0) break; if(n < 0){ fprintf(2, "read error\en"); exit(); } if(write(1, buf, n) != n){ fprintf(2, "write error\en"); exit(); } } 在代碼片斷中要注意的重要一點是cat不知道它是從文件,控制檯仍是管道中讀取。 一樣,cat不知道它是要打印到控制檯,文件仍是其餘地方。 使用文件描述符以及文件描述符0是標準輸入和輸出文件描述符是標準輸出的約定能夠實現cat的簡單實現。close系統調用將釋放文件描述符,以供未來的open,pipe或dup系統調用重用。 新分配的文件描述符始終是當前進程中編號最小的未使用的描述符。
文件描述符和fork交互使I/O重定向易於實現。Fork會複製父文件的文件描述符表及其內存,以便子文件與父文件打開徹底相同的文件。 exec系統調用替換了調用進程的內存,但保留了其文件表。 此行爲容許Shell經過分叉,從新打開選定的文件描述符,而後exec新程序來實現I / O重定向。 這是shell爲cat <input.txt命令運行的代碼的簡化版本:
char *argv[2]; argv[0] = "cat"; argv[1] = 0; if(fork() == 0) { close(0); open("input.txt", O_RDONLY); exec("cat", argv); }
當child關閉文件描述符0後,0 是最小的文件描述符。所以open操做將使文件描述符0(標準輸入)指向文件input.txt.xv6 shell中的I / O重定向代碼徹底以這種方式工做(user / sh.c:82)。
如今應該清楚爲何將fork和exec分開調用是一個好主意? 由於若是它們是分開的,則shell能夠fork一個child,在該child中使用open,close,dup來更改標準輸入和輸出文件描述符,而後exec。 不須要更改正在執行的程序(在咱們的示例中爲cat)。 若是將fork和exec組合到單個系統調用中,則shell將須要一些其餘(可能更復雜)的方案來重定向標準輸入和輸出,或者程序自己將必須瞭解如何重定向I / O。
儘管fork複製了文件描述符表,但每一個潛在文件的偏移量在父級和子級之間共享。 考慮如下示例:
if(fork() == 0) { write(1, "hello ", 6); exit(0); } else { wait(0); write(1, "world\en", 6); }
在上例中,父進程和子進程都將寫入文件描述符1.最後輸出的數據是"hello world"
父進程的寫入會等到子進程寫入後進行(因爲wait)。兩個文件描述符共享一個偏移量。
此行爲有助於從Shell命令序列產生順序輸出,例如(echo hello; echo world)> output.txt。
dup系統調用複製了一個現有的文件描述符,並返回了一個新的文件描述符,該描述符引用了相同的潛在I/O對象。兩個文件描述符共享一個偏移量,就像fork所複製的文件描述符同樣。這是將hello world寫入文件的另外一種方法:
fd = dup(1); write(1, "hello ", 6); write(fd, "world\en", 6);
若是兩個文件描述符是經過fork和dup調用序列從同一原始文件描述符派生的,則它們共享一個偏移量。 不然,文件描述符不共享偏移量,即便它們是對同一文件的open產生的。 Dup容許shell執行如下命令: ls existing-file non-existing-file > tmp1 2>&1。 2>&1告訴shell將文件描述符2與描述符1相同。已存在文件的名稱和文件不存在等錯誤消息都將顯示在文件tmp1中。 xv6 Shell不支持錯誤文件描述符的I / O重定向,可是如今您知道如何實現它。
文件描述符是一種強大的抽象,由於它們隱藏了它們所鏈接的對象的詳細信息:寫入文件描述符1的進程可能正在寫入文件,諸如控制檯的設備或管道。
管道是一個小的內核緩衝區,以一對文件描述符的形式暴露給進程,一個用於讀取,一個用於寫入。
將數據寫入管道的一端可以使該數據可從管道的另外一端讀取。 管道爲流程進行通訊提供了一種方法。 如下示例代碼運行程序wc,使用標準輸入鏈接到管道的讀取端。
int p[2]; char *argv[2]; argv[0] = "wc"; argv[1] = 0; pipe(p); if(fork() == 0) { close(0); dup(p[0]); close(p[0]); close(p[1]); exec("/bin/wc", argv); } else { close(p[0]); write(p[1], "hello world\en", 12); close(p[1]); } * 程序調用pipe建立一個新管道,並將讀取和寫入文件描述符記錄在數組p中。 在fork以後,父進程和子進程都具備引用管道的文件描述符。 子進程將讀取端複製到文件描述符0上,關閉p中的文件描述符,而後執行wc。 當wc從其標準輸入中讀取時,它將從管道中讀取。 父進程關閉管道的讀取側,寫入管道,而後關閉寫入側。
若是沒有可用數據,則在管道上進行讀取以等待寫入數據或全部引用寫入端的文件描述符被關閉; 在後一種狀況下,讀取將返回0,就像到達數據文件的末尾同樣。 讀取管道會一直堵塞直到沒法接受到數據。所以,對於子進程來講,在執行上述wc以前關閉管道的寫端很重要:若是wc進程的文件描述符之一引用了管道的寫端,則wc將永遠等不到文件末尾 。
xv6 shell實現了管道,例如grep fork sh.c | wc -l 相似於上面的代碼(user / sh.c:100)。 子進程建立一個管道,以將管道的左端與右端鏈接起來。 而後,它在管道的左端調用fork和runcmd,在右端調用fork和runcmd,並等待二者都完成。 管道的右端多是一個命令,該命令自己包括一個管道(例如a | b | c),該管道自己派生了兩個新的子進程(一個用於b,一個用於c)。 所以,shell能夠建立進程樹。 該樹的葉子是命令,內部節點是等待左右子節點完成的進程。 原則上,您可讓內部節點在管道的左端運行,可是這樣作會使實現複雜化。
管道彷佛沒有臨時文件強大:echo hello world | wc
能夠在沒有管道的狀況下實現:
echo hello world >/tmp/xyz; wc </tmp/xyz
在這種狀況下,管道比臨時文件至少具備四個優勢。 首先,管道會自動清理本身; 使用文件重定向,shell必須在完成後當心刪除/ tmp / xyz。 其次,管道能夠傳遞任意長的數據流,而文件重定向須要磁盤上有足夠的可用空間來存儲全部數據。 第三,管道容許並行執行管道階段,而文件方法要求第一個程序在第二個程序啓動以前完成。 第四,若是要實現進程間通訊,則管道的讀寫鎖比文件的 non-blocking語義更有效。
xv6文件系統提供了數據文件和目錄,這些數據文件是原始的字節數組。目錄包含對數據文件和其餘目錄的命名引用。 目錄造成一棵樹,從一個特殊的root目錄開始。
相似於/a/b/c的路徑是指根目錄/中名爲a的文件夾中名爲b的文件夾中名爲c的文件或文件夾。
不以/開頭的路徑是相對於調用進程的當前目錄的。調用進程的當前目錄能夠經過chdir系統調用對其進行更改。
下面這兩個程序片斷都打開同一個文件(假設文件存在)
chdir("/a"); chdir("b"); open("c", O_RDONLY); open("/a/b/c", O_RDONLY);
第一個代碼片斷將進程的當前目錄更改成/a /b; 第二個既不引用也不更改進程的當前目錄。
有多個操做系統接口來建立新文件或文件夾:mkdir建立新文件夾,使用O_CREATE標誌調用open將建立新數據文件,而mknod將建立新設備文件。 以下例所示:
mkdir("/dir"); fd = open("/dir/file", O_CREATE|O_WRONLY); close(fd); mknod("/console", 1, 1);
Mknod在文件系統中建立一個文件,可是該文件沒有內容。 可是,文件的元數據會將其標記爲設備文件,並記錄主設備號和次設備號(mknod的兩個參數),它們惟一地標識內核設備。 當之後有一個進程打開文件時,內核會將read和write系統調用轉換到內核設備的讀寫實現,而不是將它們轉換到文件系統。
fstat系統調用獲得有關文件描述符引用的對象的信息。此對象信息返回結構體stat,定義在
stat.h (kernel/stat.h):
#define T_DIR 1 // Directory #define T_FILE 2 // File #define T_DEVICE 3 // Device struct stat { int dev; // File system’s disk device uint ino; // Inode number short type; // Type of file short nlink; // Number of links to file uint64 size; // Size of file in bytes };
文件名與文件不一樣; 同一個文件(稱爲inode)能夠具備多個名稱(稱爲links)。
link系統調用將建立另外一個文件名稱,該名稱引用與現有文件相同的inode。 下面的程序片斷建立了一個名爲a又爲b的新文件。
open("a", O_CREATE|O_WRONLY); link("a", "b");
讀取,寫入a與讀取,寫入到b相同。 每一個inode由惟一的inode編號標識。 在上面的代碼片斷以後,能夠經過檢查fstat的結果肯定a和b是否引用相同的文件:二者將返回相同的inode編號(ino),而且nlink將變爲2。
unlink系統調用從文件系統中刪除一個名稱。 僅當文件的link計數爲零且沒有文件描述符引用該文件時, 纔會將inode和其所在的磁盤空間清除。
所以當執行了
unlink("a");
以後,使用名稱b任然可以訪問文件。
下面的程序片斷是一種慣用的方式建立一個臨時inode。
fd = open("/tmp/xyz", O_CREATE|O_RDWR); unlink("/tmp/xyz");
當fd文件描述符被關閉後,臨時的inode將會被清除。
用於文件系統操做的Shell命令是做爲用戶級程序(例如mkdir,ln,rm等)實現的。該設計容許任何人經過添加新的用戶程序擴展Shell。在過後看來,彷佛是理所固然的。
但和Unix同時期的其餘系統設計,一般將這樣的命令構建到shell中(並將shell構建到內核中)。
cd是一個例外,它內置在shell中(user / sh.c:160)。 cd必須更改shell自己的當前工做目錄。 若是cd以常規命令運行,那麼shell將派生一個子進程,該子進程將運行cd,而cd會更改該子進程的工做目錄。 父進程(即shell)的工做目錄不會更改。
Unix結合了文件描述符,管道和方便的shell語法以對其進行操做,這是編寫通用可複用程序的重大進步。這是Unix的強大功能和普遍使用的緣由,外殼程序是第一種所謂的「腳本語言」。Unixit系統調用接口在BSD,Linux,和Mac OSX 上普遍使用。
Unix系統調用接口已經過可移植操做系統接口(POSIX)標準進行了標準化。 Xv6不兼容POSIX。 它拋棄一些了系統調用(包括諸如lseek之類的基本調用),僅部分實現了系統調用以及其餘差別。 xv6的主要目標是簡單性和清晰度,同時提供簡單的類UNIX系統調用接口。 爲了運行基本的Unix程序,一些人用更多的系統調用和一個簡單的C庫擴展了xv6。 可是,與xv6相比,現代內核提供了更多的系統調用和內核服務。 例如,它們支持聯網,窗口系統,用戶級線程,許多設備的驅動程序等。 現代內核不斷快速發展,並提供了POSIX之外的許多功能。
在很大程度上,現代Unix派生的操做系統沒有遵循早期的Unix模型,即將設備公開爲特殊文件,例如上面討論的控制檯設備文件。Unix的做者繼續構建Plan9,將「資源即文件」概念應用於現代設施,將網絡,圖形和其餘資源表示爲文件或文件樹。
文件系統和文件描述符是強大的抽象。 即便這樣,也存在其餘模型。 Multics是Unix的前身,它以一種相似於內存的方式抽象了文件存儲,從而產生了大相徑庭的界面風格。 Multics設計的複雜性直接影響了Unix的設計師,後者試圖構建更簡單的東西。
本書探討了xv6如何實現其相似Unix的接口,可是這些思想和概念不只適用於Unix。 任何操做系統都必須將進程多路複用到基礎硬件上,將進程彼此隔離,並提供用於受控的進程間通訊的機制。 研究xv6以後,您應該可以查看其餘更復雜的操做系統,
https://dreamerjonson.com/2020/01/04/6-s081-1/
https://pdos.csail.mit.edu/6.828/2019/labs/util.html
https://pdos.csail.mit.edu/6.828/2019/xv6/book-riscv-rev0.pdf
https://pdos.csail.mit.edu/6.828/2019/lec/l-overview.txt