從磁碟上的一個名字,到一個你能使用的東西
在上一篇導覽中我們說過,檔案是一串有名字、能持久保存的位元組,再加上它的屬性——它的大小、擁有者、各種時間戳記。但這一切都只是被動地躺在磁碟上。程式光知道檔案的名字並無法讀取它,就像你光知道瓶子上的標籤並無法喝到裡頭的東西一樣。你得先開啟它。開啟,正是作業系統把一個靜態名字變成一個你的程式真正能使用的、活生生的工作連結的那一刻。
為什麼非得有個「開啟」步驟不可——為什麼不能每一次都直接說「從 /home/me/notes.txt 讀取 100 個位元組」就好?因為靠名字找到一個檔案是件慢工。核心必須走過目錄結構、檢查你是否獲准進入,並定位出檔案在磁碟上的紀錄。若每一次讀取都這麼幹,那簡直是發瘋。所以 open() 把這份查找成本只付一次,架設好一些帳冊,再遞還給你一個此後便宜好用的捷徑。之後每一次讀或寫,都搭著這個捷徑走,而不必再重做一遍那場搜尋。
檔案描述符:一個意思是「那個檔案,給你用」的數字
當 open() 成功時,核心不會把檔案本身交給你,也不會交給你它的一堆位元組。它交給你一個小小的非負整數,叫做檔案描述符——常常是 3、4、5,以此類推。把它想成寄物處的號碼牌。你把笨重的外套(也就是檔案,連同它在磁碟上一切雜亂的細節)交給寄物間,自己走開時手上只攥著一張薄薄的、編了號的存根。這存根又小又好帶,可是只要你一出示它,服務員就確切知道你指的是哪一件外套。你的描述符就是那張存根,而核心就是那位服務員。
為什麼從 3 開始?因為按照悠久的傳統,每個行程一出生就已經有三個描述符開著:0 是標準輸入(通常是鍵盤)、1 是標準輸出(通常是螢幕)、2 是標準錯誤。這正是為什麼往螢幕印字、從鍵盤讀取會感覺如此毫不費力——這些連結在你的程式跑第一行之前就已經遞到它手上了。你接下來開啟的檔案,就單純取用最小的那個空閒號碼,也就是 3。
數字背後:開啟檔案表
那麼,那個小整數實際上索引到的是什麼?每個行程都擁有一個私有的小陣列,也就是每行程描述符表。3 號格子裡放著一個指標,指向一筆更豐富的紀錄,那筆紀錄活在整個核心共用、橫跨全系統的開啟檔案表裡。那筆更豐富的紀錄,才是一個已開啟檔案真正狀態的所在:它是哪個檔案、你是為了讀還是寫而開啟它,以及我們接下來要遇見的那個位置游標。檔名本身根本不存放在這裡——檔案一旦開啟,它的名字便已完成它唯一的任務(幫忙找到它),就此功成身退、不再被需要。
順著那筆紀錄再往下深入一層,你就抵達檔案在磁碟上真正的身分——它的 索引節點,那是一筆精簡的紀錄,握有檔案的屬性,以及它的區塊實際坐落在何處的地圖。我們會在實作那一段裡深入挖掘索引節點;目前最關鍵的概念是這條鏈。你的 fd 不過是第一個環節:描述符號碼通向一筆開啟檔案表的條目,那條目通向索引節點,索引節點再通向真正的位元組。描述符就是這整條鏈靠你這一端的那個便宜把手。
process A process B
fd table fd table
+----+ +----+
| 0 |--> stdin | 0 |--> stdin
| 1 |--> stdout | 1 |--> stdout
| 2 |--> stderr | 2 |--> stderr
| 3 |---------+ +---------| 3 |
+----+ | | +----+
v v
system-wide open-file table
+---------------------------+
| mode: read | pos: 1024 |---> inode ---> [ disk blocks ]
+---------------------------+檔案位置:一個悄悄移動的游標
在每一筆開啟檔案表條目的內部,坐著系統中最有用、卻也最常被忽略的數字之一:檔案位置——有時也叫偏移量或游標。它記錄著你下一次讀或寫,將從檔案開頭算起的第幾個位元組開始。當你為了讀取而開啟一個檔案時,它從 0 開始,也就是最最開頭的那個位元組。這正是文字編輯器裡那個閃爍的游標,只不過對象是原始的位元組:它標示著動作將在哪裡發生。
巧妙之處在於:這個位置會自己往前推進。像 read(fd, buf, n) 這樣一通呼叫做兩件事:它把至多 n 個位元組複製進你的緩衝區,並把游標恰好往前推進它所交付的位元組數。所以你不必自己追蹤你讀到哪了——核心替你追蹤。再呼叫一次 read,你便能無縫地拿到下一段。讓我們在一個 4000 位元組的檔案上追蹤一遍。
- open("data.bin") 回傳 fd 3;它在開啟檔案表裡的條目把位置設為 0。
- read(3, buf, 1000) 把第 0 到第 999 個位元組複製進 buf,並把位置推進到 1000。
- 再一次 read(3, buf, 1000) 複製第 1000 到第 1999 個位元組——它恰好從上回停下的地方接續——位置變成 2000。你從沒告訴它要從哪開始;它自己記得。
- 若想四處跳動而非一路讀到底,可呼叫 lseek(3, 0, start) 把游標一把拉回 0,或 lseek 到你喜歡的任何偏移量;緊接著的下一次讀取就從那裡開始。
這裡有一個微妙卻重要的要點。因為位置活在共用的開啟檔案表條目裡,而不在你私有的描述符格子裡,所以兩個指向同一條目的描述符會共用同一個游標——透過其中一個讀取,另一個也會看到位置移動。但如果兩個行程各自分別開啟同一個檔案,它們會拿到兩筆不同的條目,因而擁有兩個各自獨立的游標,各以自己的步調讀取檔案,互不干擾。
循序讀取,或一步到位地跳過去
你既能讓游標慢慢往前爬、也能把它甩到任何地方,這個事實正是檔案存取方法的核心——也就是程式在一個檔案的位元組之間移動所採用的模式。最常見的模式是循序存取:從頭開始一路讀到尾,游標自己往前推進。你讀一份日誌檔、播一首歌、串流一段影片,用的都是這個方式——從前到後,就像一頁一頁地讀小說。
另一種模式是直接(或隨機)存取:先用 lseek 設定位置,便能以任意順序、一步跳到你想要的任何位元組。資料庫就是這樣一躍跳到第 5000 筆紀錄,而不必讀過它前面的 4999 筆;媒體播放器也是這樣跳到一部電影的中段。作業系統用同一套機制——一個可移動的游標——同時支援這兩者。循序存取,不過是你碰巧從不跳動的那個特例罷了。
當你的程式做完事,它呼叫 close(fd)。這不只是為了整潔。Close 會拆掉那筆開啟檔案表條目(前提是沒有別人正共用它)、釋放描述符號碼好讓未來的 open() 能再利用它,並且重要的是,把任何被緩衝起來的寫入安全地刷寫到磁碟上。忘了 close 會洩漏描述符——一個行程被允許持有的描述符數量是有限的,而一支長時間執行、只開不關的程式,最終會把它們用罄而失敗。開啟、使用、關閉:這就是一個檔案工作連結的完整一生。