2. C標準I/O庫函數與Unbuffered I/O函數

現在看看C標準I/O庫函數是如何用系統調用實現的。

fopen(3)

調用open(2)打開指定的檔案,返回一個檔案描述符(就是一個int類型的編號),分配一個FILE結構體,其中包含該檔案的描述符、I/O緩衝區和當前讀寫位置等信息,返回這個FILE結構體的地址。

fgetc(3)

通過傳入的FILE *參數找到該檔案的描述符、I/O緩衝區和當前讀寫位置,判斷能否從I/O緩衝區中讀到下一個字元,如果能讀到就直接返回該字元,否則調用read(2),把檔案描述符傳進去,讓內核讀取該檔案的數據到I/O緩衝區,然後返回下一個字元。注意,對於C標準I/O庫來說,打開的檔案由FILE *指針標識,而對於內核來說,打開的檔案由檔案描述符標識,檔案描述符從open系統調用獲得,在使用readwriteclose系統調用時都需要傳檔案描述符。

fputc(3)

判斷該檔案的I/O緩衝區是否有空間再存放一個字元,如果有空間則直接保存在I/O緩衝區中並返回,如果I/O緩衝區已滿就調用write(2),讓內核把I/O緩衝區的內容寫回檔案。

fclose(3)

如果I/O緩衝區中還有數據沒寫回檔案,就調用write(2)寫回檔案,然後調用close(2)關閉檔案,釋放FILE結構體和I/O緩衝區。

以寫檔案為例,C標準I/O庫函數(printf(3)putchar(3)fputs(3))與系統調用write(2)的關係如下圖所示。

圖 28.1. 庫函數與系統調用的層次關係

庫函數與系統調用的層次關係

openreadwriteclose等系統函數稱為無緩衝I/O(Unbuffered I/O)函數,因為它們位於C標準庫的I/O緩衝區的底層[36]。用戶程序在讀寫檔案時既可以調用C標準I/O庫函數,也可以直接調用底層的Unbuffered I/O函數,那麼用哪一組函數好呢?

C標準庫函數是C標準的一部分,而Unbuffered I/O函數是UNIX標準的一部分,在所有支持C語言的平台上應該都可以用C標準庫函數(除了有些平台的C編譯器沒有完全符合C標準之外),而只有在UNIX平台上才能使用Unbuffered I/O函數,所以C標準I/O庫函數在標頭檔stdio.h中聲明,而readwrite等函數在標頭檔unistd.h中聲明。在支持C語言的非UNIX操作系統上,標準I/O庫的底層可能由另外一組系統函數支持,例如Windows系統的底層是Win32 API,其中讀寫檔案的系統函數是ReadFileWriteFile

關於UNIX標準

POSIX(Portable Operating System Interface)是由IEEE制定的標準,致力於統一各種UNIX系統的介面,促進各種UNIX系統向互相兼容的發向發展。IEEE 1003.1(也稱為POSIX.1)定義了UNIX系統的函數介面,既包括C標準庫函數,也包括系統調用和其它UNIX庫函數。POSIX.1隻定義介面而不定義實現,所以並不區分一個函數是庫函數還是系統調用,至于哪些函數在用戶空間實現,哪些函數在內核中實現,由操作系統的開發者決定,各種UNIX系統都不太一樣。IEEE 1003.2定義了Shell的語法和各種基本命令的選項等。本書的第三部分不僅講解基本的系統函數介面,也順帶講解Shell、基本命令、帳號和權限以及系統管理的基礎知識,這些內容合在一起定義了UNIX系統的基本特性。

在UNIX的發展歷史上主要分成BSD和SYSV兩個派系,各自實現了很多不同的介面,比如BSD的網絡編程介面是socket,而SYSV的網絡編程介面是基于STREAMS的TLI。POSIX在統一介面的過程中,有些介面借鑒BSD的,有些介面借鑒SYSV的,還有些介面既不是來自BSD也不是來自SYSV,而是憑空發明出來的(例如本書要講的pthread庫就屬於這種情況),通過Man Page的COMFORMING TO部分可以看出來一個函數介面屬於哪種情況。Linux的原始碼是完全從頭編寫的,並不繼承BSD或SYSV的原始碼,沒有歷史的包袱,所以能比較好地遵照POSIX標準實現,既有BSD的特性也有SYSV的特性,此外還有一些Linux特有的特性,比如epoll(7),依賴于這些介面的應用程序是不可移植的,但在Linux系統上運行效率很高。

POSIX定義的介面有些規定是必須實現的,而另外一些是可以選擇實現的。有些非UNIX系統也實現了POSIX中必須實現的部分,那麼也可以聲稱自己是POSIX兼容的,然而要想聲稱自己是UNIX,還必須要實現一部分在POSIX中規定為可選實現的介面,這由另外一個標準SUS(Single UNIX Specification)規定。SUS是POSIX的超集,一部分在POSIX中規定為可選實現的介面在SUS中規定為必須實現,完整實現了這些介面的系統稱為XSI(X/Open System Interface)兼容的。SUS標準由The Open Group維護,該組織擁有UNIX的註冊商標(http://www.unix.org/),XSI兼容的系統可以從該組織獲得授權使用UNIX這個商標。

現在該說說檔案描述符了。每個進程在Linux內核中都有一個task_struct結構體來維護進程相關的信息,稱為進程描述符(Process Descriptor),而在操作系統理論中稱為進程控制塊(PCB,Process Control Block)task_struct中有一個指針指向files_struct結構體,稱為檔案描述符表,其中每個表項包含一個指向已打開的檔案的指針,如下圖所示。

圖 28.2. 檔案描述符表

檔案描述符表

至于已打開的檔案在內核中用什麼結構體表示,我們將在下一章詳細介紹,目前我們在畫圖時用一個圈表示。用戶程序不能直接訪問內核中的檔案描述符表,而只能使用檔案描述符表的索引(即0、1、2、3這些數字),這些索引就稱為檔案描述符(File Descriptor),用int型變數保存。當調用open打開一個檔案或創建一個新檔案時,內核分配一個檔案描述符並返回給用戶程序,該檔案描述符表項中的指針指向新打開的檔案。當讀寫檔案時,用戶程序把檔案描述符傳給readwrite,內核根據檔案描述符找到相應的表項,再通過表項中的指針找到相應的檔案。

我們知道,程序啟動時會自動打開三個檔案:標準輸入、標準輸出和標准錯誤輸出。在C標準庫中分別用FILE *指針stdinstdoutstderr表示。這三個檔案的描述符分別是0、1、2,保存在相應的FILE結構體中。標頭檔unistd.h中有如下的宏定義來表示這三個檔案描述符:

#define STDIN_FILENO 0
#define STDOUT_FILENO 1
#define STDERR_FILENO 2


[36] 事實上Unbuffered I/O這個名詞是有些誤導的,雖然write系統調用位於C標準庫I/O緩衝區的底層,但在write的底層也可以分配一個內核I/O緩衝區,所以write也不一定是直接寫到檔案的,也可能寫到內核I/O緩衝區中,至于究竟寫到了檔案中還是內核緩衝區中對於進程來說是沒有差別的,如果進程A和進程B打開同一檔案,進程A寫到內核I/O緩衝區中的數據從進程B也能讀到,而C標準庫的I/O緩衝區則不具有這一特性(想一想為什麼)。