系统级I/O

概述

输入/输出(Input/Output,I/O)是在内存和外部设备(如磁盘驱动器、终端、网络适配器)之间拷贝数据的过程。输入操作是从I/O设备拷贝数据到内存,输出操作是从内存拷贝数据到I/O设备。

Linux将一切设备抽象为文件,常见的I/O操作包括打开文件、读文件、写文件等。Linux中的大多数文件I/O只需要用到5个函数:open、read、write、lseek和close。

文件描述符

对于内核而言,所有打开的文件都通过文件描述符引用。文件描述符是一个非负整数(int类型),当打开一个现有文件或者创建一个新文件时,内核向进程返回一个文件描述符。当读写一个文件时,将文件描述符作为参数传递给read或write。文件描述符的变化范围是0~OPEN_MAX - 1,默认情况下,OPEN_MAX的值为1024,也即文件描述符的变化范围是[0, 1024)。

对于Linux 3.2.0及以后的版本,文件描述符的变化范围几乎是无限的,它只受到系统配置的内存总量、整型的字长以及系统管理员所配置的软限制和硬限制的约束。

按照惯例,每个进程默认打开三个文件描述符:

文件描述符 magic number 含义
STDIN_FILENO 0 标准输入
STDOUT_FILENO 1 标准输出
STDERR_FILENO 2 标准错误

文件在内核中的表现形式

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

  • 描述符表(descriptor table)。每个进程都有它独立的描述符表,它的表项是由进程打开的文件描述符来索引的。每个打开的描述符表项指向文件表的一个表项。
  • 文件表(file table)。打开文件的集合是由一张文件表来表示的,所有的进程共享这张表。每个文件表的表项包含:当前文件位置(current file offset)、引用计数(reference count,当前指向该表项的描述符表项数)、指向i-node表的中对应表项的指针。关闭一个描述符会减少相应的文件表表项中的引用计数。当引用计数为零时,内核才将这个文件表表项删除。
  • i-node表(i-node table)。所有进程共享i-node表,每个表项包含stat结构中的大部分信息,包括st_mode和st_size成员。

下面列举三种打开文件在内核中的表现状态:

  • 每个描述符对应一个不同的文件,如下图所示,这是最典型的情况,没有共享文件:

  • 多个文件描述符也可以通过不同的打开文件表表项来引用同一个文件。如下图所示,以同一个pathname调用open函数两次,就会发生这种情况。在这种情况下,每个文件描述符都有它自己的文件位置,所以对不同描述符的读操作可以从文件的不同位置获取数据:

  • 父子进程共享相同的打开文件表集合,因此共享相同的文件位置,下图是在调用fork之后,父子进程打开文件的状态,可以看到文件A和文件B所对应的文件表表项的引用计数变为2,值得注意的是,在内核删除相应文件表表项之前,父子进程必须关闭它们的文件描述符:

open

读写文件之前,必须打开文件,获得对应的文件描述符。可以使用open函数打开文件:

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

int open(const char *pathname, int flags);
int open(const char *pathname, int flags, mode_t mode);

open函数有两个原型,均可用来打开pathname指定的文件。函数参数的含义如下:

  • pathname :要打开或创建文件的名字
  • flags :指明打开文件的方式
  • mode :指定新文件的访问权限位
  • return :若成功返回新文件描述符,出错返回-1

flags指明打开文件的方式

访问模式 描述
O_RDONLY 只读
O_WRONLY 只写
O_RDWR 可读可写

上面三种访问模式每次能选且只能选一个。另外,flags也可以是一个或者更多位掩码,为写操作提供额外的提示:

打开方式 描述
O_APPEND 每次写时都追加到文件的尾端
O_CREAT 如果文件不存在则创建它,并且用用mode指定该新文件的访问权限位
O_EXCL 如果同时指定了O_CREAT,并且文件已经存在,则出错。使用O_CREAT | O_EXCL可以测试一个文件是否存在,如果不存在,则创建此文件,这使测试和创建两者成为一个原子操作。
O_NONBLOCK 如果pathname引用的是一个FIFO、块文件或者字符文件,则为文件的本次打开操作和后续I/O操作设置非阻塞模式。
O_SYNC 使每次write等待物理I/O操作完成,包括有该write操作引起的文件属性更新所需的I/O
O_TRUNC 如果文件存在,并且以O_WRONLY或O_RDWR成功打开,则将其长度截断为0,即清空文件内容

mode指定新文件的访问权限位

mode参数在创建文件是发挥作用,它指定新文件的访问权限位,这些位定义在sys/stat.h中 :

掩码 对应位 描述
S_IRWXU 00700 拥有者能够读、写、执行这个文件
S_IRUSR 00400 拥有者能够读这个文件
S_WRUSR 00200 拥有者能够写这个文件
S_IXUSR 00100 拥有者能够执行这个文件
S_IRWXG 00070 拥有者所在组的成员能够读、写、执行这个文件
S_IRGRP 00040 拥有者所在组的成员能够读这个文件
S_WRGRP 00020 拥有者所在组的成员能够写这个文件
S_IXGRP 00010 拥有者所在组的成员能够执行这个文件
S_IRWXO 00007 其他人能够读、写、执行这个文件
S_IROTH 00004 其他人能够读这个文件
S_WROTH 00002 其他人能够写这个文件
S_IXOTH 00001 其他人能够执行这个文件

一个示例

需要注意几点:

  • open每次返回的文件描述符总是当前进程中没有打开的最小描述符
  • 每个进程都有一个umask,可以通过掉umask函数来设置。当进程通过带mode参数的open函数调用来创建一个新文件时,文件的访问权限位被设置为mode & ~umask

下面使用umask函数设置进程中的umask值为0022,并且使用open函数创建一个新文件:

// open_test.c
#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>
#include <stdio.h>
#include <stdlib.h>
#include <errno.h>

int main()
{
    int fd;
    
    /* 修改umask值为 00022 */
    umask(S_IWGRP | S_IWOTH);
    /* 以读写的方式打开文件 */
    if((fd = open("test.txt", O_RDWR | O_CREAT | O_TRUNC, 0777)) < 0) {
        fprintf(stderr, "open error : %s", strerror(errno));
        exit(0);
    }
    
    /* 成功创建新文件 */
    printf("create file successfully! fd = %d\n\n", fd);
    
    close(fd);
    exit(0);
}

如果open成功执行,那么将在程序的工作目录下创建一个新文件test.txt,该文件的访问权限为:0777 & (~0022) = 0755,即-rwxr-xr-x。我们在程序工作目录下运行ls -lh test.txt,结果如下:

$ ./open_test
create file successfully! fd = 3

$ ls -lh test.txt
-rwxr-xr-x 1 hwg hwg 0  4月 28 20:10 test.txt

从上面的输出结果可以得知,open返回的文件描述符为3,因为进程的0、1、2文件描述符均被打开,分别之前标准输入、标准输出、标准错误,此时进程最小的未使用文件描述符就是3,因此成功调用open返回3

creat

除了使用open,指定O_CREAT访问模式来创建一个新文件,还可以使用creat函数创建新文件:

#include <fcntl.h>

// @pathname :文件名
// @mode     :新文件访问权限
// return    :若成功则返回只写打开的文件描述符,出错返回-1
int creat(const char *pathname, mode_t mode);

这个函数与open(path, O_WRONLY | O_CREAT | O_TRUNC, mode)等价。creat的一个不足是它只能以只写方式打开所创建的文件。

close

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

#include <unistd.h>

// @fd : 要关闭文件的文件描述符
// return : 若成功则为0,出错则为-1
int close(int fd);

值得注意的有三点:

  • 关闭一个已关闭的文件描述符会出错
  • 关闭一个文件时,会释放该进程加在该文件上的所有记录锁
  • 当一个进程关闭时,内核会自动关闭它所有打开的文件

read

使用read函数从打开的文件中读数据:

#include <unistd.h>

// @fd      : 文件描述符
// @buf     : 缓冲区
// @nbytes  : 期望读的字节数
// return   : 若成功则为读到的字节数,若EOF则为0,若出错则为-1(并设置errno)
ssize_t read(int fd, void *buf, size_t nbytes);

read函数从描述符为fd的当前文件位置拷贝最多nbytes个字节到内存位置buf,注意这里返回值最多是nbytes,因为在某些情况下,read读到的字节数少于要求读的字节数:

  • 读普通文件时遇到EOF,在读到要求字节数之前已到达文件尾端,下一次调用read时将返回0。
  • 从终端读文本行,一次最多读一行,返回值等于文本行大小
  • 从网络套接字读数据时,网络中的缓存机制可能造成返回值小于所要求读的字节数
  • 从管道或FIFO读时,如果管道包含的字节数少于要求读的字节数,则read只返回实际可用的字节数
  • 在读取任何数据之前被信号中断,返回-1,并设置errno为EINTR
  • 如果文件以O_NONBLOCK模式打开,则文件为非阻塞模式,当文件没有数据可读时,read返回-1,并设置errno为EAGAIN

可见,read函数的使用比较复杂,我们可以封装一个Read函数尽可能减少调用的复杂性,下面给出Read的一个示例:

#include <unistd.h>
#include <errno.h>

ssize_t Read(int fd, void *buf, size_t n)
{
    size_t nleft = n;
    size_t nread;
    char *pbuf = buf;
    
    while(nleft > 0) {
        if((nread = read(fd, pbuf, nleft)) < 0) {
            /* 被信号中断 */
            if(errno == EINTR)
                continue;
            /* 没有数据可读 */
            else if(errno == EAGAIN)
                continue;
            /* 出错 */
            else
                return -1;
        }
        else if(nread == 0)     /* EOF */
            break;
        else {
            nleft -= nread;
            pbuf += nread;
        }
    }
    
    return n-left;
}

write

使用write函数向打开的文件写数据:

#include <unistd.h>

// @fd      :文件描述符
// @buf     :写入的内容
// @nbytes  :写入的字节数
// return   : 如果成功则为写入的字节数,出错则为-1(并设置errno)
ssize_t write(int fd, const void *buf, size_t nbytes);

write的返回值通常与nbytes的值相同,但是有时候返回值也有可能比nbytes的值小:

  • 写普通文件时,如果磁盘已满或者超过了进程的文件长度限制,write返回-1
  • 如果文件以O_NONBLOCK模式打开,调用write时会返回-1,并设置errno为EAGAIN
  • 当write在写数据的时候,被信号中断,返回-1,并设置errno为EINTR
  • 写网络套接字时,网络中的缓存机制可能造成返回值小于所要求写的字节数

同样,为了降低write的调用复杂性,我们可以封装一个Write函数,下面给出Write的一个示例:

#include <unistd.h>
#include <errno.h>

ssize_t Write(int fd, void *buf, size_t n)
{
    size_t nleft = n;
    ssize_t nwritten;
    char *pbuf = buf;
    
    while(nleft > 0) {
        if((nwritten = (fd, pbuf, nleft)) <= 0) {
            if(errno == EINTR || errno == EAGAIN)
                continue;
            else 
                return -1;
        }
        nleft -= nwritten;
        pbuf += nwritten;
    }
    return n;
}

lseek

每个打开都有一个与其相关联的当前文件偏移量(current file offset),用以度量从文件开始处计算的字节数。当打开一个文件时,除非指定O_APPEND选项,否则偏移量被设置为0。

可以调用lseek函数显式地为一个打开文件设置偏移量:

#include <unistd.h>

// @fd      : 文件描述符
// @offset  : 偏移量,解释与whence有关
// @whence  : 偏移量的位置
// return : 若成功则为新的文件偏移量,出错则为-1(并设置errno)
off_t lseek(int fd, off_t offset, int whence);

offset的解释与whence的值相关:

whence 含义 offset的解释
SEEK_SET 绝对偏移量 将该文件的偏移量设置为距文件开始处offset个字节
SEEK_CUR 相对于当前位置的偏移量 将该文件的偏移量设置为其当前值加offset,offset可正可负
SEEK_END 相对于文件尾端的偏移量 将该文件的偏移量设置为文件长度加offset,offset可正可负

值得注意的几点:

  • 如果文件描述符指向的是一个管道、FIFO或网络套接字,则lseek返回-1,并将errno设置为ESPIPE
  • lseek仅将当前的文件偏移量记录在内核的文件表表项中,并不引起任何I/O操作
  • 文件偏移量可以大于文件的当前长度,在这种情况下,对该文件的下一次写操作,将加长该文件,并在文件中构成一个空洞,这个空洞的所有字节被读为0。在Linux中,文件中的空洞并不要求在磁盘上作用存储区。

下面用一个例子来验证空洞文件:

// filehole.c

#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>
#include <stdio.h>
#include <stdlib.h>
#include <errno.h>
#include <unistd.h>

char buf1[] = "abcdefghij";
char buf2[] = "ABCDEFGHIJ";

void sys_err(const char *msg);

int main()
{
    int fd;
    
    /* 创建一个文件file.hole */
    if((fd = open("file.hole", O_WRONLY | O_CREAT | O_TRUNC)) < 0) 
        sys_err("open error");
    
    /* 写入10个字节后,当前文件位置为10 */
    if(write(fd, buf1, 10) != 10)
        sys_err("buf1 write error");
        
    /* 将当前文件位置设置为16384 */
    if(lseek(fd, 16384, SEEK_SET) == -1)
        sys_err("lseek error");
        
    /* 再写入10个字节后,当前文件位置为16384 */
    if(write(fd, buf2, 10) != 10)
        err_sys("buf2 write error");
    
    exit(0);
}

void sys_err(const char *msg)
{
    fprintf(stderr, "%s : %s", msg, strerr(errno));
    exit(0);
}

编译运行程序:

$ gcc -Wall filehole.c -o filehole

$ ./filehole            # 运行程序
$ ls -l file.hole       # 检查含有空洞的文件大小
 8 ---x-wx--T 1 hwg hwg 16394  4月 28 22:22 file.hole
$ od -c file.hole       # 观察实际内容
0000000   a   b   c   d   e   f   g   h   i   j  \0  \0  \0  \0  \0  \0
0000020  \0  \0  \0  \0  \0  \0  \0  \0  \0  \0  \0  \0  \0  \0  \0  \0
*
0040000   A   B   C   D   E   F   G   H   I   J
0040012

使用od命令观察file.hole文件的实际内容,命令行中的-c标志表示以字符方式打印文件内容。从中可以看到,文件中间的30个未写入直接都被读成0。

为了证明该文件中确实有一个空洞,我在这里创建一个包含与file.hole一样大的文件——file.nohole(以16394个a字符填充),将file.hole与file.nohole进行比较:

$ ls -ls file.hole file.nohole 
 8 ---x-wx--T 1 hwg hwg 16394  4月 28 22:22 file.hole
20 -r----x--t 1 hwg hwg 16394  4月 28 22:29 file.nohole

虽然两个文件的长度均为16394,但是无空洞的文件占用了20个磁盘块,而具有空洞的文件只占用8个磁盘块。

dup2

Linux的外壳提供了I/O重定向操作符,允许用户将磁盘文件和标准输入输出关联起来,实现I/O重定向可以使用dup2函数

#include <unistd.h>

// @oldfd : 要拷贝的描述符表项
// @newfd : 目的描述符表项
// return : 若成功则为非负的描述符,若出错则为-1
int dup2(int oldfd, int newfd);

dup2函数拷贝描述符表项oldfd到描述符表项newfd,覆盖描述符表项newfd以前的内容,如果newfd以及打开,dup2会在拷贝oldfd之前关闭newfd。

假设一个进程当前打开的文件描述符如下图所示,文件描述符1指向文件A(引用计数为1),文件描述符4指向文件B(引用计数为1)。

接着我们调用dup2(4,1)后,现在两个描述符都指向文件B,如下图所示。文件A已经被关闭了,并且它的文件表和i-node表表项也被删除;文件B的引用计数增加1,此后,写到标准输出的数据都被重定向到文件B。

参考资料

  • Randal E. Bryant, David R. O’Hallaron, 布赖恩特,等. 深入理解计算机系统[M]. 机械工业出版社, 2011.
  • W. Richard Stevens, Stephen A. Rago, 史蒂文斯, 等. UNIX 环境高级编程[M]. 人民邮电出版社, 2014.
posted @ 2017-05-16 20:02  west000  阅读(262)  评论(0编辑  收藏  举报