六星经典CSAPP-笔记(10)系统IO

六星经典CSAPP-笔记(10)系统I/O

1.Unix I/O

所有语言的运行时系统都提供了高抽象层次的I/O操作函数。例如,ANSI C在标准I/O库中提供了诸如printf和scanf等I/O缓冲功能的函数;C++中则重载了<<和>>用来支持读写。在Unix系统中,这些高层次的函数基于Unix的系统I/O函数来实现,多数时候我们都无需直接使用底层的Unix I/O。但学习Unix系统I/O能更好地理解一些系统概念,而且当高层次的函数不适用时我们也能轻松地实现想要的功能,例如访问文件的元数据。

Unix/Linux将各种I/O设备统一而优雅地抽象为文件,在此基础上提供了一套非常简洁的低层次的API接口,也就是Unix I/O。这套API主要由以下五个函数组成:

  • open():请求内核打开一个file,从而应用代码能够访问file对应的I/O设备。内核会返回一个非负整数,叫做描述符descriptor。内核会为打开的file维护一组数据结构(学习Linux内核时了解了一些),可以把描述符想成file在内核中的id,后续各种操作都需要它。
  • lseek():修改当前file的操作位置k,即从file开端的字节偏移量。读写文件时会自动移动k,也可以显式调用lseek()移动k。
  • read():从位置k开始,从file拷贝大于0个字节到内存中。当k超过文件的总字节大小时,会触发一个条件end-of-file(EOF)
  • write():类似地,从内存拷贝大于0个字节到file的位置k处,并更新k。
  • close():通知内核释放file在内核中对应的数据结构,将描述符放回到资源池中。进程结束时,内核会自动关闭所有打开的file。

EOF到底是什么东西?
首先必须明确一点,没有EOF字符这种东西。EOF是一种状态或条件,由操作系统内核去检测是否达到这种条件。当应用程序从read()中读取到0时,就说明达到EOF条件了。对于磁盘file,EOF表示位置k超过了文件大小。对于网络连接file,EOF表示连接的一端关闭了连接,另一端就会检测到EOF。

2.打开关闭文件

下面详细学习一下open()和close()函数:

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

/** Returns: new file descriptor if OK, ?1 on error */
int open(char *filename, int flags, mode_t mode);

#include <unistd.h>

/** Returns: zero if OK, ?1 on error */
int close(int fd);

flags参数指明想要怎样访问文件,它可以由几个模式组合而成:

  • O_RDONLY:只读
  • O_WRONLY:只写
  • O_RDWR:读写
  • O_CREAT:文件不存在则创建
  • O_TRUNC:文件存在则truncate它
  • O_APPEND:写文件前先将位置k置为末尾,即追加模式

mode参数指明新建文件的访问权限,此参数可以省略。要注意的是:每个进程都有一个由umask()函数设置的umask,open()函数最终创建出的文件的访问权限是mode & ~umask

  • S_IRUSR/S_IWUSR/S_IXUSR:Owner的读、写、执行权限
  • S_IRGRP/S_IWGRP/S_IXGRP:Owner所在组中成员的读、写、执行权限
  • S_IROTH/S_IWOTH/S_IXOTH:其他任何用户的读、写、执行权限

3.读写数据

read()和write()至多拷贝n个字节,返回值:-1表示错误,0表示EOF,大于0则表示实际拷贝的字节数。

#include <unistd.h>

/** Returns: number of bytes read if OK, 0 on EOF, ?1 on error */
ssize_t read(int fd, void *buf, size_t n);

/** Returns: number of bytes written if OK, ?1 on error */
ssize_t write(int fd, const void *buf, size_t n);

ssize_t和size_t有什么区别?
size_t被定义为unsigned int,而ssize_t被定义为int。因为read()和write()要返回-1表示发生错误,所以要使用ssize_t作为返回值。

#include <unistd.h>

int main(int argc, char const *argv[])
{
    char c;

    while(read(STDIN_FILENO, &c, 1) != 0) {
        write(STDOUT_FILENO, &c, 1);
    }

    return 0;
}

在以下几种情况下,read()和write()可能会返回少于n个字节:

  • 没有数据可读了:假如我们以50字节chunk读取,而只有20个字节可读,调用read()就会遇到EOF。
  • 从终端读取:每次只能读取到终端上的一行文本。
  • 读/写Socket:Socket内部的缓冲限制和网络延迟会导致只能读取一部分数据。同理,进程间的Unix管道pipe、IPC等读写的情况也类似。

而当你从硬盘读取数据时,除非读到末尾触发EOF,你永远也不会碰到返回小于n字节的情况。同样写数据时也从不会碰到这种情况。

4.文件元数据

可以通过文件名或描述符获得文件的元数据。

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

/** Returns: 0 if OK, ?1 on error */
int stat(const char *filename, struct stat *buf);
int fstat(int fd, struct stat *buf);

/* 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 */
};

例如,下面是一个结合了文件打开关闭和元数据读取的小例子,首先新建文件,然后利用fstat()函数查看新创建文件的访问权限,并以类似ls命令的格式打印出来:

// printf, getchar
#include <stdio.h>
// malloc
#include <stdlib.h>
// open, mode_t
#include <fcntl.h>
#include <sys/types.h>
#include <sys/stat.h>
// close
#include <unistd.h>

mode_t getumask();
char *mode2str(mode_t mode);

int main(int argc, char const *argv[])
{
    int fd;
    struct stat stat;

    /* 
     * 1.Print default umask. 
     * The typical default value for the process umask is:
     *      S_IWGRP | S_IWOTH  (octal 022)
     */     
    printf("umask: %s\n", mode2str(getumask()));

    // 2.Create a new file
    if ((fd = open("foo.txt", 
                O_WRONLY | O_CREAT, 
                S_IRUSR | S_IWUSR)) == -1) {
        fprintf(stderr, "Create file failed\n");
        exit(1);
    }

    // 3.Check if new file mode = mode & ~mask
    if (fstat(fd, &stat) == 0)
        printf("file mode: %s\n", mode2str(stat.st_mode));
    else
        printf("Get metadata failed\n");

    // 4.Close file
    close(fd);
    return 0;
}

/**
 * There is a getumask(), but only specified in glib_c
 * , not portable.
 * @return      current umask
 */
mode_t getumask()
{
    mode_t mode = umask(0);
    umask(mode);
    return mode;
}

/**
 * Acted like ‘ls -l‘
 * @param  mode file mode
 * @return      human-read
 */
char *mode2str(mode_t mode)
{
    char *str = malloc(10 * sizeof(char));
    int i = 0;

    str[i++] = S_ISREG(mode) ? ‘F‘ : ‘D‘;

    str[i++] = mode & S_IRUSR ? ‘R‘ : ‘-‘;
    str[i++] = mode & S_IWUSR ? ‘W‘ : ‘-‘;
    str[i++] = mode & S_IXUSR ? ‘X‘ : ‘-‘;

    str[i++] = mode & S_IRGRP ? ‘R‘ : ‘-‘;
    str[i++] = mode & S_IWGRP ? ‘W‘ : ‘-‘;
    str[i++] = mode & S_IXGRP ? ‘X‘ : ‘-‘;

    str[i++] = mode & S_IROTH ? ‘R‘ : ‘-‘;
    str[i++] = mode & S_IRWXO ? ‘W‘ : ‘-‘;
    str[i++] = mode & S_IXOTH ? ‘X‘ : ‘-‘;

    return str;
}

5.文件共享

Unix有很多种共享文件的方式,在学习之前先要了解Unix内核为每个文件维护了哪些数据结构。正是有这些数据结构的存在,才产生了多种多样的共享方式:

  • 描述符表(descriptor table):每个进程都有一张独立的描述符表,每个entry中都保存了打开file的描述符和指向文件表的指针。
  • 文件表(file table):文件表是所有进程共享的,每个entry保存了当前位置,指向文件的描述符的个数(可能有多个)和指向v-node表的指针。当引用个数减少为0时,内核会删除掉该文件表项。
  • v-node表(v-node table):v-node表也是所有进程共享的,每个entry保存了元数据stat的信息。

下面就看一下最常见的三种情况。

5.1 不共享

技术分享

5.2 同一文件被打开多次

技术分享

5.3 子进程继承父进程已打开文件

技术分享

下面是一个小例子,open_twice()尝试重复打开一个文件两次,然后用两次得到的描述符从文件中读取一个字符,结果读到的是’f’,说明v-node表项相同,但file table未发生共享。而inherit_parent_fd()则是打开文件后,fork一个子进程,子进程先读取一个字符,父进程等子进程结束后再读取一个字符,结果读到的是’o’,说明file table发生共享,子进程的读取导致位置k发生偏移

// printf
#include <stdio.h>
// exit
#include <stdlib.h>
// open, seek
#include <fcntl.h>
// read, write
#include <unistd.h>
// wait
#include <wait.h>

void open_twice(char *filename);
void inherit_parent_fd(char *filename);

int main(int argc, char const *argv[])
{
    open_twice("foo.txt");
    inherit_parent_fd("foo.txt");

    return 0;
}

void open_twice(char *filename)
{
    int fd1, fd2;
    char c;

    fd1 = open(filename, O_RDONLY);
    fd2 = open(filename, O_RDONLY);

    read(fd1, &c, 1);
    read(fd2, &c, 1);

    printf("c = %c\n", c);
}

void inherit_parent_fd(char *filename)
{
    int fd;
    char c;

    fd = open(filename, O_RDONLY);
    if (fork() == 0) {
        read(fd, &c, 1);
        exit(0);
    }

    wait(NULL);
    read(fd, &c, 1);
    printf("c = %c\n", c);
}

6.标准I/O

6.1 标准I/O库

ANSI C定义了一组更多层次的I/O函数,叫做标准I/O库(libc),提供了Unix C外的另一种选择:

  • 打开关闭:fopen()和fclose()
  • 读写字节:fread()和fwrite()
  • 读写字符:fgets()和fputs()
  • 格式化I/O:scanf()和printf()

技术分享

标准I/O库将打开的file抽象为stream。对于程序员来说,stream就是指向FILE类型的指针。stream或者说FILE类型,其实= file描述符 + buffer,其目的就是在内部维护一块缓冲区,从而避免频繁调用开销很大的系统调用。此外,每个ANSI C程序启动时都会自动打开三个stream,对应标准输入、输出、错误流:

#include <stdio.h>

extern FILE *stdin;     /* Standard input (descriptor 0) */
extern FILE *stdout;    /* Standard output (descriptor 1) */
extern FILE *stderr;    /* Standard error (descriptor 2) */

大多数C程序员在其整个职业生涯中都只使用标准I/O库,而不会直接使用底层的Unix I/O,这也是推荐的做法。但标准I/O库在处理网络全双工通信时会有些限制:

  • 限制1:调用output函数后,要调用fflush,fseek,fsetpos或rewind后,才能调用input函数。因为对Socket使用lseek是非法的,所以可以在每次调用input前,先调用fflush重置读取位置能够解决此问题。
  • 限制2:调用input函数后,要调用fseek,fsetpos或rewind,才能调用output函数,否则会遇到EOF。此问题只能为读和写分别打开两个FILE来解决。

6.2 I/O重定向

Unix Shell提供了I/O重定向操作符,例如unix> ls > foo.txt。这个功能是由dup2()函数来完成的。dup2会拷贝oldfd的描述符表entry覆盖到newfd的entry,假如之前newfd是打开状态的,那么dup2会先关闭newfd再开始拷贝。例如,默认stdout对应fd1,假设foo.txt对应fd4,那么dup2(4, 1)会将fd4的描述符表entry覆盖到fd1,所以最终stdout会和fd4一样,都指向foo.txt文件的file table表项。

#include <unistd.h>

/** Returns: nonnegative descriptor if OK, ?1 on error */
int dup2(int oldfd, int newfd);

郑重声明:本站内容如果来自互联网及其他传播媒体,其版权均属原媒体及文章作者所有。转载目的在于传递更多信息及用于网络分享,并不代表本站赞同其观点和对其真实性负责,也不构成任何其他建议。