这次回顾深入理解计算机系统第10章系统级I/O。

电子书地址:

http://eol.bnuz.edu.cn/meol/common/script/preview/download_preview.jsp?fileid=2169600&resid=242120&lid=28605

参考资料:

https://blog.csdn.net/u012294618/article/details/77979604

备注:图片和总结内容均来自于电子书。

第10章:系统级I/O

重要概念

  • 描述符
  • 输入输出的方式
    • 打开文件
    • 改变当前文件的位置
    • 读写文件
    • 关闭文件
  • EOF
  • 文件类型:
    • 普通文件(regular file)
    • 目录(directory)
    • 套接字(socket)
  • 路径:
    • 绝对路径名
    • 相对路径名
  • stat, fstat函数获得元数据
  • 目录流
  • 共享文件
    • 描述符表
    • 文件表
    • v-node表
  • I/O重定向
    • dup2
  • 双全工

Unix I/O

一个Linux文件就是一个$m$个字节的序列:

所有的I/O设备(例如网络、磁盘和终端)都被模型化为文件,而所有的输人和输出都被当作对相应文件的读和写来执行。这种将设备优雅地映射为文件的方式,允许Linux内核引出一个简单、低级的应用接口,称为Unix I/O,这使得所有的输人和输出都能以一种统一且一致的方式来执行:

  • 打开文件
    • 应用程序通过要求内核打开相应的文件来宣告它想访问一个I/O设备,内核返回一个小的非负整数,叫做描述符,它在后续对此文件的所有操作中标识这个文件,应用程序只需记住这个描述符;
    • Linux shell创建的每个进程开始时都有三个打开的文件:
      • 标准输入(描述符为0)
      • 标准输出(描述符为1)
      • 标准错误(描述符为2)
  • 改变当前文件的位置
    • 对于每个打开的文件,内核保持着一个文件位置$k$,初始为0。这个文件位置是从文件开头起始的字节偏移量。应用程序能够通过执行seek操作,显式地设置文件的当前位置为$k$。
  • 读写文件
    • 一个读操作就是从文件复制$n>0$个字节到内存,从当前文件位置$k$开始,然后将$k$增加到$k+n$ 。给定一个大小为$m$字节的文件,当 $k \ge m$ 时执行读操作会触发一个称为end-of-file(EOF)的条件,应用程序能检测到这个条件。注意在文件结尾处并没有明确的“EOF符号”。
    • 写操作就是从内存复制$n>0$个字节到一个文件,从当前文件位置$k$开始,然后更新$k$。
  • 关闭文件
    • 当应用完成了对文件的访问之后,它就通知内核关闭这个文件。作为响应,内核释放文件打开时创建的数据结构,并将这个描述符恢复到可用的描述符池中。无论一个进程因为何种原因终止时,内核都会关闭所有打开的文件并释放它们的内存资源。

文件

文件类型:

  • 普通文件(regular file)
    • 文本文件(text file):只含有ASCII或Unicode字符的普通文件;
      • Linux文本文件包含文本行序列,每行是一个字符序列,以“\n”结束;
      • Windows文本文件每行以“\r\n结束”;
    • 二进制文件(binary file):所有其他的文件;
    • 对内核而言,两者没有区别;
  • 目录(directory)
    • 包含一组链接的文件,每个链接都将一个文件名映射到一个文件,这个文件可能是另一个目录。
    • 每个目录至少含有两个文件条目:
      • “.”是到该目录自身的链接;
      • “..”是到目录层次结构中父目录的链接;
  • 套接字(socket)
  • 命名通道(named pipe)
  • 符号链接(symbolic link)
  • 字符和块设备(character and block device)

目录层次结构:

目录和文件名:

  • 每个进程都有一个当前工作目录(current working directory)来确定其在目录层次结构中的当前位置;
  • 目录层次结构中的位置用路径名(pathname) 来指定,路径名有两种形式:
    • 绝对路径名:
      • 以一个斜杠开始,表示从根节点开始的路径;
    • 相对路径名:
      • 以文件名开始,表示从当前工作目录开始的路径;

打开和关闭文件

open

open函数打开或创建文件:

#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>

int open(char *filename, int flags, mode_t mode);

返回:若成功则为新文件描述符,若出错为-1

说明:

  • open函数返回在进程中当前没有打开的最小文件描述符;

  • flags参数指明了进程打算如何访问这个文件:

    • O_RDONLY:只读;

    • O_WRONLY:只写;

    • O_RDWR:可读可写;

    • O_CREAT:如果文件不存在,就创建它的一个截断的(truncated)(空)文件;

    • O_TRUNC:如果文件已经存在,就截断它;

    • O_APPEND:在每次写操作前,设置文件位置到文件的结尾处;

    • 示例:

      • 以读的方式打开已存在的文件:

        fd = Open("foo.txt", O_RDONLY, 0);

        打开已存在的文件,并在后面添加数据:

        fd = Open("foo.txt", O_RDONLY|O_APPEND, 0);
  • mode指定新文件的访问权限:

    • 作为上下文的一部分,每个进程都有一个umask,它是通过调用umask函数来设置的。当进程通过带某个mode参数的open函数调用来创建一个新文件时,文件的访问权限位被设置为mode & umask。

    • 示例:

      #define DEF_MODE S_IRUSR|S_IWUSR|S_IRGRP|S_IWGRP|S_IROTH|S_IWOTH
      #define DEF_UMASK S_IWGRP|S_IWOTH
      
      umask(DEF_UMASK);
      fd = Open("foo.txt", O_CREAT|O_TRUNC|O_WRONLY, DEF_MODE);

      利用该代码打开的文件,文件的拥有者有读写权限,所有其他用户都有读权限;

    • 访问权限位:

close

进程通过调用close函数关闭一个打开的文件:

#include <unistd.h>

int close(int fd);

返回:若成功则为0,若出错则为-1

读和写文件

应用程序使用read和write函数执行输入输出:

#include <unistd.h>

ssize_t read(int fd, void *buf, size_t n);
返回:若成功则为读的字节数,若EOF则为0,若出错为-1ssize_t write(int fd, const void *buf, size_t n);
返回:若成功则为写的字节数,若出错则为-1

read函数说明:

  • read函数从描述符为fd的当前文件位置复制最多$n$个字节到内存位置buf;
  • 返回值-1表示一个错误,而返回值0表示EOF;
  • 否则,返回值表示的是实际传送的字节数量;

write函数说明:

  • write函数从内存位置buf复制至多$n$个字节到描述符的fd当前文件位置‘

在某些情况下,read和write传送的字节比应用程序要求的要少。这些不足值(short count)不表示有错误。出现这样情况的原因有:

  • 读时遇到EOF;
  • 从终端读文本行;
  • 读和写网络套接字(socket);
  • 对Linux管道(pipe)调用read和write时;

补充:

  • 除了EOF,在读写磁盘文件时将不会遇到不足值;
  • size_t为无符号整数,ssize_t为有符号整数;

用RIO包健壮地读写

课程老师提供了RIO包,RIO提供了两类不同的函数:

  • 无缓冲的输入输出函数;
  • 带缓冲的输入函数;

RIO的无缓冲的输入输出函数

应用程序通过调用rio_readn和rio_writen函数在内存和文件时间直接传送数据:

#include "csapp.h"

ssize_t rio_readn(int fd, void *usrbuf, size_t n);
ssize_t rio_writen(int fd, void *usrbuf, size_t n);

返回:若成功则为传送的字节数,若EOF则为0(只对rio_readn而言),若出错则为-1

实现:

/*
 * rio_readn - Robustly read n bytes (unbuffered)
 */
/* $begin rio_readn */
ssize_t rio_readn(int fd, void *usrbuf, size_t n) 
{
    size_t nleft = n;
    ssize_t nread;
    char *bufp = usrbuf;

    while (nleft > 0) {
	if ((nread = read(fd, bufp, nleft)) < 0) {
	    if (errno == EINTR) /* Interrupted by sig handler return */
		nread = 0;      /* and call read() again */
	    else
		return -1;      /* errno set by read() */ 
	} 
	else if (nread == 0)
	    break;              /* EOF */
	nleft -= nread;
	bufp += nread;
    }
    return (n - nleft);         /* Return >= 0 */
}
/* $end rio_readn */

/*
 * rio_writen - Robustly write n bytes (unbuffered)
 */
/* $begin rio_writen */
ssize_t rio_writen(int fd, void *usrbuf, size_t n) 
{
    size_t nleft = n;
    ssize_t nwritten;
    char *bufp = usrbuf;

    while (nleft > 0) {
	if ((nwritten = write(fd, bufp, nleft)) <= 0) {
	    if (errno == EINTR)  /* Interrupted by sig handler return */
		nwritten = 0;    /* and call write() again */
	    else
		return -1;       /* errno set by write() */
	}
	nleft -= nwritten;
	bufp += nwritten;
    }
    return n;
}
/* $end rio_writen */

说明:

  • 对同一个描述符,可以任意交错地调用rio_readn和rio_writen;

RIO的带缓冲的输入函数

函数接口:

#include "csapp.h"

void rio_readinitb(rio_t *rp, int fd);
返回:无。

ssize_t rio_readlineb(rio_t *rp, void *usrbuf,size_t maxlen);
ssize_t rio_readnb(rio_t *rp, void *usrbuf, size_t n);
返回:若成功则为读的字节数,若EOF则为0,若出错则为-1

说明:

  • rio_readinitb:每打开一个描述符,都会调用一次rio_readinitb函数,它将描述符fd和地址rp处的一个类型为rio_t的读缓冲区联系起来。
  • rio_readlineb:rio_readlineb函数从文件rp读出下一个文本行(包括结尾的换行符),将它复制到内存位置 usrbuf,并且用 NULL(零)字符来结束这个文本行。rio_readlineb函数最多读maxlen-1个字节,余下的一个字符留给结尾的NULL字符。超过 maxlen-1字节的文本行被截断,并用一个NULL字符结束。
  • rio_readnb:rio_readnb函数从文件rp最多读n个字节到内存位置usrbuf。
  • 对同一描述符, 对rio_readlineb和rio_readnb的调用可以任意交叉进行。
  • 然而,对这些带缓冲的函数的调用却不应和无缓冲的rio_readn函数交叉使用。

rio_t文件结构:

/* $begin rio_t */
#define RIO_BUFSIZE 8192
typedef struct {
    int rio_fd;                /* Descriptor for this internal buf */
    int rio_cnt;               /* Unread bytes in internal buf */
    char *rio_bufptr;          /* Next unread byte in internal buf */
    char rio_buf[RIO_BUFSIZE]; /* Internal buffer */
} rio_t;
/* $end rio_t */

rio_readinitb代码:

/* $begin rio_readinitb */
void rio_readinitb(rio_t *rp, int fd) 
{
    rp->rio_fd = fd;  
    rp->rio_cnt = 0;  
    rp->rio_bufptr = rp->rio_buf;
}
/* $end rio_readinitb */

rio_read函数

RIO读程序的核心是rio_read函数,该函数是Linux read函数的带缓冲版本,其作用方式如下:

  • 如果缓冲区为空,则调用read填满缓冲区;
  • 从读缓冲区复制min(n, rp->rio_cnt)中较小的值个字节到用户缓冲区,然后返回复制的字节数;
/* $begin rio_read */
static ssize_t rio_read(rio_t *rp, char *usrbuf, size_t n)
{
    int cnt;

    while (rp->rio_cnt <= 0) {  /* Refill if buf is empty */
	rp->rio_cnt = read(rp->rio_fd, rp->rio_buf, sizeof(rp->rio_buf));
	if (rp->rio_cnt < 0) {
	    if (errno != EINTR) /* Interrupted by sig handler return */
		return -1;
	}
	else if (rp->rio_cnt == 0)  /* EOF */
	    return 0;
	else 
	    rp->rio_bufptr = rp->rio_buf; /* Reset buffer ptr */
    }

    /* Copy min(n, rp->rio_cnt) bytes from internal buf to user buf */
    cnt = n;          
    if (rp->rio_cnt < n)   
	cnt = rp->rio_cnt;
    memcpy(usrbuf, rp->rio_bufptr, cnt);
    rp->rio_bufptr += cnt;
    rp->rio_cnt -= cnt;
    return cnt;
}
/* $end rio_read */

rio_read函数可以代替read函数,利用该函数实现rio_readlineb和rio_readnb的方式如下:

  • rio_readnb和rio_read结构相似;
  • rio_readlineb最多调用maxlen-1次rio_read,每次调用都从读缓冲区返回一个字节,然后检查该字节是否是结尾的换行符号;

代码:

/* $begin rio_readnb */
ssize_t rio_readnb(rio_t *rp, void *usrbuf, size_t n) 
{
    size_t nleft = n;
    ssize_t nread;
    char *bufp = usrbuf;
    
    while (nleft > 0) {
	if ((nread = rio_read(rp, bufp, nleft)) < 0) 
            return -1;          /* errno set by read() */ 
	else if (nread == 0)
	    break;              /* EOF */
	nleft -= nread;
	bufp += nread;
    }
    return (n - nleft);         /* return >= 0 */
}
/* $end rio_readnb */

/* 
 * rio_readlineb - Robustly read a text line (buffered)
 */
/* $begin rio_readlineb */
ssize_t rio_readlineb(rio_t *rp, void *usrbuf, size_t maxlen) 
{
    int n, rc;
    char c, *bufp = usrbuf;

    for (n = 1; n < maxlen; n++) { 
        if ((rc = rio_read(rp, &c, 1)) == 1) {
	    *bufp++ = c;
	    if (c == '\n') {
                n++;
     		break;
            }
	} else if (rc == 0) {
	    if (n == 1)
		return 0; /* EOF, no data read */
	    else
		break;    /* EOF, some data was read */
	} else
	    return -1;	  /* Error */
    }
    *bufp = 0;
    return n-1;
}
/* $end rio_readlineb */

读取文件元数据

应用程序能够通过调用stat和fstat函数,检索到关于文件的信息(有时也称为文件的元数据(metadata))。

#include <unistd.h>
#include <sys/stat.h>

int stat(const char *filename, struct stat *buf);
int fstat(int fd, struct stat *buf);
返回:若成功则为0,若出错则为-1

说明:

  • stat函数以一个文件名作为输人,并填写下图所示的一个stat数据结构中的各个成员。
  • fstat函数是相似的,只不过是以文件描述符而不是文件名作为输入。

stat数据结构:

/* Metadata returned by the stat and fstat functions */
struct stat {
    dev_t			st_dev;		/* Device */
    ino_t			st_ino;		/* inode */
    mode_t			st_mode;	/* Protection and 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; /* Block size 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*/
};

重要字段说明:

  • st_size:文件的字节数大小;
  • st_mode:编码了文件访问许可位;
    • 宏谓词可以确定st_mode成员的文件类型:
      • S_ISREG(m):这是一个普通文件吗?
      • S_ISDIR(m):这是一个目录文件吗?
      • S_ISSOCK(m):这是一个网络套接字吗?

读取目录内容

opendir以路径名为参数,返回指向目录流的指针,流是对条目有序列表的抽象,在这里是指目录项的列表:

#include <sys/types.h>
#include <dirent.h>

DIR *opendir(const char *name);

返回:若成功,则为处理的指针;若出错,则为NULL

每次对readdir的调用返回的都是指向流dirp中下一个目录项的指针,或者,如果没有更多目录项则返回NULL:

#include <dirent.h>

struct dirent *readdir(DIR *dirp);

返回:若成功,则为指向下一个目录项的指针﹔若没有更多的目录项或出错,则为NULL

说明:

  • 如果出错,则readdir返回NULL,并设置errno;

每个目录项都是一个结构,其形式如下:

struct dirent {
    ino_t d_ino;		/*inode number */
    char d_name[256];	/*Filename */
};

d_name是文件名,d_ino是文件位置。

closedir关闭流并释放所有的资源:

#include <dirent.h>

int closedir(DIR *dirp);

返回:成功为0;错误为-1

共享文件

内核用三个相关的数据结构来表示打开的文件:

  • 描述符表:
    • 每个进程都有它独立的描述符表,它的表项是由进程打开的文件描述符来索引的。
    • 每个打开的描述符表项指向文件表中的一个表项。
  • 文件表:
    • 打开文件的集合是由一张文件表来表示的,所有的进程共享这张表。
    • 每个文件表的表项组成(针对我们的目的)包括当前的文件位置、引用计数(reference count)(即当前指向该表项的描述符表项数),以及一个指向v-node表中对应表项的指针。
    • 关闭一个描述符会减少相应的文件表表项中的引用计数,内核不会删除这个文件表表项,直到它的引用计数为零。
  • v-node表:
    • 同文件表一样,所有的进程共享这张v-node表。
    • 每个表项包含stat结构中的大多数信息,包括st_mode和st_size成员。

示例1:

示例2:

如果以同一个filename调用open函数两次,就会发生下图这种情况:

示例3:

  • 父子进程是如何共享文件的,假设在调用fork之前,父进程有下图所示的打开文件。
  • 然后,下图展示了调用fork后的情况:子进程有一个父进程描述符表的副本。
  • 父子进程共享相同的打开文件表集合,因此共享相同的文件位置。
  • 一个很重要的结果就是,在内核删除相应文件表表项之前,父子进程必须都关闭了它们的描述符。

I/O重定向

Linux shell提供了I/O重定向操作符,允许用户将磁盘文件和标准输入输出联系起来,具体实现是使用dup2函数:

#include <unistd.h>
int dup2(int oldfd, int newfd);

返回:若成功则为非负的描述符,若出错则为-1

说明:

  • dup2函数复制描述符表表项oldfd到描述符表表项newfd,覆盖描述符表表项newfd以前的内容。
  • 如果newfd已经打开了,dup2会在复制oldfd之前关闭newfd。

示例:

调用dup2(4, 1)之前:

描述符1对应于文件A,描述符4对应于文件B,此时文件A, B的引用计数都等于1。

调用dup2(4, 1)之后:

两个描述符都对应于文件B,文件A被关闭,对应的文件表和v-node表被删;此后任何写到标准输出的数据都被重定向到文件B。

标准I/O

  • C语言定义了一组高级输入输出函数,称为标准I/O库;

  • 标准I/O库将一个打开的文件模型化为一个流;

  • 对于程序员而言,一个流就是一个指向FILE类型的结构的指针;

  • 每个ANSI C程序开始时都有三个打开的流stdin、stdout和stderr,分别对应于标准输入、标准输出和标准错误:

    #include <stdio.h>
    extern FILE *stdin;		/* Standard input (descriptor 0) */
    extern FILE *stdout;	/* Standard output (descriptor 1) */
    extern FILE *stderr;	/* Standard error (descriptor 2) */

该使用哪些I/O函数

I/O函数总结:

I/O函数使用原则:

  • G1:只要有可能就使用标准I/O;
  • G2:不要使用scanf或rio_readlineb来读二进制文件,像scanf或rio_readlineb这样的函数是专门设计来读取文本文件的;
  • G3:对网络套接字的I/O使用RIO函数。
    • 具体原因见10.11。

补充:

标准I/O是双全工的,即可以同时处理输入输出。