s081[1]-操作系統原理

操作系統

操作系統接口

前言

實驗1需要我們調用unix操作系統保持出的接口,因此首先需要了解unix操作系統有關的知識。

操作系統(operating system)的功能

  • 操作系統的任務是在多個程序之間共享一臺計算機,並提供比單獨的硬件所支持的更爲有用的服務集。

  • 操作系統管理和抽象化低級硬件,因此,例如,文字處理器不必擔心自己正在使用哪種類型的硬件。

  • 操作系統允許多個程序之間共享硬件,以便它們可以併發運行。

  • 最後,操作系統提供了程序交互的方式,以便它們可以共享數據,協同工作。

  • 操作系統爲用戶程序提供接口去調用。設計一個好的接口操作系統接口非常困難。

  • 一方面希望簡單,一方面又希望實現複雜的功能。

  • 一種好的設計思路是接口之間可以通過某種機制組合起來以實現複雜的操作。

  • 本實驗中,使用了xv6 操作系統。其提供了unix的基本操作系統接口,並且模仿了Unix的內部設計。

  • unix提供的接口很少,但是由於其可以組合的機制,提供了難以想象的通用性。該接口非常成功,以至於現代操作系統(BSD,Linux,Mac OS X,Solaris,甚至在較小程度上是Microsoft Windows)都具有類似Unix的接口。瞭解xv6是瞭解這些系統和許多其他系統的良好起點。

  • 內核是操作系統的核心,爲運行的程序提供服務。 每個正在運行的程序(稱爲進程)都具有包含指令,數據和堆棧的內存。

  • 指令說明了程序的運行邏輯。數據是指令運行所需要的變量。堆棧組織程序的過程調用。

  • 當進程需要調用內核服務時,它將通過操作系統提供的接口進行過程調用。 這樣的過程稱爲系統調用。

  • 內核使用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身份運行。

I/O 與文件描述符

* 文件描述符是一個小的整數,表示進程可以從中讀取或寫入的內核管理的對象。 進程可以通過打開文件,目錄或設備,或通過創建管道,或通過複製現有描述符來獲取文件描述符。 爲簡單起見,我們通常將文件描述符所指的對象稱爲“文件”; 文件描述符接口抽象了文件,管道和設備之間的差異,使它們看起來都像字節流。
* 在內部,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之後,您應該能夠查看其他更復雜的操作系統,

參考資料

dreamerjonson.com/2020/
pdos.csail.mit.edu/6.82
pdos.csail.mit.edu/6.82
pdos.csail.mit.edu/6.82


發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章