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

open、read、write、close

第 1 篇給了你檔案描述符:一個替「已開啟的檔案」命名的小整數。這篇要交給你讓那個整數真正幹活的四個動詞——open() 拿到一個、read() 與 write() 把位元組搬進搬出、close() 把它還回去——並誠實面對一件彆扭的事:read() 與 write() 不見得每次都會把你要求的所有位元組搬完。

給一個整數的四個動詞

從第 1 篇你已握住了那個核心物件:一個檔案描述符就只是一個小的非負整數——0、1、2,接著 3、4,往上數——核心把它交給你,用來指稱一個已開啟的檔案。那個整數本身什麼也不做;它是一張票,是核心代你保管的「每行程一份」表格裡的一個索引。本篇要談的,就是把那張票變成實際工作的四個系統呼叫。它們讀起來幾乎像一句話:open() 替一個檔案取得一個描述符、read() 透過它把位元組拉出來、write() 透過它把位元組推進去、close() 在你用完後把描述符還回去。四個動詞、一個整數,而幾乎每一支會碰到檔案的程式,都是透過這四個來做的。

請牢牢記住前面講系統呼叫那階反覆強調的一件事:這四個是系統呼叫,不是普通的函式呼叫。當你寫 read(fd, buf, n),控制權會陷入核心——它跨越一道真實的界線、切換進核心模式,核心代你在某個裝置或磁碟上做特權工作,然後才返回。正是這道界線,讓它們會以純算術絕不會有的方式失敗(磁碟滿了、檔案消失了),也正是這道界線,讓第 3 篇會那麼在意「不要」比你需要的更常去跨越它。現在只要先記住這幅圖:這四個裡的每一個,都是一趟跨過使用者/核心界線、再回來的旅程。

open():向核心要一個描述符

你從「用一個路徑替你想要的檔案命名、並說明你打算拿它做什麼」開始。那就是 open()。你交給它一個像 "notes.txt" 的路徑名,加上一組描述你意圖的旗標,它就遞回一個全新的描述符——你表格裡編號最小的空閒整數,這也是為什麼一支只開著三個標準串流的程式,第一次 open() 通常會拿到 3。旗標是其中要緊的那一半。O_RDONLY 說你只想讀;O_WRONLY 只想寫;O_RDWR 兩者都要。在那之上你再用 OR 加進額外的願望:O_CREAT 在檔案不存在時建立它、O_TRUNC 先把既有檔案砍成零長度、O_APPEND 讓每次寫入都落在結尾。你用位元 OR 把它們組起來,正是資料表示法那階講過的那種位元旗標模式。

兩個小例子能把這個形狀說具體。要讀一個既有的檔案,fd = open("notes.txt", O_RDONLY) 遞回一個描述符、或 -1;你立刻用 `if (fd == -1) { perror("open"); return 1; }` 檢查它。要建立或覆寫一個檔案來寫入,out = open("out.txt", O_WRONLY | O_CREAT | O_TRUNC, 0644) 多吃一個第三個引數——像 0644 這樣的權限模式——但這裡有個值得點明的微妙之處:那個模式「只有」在 O_CREAT 真的建立了一個全新檔案時才會被參考,而當你開的是一個已經存在的檔案時,它完全被忽略。所以 0644 設定的是你建立出來那個檔案的權限,絕不是你只是重新打開的那個檔案的。

read() 與 write(),以及它們之間那個藏起來的游標

read() 與 write() 都吃同樣的三個引數:一個描述符、你自己記憶體裡某個緩衝區的位址、以及一個位元組數。write(fd, buf, n) 把至多 n 個位元組從你的緩衝區「拷出去」、進入檔案;read(fd, buf, n) 把至多 n 個位元組從檔案「拷進」你的緩衝區。緩衝區就只是你記憶體裡的一塊區域——堆疊上的一個 `char buf[4096]`,或一塊從 malloc() 來的記憶體——你是在告訴核心「把位元組在這塊記憶體和那個描述符之間搬動」。這裡沒有任何東西知道、也不在乎那個描述符通往的是磁碟上的檔案、一個終端機、還是一條管線;這種一致性,正是你在第 1 篇遇到的那個一切皆檔案理念開花結果——同樣這兩個呼叫,不管另一端是什麼,都能搬動位元組。

但你從沒告訴過 read(),它怎麼知道該從檔案的「哪裡」開始?這正是那個安靜卻重要的環節。在你的描述符背後,核心保管著一個叫做檔案偏移量的單一數字——一個從檔案開頭算起、以位元組為單位的游標。當你 open() 一個檔案時,它從 0 開始。每一次 read() 與 write() 都從當前的偏移量開始,搬動了 k 個位元組之後,就把偏移量往前推進 k,所以下一次呼叫很自然地接在上一次停下的地方。你從來不必傳入一個位置;偏移量是隱含的,由描述符背後那個「已開啟的檔案」攜帶著。讀 100 個位元組,游標就停在 100;再寫 50 個,它就停在 150。正是這個藏起來、會前進的游標,讓一個單純的 read() 迴圈能從檔案的頭直直走到尾。

誠實的部分:短讀與部分寫

接下來是初學者最常沒被告知、卻會造成真實 bug 的真相:read() 與 write() 搬動的位元組,可能比你要求的「更少」。再看一次那個回傳值。read() 回傳的是這一次呼叫實際放進你緩衝區的位元組數——它可能小於 n。要求 4096,你可能拿到 512,甚至 1,即使檔案裡剩下的還多得很。這叫做短讀,而且很正常:一個終端機一次給你一行、一條網路連線給你目前為止已到達的、一條管線只給你另一端已經寫進去的。同樣地,write() 回傳它實際接受了多少位元組,這也可能偏短——磁碟寫到一半滿了、或一條管線的緩衝區快滿了。那個計數是「發生了什麼」的回報,絕不是「全部 n 個都搬了」的保證。

正因為如此,你幾乎從不會寫單獨一個赤裸的 read() 或 write() 就信任它。你會寫一個迴圈,一直跑到計數補足、或呼叫表示它做完了為止。對寫入來說,這個模式是:往前推進一個指標、把剩餘計數減掉「實際被接受的那幾個位元組」,再對剩下的部分重新呼叫 write(),直到一個不剩。略過這個,是一種經典又無聲的崩壞:一支程式呼叫 write(fd, buf, n) 一次、看到它回傳 n-100、就假定整件事都送出去了,那它剛剛已經把資料的最後 100 個位元組丟掉了,連個崩潰來警告你都沒有。這個迴圈不是吹毛求疵;它是用這些呼叫搬動「已知份量資料」的唯一正確方式。

  1. 保留兩個追蹤者:一個指向你資料的指標 p,以及一個「還剩多少位元組要寫」的計數 left。
  2. 呼叫 w = write(fd, p, left) 一次,把這一輪它願意接受的盡量推出去。
  3. 若 w == -1,這次寫入失敗了;檢查 errno(EINTR 就重試,否則回報並停止)。
  4. 否則把 p 往前推進 w、把 left 減掉 w,這樣下一次呼叫只寫那段剩下的。
  5. 重複到 left 變成 0——到那時、也只有到那時,你所有的位元組才真正寫完了。

檔案結尾,以及一個寫對的讀取迴圈

讀取還需要多一個訊號:你怎麼知道檔案讀完了?read() 同樣用它的回傳值來回答,而規則很精確。回傳大於 0,是你拿到的位元組數。回傳剛好為 0,代表檔案結尾——沒有東西可讀了,游標在結尾,之後的讀取會持續回傳 0。而回傳 -1 是一個貨真價實的錯誤,errno 會說明原因。這三種情況——正、零、負——就是 read() 的全部詞彙,而一個正確的迴圈會對這三者都分支處理。注意 0 既不是錯誤、也不是短讀;它是核心用來說「就這些了」的那個乾淨、刻意的方式。把一個 0(真正的 EOF)跟一個 -1(失敗)搞混,是個常見的初學者失誤。

char buf[4096];
ssize_t n;
while ((n = read(in, buf, sizeof buf)) > 0) {
    /* n bytes are valid in buf[0..n-1]; may be fewer than 4096 */
    write_all(out, buf, n);          /* the loop-until-done writer */
}
if (n == -1) {                       /* read() error, not EOF */
    perror("read");
    /* handle: propagate or abort */
}
/* n == 0 here means clean end of file: every byte was read */
一個正確的複製迴圈:一直讀到 read() 回傳 0(EOF)為止,把 -1 當成錯誤,並把每一塊交給一個「全部寫完」的輔助函式去處理部分寫入的迴圈。

把那個迴圈慢慢讀一遍,因為它幾乎是所有低階 I/O 的標準形狀。它讀進一個緩衝區、拿回核心這一次選擇要給的那麼多位元組、就剛好處理那麼多、然後迴圈;只有回傳 0 才乾淨地結束它,而 -1 被獨立切出來當作錯誤。同樣這副骨架,可以複製一個檔案、抽乾一條管線、或從一個通訊端拉資料——改變的只有那些描述符。在這裡把它內化一次,你就握住了接下來三篇的骨幹——在那裡,位元組會來自緩衝串流、來自真實檔案系統上的 inode、以及來自接在兩個行程之間的管線。

close():把描述符還回去,以及這為什麼要緊

當你用完一個檔案,你呼叫 close(fd)。這會釋放那個描述符:你檔案描述符表裡的那一筆被釋放、那個整數重新變得可用(下一個 open() 很可能就重用那個號碼),核心也放掉它背後的資源。描述符是有限的資源——一個行程能同時開著的數量有上限,預設常常在 1024 上下——所以一支會在迴圈裡開檔、卻從不關檔的長時間執行程式,最終會用光、然後看見 open() 開始以 EMFILE 失敗。那是一個描述符洩漏,是你在堆積上遇過的那個記憶體洩漏在 I/O 世界的表親:一個被取得卻從不被釋放的資源,慢慢地把程式餓死。

還有第二個、更尖銳的關閉理由,它接回上一節。緩衝的資料與排隊中的寫入,可能要等到描述符被關閉時才完全提交,而且——老實說——連 close() 本身都可能失敗、回傳 -1。如果你最後寫的那些位元組還在飛往一個滿了的磁碟、或一個已掛斷的網路的途中,close() 可能就是那個最終把它報出來的呼叫。所以一支謹慎的程式,連 close() 的回傳值都會檢查,尤其是對一個它剛寫過的檔案。關閉不是一個「反正程式快結束了就能跳過」的客套;對一個你寫過的檔案,它可能就是「你的資料在磁碟上」與「你的資料無聲地遺失了」之間的差別。

退一步,你就握住了一個檔案完整的一生:open() 帶著明確的意圖取得一個描述符、一個會尊重「它拿回的計數」與「會前進的偏移量」的 read() 或 write() 迴圈、以及 close() 來釋放描述符並把最後的位元組刷出去——每一個呼叫都檢查,因為每一個都跨進核心、每一個都可能失敗。那就是最底層的機械。第 3 篇在它之上鋪一層緩衝區,讓你不必每一個位元組都付一趟進核心的旅費;第 4 篇順著你 open() 的那個路徑,往下走進目錄與 inode;第 5 篇用一條管線把兩個描述符接在一起。但它們全都立足於這四個動詞,以及「誠實地讀它們的回傳值」之上。