檔案輸入與輸出

程式設計為何需要檔案?

撰寫程式時我們會把 CPU 要處理的資料設計為存放在記憶體內, 不同的資料還可以給它不同的名稱 (變數), 可是這些資料在程式結束以後就消失了, 而且因為記憶體速度快、比較貴、數量比較有限, 在記憶體內不能夠記錄太多的資料, 因此 CPU 必須有其它能夠大量地、 長久地記錄資料的地方, 這個需求可以由檔案來滿足。

作業系統的觀點來看, 檔案是在磁碟機 (光碟機) 上一群位元組 (資料) 的集合體, 這些資料由電腦操作者的觀點來看通常有一些邏輯上共同的意義, 例如一個 C 程式檔案裡面放的是同一個程式的敘述, 一個學生資料檔案裡面放的是同一班同學的資料或是同一系同學的資料, 也就是說檔案是用來整理資料的, 當然操作者也可以選擇把邏輯上不相關的資料放在同一個檔案裡, 例如上面兩種資料放在同一個檔案裡, 這是操作者可以自己決定的, 但是因為作業系統只能給一個檔案一個名稱, 這個名稱是作業系統用來記著這個檔案是在磁碟機什麼地方的, 也是電腦操作者用來記著檔案內存放些什麼東西的根據, 如果檔案內有邏輯上不相關的資料, 那其實就乾脆整個磁碟機一個檔案就好了, 別整理它了。

注意

對撰寫C程式的人來說,檔案是什麼?

C程式裡面看到的檔案非常的單純:

  1. 每一個檔案有一個檔案名稱,

  2. 程式設計者可以把一個檔案看成是一長串連續的位元組, 當然不見得每一個位元組獨立代表一個資料, 也可能兩個位元組代表一筆資料或是更多位元組合在一起代表一個資料, 完全由程式設計的人自己決定。 (在 C 中稱為資料串流 stream。)

  3. 每一個檔案在程式讀取之前要先做開啟的動作,

  4. 檔案開啟時可以限定某一個檔案是用來讀取、 或是寫入、 是二進位資料、 還是 ASCII 字元資料等等特性,

  5. 開啟中的檔案基本上是順序讀寫的, 因此 C 執行系統 (runtime system) 會記錄著目前正在讀/寫檔案中哪一個位元組的資料 (file pointer), 程式中只要管是讀或是寫就可以了, 不用管讀/寫到哪裡去了, 讀/寫的動作都由那個位元組依序向後處理。

  6. 不需要再讀寫 (存取) 的檔案要把它關閉起來, 以免浪費系統資源。
注意

在作業系統中檔案不是這麼單純的, 不見得是在磁碟機上連續的位元組, 因為要顧及存取的效率以及 磁碟機空間使用的效率、 一般檔案在磁碟機上又再細分為資料錄 (record)、 磁區 (sector)、 磁軌 (track), 等等單位, 作業系統為了讓使用者操作起來方便, 把這些細節隱藏起來了, 只讓你感覺到一個檔案是一連串資料 (位元組) 的整體。

檔案資料的使用

我們已經知道記憶體內資料的使用方法, 記憶體本身是一長串可以存放資料的位元組, 每一個位元組有唯一的一個位址, CPU 可以藉此來存取存放在內的資料, 在程式內我們通常讓記憶體對應到有名稱的變數, 程式內可以藉由變數名稱來存取其內存放的資料, 簡易地說: 存放在記憶體內的資料在程式內都有對應的名稱, 程式內可以直接存取。

檔案內的資料就沒那麼好命了, 我們不能替檔案裡的每一個資料都取一個名稱, 讓程式直接存取, 檔案裡的資料到底是什麼, 依照什麼順序、 什麼格式來存放, 只有設計程式的人心裡知道, 在程式中則隱藏在程式的邏輯中, 因此程式要使用檔案內的資料時, 通常會透過一些檔案讀取的函式將檔案內資料轉到記憶體內的變數內。 比方說有一個文字檔案內有五十列的資料, 每一列有一個學生姓名、 一個年齡數字, 一個體重如下:

程式內於是需要將資料搬移到記憶體變數去才能處理, 搬移資料的程式碼如下: 上面的程式中暫時不管 fp 是什麼東西, 你應該可以感覺到檔案內資料的順序、 數量、 格式、 與意義是如何在程式的邏輯中表達出來, 沒有任何方法可以幫助你, 如果對於同樣的檔案你寫成 則第一次迴圈做到 fscanf() 時, cStudentName 是 john, fStudentWeight 是 19.0, 第二次讀時, cStudentName 是 60.5, fStudentWeight 是 18.0, 第三次讀時, cStudentName 是 mary, fStudentWeight 是 18.0,... 多可怕啊! 程式裡的動作順序一定要和檔案內資料的排列順序、 格式、 及數目完全一致才能正確地轉換為變數內的資料, 也就因為這樣, 你只要一處理起檔案的時候, 錯誤的機會就突然增加了許多。 當然你也許會想到執行程式提供資料的人常常不是製作程式的人, 萬一資料檔案內的格式不是程式要求的那該怎麼辦, 不是註定要錯得一蹋糊塗了嗎? 喔! 不! 你的程式要想辦法偵測這種錯誤, 提醒操作此程式的人, 這樣程式的功能才完備啊!

程式內如何操作檔案呢?

藉由 stdio 函式庫中的 fopen, fclose, ftell, fscanf, fprintf, putc, getc, fputs, fgets, fread, fwrite 等等函式、 NULL, EOF, SEEK_SET,SEEK_END, SEEK_CUR, stdin, stdout 等常數、 以及 FILE 資料型態。

程式如何開啟一個檔案?

使用 fopen() 函式如下: (注意:一定要含入 stdio.h 檔案) 首先在程式內宣告一個 FILE * 型態的指標變數, 然後呼叫 fopen() 函式並給它兩個字串型態的參數, 第一個是檔案名稱 (可以包括路徑名稱, 但是所有的反斜線必須要打兩次, 例如:"c:\\user\\pyting\\file.dat"), 第二個是讀/寫之屬性, fopen() 函式去和作業系統打交道, 看看檔案是否存在, 可不可以允許程式做它想做的事, 如果都可以的話, fopen() 函式會配置一小塊記憶體來存放 FILE 型態的資料, 並且把指標回傳給你的程式, stdio 函式庫內的檔案操作函式憑藉這個 FILE 型態內的資料認得這個已開啟檔案, 你不需要知道其內容的細節, 但是每次你要求 stdio 的檔案操作函式來處理這個已開啟的檔案時, 你必須將此指標提供給它參考, 它才找得到這個檔案及其內部的資料。

萬一 fopen() 函式失敗的話, 會傳回一 NULL 指標, 程式應該做適當的處理, 比方說列印錯誤訊息, 結束程式等等。

如何關閉一個開啟中的檔案?

使用 stdio 函式庫中的 fclose() 函式如下:

如何讀取檔案中的文字型態資料?

使用 getc()、 fgets()、或是 fscanf() 函式如下:

  1. getc() 由檔案內讀回目前讀取位置的一個位元組, 傳回一個 int 型態 (MSB 8 個位元都為 0,LSB 8 個位元才是讀入的資料) 的資料, 並將檔案內目前讀取位置移到下一個位元組去, 如果檔案結束了則會傳回 EOF。

  2. fgets() 函式由檔案內目前讀取位置讀取連續的位元組, 直到換列字元、檔案結束、 或是已經到達 n-1 個字元時就會成功地結束, 在 cBuf 內剛才讀進的最後一個字元之後加上一個 NULL 字元以標示字串的結束位置, 並將 cBuf 的位址傳回。

    注意:

    若是讀到換列字元 (DOS 下為 ox0a0d) 的話, 換列字元 (0x0a 或是 '\n') 會讀入 cBuf 中函式才結束, 若是檔案結束 (DOS 中為 0x0a1a) 則換列字元 (0x0a或'\n') 會讀入 cBuf 中然後函式才結束。 若是 cBuf 中沒有其它字元則傳回 0, 若有其它字元則仍傳回 cBuf 位址, 下一次 int fscanf(FILE *stream,const char *format,...) 讀檔時會再讀到檔案結束 0x0a1a 而傳回 0。

  3. fscanf() 函式除了需要一個 FILE * 指標做為參數之外, 其它參數的給法和 scanf() 一模一樣, 在運作上有一點點不同, scanf() 因為是從鍵盤讀入資料, 必須等到操作者按下 Enter 鍵後才讀得到, 如果程式內使用 scanf("%d%d%d",&x,&y,&z) 而操作者打入兩個數字後就按下 Enter 則 scanf() 函式還不會結束, 繼續等待第三個數字的輸入及 Enter, 反之如果用 scanf("%d%d",&x,&y) 而操作者打入三個數字之後才按下 Enter 則 scanf() 會讀入前兩個數字, 第三個數字會留給下一次 scanf() 的呼叫去讀。

如何將資料以文字格式 (ASCII 編碼) 寫入檔案?

使用 putc()、fputs()、或是 fprintf() 函式如下:

  1. putc() 將一個整數型態變數的 LSB 8 個位元寫入檔案, 並且傳回寫入的數值, 如果失敗的話會傳回 EOF (通常寫出資料錯誤時多為磁碟已滿或是硬體的錯誤)。

  2. fputs() 將一個字元陣列的字串寫入檔案中, 並且傳回最後寫入的字元的數值, 如果寫出錯誤的話函式傳回 EOF。

  3. int fprintf(FILE *stream,const char *format,...);

    使用 fprintf() 函式除了需要給它一個 FILE * 型態參數之外; 其它參數之使用方法與 printf() 函式一樣。

如何在開啟檔案之後,先知道檔案內共有幾個位元組?

如下例:

fseek() 是一個移動檔案內目前讀取位置 (file pointer) 的函式, 成功時會傳回 0, 其參數是一個以位元組為單位的位移 (offset) 和一個參考點 (SEEK_SET 為檔案開頭, SEEK_CUR 為檔案目前讀取位置, SEEK_END 為檔案結尾)。 在上例中第一個 fseek() 把目前讀取位置移到檔案結束的地方, 第二個 fseek() 則把目前讀取位置移到檔案開始的地方。 ftell() 函式則是傳回目前的讀取位置是檔案中的第幾個位元組。

如何將變數內資料以二進位格式寫入檔案中?

  1. 首先要知道這裡所謂的二進位格式是什麼意思? 電腦裡頭不是本來就存放著二進位的資料嗎? 這裡最主要是要和格式化的輸出輸入函式 fprintf() 及 fscanf() 做一對比, 如果有一個記憶體變數 int x=15; 用 fprintf(fp,"%d",x) 輸出到檔案去的時候, CPU 在檔案裡放的是 '1' 和 '5' 這兩個字元對應的 ASCII 編碼 00110001 和 00110101, 這和記憶體變數 x 存放的資料 0000000000001111 顯然不一樣。

    原來的二進位值只有機器能很快速地處理, 人卻不容易一眼就看出它是什麼數值; 轉換成 '1' 和 '5' 這兩個字元對應的 ASCII 碼之後, 任何一個編輯器可以很快地顯示其對應的字元, 使得人可以很快地看懂並且加以修改。

    如果 x 變數內的數值很大的話, 例如 long x = 10241377198; 那麼換成 ASCII 碼的話變成 11 個位元組, 而原先 long 變數只需要四個位元組, 另外二進位浮點數若是轉換為十進位小數時常常需要四捨五入, 就會產生誤差。

    如果程式將資料寫在檔案裡頭只是為了暫存一下, 下一次程式還要再讀出來處理、 而且這些資料不需要給人閱讀的話, 那就別轉換了, 原來變數裡存放什麼就直接在檔案中存放什麼如此又省 CPU 時間又省檔案空間, 還不會有誤差, 這就是所謂"二進位的檔案格式"。

  2. 其次在開啟檔案時檔案屬性請加上 b, 例如:

  3. 利用 stdio 函式庫中的 fwrite() 函式可以將變數內資料直接寫入檔案, 例如: 其中 fwrite() 函式的第一個參數是一個記憶體位址, 第二個參數是你希望寫到檔案中每個變數占幾個位元組, 第三個參數是希望連續寫幾個同樣的變數出去, 第四個參數是檔案指標。 fwrite() 在成功寫出資料後會傳回寫出變數的個數, 也就是第三個參數的內容。

如何將檔案內的二進位資料讀入變數中?

利用 stdio 函式庫中的 fread() 函式可以將檔案內的資料直接讀入記憶體變數中, 例如:下列程式中可以開啟前一節中寫在磁碟機上的 test.dat 檔案, 並依序將寫入的資料讀出。 注意

  1. 在二進位檔案內寫入或是讀取結構型態之資料時請務必使用 sizeof 運算子來得到每一個結構變數的位元組數目, 不要自己去計算位元組數, 例如: 像上面這樣子的敘述很可能根本沒有把 myVariable 這個結構變數所存放的所有資料存入檔案之中, 因為 sizeof(myVariable) 很可能會大於 21。 正確的用法是 fwrite(&myVariable,sizeof(myVariable),1,fp); 至於 sizeof(myVariable) 究竟是多少, 和編譯器編譯的選項很有關係, 請參考自定資料型態

  2. 在一個多工的作業系統中, 可能會發生好幾個程式同時希望存取一個檔案的狀況, 一般而言, 寫入檔案時因為檔案的內容正在改變, 所以不允許其它程式來存取這個檔案, 好幾個檔案如果都只是希望讀取某一檔案內的資料的話, 則可以容許它們同時進行。

  3. 沿習 UNIX 作業系統的習慣, C 的 stdio 函式庫內定義了三個特殊的 stream 檔案,

    此三個檔案不需要開啟或是關閉而可以直接使用, 如下:

    stdout 及 stdin 因為常常是鍵盤及螢幕, 都是以字元為標準的媒介, 因此比較少使用 fread 及 fwrite, 但是因為 stdin 及 stdout 也可以是資料串流 (例如 cat test.dat | gzip), 所以在某些狀況下還是可能用 fread 及 fwrite 處理二進位型態之資料。

    程式設計課程 首頁
    by Pei-yih Ting
    E-mail: pyting@cs.ntou.edu.tw