操做系統的任務是讓多個程序共享計算機(資源),而且提供一系列基於計算機硬件的但更有用的服務。操做系統管理而且把底層的硬件抽象出來,舉例來講,一個文字處理軟件(例如word)不須要關心計算機使用的是哪一種類型的磁盤。操做系統使得硬件能夠多路複用,容許許多程序共同使用計算機而且在同一時間上運行。最後,操做系統爲程序間的互動提供受控的方法,所以多個程序能夠共享數據、協同工做。node
計算機操做系統經過接口向用戶程序提供服務。設計一個好的接口是一件困難的事情。一方面,咱們但願設計出來的接口足夠簡單且功能單一(精準),這樣可以容易地保證明現的正確性。而另外一方面,咱們可能會忍不住想爲應用添加一些更加複雜的功能。解決這種矛盾的訣竅是讓接口的設計依賴於少許的機制(mechanism),並經過這些機制的組合來提供更加通用的功能。shell
這本書以XV6操做系統做爲一個實用的例子來闡述操做系統的觀念。XV6提供Unix操做系統的基本接口(由Ken Thompson 和 Dennis Ritchies引入) ,同時也模仿了Unix的內部實現。Unix提供的窄接口(narrow interface)所實現的機制可以很好地組合起來,而且具備使人吃驚的通用性。這些接口設計得如此成功——以致於現代操做系統包括BSD、Linux、Mac OS X ,Solaris,甚至是Microsoft Windows(在某種較小的程度上)都擁有相似於Unix的接口。理解XV6是理解上述操做系統的好開端。數組
如圖 Figure 0-1所示,XV6採用了傳統的內核概念:內核是向運行中的其餘程序提供服務的特殊程序。每個運行中的程序稱之爲進程,都擁有包括指令集、數據、棧的內存空間。指令完成了程序的運算,數據爲運算過程當中的變量,而棧管理程序運行中的函數調用。網絡
當進程須要內核所提供的服務時,進程調用了稱爲系統調用的操做系統接口。系統調用會進入內核,內核執行相應的服務後返回用戶空間,因此進程老是在用戶空間與內核空間之間交替進行着。ide
內核使用CPU的硬件保護機制來確保每個在用戶空間中執行的進程只能訪問它本身的內存空間。內核擁有實現這些保護機制所須要的硬件特權,而用戶程序沒有這些特權。當用戶進程調用系統調用時,硬件會提供權限登記,而且執行內核中預先設置的功能。函數
內核提供的一系列的系統調用集合,這些系統調用是用戶程序可見的接口。XV6操做系統的內核提供了Unix系統調用的子集。下面這張列表中是全部XV6所提供的系統調用:工具
系統調用 | 描述 |
---|---|
fork() | 建立一個進程 |
exit() | 結束當前進程 |
wait() | 等待子進程結束 |
kill(pid) | 結束 pid 所指進程 |
getpid() | 返回當前進程 pid |
sleep(n) | 睡眠 n 秒 |
exec(filename, *argv) | 加載一個文件並執行它 |
sbrk(n) | 爲進程內存空間增長 n 字節 |
open(filename, flags) | 打開一個文件,flags 指定讀/寫模式 |
read(fd, buf, n) | 從文件中讀 n 個字節到 buf |
write(fd, buf, n) | 從 buf 中寫 n 個字節到文件 |
close(fd) | 關閉fd指向的文件 |
dup(fd) | 複製 fd |
pipe( p) | 建立管道, 並把讀和寫的 fd 返回到p |
chdir(dirname) | 改變當前目錄 |
mkdir(dirname) | 建立新的目錄 |
mknod(name, major, minor) | 建立設備文件 |
fstat(fd) | 返回打開的文件信息 |
link(f1, f2) | 給 f1 建立一個新名字(f2) |
unlink(filename) | 刪除文件 |
本章的剩餘內容將概述XV6所提供的服務——進程、內存、文件描述符、管道以及文件系統,經過一段段的代碼來介紹它們而且討論shell是如何使用它們的。這些系統調用在shell上的使用,體現了它們的設計是多麼獨具匠心。學習
shell是一個普通的程序,它讀取用戶的命令而且執行它們,shell也是傳統的類Unix(Unix-like)系統中主要的用戶界面。實際上,shell也是一個用戶程序,它並非內核的一部分,這也說明了系統調用接口的強大:shell並無什麼特殊之處,它很容易被替代。因此,現代的類Unix操做系統有許多種shell能夠選擇,每種shell都有其自身的用戶界面與腳本特性。XV6的shell 是Unix Bourne shell的一個簡單實現。在第8350行可以找獲得它的實現。ui
一個Xv6進程由用戶內存空間(指令、數據、棧)和僅對內核可見的進程狀態這兩部分組成。Xv6可以分時運行進程:等待執行的多個進程可以在CPU可用時佔用CPU,並不斷切換。當一個進程再也不執行而讓出CPU時,Xv6保存了該進程的CPU上某些相關寄存器中的內容,方便該進程在下次佔用CPU時恢復到上次運行的狀態並接着運行。內核將每個進程與一個惟一的進程標識符,即pid(process identifier)關聯在一塊兒。操作系統
一個進程可使用系統調用fork
來建立一個新的進程。調用fork
的進程稱爲父進程,fork
建立了一個新的進程,稱爲子進程。子進程擁有與父進程徹底相同的內存內容。在父進程的程序中,fork
函數返回的是子進程的pid,而在子進程的程序中,fork函數返回0。例如,思考下面代碼片斷:
int pid = fork(); if(pid >0){ printf("parent : child = %d \n",pid); pid = wait(); printf("child %d is done \n",pid); }else if(pid == 0) { printf("child: exiting \n"); eixt(); }else{ printf("fork error\n"); }
系統調用exit
會致使進程中止執行並釋放資源,例如內存或者打開的文件。系統調用wait
會返回一個當前進程已退出的子進程的pid,若是沒有子進程退出,wait
會等待直到有一個進程退出。在例子中,輸出結果爲:
parent: child = 1234 child:eixting
可能會有不一樣順序的結果,這取決於父進程與子進程誰先執行完printf函數。在子進程退出以後,父進程的wait
也就返回了,因而父進程打印:
parent:chlid 1234 is done
注意到父進程與子進程擁有不一樣的內存空間與寄存器,所以在父進程中改變某個變量的值,並不影響子進程中該變量的值,反過來也成立。
系統調用exec
用新的內存鏡像替換掉當前進程的內存空間,內存鏡像從存儲在文件系統中的文件加載進來。這份文件必須符合特定的格式,該格式規定了文件哪部分存儲指令、哪部分是指令、哪部分是指令的開始等等。xv6使用ELF文件格式,在第二章將討論更多關於它的細節。當exec
成功調用後,它並不返回到調用進程,而是從文件的開頭加載指令,在ELF頭聲明的入口點開始執行。exec
接受兩個參數:包含可執行文件的文件名稱以及字符串參數數組。例如:
char * argv[3]; argv[0] = "echo"; argv[1] = "hello"; argv[2]= 0; exec("/bin/echo",argv); printf("exec error\n");
這段代碼用/bin/echo
這個程序代替了調用程序,/bin/echo
程序的參數列表爲echo hello
。大部分程序忽略第一個參數,這一般是程序的名稱。
xv6的shell使用上述的系統調用來運行用戶程序。shell的主要結構很簡單:詳見main
的代碼(在8501行),主循環使用getcmd
讀取命令行的輸入,而後它調用fork
,來建立shell進程的一份拷貝。父進程shell調用wait
,子進程執行命令。例如,若是用戶輸入了echo hello
,runcmd
(在8406行)將被調用並以echo hello
做爲參數,runcmd
真正執行了命令。對於echo hello
,runcmd
將調用exec
(在8426行),若是exec
調用成功,那麼子進程將代替runcmd
執行echo
指令。在某個時刻,echo
將調用exit
,這會使得父進程shell從wait
返回到main
。你或許會疑惑爲何fork
與exec
不合併爲一個系統調用,咱們稍後將看到,把建立進程與加載進程分割成兩個系統調用是一個靈巧的設計。
Xv6一般隱式地分配用戶空間的內存:當子進程複製父進程的內存時,fork
爲子進程分配內存,而exec
分配了足夠的內存來保存可執行文件。在運行時須要更多內存的進程能夠調用sbrk(n)
來增長n字節的數據內存。sbrk
返回新內存的地址。
Xv6沒有提供用戶的概念,或者提供用戶之間的保護隔離機制。用Unix的術語來講,全部的xv6的進程都以root的身份來運行。
文件描述符是一個整數,表示一個可被進程讀或寫內核管理對象。進程能夠經過打開一個文件來得到該文件的文件描述符,文件能夠是目錄、設備,或者建立一個管道(pipe),或者經過複製已經存在的文件描述符。簡單起見,咱們把文件描述符指向的對象稱爲「文件」。文件描述符接口是對文件、管道、設備的抽象,使它們看上去都只是字節流。
每一個進程都有一張進程表,Xv6內核使用文件描述符做爲進程表的索引,使每個進程都有一個從0開始的私有的文件描述符空間。按照Unix慣例,進程從文件描述符0讀入(標準輸入),從文件描述符1輸出(標準輸出),將錯誤信息寫入到文件描述符2(標準錯誤)。正如咱們將看到的,shell運用這三個文件描述符來實現I/O重定向以及管道。shell進程確保它始終打開了這三個文件描述符(在8507行),這些是控制檯的默認文件描述符。
系統調用read
和write
從文件描述符所指的文件讀或寫數個字節的數據。read(fd,buf,n)
從文件描述符fd所指的文件讀取最多n個字節,並將它們拷貝到緩衝區,同時返回成功讀取到的字節數。每一個文件描述符都與一個偏移值相關,read
讀取數據時從當前文件的偏移值開始讀取,而後偏移值增長成功讀取的字節數,隨後的read
會重新的文件偏移讀取數據。當沒有更多的數據能夠讀取時,read
返回0,表示文件結束了。
系統調用write(fd,buf,n)
從buf取出n個字節的輸入寫入到文件描述符fd所指的文件中,並返回寫入的字節數。若是返回值小於n,那麼只有多是發生了錯誤。與read
類似,write
也從文件當前的偏移值處寫入文件,而後把偏移值增長成功寫入的字節數。
下面的程序片斷(實際上就是cat
的本質)從標準輸入拷貝數據到標準輸出,若是遇到了錯誤,它會往標準錯誤中輸出錯誤消息。
char buf [512] int n ; for(;;){ n = read(0,buf,sizeof buf); if(n==0) break; if(n<0){ fprintf(2,"read error\n"); exit(); } if(write(1,buf,n) != n){ fprintf(2,"write error\n"); eixt(); } }
這段代碼須要重視的地方在於,cat
並不知道它是從文件、控制檯仍是管道中讀取數據的。一樣的,cat
也不知道它是否寫到了一個控制檯、一個文件或其餘的什麼地方。文件描述符的使用與一些慣例——0是標準輸入,1是標準輸出,2是標準錯誤,使咱們很輕鬆地實現了cat
。
系統調用close
釋放了一個文件描述符,使得該文件描述符將來能夠被open
pipe
dum
等系統調用重用。一個新分配的文件描述符當前進程中最小的、未使用的文件描述符。
文件描述符與fork
的共同做用,使得I/O重定向易於實現。fork
複製父進程的文件描述符表與內存,因此子進程具備與父進程徹底相同的文件描述符。系統調用exec
替換掉調用進程的內存,但保留它的文件描述符表。這種行爲使得shell可以經過這些步驟實現I/O重定向:fork
一個進程、從新打開指定的文件描述符、而後exec
執行新的程序。下面是一段簡單版本的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); }
當子進程關閉了文件描述符0(標準輸入)以後,系統調用open
可以保證會使用0做爲文件input.txt
的文件描述符,這是由於0是此時進程中最小的、未使用的文件描述符。而後,cat
就會在標準輸入指向input.txt
的狀況下運行。
xv6 的shell正是以這樣的方式實現I/O重定向的(在8430行)。回想一下,在shell進程中會fork
出一個shell子進程,子進程運行runcum
系統調用,runcum
調用exec
加載新的程序。如今你應該很清楚爲何把fork
與exec
分開調用是個好主意了:這種分離使得shell能夠在子進程執行指定程序以前對子進程進行修改。
雖然fork
複製文件描述符表,但父進程與子進程共享每個文件的當前偏移。思考下面這個例子:
if((fork() == 0) { write(1,"hello ",6); exit(); }else{ wait(); write(1,"world\n",6); }
在這段代碼的執行末尾,文件描述符1所指的文件將包含數據"hello world"。父進程的系統調用write
從子進程write
結束的地方開始繼續寫入數據,這要感謝系統調用wait
,它會讓子進程結束後,父進程才接着執行。這種行爲有助於順序執行的shell命令也順序地輸出,例如(echo hello;echo world)>output.txt
系統調用dup
複製一個已有的文件描述符,返回一個指向同一I/O對象的新的文件描述符。這兩個文件描述符共享同一個文件偏移,與fork
所複製的同樣。這是另外一種方式來把hello world寫入文件中:
fd = dup(1); write(1,"hello ",6); write(fd,"world\n",6);
若是兩個文件描述符是經過系統調用fork
或系統調用dup
從同一個原始的文件描述符派生而來,那麼這兩個文件描述符共享同一個文件偏移,不然文件描述符不共享文件偏移,即便這兩個文件描述符是使用系統調用open
來打開同一個文件而獲得的。系統調用dup
容許shell這樣來實現命令:ls existing-file non-existing-file > tmp1 2>&1
。2>&1
通知shell把文件描述符2給命令,這個文件描述符2是文件描述符1的拷貝。已存在的文件名稱與因文件不存在而引起的錯誤信息將顯示在文件temp1
中。xv6的shell不支持標準錯誤輸出的重定向,但如今你知道如何去實現它。
文件描述符是一個強大的抽象,由於它隱藏了它所指向的文件的細節:一個向文件描述符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{ write(p[1],"hello world\n",12); close(p[0]); close(p[1]);
程序調用了系統調用pipe
,pipe
建立了一個新的管道並將讀與寫這兩個文件描述符保存在數組p中。執行了fork
以後,父進程與子進程都擁有與管道相關的文件描述符。子進程複製了管道讀的一端到文件描述符0,接着關閉了文件描述符p[0]及p[1],而後執行了系統調用wc
。當wc
從標準輸入讀取時,它其實是從管道讀取數據的。父進程從管道的寫端口寫入數據,而後關閉了管道的文件描述符。
若是管道中沒有可用的數據,從管道讀取數據的系統調用read
將一直等待,直到有數據寫入管道或者全部與管道寫端口關聯的文件描述符都被關閉。在後面這種狀況中,read
返回0,就好像數據的讀取已經到了文件結束部分(end-of-file)。讀操做會一直阻塞直到不可能有新數據到來,這就是爲何咱們在執行wc
以前要關閉子進程的寫端口。若是wc
指向一個管道的寫端口,那麼wc
就永遠看不到eof了。
xv6 shell使用了與上面代碼相似的方法,實現瞭如grep fork sh.c | wc -l
這樣的管道(在8450行)。子進程建立一個管道鏈接管道的左右兩端,而後爲管道的左右兩端都調用runcmd
,而後經過調用兩次wait
等待左右兩端結束。管道的右端可能也是一個帶有管道的命令(例如 a|b|c
),它fork
兩個新的子進程(一個b
,一個c
)。所以,shell可能會建立出一棵進程樹,樹的葉子節點爲命令,中間節點爲進程,它們等待左右子樹執行結束。原則上來講,你可讓中間節點都運行在管道的左端,但作得如此精確會使得實現變得複雜。
管道看起來彷佛比臨時文件沒什麼兩樣:管道echo hello world | wc
可用用無管道的方式實現爲echo hello world >/temp/xyz; wc< /tmp/xyz
。
管道與臨時文件的區別至少有三點。第一,管道會進行自我清掃,若是使用文件重定向的話,shell須要在任務完成後刪除temp/xyz
。第二,管道能夠傳遞任意長度的數據流,而文件重定向須要在磁盤上有足夠的空閒空間來存儲數據。第三,管道容許同步:兩個進程可使用一對管道來進行彼此間的通訊,調用進程的read
操做會被阻塞,直到另外一個進程調用write
完成數據的發送。
xv6 文件系統提供數據文件與目錄,文件就是一個簡單的字節數組,目錄包含了指向文件或其餘目錄的引用。Xv6把目錄做爲特殊的文件來處理。目錄構成了一棵樹,樹根爲一個稱爲root
的特殊目錄。路徑a/b/c
指向了一個名爲c
的文件或目錄,c
在文件目錄b
下,而目錄b
又處於目錄a
下,a
又是處於root
目錄之下。不以/
開頭的目錄表示相對當前進程目錄的目錄,進程的當前目錄能夠經過系統調用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
用來獲取文件描述符所指向的對象的信息。這些信息使用stuct stat
結構來描述,該結構定義在頭文件stat.h
中:
#define T_DIR 1 //目錄 #define T_FILE 2 //文件 #define T_DEV 3 //設備 struct stat{ short type; //文件的類型 int dev; //文件系統的磁盤設備 uint ino; //inode號碼 short nlink; //連接到文件的連接數目 unit size; //文件大小,以字節爲單位 };
文件名稱與文件自己是有很大區別的。同一個文件稱爲inode
,它能夠由多個不一樣的文件名,稱爲links
(連接)。系統調用link
爲文件建立了另外一個名稱,它們指向同一個已存在文件的inode
。這段代碼建立了建立了一個新文件,a
與b
都是該文件的名稱。
open("a",O_CREATE|O_WRONLY); link("a","b");
對文件a
進行讀寫就是對文件b
進行讀寫。每個inode
使用一個惟一的inode號
來肯定。在上面這些代碼示例後,咱們能夠經過fstat
來驗證a
與b
都指向了一樣的內容:它們都返回了相同的inode號
,並且nlink
會被設置爲2。
系統調用unlink
從文件系統中刪除一個名字。文件的inode
以及存儲該文件內容的磁盤空間只有在文件的連接數目(nlink
)爲0時被清空,此時沒有文件描述符指向該文件。所以在上面代碼的末尾加入:
unlink("a");
此時只有經過b
來訪問文件的inode
與文件內容。更多的,
fd = open("/tmp/xyz",O_CREATE|O_RDWR); unlink("/tmp/xyz");
這是一種建立臨時inode
的慣用方法,當進程關閉文件描述符fd
或進程退出時,這個inode
會被自動清空。
Xv6對文件系統進行操做的命令被實現爲用戶程序,例如mkdir
,ln
,rm
等等。這種設計容許任何人爲shell拓展新的命令。如今看來這種設計彷佛是理所應當的,但其餘在Unix時代設計的系統都將這些命令內置在shell之中(並且把shell也內置在內核之中)。
cd
是這種設計的一個例外,它是在shell中實現的(在8516行)。cd
必須改變shell自身的當前工做目錄。若是cd
做爲一個普通命令來執行,那麼shell會 fork
一個子進程,由子進程執行cd
,cd
會改變子進程的工做路徑,然而父進程的工做目錄不會被改變。
UNIX將"標準的"文件描述符,管道以及操縱它們的便捷的shell語法組合在一塊兒,這是編寫通用且可重用程序的重大進步。這種想法引起了「軟件工具」的文化以及Unix的強大,而shell也成爲首個所謂的「腳本語言」。Unix的系統調用接口在今天仍然存在於許多操做系統上,如BSD、Linux以及Mac OS X。
現代內核提供了比xv6要多得多的系統調用以及各類類型的內核服務。最重要的一點,從Unix衍生出來的現代操做系統沒有沿用早期Unix把設備暴露爲特殊文件的設計,好比上面所提到的控制檯文件。Unix系統的做者打算創建Plan 9,它將「資源是文件」的觀念應用到現代設備上,把網絡、圖形以及其餘的資源做爲文件或者文件樹。
把文件系統進行抽象是個強大的想法,它被以萬維網的形式應用在網絡的資源上。儘管如此,還有其餘操做系統接口的設計模型。Multics,一位Unix的前輩,將文件抽象成相似與內存的概念,產生了風格很是不同的接口。Multies設計的複雜性對Unix設計者由直接的影響,他們嘗試把文件系統的設計作的更簡單。
這本書詳述xv6是如何實現類Unix的接口,但設計的想法與觀念能夠應用到Unix以外的更多地方。任何操做系統必須讓多個進程複用硬件,進程與進程之間須要隔離開來,並提供進程間通訊的機制。在學習了xv6以後,你應該可以看到其餘更復雜的操做系統背後蘊藏着xv6的種種概念。