文件操作的底層原理 -文件描述符與緩衝區-

前言


在 C 語言和 C++ 中都存在文件操作,通常是以讀或者寫的方式打開文件,然後進行讀寫,最後關閉文件。但其實文件操作的底層並沒有這樣簡單。

文件操作的底層原理分爲兩部分,分別某一進程找到它打開的文件,某一進程對該文件進行操作,要理解這兩部分,就需要理解文件描述符和緩衝區。

一、C/C++ 語言的文件操作回顧


1.C 語言文件操作

#include<stdio.h>    
#include<stdlib.h>    
int main()    
{    
  FILE* fp1=fopen("./log.txt","w");    
  if(fp1==NULL)    
  {    
    perror("fopen");    
    return -1;    
  }    
  int cnt=10;    
  while(cnt--)    
  {    
    const char* msg="hello file!\n";    
    fputs(msg,fp1);//寫操作    
  }    
  fclose(fp1);    
  FILE* fp2=fopen("./log.txt","r");    
  char buffer[64];    
  while(fgets(buffer,sizeof(buffer),fp2))    
  {    
    printf("%s\n",buffer);//讀操作    
  }    
  if(feof(fp2))//判斷是否正常退出    
  {    
        printf("fgets quit normal!\n");    
  }    
  else     
  {    
    printf("fgets quit not normal");                                                                                                                     
  }                                       
  fclose(fp2);    
  return 0;
}

2.C++ 文件操作

#include<iostream>    
#include<fstream>    
#include<string>    
using namespace std;    
int main()    
{    
   ofstream out("./log.txt",std::ios::out|std::ios::binary);    
   if(!out.is_open())    
   {    
     std::cerr<<"open error"<<std::endl;    
     return 1;    
   }    
   string msg="hello world!\n";    
   int cnt=10;    
   while(cnt--)    
   {    
     out.write(msg.c_str(),msg.size());    
   }    
   out.close();                                                                                                                                          
}

我們發現兩種語言處理的方式都是一樣的,以讀或者寫的操作打開文件,對文件進行操作之後關閉文件。

二、三種輸入輸出流


  1. 讀寫的本質

當進行文件讀寫操作時,其本質上是向硬件上進行讀寫。當 C 語言程序運行時,會默認打開三個標準輸入輸出流:分別是標準輸入,標準輸出,標準錯誤。其中標準輸入是鍵盤,標準輸出和標準錯誤都是顯示器,它們在 C 語言中是以文件的形式而存在 (stdin,stdout,stderror),在 C++ 中以對象的形式存在 (cin,cout,cerror):

當向標準輸出文件中寫入內容時,其實就是將內容打印在顯示器上。

  const char* msg="I am the king of Asgard\n";      
  fputs(msg,stdout);

此時運行程序,msg 的內容被顯示到顯示器上。

2.stdout 和 stderr 區別

stdout 和 stderr 都代表顯示器,兩者是有區別的。標準輸出 stdout 是可以進行輸出重定向的,但是 stderr 是不能進行輸出重定向的。 依然使用上文的代碼舉例:

./mytest1>log.txt

此時再打開 log.txt 就會發現重定向完成:

我們使用的是輸入重定向,如果不希望覆蓋的話還可以使用追加重定向。 但是將 fputs 中的參數 stdout 改爲 stderr 後,就無法完成重定向。

三、系統調用接口


無論在鍵盤還是顯示器還是磁盤,當我們在語言層面進行讀寫操作的時候,本質上都是在訪問硬件。而 OS 又是硬件的管理者,因此在語言上對文件進行操作都需要貫穿操作系統。再由操作系統來進行對硬件的讀寫。

不同的語言實際上使用的是操作系統讀寫文件的同一套接口。語言中文件操作的函數都是對這些接口的封裝。

1.open 和 close 的概念

在系統調用接口中的打開文件和關閉文件的函數就是 open 和 close。

我們可以通過 man 手冊來查詢 open 和 close 的使用,由於是系統調用接口,所以使用 man2 來進行查詢:

man 2 open

open:

close:

在 open 中

返回值是 - 1 或者文件描述符。 pathname 指的是文件名。 flag 指的是文件的打開方式。它的值可以是 O_RDONLY: 只讀模式 O_WRONLY: 只寫模式 O_RDWR: 可讀可寫 O_CREAT: 創建 mode 指的是文件的權限信息。 在 close 中 當打開成功則返回 1。打開失敗則返回 0。 傳入的參數 fd 是文件描述符。

2.open 和 close 的使用

int fd=open("./log.txt",O_WRONLY|O_CREAT,0644);
  if(fd<0)
  {
     printf("open error!\n");
  }
  else
  {
    close(fd);
  }

此時以 O_WRONLY|O_CREAT 的方式創建了一個文件 log.txt,表示的是以寫的方式打開,如果文件不存在則創建它。

權限爲 0644,轉換爲二進制表示爲 110 100 100。

我們可以得知,當 C 語言使用 fopen 來對 open 進行封裝時,沒有讓我們自己規定權限,說明在封裝的過程中權限已經被規定了。

此時我們可以觀察到 log.txt 的權限:

最後通過 close 關閉文件。

  1. 系統調用接口的參數和返回值

文件名沒有什麼可以介紹的,下面主要來介紹標誌位:

通過 man 手冊查詢可知,flags 的類型爲 int 類型,而顯然 O_RDONLY,O_WRONLY 等都是宏,因此,標誌位是由 int 型所定義的宏。

在代碼中使用或操作符 O_WRONLY|O_CREAT 來滿足實現兩者中的一種操作。因此我們可以大概可以猜到這些宏是怎麼定義的,即:這些宏都是隻有一個比特位爲 1 的數據,並且不重複。

我們可以通過查詢這些宏的定義來驗證這一猜想:

grep -ER 'O_CREAT|O_RDONLY|WRONLY' /usr/include/

可以發現 O_RDONLY 被定義爲 00,O_WRONLY 被定義爲 01,O_CREAT 被定義爲 0100。 open 返回的是一個文件描述符,在下面會進行詳細的介紹。

四、文件描述符


  1. 文件描述符的值

每當操作系統打開一個文件的時候,會給他一個編號,這個編號就叫做文件描述符。當打開文件失敗,則文件描述符爲 - 1。

其中標準輸入,標準輸出,標準錯誤的文件描述符分別爲 0,1,2(因爲在操作系統眼中,它們都是以文件的形式來存在的。)

首先我們可以來接收一下 open 的返回值,即文件描述符:

  int fd=open("./log.txt",O_WRONLY|O_CREAT,0644);    
  printf("%d\n",fd);    
  if(fd<0)                                                                                                                                               
  {    
     printf("open error!\n");    
  }    
  else    
  {    
    close(fd);    
  }

最終得到 open 的返回值,即文件描述符的值是:3,這是因爲在 C 語言中 0,1,2 號文件是默認被打開的。

顯然文件描述符的值是與我們創建的文件 log.txt 是有關的。這也說明 3 之前的 0,1,2 是已經被佔用了的。

  1. 操作系統對文件的管理

我們對文件進行操作就需要打開文件,而打開文件是由某一個進程來完成的,打開文件的本質是將文件信息加載到內存中,而一個進程可以打開很多個文件,如果有很多進程,那麼內存中就存在很多的已經打開的文件的信息。因此操作系統是需要對這些打開的文件來進行管理的。

操作系統的管理方法是:先描述,再組織。而對文件的描述是在一個名爲 file 的結構體中進行的。

一個文件包括內容和屬性兩個部分 (比如創建一個空文件,在磁盤中也會佔據空間的,這是因爲需要存儲該空文件的屬性)。因此在 file 結構體中有文件的內容和屬性兩個內容。同時,操作系統將所有打開的文件通過數據結構組織了起來(即將各個 file 結構體組織了起來),每一個進程需要知道自己打開的文件在哪一個位置,因此在 PCB 中需要一個來描述該進程打開哪些文件的結構體 files,而結構體 files 中存在一個指針數組 array_file,它的每個元素指向的就是該進程打開的每一個文件對應的 file,下面用一張圖來說明幾者之間的關係:

因此我們在使用系統調用接口 open 和 write 的時候,會通過該進程 PCB 中指向 files 的結構體的指針找到 files 結構體,通過該結構體中的數組編號找到對應的文件指針,然後找到對應的文件。

因此當我們進行文件操作時,只需要傳入該文件對應的數組下標 (即文件描述符) 即可。

  1. 父子進程的文件關係

子進程在創建之初是父進程的拷貝,它的 files 結構體與父進程是相同的,因此父進程打開的文件子進程也會進行打開。而我們在創建進程打開的標準輸入,標準輸出以及標準錯誤其實是由 bash 打開的,由各個進程繼承下來的。

  1. 不同外設的讀寫

注意,系統調用接口只有一套,但是顯然我們對不同外設的讀寫方式是不一樣的,比如對磁盤,顯示器的讀寫,對標準輸入甚至不需要寫操作。那麼一套系統調用接口如何實現對不同外設的讀寫呢?

這和 C++ 的多態有些相似,不同外設的讀寫方式被寫入了對應的驅動中,但調用讀寫操作的時候,實際上調用的是該外設的驅動上的讀寫操作,從而完成讀寫的。外設都有 I/O 接口,但不一定都要被實現。

五、文件描述符的分配規則以及輸入重定向


  1. 文件描述符的分配規則

標準輸入,標準輸出以及標準錯誤的文件描述符分別爲 0,1,2,並且會在進程打開的時候自動進行打開,從而導致後序創建的文件的文件描述符的值爲 3,4,5… 如果我們在進程中將標準輸入或輸出關閉呢?後序文件的文件描述符的值是否會發生變化呢?

答案是會的,我們可以通過下面的例子來總結一些規律:

  close(0);                                                                                                                                              
  int fd=open("./log.txt",O_WRONLY|O_CREAT,0644);                       
  printf("%d\n",fd);

通過 close 將標準輸入關閉,此時我們運行程序會發現,log.txt 的文件操作符變成了 0。正好填補了空出的 0 號位置。

當我們關閉標準輸出,此時顯示器上不會顯示任何內容,但是 log.txt 中出現了本該在顯示器上所打印的內容:

  close(1);                                                                                                                                              
  int fd=open("./log.txt",O_WRONLY|O_CREAT,0644);                       
  printf("%d\n",fd);
  printf("I am here!\n");

這是什麼原因呢?

在 C 語言中的標準輸出是 stdout 也就是顯示器,在操作系統看來,它就是一個文件。

我們可以通過 man 手冊查詢到 stdout 的類型是 FILE 類型,而 FILE 類型是一個結構體,它其中有一個名爲_fileno 的整型,每一個 FILE 類型的文件的文件描述符就存放在這裏。

我們可以通過代碼來驗證一下:

  printf("stdin->_fileno:%d",stdin->_fileno);    
  printf("stdout->_fileno:%d",stdout->_fileno);    
  printf("stderr->_fileno:%d",stderr->_fileno);

打印的結果是:

也就是說 printf 向 stdout 文件進行打印的本質是它獲得了 stdout 文件的文件操作符 1,通過這個 1 來找到對應的文件並向其中寫入內容。

而在代碼的開始我們將已經打開的顯示器文件進行了關閉,此時 log.txt 的文件描述符變成了 1,此時 printf 依然通過 1 來找到對應的文件並向其中寫入數據,因此最終寫入的數據進入 log.txt 中。並且由於系統調用接口類似多態,調用的是不同的底層輸入函數,因此可以將內容寫入 log.txt 中。

這一過程也是輸入重定向的原理。

同時我們還可以得出一個結論:當爲一個文件分配文件描述符時,從數組下標 0 的開始遍歷,如果某一位置的指針爲空,則將指針指向該文件,並將數組下標作爲該文件的文件描述符。

  1. 重定向

(1) 輸出重定向

echo "hello world">log.txt

即將本來要打印在顯示器上的內容打印在了文件 log.txt 中,它的原理就是先將顯示器關閉,使得 log.txt 的文件描述符爲 1,然後調用系統調用函數來向描述符爲 1 的文件,即 log.txt 進行寫入。

注意輸出重定向是將標準輸出重定向,而不是標準錯誤。在標準錯誤打印的內容不會被重定向,因爲它的文件描述符是 2。

(2) 追加重定向

追加重定向指的是重定向的內容不覆蓋文件中原有的內容。只是在 open 函數中的 flag 多加了一個 APPEND 的參數,其他和輸出重定向相同。

(3) 輸入重定向

輸入重定向即將標準輸入關閉,將某一個文件來作爲輸入。

    close(0);    
    int fd=open("./log.txt",O_RDONLY,0644);    
    char line[128];    
    while(fgets(line,sizeof(line)-1,stdin))    
    {    
         printf("%s\n",line);                                                                                                                            
    }

此時 fgets 拿到的就是文件 log.txt 中的內容,而不是標準輸入的,說明它本質是通過 stdin 的文件描述符來尋找文件的。

(4) 直接重定向

我們會發現,如果想完成重定向操作需要進行關閉標準輸入輸出等操作,是比較麻煩的,因此係統提供了一個 dup2 函數來直接進行重定向的操作:

它的參數有兩個,即 oldfd 與 newfd,其中下標爲 newfd 中的元素是下標爲 oldfd 的元素的拷貝。

  int fd=open("./log.txt",O_WRONLY,0644);    
  dup2(fd,1);    
  printf("hello dup2!\n");

此時將數組中的 1 下標中的元素替換爲 fd 下標的元素,上層對文件描述符爲 1 的文件進行操作就是對之前的 fd 對應的文件進行操作。

六、緩衝區


  1. 緩衝區的分類

在 C 語言中我們在學習 getchar 函數的時候就提到過緩衝區,只不過只是淺嘗則止。其實緩衝區分爲兩類,分別是用戶緩衝區和系統緩衝區。

用戶緩衝區的數據最終刷新到系統中,系統緩衝區的數據最終刷新到硬件上。在 C 語言中我們寫入的數據會首先保存在用戶緩衝區中,該用戶緩衝區是由 C 語言提供的。

在 C 語言的結構體 FILE 中,不僅僅封裝了文件描述符,還封裝了緩衝區。

我們可以在 usr/include/libio.h 找到關於 FILE 的定義:

其中_fileno 指的就是文件描述符,而上面的 char * 內容就有些是與緩衝區相關的。

因此用戶緩衝區屬於用戶層,而不屬於操作系統層,在 C 語言進行讀寫文件時,會先將數據放入 FILE 結構體中的緩衝區中,然後再刷新到操作系統中。操作系統中的系統中緩衝區也同理,這裏我們主要了解用戶層的緩衝區。

  1. 緩衝區的刷新策略

  2. 立即刷新 (不緩衝)

  3. 行刷新 (行緩衝):遇到換行操作,則進行緩衝區的刷新。比如向顯示器的刷新。

  4. 全緩衝:緩衝區滿了即進行刷新,比如向磁盤文件中寫入,這樣也解決了緩衝區的溢出問題。

  1. 對緩衝區刷新的理解

首先我們來驗證緩衝區的刷新策略。

    const char* msg="hello 標準輸出!\n";      
    write(1,msg,strlen(msg));                                                                                                                            
    printf("hello printf!\n");                                           
    fprintf(stdout,"hello fprintf!\n");                                          
    fputs("hello fputs!\n",stdout);

這段代碼的打印結果很簡單:

下面來解釋打印的原理,其中 printf,fprintf,fputs 是 C 語言的函數,因此它們所打印的內容要先存放在用戶區,由於是向顯示器打印,因此是行刷新,每打印完一行就會刷新到系統中,再向硬件上顯示。而 write 是系統調用接口,是直接寫入系統緩衝區的,再向硬件上顯示。

如果我們對其進行重定向操作呢?

我們發現文件中的內容和顯示器上顯示的是一模一樣的,但是性質卻發生了變化,由於是將顯示器中的內容重定向到 log.txt 中,log.txt 是磁盤中的一個文件,刷新方式也就由原來的行刷新轉換爲了全刷新。

其中 write 中內容依然是直接寫入系統緩衝區中,然後再顯示在硬件上。而 printf,fprintf,fputs 中的內容會先存入用戶緩衝區 (不過用戶緩衝區沒有被填滿,不會刷新到系統緩衝區中),待進程運行結束之後再刷新到系統緩衝區,最終顯示在硬件上。

我們可以再通過一個更直觀的方式來驗證:

    const char* msg="hello 標準輸出!\n";    
    write(1,msg,strlen(msg));    
    printf("hello printf!\n");    
    fprintf(stdout,"hello fprintf!\n");    
    fputs("hello fputs!\n",stdout);    
    close(1);

當我們在代碼的末尾將文件 1 關閉的時候,再進行重定向:

./mytest1>log.txt

此時我們在文件中看到的結果是:

只有標準輸出一個內容,這是因爲在三個 C 語言字符串內容剛寫入緩衝區,就把文件 log.txt 關閉了 (重定向後文件描述符 1 代表了重定向文件),緩衝區中的內容還沒來得及刷新到系統緩衝區中文件就已經被關閉了,因此不會寫到硬件。而 write 是系統調用接口,它的內容是直接向系統緩衝區中進行寫入了,因此會寫到硬件上。

這個例子也說明了,用戶緩衝區是存在在用戶層的,而不是存在在系統層的。

我們如果一定要關閉文件,我們可以用 fflush 函數來幫助刷新到系統緩衝區:

    const char* msg="hello 標準輸出!\n";    
    write(1,msg,strlen(msg));    
    printf("hello printf!\n");    
    fprintf(stdout,"hello fprintf!\n");    
    fputs("hello fputs!\n",stdout);    
    fflush(stdout)
    close(1);

此時硬件就被成功刷新了。

我們可以使用子進程來測試一下是否理解了:

    const char* msg="hello 標準輸出!\n";    
    write(1,msg,strlen(msg));    
    printf("hello printf!\n");    
    fprintf(stdout,"hello fprintf!\n");    
    fputs("hello fputs!\n",stdout);    
    fork();

當我們在代碼結尾創建一個子進程的時候:

由於 write 是系統調用接口,因此其中的內容會被直接存放在系統緩衝區中,在子進程創建之前就已經執行完該過程了。而 C 語言函數中的字符串會首先被存放在用戶緩衝區中,此時進程還沒有結束,因此沒有被刷新到系統緩衝區中,子進程被創建,繼承了父進程的緩衝區中的內容,進程結束,父子進程的內容被刷新到系統緩衝區最終顯示在硬件上。因此是 C 字符串是兩份。

七、總結


本文闡述了數據被寫入硬件的完整過程,首先數據是在一個進程中完成寫入操作的。通過該進程的 PCB 中的 file 指針,會找到該進程的 files 結構體,在 files 結構體中有一個數組,它的元素是該進程打開的文件的指針 (指向用來描述文件的 file 結構體),它的下標是各個文件的文件描述符。

在用戶層面,對某一個文件進行寫入只需要知道它的文件描述符即可,在寫入的過程中,如果是用戶層面的寫入需要先將內容寫入用戶層的緩衝區,然後根據要寫入的不同硬件的刷新策略,將數據刷新到系統緩衝區,最終再寫到硬件中。如果是系統層面的寫入,則直接在系統緩衝區寫入,然後再寫入硬件。

總體來說,文件操作分爲兩個部分,分別是找文件和寫文件,對應的分別爲對文件操作符的理解,和對緩衝區的理解。

本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源https://mp.weixin.qq.com/s/A0roIlmacwaX01e6K2Vesw