一、理解文件
拋一個概念:
文件 = 內容 + 屬性
。
1. 那么,空文件有大小嗎?答案是有的。因為空文件指的是文件內容為空,文件屬性也要占據大小啊。
將來對文件操作,無非分為兩類:
1.對文件內容做修改
。
2.對文件屬性做修改
。
2. 在學習C語言時,我們訪問一個文件,都要先把對應的文件打開,為什么?
因為要訪問文件的內容和屬性,內容和屬性都是數據,所謂的訪問就是對文件進行增刪查改,都是由CPU執行你的代碼進行訪問,根據馮諾依曼體系結構,要對文件進行操作,你的文件必須在物理內存里,所以,我們把文件打開本質上是把文件加載到內存中
。
3. 文件有很多啊,被打開的文件在物理內存上,如果一個文件沒有被打開呢?沒有被打開的文件就在磁盤上。
所以,將來學習文件就被分為兩種:被打開的文件和沒有被打開的文件(文件系統)。
4. 誰打開的文件?
CPU在調度這個進程時,執行到 fopen 的時候就打開文件。文件是在磁盤上的,磁盤是硬件,誰能去訪問磁盤呢?是操作系統
。所以,是用戶通過bash,啟動進程,進程通過操作系統打開文件的
。
那么,這時候就有人說了,我用的是C語言的庫函數啊,沒調用系統調用怎么訪問的操作系統。是因為庫函數底層對系統調用進行了封裝
。
5. 一個進程可以打開多個文件嗎?答案是可以的。那如果有多個進程呢?
所以,OS內一定同時存在大量被打開的文件,OS要不要對這些文件進行管理呢?肯定是要的
。怎么管理?先描述在組織
。
所以,OS一定存在一種數據結構體,描述被打開的文件,如同PCB一樣
。
6. 進程有 task_struct ,未來進程也有打開的文件,我們平時研究打開文件,是在研究什么?
本質是研究:進程與文件的關系
。
二、回顧C文件接口
1. 回顧C接口
//pathname (路徑)+文件名
//mode 打開模式
//成功返回文件指針,失敗返回NULL
FILE* fopen(const char* pathname, const char* mode);//打開文件
//關閉文件,成功返回0,失敗返回EOF
int fclose(FILE* stream);
mode:
r:以讀的方式打開文件,定位在文件的開始
r+:以讀寫的方式打開文件,定位在文件的開始
w:以寫的方式打開文件,文件不存在就創建文件,或者清空文件,定位在文件開始
w+:以讀寫的方式打開文件,文件不存在就創建文件,反之清空文件,文件指針定位在文件開始
a:以追加(寫)的方式打開文件,文件不存在就創建文件,定位在文件末尾
a+:以讀寫的方式打開文件,文件不存在就創建文件,寫入時,在文件末尾,讀取時,文件開始。
.
以 w 模式打開文件
.
以 a 模式打開文件
剩下的就不再枚舉了,大家可以自己驗證一下。
2. 文件讀取是有讀取位置的,那么怎么理解這個呢?
所謂的文件我們可以把它看做成一個“一維數組”,文件的位置不就是數組下標嗎
。
這時候就有人不理解了,文件是怎么被看做是一個一維數組的?
我們可以把文件里的內容看做是一個長字符串,只不過這個長字符串里面有許多換行符而已
。這樣應該理解了吧。
接下來看下面的現象,又是怎么回事呢?
這是怎么回事呢?當我們向文件里寫入內容時,肯定要先打開文件,當識別到 > 符號時,就會以 w 的方式打開文件,所以就可以進行寫入或者清空文件了。
相同的道理,>> 符號就會以 a 的方式打開文件,在文件末尾進行追加數據。
補充:
向顯示器寫入12345,是寫入了一個int 12345 還是向顯示器寫入了 ‘1’,‘2’,‘3’,‘4’,‘5’?
通過鍵盤輸入12345,輸入了一個 ‘1’,‘2’,‘3’,‘4’,‘5’,還是輸入了 int 12345?
答案是輸入了一個個字符。所以顯示器和鍵盤也叫字符設備。
int x = 100 , printf(“%d”,x); ,int 占4個字節,%d表明是一個整數,所以 printf 在向顯示器打印的時候會將數據拆分成一個個字符,輸出到顯示器上,所以 printf 也叫格式化輸出。同理,scanf 函數從字符設備上一個個讀取字符,所以也叫做格式化輸入。
那么,顯示器和鍵盤是文件嗎?當然是文件。它和我們在軟件層上創建的文件獲取數據和輸出數據是類似的,它就是一個文本文件。
那么什么是二進制文件呢?
以二進制形式存儲數據的文件。
我們在C語言中學習的stdin,stdout,strerr是文件嗎?當然也是了
。
它們都是FILE* 的文件指針
。我們常說進程在啟動的時候會默認打開這三個文件流(其實是打開三個文件),這是為什么呢?
大部分進程是需要使用CPU資源進行計算的,而計算就需要數據,計算結果有時候也是需要輸出的,也有可能計算錯誤。所以為了方便起見,進程會默認打開對應的文件。
看下面的幾個函數。
這說明了什么呢?本質向顯示器打印,就是向stdout中寫入,就如同向文件寫入,因為stdout也是FILE*
。
文件是在磁盤上的,而打開文件就需要將文件加載到內存里,只有操作系統才可以,訪問操作系統就必須經過系統調用。所以接下來,我們就來看看打開文件的系統調用。
3. 系統調用
//pathname 文件路徑+文件名
//flags 打開文件的方式
int open(const char* pathname, int flags);
//mode 文件的權限
int open(const char* pathname, int flags, mode_t mode);
open可以打開文件,如果文件不存在,是否會創建,取決于標志位(flags)
。
flags(標志位)有很多,我們列舉幾個最常用的。
.
O_APPEND
追加,文件不存在也會創建
.
O_CREAT
創建文件
.
O_RDONLY
只讀方式打開文件
.
O_WRONLY
只寫方式打開文件
.
O_RDWR
讀寫方式打開文件
.
O_TRUNC
清空文件
可以看到,O_WRONLY是不會創建文件的。
O_CREAT會創建文件。
但是,創建的文件權限怎么是亂碼的呢?這是需要你自己手動設置的。
但是,文件的權限怎么是644呢?還記得umask嗎?就是因為它。每個系統的umask可能不一樣。
我們也可以調用系統調用來設置umask,并且不會影響系統的umask。按就近原則執行umask
。
剩下的選項就不再做詳細介紹了。現在,就來聊聊 open 系統調用的返回值吧。
4. 理解文件描述符
可以看到,open 的返回值:文件描述符是一個個整數
,它是什么呢?
一個進程是可以打開多個文件的,OS內一定有大量的文件被打開,這些文件也是要被管理的。
在OS內,如何描述被打開的文件呢?struct file
,這個結構體內一定直接或間接的包含被打開文件的內容和屬性,以雙鏈表的形式進行管理
。
自此以后,對文件進行管理就轉變為對鏈表的增刪查改。
而文件是由進程通過OS打開的
,所以文件與進程之間也是有著密不可分的聯系。
一個進程可以打開多個文件,多個進程也可以打開多個文件,那么,被打開的文件是屬于哪個進程呢?
在進程PCB里有一個結構體指針struct file_struct* files,它指向一個struct file-struct(文件描述符表)的結構體,這個結構體內有一個成員struct file* fd_array[],這是一個指針數組,指向struct file。打開文件時,OS分配 struct file 結構體,鏈入到struct file的鏈表中,將新申請的struct file結構體的地址填入到fd_array數組中,給用戶返回數組下標
。
總結:文件描述符的本質就是數組下標
。
也就是說,在OS角度,識別打開的文件,只認:int fd 文件描述符
。
那么,數組下標 0,1,2去哪里了呢?我們說進程默認打開了三個文件流:stdin, stdout, stderr
,這不剛好三個嗎?沒錯,0,1,2就是分別給它們三個文件流了
。
那這時候有人就有疑問了,在學習C語言的時候,我們使用文件接口并沒有用到文件描述符 fd 呀,不是說OS只認 fd 嗎?C語言的文件接口返回類型是FILE*
呀。
那么,FILE是什么呢?它其實就是C語言標準庫定義的一個結構體。
所以,推測,FILE 結構體里面,一定要封裝一個整數,這個整數就是 fd
。
現在看來,封裝,不僅僅是對于系統調用接口的封裝,連數據類型也做了封裝。
打開文件時,OS只給我們做了上述的工作嗎?當然不是了。它還要把磁盤上的文件加載到內存里。
文件 = 屬性 + 內容
。OS會給文件屬性開辟一段空間,給文件內容開辟一段空間(文件緩沖區)。而struct file 結構體里會間接的找到它們。
我們在使用 write(3, "hello world")
系統調用的時候,是在干什么呢?
進程會找到文件描述符表,拿著3號文件描述符找到對應的文件,將數據拷貝到文件內核緩沖區里
。
所以:write的本質根本就不是寫入到文件里,write的本質是拷貝函數,把數據從用戶空間拷貝到對應文件的內核緩沖區中
。
那如果讀取文件呢?只能從文件緩沖區里面讀取
。
修改文件呢?從內核緩沖區里拷貝數據到用戶緩沖區,進行修改,再拷貝到內核緩沖區,刷新到磁盤上
。
所以,我們對任何文件內容進行增刪查改,都必須把文件的內容提前預加載到該文件的內核緩沖區中
。
5. 文件描述符的分配規則
我們關閉了文件描述符 0 所指向的文件,所以OS給我們分配了 0,那么如果關閉2號文件呢?我們來驗證一下。
由此可見,文件描述符的分配規則是:給新打開的文件分配 fd ,從文件描述符表數組中尋找,最小的,沒有被使用的數組下標,作為該文件的 fd
。
那么,有沒有人疑惑呢?為什么跳過了 1 號描述符呢?
我們來試一下。
沒有打印,這是為什么呢?因為1號文件描述符指向的文件是 stdout ,我們把它關閉了,然后創建了 log.txt 文件,1號文件描述符就被分配給了 log.txt文件,所以就無法向顯示器打印了
。那么,既然 1 號文件描述符分配給了 log.txt,數據是不是在 log.txt 里呢?
可以看到,是沒有的,那是怎么回事呢?我們對代碼做出些許改動,看看結果。
現在,我們換一種方式檢驗結果。
我們用了兩種方式,表達的都是同一個問題。這是為什么呢?
這與緩沖區有關
。我們需要等到后面才能解釋。
一般,我們不采用這種關閉某個文件,打開另一個文件的做法。所以,接下來,我們需要先了解一個系統調用。
//用于復制文件描述符
//oldfd文件描述符復制到newfd文件描述符上
//如果newfd已經打開,dup2會先關閉它,然后再進行復制。
//成功,返回新的文件描述符newfd
//失敗,返回-1,并設置errno
int dup2(int oldfd, int newfd);
利用這個系統調用,我們可以寫一個輸出重定向的代碼。
有眼尖的伙伴就發現了,它打印的順序怎么和我們代碼所寫的順序不一樣呢?這也是因為緩沖區的緣故。
6. 如果我們創建子進程,子進程是如何看待父進程打開的文件的?
父進程創建子進程,OS會給子進程分配PCB,虛擬地址空間,頁表...,當然了,也包括今天的文件描述符表(struct file_struct)
。
PCB,,虛擬地址空間,頁表是以父進程為模板的,文件描述符表當然也沒有例外,也就是說,父進程打開的文件,子進程也會打開
,那么,OS會重新再加載一份文件嗎?當然不會了
。我們是創建了一個新的進程,又不是打開了新的文件。
那么,子進程以父進程為模板,文件描述符表中的struct file* fd_array[]就是淺拷貝,也就意味著子進程和父進程指向的是同一個文件
。
不知道大家看到這里有沒有問題呢?
既然子進程和父進程指向的是同一個文件,那如果子進程關閉了某些文件,是不是父進程也無法使用呢?
當然不會了。進程是具有獨立性的。父子進程指向同一個文件,為了保證進程的獨立性,OS采用了引用計數的方法,子進程關閉了某個文件,OS就對計數做 - - 操作。直到計數為0,就會關閉該文件
。
我們常說進程默認會打開 stdin, stdout, stderr 這三個文件流,我們在命令行上啟動的進程,我們是沒有打開這幾個文件流的。因此,進程都是通過父進程繼承來的
。
我們可以驗證一下。
7. 如果程序替換,不會創建新進程,會影響我們歷史打開的文件嗎?
答案是不會。
程序替換,加載的是新的代碼和數據,跟文件有什么關系。所以,程序替換不會影響文件
。
今天的文章分享到此結束,覺得不錯的給個一鍵三連吧。