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

檔案系統、inode 與路徑

你已經能用 open()、read()、write()、close() 依名字操作一個檔案了。但「名字」究竟是什麼,它又指向什麼?這篇導引把檔案系統打開來看:作為檔案真正身分的 inode、其實只是一張名字表的目錄、以及你的程式交給 open() 的那條路徑——還有為什麼硬連結、符號連結與 rename() 會是它們現在這個樣子。

名字不是檔案本身

在前三篇導引裡,你把檔案當成透過檔案描述符去碰到的東西:你用一個像 "/home/jo/notes.txt" 的名字呼叫 open()、拿回一個小整數,再透過它讀寫。名字進去、一個可用的檔案出來,而中間那套機制始終藏著。這篇導引把那套機制拉到光下,而第一個出人意料、值得牢牢記住的事實是:名字不是檔案本身。"/home/jo/notes.txt" 這串文字是一個標籤、一個找到檔案的方式;檔案本身——它的位元組、它的大小、它屬於誰、它上次被改是什麼時候——住在完全另一個地方,名字只不過指向它而已。

這種分離正是 Unix 檔案系統的整個設計,而它分成兩塊。檔案那個真正的、無名的身分,是一個叫做 inode(索引節點)的結構,它持有關於檔案的每一項事實,除了名字以外。名字則分開住在目錄裡,而目錄不過就是把名字對應到 inode 的表格罷了。所以一條路徑是一連串的查找,而不是一個東西本身:"/home/jo/notes.txt" 的意思是*從根目錄出發,在裡面找 "home"、在那裡面找 "jo"、再在那裡面找 "notes.txt"*——只有走到這趟旅程的盡頭,你才抵達那個就是實際檔案的 inode。一旦你把名字和檔案看成兩個由一筆目錄項目接起來的分開東西,Unix 檔案幾乎每一個古怪的角落都不再古怪了。

inode:檔案真正的身分

inode 是檔案系統為每個檔案保留的一筆小小的、固定大小的記錄。它儲存檔案的中繼資料——以位元組計的大小、型別(一般檔案、目錄、裝置等等)、擁有者與群組、權限位元、三個時間戳、一個我們等一下會用到的連結計數——以及最重要的,磁碟上存放實際位元組的那些資料區塊的位置。inode 刻意儲存的,是檔案的名字。名字是關於一個檔案、唯一住在它之外(在某個目錄裡)的那項事實。檔案系統上每個 inode 都有一個獨一無二的號碼,也就是 inode 號碼,而那個號碼——不是路徑——才是檔案真正的身分。你可以用 "ls -i" 或 "stat" 看到它。

這就是為什麼路徑可以改變而檔案不變。把 "notes.txt" 改名成 "draft.txt" 根本沒碰到 inode——它只是編輯了一筆目錄項目,在同一個 inode 號碼上把一個名字換成另一個。位元組從未移動;檔案的身分絲毫未動;你不過是把門上的標籤換掉了。同樣的道理解釋了前幾篇導引裡一件微妙的事:當你 open() 一個檔案時,你得到的描述符指的是那個已開啟的檔案(並透過它指向 inode),而不是名字。所以如果在你還握著描述符開著的時候,另一支程式把路徑改名、甚至刪掉了,你的讀寫仍會在同一個 inode 上照常運作,因為你的把柄打從一開始就不是名字。

$ stat -c '%i  %s bytes  links=%h  %n' notes.txt
  1310726  482 bytes  links=1  notes.txt

  path  -->  directory entry  -->  inode #1310726
  "notes.txt"     (name -> number)     size, owner, perms,
                                       timestamps, link count,
                                       block locations  ... NO name
目錄把名字 "notes.txt" 對應到 inode 號碼 1310726;inode 持有關於這個檔案的一切,唯獨不含那個名字。

目錄,以及一條路徑是怎麼被走過的

如果名字住在目錄裡,那目錄到底是什麼?在一切皆檔案的觀念下,目錄本身也只是一個有自己 inode 的檔案——但是一種特殊的檔案,核心把它的內容詮釋成一張項目表,每一項把一個名字配上一個 inode 號碼。目錄就是這樣而已:一串(名字、inode 號碼)的配對。"home"、"jo"、"notes.txt" 是三張不同的這種表裡的項目。因為目錄只存名字和號碼,實際的檔案可以很巨大而目錄仍然很小;又因為名字住在目錄裡、而不在檔案裡,單一一個 inode 可以從不只一筆目錄項目被碰到——那正是下一節要掛上去的鉤子。

現在這趟路徑漫步變成了機械式的。當你 open() 一條像 "/home/jo/notes.txt" 的絕對路徑時,核心從根目錄("/" 的 inode)出發,在裡面查 "home" 得到下一個 inode 號碼、讀那個目錄、查 "jo"、讀那個目錄、查 "notes.txt",落在最後那個 inode 上——就是你要的檔案。一條像 "jo/notes.txt" 的相對路徑運作得一模一樣,差別只在這趟漫步從你行程的目前工作目錄、而不是根目錄出發。每一條斜線就是一個查找步驟,而 ".." 不過是每個目錄都保有的、指回它父目錄 inode 的一個項目。這就是為什麼一條有許多段的路徑比短路徑花更多次查找,也是為什麼路徑深處的一個拼字錯誤,會恰好在核心找不到的那一段失敗。

硬連結、符號連結,以及為什麼刪除其實叫 unlink

既然一筆目錄項目只是一個指向某個 inode 號碼的名字,那就沒有什麼能阻止兩筆項目——甚至在不同目錄裡——指向同一個 inode 號碼。那就是硬連結:兩個名字,它們不是複本,而是貨真價實同一個檔案,共用一個 inode、一份位元組、一個身分。透過任一個名字編輯,改動都會從另一個名字看見,因為檔案只有一個。這正是為什麼 inode 帶著一個連結計數:它記著有多少筆目錄項目指向它。建立第二個硬連結,計數就從 1 變成 2;檔案不是存在了兩次,它只是被命名了兩次。

這重新框定了「刪除一個檔案」的意思,而真相比 delete(刪除)這個詞所暗示的更誠實。那個系統呼叫叫做 unlink(),而它名副其實:它從一個目錄裡移除一筆名字對 inode 的項目,並把那個 inode 的連結計數減一。檔案的位元組只有在計數歸時才會被釋放——也就是說,當最後一個名字消失而且再沒有任何行程還開著它的時候。所以 "rm notes.txt" 不見得抹掉任何資料;如果別處還有一個硬連結指名那個 inode,檔案就以另一個名字繼續活著。而一個你在某程式還開著它時就 unlink 掉的檔案會存活下來、無名而隱形,直到那個程式關掉它為止——這對暫存檔來說是一個真實又好用的把戲。

符號連結(symlink,又稱軟連結)是另一種動物,而這個對比正是重點所在。符號連結是它自己一個小小的檔案,內容是一串路徑字串——它不指向 inode 號碼,它指向一個名字,而核心每次跟隨這個符號連結時,都重新解析那個名字。所以符號連結可以跨越檔案系統、可以指向目錄,這兩件事硬連結都做不到;但它也可能變成一個懸空連結——當它的目標被改名或刪除時,它指著一個不再解析到任何東西的名字。硬連結永遠不會懸空,因為它本身就是 inode 的共同擁有者之一。硬連結:第二個真實的名字。符號連結:一個拿著路徑的路標,那條路徑也許還通往某處、也許不再。

這給了你什麼,以及誠實的界限

退一步看,名字與 inode 的分離,正是讓一大堆日常操作既便宜又不可分割的原因。rename() 可以在同一個檔案系統上把一個名字移過不同目錄,只靠編輯兩筆目錄項目——一個位元組都不複製——這就是為什麼把一個 1 GiB 的檔案改名是瞬間完成的。這也是為什麼更新一個設定檔的安全做法,是先寫出一個全新的檔案、再用 rename() 蓋過舊名字:在單一檔案系統上,rename() 是不可分割(atomic)的,所以讀取者要嘛看到整個舊檔、要嘛看到整個新檔,絕不會看到一個寫到一半的爛攤子。如果一個檔案就是它的名字,這些把戲一個都不可能成立;它們全都倚賴名字與位元組可以被分開。

現在來談誠實的但書,因為一個整齊的模型會引人過度推論。第一,這裡每一道界線都是以檔案系統為單位的:inode 號碼只在單一檔案系統內是獨一無二的,硬連結無法從一個檔案系統跨到另一個,而 rename() 那個又便宜又不可分割的保證,在來源與目的地住在不同掛載點的那一刻就蒸發了——在那裡,搬移會變成一次真實的「先複製再刪除」,而它可能中途失敗。第二,open() 的旗標與模式作用在目錄項目與 inode 上,而不是路徑文字上:O_CREAT 在沒有對應名字時造出一個新的名字與 inode,而你傳入的模式位元設定的是 inode 的權限,當檔案已經存在時則被完全忽略。這個心智模型很有威力,但它是順著檔案系統的形狀來的,它的保證在檔案系統的邊緣就停住了。