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 下自建測試檔案與目錄,執行完自動清理,不碰系統既有檔案。
常見錯誤與陷阱
- 不檢查回傳值:
open失敗回 -1,接著read(-1, ...)又失敗, 錯誤被層層掩蓋。每個 syscall 都要檢查,失敗立即perror。 - 假設 read 一次讀滿:
read(fd, buf, 4096)可能只回 1 個位元組 (管線、tty、網路)。正確姿勢是迴圈累積到 0(EOF)為止。 - fd 洩漏:迴圈中 open 而錯誤路徑忘記 close,行程 fd 上限
(
ulimit -n)很快耗盡,之後所有 open 都 EMFILE。 - 混用 fd 與 FILE* 操作同一檔案:stdio 的緩衝與 fd 的位移互不知情,
輸出順序錯亂。要混用得先
fflush並用fileno同步。 - 權限用十進位:
open(..., O_CREAT, 644)中 644 是十進位(= 01204), 會建出權限詭異的檔案;一定要寫0644。
練習題
- 【易】寫
mywc:用 read 統計自建測試檔的位元組數、行數(數 '\n')。 - 【易】寫
mycp src dst:用 read/write 迴圈複製檔案,緩衝區 4096, 結尾用 stat 驗證兩檔大小相同。 - 【中】用 lseek 實作「固定長度記錄」讀寫:寫入 10 筆 64-byte 記錄, 然後直接跳到第 7 筆讀出(不讀前面的)。
- 【中】實作
tail -c N:lseek 到SEEK_END - N再 read(注意檔案比 N 小的情況)。 - 【中】為
03_stat_ls.c加上-t排序(依 mtime 遞減,qsort+比較函式—— 複習函式指標)。 - 【難】實作遞迴的
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 章