printf、fopen 這些標準函式庫的底下是什麼?答案是作業系統的系統呼叫(system call)。 K&R 第 8 章揭開這層面紗:檔案描述子、open/read/write/close、目錄與 inode。 理解這一層,你才真正知道 I/O 的成本從哪來、緩衝為什麼存在、 以及 ls、cat 這些工具是怎麼寫出來的。本單元所有範例皆用 POSIX API, 在 macOS(與 Linux)可直接編譯執行,並且只操作 /tmp 下自建的測試檔。

學習目標

  • 理解檔案描述子(file descriptor)與 0/1/2 三個標準描述子
  • 會使用低階 I/O:open、read、write、close、lseek
  • 理解低階 I/O 與 stdio 緩衝 I/O 的差異與效能含意
  • 用 read/write 實作簡化版 cat
  • 會用 errno/perror/strerror 做系統呼叫錯誤處理
  • 用 stat 取得檔案中繼資料;用 opendir/readdir 走訪目錄(簡化版 ls)

對照章節 / 參考來源

  • K&R 第 8 章 §8.1–8.6(The UNIX System Interface)
  • POSIX.1-2017:open(2), read(2), write(2), lseek(2), stat(2), opendir(3), readdir(3)
  • 《Advanced Programming in the UNIX Environment》(APUE, Stevens) 第 3–4 章
  • macOS:man 2 open 等(本機 man page)

觀念講解

1. 檔案描述子

核心(kernel)用一個小的非負整數代表行程開啟的每個檔案——檔案描述子(fd)。 程式啟動時自動擁有三個:

fd 名稱 慣例用途
0 stdin 標準輸入
1 stdout 標準輸出
2 stderr 標準錯誤(不被管線重導)
行程的 fd 表          核心的開啟檔案表         磁碟
┌────┬──────┐
│ 0  │  ────┼──> (terminal)
│ 1  │  ────┼──> (terminal)
│ 2  │  ────┼──> (terminal)
│ 3  │  ────┼──> [offset=0, mode=RD] ──> /tmp/demo.txt (inode)
└────┴──────┘

open 回傳「目前最小的未用 fd」;shell 的重導(>、<、|) 本質上就是在啟動子行程前替換 fd 0/1/2 指向的東西。

2. open / read / write / close

#include <fcntl.h>      /* open 與 O_* 旗標 */
#include <unistd.h>     /* read, write, close, lseek */

int fd = open("/tmp/demo.txt", O_WRONLY | O_CREAT | O_TRUNC, 0644);
ssize_t n = write(fd, buf, len);     /* 回傳實際寫入的位元組數 */
close(fd);
  • 第三個參數 0644 是八進位權限(rw- r-- r--),只在 O_CREAT 時需要。
  • read 回傳實際讀到的位元組數;0 表示 EOF、-1 表示錯誤。
  • read/write 不保證一次處理完你要求的量(尤其是管線與網路), 嚴謹的程式要寫「迴圈直到完成」的 full read/write。

3. 與 stdio 的差異:緩衝

低階 I/O(fd) stdio(FILE*)
介面 open/read/write fopen/fread/fprintf
緩衝 無(每次呼叫都是 syscall) 有(累積到緩衝滿才 syscall)
成本 syscall 次數 = 呼叫次數 syscall 次數大幅減少
控制 精確、可搭配 select/mmap 方便、可攜

一次 syscall 的成本約數百 ns~1µs(模式切換 + 核心路徑)。 逐字元呼叫 write(fd, &c, 1) 比 stdio 的 putc 慢一到兩個數量級—— 範例程式會實際量測給你看。經驗法則:應用程式用 stdio, 系統程式/需要精確控制時用 fd,兩者別混用同一個檔案。

4. lseek:隨機存取

每個開啟的檔案帶有「目前位移」。read/write 會推進它,lseek 直接設定:

lseek(fd, 0L, SEEK_SET);    /* 回到檔頭 */
lseek(fd, -4L, SEEK_END);   /* 檔尾倒數 4 位元組 */
off_t size = lseek(fd, 0L, SEEK_END);  /* 慣用技巧:取檔案大小 */

有了 lseek + read/write 你就能實作固定長度記錄的「迷你資料庫」。

5. errno 與 perror

系統呼叫失敗回傳 -1,並把原因代碼放進全域的 errno:

int fd = open("/no/such/file", O_RDONLY);
if (fd == -1) {
    perror("open");                     /* open: No such file or directory */
    fprintf(stderr, "code=%d msg=%s\n", errno, strerror(errno));
}

規矩:只有在呼叫失敗後 errno 才有意義;成功的呼叫不會清除它。 常見代碼:ENOENT(不存在)、EACCES(權限)、EBADF(壞 fd)、 EINTR(被訊號打斷——迴圈重試的理由之一)。

6. stat 與目錄走訪

stat 取得檔案的中繼資料(inode 內容):大小、權限、擁有者、三個時間戳:

struct stat st;
if (stat(path, &st) == 0) {
    if (S_ISDIR(st.st_mode))  /* 是目錄嗎? */
    if (S_ISREG(st.st_mode))  /* 是一般檔案嗎? */
    st.st_size;               /* 位元組數 */
    st.st_mtime;              /* 最後修改時間 */
}

目錄不能用 read 直接讀(macOS 上會 EISDIR),要用 opendir/readdir:

DIR *dp = opendir(path);
struct dirent *ent;
while ((ent = readdir(dp)) != NULL)
    printf("%s\n", ent->d_name);    /* 含 "." 與 ".." */
closedir(dp);

readdir 只給名字;要大小、型別等資訊,對每個名字再呼叫 stat—— 這正是 ls -l 的工作流程(K&R §8.6 的 fsize 範例同構)。

範例程式

檔案 內容 編譯 / 執行
01_lowlevel_io.c open/write/read/lseek/close 全流程、errno 錯誤示範、取檔案大小 cc -std=c11 -Wall -Wextra -o out 01_lowlevel_io.c && ./out
02_mycat.c 用 read/write 實作 cat;不同緩衝區大小的 syscall 次數與耗時比較 cc -std=c11 -Wall -Wextra -o out 02_mycat.c && ./out
03_stat_ls.c stat 解讀(型別/權限/大小/時間)+ opendir/readdir 簡化版 ls -l cc -std=c11 -Wall -Wextra -o out 03_stat_ls.c && ./out

三個程式都只在 /tmp 下自建測試檔案與目錄,執行完自動清理,不碰系統既有檔案。

常見錯誤與陷阱

  1. 不檢查回傳值:open 失敗回 -1,接著 read(-1, ...) 又失敗, 錯誤被層層掩蓋。每個 syscall 都要檢查,失敗立即 perror。
  2. 假設 read 一次讀滿:read(fd, buf, 4096) 可能只回 1 個位元組 (管線、tty、網路)。正確姿勢是迴圈累積到 0(EOF)為止。
  3. fd 洩漏:迴圈中 open 而錯誤路徑忘記 close,行程 fd 上限 (ulimit -n)很快耗盡,之後所有 open 都 EMFILE。
  4. 混用 fd 與 FILE* 操作同一檔案:stdio 的緩衝與 fd 的位移互不知情, 輸出順序錯亂。要混用得先 fflush 並用 fileno 同步。
  5. 權限用十進位:open(..., O_CREAT, 644) 中 644 是十進位(= 01204), 會建出權限詭異的檔案;一定要寫 0644。

練習題

  1. 【易】寫 mywc:用 read 統計自建測試檔的位元組數、行數(數 '\n')。
  2. 【易】寫 mycp src dst:用 read/write 迴圈複製檔案,緩衝區 4096, 結尾用 stat 驗證兩檔大小相同。
  3. 【中】用 lseek 實作「固定長度記錄」讀寫:寫入 10 筆 64-byte 記錄, 然後直接跳到第 7 筆讀出(不讀前面的)。
  4. 【中】實作 tail -c N:lseek 到 SEEK_END - N 再 read(注意檔案比 N 小的情況)。
  5. 【中】為 03_stat_ls.c 加上 -t 排序(依 mtime 遞減,qsort+比較函式—— 複習函式指標)。
  6. 【難】實作遞迴的 du:對目錄樹走訪加總 st_size, 小心不要跟著 symlink 走(用 lstat、檢查 S_ISLNK)。

補充:從 fd 再往上與往下看

  • 往上:FILE* 內部就包著一個 fd(fileno(fp) 可取出); fopen 是 open 加上緩衝區管理的包裝。
  • 往下:fd 不只指檔案——管線、socket、裝置、epoll/kqueue 控制代碼 全都是 fd。「一切皆檔案」讓 read/write/close 成為統一介面。
  • 重導的原理:dup2(fd, 1) 把 stdout 換成任意 fd; shell 的 prog > file 就是 fork 之後、exec 之前做這件事。
  • 觀察工具:lsof -p PID 列出行程開啟的所有 fd; macOS 用 dtruss、Linux 用 strace 可以即時看到每個 syscall。

延伸閱讀

  • K&R 第 8 章(§8.5 fsize、§8.6 目錄走訪的原始實作)
  • APUE(Stevens & Rago)第 3 章 File I/O、第 4 章 Files and Directories
  • man 2 open、man 2 read、man 2 stat、man 3 opendir(macOS 本機)
  • Linux 的 strace / macOS 的 dtruss:實際觀察程式發出的 syscall
  • 《The Linux Programming Interface》(Kerrisk) 第 4–5、13–15 章