問題:檔案系統太多種,呼叫卻只能有一組
本階前三篇導覽,從頭蓋起了一個檔案系統:一個描述整個磁碟區的超級區塊、每個檔案一個、透過直接與間接區塊指向資料區塊的 inode,以及一張追蹤空閒空間的點陣圖。但你看任何一台真實的電腦,看到的都不是「一個」檔案系統——是一大群。你筆電的主硬碟可能是 ext4 或 APFS,你剛插上的隨身碟是 FAT32,光碟是 ISO 9660,而某個資料夾搞不好偷偷是個網路檔案系統,正從房間另一頭的伺服器拉位元組過來。它們排布 inode、目錄與空閒空間的方式天差地遠。然而你打開它們,用的卻都是同一個 read(fd, buf, n) 和 write(fd, buf, n)。
一組系統呼叫,怎麼可能套用在這麼多種互不相容的設計上?答案,正是作業系統到處都在用的同一招:在中間放一層抽象。程式不會直接呼叫 ext4 的程式碼;它呼叫一個通用的檔案介面,而底下的一層會判斷牽涉到的是哪個真實檔案系統,再把請求轉交給那個檔案系統自己的程式碼。這就是虛擬檔案系統,也就是 VFS,是這篇導覽的核心想法。它是一個沙漏的細腰:上面是許多程式,下面是許多檔案系統,中間擠過一道窄窄的、共同的介面。
VFS 怎麼運作:共同物件、各檔案系統各自的程式碼
VFS 辦到這件事的方法,是定義一小組通用的、存在記憶體裡的物件,每個檔案系統都得學會「說」這些物件。其中最重要的是 vnode(在 Linux 裡叫「記憶體中的 inode」):它是一個已開啟檔案的、統一的、住在 RAM 裡的替身,不管那檔案真正住在哪種檔案系統上。無論檔案在 ext4、FAT32,還是在網路另一端,核心的其他部分看到的都是同一種 vnode,欄位相同、操作相同。其餘的通用物件,則描述一個已掛載的磁碟區、一個目錄項目,以及一個已開啟檔案的狀態——這是一套上層核心可以使用、卻不必懂底下方言的共同詞彙。
聰明的地方在這裡。每個通用物件都帶著一張函式指標表——像是讀、寫、查找、建立這些操作的一張小菜單——而每個檔案系統都用自己的程式碼把那張菜單填好。當你對一個檔案呼叫 read(),VFS 裡並沒有「讀 ext4 檔案」的邏輯;它只是順著那個 vnode 的「讀」指標走,落進 ext4 自己的讀取程式碼裡。這就是用 C 結構做出來、樸實的物件導向分派:介面相同,實作不同,由「裝了哪些指標」在執行期挑出來。要把一個全新的檔案系統加進核心,於是就變成「寫一組新函式、登記上去」這麼一件事——上面的程式完全不必改。
- 程式呼叫 read(fd, buf, n)。陷入核心後,fd 被轉成開啟檔案表裡的一個項目,那項目指向這個檔案的 vnode。
- VFS 讀出那個 vnode 的通用「讀取」操作——一個函式指標——而不是自己內含任何特定於某檔案系統的邏輯。
- 那個指標落進真實檔案系統的程式碼裡(比方說 ext4),它懂得如何用 inode 與它的間接區塊,把檔案的位元組偏移量對映成一個磁碟區塊。
- 在去碰磁碟之前,那段程式碼會先向緩衝/分頁快取要這個區塊——這正是下一節整節的工作——只有在沒命中時,它才真的發出一次慢速的磁碟讀取。
緩衝快取:別碰磁碟第二次
就算 VFS 把請求繞送得天衣無縫,儲存那一階留下的一個殘酷事實仍在:磁碟慢得讓人心痛。從 RAM 讀一次要花幾十奈秒;從一顆轉動式磁碟讀一次,加上尋軌時間與旋轉,就要花上幾毫秒——這道落差大約是十萬比一。如果作業系統每一次 read() 和 write() 都去碰磁碟,整台機器就會慢如爬行。解法是把最近用過的磁碟區塊留在主記憶體的一塊區域裡,並且先去那裡查。那塊區域,就是緩衝快取,也是你的檔案系統感覺起來很快的、單一最大的原因。
這個機制就只是「快取」,跟你在分頁那一階見過的 TLB 是同一個想法——把你最近用過的東西,留一份快速的副本。當檔案系統需要一個區塊,它先查快取。命中(hit)幾乎瞬間就從 RAM 把區塊還回來。未命中(miss)則意味著作業系統從磁碟讀它、在快取裡放一份副本、再交出去;下一次再要這個區塊,就是命中了。因為真實的工作負載會不斷重複用到同樣的區塊——同一個目錄、同一個超級區塊、同一個熱門檔案——所以就算只是一個不算大的快取,也能把壓倒性多數的存取,變成 RAM 速度的命中。
寫入也享有同樣的待遇,但帶著一個很要緊的轉折。一次寫入通常會進到快取、立刻就回到你的程式,並把那個被快取的區塊標記為髒的(dirty,改過、但還沒存)。作業系統稍後才在背景把髒區塊推到磁碟——這個策略叫回寫(write-back)。這對速度棒極了:你的程式不必等磁碟,而且許多對同一區塊的小寫入會塌縮成一次。但它也打開了一道危險的縫,你現在就該感覺到這份危險:在 write() 回報成功的那一刻,與那個區塊真正抵達磁碟的那一刻之間,資料只活在易失的 RAM 裡。在這個視窗裡發生當機或斷電,它就沒了。安全地關上這道縫,正是下一篇談當機一致性的導覽,從頭到尾的主題。
預讀,以及一個快取統管全局
快取最幫得上忙,是在你想要的資料已經在裡頭的時候。所以作業系統做了一件近乎調皮的事:它去猜你的未來。如果它注意到你正一塊接一塊、按順序地讀一個檔案——這是最常見的存取樣式——它就賭你接下來那些區塊也會想要,並在你開口前先把它們抓進來。這就是預讀(read-ahead,預先擷取)。當這一賭賭中了,你的第二、第三、第四次讀取,就都是命中——命中在那些作業系統趁你忙著處理第一塊時、悄悄拉進來的區塊上。賭不中時——比方說你在一個檔案裡到處隨機 seek——預讀就浪費了一點頻寬,所以作業系統會盯著你的樣式,對循序的串流把預讀調高、對隨機的調低。
還有一個值得知道的「統一」,因為它把這一階接回了記憶體那一階。現代系統並不維護兩個分開的快取——一個給檔案區塊、一個給虛擬記憶體的分頁。它們把兩者合併成單一的分頁快取(page cache),用同一池實體框格,同時裝檔案資料與記憶體分頁。這正是記憶體映射檔案之所以可能的原因:當你把一個檔案映射進你的位址空間、像陣列那樣讀它時,一次分頁錯誤只不過是把檔案的那一頁拉進這個共用的快取,而你平常的記憶體載入就直接讀到它了——根本沒有 read() 這個呼叫。檔案 I/O 與記憶體分頁,結果是同一套機器戴著兩頂帽子。
把各層拼起來
退一步,你就能看見檔案系統各層由上到下構成的整個堆疊。你的程式坐在最上面,發出 read() 與 write()。系統呼叫層接住它們,交給 VFS。VFS 透過 vnode 的函式指標,挑出對的檔案系統。那個檔案系統的邏輯,用 inode 與間接區塊,把一個檔案偏移量翻譯成一個磁碟區塊號碼。在任何磁碟存取之前,它先問緩衝/分頁快取。只有在未命中時,請求才落到區塊層與裝置驅動程式,後者可能會先用磁碟排程把待處理的請求重新排序,硬體最後才真正搬動資料。每一層都只認得它的鄰居,而正是這種嚴格的分層,讓這套龐大的系統蓋得起來。
your program read(fd, buf, n)
|
system-call layer trap into the kernel; fd -> open-file table -> vnode
|
VFS follow vnode's read pointer (generic -> specific)
|
a real file system inode + indirect blocks: file offset -> block #
|
buffer / page cache HIT? return from RAM (fast, the common case)
| (miss) MISS? read from disk, cache a copy
block layer + driver disk scheduling reorders requests, hardware moves data
|
the disk ~ a hundred thousand times slower than RAM正是這種分層設計,讓一個隨身碟和一個網路共享都顯示成普通的資料夾,讓你的編輯器能讀一個檔案而不必知道它在磁碟上的格式,也讓單單一個快取能一口氣把它上面的一切都加速。它同時也是最後一篇導覽所立足的平台:一旦你接受了「寫入會在快取裡逗留、之後才抵達磁碟」,你就被迫要問:如果電在寫到一半時死了,會怎樣?——而那些答案(日誌、寫入時複製,以及日誌結構式的設計),正是一個檔案系統如何撐過當機、又不把你的資料變成垃圾的方法。這也正是本階的終點。