JOVANA
Explore Library Glossary Getting Started Three Levels Fields How it works Mission
Join the mission
All guides

VFS 層:一套 API 通吃每一種檔案系統

你早就在呼叫 open()、read()、write(),卻從來不必說明你指的是哪一種檔案系統。本篇打開核心,展示讓這件事得以成立的把戲:虛擬檔案系統——一層薄薄的多型,讓 ext4、一支 USB 隨身碟、一個網路共享、甚至 /proc,全都回應同樣那少少幾個呼叫。

你早已視為理所當然的奇蹟

在檔案 I/O 那一級,你學會了透過檔案描述符開啟、讀取、寫入、關閉一個檔案,也見過那句口號:在 Unix 裡一切皆檔案。仔細看看這有多奇怪。同一個 `read(fd, buf, n)` 呼叫都能運作——不論 `fd` 指的是你 SSD 上的一個文字檔、一支以 FAT32 格式化的 USB 隨身碟上的一段影片、一台位在三個國家外、透過 NFS 連過去的伺服器上的檔案,還是像 /proc/cpuinfo 這種沒有任何磁碟存過的假檔案。你從來不必告訴 `read()` 它是哪一個。某種機制讓單一個呼叫伸進天差地遠的機件、再把位元組取回來。

那套機件就是虛擬檔案系統(Virtual Filesystem),通常簡稱 VFS。它是核心裡緊貼在你的系統呼叫後方、又站在每一種具體檔案系統前方的那一部分。當你的 `read()` 跨進核心,它並不會落進 ext4 或 FAT32 的程式碼裡;它落進 VFS,再由 VFS 把它派遣給真正擁有那個檔案的那種檔案系統。虛擬檔案系統正是你早已熟悉的那套檔案 I/O API 之所以如此精簡、如此一致的原因:呼叫就只有一套,而 VFS 是那台把每一個呼叫導向正確去處的總機。

留意 VFS 所解決的問題的形狀,因為你在使用者空間早就自己解過它。你想要讓一個呼叫方,透過一個固定的介面去驅動許多不同的實作——這正是 C 裡的函式指標所解決的問題,是 C++ 的虛擬方法表所解決的問題,是 Rust 的 trait 物件所解決的問題。VFS 不過是把這個想法套用進核心裡:給檔案系統的多型。本篇其餘部分講的,其實就是核心如何用樸素的 C 結構去打造出這層多型,因為核心沒有語言層級的物件可以倚靠。

沒有物件的多型:函式指標構成的結構

整個把戲一句話講完:核心裡的一個檔案帶著一個指向「操作表」的指標,而 `read()` 就只是透過那張表去呼叫。那張表是一個結構,它的成員是函式指標——在 Linux 裡它就直接叫做 file_operations 結構——每個操作配一個欄位:一個 `read` 欄、一個 `write` 欄、一個 `open` 欄、一個給 close 用的 `release` 欄,以此類推。每一種檔案系統都用自己的函式去填那張表。ext4 把 ext4_file_read 放進 read 欄;一個字元裝置驅動程式則把它自己的常式放進去。VFS 從不知道、也不在乎是哪一個;它只認得欄位。

user space:        read(fd, buf, n)
                          |   (one system call, crosses into the kernel)
                          v
VFS dispatch:      f = fd_table[fd];          /* the open file object   */
                   f->f_op->read(f, buf, n);  /* call THROUGH the table  */
                          |
          +---------------+----------------+----------------+
          v               v                v                v
   ext4_file_read   fat_file_read   nfs_file_read    proc_cpuinfo_read
   (reads a disk    (reads FAT      (sends an RPC    (formats a string
    block)           cluster)        over the net)    on the fly)

struct file_operations {                 /* the "vtable" for a file */
    ssize_t (*read) (struct file *, char *, size_t, loff_t *);
    ssize_t (*write)(struct file *, const char *, size_t, loff_t *);
    int     (*open) (struct inode *, struct file *);
    int     (*release)(struct inode *, struct file *);  /* close */
    /* ... more slots ... */
};
一個 read() 呼叫,經由一個函式指標結構派遣出去。每一種檔案系統為同樣的欄位提供自己的函式;VFS 永遠只呼叫 f->f_op->read,從不指名 ext4 或 FAT。

這正是一張手工打造的 C++ vtable,把這層關聯看清楚很值得。在 C++ 裡,編譯器把函式指標表與那個間接呼叫藏在語言裡頭;而以 C 寫成的核心,把那張表攤開成一個普通的結構,並親自用 `f->f_op->read(...)` 做那個間接呼叫。「物件導向的核心」沒有任何魔法:從頭到尾都是函式指標。日後當你讀核心原始碼、看到 `f_op`、`i_op` 或 `s_op` 時,你看到的就是這些操作表之一,掛在一個檔案、一個 inode、或一個 superblock 上。

VFS 所思考的四種物件

VFS 並不把一個檔案系統塑造成一坨大東西;它把它拆解成四種記憶體內的物件,每一種都帶著自己的操作表。superblock(超級區塊)代表一整個已掛載的檔案系統——每次掛載一個,存放像區塊大小與樹根這類全域事實。inode 代表一個檔案的中繼資料與身分:它的大小、擁有者、權限、時間戳,以及最關鍵的——指向它資料所在之處的指標。dentry(目錄項)代表樹中的一個名字,把一個名字連到一個 inode。而 file(檔案)物件代表一個開啟中的實例——一個特定的描述符,帶著它自己的檔案偏移量與存取模式。

把 inode 與 dentry 分開,正是讓 Unix 的命名運作成那個樣子的設計抉擇,值得停下來想想。名字不是檔案。一個 dentry 持有一個名字、指向一個 inode;好幾個 dentry 可以指向同一個 inode,而那恰恰就是硬連結的意思——兩個名字、一個底層檔案、一個 inode。這也是為什麼刪除一個名字叫做 `unlink()`:你移除的是一個 dentry,而 inode 本身只有在它的連結計數降到零時才真正消失。file 物件與 inode 分開,正是讓兩個行程能開啟同一個檔案、各自保有獨立偏移量的原因——兩個 file 物件、一個共享的 inode。

這些物件每一個都帶著自己的操作表,而它們把工作劃分得很乾淨。superblock 操作懂得如何為整個檔案系統配置與寫回 inode;inode 操作處理中繼資料上的名稱空間變動——create、lookup、mkdir、rename、unlink;而 file 操作,也就是上一節那張表,處理內容——read、write、seek、mmap。一個好用的判準:如果它改變目錄結構或一個檔案的中繼資料,它走 inode 操作;如果它把位元組搬進搬出一個已開啟的檔案,它走 file 操作。

走一條路徑:/home/ana/notes.txt 如何變成一個檔案

現在我們可以回答那個最基本的問題:當你呼叫 open("/home/ana/notes.txt", ...) 時,一串字元如何變成一個核心能從中讀取的 inode?答案是路徑解析,一趟沿著樹一步步往下走的歷程。核心從不把整條路徑當成一次查找;它依斜線把路徑切開、一次解析一個元件,因為每一個元件都可能跨進一個前一個元件才剛剛指認出來的目錄。

  1. 從一個根開始。像 /home/... 這種絕對路徑從檔案系統的根 inode 開始;一條相對路徑則從行程的目前工作目錄開始。這個起始 inode 成為這趟走訪的「當前目錄」。
  2. 取下一個元件,這裡是「home」。呼叫當前目錄 inode 的 lookup 操作,問它:「你裡面有沒有一個名字叫『home』?如果有,是哪一個 inode?」這是一個 inode 操作,也是具體檔案系統真正去讀那個目錄之處。
  3. lookup 回傳「home」的 inode。把它變成新的當前目錄,再對「ana」重複一次、然後對「notes.txt」。每一步往下降一層,重用前一步的結果。
  4. 當最後一個元件解析完,你就拿到目標檔案的 inode。VFS 建立一個全新的 file 物件、把它的操作表指向那個 inode 的 file 操作、把它裝進行程的描述符表,再把那個小整數 fd 交還給你。

為每一條路徑的每一個元件都去做一次真正的磁碟查找,會慢得讓人受不了——繁忙的系統每秒解析數百萬條路徑,而像 /usr/lib 這類同樣的目錄會被不停地走訪。所以 VFS 在記憶體裡保有一個 dentry 快取(即「dcache」):一張雜湊表,把「一個父目錄加上一個名字」對應到它上次解析出的 dentry。命中快取時,這趟走訪就完全略過磁碟查找。這個快取是真正檔案系統前方一層純粹的效能層,也是整個核心裡最炙手可熱的資料結構之一。

抽象化的回報之處:掛載與假檔案

VFS 抽象化是真的最清楚的證明,就是毫不相干的東西能以平等的身分接進它。掛載(mount)把一個檔案系統的樹嫁接到另一個的某個目錄上:把一支 USB 隨身碟掛在 /mnt/usb,從此以後,當路徑解析走到 /mnt/usb 這個 dentry,它發現這是一個掛載點,便透明地跳到隨身碟檔案系統的根。這趟走訪繼續走進 FAT32,而你的 `open()` 從頭到尾不知道腳下的地基換過了。一個名稱空間,許多檔案系統縫合在一起,而那道縫看不見——那道縫,正就是擁有一套統一介面的全部回報。

更令人驚奇的,是那些根本不是檔案的檔案。像 /proc 或 /sys 這種偽檔案系統,透過檔案介面把核心資料暴露出來,卻沒有任何後援儲存。讀 /proc/cpuinfo,它的 read 操作只是當場格式化出一個字串、取自即時的核心狀態——沒有任何磁碟上的任何區塊存著那段文字;它在你一開口的那一瞬間才生成。這就是「一切皆檔案」這句口號被推到它邏輯上的盡頭:因為讀一個檔案不過意味著「呼叫這個物件的 read 欄」,任何能填好一個 read 欄的東西,都能假裝成一個檔案。讀 /proc/cpuinfo 的 cat 工具分辨不出來,也不需要分辨。

你帶進這一級其餘部分的東西

本篇刻意停留在磁碟之上。我們把 inode、dentry 與 superblock 當成核心記憶體內的抽象來談,對「指向資料所在之處的指標」只是揮手帶過,沒說那些指標在真實儲存上是怎麼排列的。那個磁碟上的現實,正就是接下來幾篇要去的地方。第 2 篇撬開 inode 與目錄,展示 extent 與磁碟上的版面如何把檔案塞進區塊。第 3 篇問:當電源在寫入到一半時斷掉,會發生什麼,以及日誌如何讓檔案系統跨越一次當機仍保持一致。第 4 篇造訪像 ZFS 與 Btrfs 這種寫入時複製的設計,它們以截然不同的方式對付同一個問題。

牢牢記住那個核心畫面,因為後面每一篇都懸掛在它之上。頂端有一套單一、統一的介面——你早已熟悉的那少少幾個呼叫——底端有許多具體的實作,每一個都是一組填好的操作表。VFS 是它們之間的派遣層,而從一支 USB 隨身碟到 /proc 到一個網路共享,全都只是另一個滿足契約的實作。當第 5 篇終於釘清 fsync() 究竟對持久性許下了什麼承諾時,你會看到:即使那個承諾,歸根究柢,也不過是 file 表裡的又一個操作——而整個堆疊的誠實與否,端看每一種檔案系統是否如實地實作它。