Unix I/O 與檔案 (Unix I/O & Files)

Overview Table

主題 核心概念 關鍵 API / 事實 書頁
Unix I/O 模型 所有裝置都被模型化為檔案;檔案是位元組序列 B0,B1,,Bm1 open / seek / read / write / close p.926
檔案描述子 kernel 回傳的小非負整數 descriptor,識別開啟的檔案 stdin=0, stdout=1, stderr=2 p.927
檔案位置 kernel 為每個開啟檔案維護 file position k(從 0 起) read/write 後 k 自動遞增;seek 可顯式設定 p.927
EOF 當 k ≥ m 時 read 觸發 end-of-file 條件 沒有「EOF 字元」,read 回傳 0 p.927
檔案類型 regular file / directory / socket(+named pipe、symbolic link、device) kernel 不區分 text/binary p.927-928
目錄階層 單一階層,根為 /;每個 process 有 current working directory 絕對/相對 pathname p.928-929
open/close open() 回傳最小未使用的 descriptor;close() 釋放 flags + mode,權限 = mode & ~umask p.929-930
read/write 傳輸最多 n bytes;可能回傳 short count(不足計數) 回傳值:-1 錯誤、0 EOF、>0 實際位元組數 p.931-932

10.1 Unix I/O(p.926-927)

I/O 是在主記憶體與外部裝置(磁碟、終端機、網路)之間複製資料的過程:input 是裝置 → 記憶體,output 是記憶體 → 裝置。ANSI C 的 standard I/O(printf/scanf)與 C++ 的 <</>> 都是較高階的緩衝式 I/O,在 Linux 上它們底層都由 kernel 提供的 Unix I/O 系統函式實作

一個 Linux 檔案就是 m 個位元組的序列:

B0,B1,,Bk,,Bm1

所有 I/O 裝置(網路、磁碟、終端機)都被模型化為檔案,所有輸入輸出都透過讀寫對應的檔案完成——這個「裝置即檔案」的優雅映射讓 kernel 能匯出一個簡單、低階、統一的介面,稱為 Unix I/O:

 Unix I/O 檔案生命週期與資料流

 open(filename)                  read(fd, buf, n)
      |                       +---------------------+
      v                       |    file (m bytes)   |
 [descriptor fd] ---seek----> | B0 B1 .. Bk .. Bm-1 |
      |                       +---------|-----------+
      |                                 | k 為目前位置,read/write 後 k += n
      |                                 v
      |                        主記憶體 buf  <--(read: 檔案->記憶體)
      |                                     -->(write: 記憶體->檔案)
      v
 close(fd)  --> kernel 釋放資料結構、fd 回到可用池
 (k >= m 時 read 觸發 EOF,回傳 0)

每個由 Linux shell 建立的 process 一出生就有三個開啟檔案:

名稱 descriptor <unistd.h> 常數
standard input 0 STDIN_FILENO
standard output 1 STDOUT_FILENO
standard error 2 STDERR_FILENO
Tip

「所有裝置皆檔案」是 Unix 抽象層次的代表作,與 01-Computer-Systems-Tour/03-OS-Abstractions-and-Networks 中「檔案 = 位元組序列」的抽象相呼應;網路 socket 也走同一套 read/write 介面(見 11-Network-Programming/03-Sockets-Interface)。

為什麼要學 Unix I/O(而非只用 standard I/O)?(p.926)

  1. 理解其他系統概念:I/O 與 process 建立/執行、檔案共享互為因果,和記憶體階層、linking/loading、virtual memory 都有交集。
  2. 有時別無選擇:standard I/O 無法存取檔案 metadata(如檔案大小、建立時間),且用於網路程式設計有風險

10.2 檔案(Files)(p.927-929)

每個 Linux 檔案都有一個**類型(type)**標示其在系統中的角色:

類型 說明 相關指令/章節
Regular file 含任意資料。應用程式常區分 text file(僅 ASCII/Unicode 字元)與 binary file(其他);對 kernel 而言兩者毫無差別
Directory link 陣列組成的檔案,每個 link 把檔名映射到一個檔案(可能又是目錄) mkdir / ls / rmdir
Socket 用於跨網路與另一個 process 通訊的檔案 11.4 節
其他 named pipe、symbolic link、character/block device 超出本書範圍
EOL(行尾符號)差異(p.928 Aside)

Linux / Mac OS X 用 '\n'(0x0a, LF);MS Windows 與 HTTP 等網路協定用 '\r\n'(0x0d 0x0a, CR+LF)。Windows 建立的檔案在 Linux 編輯器會在行尾顯示 ^M(CR)。可用 perl -pi -e "s/\r\n/\n/g" foo.txt 就地移除。

目錄階層與 pathname:kernel 把所有檔案組織成單一目錄階層,錨定於根目錄 /;每個檔案都是根的直接或間接後代。每個 process 的 context 中有一個 current working directory(shell 可用 cd 更改)。

                    /
     +------+------+--------+--------+
    bin/   dev/   etc/    home/     usr/
     |      |    +--+--+  +--+---+   +------+
    bash  tty1 group passwd |      \  include/ bin/
                          droh/  bryant/  |      |
                            |            stdio.h vim
                          hello.c         sys/
                                           |
                                        unistd.h

Pathname 是「可選的 slash + 以 slash 分隔的檔名序列」,有兩種形式:

形式 起點 範例(目標 hello.c)
Absolute pathname / 開頭,從根目錄出發 /home/droh/hello.c
Relative pathname 以檔名開頭,從 current working directory 出發 cwd=/home/droh 時為 ./hello.c;cwd=/home/bryant 時為 ../droh/hello.c

10.3 開啟與關閉檔案(p.929-930)

Process 呼叫 open 開啟既有檔案或建立新檔:

#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>
int open(char *filename, int flags, mode_t mode);
// Returns: 成功回傳新 file descriptor,錯誤回傳 -1

open 把 filename 轉換為 file descriptor 並回傳其編號。回傳的一定是該 process 目前未開啟的最小描述子

flags 引數(存取意圖,三選一):

flag 意義
O_RDONLY 只讀
O_WRONLY 只寫
O_RDWR 可讀可寫

flags 還可以 OR 上額外的寫入指示位元遮罩:

flag 意義
O_CREAT 檔案不存在時,建立一個截斷(空)的版本
O_TRUNC 檔案已存在時,截斷之
O_APPEND 每次 write 前把檔案位置設到檔案結尾
fd = Open("foo.txt", O_RDONLY, 0);              /* 開既有檔讀取 */
fd = Open("foo.txt", O_WRONLY|O_APPEND, 0);     /* 開既有檔附加 */

mode 引數與 umask:mode 指定新檔案的存取權限位元(定義於 sys/stat.h,Figure 10.2):

遮罩 意義 遮罩 意義 遮罩 意義
S_IRUSR 擁有者可讀 S_IRGRP 群組可讀 S_IROTH 其他人可讀
S_IWUSR 擁有者可寫 S_IWGRP 群組可寫 S_IWOTH 其他人可寫
S_IXUSR 擁有者可執行 S_IXGRP 群組可執行 S_IXOTH 其他人可執行

每個 process 的 context 有一個 umask(由 umask() 函式設定)。以某 mode 建立新檔時,實際權限位元為:

permission = mode & ~umask

#define DEF_MODE   S_IRUSR|S_IWUSR|S_IRGRP|S_IWGRP|S_IROTH|S_IWOTH
#define DEF_UMASK  S_IWGRP|S_IWOTH

umask(DEF_UMASK);
fd = Open("foo.txt", O_CREAT|O_TRUNC|O_WRONLY, DEF_MODE);
/* 結果:擁有者可讀寫,群組與其他人只可讀 */

關閉檔案:

#include <unistd.h>
int close(int fd);   // Returns: 0 if OK, -1 on error
Warning

關閉一個已經關閉的描述子是錯誤(close 回傳 -1)。另外「最小未用描述子」規則是重定向(dup2 技巧)的基礎,詳見 10-System-IO/03-File-Sharing-Redirection-and-Standard-IO

10.4 讀取與寫入檔案(p.931-933)

應用程式以 readwrite 執行輸入輸出:

#include <unistd.h>
ssize_t read(int fd, void *buf, size_t n);
// Returns: 讀到的位元組數 if OK, 0 on EOF, -1 on error
ssize_t write(int fd, const void *buf, size_t n);
// Returns: 寫入的位元組數 if OK, -1 on error

Figure 10.3(cpstdin.c):用 read/write 一次 1 byte 把標準輸入複製到標準輸出:

#include "csapp.h"
int main(void)
{
    char c;
    while (Read(STDIN_FILENO, &c, 1) != 0)
        Write(STDOUT_FILENO, &c, 1);
    exit(0);
}
ssize_t vs. size_t(p.932 Aside)

x86-64 上 size_t = unsigned long,ssize_t(signed size)= long。read 回傳有號型別是因為必須能回傳 −1 表錯誤;為了這一個 −1,read 單次最大長度被砍半

Short count(不足計數):某些情況下 read/write 傳輸的位元組數少於應用程式要求的量。short count 不是錯誤,成因有:

情境 行為
讀到 EOF 檔案只剩 20 bytes 而以 50-byte 為單位讀:下一次 read 回傳 short count 20,再下一次回傳 0(EOF)
從終端機讀 text line 每次 read 一次只傳一行,回傳值 = 該行長度
讀寫網路 socket / Linux pipe 內部緩衝限制與網路長延遲會造成 read 與 write 都出現 short count
 讀取 50-byte chunk、檔案僅剩 20 bytes 的 short count 時序

  read(fd, buf, 50) --> 回傳 20   (short count,非錯誤)
  read(fd, buf, 50) --> 回傳 0    (EOF)
  read(fd, buf, 50) --> 回傳 0    (仍是 EOF)
一般化敘述與例外

實務上:讀磁碟檔案除了 EOF 之外不會遇到 short count;寫磁碟檔案永遠不會遇到 short count。但例外是終端機、網路 socket、pipe——要寫健壯(robust)的網路應用(如 Web server),就必須反覆呼叫 read/write 直到傳完所有要求的位元組;這正是 Rio 套件(10.5 節)存在的理由,見 10-System-IO/02-Rio-Package-and-File-Metadata

Exam/Test Patterns

情境 / 關鍵字 答案
open/close 後再 open,問新描述子編號(Practice 10.1) open 永遠回傳最小未開啟描述子。初始 0/1/2 已占用 → 第一次 open 得 3;close(3) 後再 open 又得 3(輸出 fd2 = 3)
read 回傳 0 代表什麼 EOF(k ≥ m);−1 才是錯誤;檔案裡沒有 EOF 字元
read 回傳值 < 要求的 n,是不是出錯 不是,這是 short count;成因:EOF、終端機一次一行、socket/pipe 緩衝與延遲
讀/寫磁碟檔案會不會有 short count 讀:只在 EOF 時;寫:不會。socket/terminal/pipe 才會
新檔實際權限怎麼算 mode & ~umask
O_APPEND 的語意 每次 write 之前把 file position 移到檔案結尾(不是只有 open 時一次)
O_CREAT vs O_TRUNC O_CREAT:不存在則建立空檔;O_TRUNC:已存在則截斷
stdin/stdout/stderr 的描述子 0 / 1 / 2(STDIN_FILENO / STDOUT_FILENO / STDERR_FILENO,定義於 <unistd.h>)
text file 與 binary file 對 kernel 的差別 沒有差別;區分只存在於應用程式層
Windows 檔在 Linux 出現 ^M Windows/HTTP 行尾是 \r\n(CR+LF, 0x0d 0x0a),Linux 是 \n(LF, 0x0a);^M 是 CR
為何 read 回傳 ssize_t 而參數是 size_t 需要回傳 −1 表錯誤 → 用有號 long;代價是最大讀取長度減半
關閉已關閉的描述子 錯誤,close 回傳 −1
相對 vs 絕對 pathname 絕對:以 / 開頭從根出發;相對:從 current working directory 出發(. 自己、.. 父目錄)
standard I/O 做不到什麼 無法存取檔案 metadata(大小、建立時間);網路程式設計上有風險