文件概念對於Linux系統的重要性不言而喻,本章主要介紹了內核為文件的創建、讀、寫、定位等系統調用以及高效的I/O機制。Linux系統為文件操作提供了通用的系列系統調用,使開發人員能夠對所有“文件”做相同的操作,同時還提供了ioctl()
系統調用對非通用文件操作,fcntl()
系統調用對文件描述符做操作。
此外Linux內核為了彌補CPU運算速度和磁盤I/O速度的巨大差異,引入了頁緩存、頁回寫機制。簡單來說就是讀取文件時多讀取一部分並保存在內核緩沖區中,在下次收到讀取請求時直接將內存中的數據返回;寫入文件時只是在內核緩沖區中保存要寫入的數據,在合適的時機提交給磁盤(這樣在寫入之後的讀取請求是從內核緩沖區中獲取數據的,多個寫入請求合並為一次磁盤操作能夠提高效率)。當然,內核也提供了同步I/O機制來確保數據立即被寫入磁盤;直接I/O機制來確保數據不經由內核緩存,直接提交給磁盤。
文件描述符與偏移量
Linux系統調用一般通過文件描述符來指定對哪個文件進行操作,文件描述符與文件直接的關系在系統中由三個不同層次的表來維護。
* 進程級的文件描述符表(open file descriptor)
系統為每個進程維護了一個表,該表中每條記錄保存了控制文件描述符操作的一組標志(目前僅有一個close-on-exec標志——使用exec()系列函數加載程序時自動關閉)和打開的文件句柄的引用
* 系統級的打開文件表(open file table/open file descriptor)
每個打開的文件都在該表中有一條對應數據,表中每條記錄稱為文件句柄(open file handle),一個文件句柄對應了一個打開的文件的全部信息,包括:當前文件偏移量;打開文件時使用的標志(open()的flags參數);文件訪問模式(讀寫模式);與信號驅動I/O相關的設置;inode節點的引用。
* 文件系統級的inode表
inode表被文件系統保存,任何文件系統中的文件都擁有inode信息,一個inode節點包括:文件類型和訪問權限;指向文件持有的鎖的指針;文件的各種類型例如大小、時間戳等。
由此可見,文件描述符與打開的文件之間有如下關系:
1 文件的偏移量和文件句柄相關,和文件描述符無關,同一個文件句柄可能被不同的文件描述符或進程共享。每次調用open()
時內核都會分配一個文件句柄。
2 同一進程的不同文件描述符指向同一個文件句柄。這種情況可能是dup()
、dup()2
、fcntl()
等系統調用或重定向>
、>>
導致的,盡管文件描述符值不同,但其指向的句柄是同一個。此時不同文件描述符的文件偏移量是通用的,因為文件句柄是同一個。
3 不同進程的相同文件描述符指向同一個文件句柄。這種情況可能是打開文件描述符後調用了fork()
函數,此時文件描述符的文件偏移量是通用的,因為文件句柄是同一個(見2.1的例子)。
4 不同進程的不同文件描述符指向同一個文件句柄,這種情況是2和1的順序組合的結果,此時文件描述符的文件偏移量也是通用的。
5 同一個進程的文件描述符指向同一個文件(例如多次open()),這種情況下盡管一個進程多次打開同一個文件,資源描述符不相同,但是文件句柄也不相同,其文件偏移量必然不同:
#include
#include
#include
#include
using namespace std;
int main ()
{
//test.txt存在,內容為
//1234567890
int fd1 = open("test.txt", O_RDWR);
int fd2 = open("test.txt", O_RDWR);
cout << "fd1: " << fld1 << " fld2: " << fld2 << endl;//fld1:3 fld2:4
char arr1[2] = {0};
char arr2[2] = {0};
read(fld1, arr1, 1);//簡單起見,不再錯誤處理
read(fld2, arr2, 1);
cout << "arr1: " << arr1 << endl;//arr1:1,說明是從文件頭開始讀取的
cout << "arr2: " << arr2 << endl;//arr2:1,說明fd2的偏移量不與fd1共享
return 0;
}
/dev/fd目錄
內核為每個進程提供了一個/dev/fd/n
的虛擬路徑,鏈接到對應該進程打開的文件描述符,例如對每個進程來說,/dev/fd/0
代表標准輸入。在進程中打開該目錄,相當於復制了一個文件描述符,其指向同一個文件句柄。fd=open("/dev/fd/1")
等於fd=dup(1)
(dup()的作用見【重定向與復制文件描述符】)。在這種情況下open()傳遞的標准應該與對應的文件描述符一樣。
此外系統還提供了/dev/stdin
、/dev/stdout
、/dev/stderr
三個文件來方便引用。
一個例子來演示如何操作該目錄下的文件:
#include
#include
#include
#include
using namespace std;
int main ()
{
//從標准輸入讀取信息並輸出到文件test.txt中
//簡單起見不做錯誤處理
int fd1 = open("/dev/stdin", O_RDONLY);
int fd2 = open("test.txt", O_WRONLY|O_CREAT);
char buf[1024];
int iCount = read(fd1, buf, 1024);
if(iCount != -1)
write(fd2, buf, iCount);
return 0;
}
臨時文件
臨時文件是進程關閉時自動刪除的文件,glibc提供了類似
#include
int mkstemp(char* template);
FILE* tempfile();
的函數來幫助建立臨時文件。
* mkstemp
template參數是一個字符數組,表示文件的模板,後六個字符必須是”X”,庫函數會修改該值並在成功時返回文件描述符。例如char tempname[]="/temp/fileXXXXXX"
,在成功後會創建tempname的值會被修改並創建同名文件,進程所有者對其有讀寫權限。一般程序不再使用該臨時文件時可以使用unlink()
系統調用解除對該文件的引用,稍後在close()
時該文件由於引用數為0,會被自動刪除。當然,如果不顯示close()的話,在進程結束時系統自動調用close()。
#include
#include
#include
int main ()
{
char tempFile[]="/root/cpproot/test/tempfileXXXXXX";
int fd = mkstemp(tempFile);
if(fd == -1)
{
perror("mkstemp");
return -1;
}
unlink(tempFile);
char temp[32] = "hi?";
write(fd, temp, sizeof(temp));
lseek(fd, 0, SEEK_SET);
char temp2[32];
read(fd, temp2, sizeof(temp2));
printf("read : %s\n", temp2);
return 0;
}
tempfileunlink()系統調用作用是解除進程對文件描述符的引用(記得嗎,引用計數記錄在inode節點中),若在close(fd)時fd對應的文件引用數為0,系統會刪除該文件。在對文件進行操作時,unlink()行為與remove()行為一樣。
在對文件操作前需要打開文件,內核為每個進程維護了一個打開文件的列表。在打開文件後創建子進程,子進程會共享父進程打開的文件、當前文件位置等信息,子進程關閉文件不會對父進程有影響。
以下代碼驗證不同的進程中文件描述符是獨立的:
#include
#include
#include
int main ()
{
pid_t fpid;
fpid=fork();
if (fpid < 0)
printf("error in fork!");
//父進程返回子進程pid,子進程返回0
else if (fpid == 0) {
int fd1 = open("test.txt", O_RDWR|O_CREAT );
printf("child process fd %d\n",fd1 ); //3
}
else {
int fd1 = open("test.txt", O_RDWR|O_CREAT );
printf("parent process fd %d\n",fd1 ); //3
}
return 0;
}
可以看出對於不同的進程而言,每個進程的文件描述符都是私有的,內核會為每個進程重新分配文件描述符。
以下代碼驗證子進程共享父進程文件描述符和文件當前位置:
#include
#include
#include
int main ()
{
pid_t fpid;
int fd1 = open("test.txt", O_RDWR|O_CREAT );
fpid=fork();
if (fpid < 0)
printf("error in fork!");
//父進程返回子進程pid,子進程返回0
else if (fpid == 0)
{
write(fd1, "123", 3);
}
else
{
write(fd1, "abc", 3);
}
return 0; //文件最後是6個字符,如果父子進程分別向0位置寫的話,會有數據被覆蓋
}
可以看出在fork()之後,子進程會共享父進程的文件描述符和文件位置(即共享同一個文件句柄),相對的測試如下:
#include
#include
#include
int main ()
{
pid_t fpid;
fpid=fork();
if (fpid < 0)
printf("error in fork!");
//父進程返回子進程pid,子進程返回0
else if (fpid == 0)
{
int fd1 = open("test.txt", O_RDWR|O_CREAT );
write(fd1, "123", 3);
}
else
{
int fd1 = open("test.txt", O_RDWR|O_CREAT );
write(fd1, "abc", 3);
}
return 0; //文件最後是3個字符,因為父子進程都是從文件頭開始寫的,有一個進程寫的內容會被另一個覆蓋
}
系統調用open()打開文件並返回系統分配的文件描述符,錯誤時返回-1並設置errno,錯誤碼取值可以通過man 2 open
查看。
#include
int open(const char *pathname, int flags);
int open(const char *pathname, int flags, mode_t mode );
pathname-文件路徑
flags-讀寫模式,有O_RDONLY、O_WRONLY、O_RDWR三種,同時可以和以下至少一個選項做或運算:
mode-新創建文件時用於指定權限,在創建文件時不指定權限會產生未定義的行為。權限在第一次創建文件時不會檢查,因此第一次打開文件時權限是無效的。POSIX規定了以下參數可以用或運算同時指定:
由於O_WRONLY|O_CREAT|O_TRUNC組合非常常見,有專門的函數來實現相同功能(某些架構上可能沒有該系統調用,使用的是庫函數):
#include
int creat(const char* name, mode_t mode);
返回值與open()一樣。
read()是系統調用,用於從fd代表的文件中讀取最多len個字節到buf中,正確時返回讀取的字節數,失敗時返回-1並設置errno,只讀到一個文件結束符(EOF)時返回0。
#include
ssize_t read (int fd, void *buf, size_t len);
每次read()成功後內核保存的文件位置指針會向前移動len個字節長度。這裡的位置是內核記錄的位置,與庫調用時的位置不一樣——庫函數例如fgetc()
,可能每次從內核讀取1024個字符,但是每次只返回一個字符,此時內核的位置應該是1024而不是1。
以下代碼驗證對於一個文件描述符,不同進程共享同一個文件位置:
#include
#include
#include
int main ()
{
pid_t fpid;
//假設test.txt存在且內容為"abcd",父子進程讀取出來的是a和b而不是a和a
int fd1 = open("test.txt", O_RDWR );
fpid=fork();
if (fpid < 0)
printf("error in fork!");
//父進程返回子進程pid,子進程返回0
else if (fpid == 0)
{
char temp[1024] = {0};
read(fd1, temp, 1);
printf("child read:%s", temp);
}
else
{
char temp[1024] = {0};
read(fd1, temp, 1);
printf("parent read:%s", temp);
}
return 0;
}
當文件沒有數據可讀或未讀完len個字節時(並且沒讀取到EOF,例如網絡通信時)read()調用會阻塞,直到讀滿。
read()可能會有如下返回值對應的場景:
返回len,說明需要的數據長度全部讀取到內存中
返回小於len,說明讀取過程中有信號大端、讀取時發生了錯誤、讀取到EOF,再次讀取時將剩余數據讀到內存中
返回0,讀到EOF
調用阻塞,等待可讀的數據到來
調用返回-1,errno為EINTR,表示讀取之前收到信號,可以再次調用
調用返回-1,errno為EAGAIN,表示沒有可用數據,在非阻塞模式下發生
調用返回-1,errno為其他值,表示發生的其他嚴重錯誤
一個比較全面的代碼是這樣的:
ssize_t ret;
while (len != 0 && (ret = read (fd, buf, len)) != 0)
{
if (ret == -1) {
if (errno == EINTR)
continue;
perror (”read”);
break;
}
len -= ret;
buf += ret;
}
函數在讀取到len個字節或遇到EOF之前會一直嘗試從fd中讀取,在出現中斷時再次讀取,在出現錯誤時打印錯誤信息並退出循環。
read()系統調用在等待數據時會阻塞,在有些情況下我們不希望等待數據時阻塞,而是做一些其他操作——例如有多個文件要讀,阻塞在一個上面不如看看其他文件有沒有數據(當然有更好的辦法,見I/O多路復用);或者有大量數據要在後面的代碼中處理,而且數據不依賴於從文件中讀取的內容,此時可以使用非阻塞I/O模式。在沒有數據可讀時read()也立即返回-1,並將errno設置為EAGAIN。進入非阻塞I/O模式需要在open()時指定O_NONBLOCK參數。
POSIX規定了size_t和ssize_t類型表示占用內存的大小,ssize_t是有符號版的size_t,32位操作系統上是int,64位系統是long int。size_t最大值是SIZE_MAX,ssize_t則為SSIZE_MAX。若len大於SSZIE_MAX,read()調用結果是未定義的。
write()也是系統調用,表示從fd代表的當前位置讀取最多count個字符到buf中。讀取成功時返回真實的讀取字符數,否則返回-1且設置errno。當count比SSIZE_MAX還大,調用的結果未定義。
#include
ssize_t write (int fd, const void *buf, size_t count);
write()不太可能返回一個小於count的數,對於普通文件,發生部分寫時很可能出現了錯誤。因此對於普通文件,出現部分寫時不需要通過循環保證所有字符都寫入文件。但對於socket等特殊文件來說,最好使用循環來保證全部寫入文件。
ssize_t ret, nr;
while (len != 0 && (ret = write (fd, buf, len)) != 0)
{
if (ret == -1)
{
if (errno == EINTR)
continue;
perror (”write”);
break;
}
len -= ret;
buf += ret;
}
文件在open()時如果指定O_APPEND參數,則寫操作每次都從文件末尾開始。例如有多個進程向同一個文件寫入,追加模式保證了每個進程都不會覆蓋其他進程寫入的數據,因為系統能夠保證每次寫入都從尾部開始,對日志功能非常有用。
文件在open()時如果指定了O_NONBLOCK參數,則write()會正常阻塞時,調用會立即返回-1並設置errno為EAGAIN,普通文件不會出現這種情況。
write()操作會產生的錯誤碼還有:
由於硬盤I/O速度與CPU處理速度相差較大,在程序執行時等待數據真正寫入硬盤對程序性能有較大影響,因此內核提供了內核緩沖區用於存放要寫入硬盤的數據。write()操作在數據從用戶空間拷貝到內核空間時即返回,內核會確保在某個合適的時機將數據寫入硬盤。
這種延遲寫入的方式不會改變交替讀寫的結果:當需要read()剛剛寫入硬盤的數據時,若此時數據在內核緩沖區中還未寫入硬盤,讀取的將是緩沖區的數據,減少了讀取硬盤的次數。
為了保證及時將輸入寫入硬盤,在/proc/sys/vm/dirty_expire_centiseconds
中配置了最大時效,內核會在超出最大時效之前將數據寫入硬盤。內核緩沖區
系統變量類型是指一些系統實現細節相關的變量類型typedef成不暴露實現細節的變量類型,從而保證跨平台時源碼級別的兼容性。例如對於進程id,其系統變量類型是pid_t
,常見的還有size_t
、socklen_t
等,在sys/types.h
中定義。
32位Linux上,文件偏移量類型off_t
類型為int,32位,即能夠尋址的文件最大為2G。若想實現32位系統上對超過2G的文件尋址,需要將_FILE_OEFFSET_BISTS
宏設置為64,可以在包含其他頭文件之前#define _FILE_OEFFSET_BISTS 64
或在makefile中添加-D_FILE_OEFFSET_BISTS=64
,編譯時根據該條件會再次將off_t類型typedef成__off64_t
,這個變量是64位長度的。通過這個宏,在不修改源碼的前提下將只支持最大2G操作的源碼擴展成支持最大2^63-1大小。
除此之外還能使用過度型API來指明對大文件讀寫,包括open64()
、lseek64()
等。更推薦的是在32位Linux上使用_FILE_OEFFSET_BISTS宏。此外使用宏定義的方式對大文件做支持時要注意,所有與文件讀寫相關的模塊在編譯時都要使用該宏,避免類型不一致導致的問題。
64位Linux的off_t長度默認就是64位的,因此不需要定義上述的宏。