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

檔案描述符:藏在檔案背後的那個整數

當你的程式開啟一個檔案時,核心並沒有把那個檔案交給你——它交給你的是一個小小的整數,例如 3。本篇講的就是這個整數:它真正指向什麼、為什麼一個 Unix 系統裡幾乎所有東西都是透過它來抵達的,以及為什麼把這個觀念刻進骨子裡,會讓接下來檔案、管線與 I/O 的一切忽然都說得通。

核心交給你一個號碼,不是一個檔案

在我們開始之前,你其實已經知道本階段最重要的那個事實了:你的程式沒辦法自己碰硬碟。要抵達一個檔案,就得跨過你在上一階畫好的那道系統呼叫邊界去請求核心。所以,想像你請它開啟一個檔案的那一刻。你會期望拿回什麼——一塊檔案的內容?一個指向硬碟的指標?兩者都不是。核心把那個檔案、它所有真正的機件,完完整整地留在牆的它那一側,然後交回給你某個小到近乎失禮的東西:一個非負的整數。那個整數就是檔案描述符(file descriptor),而在一支剛啟動的程式裡,你開的第一個通常是 `3`。

為什麼是個號碼,而不是一個指向某個豐富的、描述這檔案的結構的指標?因為這檔案真正的狀態——它在硬碟上住哪、你已經讀進去多遠、你能不能寫它——是必須留在核心裡的特權資訊,一支有臭蟲或懷有惡意的程式不能去弄壞它。一個指標會讓你伸進去亂動。一個檔案描述符是刻意做成不透明的:它是一個核心同意應答的名字,只有在你把它跨過邊界交還時才有意義。你說「對描述符 3 做這件事」,而握著那個真正物件的核心,便替你做。你永遠看不到那個物件本身,而這正是重點所在。

這個號碼實際上是在索引什麼

那麼,如果 3 不是那個檔案,它是什麼?它是一個索引。每一個執行中的行程,都擁有一個由核心代它維護的小小私有陣列:檔案描述符表。槽 0、槽 1、槽 2、槽 3,依此類推。當你開啟一個檔案時,核心會在它自己那一側,替那個開啟的檔案建立起真正的帳目物件,然後把一個指向它的指標寫進你那張表裡最低的那個空槽——而那個槽的號碼,就是它回傳給你的整數。描述符 `3` 字面上的意思就是「這個行程的描述符表裡的第 3 號項目」。這就是為什麼這些號碼從小開始、往上爬:核心總是填入最低的那個空槽。

  YOUR PROCESS                          KERNEL SIDE
  ---------------------------           ------------------------------
  fd table (one per process)            open file objects
   index ->  points to
     0   ->  ------------------------>  [ terminal, for input  ]
     1   ->  ------------------------>  [ terminal, for output ]
     2   ->  ------------------------>  [ terminal, for errors ]
     3   ->  ------------------------>  [ notes.txt, offset=0   ]
     4   ->  (empty - next open() lands here)

  read(3, buf, 100)  =  "use slot 3"  ->  kernel reads notes.txt
描述符是左邊那個索引;真正開啟中的檔案狀態住在核心那一側。read(3, ...) 的意思是「對槽 3 所指向的東西動作」。整數從不離開你這側,檔案也從不離開核心那側。

有一個值得記住的微妙之處:這張表是每個行程各自一張的。你的描述符 3 和另一支程式的描述符 3,毫不相干——同一個號碼、不同的表、不同的檔案。這也是為什麼一個描述符不能就這樣當成一個整數寄給另一支程式:一個光禿禿的 3,在一張不屬於你的表裡什麼也不是。(確實有辦法把一個活的描述符遞交給另一個行程,叫做傳遞檔案描述符,但那是一個刻意的、由核心協助的動作,恰恰因為光是那個原始號碼在別處毫無用處。你會在行程間通訊那一階遇到它。)

三道已經開著的門:0、1、2

現在那些小號碼自己解釋了自己。如果開的第一個檔案是 3,那 0、1、2 跑哪去了?早在你的程式碼跑出任何一行之前,它們就已經被佔走了。依照由來已久的慣例,核心與 shell 會安排每一支程式啟動時,表裡就已經有三個描述符:0 是標準輸入、1 是標準輸出、2 是標準錯誤。這些就是你一直默默在用的標準串流——printf() 最終會把位元組寫到描述符 1,而 perror() 發出的一則錯誤訊息,會送到描述符 2。它們不是什麼魔法全域變數;它們只不過是你描述符表裡前三個項目,在 main() 開始之前就被佔好了。

這正是讓 shell 能在程式毫不知情下,悄悄替它重新接線的東西。當你執行 `$ ./a.out > out.txt` 時,你的程式依然只是和先前一模一樣地寫到描述符 1——但 shell 在啟動你之前,已經把槽 1 指向了檔案 out.txt,而不是終端機。程式請求的是「描述符 1」,拿到的則是槽 1 剛好指向的任何東西。那層間接性,正是藏在檔案描述符這個觀念裡的禮物:一支程式透過樸素的號碼讀它的輸入、寫它的輸出,而由別人來決定那些號碼接到哪裡去。我們會在本階最後一篇——重導向——把那套機制拆開來看,但你現在已經能看見它的形狀了。

一切皆檔案(嗯,幾乎啦)

這裡正是這個觀念變得真正優美的地方。一個描述符不一定要指名一個硬碟上的檔案。上面槽 0、1、2 指向的是你的終端機,那根本不是一個檔案——可你卻用同一套描述符機制去讀它、寫它。這就是著名的 Unix 設計原則:一切皆檔案(everything is a file):一個普通的硬碟檔案、一個終端機、一條網路連線、一條通往另一支程式的管線,甚至某些核心狀態——五花八門的東西,全都以描述符的形式呈現給你的程式,讓你用 read() 與 write() 去操作。一個一致的介面,背後是許多種不同的東西。

這帶來的回報極其巨大。因為一條管線、一個通訊端、一個檔案都穿著同一套描述符的衣服,一支被寫成從描述符 0 讀取的程式,並不在乎那些位元組是來自鍵盤、來自一個 shell 重導向進來的檔案,還是來自另一支程式的輸出、流經一條管線而來。你的邏輯只針對「一條由描述符命名的位元組串流」寫一次,它就能對所有符合那個形狀的東西運作。一切皆檔案這個觀念,正是為什麼如此之少、如此之小的幾個系統呼叫——open、read、write、close——能夠驅動如此龐大範圍的裝置與連線。

描述符記得你讀到哪了

還有一塊隨著描述符一起的狀態,你現在就必須認識,因為它解釋了為什麼讀同一個檔案兩次,並不會給你同樣的位元組。描述符所指向的那個開啟中的檔案物件,記得一個檔案偏移量(file offset):一個單一的號碼,也就是下一個要被讀或寫的位元組的位置,它在你開啟檔案時從 0 開始。你並不傳一個位置給 read()——你只是說 read(3, buf, 100)。核心會從目前的偏移量開始讀 100 個位元組,然後把偏移量往前推進,推進它實際交付了多少。描述符攜帶著一個游標,而這個游標會隨著你的讀寫移動。

這就是為什麼對同一個描述符接連做兩次 read() 呼叫,會回傳檔案不同的部分——第一次讀取把偏移量留在了半途,第二次就從那裡接著讀。這正是你想要的、用來從頭到尾串流讀過一個檔案的行為,也是為什麼你不必自己去追蹤「我讀到哪了?」。檔案偏移量是一個微小卻承重的觀念:它正是為什麼一個描述符是一個開啟中檔案裡的位置,而不僅僅是那個檔案的一個名字。記住這個念頭——緊接著的下一篇,講 open、read、write 與 close 的,每一個範例都倚靠著它。

退一步,把整幅形狀握在手裡。檔案描述符是一個小小的整數;它索引一張每個行程各自一張的表;每個項目指向一個核心那側的、開啟中的檔案物件,那物件攜帶著真正的狀態,包括偏移量;最小的三個在你開始之前就被接到你的標準串流上了;而幾乎每一種位元組的來源或去處——檔案、終端機、管線、通訊端——都是透過這一個一致的把手來抵達的。把這個觀念刻進骨子裡,本階其餘的部分就都是細節了。接下來我們讓它上工:open、read、write、close 這幾個系統呼叫,它們建立一個描述符、讓位元組流經它,再把它放開。