隨着時間的推移,有關存儲虛擬化造成了兩個關鍵的抽象。第一個是文件(file)。文件就是一個線性字節數組,每一個字節均可以讀取或寫入。每一個文件都有某種低級名稱,一般是某種數字,用戶一般不知道這個名字。因爲歷史緣由,文件的低級名稱一般稱爲inode號(inode number)。每一個文件都有一個與其關聯的inode號。node
第二個抽象是目錄(directory)。一個目錄,像一個文件同樣,也有一個低級名字(即inode號),可是它的內容很是具體:它包含一個(用戶可讀名字,低級名字)對的列表。例如,假設存在一個低級別名稱爲「10」的文件,它的用戶可讀的名稱爲「foo」。「foo」所在的目錄所以會有條目(「foo」,「10」),將用戶可讀名稱映射到低級名稱。目錄中的每一個條目都指向文件或其餘目錄。經過將目錄放入其餘目錄中,用戶能夠構建任意的目錄樹(directory tree,或目錄層次結構,directory hierarchy),在該目錄樹下存儲全部文件和目錄。數據庫
經過調用open()並傳入O_CREAT標誌,程序能夠建立一個新文件。下面是示例代碼,用於在當前工做目錄中建立名爲「foo」的文件。數組
int fd = open("foo", O_CREAT | O_WRONLY | O_TRUNC);
函數open()接受一些不一樣的標誌。在本例中,程序建立文件(O_CREAT),只能寫入該文件,由於以(O_WRONLY)這種方式打開,而且若是該文件已經存在,則首先將其截斷爲零字節大小,刪除全部現有內容(O_TRUNC)。函數
open()的一個重要方面是它的返回值:文件描述符(file descriptor)。文件描述符只是一個整數,是每一個進程私有的,在UNIX系統中用於訪問文件。所以,一旦文件被打開,若是你有權限的話,就可使用文件描述符來讀取或寫入文件。這樣來看,一個文件描述符就是一種權限(capability),即一個不透明的句柄,它可讓你執行某些操做。另外一種看待文件描述符的方法,是將它做爲指向文件類型對象的指針。一旦你有這樣的對象,就能夠調用其餘「方法」來訪問文件,如read()和write()。工具
文件成功打開後,就能夠對文件進行讀寫。read()是讀取文件的系統調用,它的原型以下:佈局
size_t read(int fildes, void *buf, size_t nbytes);
read()的第一個參數是文件描述符,一個進程能夠同時打開多個文件,所以描述符使操做系統可以知道某個特定的讀取引用了哪一個文件。第二個參數指向一個用於放置read()結果的緩衝區。第三個參數是緩衝區的大小。對read()的成功調用返回它讀取的字節數。性能
系統調用write()的原型以下:優化
size_t write(int fildes, const void *buf, size_t nbytes);
它的做用是把緩衝區buf的前nbytes個字節寫入與文件描述符fildes關聯的文件中,它返回實際寫入的字節數。ui
有時可以讀取或寫入文件中的特定偏移量是有用的。例如,若是你在文本文件上構建了索引並利用它來查找特定單詞,最終可能會從文件中的某些隨機偏移量中讀取數據。爲此,咱們可使用lseek()系統調用。下面是函數原型:atom
off_t lseek(int fildes, off_t offset, int whence);
第一個參數是一個文件描述符。第二個參數是偏移量,它將文件偏移量定位到文件中的特定位置。第三個參數,因爲歷史緣由而被稱爲whence,指定了搜索的執行方式。
對於每一個進程全部打開的文件,操做系統都會跟蹤一個「當前」偏移量,這將決定在文件中下一次讀取或寫入開始的位置。所以,打開文件的抽象包括它當前的偏移量,偏移量的更新有兩種方式。第一種是當發生N個字節的讀或寫時,N被添加到當前偏移,所以每次讀取或寫入都會隱式更新偏移量。第二種是lseek,它顯式改變上面指定的偏移量。
請注意,lseek()調用只是在OS內存中更改一個變量,該變量跟蹤特定進程的下一個讀取或寫入開始的偏移量。調用lseek()與移動磁盤臂的磁盤的尋道(seek)操做無關,執行I/O時,根據磁頭的位置,磁盤可能會也可能不會執行實際的尋道來完成請求。
大多數狀況下,當程序調用write()時,它只是告訴文件系統:在未來的某個時刻,將此數據寫入持久存儲。出於性能的緣由,文件系統會將這些寫入在內存中緩衝(buffer)一段時間。在稍後的時間點,纔會將寫入實際發送到存儲設備。
從應用程序的角度來看,寫入彷佛很快完成,而且只有在極少數狀況下(例如,在write()調用以後但寫入磁盤以前,機器崩潰)數據會丟失。可是,有些應用程序須要的不僅是這種保證。例如,在數據庫管理系統(DBMS)中,常常要求可以強制寫入磁盤。
爲了支持這些類型的應用程序,大多數文件系統都提供了一些額外的控制API。在UNIX中,提供給應用程序的接口被稱爲fsync。當進程針對特定文件描述符調用fsync()時,文件系統經過強制將全部髒數據寫入磁盤來響應。
經常使用的Linux命令mv,就使用了系統調用rename(char old, char new),它只須要兩個參數:文件的原來名稱和新名稱。
rename()調用提供了一個保證:它一般是一個原子(atomic)調用。若是系統在重命名期間崩潰,文件將被命名爲舊名稱或新名稱,不會出現奇怪的中間狀態。所以,對於支持某些須要對文件狀態進行原子更新的應用程序,rename()很是重要。
除了文件訪問以外,咱們還但願文件系統可以保存關於它正在存儲的每一個文件的信息,咱們一般將這些數據稱爲文件元數據(metadata)。要查看特定文件的元數據,咱們可使用stat()或fstat()系統調用。
每一個文件系統一般將這種類型的信息保存在一個名爲inode的stat結構體中。stat結構體的詳細信息以下所示:
struct stat { dev_t st_dev; /* ID of device containing file */ ino_t st_ino; /* inode number */ mode_t st_mode; /* protection */ nlink_t st_nlink; /* number of hard links */ uid_t st_uid; /* user ID of owner */ gid_t st_gid; /* group ID of owner */ dev_t st_rdev; /* device ID (if special file) */ off_t st_size; /* total size, in bytes */ blksize_t st_blksize; /* blocksize for filesystem I/O */ blkcnt_t st_blocks; /* number of blocks allocated */ time_t st_atime; /* time of last access */ time_t st_mtime; /* time of last modification */ time_t st_ctime; /* time of last status change */ };
你能夠看到有關於每一個文件的大量信息,包括其大小、低級名稱(即inode號)、一些全部權信息以及有關什麼時候文件被訪問或修改的一些信息等等。
若是用過UNIX,你知道只需運行程序rm就能夠刪除一個文件。可是,rm使用什麼系統調用來刪除文件?
答案是unlink,unlink()只須要待刪除文件的名稱,並在成功時返回零。
除了文件外,還可使用一組與目錄相關的系統調用來建立、讀取和刪除目錄。請注意,你永遠不能直接寫入目錄。由於目錄的格式被視爲文件系統元數據,因此你只能間接更新目錄,例如經過在其中建立文件、目錄或其餘對象類型。經過這種方式,文件系統能夠確保目錄的內容始終符合預期。
要建立目錄,能夠用系統調用mkdir()。新建立的目錄被認爲是「空的」,空目錄有兩個條目:一個引用自身的條目,一個引用其父目錄的條目。前者稱爲「.」目錄,後者稱爲「..」目錄。
既然咱們建立了目錄,也可能但願讀取目錄。下面是一個打印目錄內容的示例程序。該程序使用了opendir()、readdir()和closedir()這3個調用來完成工做。咱們只需使用一個簡單的循環就能夠一次讀取一個目錄條目,並打印目錄中每一個文件的名稱和inode編號。
int main(int argc, char *argv[]) { DIR *dp = opendir("."); assert(dp != NULL); struct dirent *d; while ((d = readdir(dp)) != NULL) { printf("%d %s\n", (int) d->d_ino, d->d_name); } closedir(dp); return 0; }
因爲目錄只有少許的信息(基本上,只是將名稱映射到inode號,以及少許其餘細節),程序可能須要在每一個文件上調用stat()以獲取每一個文件的更多信息,例如長度或其餘詳細信息。
你能夠經過調用rmdir()來刪除目錄。然而,與刪除文件不一樣,刪除目錄更加危險,由於你可使用單個命令刪除大量數據。所以,rmdir()要求該目錄在被刪除以前是空的(只有「.」和「..」條目)。若是你試圖刪除一個非空目錄,那麼對rmdir()的調用就會失敗。
咱們來談論一種在文件系統樹中建立條目的新方法,即link()系統調用。link()系統調用有兩個參數:一箇舊路徑名和一個新路徑名。當你將一個新的文件名「連接」到一箇舊的文件名時,實際上建立了另外一種引用同一個文件的方法。命令行程序ln用於執行此操做,以下面的例子所示:
prompt> echo hello > file prompt> cat file hello prompt> ln file file2 prompt> cat file2 hello
link只是在要建立連接的目錄中建立了另外一個名稱,並將其指向原有文件的相同inode號(即低級別名稱)。如今就有了兩個可讀的名稱(file和file2),都指向同一個文件。經過打印每一個文件的inode號,咱們能夠在目錄中看到這一點:
prompt> ls -i file file2 67158084 file 67158084 file2 prompt>
建立一個文件時,實際上作了兩件事。首先,要構建一個結構(inode),它將跟蹤幾乎全部關於文件的信息,包括其大小、文件塊在磁盤上的位置等等。其次,將人類可讀的名稱連接到該文件,並將該連接放入目錄中。
讓咱們回到刪除文件所提到的unlink()調用上來。當文件系統取消連接文件時,它檢查inode號中的引用計數(reference count)。該引用計數(有時稱爲連接計數,link count)容許文件系統跟蹤有多少不一樣的文件名已連接到這個inode。調用unlink()時,會刪除人類可讀的名稱與給定inode號之間的「連接」,並減小引用計數。只有當引用計數達到零時,文件系統纔會釋放inode和相關數據塊,從而真正「刪除」該文件。
還有一種很是有用的連接類型,稱爲符號連接(symbolic link),有時稱爲軟連接(soft link)。事實代表,硬連接有點侷限:你不能建立目錄的硬連接(由於擔憂會在目錄樹中建立一個環)。你不能硬連接到其餘磁盤分區中的文件(由於inode號在特定文件系統中是惟一的,而不是跨文件系統),等等。所以,人們建立了一種稱爲符號連接的新型連接。
要建立這樣的連接,可使用相同的程序ln,但須要使用-s標誌。
prompt> echo hello > file prompt> ln -s file file2 prompt> cat file2 hello
除了表面類似以外,符號連接實際上與硬連接徹底不一樣。第一個區別是符號連接自己其實是一個不一樣類型的文件。運行ls也揭示了這個事實,能夠看到常規文件最左列中的第一個字符是「-」,目錄是「d」,軟連接是「l」。你還能夠看到符號連接的大小,以及連接指向的內容。
prompt> ls -al drwxr-x--- 2 remzi remzi 29 May 3 19:10 ./ drwxr-x--- 27 remzi remzi 4096 May 3 15:14 ../ -rw-r----- 1 remzi remzi 6 May 3 19:10 file lrwxrwxrwx 1 remzi remzi 4 May 3 19:10 file2 -> file
file2是4個字節,緣由在於造成符號連接的方式,即將連接指向文件的路徑名做爲連接文件的數據。
最後,因爲建立符號連接的方式,有可能形成所謂的懸空引用(dangling reference)。刪除名爲file的原始文件會致使符號連接指向再也不存在的路徑名。
咱們如今已經瞭解了訪問文件、目錄和特定類型連接的基本接口。咱們再來討論另外一個話題:如何從許多底層文件系統組建完整的目錄樹。這項任務的實現是先製做文件系統,而後掛載它們,使其內容能夠訪問。
爲了建立一個文件系統,大多數文件系統提供了一個工具,一般名爲mkfs。思路以下:做爲輸入,爲該工具提供一個設備(例如磁盤分區,例如/dev/sda1),一種文件系統類型(例如ext3),它就在該磁盤分區上寫入一個空文件系統,從根目錄開始。
可是,一旦建立了這樣的文件系統,就須要在統一的文件系統樹中進行訪問。這個任務是經過mount程序實現的。mount的做用很簡單:以現有目錄做爲目標掛載點(mount point),本質上是將新的文件系統粘貼到目錄樹的這個點上。
咱們將介紹一個簡單的文件系統實現,稱爲VSFS(Very Simple File System,簡單文件系統),它是典型UNIX文件系統的簡化版本。
咱們須要作的第一件事是將磁盤分紅塊(block)。簡單的文件系統只使用一種塊大小,這裏正是這樣作的,咱們選擇經常使用的4KB。
所以,咱們對構建文件系統的磁盤分區的見解很簡單:一系列塊,每塊大小爲4KB。在大小爲N個4KB塊的分區中,這些塊的地址爲從0到N−1。假設咱們有一個很是小的磁盤,只有64塊:
爲了構建文件系統,須要在這些塊中存儲什麼。固然,首先想到的是用戶數據。咱們將用於存放用戶數據的磁盤區域稱爲數據區域(data region),簡單起見,將磁盤的固定部分留給這些塊,例如磁盤上64個塊的最後56個:
文件系統還必須記錄每一個文件的信息,該信息是元數據(metadata)的關鍵部分。爲了存儲這些信息,文件系統一般有一個名爲inode的結構。爲了存放inode,咱們還須要在磁盤上留出一些空間。咱們將這部分磁盤稱爲inode表(inodetable),它只是保存了一個磁盤上inode的數組。所以,假設咱們將64個塊中的5塊用於inode,磁盤如今看起來以下:
inode一般不是那麼大,假設每一個inode有256字節,一個4KB塊能夠容納16個inode,而咱們上面的文件系統則包含80個inode。在咱們簡單的文件系統中,這個數字表示文件系統中能夠擁有的最大文件數量。
咱們還須要某種方法來記錄inode或數據塊是空閒仍是已分配。固然,可能有許多分配記錄方法。咱們選擇一種簡單而流行的結構,稱爲位圖(bitmap),一種用於數據區域(數據位圖,data bitmap),另外一種用於inode表(inode位圖,inode bitmap)。位圖是一種簡單的結構:每一個位用於指示相應的對象/塊是空閒(0)仍是正在使用(1)。所以新的磁盤佈局以下,包含inode位圖(i)和數據位圖(d):
在極簡文件系統的磁盤結構設計中,還有一塊。咱們將它保留給超級塊(superblock),在下圖中用S表示。超級塊包含關於該特定文件系統的信息,包括例如文件系統中有多少個inode和數據塊、inode表的開始位置等等。它可能還包括一些幻數,來標識文件系統類型。
在掛載文件系統時,操做系統將首先讀取超級塊,初始化各類參數,而後將該卷添加到文件系統樹中。當卷中的文件被訪問時,系統就會知道在哪裏查找所需的磁盤上的結構。
文件系統最重要的磁盤結構之一是inode,幾乎全部的文件系統都有相似的結構。每一個inode都由一個數字(稱爲inumber)隱式引用,咱們以前稱之爲文件的低級名稱(low-levelname)。在VSFS中,給定一個inumber,你應該可以直接計算磁盤上相應節點的位置。假設inode區域從12KB開始(即超級塊從0KB開始,inode位圖在4KB地址,數據位圖在8KB,所以inode表緊隨其後)。所以,在VSFS中,咱們爲文件系統分區的開頭提供瞭如下佈局:
要讀取inode號32,文件系統首先會計算inode區域的偏移量(32×inode的大小,即8192),將它加上磁盤inode表的起始地址(inodeStartAddr = 12KB),從而獲得但願的inode塊的正確字節地址:20KB。回想一下,磁盤不是按字節可尋址的,而是由大量可尋址扇區組成,一般是512字節。所以,爲了獲取包含索引節點32的索引節點塊,文件系統將向節點(即40)發出一個讀取請求,取得指望的inode塊。
在每一個inode中,其實是全部關於文件的信息:文件類型(例如,常規文件、目錄等)、大小、分配給它的塊數、保護信息(如誰擁有該文件以及誰能夠訪問它)、一些時間信息(包括文件建立、修改或上次訪問的時間文件下),以及有關其數據塊駐留在磁盤上的位置的信息(如某種類型的指針)。咱們將全部關於文件的信息稱爲元數據(metadata)。
設計inode時,最重要的決定之一是它如何引用數據塊的位置。一種簡單的方法是在inode中有一個或多個直接指針(磁盤地址)。每一個指針指向屬於該文件的一個磁盤塊。這種方法有侷限:例如,若是你想要一個很是大的文件,那就沒法實現了。
爲了支持更大的文件,文件系統設計者必須在inode中引入不一樣的結構。一個常見的思路是有一個稱爲間接指針(indirect pointer)的特殊指針。它不是指向包含用戶數據的塊,而是指向包含更多指針的塊,每一個指針指向用戶數據。所以,inode能夠有一些固定數量(例如12個)的直接指針和一個間接指針。若是文件變得足夠大,則會分配一個間接塊(來自磁盤的數據塊區域),並將inode的間接指針設置爲指向它。假設一個塊是4KB,磁盤地址是4字節,那就增長了1024個指針。文件能夠增加到(12 + 1024)×4KB,即4144KB。
另外一種方法是使用範圍(extent)而不是指針。範圍就是一個磁盤指針加一個長度(以塊爲單位)。所以,不須要指向文件的每一個塊的指針,只須要指針和長度來指定文件的磁盤位置。不過只有一個範圍是有侷限的,由於分配文件時可能沒法找到連續的磁盤可用空間塊。所以,基於範圍的文件系統一般容許多個範圍,從而在文件分配期間給予文件系統更多的自由。
在間接指針這種方法中,你可能但願支持更大的文件。爲此,只需添加另外一個指向inode的指針:雙重間接指針(double indirect pointer)。該指針指的是一個包含間接塊指針的塊,每一個間接塊都包含指向數據塊的指針。所以,雙重間接塊提供了可能性,容許使用額外的1024×1024個4KB塊來增加文件,換言之,支持超過4GB大小的文件。
這種不平衡樹被稱爲指向文件塊的多級索引(multi-level index)方法。許多文件系統使用多級索引,包括經常使用的文件系統,如Linux ext2和ext3,以及原始的UNIX文件系統。其餘文件系統,包括Linux ext4,使用範圍而不是簡單的指針。
爲何使用這樣的不平衡樹?其中一個緣由是,大多數文件很小。這種不平衡的設計反映了這樣的現實。若是大多數文件確實很小,那麼爲這種狀況優化是有意義的。
在VSFS中(像許多文件系統同樣),目錄的組織很簡單。一個目錄基本上只包含一個二元組(條目名稱,inode號)的列表。對於給定目錄中的每一個文件或目錄,目錄的數據塊中都有一個字符串和一個數字。對於每一個字符串,可能還有一個長度(假定採用可變大小的名稱)。
一般,文件系統將目錄視爲特殊類型的文件。所以,目錄有一個inode,位於inode表中的某處(inode表中的inode標記爲「目錄」的類型字段,而不是「常規文件」)。
咱們假設文件系統已經掛載,所以超級塊已經在內存中。其餘全部內容(如inode、目錄)仍在磁盤上。
當你發出一個open("/foo/bar", O_RDONLY)調用時,文件系統首先須要找到文件bar的inode,從而獲取關於該文件的一些基本信息(權限信息、文件大小等等)。爲此,文件系統必須可以找到inode,但它如今只有完整的路徑名。文件系統必須遍歷路徑名,從而找到所需的inode。
全部遍歷都從文件系統的根開始,即根目錄(root directory),它就記爲/。所以,文件系統的第一次磁盤讀取是根目錄的inode。可是這個inode在哪裏?要找到inode,咱們必須知道它的i-number。一般,咱們在其父目錄中找到文件或目錄的i-number。根沒有父目錄(根據定義)。所以,根的inode號必須是「衆所周知的」。在掛載文件系統時,文件系統必須知道它是什麼。在大多數UNIX文件系統中,根的inode號爲2。所以,要開始該過程,文件系統會讀入inode號2的塊(第一個inode塊)。
一旦inode被讀入,文件系統能夠在其中查找指向數據塊的指針,數據塊包含根目錄的內容。所以,文件系統將使用這些磁盤上的指針來讀取目錄,尋找foo的條目。經過讀入一個或多個目錄數據塊,它將找到foo的條目。一旦找到,文件系統也會找到下一個須要的foo的inode號。
下一步是遞歸遍歷路徑名,直到找到所需的inode。在這個例子中,文件系統讀取包含foo的inode及其目錄數據的塊,最後找到bar的inode號。open()的最後一步是將bar的inode讀入內存。而後文件系統進行最後的權限檢查,在每一個進程的打開文件表中,爲此進程分配一個文件描述符,並將它返回給用戶。
打開後,程序能夠發出read()系統調用,從文件中讀取。第一次讀取(除非lseek()已被調用,則在偏移量0處)將在文件的第一個塊中讀取,查閱inode以查找這個塊的位置。它也會用新的最後訪問時間更新inode。讀取將進一步更新此文件描述符在內存中的打開文件表,更新文件偏移量,以便下一次讀取會讀取第二個文件塊,等等。
整個流程總結起來就是:先打開文件,而後遞歸地屢次讀取,以便最終找到文件的inode。以後,讀取每一個塊須要文件系統首先查詢inode,而後讀取該塊,再更新inode的最後訪問時間字段。
寫入文件是一個相似的過程。首先,文件必須打開。其次,應用程序能夠發出write()調用以用新內容更新文件。最後,關閉該文件。
與讀取不一樣,寫入文件也可能會分配(allocate)一個塊(除非塊被覆寫)。當寫入一個新文件時,每次寫入操做不只須要將數據寫入磁盤,還必須首先決定將哪一個塊分配給文件,從而相應地更新磁盤的其餘結構(例如數據位圖和inode)。所以,每次寫入文件在邏輯上會致使5個I/O:一個讀取數據位圖(而後更新以標記新分配的塊被使用),一個寫入位圖(將它的新狀態存入磁盤),再是兩次讀取,而後寫入inode(用新塊的位置更新),最後一次寫入真正的數據塊自己。
考慮簡單和常見的操做(例如文件建立),寫入的工做量更大。要建立一個文件,文件系統不只要分配一個inode,還要在包含新文件的目錄中分配空間。這樣作的I/O工做總量很是大:一個讀取inode位圖(查找空閒inode),一個寫入inode位圖(將其標記爲已分配),一個寫入新的inode自己(初始化它),一個寫入目錄的數據(將文件的高級名稱連接到它的inode號),以及一個讀寫目錄inode以便更新它。若是目錄須要增加以容納新條目,則還須要額外的I/O(即數據位圖和新目錄塊)。