程式的資料若只存在記憶體,程式一結束就煙消雲散。 本單元學習 C 標準庫的檔案 I/O:開檔、讀寫(字元/行/格式化/二進位)、 定位、錯誤處理。所有範例都自己先建立測試檔(寫到 /tmp/)再讀取, 不依賴任何外部檔案,直接編譯執行即可看到完整結果。

學習目標

  • 理解 FILE * 串流的概念,熟記 fopen 模式表
  • 會用四個層次讀寫檔案:字元(fgetc/fputc)、行(fgets/fputs)、 格式化(fprintf/fscanf)、二進位(fread/fwrite)
  • 會用 fseek/ftell/rewind 隨機存取
  • 掌握錯誤處理三件套:ferror/feof/perror,以及 stderr 與 exit
  • 完成三個實作:檔案複製、行數統計、CSV 讀寫

對照 K&R 章節

本單元主題 K&R 2nd Edition
檔案存取(fopen/getc/putc) §7.5 File Access
錯誤處理(stderr 與 exit) §7.6 Error Handling
行的輸入輸出 §7.7 Line Input and Output
完整函式規格 Appendix B1

觀念講解

1. FILE * 與串流

fopen 回傳 FILE *——一個指向「串流狀態」(緩衝區、目前位置、錯誤旗標……)的指標。 之後所有操作都透過這個指標。程式啟動時自動有三條串流:

串流 用途 預設對象
stdin 標準輸入 鍵盤
stdout 標準輸出 螢幕(有緩衝)
stderr 標準錯誤 螢幕(通常不緩衝,錯誤訊息不會被吞)

2. fopen 模式表

FILE *fp = fopen("/tmp/data.txt", "r");
if (fp == NULL) {                   /* 開檔可能失敗:檔案不存在、沒權限… */
    perror("fopen");                /* 印出系統錯誤原因 */
    exit(EXIT_FAILURE);
}
/* ... */
fclose(fp);                         /* 用完必關:釋放資源、沖出緩衝 */
模式 意義 檔案不存在時 原內容
"r" 唯讀 失敗(NULL) 保留
"w" 唯寫 建立新檔 整個清空!
"a" 附加寫 建立新檔 保留,寫入永遠在檔尾
"r+" 讀寫 失敗(NULL) 保留
"w+" 讀寫 建立新檔 整個清空!
"a+" 讀+附加寫 建立新檔 保留
加 b(如 "rb"、"wb") 二進位模式 同上 Unix/macOS 無差別;為可攜性二進位檔一律加 b

3. 四個層次的讀寫

字元層:int fgetc(FILE *) 回傳字元或 EOF——注意回傳型別是 int, 必須用 int 變數接才能區分 EOF 與合法位元組:

int c;                              /* 不是 char! */
while ((c = fgetc(in)) != EOF)
    fputc(c, out);

行層:fgets 讀進至多 n−1 個字元並保留換行、保證補 '\0';讀到檔尾回傳 NULL:

char line[256];
while (fgets(line, sizeof line, fp) != NULL)
    fputs(line, stdout);

格式化層:fprintf/fscanf 就是 printf/scanf 的檔案版:

fprintf(fp, "%s,%d,%.2f\n", name, age, score);
while (fscanf(fp, "%31[^,],%d,%lf\n", name, &age, &score) == 3) { ... }

二進位層:fwrite/fread 直接搬記憶體位元組,適合結構陣列:

fwrite(records, sizeof records[0], n, fp);       /* 寫 n 筆 */
size_t got = fread(buf, sizeof buf[0], n, fp);   /* 回傳實際讀到的筆數 */

4. 定位:fseek / ftell / rewind

fseek(fp, 0L, SEEK_END);       /* 跳到檔尾 */
long size = ftell(fp);         /* 目前位置 = 檔案大小(bytes) */
rewind(fp);                    /* 回到開頭(= fseek(fp, 0, SEEK_SET)) */
fseek(fp, (long)(i * sizeof(struct rec)), SEEK_SET);  /* 隨機跳到第 i 筆 */

SEEK_SET(從頭)、SEEK_CUR(從目前位置)、SEEK_END(從檔尾)。

5. 錯誤處理

工具 用途
回傳值檢查 fopen→NULL、fgets→NULL、fread→短筆數…第一道防線
feof(fp) 讀取失敗後判斷:是「到檔尾了」嗎?
ferror(fp) 讀取失敗後判斷:是「I/O 錯誤」嗎?
perror("msg") 印出 msg: 系統錯誤原因(根據 errno)到 stderr
exit(EXIT_FAILURE) 結束程式並回報失敗;會沖出緩衝、關閉串流

正確的讀迴圈收尾:

while ((c = fgetc(fp)) != EOF) { ... }
if (ferror(fp))       fprintf(stderr, "read error\n");
else /* feof(fp) */   ;   /* 正常讀完 */

6. stderr 與 exit

錯誤訊息一律印到 stderr:它不經緩衝、且與正常輸出分流 (./prog > out.txt 時錯誤仍會顯示在螢幕)。 exit(status) 可在任何函式中結束程式;main 中 return n 等價於 exit(n)。

範例程式

檔案 內容
01_text_io_basics.c 建立測試檔後示範:fputc/fgetc、fputs/fgets、fprintf、行數字數統計(wc 雛形)、feof/ferror
02_binary_seek.c 結構陣列的 fwrite/fread、fseek/ftell/rewind 隨機存取第 i 筆、量檔案大小
03_copy_and_csv.c 檔案複製(含錯誤處理的完整版)、CSV 寫入與逐欄解析(fgets+strtok)

每支程式都會先把測試檔寫進 /tmp/,再讀回展示,無需準備任何外部檔案:

cc -std=c11 -Wall -Wextra -o /tmp/demo 01_text_io_basics.c
/tmp/demo

常見錯誤與陷阱

1. 不檢查 fopen 的回傳值

FILE *fp = fopen("nonexistent.txt", "r");
fgets(line, sizeof line, fp);       /* fp 是 NULL -> 當掉 */

修正:開檔後立刻 if (fp == NULL) { perror("fopen"); exit(EXIT_FAILURE); }。

2. 用 char 接 fgetc 的回傳值

char c;
while ((c = fgetc(fp)) != EOF) ...  /* char 裝不下 EOF 與全部 256 種位元組 */

平台上 char 為無號時迴圈永不結束;為有號時位元組 0xFF 會被誤判成 EOF。 修正:int c;。

3. 忘記 fclose(或以為它只是禮貌)

寫入是有緩衝的:不 fclose(或 fflush),最後一批資料可能根本沒落地。 而且行程能同時開啟的檔案數有限。規則:一個 fopen 對一個 fclose。

4. 用 "w" 開啟想保留的檔案

FILE *fp = fopen("precious.log", "w");   /* 內容瞬間被清空! */

要附加用 "a";要讀改用 "r+"。"w" 是「砍掉重練」。

5. 二進位資料用文字模式與跨平台直寫結構

  • 二進位檔開檔忘了 b:在 Windows 上換行會被翻譯,資料毀損(macOS 無差別但要養成習慣)。
  • 直接 fwrite 結構再到別的機器 fread:endianness 與 padding 都可能不同, 可攜格式要逐欄位序列化。

練習題

  1. 寫程式統計一個文字檔的字元數、單字數、行數(自己先產生測試檔),對照 wc 的輸出。
  2. 寫 int file_size(const char *path):用 fseek/ftell 回傳檔案大小,失敗回傳 -1。
  3. 寫「加上行號輸出」程式:讀文字檔,每行前面印 %6d:(cat -n 的雛形)。
  4. 寫學生成績的二進位資料庫:struct student 陣列 fwrite 到檔案, 再用 fseek 直接讀出「第 k 筆」而不讀整個檔案。
  5. 擴充 CSV 範例:支援欄位內含空白,並把解析結果依某一欄排序後另存新 CSV。
  6. (進階)實作簡單的 grep:mygrep 關鍵字 檔案——逐行讀取, 含關鍵字的行印出行號與內容(用 strstr)。

延伸閱讀

  • K&R 2nd Edition,§7.5–7.7、Appendix B1(本單元主軸)
  • K&R §8.1–8.4:系統呼叫層的 I/O(open/read/write,無緩衝)——理解 stdio 底下的世界
  • man fopen、man fread、man fseek(macOS)
  • 恭喜完成中階教程!接下來可挑戰進階主題:鏈結串列與樹(K&R §6.5–6.6)、 函式指標(§5.11)、自製 malloc(§8.7)