System-Level I/O

輸入/輸出(I/O)是指在記憶體與外部設備之間複製數據的過程。

  • 輸入(Input):將數據從 I/O 設備讀取到主記憶體中。
  • 輸出(Output):將數據從記憶體寫入 I/O 設備。

所有程式語言的 Run-time System 都提供 高階的 I/O 介面 來簡化 I/O 操作。例如:

  • ANSI C 提供標準 I/O 函式庫,包含 printf 和 scanf 等函式,這些函式使用 緩衝 I/O(buffered I/O) 來提高效能。
  • C++ 提供類似功能,透過 <<(put to)和 >>(get from)運算子 來操作輸入輸出。
  • 在 Unix 系統,這些高階 I/O 函式最終是透過系統核心(Kernel)提供的低階 Unix I/O 函式來實作的。

10.1 Unix I/O

在 Unix 系統中,檔案(File) 是由 m 個位元組(bytes) 組成的序列,
Unix 將所有 I/O 設備都視為檔案,所有的輸入與輸出都是透過 Read 與 Write 檔案來完成的。
這種將設備映射為檔案的設計,讓稱為 Unix I/O。

Unix I/O 的基本操作

開啟檔案(Opening Files)
當需要存取 I/O 設備時,必須請求 kernel 開啟對應的檔案。核心會回傳一個非負整數,稱為 File Descriptor,用來識別該檔案,後續的操作都透過此描述符來進行。

kernel 會維護所有開啟檔案的資訊,而應用程式只需管理檔案描述符。

在 Unix Shell 中,每個新建立的 Process 都會有三個預設開啟的檔案:

  • 標準輸入(Standard Input):描述符 0
  • 標準輸出(Standard Output):描述符 1
  • 標準錯誤(Standard Error):描述符 2

標頭檔 <unistd.h> 定義了對應的常數:

  • STDIN_FILENO(標準輸入)
  • STDOUT_FILENO(標準輸出)
  • STDERR_FILENO(標準錯誤)

改變檔案當前位置

檔案位置是 kernel 為每個開啟的檔案維護的一個指標 k,預設為 0,代表檔案的開頭。
應用程式可以透過 seek 操作來變更當前的檔案位置。

讀取與寫入檔案

讀取

  • 讀取 n > 0 個位元組從檔案複製到記憶體,從當前檔案位置 k 開始,然後將 k 增加 n。
  • 若檔案大小為 m,當 k ≥ m 時,會觸發 EOF (End-of-File)。
  • Unix 檔案沒有明確的 EOF 字元,應用程式需自行偵測 EOF。

寫入

  • 寫入 n > 0 個位元組 從記憶體複製到檔案,從當前檔案位置 k 開始,然後更新 k。

關閉檔案

  • 當應用程式完成對檔案的操作時,它需要請求 kernel 關閉檔案。
  • 核心會釋放該檔案的記憶體資源並將檔案描述符歸還給系統以供未來使用。

10.2 Opening and Closing Files

在 UNIX 系統中,process 可以透過 open() 系統呼叫來開啟現有的文件或建立新的文件。
在 UNIX 中,open() 函式會將檔名轉換為文件描述符,並回傳該描述符數字。
回傳的文件描述符一定是目前尚未被使用的最小數字。

int fd = open(const char *pathname, int flags, mode_t mode);

pathname

要開啟的檔案名稱(包含路徑)

flags

指定開啟檔案的模式(讀取、寫入、創建等)

mode(可選)

如果使用 O_CREAT,則需要設定新建文件的權限(如 0644)

open() 回傳值

  • 成功:回傳文件描述符(FD),最小未被使用的描述符數字
  • 失敗:回傳 -1,並設定 errno

umask:限制新檔案的預設權限
每個 Process 都有一個 umask 設定,它決定「新建檔案時」哪些權限會被取消。

10.3 Reading and Writing Files

在 Unix 系統中,應用程式透過 read 和 write 這兩個函式來執行輸入(讀取)與輸出(寫入)操作。

read(fd, buf, n)

從檔案描述符當前位置讀取最多 n 個位元組到緩衝區 buf。

回傳值

  • -1:發生錯誤
  • 0:遇到 EOF(檔案結尾)
  • 回傳實際讀取的位元組數量

write(fd, buf, n)

將緩衝區 buf 內最多 n 個位元組寫入檔案描述符的當前位置。

回傳值

  • -1:發生錯誤
  • 回傳實際讀取的位元組數量

補充

應用程式可以透過 lseek 函數來明確修改目前的檔案位置,但這部分超出了範圍。

ssize_t 和 size_t 有什麼不同?
你可能注意到 read 函數的輸入參數是 size_t,而返回值則是 ssize_t。那麼這兩種型別有什麼區別呢?

size_t:無符號整數(unsigned int),用來表示大小或計數。
ssize_t:有符號整數(signed int),用來表示可能會返回錯誤的大小。

read 函數使用 ssize_t 作為返回值,因為當發生錯誤時,它必須返回 -1。有趣的是,因為 ssize_t 需要保留一個負值(-1)來表示錯誤,這導致 read 最大可讀取的數據量減少了一半,從 4GB 降到 2GB。
(因為 read 假如成功是要回傳實際讀取的位元組數量)

read 或 write 可能傳輸的字節數比請求的少(叫短讀 short read 或短寫short write

  • 假設我們的檔案從當前位置到結尾還剩 20 個字節,但我們用 read 嘗試讀取 50 個字節,此時 read 會返回 20,表示只讀到了 20 個字節。下一次 read 會返回 0,表示已達到 EOF(檔案結尾)
  • 從鍵盤讀取輸入時,每次 read 會讀取一整行文本。
  • 在網路或 pipe 中,由於緩衝區或延遲,read / write 可能無法一次性完成所有數據傳輸,導致短讀(short read)或短寫(short write)。
    例子 1:從網路 socket 讀取資料
    • 假設我們的伺服器預期從 socket 接收 1000 個字節的資料,但因為網路的緩衝區大小限制,read() 可能只讀取到部分數據(例如 512 個字節),需要多次 read() 才能讀完全部數據。
  • 讀寫普通的磁碟文件時,一般不會遇到短讀/短寫,除了讀取時遇到 EOF(文件結束)。

注:如果開發可靠的網路應用,應該用迴圈來確保所有數據都被完整讀取或寫入。

10.4 Robust Reading and Writing with the RIO Package

本節介紹 RIO(Robust I/O) 的 I/O 套件,它能夠自動處理短讀(short count)的問題。RIO 套件專為處理可能發生短讀的應用場景(例如網路程式)而設計。

RIO 提供了兩種類型的函式:

  1. 無緩衝(Unbuffered)輸入與輸出函式
    這些函式直接在記憶體與檔案之間傳輸數據,不使用應用層級的緩衝區(應用程式內部使用的記憶體區塊)。
    它們特別適用於讀寫二進位數據(binary data),例如網路通訊時的資料傳輸。(當你使用 read() 和 write() 來處理數據時,它們不會對數據進行轉換、解析、格式化等操作,而是原封不動地讀取或寫入數據。)
    fread() 和 fwrite() 可能會對換行符(\n)進行轉換,尤其是在 Windows 上(會把 \n 轉換成 \r\n)。
  2. 有緩衝(Buffered)輸入函式
    這些函式高效地從檔案讀取文字行和二進位數據,並使用應用層級的緩衝區來提升效能,類似於標準 I/O 函式(如 printf)提供的緩衝機制。
    與一般的標準 I/O 不同,RIO 的有緩衝輸入函式是執行緒安全(thread-safe) 的 ,並且可以在同一個描述符(descriptor)上交錯讀取不同類型的數據,例如你可以先從一個描述符讀取文字行再讀取一些二進位數據接著再讀取更多文字行。
    補充在多執行緒環境中,如果多個執行緒同時讀取同一個檔案描述符(file descriptor),可能會發生競爭條件(race condition)

10.4.1 RIO Unbuffered Input and Output Functions

應用程式可以透過調用 rio_readnrio_writen 函式,在記憶體與檔案之間直接傳輸數據。

ssize_t rio_readn(int fd, void *usrbuf, size_t n);
ssize_t rio_writen(int fd, void *usrbuf, size_t n);

rio_readn 和 rio_writen 的功能:

rio_readn:從檔案描述符 (fd) 讀取最多 n 個位元組到記憶體 (usrbuf)。
rio_writen:將 n 個位元組從記憶體 (usrbuf) 寫入檔案描述符 (fd)。

短讀與短寫的行為:

rio_readn 可能發生短讀(short read),但只會發生在遇到 EOF 時。
rio_writen 不會發生短寫(short write),它會確保所有資料都被寫入。

可交錯執行:

rio_readn 和 rio_writen 可以在同一個文件描述符上任意交錯執行,不會互相影響。

中斷處理與可攜性:

這些函式會自動重新啟動 read 和 write,如果它們因應用程式的訊號處理(signal handler)被中斷。
這種設計確保函式在不同的 Unix 系統上具有良好的可攜性(portability)。

10.4.2 RIO Buffered Input Functions

這段的重點是透過封裝函式(如 rio_readlineb)來提升讀取文字行的效率,避免使用 read() 每次讀取 1 個字元導致效能低落。此外,對於包含文字行和二進位數據還提供了 rio_readn 的有緩衝版本,稱為 rio_readnb,它提供了一種適合處理混合文本與二進位數據(如 HTTP 回應)的高效讀取方式,並透過緩衝區機制減少系統呼叫次數。
下列為他所定義的結構:

 #define RIO_BUFSIZE 8192
 typedef struct {
     int rio_fd; 
     int rio_cnt; 
     char *rio_bufptr; /* next unread byte in internal buf */
     char rio_buf[RIO_BUFSIZE]; /* internal buffer */
 } rio_t;

rio_fd : 為該檔案的文件表示符
rio_cnt : 在internal buf中還有幾個尚未讀取的位元
rio_bufptr: 指向 internal buf 中還未讀取的位元起始位置
rio_buf[RIO_BUFSIZE]: internal buffer

rio_readinitb
rio_readinitb 函式會在每個開啟的描述符上呼叫一次。它將描述符 fd 與位於 rp 地址的 rio_t 型態的讀取緩衝區關聯起來。

void rio_readinitb(rio_t *rp, int fd)
{
    rp->rio_fd = fd;
    rp->rio_cnt = 0;
    rp->rio_bufptr = rp->rio_buf;
}

在介紹 rio_readlineb 和 rio_readnb 之前需要先了解到 rio_read 函數是作什麼用的,因為 rio_readlineb 和 rio_readnb 皆為封裝函數其中就包涵 rio_read 這個函數。
簡單來說 rio_read 這個函數會去把你所指定的文件描述符也就是( rio_fd )的資料去讀到 rio_buf 中,只是這個程式可能會有上述所說的短讀(short read)的問題,因此我們需要使用封裝函數 rio_readlineb 和 rio_readnb 來去解決。

rio_readlineb

ssize t rio readlineb(rio t *rp, void *usrbuf, size_t maxlen);

rio_readlineb 函式的話會從 rp 指向的檔案中讀取下一行文字(包含結尾的換行字元 \n),將其複製到記憶體位置 usrbuf,並在該行的最後加上空字元(null 字元,值為 0)。
rio_readlineb 函式最多會讀取 maxlen-1 個 bytes,並預留 1 個位元組來存放字串結尾的 null (\0) 字元。

如果讀取的文字行超過 maxlen-1 個位元組,則會截斷並在最後加上 null 字元。
特點確保讀取整行:不會發生部分行被截斷的問題(除非超過 maxlen)

ssize t rio readnb(rio t *rp, void *usrbuf, size_t n);

rio_readnb 函式則是最多讀取 n 個位元組,將其從文件 rp 複製到記憶體位置 usrbuf 且保證完整讀取只要沒有錯誤,就會嘗試讀取完整的 n 個字節。
rio_readlineb 和 rio_readnb 這兩個函式可以交錯使用(interleave)在同一個文件描述符(descriptor)上,但不能與無緩衝(unbuffered) 的 rio_readn 交錯使用。