课程主页:http://www.cs.cmu.edu/afs/cs/academic/class/15213-f15/www/schedule.html

课程资料:https://github.com/EugeneLiu/translationCSAPP

课程视频:https://www.bilibili.com/video/av31289365/

这一讲介绍了系统级I/O。

Unix I/O

Unix I/O概述
  • Linux文件是一个$m$个字节的序列:

  • 所有的I/O设备都被模型化为文件:

    • /dev/sda2(/usr磁盘分区)
    • /dev/tty2(终端)
  • 内核也表示为文件:

    • /boot/vmlinuz-3.13.0-55-generic(内核映像)
    • /proc(内核数据结构)
  • 将设备优雅地映射为文件的方式,允许Linux内核引出一个简单,低级的应用接口,称为Unix I/O:

    • 打开关闭文件
      • open()和close()
    • 读写文件
      • read()和write()
    • 改变当前文件位置
      • 指示文件中下一个要读取或写入的偏移量
      • Iseek()
文件类型
  • 每个文件都有一个类型来表明其在系统中角色:
    • 普通文件:包含任意数据
    • 目录:一组相关文件的索引
    • 套接字:用于与另一台计算机上的进程进行通信
  • 其他文件类型
    • 命名通道(FIFO)
    • 符号链接
    • 字符和块设备
常规文件
  • 常规文件包含任意数据
  • 应用程序经常区分文本文件和二进制文件
    • 文本文件是仅包含ASCII或Unicode字符的常规文件
    • 二进制文件是所有其他文件
      • 例如目标文件,JPEG图像
    • 内核不知道区别!
  • 文本文件是文本行的序列
    • 文字行是由换行符(’\n’)终止的字符序列
    • 换行符为0xa,与ASCII换行符(LF)相同
  • 其他系统中的行尾(EOL)指示器
    • Linux和Mac OS:“ \n”(0xa)
      • 换行(LF)
    • Windows和Internet协议:“\r\n”(0xd,0xa)
      • 回车(CR),然后换行(LF)
目录
  • 目录由一系列链接组成

    • 每个链接都将文件名映射到文件
  • 每个目录至少包含两个条目

    • .是指向自身的链接
    • ..是指向目录层次结构中上级目录的链接
  • 用于操作目录的命令

    • mkdir:创建空目录
    • ls:查看目录内容
    • rmdir:删除该目录
  • 目录层次结构:

  • 目录层次结构中的位置用路径名指定

    • 绝对路径名以/开头,表示从根目录开始的路径
      • /home/droh/hello.c
    • 相对路径名表示当前工作目录的路径
      • ../home/droh/hello.c
打开文件
  • 打开文件会通知内核您已准备好访问该文件

    int fd;   /* file descriptor */
    
    if ((fd = open("/etc/hosts", O_RDONLY)) < 0) {
       perror("open");
       exit(1);
    }
  • 返回一个整数文件描述符

    • fd==-1表示发生错误
  • Linux shell程序创建的每个进程都以与终端相关联的三个打开文件开始运行:

    • 0:标准输入(stdin)
    • 1:标准输出(stdout)
    • 2:标准错误(stderr)
关闭文件
  • 关闭文件会通知内核您已完成对该文件的访问

    int fd;     /* file descriptor */
    int retval; /* return value */
    
    if ((retval = close(fd)) < 0) {
       perror("close");
       exit(1);
    }
  • 关闭一个已经关闭的文件是线程程序中灾难的根源(稍后会详细介绍)

  • 约定:始终检查返回代码

读取文件
  • 读取文件会将字节从当前文件位置复制到内存,然后更新文件位置

    char buf[512];
    int fd;       /* file descriptor */
    int nbytes;   /* number of bytes read */
    
    /* Open file fd ...  */
    /* Then read up to 512 bytes from file fd */
    if ((nbytes = read(fd, buf, sizeof(buf))) < 0) {
       perror("read");
       exit(1);
    }
  • 返回从文件fd读取到buf的字节数

    • 返回类型ssize_t是有符号整数
    • nbytes<0表示发生错误
    • 不足值(nbytes < sizeof(buf) )是可能的,并且不是错误!
写文件
  • 写入文件会将字节从内存复制到当前文件位置,然后更新当前文件位置

    char buf[512];
    int fd;       /* file descriptor */
    int nbytes;   /* number of bytes read */
    
    /* Open the file fd ... */
    /* Then write up to 512 bytes from buf to file fd */
    if ((nbytes = write(fd, buf, sizeof(buf)) < 0) {
       perror("write");
       exit(1);
    }
  • 返回从buf写入文件fd的字节数

    • nbytes<0表示发生错误
    • 与读取一样,不足值是可能的,并且不是错误!
例子
#include "csapp.h"

int main(void)
{
    char c;

    while(Read(STDIN_FILENO, &c, 1) != 0)
        Write(STDOUT_FILENO, &c, 1);
    exit(0);
}
关于不足值
  • 在以下情况下可能会发生不足值的情况:
    • 读取时遇到(文件结束)EOF
    • 从终端读取文本行
    • 读写网络套接字
  • 在以下情况下绝对不会发生不足值:
    • 从磁盘文件读取(EOF除外)
    • 写入磁盘文件
  • 最佳做法是始终允许不足值

RIO (robust I/O) 包

  • RIO是一组包装程序,可在应用程序中提供高效且强大的I/O,例如短计数限制的网络程序
  • RIO提供两种不同的函数
    • 无缓冲的二进制数据输入和输出
      • rio_readn和rio_writen
    • 文本行和二进制数据的缓冲输入
      • rio_readlineb和rio_readnb
      • 缓冲的RIO例程是线程安全的,可以在同一描述符上任意交织
  • 下载地址http://csapp.cs.cmu.edu/3e/code.html
无缓冲RIO输入输出
  • 与Unix read,write接口相同

    #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);
    
    Return: num. bytes transferred if OK,  0 on EOF (rio_readn only), -1 on error 
  • 对于在网络套接字上传输数据特别有用

    • rio_readn仅在遇到EOF时返回不足值
      • 仅在知道要读取多少字节时使用它
    • rio_writen从不返回不足值
    • 可以在同一描述符上任意交错对rio_readn和rio_writen的调用
  • rio_readn的实现:

    /*
     * rio_readn - Robustly read n bytes (unbuffered)
     */
    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 */
    }
带缓冲的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);
    
                              Return: num. bytes read if OK, 0 on EOF, -1 on error
    • rio_readlineb从文件fd读取最多为maxlen个字节的文本行,并将该行存储在usrbuf中
      • 对于从网络套接字读取文本行特别有用
    • 停止条件
      • 读取的最大字节数
      • 遇到EOF
      • 遇到换行符(’\n’)
    • rio_readnb从文件fd读取最多n个字节
    • 停止条件
      • 读取的最大字节数
      • 遇到EOF
    • 可以在同一描述符上任意交错对rio_readlineb和rio_readnb的调用
      • 警告:请勿插入对rio_readn的调用
带缓冲I/O:实现
  • 从文件读取

  • 文件具有关联的缓冲区,来保存已从文件读取但用户代码尚未读取的字节

  • 在Unix文件上的层次结构:

  • 结构中包含的所有信息

    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;
RIO例子
  • 将文本文件的行从标准输入复制到标准输出

    #include "csapp.h"
    
    int main(int argc, char **argv) 
    {
        int n;
        rio_t rio;
        char buf[MAXLINE];
    
        Rio_readinitb(&rio, STDIN_FILENO);
        while((n = Rio_readlineb(&rio, buf, MAXLINE)) != 0) 
    		Rio_writen(STDOUT_FILENO, buf, n);
        exit(0);
    }
    
    void Rio_readinitb(rio_t *rp, int fd)
    {
        rp->rio_fd = fd;
        rp->rio_cnt = 0;
        rp->rio_bufptr = rp->rio_buf;
    }

元数据,共享和重定向

文件元数据
  • 元数据是关于数据的数据,在这种情况下是文件数据

  • 内核维护的每个文件元数据

    • 用户使用stat和fstat函数访问
  • 返回的结构:

    /* 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;  /* 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))     /* Determine file type */
		type = "regular";
    else if (S_ISDIR(stat.st_mode))
		type = "directory";
    else
         type = "other";
    if ((stat.st_mode & S_IRUSR)) /* Check read access */
		readok = "yes";
    else
         readok = "no";

    printf("type: %s, read: %s\n", type, readok);
    exit(0);
}
linux> ./statcheck statcheck.c
type: regular, read: yes
linux> chmod 000 statcheck.c
linux> ./statcheck statcheck.c
type: regular, read: no
linux> ./statcheck ..
type: directory, read: yes
Unix内核如何表示打开的文件
  • 内核用三个相关的数据结构来表示打开的文件。

    • 描述符表:每个进程都有它独立的描述符表,它的表项是由进程打开的文件描述符来索引的。每个打开的描述符表项指向文件表中的一个表项。
    • 文件表:打开文件的集合是由一张文件表来表示的,所有的进程共享这张表。每个文件表的表项组成包括当前的文件位置、引用计数 (reference count)(即当前指向该表项的描述符表项数),以及一个指向v-node表中对应表项的指针。
    • v-node表:同文件表一样,所有的进程共享这张v-node表。每个表项包含stat结构中的大多数信息,包括st_mode和st_size成员。
  • 例子:两个描述符引用两个不同的打开文件。描述符1(stdout)指向终端,描述符4指向打开磁盘文件。

进程如何共享文件:fork
  • 子进程继承其父进程的打开文件

    • 注意:调用exec函数情形不变(使用fcntl进行更改)
  • 在调用fork之前:

  • 调用用fork之后:

I/O重定向
  • 问题:Shell如何实现I/O重定向?
    • linux>ls>foo.txt
  • 答:通过调用dup2(oldfd, newfd)函数
    • 将描述符表条目oldfd复制到条目newfd
例子
  • 步骤1:打开标准输出应该重定向到的文件

  • 步骤2:调用dup2(4,1)

    • 使fd=1(stdout)引用fd=4指向的磁盘文件

标准I/O

标准I/O函数
  • C标准库(libc.so)包含更高级别的标准I/O函数的集合
  • 标准I/O函数的示例:
    • 打开和关闭文件(fopen和fclose)
    • 读写字节(fread和fwrite)
    • 读写文本行(fgets和fputs)
    • 格式化的读写(fscanf和fprintf)
标准I/O流
  • 标准I/O模型将文件作为流打开

    • 文件描述符和内存中缓冲区的抽象
  • C程序以三个打开的流(在stdio.h中定义)开始

    • 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) */
      
      int main() {
          fprintf(stdout, "Hello, world\n");
      }
带缓冲I/O:动机
  • 应用程序经常一次读取/写入一个字符
    • getc, putc, ungetc
    • gets, fgets
      • 每次读取一行中一个字符,在换行处停止
  • 实现为Unix I/O调用很昂贵
    • 读写需要Unix内核调用
    • 大于10,000个时钟周期
  • 解决方案:缓冲读取
    • 使用Unix读取来获取字节块
    • 用户输入函数一次从缓冲区读取一个字节
      • 如果缓冲区为空则重新填充缓冲区
例子

实际的标准I/O缓冲
  • 可以使用Linux strace程序亲自看到这种缓冲作用:

    #include <stdio.h>
    
    int main()
    {
        printf("h");
        printf("e");
        printf("l");
        printf("l");
        printf("o");
        printf("\n");
        fflush(stdout);
        exit(0);
    }
    linux> strace ./hello
    execve("./hello", ["hello"], [/* ... */]).
    ...
    write(1, "hello\n", 6)               = 6
    ...
    exit_group(0)                        = ?

结束语

几种I/O的比较

Unix I/O的优劣势
  • 优点
    • Unix I/O是最通用,开销最低的I/O形式
      • 所有其他I/O软件包均使用Unix I/O函数实现
    • Unix I/O提供用于访问文件元数据的函数
    • Unix I/O函数是异步信号安全的,可以在信号处理程序中安全使用
  • 缺点
    • 处理不足值非常棘手且容易出错
    • 高效阅读文本行需要某种形式的缓冲,也很棘手且容易出错
    • 这两个问题均通过标准I/O和RIO包解决
标准 I/O的优劣势
  • 优点:
    • 缓冲通过减少读写系统调用的数量来提高效率
    • 不足值自动处理
  • 缺点:
    • 不提供访问文件元数据的函数
    • 标准I/O函数不是异步信号安全的,并且不适用于信号处理程序
    • 标准I/O不适合网络套接字上的输入和输出
选择I/O函数
  • 一般规则:使用最高级的I/O函数
    • 许多C程序员都可以使用标准I/O功能来完成所有工作
    • 但是,请务必了解您使用的函数!
  • 何时使用标准I/O
    • 使用磁盘或终端文件时
  • 何时使用原始Unix I/O
    • 内部信号处理程序,因为Unix I/O是异步信号安全的
    • 在极少数情况下,需要绝对最高的性能
  • 何时使用RIO
    • 当读写网络套接字时
    • 避免在套接字上使用标准I/O