背景
作為linux 開發者,我們不可避免會接觸到文件編程。比如通過文件記錄程序配置參數,通過字符設備與外設進行通信。因此作為合格的linux開發者,一定要熟練掌握文件編程。在文件編程中,我們一般會有兩類接口函數:標準I/O(帶緩沖)和POXIS.1 I/O(不帶緩沖)。本章節主要介紹不帶緩沖的相關API及注意事項。
open 接口
open
函數的作用是打開或創建一個文件。其聲明如下:
int open(const char* path, int oflag, .../*mode_t mode*/);
參數解析:
- path :是打開或需要創建的文件名稱;
- oflag : 設置打開文件的權限,該參數取值范圍較廣。并且需要區分。大致可以分為兩類:
- 權限類型標識。需要關注的有O_RDONLY(只讀權限)、O_WRONLY(只寫權限)、O_RDWR(可讀寫權限)。這三個標識位必須指定一個且只能指定一個。(O_ECEC(只執行)和O_SEARCH(只搜索)已被移除)。
- 特性類標識。這些標識可多選,常見的有如下:
標識常量 | 含義 |
---|---|
O_APPEND | 每次寫都追加到文件的尾端。即使你顯式的調用lseek改變文件當前偏移量,但是在write 時,依然會追加到文件末尾 |
O_CREAT | 若文件不存在則創建它。 |
O_EXCL | 如果同時指定了O_CREAT,而文件已經存在,則出錯。經常用于判斷文件是否存在,與access() 函數功能類似。 |
O_NOBLOCK | 如果path引用的是一個FIFO、塊特殊文件、字符特殊文件。那么本文件描述符后續的I/O操作,都設置為非阻塞方式。 |
O_SYNC | 每次write 都會等待物理I/O操作完成,包括文件屬性更新。 在linux ext4 系統中,該標識可能不生效 |
O_TRUNC | 如果文件存在,且以只寫或讀寫權限打開。則將長度截斷為0。常見的業務場景就是更新配置文件。 |
- mode 可選參數。只有當oflag參數中,具備O_CREAT屬性時,才需要指定新創建的文件權限。
知識點: open 函數返回的文件描述符一定時最小的未用描述符數值。
基于上述知識點,經常會被用來重定向程序的標準輸入、標準輸出或標準錯誤輸出。
場景如下:有一個封裝庫內部是通過采用的是printf
進行日志打印,無法體現在我們日志系統中。我們如何觀察其日志輸出呢?常見做法如下:
#include<stdlib.h>
#include<stdio.h>
int main()
{ printf("hello world\n");return 0;
}
默認情況下,日志輸出到終端:
xieyihua@xieyihua:~/test$ gcc 1.c -o 1
xieyihua@xieyihua:~/test$ ./1
hello world
xieyihua@xieyihua:~/test$
可以做以下修改:
#include<stdlib.h>
#include<stdio.h>
#include<fcntl.h>
#include<unistd.h>
int main()
{close(1); /* 關閉 文件描述符1'*/open("./log",O_WRONLY|O_CREAT,0755);/* 此時文件描述符1 與 ./log文件綁定*/printf("hello world\n");return 0;
}
輸出如下:
xieyihua@xieyihua:~/test$ gcc 1.c -o 1
xieyihua@xieyihua:~/test$ ./1
xieyihua@xieyihua:~/test$ cat log
hello world
xieyihua@xieyihua:~/test$
creat 接口
creat
函數主要用于創建一個新文件。函數原型聲明如下:
#include <fcntl.h>
int creat(const char* path, mode_t mode);
其等效于:open(path,O_WRONLY|O_CREAT|O_TRUNC,mode);
但是由于creat
只能以只寫方式打開文件,使用場景便較少,漸漸被‘冷落’了。
close 接口
close
函數關閉一個打開文件。其函數原型聲明如下:
#include<unistd.h>
int close(int fd);
知識點: 當一個進程終止時,內核會自動關閉它所有的打開文件。很多程序都利用了這一功能而不顯示調用
close
。
lseek 接口
每個打開文件都有一個與其相關的“當前文件偏移量”。它通常是一個非負整數(/dev/kmem/
支持負的偏移量),用于度量從文件開始處計算的字節數。通常讀、寫操作都是從當前文件偏移處開始的,并使偏移量增加所讀寫的字節數。
lseek
接口就可以顯式的為一個打開的文件設置偏移量。其函數原型聲明如下:
#include <unistd.h>
off_t lseek(int fd,off_t offset,int whence);
- fd, 文件描述符
offset
,其含義與whence
的值相關。
- 若
whence
是SEEK_SET
,則將該文件的偏移量設置為距文件開始處的offset
個字節。 - 若
whence
是SEEK_CUR
,則將該文件的偏移量設置為當前值加上offset
個字節,offset
可為正或負。 - 若
whence
是SEEK_END
,則將該文件的偏移量設置為文件長度加上offset
個字節,offset
可為正或負。
知識點: 系統默認情況下,當打開一個文件時,除非指定
O_APPEND
選項,否則該偏移量被設置為0。
lseek 僅是將當前的文件偏移量記錄在內核中,并不引起任何I/O操作,其目的是用于接下來的讀寫操作。
空洞文件
文件偏移量可以被設置為大于文件當前長度,在這種情況下,對該文件的下一次寫將加長該文件,并在文件中構成一個空洞。位于文件中沒有寫過的字節都被讀為0。并且文件中的空洞并不要求在磁盤上占用存儲區。示例代碼如下:
#include<fcntl.h>
#include<unistd.h>
#include<stdlib.h>
#include<stdio.h>int main()
{int fd;char* buf1 = "123456789a";char* buf2 = "abcdefghij";if((fd = creat("file.hole",0755)) < 0){printf("creat error\n");return -1;}printf("fd = %d\n",fd);if(write(fd,buf1,10) != 10){printf("write buf1 error\n");}if(lseek(fd,16384,SEEK_SET) == -1){printf("lseek error\n");}if(write(fd,buf2,10) != 10){printf("write buf1 error\n");}return 0;
}
結果如下:
/*文件長度有16394 Byte*/
xieyihua@xieyihua:~/test$ ls -la file.hole
-rwxr-xr-x 1 xieyihua xieyihua 16394 Jul 4 01:58 file.hole/*文本的實際內容也只有兩個字符串*/
xieyihua@xieyihua:~/test$ od -c file.hole
0000000 1 2 3 4 5 6 7 8 9 a \0 \0 \0 \0 \0 \0
0000020 \0 \0 \0 \0 \0 \0 \0 \0 \0 \0 \0 \0 \0 \0 \0 \0
*
0010000 a b c d e f g h i j
0010012
xieyihua@xieyihua:~/test$ cat file.hole
123456789aabcdefghijxieyihua@xieyihua:~/test$/* 沒有空洞的文件其占用了16個磁盤塊*/
xieyihua@xieyihua:~/test$ ls -ls file.*8 -rwxr-xr-x 1 xieyihua xieyihua 16394 Jul 4 01:58 file.hole
16 -rwxr-xr-x 1 xieyihua xieyihua 16384 Jul 4 01:58 file.nohole
文件空洞的特性:分配了文件偏移量范圍,但是實際卻沒有分配磁盤空間。我們一般可在兩個方向應用:
- 多線程下載。當創建一個巨大的文件時,單個線程逐步構建文件會耗費大量時間。一種優化思路是將文件劃分為多個段,利用多線程同時操作,每個線程負責寫入其中一段數據。這類似于現實生活中修路的場景,如修建高速公路時,單個施工隊的進度可能較慢,但通過安排多個施工隊,每個隊負責修建一段,最終將它們連接起來,大大提高了效率。
- 共享內存。當不同進程需要共享內存時,并不清楚實際需要多大的文件,可以先開辟一個大文件。比如:在創建虛擬機時,如果一開始就分配了100GB的磁盤空間,而實際上系統安裝完成后可能只使用了3、4GB的空間,這就是空洞文件的應用。通過空洞文件,可以避免一開始就分配過多的資源,節約了存儲空間的浪費。
read 接口
read
接口用于向打開文件中讀數據。其原型聲明如下:
#include<unistd.h>
ssize_t read(int fd, void* bff, size_t nbyte);
若read
成功,則返回讀到的字節數,如果已經達到文件的尾端,則返回0。
知識點: 大多數文件系統為改善性能都會采用某種預讀技術,即即使你每次僅讀取100Byte內容,但是實際上會從磁盤中讀取一頁數據,保存在內存中。從而減少磁盤I/O操作,提高系統性能。但是也會增加內存使用壓力。
write 接口
write
接口用于向打開文件寫數據。其接口聲明如下:
#include <unistd.h>
ssize_t write(int fd, const void* buf,size_t nbytes);
其返回值通常與參數nbytes的值相同,否則標識出錯。其出錯原因:
- 磁盤已寫滿
- 超過文件限定長度
linux 內核標識打開文件的方式
linux 內核通過三個數據結構表示打開的文件。記錄項、文件表項、V節點。其三者關系大致如下:
- 進程表項中,記錄中文件描述符與文件表項的關系;
- 文件表項中,記錄文件狀態標志位、當前文件偏移量、V節點指針;
- V節點中,記錄文件類型、各種操作函數指針、指向i節點。而i節點包含文件的詳細信息,比如:文件的所有者、文件長度、指向文件實際數據塊再磁盤上所在位置的指針等。
注意:每一個文件只有一個唯一的V節點表;多個文件表項可以指向同一個V節點表,每調用一次open
,則創建一個新的文件表項;不同fd可以指向同一個文件表項;即可能存在以下場景:
正是這樣的機制原理,linux 讓我們可以多任務同時訪問同一文件。但是在寫文件時,我們需要關注寫入時序以及數據錯亂問題。
- 在完成每一個
read
或write
操作后,文件表項中的當前文件偏移量增加所讀寫的字節數。 - 如果使用O_APPEND標志打開一個文件,則響應標志也被設置到文件表項的文件狀態標志中。每次進行寫操作時,文件表項中的當前文件偏移量首先會被設置為表項中的文件長度。這就確保每次寫入的數據追加到當前尾端處。
- lseek 函數只是修改文件表項中的當前文件偏移量,不進行任何I/O操作。
- 每一個進程都有它自己的文件表項和進程表項。
dup和dup2
這兩個接口用于復制一個現有的文件描述符。其接口聲明如下:
#include<unistd.h>int dup(int fd);
int dup2(int fd, int fd2);
/*兩函數的返回值:若成功,返回新的文件描述符;若出錯,返回-1*/
dup
返回新的文件描述符一定是當前可用文件描述符中的最小數值。其效果就是多個fd指向同一個文件表項。其關系與上圖中多線程訪問文件一致。
sync、fsync、fdatasync接口
傳統的linux 系統中設備緩沖區高速緩存或頁高速緩存,大多數磁盤I/O都通過緩沖區進行。當我們向文件寫入數據時,內核通常先將數據復制到緩沖區中,然后排入隊列,晚些時候在寫入磁盤。這種方式稱為“延遲寫”。
“延遲寫”雖然提高了write的響應速度(不需要等待數據經過IO,寫入磁盤)。但是也帶來了風險:當應用層認為已經將數據寫入文件了,但實際數據還并沒有落入磁盤。若此時系統出現異常,則會將這部分數據丟失。為了避免這種情況,linux 系統提供了sync
、fsync
、fdatasync
接口,應用層主動要求內核將緩沖區中的數據進行落盤。原型聲明如下:
#include<unistd.h>
int fsync(int fd);
int fdatasync(int fd);void sync(void);
- sync 只是將所有修改過的塊緩沖區排入寫隊列,然后就返回。它并不等待實際寫磁盤操作結束。
- fsync 函數只對文件描述符fd指定的文件起作用,并等待寫磁盤操作結束才返回。
- fdatasync 函數類似于 fsync,但只影響文件的數據部分。
注: open 接口中有一個標識 O_SYNC含義標識同步寫,但經過驗證,似乎并不起作用,與預期不一致。建議為了保險起見,還是調用fsync接口。
總結
文件編程是Linux開發者必須掌握的技能。本文介紹了Linux文件編程中常用的API及其注意事項,包括open、creat、close、lseek、read、write、dup和dup2等。還介紹了sync、fsync和fdatasync等接口,用于確保數據安全。此外,文章還解釋了Linux內核如何標識打開的文件,以及文件表項、V節點和進程表項之間的關系。希望能給您帶來幫助。
若我的內容對您有所幫助,還請關注我的公眾號。不定期分享干活,剖析案例,也可以一起討論分享。
我的宗旨:
踩完您工作中的所有坑并分享給您,讓你的工作無bug,人生盡是坦途
參考文章:https://applink.feishu.cn/client/message/link/open?token=AmX27V1AQAADZjdT9KRAgAQ%3D