0x07_檔案I/O
檔案描述符
核心用三個相關的資料結構來表示開啟的檔案:
- 描述符表。每個程序有它獨立的描述符表,它的表項是由程序開啟的檔案描述符來索引的。每個開啟的描述符指向檔案表中的一個表項。
- 檔案表。開啟檔案的集合是由一張檔案表來表示的,所有的程序共享這張表。每個檔案表的表項組成包括當前的檔案位置、引用計數、檔案狀態標誌(讀、寫、同步和非阻塞等),以及一個指向v-node表中對應表項的指標。關閉一個描述符會減少相應的檔案表表項中的引用計數,除非引用計數為零,否則核心不會刪除這個檔案表表項。
- v-node表。所有程序共享一張v-node表。每個表項包含stat結構中大多數資訊,包括檔案訪問、檔案大小、檔案型別和對此檔案進行各種操作函式的指標等。對於大多數檔案,v-node還包含了該檔案的i-node(索引節點)。這些資訊是在開啟檔案時從磁碟上讀入記憶體的。i-node包含了檔案的所有者、檔案長度、指向檔案實際資料塊在磁碟上所在位置的指標等。
描述符1和4通過不同的開啟檔案表表項來引用兩個不同的檔案,沒有共享檔案,而且每個描述符對應一個不同的檔案。
多個描述符可以通過不同的檔案表表項來引用同一個檔案。如果以同一個filename呼叫open函式兩次,就會發生這種情況。關鍵思想是每個描述符都有它自己的檔案位置,所以對不同描述符的讀操作可以從檔案的不同位置獲取資料。
子程序有一個父程序描述符表的副本,父子程序共享相同的開啟檔案表集合,因此共享相同的位置。一個很重要的結果就是,在核心刪除相應檔案表表項之前,父子程序必須都關閉了它們的描述符。
按照慣例,把檔案描述符0與程序的標準輸入關聯,1與標準輸出關聯,2與標準錯誤關聯。應該把它們替換成符號常量STDIN_FILENO、STDOUT_FILENO和STDERR_FILENO
<unistd.h>
中定義。
檔案描述符的變化範圍是0~OPEN_MAX-1。早起只有幾個,目前檔案描述符的變化範圍幾乎是無限的。
函式open和openat
#include <fcntl.h>
int open(const char *path, int oflag, ...);
int openat(int fd, const char *path, int oflag, ...);
若成功,返回檔案描述符;若出錯,返回-1。
僅當建立新檔案時才使用最後這個引數。path引數是要開啟或建立檔案的名字。oflag引數可用來說明此函式的多個選項。用下列一個或多個常量進行或運算構成oflag引數。
O_RDONLY 只讀開啟
O_WRONLY 只寫開啟
O_RDWR 讀、寫開啟
O_APPEND 每次寫時都追加到檔案的尾端
O_CREAT 若此檔案不存在則建立它。使用此選項時,open函式需同時說明第3個引數mode
O_EXCL 如果同時指定了O_CREAT,而檔案已經存在,則出錯,這使測試和建立兩者成為一個原子操作
O_SYNC 使每次write等待物理I/O操作完成,包括由該write操作引起的檔案屬性更新
O_TRUNC 如果此檔案存在,而且為只寫或讀寫成功開啟,則將其長度截斷為0
O_NONBLOCK 如果path引用的是一個裝置檔案/網路檔案等,則此選項為檔案的本次開啟和後續的I/O設定為非阻塞
由open和openat函式返回的檔案描述符一定是最小的未用描述符數值。若開啟失敗,返回-1。
fd引數把open和openat函式區分開:
- path引數指定的是絕對路徑名,在這種情況下,fd引數被忽略,openat函式等價於open函式。
- path引數指定的是相對路徑名,fd引數指出了相對路徑名在檔案系統中的開始地址。fd引數是通過開啟相對路徑名所在的目錄來獲取。
- path引數指定了相對路徑名,fd引數具有特殊值AT_FDCWD。在這種情況下,路徑名在當前工作目錄中獲取。
openat函式希望解決兩個問題:一,讓執行緒可以使用相對路徑名開啟目錄中的檔案,而不再只能開啟當前工作目錄。同一程序中的所有執行緒共享相同的當前工作目錄,因此很難讓同一程序的多個不同執行緒在同一時間工作在不同的目錄中。二,可以避免time-of-check-to-time-of-use(TOCTTOU)錯誤。
TOCTTOU錯誤的基本思想是:如果有兩個基於檔案的函式呼叫,其中第二個呼叫依賴於第一個呼叫的結果,那麼程式是脆弱的。因為兩個呼叫並不是原子操作,在兩個呼叫函式之間檔案可能改變了,這樣就造成了第一個呼叫的結果不再有效,使得程式的最終的結果是錯誤的。
函式close
可呼叫close函式關閉一個開啟檔案。
#include <unistd.h>
int close(int fd);
若成功,返回0;若出錯,返回-1。
關閉一個檔案時還會釋放該程序加在該檔案上的所有記錄鎖。當一個程序終止時,核心自動關閉它所有的開啟檔案。
函式lseek
每個開啟檔案都有一個與其關聯的當前檔案偏移量。它通常是一個非負整數,以度量從檔案開始處計算的位元組數。通常,讀、寫操作都從當前檔案偏移量處開始,並使偏移量增加所讀寫的位元組數。按系統預設的情況,當開啟一個檔案時,除非指定O_APPEND選項,否則該偏移量被設定為0。
可以呼叫lseek顯式地為一個開啟檔案設定偏移量。
#include <unistd.h>
off_t lseek(int fd, off_t offset, int whence);
若成功,返回新的檔案偏移量;若出錯,返回-1。
對引數offset的解釋與引數whence的值有關。
- 若whence是SEEK_SET,則將該檔案的偏移量設定為距檔案開始處offset個位元組。
- 若whence是SEEK_CUR,則將該檔案的偏移量設定為當前值加offset,offset可正可負。
- 若whence是SEEK_END,則將該檔案的偏移量設定為檔案長度加offset,offset可正可負。
若lseek成功執行,則返回新的相對檔案開始處的偏移量。
可使用如下方式確定開啟檔案的當前偏移量。
off_t currpos;
currpos = lseek(fd, 0, SEEK_CUR);
這種方法也可以用來確定所涉及的檔案是否可以設定偏移量,如果檔案描述符指向的是一個管道、FIFO或網路套接字,則lseek返回-1,並將errno設定為ESPIPE。
通常,檔案的當前偏移量應當是一個非負整數,但是,某些裝置也可能允許負的偏移量。但對於普通檔案,其偏移量必須是非負值。因為偏移量可能是負值,所以在比較lseek的返回值時不要測試是否小於0,而要測試是否等於-1。
lseek僅將當前的檔案偏移量記錄在核心中,它並不引起任何I/O操作。然後該偏移量用於下一個讀或寫操作。檔案偏移量可以大於檔案的當前長度,在這種情況下,對該檔案的下一個寫將加長該檔案,並在檔案中構成一個空洞。這是允許的,位於檔案中但沒有寫過的位元組都被讀為0。檔案中的空洞並不要求在磁碟上佔用儲存區。具體處理方式與檔案系統的實現有關,當定位到超出檔案尾端之後寫時,對於新寫的資料需要分配磁碟塊,但是對於原始檔尾端和新開始寫位置之間的部分則不需要分配磁碟塊,有無空洞的檔案,雖然長度相同,但無空洞的檔案佔用的磁碟塊較少。
od -tcx filename 檢視檔案的16進製表示形式
od -tcd filename 檢視檔案的10進製表示形式
函式truncate
#include <unsitd.h>
int truncate(const char *path, off_t length);
若成功,返回0;若失敗,返回-1。
使用此函式擴充套件檔案大小到一個指定的長度。
函式read
#include <unistd.h>
ssize_t read(int fd, void *buf, size_t nbytes);
返回讀到的位元組數,若已到檔案檔案尾,返回0;若出錯,返回-1。
如read成功,則返回讀到的位元組數。如已到達檔案的尾端,則返回0。
有多種情況可使實際讀到的位元組數小於要求讀的位元組數:
- 讀普通檔案時,在讀到要求位元組數之前已到達了檔案尾端。
- 當從終端裝置讀時,通常一次最多讀一行。
- 當從網路讀時,網路的緩衝機制可能造成返回值小於所要求讀的位元組數。
- 當從管道或FIFO讀時,若管道包含的位元組少於所需的數量,那麼read將只返回實際可用的位元組數。
- 當從某些面向記錄的裝置讀時,一次最多返回一個記錄。
- 當一訊號造成中斷,而已經讀了部分資料量時。
讀操作從檔案的當前偏移量處開始,在成功返回之前,該偏移量將增加實際讀到的位元組數。其次,返回值必須是一個帶符號整型(ssize_t),以保證能夠返回正整數字節數、0(表示檔案尾端)或-1(出錯)。
函式write
#include <unistd.h>
ssize_t write(int fd, const void *buf, size_t nbytes);
若成功,返回已寫的位元組數;若出錯,返回-1。
其返回值通常與引數nbytes的值相同,否則表示出錯。write出錯的一個常見原因是磁碟已寫滿,或者超過了一個給定程序的檔案長度限制。從某個偏移位置開始寫,如果此位置有資料,則會覆蓋。
I/O的效率
大多數檔案系統為改善效能都採用某種預讀計數。當檢測到正進行順序讀取時,系統就試圖讀入比應用所要求的更多資料,並假想應用很快就會讀這些資料。
可使用strace a.out
檢視程式執行時使用的系統呼叫。
使用fgetc/fputc時,底層並不是一次讀一個字元,而是使用一個緩衝區,預設4096(預讀入,緩輸出機制)。而如果read/write每次寫一個字元,就會持續進行核心態和使用者態的切換。
標準IO函式自帶使用者緩衝區,系統呼叫無使用者級緩衝區。兩種都有系統級緩衝區。
檔案共享
如果兩個獨立程序各自打開了同一個檔案,開啟該檔案的每個程序都獲得各自的一個檔案表項,但對一個給定的檔案只有一個v-node表項。之所以每個程序都獲得自己的檔案表項,是因為可以使得每個程序都有它自己的對該檔案的當前偏移量,對檔案的讀和寫使用同一偏移位置。
- 在完成每個write後,在檔案表項中的當前檔案偏移量即增加所寫入的位元組數。如果這導致當前檔案偏移量超出了當前檔案長度,則將i-node表項中的當前檔案長度設定為當前檔案偏移量(檔案加長)。
- 如果用O_APPEND標誌開啟一個檔案,則相應標誌也被設定到檔案表項的檔案狀態標誌中。每次對這種具有追加寫標誌的檔案執行寫操作時,檔案表項中的當前檔案偏移量首先會被設定為i-node中的檔案長度。這就使得每次寫入的資料都追加到檔案的當前尾端處。
- 若一個檔案用lseek定位到檔案當前的尾端,則檔案表項中的當前檔案偏移量被設定為i-node表項中的當前檔案長度(與用O_APPEND標誌開啟檔案是不同的)。
- lseek函式只修改檔案表項中的當前檔案偏移量,不進行任何I/O操作。
原子操作
追加到一個檔案
早期的open不支援O_APPEND選項,所以要追加到尾端被編寫成以下形式:
if (lseek(fd, OL, 2) < 0)
printf("lseek error");
if (write(fd, buf, 100) != 100)
printf("write error");
若有多個程序同時使用這種方法將資料追加寫到同一個檔案,則會產生問題。問題在於邏輯操作:先定位到檔案尾端,然後寫,它使用了兩個分開的函式呼叫。解決問題的方法是使這兩個操作對於其他程序而言成為一個原子操作。任何要求多於一個函式呼叫的操作都不是原子操作,在兩個函式呼叫之間,核心有可能會臨時掛起程序。
系統為這種操作提供了一種原子操作方法,即在開啟檔案時設定O_APPEND標誌。這樣做使得核心在每次寫操作之前,都將程序的當前偏移量設定到該檔案的尾端處,於是在每次寫之前就不再需要呼叫lseek。
函式pread和pwrite
#include <unistd.h>
ssize_t pread(int fd, void *buf, size_t nbytes, off_t offset);
返回讀到的位元組數,若讀到檔案尾返回0;若出錯,返回-1。
ssize_t pwrite(int fd, const void *buf, size_t nbytes, off_t offset);
返回已寫的位元組數;若出錯,返回-1。
呼叫pread相當於呼叫lseek後呼叫read,但是又有區別:
- 呼叫pread時,無法中斷其定位和讀操作。
- 不更新當前檔案偏移量。
建立一個檔案
如果在open和creat之間,另一個程序建立了該檔案,就會出現問題。若在這兩個函式呼叫之間,另一個程序建立了該檔案,並且寫入了一些資料,然後,原程序執行這段程式中的creat,這時,剛由另一程序寫入的資料就會被擦去。同時使用open函式的O_CREAT和O_EXCL選項,而該檔案又已經存在時,open將失敗。
函式dup和dup2
兩個函式都用來複制一個現有的檔案描述符:
#include <unistd.h>
int dup(int fd);
int dup2(int fd, int fd2);
由dup返回的新檔案描述符一定是當前可用檔案描述符中的最小數值。對於dup2,可以用fd2引數指定新描述符值,如果fd2已經開啟,則先將其關閉。如果fd等於fd2,則dup2返回fd2,而不關閉它,否則,fd2的FD_CLOEXEC檔案描述符標誌就被清除(這表示該描述符在exec時仍保持有效),這樣fd2在程序呼叫exec時是開啟狀態。
這些函式返回的新檔案描述符與引數fd共享同一個檔案表項。
兩個描述符指向同一個檔案表項,所以它們共享同一檔案狀態標誌(讀、寫、追加等)以及同一當前檔案偏移量。每個檔案描述符都有它自己的一套檔案描述符標誌,新描述符的執行時關閉(close-on-exec)標誌總是由dup函式清除。
函式sync、fsync和fdatsync
傳統的UNIX系統在核心中設有緩衝區快取記憶體或頁快取記憶體,大多數磁碟I/O都通過緩衝區進行。當向檔案寫入資料時,核心通常先將資料複製到緩衝區中,然後排入佇列,晚些再寫入磁碟,這種方式稱為延遲寫。
通常,當核心需要重用緩衝區來存放其他磁碟塊資料時,它會把所有延遲寫資料塊寫入磁碟。為了保證磁碟上實際檔案系統與緩衝區中內容的一致性,UNIX提供了三個函式。
#include <unistd.h>
int fsync(int fd);
int fdatasync(int fd);
void sync(void);
sync只是將所有修改過的塊緩衝區排入寫佇列,然後就返回,它並不等待實際寫磁碟操作結束。通常,稱為update的系統守護程序週期性地呼叫(一般30s)sync函式,這就保證了定期重新整理核心的塊緩衝區。命令sync也呼叫sync函式。
fsync函式只對由檔案描述符fd指定的一個檔案起作用,並且等待寫磁碟操作結束才返回。fsync可用於資料庫等應用程式,需要確保修改過的塊立即寫到磁碟上。
fdatasync函式類似於fsync,但它隻影響檔案的資料部分,而除資料外,fsync還會同步更新檔案的屬性。
函式fcntl
#include <fcntl.h>
int fcntl(int fd, int cmd, ...);
若成功,則依賴於mod;若出錯返回-1。
函式fcntl改變一個已經開啟的檔案的訪問控制屬性。
F_GETFL: 對應於fd的檔案狀態作為函式值返回。
F_SETFL: 將檔案狀態標誌設定為第三個引數的值。可以更改的幾個標誌是
O_APPEND O_NONBLOCK O_SYNC O_DSYNC O_RSYNC O_FSYNC O_ASYNC
int flags = fcntl(fd, F_GETFL);
flags |= O_NONBLOCK;
int ret = fcntl(fd, F_SETFL, flags);
對檔案加上O_SYNC屬性則開啟了同步寫標誌,就使每次write都要等待,直至資料已寫到磁碟上再返回。在UNIX系統中,通常write只是將資料排入佇列,而實際的寫磁碟操作則可能在以後的某個時刻進行。而資料庫系統則需要使用O_SYNC,這樣一來,當它從write返回時就知道資料已確實寫到了磁碟上。
fsync和fdatasync兩者都更新檔案內容,用了O_SYNC標誌,每次寫入檔案時都更新檔案內容。
fcntl的必要性:程式在一個描述符(標準輸出)上進行操作,但是不知道由shell開啟的相應檔案的檔名,因此不能在開啟時按要求設定O_SYNC標誌。使用fcnl,我們只需要知道開啟檔案的描述符,就可以修改描述符的屬性。另外,對於管道,我們所知的只有其描述符。
/dev/fd
較新的系統都提供名為/dev/fd的目錄,其目錄項是名為0、1、2等的檔案。開啟檔案/dev/fd/n等效於複製描述符n(假定描述符n是開啟的)。