【讀薄 CSAPP】陸 系統輸入輸出

【讀薄 CSAPP】陸 系統輸入輸出

學習目標

  1. 理解 Unix I/O 的設計與應用
  2. 瞭解不同的文件類型
  3. 理解文件描述符(file descriptor)及在讀寫中的應用
  4. 瞭解元數據的相關概念及訪問方法
  5. 理解輸入輸出重定向的實現機制
  6. 簡單瞭解 C 標準庫中的標準 I/O 函數
  7. 知道如何根據不同場景選擇對應的 I/O 方式

Unix I/O

在 Linux 中,文件實際上可以看做是字節的序列。更有意思的是,所有的 I/O 設備也是用文件來表示的,比如:

  • ./dev/sda2 (/usr 磁盤分區)
  • /dev/tty2 (終端)

甚至連內核也是用文件來表示的:

  • /boot/vmlinuz-3.13.0-55-generic (內核鏡像)
  • /proc (內核數據結構)

因爲 I/O 設備也是文件,所以內核可以利用稱爲 Unix I/O 的簡單接口來處理輸入輸出,比如使用 open()close() 來打開和關閉文件,使用 read()write() 來讀寫文件,或者利用 lseek() 來設定讀取的偏移量等等。

爲了區別不同文件的類型,會有一個 type 來進行區別:

  • 普通文件:包含任意數據
  • 目錄:相關一組文件的索引
  • 套接字 Socket:和另一臺機器上的進程通信的類型

其實還有一些比較特別的類型,但是這裏提一下,不深入瞭解:

  • Named pipes(FIFOs)
  • Symbolic links
  • Character and block devices

普通文件

普通的文件包含任意數據,應用一般來說需要區分出文本文件和二進制文件。文本文件只包含 ASCII 或 Unicode 字符。除此之外的都是二進制文件(對象文件, JPEG 圖片, 等等)。對於內核來說其實並不能區分出箇中的區別。

文本文件就是一系列的文本行,每行以 \n 結尾,新的一行是 0xa,和 ASCII 碼中的 line feed 字符(LF) 一樣。不同系統用用判斷一行結束的符號不同(End of line, EOL),如:

  • Linux & Mac OS:\n (0xa)

    • line feed(LF)
  • Windows & 網絡協議:\r\n (0xd 0xa)

    • Carriage return(CR) followed by line feed(LF)

目錄

目錄包含一個鏈接(link)數組,並且每個目錄至少包含兩條記錄:

  • .(dot) 當前目錄
  • ..(dot dot) 上一層目錄

用來操作目錄的命令主要有 mkdir, ls, rmdir。目錄是以樹狀結構組織的,根目錄是 /(slash)。

內核會爲每個進程保存當前工作目錄(cwd, current working directory),可以用 cd 命令來進行更改。我們通過路徑名來確定文件的位置,一般分爲絕對路徑和相對路徑。

接下來我們瞭解一下基本的文件操作。

打開文件

在使用文件之前需要通知內核打開該文件:

int fd; // 文件描述符 file descriptor

if ((fd = open("/etc/hosts", O_RDONLY)) < 0)
{
    perror("open");
    exit(1);
}

返回值是一個小的整型稱爲文件描述符(file descriptor),如果這個值等於 -1 則說明發生了錯誤。每個由 Linux shell(注:感謝網友 yybear 的勘誤) 創建的進程都會默認打開三個文件(注意這裏的文件概念):

  • 0: standard input(stdin)
  • 1: standard output(stdout)
  • 2: standar error(stderr)

關閉文件

使用完畢之後同樣需要通知內核關閉文件:

int fd;     // 文件描述符
int retval; // 返回值

int ((retval = close(fd)) < 0)
{
    perror("close");
    exit(1);
}

如果在此關閉已經關閉了的文件,會出大問題。所以一定要檢查返回值,哪怕是 close() 函數(如上面的例子所示)

讀取文件

在打開和關閉之間就是讀取文件,實際上就是把文件中對應的字節複製到內存中,並更新文件指針:

char buf[512];
int fd;
int nbytes;

// 打開文件描述符,並從中讀取 512 字節的數據
if ((nbytes = read(fd, buf, sizeof(buf))) < 0)
{
    perror("read");
    exit(1);
}

返回值是讀取的字節數量,是一個 ssize_t 類型(其實就是一個有符號整型),如果 nbytes < 0 那麼表示出錯。nbytes < sizeof(buf) 這種情況(short counts) 是可能發生的,而且並不是錯誤。

寫入文件

寫入文件是把內存中的數據複製到文件中,並更新文件指針:

char buf[512];
int fd;
int nbytes;

// 打開文件描述符,並向其寫入 512 字節的數據
if ((nbytes = write(fd, buf, sizeof(buf)) < 0)
{
    perror("write");
    exit(1);
}

返回值是寫入的字節數量,如果 nbytes < 0 那麼表示出錯。nbytes < sizeof(buf) 這種情況(short counts) 是可能發生的,而且並不是錯誤。

綜合上面的操作,我們可以來看看 Unix I/O 的例子,這裏我們一個字節一個字節把標準輸入複製到標準輸出中:

#include "csapp.h"

int main(void)
{
    char c;
    while(Read(STDIN_FILENO, &c, 1) != 0)
        Write(STDOUT_FILENO, &c, 1);
    exit(0);
}

前面提到的 short count 會在下面的情形下發生:

  • 在讀取的時候遇到 EOF(end-of-file)
  • 從終端中讀取文本行
  • 讀取和寫入網絡 sockets

但是在下面的情況下不會發生

  • 從磁盤文件中讀取(除 EOF 外)
  • 寫入到磁盤文件中

最好總是允許 short count,這樣就可以避免處理這麼多不同的情況。

元數據

元數據是用來描述數據的數據,由內核維護,可以通過 statfstat 函數來訪問,其結構是:

struct stat
{
    dev_t           st_dev;     // Device
    ino_t           st_ino;     // inode
    mode_t          st_mode;    // Protection & file type
    nlink_t         st_nlink;   // Number of hard links
    uid_t           st_uid;     // User ID of owner
    gid_t           st_gid;     // Group ID of owner
    dev_t           st_rdev;    // Device type (if inode device)
    off_t           st_size;    // Total size, in bytes
    unsigned long   st_blksize; // Blocksize for filesystem I/O
    unsigned long   st_blocks;  // Number of blocks allocated
    time_t          st_atime;   // Time of last access
    time_t          st_mtime;   // Time of last modification
    time_t          st_ctime;   // Time of last change
}

對應的訪問例子:

int main (int argc, char **argv)
{
    struct stat stat;
    char *type, *readok;
    
    Stat(argv[1], &stat);
    if (S_ISREG(stat.st_mode)) // 確定文件類型
        type = "regular";
    else if (S_ISDIR(stat.st_mode))
        type = "directory";
    else
        type = "other";
    
    if ((stat.st_mode & S_IRUSR)) // 檢查讀權限
        readok = "yes";
    else
        readok = "no";
    
    printf("type: %s, read: %s\n", type, readok);
    exit(0);
}

重定向

瞭解了具體的結構之後,我們來看看內核是如何表示已打開的文件的。其實過程很簡單,每個進程都有自己的描述符表(Descriptor table),然後 Descriptor 1 指向終端,Descriptor 4 指向磁盤文件,如下圖所示:

img

這裏有一個需要說明的情況,就是使用 fork。子進程實際上是會繼承父進程打開的文件的。在 fork 之後,子進程實際上和父進程的指向是一樣的,這裏需要注意的是會把引用計數加 1,如下圖所示

img

瞭解了這個,我們我們就可以知道所謂的重定向是怎麼實現的了。其實很簡單,只要調用 dup2(oldfd, newfd) 函數即可。我們只要改變文件描述符指向的文件,也就完成了重定向的過程,下圖中我們把原來指向終端的文件描述符指向了磁盤文件,也就把終端上的輸出保存在了文件中:

img

標準輸入輸出

C 標準庫中包含一系列高層的標準 IO 函數,比如

  • 打開和關閉文件: fopen, fclose
  • 讀取和寫入字節: fread, fwrite
  • 讀取和寫入行: fgets, fputs
  • 格式化讀取和寫入: fscanf, fprintf

標準 IO 會用流的形式打開文件,所謂流(stream)實際上是文件描述符和緩衝區(buffer)在內存中的抽象。C 程序一般以三個流開始,如下所示:

#include <stdio.h>
extern FILE *stdin;     // 標準輸入 descriptor 0
extern FILE *stdout;    // 標準輸出 descriptor 1
extern FILE *stderr;    // 標準錯誤 descriptor 2

int main()
{
    fprintf(stdout, "Hello, Da Wang\n");
}

接下來我們詳細瞭解一下爲什麼需要使用緩衝區,程序經常會一次讀入或者寫入一個字符,比如 getc, putc, ungetc,同時也會一次讀入或者寫入一行,比如 gets, fgets。如果用 Unix I/O 的方式來進行調用,是非常昂貴的,比如說 readwrite 因爲需要內核調用,需要大於 10000 個時鐘週期。

解決的辦法就是利用 read 函數一次讀取一塊數據,然後再由高層的接口,一次從緩衝區讀取一個字符(當緩衝區用完的時候需要重新填充)

總結

前面介紹了兩種 I/O 方法,Unix I/O 是最底層的,通過系統調用來進行文件操作,在這之上是 C 的標準 I/O 庫,對應的函數爲:

  • Unix I/O: open, read, write, lseek, stat, close
  • Standard C I/O: fopen, fdopen, fread, fwrite, fscanf, fprintf, sscanf, sprintf, fgets, fputs, fflush, fseek, fclose

Unix I/O 是最通用最底層的 I/O 方法,其他的 I/O 包都是在 Unix I/O 的基礎上進行構建的,值得注意的一點是,Unix I/O 中的方法都是異步信號安全(async-signal-safe)的,也就是說,可以在信號處理器中調用。因爲比較底層和基礎的緣故,需要處理的情況非常多,很容易出錯。高效率的讀寫需要用到緩衝區,同樣容易出錯,這也就是標準 C 庫着重要解決的問題。

標準 C I/O 提供了帶緩存訪問文件的方法,使用的時候幾乎不用考慮太多,但是如果我們想要得到文件的元信息時,就還是得使用 Unix I/O 中的 stat 函數。另外標準 C I/O 中的函數都不是異步信號安全(async-signal-safe)的,所以並不能在信號處理器中使用。最後,標準 C I/O 不適合用於處理網絡套接字。

參考鏈接

特別緻謝

致謝:wdxtub
鏈接:http://wdxtub.com/csapp/thin-csapp-6/2016/04/16/

發佈了53 篇原創文章 · 獲贊 21 · 訪問量 8346
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章