天天看點

萬字深剖 Linux I/O 原理

目錄

  • 傳統藝能😎
  • 梅開二度🤔
  • 目前路徑🤔
  • 三大輸入輸出流🤔
  • 系統檔案 I/O🤔
  • open😋
  • open 傳回值🤔
  • close😋
  • write😋
  • read😋
  • 檔案描述符fd😋
  • 對應關系😋
  • 記憶體檔案🤔
  • 配置設定規則🤔
  • 重定向🤔
  • 原理😋
  • dup2 🤔
  • 重定向模拟實作🤔
  • FILE 的檔案描述符🤔
  • inode🤔
  • 磁盤🤔
  • 尋址方案😋
  • 分區與存儲媒體😋
  • 磁盤分區😋
  • 格式化😋
  • EXT2 存儲方案😋
  • 軟連結🤔
  • 硬連結🤔

傳統藝能😎

小編是雙非大學大二菜鳥不贅述,歡迎米娜桑來指點江山哦

萬字深剖 Linux I/O 原理

🎉🎉非科班轉碼社群誠邀您入駐🎉🎉

小夥伴們,滿懷希望,所向披靡,打碼一路向北

一個人的單打獨鬥不如一群人的砥砺前行

這是和夢想合夥人組建的社群,誠邀各位有志之士的加入!!

社群使用者好文均加精(“标兵”文章字數2000+加精,“達人”文章字數1500+加精)

直達: ​​社群連結點我​​

萬字深剖 Linux I/O 原理

梅開二度🤔

在 C 文法下就早已知悉基礎 IO ,其實就是耳熟能詳的檔案操作,說到檔案操作腦子裡又是一堆耳熟能詳的函數接口:

萬字深剖 Linux I/O 原理

以一個簡單的寫入操作為例,運作程式後目前路徑下會生成對應檔案,檔案當中就是我們寫入的内容:

#include <stdio.h>
int main()
{
  FILE* fp = fopen("log.txt", "w");
  if (fp == NULL){
    perror("fopen");
    return 1;
  }
  int count = 5;
  while (count){
    fputs("hello world\n", fp);
    count--;
  }
  fclose(fp);
  return 0;
}      
萬字深剖 Linux I/O 原理

目前路徑🤔

檔案操作我們打開檔案時,如果 fopen 對象是一個未建立的對象,那麼就會自動在目前路徑生成一個該檔案,這裡就牽涉到一個

的概念。

比如我們在剛剛寫入後的 log.txt 檔案進行讀取:

#include <stdio.h>
int main()
{
  FILE* fp = fopen("log.txt", "r");
  if (fp == NULL){
    perror("fopen");
    return 1;
  }
  char buffer[64];
  for (int i = 0; i < 5; i++){
    fgets(buffer, sizeof(buffer), fp);
    printf("%s", buffer);
  }
  fclose(fp);
  return 0;
}      
萬字深剖 Linux I/O 原理

該情況下,我們在總目錄下運作可執行程式 myproc,那麼該可執行程式建立的 log.txt 檔案會出現在總目錄下:

萬字深剖 Linux I/O 原理

這是否意味着 “目前路徑” 就是指的 “目前可執行程式所處的路徑” ?

我們不妨直接去檢視他的路徑對吧,我們用 ​

​ps -axj | head -1&&ps -axj | grep myproc | grep -v grep ​

​ 可以檢視可執行程式的 PID :

萬字深剖 Linux I/O 原理

然後我們再利用 PID 來檢視執行路徑 ​

​ sudo ls /proc/8189 -al​

​,因為我在總目錄 ~ 下,是以這裡我使用弄了 sudo 指令進行管理者權限查找:

萬字深剖 Linux I/O 原理

這裡的 cwd 和 exe 是​

​軟連結​

​,我們下文細談,是以實際上,目前路徑不是指可執行程式所處的路徑,而是指該可執行程式運作成為程序時我們所處的路徑

三大輸入輸出流🤔

我們一直貫徹一個理念就是 Linux 下 一切皆檔案,我們肉眼可見的顯示屏輸出的資料,本質是電腦讀取鍵入的字元,電腦從“電腦檔案” 讀取字元,電腦再對“顯示器檔案”進行輸出

那麼問題來了,​

​在我們對這些“檔案”進行讀寫之前,為什麼我們沒有一個檔案打開的操作呢?​

要知道打開檔案一定是程序運作的時候打開的,而任何程序在運作的時候都會預設打開三個輸入輸出流,即标準輸入流、标準輸出流、标準錯誤流,就是 C 當中的 stdin、stdout、stderr;C++當中的 cin、cout、cerr,其他所有語言都有類似的概念。實際上這種特性并不是某種語言所特有的,而是由作業系統所支援的

其中,标準輸入流對應的裝置就是鍵盤,标準輸出流和标準錯誤流對應的裝置都是顯示器。檢視 man 手冊我們不難發現,stdin、stdout、stderr 這仨 byd 其實就是 FILE* 類型的

extern FILE *stdin;
extern FILE *stdout;
extern FILE *stderr;      

我們之是以可以調用 scanf 、printf 這類的函數向鍵盤顯示器進行輸入輸出操作,其實就是程式運作時,作業系統預設使用 C 的接口将這三個輸入輸出流打開。試想我們使用 fputs 函數時,将其第二個參數設定為 stdout,此時 fputs 函數會不會直接将資料顯示到顯示器上呢?

fputs("hello stdin\n", stdout);      

答案是肯定的,因為此時就是用 fputs 向顯示器檔案進行了寫入操作

系統檔案 I/O🤔

相比 C,C++ 這些語言的接口,作業系統也有一套檔案操作的接口,而且作業系統的接口更加貼近底層,而其他語言的接口本質上也是對作業系統的接口的封裝,我們在 Linux、Windows 平台下運作 C 代碼時,C 庫函數就是對 Linux、Windows 系統調用接口進行的封裝,這樣做使得語言有了跨平台性,也友善進行二次開發

open😋

函數原型:

int open(const char *pathname, int flags, mode_t mode);      
  1. pathname 表示要打開或建立的目标檔案。

若pathname以路徑的方式給出,則當需要建立該檔案時,就在pathname路徑下進行建立。

若pathname以檔案名的方式給出,則當需要建立該檔案時,預設在目前路徑下進行建立,注意目前路徑的含義

  1. flags 表示打開檔案的方式。

flags 的可調用參數有如下這些:

萬字深剖 Linux I/O 原理

flags 可以同時傳入多個參數選項,這些選項用 “或” 運算符連接配接。例如以隻寫的方式打開檔案時,檔案不存在就應該自動建立檔案,則參數設定如下

O_WRONLY | O_CREAT      

我們基于與運算的最根本原因是因為:

,除了 O_RDONLY 序列為全 0,表示他為預設選項,且為 1 的比特位是各不相同的,這樣一來函數内部就可以通過使用與運算來判斷是否設定了某一選項

int open(arg1, arg2, arg3){
  if (arg2&O_RDONLY){
    //設定了O_RDONLY選項
  }
  if (arg2&O_WRONLY){
    //設定了O_WRONLY選項
  }
  if (arg2&O_RDWR){
    //設定了O_RDWR選項
  }
  if (arg2&O_CREAT){
    //設定了O_CREAT選項
  }
  //...
}      
  1. mode,表示建立檔案的預設權限,在不建立檔案時,此選項可以不設定。

我們将mode設定為 0666,則檔案建立出來的權限如下,按理說本來應該是 :

萬字深剖 Linux I/O 原理

但是不要忘了,Linux 系統設有 umask 權限掩碼,檔案的真正權限計算方法是:​

​mode &( ~umask)​

​,umask 的預設值應該是 0002,是以在我們自己設定的權限下應該減去 umask 得到 0664,即:

萬字深剖 Linux I/O 原理

當然,如果想繞開 umask ,直接使用我們第一手的設定,那麼我們可以直接将 umask 進行置 0 操作

umask(0);      

open 傳回值🤔

open 的傳回值其實是新打開檔案的檔案描述符 fd,我們這裡嘗試一次打開多個檔案,然後分别列印它們的檔案描述符:

#include <stdio.h>
#include <sys/stat.h>
#include <sys/types.h>
#include <fcntl.h>
int main()
{
  umask(0);
  int fd1 = open("log1.txt", O_RDONLY | O_CREAT, 0666);
  int fd2 = open("log2.txt", O_RDONLY | O_CREAT, 0666);
  int fd3 = open("log3.txt", O_RDONLY | O_CREAT, 0666);
  int fd4 = open("log4.txt", O_RDONLY | O_CREAT, 0666);
  int fd5 = open("log5.txt", O_RDONLY | O_CREAT, 0666);
  printf("fd1:%d\n", fd1);
  printf("fd2:%d\n", fd2);
  printf("fd3:%d\n", fd3);
  printf("fd4:%d\n", fd4);
  printf("fd5:%d\n", fd5);
  return 0;
}      
萬字深剖 Linux I/O 原理

我們又知道系統是無法打開一個不存在的檔案 fd 會傳回 -1,打開成功時如圖所示每個檔案的 fd 從 3 開始且都是連續遞增的,​

​那麼問題來了:0~2 哪裡去了?​

所謂的檔案描述符本質上是一個指針數組的下标,指針數組當中的每一個指針都指向一個被打開檔案的檔案資訊,通過對應檔案的檔案描述符就可以找到對應的檔案資訊

open函數打開檔案成功時數組當中的指針個數增加,然後傳回​

​該指針在數組中的下标​

​​,而當檔案打開失敗時直接傳回 ​

​-1​

​,是以,成功打開多個檔案時所獲得的檔案描述符就是連續且遞增的

而 Linux 程序預設情況下會有 3 個預設打開的檔案描述符,分别就是标準輸入0、标準輸出1、标準錯誤2,這就是為什麼成功打開檔案時所得到的檔案描述符會從3開始

close😋

系統接口中使用close函數關閉檔案,close函數的函數原型如下:

int close(int fd);      

若關閉檔案成功則傳回 0,若關閉檔案失敗則傳回 -1

write😋

系統接口中使用write函數向檔案寫入資訊,write函數的函數原型如下:

ssize_t write(int fd, const void *buf, size_t count);      

write函數将 buf 位置開始向後 count 位元組的資料寫入檔案描述符為 fd 的檔案當中;如果資料寫入成功,傳回寫入資料的位元組個數,如果資料寫入失敗,傳回 -1。

read😋

系統接口中使用read函數從檔案讀取資訊,read函數的函數原型如下:

ssize_t read(int fd, void *buf, size_t count);      

read 函數從檔案描述符為 fd 的檔案讀取 count 位元組的資料到 buf 位置當中。如果資料讀取成功,實際讀取資料的位元組個數被傳回;如果資料讀取失敗,傳回 -1

檔案描述符fd😋

我們知道檔案隻能在程序執行時才能打開,且一個程序可打開多個檔案,系統中存在大量的程序,這就表示系統可以在任何時刻存在大量已經打開的檔案

Linux 思想面對批量的處理時總會采取 “先描述後組織” 的思想,系統會為大量的檔案描述一個 file struct 的結構體,裡面存放着這些檔案的主要資訊,然後将結構體以雙連結清單的形式進行組織,相當于将檔案的管理具象成對雙連結清單的增删查改。

但是在大量程序和大量已打開的檔案裡,我們要找到每個檔案的歸屬程序系統就應該建立對應關系

對應關系😋

當一個程式運作起來時,作業系統會将該程式的代碼和資料加載到記憶體,然後為其建立對應的task_struct、mm_struct、頁表等相關的資料結構,并通過頁表建立虛拟記憶體和實體記憶體之間的映射關系

萬字深剖 Linux I/O 原理

首先在 task_struct 裡有一個指針,他指向一個名為 file_struct 的結構體,在這個結構體裡面又有一個 fd_array 的指針數組,這個數組的下标就是我們所謂的檔案描述符。比如程序打開 log.txt 時會先加載進記憶體形成 struct file ,然後将 struct file 放入一個檔案的雙連結清單裡,struct file 的首位址再放傳入連結表中下标為 3 處的地方,最後傳回他的檔案描述符即可。

萬字深剖 Linux I/O 原理

向檔案寫入資料時,是先将資料寫入到對應檔案的緩沖區當中,然後定期将緩沖區資料重新整理,資料才能進入磁盤。

那麼為什麼程序建立時會預設打開0、1、2 呢?

我們知道作業系統能夠識别硬體,作業系統能管理硬體也意味着鍵盤,顯示器這些東西都有自己對應的 struct_file ,将這 3 個 struct_file 放入雙連結清單,就會對應填入到下标為 0,1,2 的位置,就預設打開了标準輸入流、輸出流、錯誤流。

記憶體檔案🤔

磁盤檔案和記憶體檔案之間的關系就像程式和程序的關系一樣,當程式運作起來後便成了程序,而當磁盤檔案加載到記憶體後便成了記憶體檔案。

磁盤檔案分為了檔案内容和檔案屬性兩部分,也将檔案屬性叫做元資訊,檔案加載到記憶體時,一般先加載檔案的屬性資訊,當需要對檔案内容進行讀取、輸入或輸出等操作時,再加載檔案資料

配置設定規則🤔

我們還是用最開始的代碼做解釋:

#include <stdio.h>
#include <sys/stat.h>
#include <sys/types.h>
#include <fcntl.h>
int main()
{
  umask(0);
  int fd1 = open("log1.txt", O_RDONLY | O_CREAT, 0666);
  int fd2 = open("log2.txt", O_RDONLY | O_CREAT, 0666);
  int fd3 = open("log3.txt", O_RDONLY | O_CREAT, 0666);
  int fd4 = open("log4.txt", O_RDONLY | O_CREAT, 0666);
  int fd5 = open("log5.txt", O_RDONLY | O_CREAT, 0666);
  printf("fd1:%d\n", fd1);
  printf("fd2:%d\n", fd2);
  printf("fd3:%d\n", fd3);
  printf("fd4:%d\n", fd4);
  printf("fd5:%d\n", fd5);
  return 0;
}      

​然而檔案描述符是從最小的 0 開始且未被配置設定的開始配置設定的,比如我關閉了 0,2 的流,那麼新打開 3 個檔案就是不是 3,4 5 而是 0,2,3 了​

close(0);
close(2);//關閉描述符為 0,2 的檔案      

重定向🤔

原理😋

到這裡其實不難了解重定向的原理是修改檔案描述符下标對應的 struct file* 内容,比如我們說過的輸出重定向就是将一個本應該輸出到一個檔案的資料輸出到另一個檔案

比如想讓本應該輸出到顯示器的資料輸出到 log.txt 檔案當中,那麼可以在打開 log.txt 檔案之前将檔案描述符為 1 的檔案關閉,也就是将“顯示器檔案”關閉,這樣一來,當我們後續打開 log.txt 檔案時所配置設定到的檔案描述符就是 1

#include <stdio.h>
#include <unistd.h>
#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>
int main()
{
  close(1);
  int fd = open("log.txt", O_WRONLY | O_CREAT, 0666);//輸出重定向
  if (fd < 0){
    perror("open");
    return 1;
  }
  printf("hello world\n");
  printf("hello world\n");
  printf("hello world\n");
  printf("hello world\n");
  printf("hello world\n");
  fflush(stdout);
  
  close(fd);
  return 0;
}      
萬字深剖 Linux I/O 原理

這裡 printf 是預設向 stdout 輸出資料的,而 stdout 指向的 FILE 結構體中存儲的檔案描述符就是1,是以 printf 實際上就是向檔案描述符為1的檔案輸出資料。C 的資料并不是立馬寫到了記憶體作業系統裡面,而是寫到了緩沖區當中,是以使用 printf 列印完後需要使用 ​

​fflush​

​ 将緩沖區當中的資料重新整理到檔案中可以看出,我執行 file 程式時并沒有任何結果,但是列印 log.txt 時卻得到了我想要的結果,是以就證明了上面的觀點:

萬字深剖 Linux I/O 原理

但是又有一個問題:标準輸出流和标準錯誤流對應的都是顯示器,它們有什麼差別嗎?

答案是有的, 我們以代碼為例:

#include <stdio.h>
int main()
{
  printf("hello printf\n"); //stdout
  perror("perror"); //stderr

  fprintf(stdout, "stdout:hello fprintf\n"); //stdout
  fprintf(stderr, "stderr:hello fprintf\n"); //stderr
  return 0;
}      

結果一定會成功的輸出四行内容,然後再對他進行重定向到 log.txt 中:

萬字深剖 Linux I/O 原理

很明顯這裡 log.txt 檔案當中隻有向标準輸出流輸出的兩行字元串,而向标準錯誤流輸出的兩行資料并沒有重定向到檔案當中,而是仍然輸出到了顯示器上。

dup2 🤔

要完成重定向我們隻需對 fd_array 數組當中元素的拷貝即可,Linux 中對于重定向給出了一個接口:==dup2 ==,我們可以使用這個接口完成重定向:

int dup2(int oldfd, int newfd);      

dup2 會将 fd_array[oldfd] 的内容拷貝到 fd_array[newfd] 當中,如果有必要的話我們需要先使用關閉檔案描述符為 newfd 的檔案,dup2 函數傳回值如果調用成功傳回 newfd,否則傳回 -1。

需要的是:

  1. 如果 oldfd 不是有效的檔案描述符,則 dup2 調用失敗,并且此時檔案描述符為 newfd 的檔案沒有被關閉
  2. oldfd 是一個有效的檔案描述符,但是 newfd 和 oldfd 具有相同的值,則 dup2 不做任何操作,并傳回 newfd

比如:

#include <stdio.h>
#include <sys/types.h>
#include <sys/stat.h>
#include <unistd.h>
#include <fcntl.h>
int main()
{
  int fd = open("log.txt", O_WRONLY | O_CREAT, 0666);
  if (fd < 0){
    perror("open");
    return 1;
  }
  close(1);
  dup2(fd, 1);
  printf("hello printf\n");
  fprintf(stdout, "hello fprintf\n");
  return 0;
}      
萬字深剖 Linux I/O 原理

就像這樣,資料會被傳到 log.txt 裡面。

重定向模拟實作🤔

在我們自己實作 shell 的基礎上,是可以自己實作重定向功能的。對于擷取到的指令進行判斷,若指令當中包含重定向符号 >、>> 或是 <,則該指令需要進行處理

設定 type 變量,type 為 0 表示指令當中為輸出重定向,type 為 1 表示追加重定向,type為 2 表示輸入重定向。若 type 值為 0 或者 1,則使用 dup2 接口實作目标檔案與标準輸出流的重定向;若 type 值為 2,則使用 dup2 接口實作目标檔案與标準輸入流的重定向

#include <stdio.h>
#include <fcntl.h>
#include <ctype.h>
#include <pwd.h>
#include <string.h>
#include <unistd.h>
#include <stdlib.h>
#include <sys/types.h>
#include <sys/wait.h>
#define LEN 1024 //指令最大長度
#define NUM 32 //指令拆分後的最大個數
int main()
{
  int type = 0; //0 >, 1 >>, 2 <
  char cmd[LEN]; //存儲指令
  char* myargv[NUM]; //存儲指令拆分後的結果
  char hostname[32]; //主機名
  char pwd[128]; //目前目錄
  while (1){
    //擷取指令提示資訊
    struct passwd* pass = getpwuid(getuid());
    gethostname(hostname, sizeof(hostname)-1);
    getcwd(pwd, sizeof(pwd)-1);
    int len = strlen(pwd);
    char* p = pwd + len - 1;
    while (*p != '/'){
      p--;
    }
    p++;
    //列印指令提示資訊
    printf("[%s@%s %s]$ ", pass->pw_name, hostname, p);
    //讀取指令
    fgets(cmd, LEN, stdin);
    cmd[strlen(cmd) - 1] = '\0';

    //實作重定向功能
    char* start = cmd;
    while (*start != '\0'){
      if (*start == '>'){
        type = 0; //遇到一個'>',輸出重定向
        *start = '\0';
        start++;
        if (*start == '>'){
          type = 1; //遇到第二個'>',追加重定向
          start++;
        }
        break;
      }
      if (*start == '<'){
        type = 2; //遇到'<',輸入重定向
        *start = '\0';
        start++;
        break;
      }
      start++;
    }
    if (*start != '\0'){ //start位置不為'\0',說明指令包含重定向内容
      while (isspace(*start)) //跳過重定向符号後面的空格
        start++;
    }
    else{
      start = NULL; //start設定為NULL,辨別指令當中不含重定向内容
    }

    //拆分指令
    myargv[0] = strtok(cmd, " ");
    int i = 1;
    while (myargv[i] = strtok(NULL, " ")){
      i++;
    }
    pid_t id = fork(); //建立子程序執行指令
    if (id == 0){
      //child
      if (start != NULL){
        if (type == 0){ //輸出重定向
          int fd = open(start, O_WRONLY | O_CREAT | O_TRUNC, 0664); //以寫的方式打開檔案(清空原檔案内容)
          if (fd < 0){
            error("open");
            exit(2);
          }
          close(1);
          dup2(fd, 1); //重定向
        }
        else if (type == 1){ //追加重定向
          int fd = open(start, O_WRONLY | O_APPEND | O_CREAT, 0664); //以追加的方式打開檔案
          if (fd < 0){
            perror("open");
            exit(2);
          }
          close(1);
          dup2(fd, 1); //重定向
        }
        else{ //輸入重定向
          int fd = open(start, O_RDONLY); //以讀的方式打開檔案
          if (fd < 0){
            perror("open");
            exit(2);
          }
          close(0);
          dup2(fd, 0); //重定向
        }
      }

      execvp(myargv[0], myargv); //child進行程式替換
      exit(1); //替換失敗的退出碼設定為1
    }
    //shell
    int status = 0;
    pid_t ret = waitpid(id, &status, 0); //shell等待child退出
    if (ret > 0){
      printf("exit code:%d\n", WEXITSTATUS(status)); //列印child的退出碼
    }
  }
  return 0;
}      

效果如圖:

萬字深剖 Linux I/O 原理

FILE 的檔案描述符🤔

通路檔案的本質都是通過檔案描述符進行通路的,而且庫函數又是對系統接口的封裝,是以在庫函數中的 FILE 結構體也必定存在檔案描述符 fd

我們在

頭檔案中可以看到下面這句代碼,也就是說 FILE 實際上就是struct _IO_FILE 結構體的一個别名。

typedef struct _IO_FILE FILE;      

接下來轉到 struct _IO_FILE 結構體的定義,其中我們可以看到一個名為 ​

​_fileno​

​ 的成員,這個成員實際上就是封裝的檔案描述符

struct _IO_FILE {

int _flags; /* High-order word is _IO_MAGIC; rest is flags. */

#define _IO_file_flags _flags

//緩沖區相關 /* The following pointers correspond to the C++ streambuf

protocol. / / Note: Tk uses the _IO_read_ptr and _IO_read_end

fields directly. / char _IO_read_ptr; /* Current read pointer /

char _IO_read_end; /* End of get area. / char _IO_read_base;

/* Start of putback+get area. / char _IO_write_base; /* Start of

put area. / char _IO_write_ptr; /* Current put pointer. / char

_IO_write_end; /* End of put area. / char _IO_buf_base; /* Start of reserve area. / char _IO_buf_end; /* End of reserve area. /

/ The following fields are used to support backing up and undo. */

char _IO_save_base; / Pointer to start of non-current get area. */

char _IO_backup_base; / Pointer to first valid character of backup

area */ char _IO_save_end; / Pointer to end of non-current get

area. */

struct _IO_marker *_markers;

struct _IO_FILE *_chain;

int _fileno; //封裝的檔案描述符

#if 0 int _blksize;

#else int _flags2;

#endif _IO_off_t _old_offset; /* This used to be _offset but it’s too small. */

#define __HAVE_COLUMN /* temporary / / 1+column number of pbase(); 0 is unknown. */ unsigned short _cur_column; signed char

_vtable_offset; char _shortbuf[1];

/* char* _save_gptr; char* _save_egptr; */

_IO_lock_t *_lock;

#ifdef _IO_USE_OLD_IO_FILE };

那我們再來聊聊檔案函數的底層:

fopen 函數會為使用者在上層調申請 FILE 結構體,傳回 FILE* 結構體指針,底層上調用 open 函數擷取檔案的 fd,并将 fd 交給 ​​

​_fileno​

​​ 填充

,這樣就完成了檔案的打開操作。其他的比如 fread、fwrite、fputs、fgets ,都會先根據我們傳入的檔案指針找到對應的FILE結構體,然後在FILE結構體當中找到檔案描述符,最後通過檔案描述符對檔案進行的一系列操作

我們以三種輸出函數為例:

#include <stdio.h>
#include <unistd.h>
int main()
{
  //c
  printf("hello printf\n");
  fputs("hello fputs\n", stdout);
  //system
  write(1, "hello write\n", 12);
  fork();
  return 0;
}      
萬字深剖 Linux I/O 原理

看到這結果是不是覺得淦!好怪。按照代碼邏輯的話,這裡應該隻會列印出三個句子對應三個函數,但是為什麼這裡有兩個函數出現了兩次呢?

不難發現,這裡重複的兩個函數都是 C 庫函數,我們就要牽扯到三種緩沖方式了:

無緩沖

行緩沖(對顯示器進行重新整理資料)

全緩沖(對磁盤檔案寫入資料)

直接執行可執行程式,将資料列印到顯示器時所采用的就是行緩沖,因為代碼當中每句話後面都有 \n,是以當我們執行完對應代碼後就立即将資料重新整理到了顯示器上

如果将運作結果重定向到 log.txt 檔案時,資料的重新整理政策就變為了全緩沖,此時使用 printf 和 fputs 列印的資料都列印到了C語言自帶的緩沖區當中,之後 fork 建立子程序時,由于程序間具有獨立性,而之後當父程序或是子程序對要重新整理緩沖區内容時,本質就是對父子程序共享的資料進行了修改,此時就需要對資料進行寫時拷貝,至此緩沖區當中的資料就變成了兩份,一份父程序的,一份子程序的,是以重定向到 log.txt 檔案當中 printf 和 puts 函數列印的資料就有兩份。但由于 write 是系統接口,我們可以将 write 看作是沒有緩沖區的,是以 write 列印的資料就隻列印了一份

​這個緩沖區是誰提供的?​

他是 C 自帶的,如果說這個緩沖區是作業系統提供的,那麼 printf、fputs 和 write 列印的資料重定向到檔案後都應該列印兩次

​這個緩沖區在哪?​

printf 是将資料列印到 stdout 裡面,而 stdout 就是一個 FILE* 指針,在 FILE 結構體當中還有一大部分成員是用于記錄緩沖區相關的資訊的,我們來看看底層代碼:

//緩沖區相關
 /* The following pointers correspond to the C++ streambuf protocol. /
 / Note: Tk uses the _IO_read_ptr and _IO_read_end fields directly. /
 char _IO_read_ptr; /* Current read pointer /
 char _IO_read_end; /* End of get area. /
 char _IO_read_base; /* Start of putback+get area. /
 char _IO_write_base; /* Start of put area. /
 char _IO_write_ptr; /* Current put pointer. /
 char _IO_write_end; /* End of put area. /
 char _IO_buf_base; /* Start of reserve area. /
 char _IO_buf_end; /* End of reserve area. /
 / The following fields are used to support backing up and undo. */
 char _IO_save_base; / Pointer to start of non-current get area. */
 char _IO_backup_base; / Pointer to first valid character of backup area */
 char _IO_save_end; / Pointer to end of non-current get area. */      

抗疫知道這裡緩沖區是由 C 提供,在 FILE 結構體當中進行維護,FILE 結構體當中不僅儲存了對應檔案的檔案描述符還儲存了使用者緩沖區的相關資訊

​作業系統有緩沖區嗎?​

答案是一定的,其實我們資料并不是直接重新整理到顯示器和磁盤上的,而是先重新整理到作業系統的緩沖區裡面,再經過緩沖區加載到顯示器和磁盤上,當然這裡我們先不關心作業系統的重新整理政策。

萬字深剖 Linux I/O 原理

因為作業系統是進行軟硬體資源管理的軟體,是以要将資料重新整理到具體外設硬體上,就必須要經過作業系統,看一下層狀結構圖也許會更清楚:

萬字深剖 Linux I/O 原理

inode🤔

磁盤檔案由兩部分構成,分别是檔案内容和檔案屬性。比如檔案名、檔案大小以及檔案建立時間等資訊都是檔案屬性,檔案屬性又被稱為元資訊

在指令行當中輸入​

​ls -l​

​,即可顯示目前目錄下各檔案的屬性資訊,各種檔案屬性排列如下:

萬字深剖 Linux I/O 原理

在 Linux 作業系統中,檔案的元資訊和内容是分離存儲的,

,因為系統當中可能存在大量的檔案,是以我們需要給每個檔案的屬性搞一個唯一的編号,即 inode 号也就是說,inode 是一個檔案的屬性集合,Linux 中幾乎每個檔案都有一個 inode,為了區分系統當中大量的 inode,我們為每個 inode 設定了 inode 編号,​

​ls -i​

​ 指令即可檢視目前目錄下的檔案和他的 inode 編号:

萬字深剖 Linux I/O 原理

無論是檔案内容還是檔案屬性,他們都是存儲在磁盤裡面的

磁盤🤔

磁盤是一種永久性存儲媒體,在計算機中,磁盤幾乎是唯一的機械裝置。與磁盤相對應的就是記憶體,記憶體是掉電易失存儲媒體,目前所有的普通檔案都是在磁盤中存儲的,磁盤在馮諾依曼體系結構當中既可以充當輸入裝置,又可以充當輸出裝置:

萬字深剖 Linux I/O 原理

尋址方案😋

對磁盤讀寫時,一般有以下 3 個步驟:

确定讀寫資訊的盤面

确定讀寫資訊的柱面

确定讀寫資訊的扇區

分區與存儲媒體😋

要了解檔案系統,我們必須要先将磁盤結構了解為線性的存儲媒體,比如說國小英語的錄音帶,你扯出錄音帶條條時有沒有想過複讀機讀取錄音帶的資訊,放完重來必要做倒帶的操作才能從頭開始,這就非常貼切線性結構了。

萬字深剖 Linux I/O 原理

磁盤分區😋

磁盤也被稱為塊裝置,以扇區為機關,一個扇區的大小通常為512位元組。如果以大小為 512G 的磁盤為例,該磁盤就可被分為十億多個扇區:

萬字深剖 Linux I/O 原理

計為了更好的管理磁盤,磁盤進行了分區,原理類似于将整個國家劃分為省市區縣進行管理,使用分區編輯器在磁盤上劃分幾個邏輯部分,盤片一旦劃分成數個分區,不同的目錄與檔案就可以存儲進不同的分區,分區越多,檔案的性質區分越細,Windows 磁盤就被分為 C 盤和 D 盤

Linux 也是可以檢視檔案的分區資訊:

ls /dev/vda* -l      

格式化😋

磁盤分區完成後就會進行格式化,格式化後每個分區 inode 數就會被确定下來,是以說格式化是對分區進行初始化的一種操作,會導緻所有資源被清除,本質上是對分區後各個區域寫入管理資訊

其中的管理資訊内容是由檔案系統決定的,不同的檔案系統格式化時管理資訊是不同的,常見的檔案系統有 EXT2、EXT3、XFS、NTFS 等

EXT2 存儲方案😋

而對于每一個分區來說,分區的頭部有一個啟動塊(Boot Block),對于該分區的其餘區域,EXT2 檔案系統會根據分區大小劃分為一個個的塊組(Block Group)

萬字深剖 Linux I/O 原理

啟動塊的大小是确定的,而塊組的大小是由格式化的時候确定的,并且不可以更改。

其次,每個組塊都有着相同的組成結構,每個組塊都由超級塊(Super Block)、塊組描述符表(Group Descriptor Table)、塊位圖(Block Bitmap)、inode位圖(inode Bitmap)、inode表(inode Table)以及資料表(Data Block)組成:

萬字深剖 Linux I/O 原理

Super Block: 存放檔案系統本身的結構資訊。主要有:Data Block和inode的總量、未使用的Data Block和inode的數量、一個Data Block和inode的大小、最近一次挂載時間。Super Block的資訊被破壞,可以說整個檔案系統結構就被破壞了

Group Descriptor Table: 塊組描述符表,描述該分區當中塊組的屬性資訊

Block Bitmap: 塊位圖中記錄着 Data Block 中哪個資料塊已經被占用或沒有被占用

inode Bitmap: inode 位圖中記錄着每個inode 是否空閑可用

inode Table: 檔案屬性,即每個檔案的inode。

Data Blocks: 檔案内容

因為 super block 極為重要,是以一般在其他塊組中會存在備援,友善損壞後拷貝恢複

此時我們就可以了解檔案建立了:

  1. 先通過周遊 inode 位圖找到一個空閑的 inode
  2. 再在 inode 表當中找到對應的 inode,并将檔案的屬性資訊填充進 inode 結構中。
  3. 将該檔案的檔案名和inode指針添加到目錄檔案的資料塊中

檔案寫入也是同理:

  1. 通過 inode 編号找到對應的 inode 結構
  2. 通過 inode 結構找到存儲該檔案内容的資料塊,并将資料寫入資料塊
  3. 若不存在資料塊或申請的資料塊已被寫滿,則通過周遊塊位圖的方式找到一個空閑的塊号,并在資料區當中找到對應的空閑塊,再将資料寫入資料塊,最後還需要建立資料塊和 inode 結構的對應關系(對應關系是通過數組進行維護的,該數組一般可以存儲 15 個元素,其中前 12 個元素分别對應檔案使用的 12 個資料塊,剩餘的三個元素分别是一級索引、二級索引和三級索引,當該檔案使用資料塊的個數超過12個時,可以用這三個索引進行資料塊擴充)

檔案删除也是同理:

其實删除并不會真正将檔案資訊删除,而隻是将其 inode 和資料塊号置為無效,是以删除檔案後短時間内是可以恢複的。

短時間内是個什麼意思呢,因為檔案對應的 inode 号和資料塊号被置為了無效,後續建立其他檔案或是對其他檔案進行寫入操作申請 inode 号和資料塊号時,可能會将該無效了的 inode号和資料塊号配置設定出去,此時删除檔案的資料就會被覆寫,也就無法恢複檔案了

這也就就是了為什麼拷貝檔案的時候很慢,而删除檔案的時候很快

檔案目錄也是同理:

Linux下一切皆檔案,目錄當然也會被看作為檔案。目錄有自己的屬性資訊,他 inode 結構中存儲的是目錄的屬性資訊,比如目錄的大小、目錄的擁有者等;目錄的資料塊當中存儲的就是該目錄下的檔案名以及對應檔案的 inode 指針。

注意: 檔案名并沒有存儲在自己的 inode 結構當中,而是存儲在該檔案所處目錄檔案的檔案内容當中。因為系統并不關心檔案名,他隻關心檔案的 inode ,而檔案名和 inode 指針存儲在其目錄檔案的檔案内容當中後,目錄通過檔案名和檔案的 inode 指針即可将檔案名和檔案内容及其屬性連接配接起來

軟連結🤔

檔案軟連結的建立可以通過這個指令:

ln -s myproc myproc-s      

效果如下:

萬字深剖 Linux I/O 原理

我們可以通過 ​

​ls -i​

​ 可以看到軟連結的 inode 與源檔案的 inode 是不同的,并且軟連結的大小比源檔案的大小要小得多!

萬字深剖 Linux I/O 原理

删除源檔案後軟連結檔案不能獨立存在,雖然仍保留檔案名,但卻不能執行或是檢視軟連結的内容

硬連結🤔

檔案硬連結的建立可以通過這個指令:

ln myproc myproc-h      

效果如下:

萬字深剖 Linux I/O 原理
萬字深剖 Linux I/O 原理

我們可以通過 ​

​ls -i​

​ 可以看到硬連結的 inode 與源檔案的 inode 是相同的,并且硬連結檔案的大小與源檔案的大小也是相同的,特别注意的是,當建立了一個硬連結檔案後,該硬連結檔案和源檔案的硬連結數都變成了 2

萬字深剖 Linux I/O 原理

是以硬連結檔案就是

,一個檔案有幾個檔案名,該檔案的硬連結數就是幾,這裡 inode 為 659031 的檔案有 myproc 和 myproc-h 兩個檔案名,是以該檔案的硬連結數為 2

與軟連接配接不同的是,當硬連結的源檔案被删除後,硬連結檔案仍能正常執行,隻是檔案的連結數減少了一個,但是硬連結可以同步修改多個不在或者同在一個目錄下的檔案名,其中一個修改後,所有與其有硬連結的檔案都會一起被修改