背景

在Linux上做过开发的应该对输入输出的重定向是非常熟悉的,但是估计大多数人都不知道背后的原理。最近在看Linux IO多路复用的实现原理时,select、pool、epool都围绕文件描述符来展开的,所以顺便看了一下文件描述符,本篇文章就围绕文件描述符总结一下这部分的知识,顺便实现一些可以加深理解的demo。

什么是文件描述符

文件描述符是一个非负整数,当我们在Linux系统上打开或者创建文件的时候,就会返回一个文件描述符。每一个文件描述符与一个打开的文件相对应,内核通过文件描述符来实现对文件的访问。

比如下面这个代码,就会通过open函数来打开一个文件,返回值就是文件描述符。

int fd;
if ((fd = open(filename, O_CREAT|O_WRONLY, 0666)) < 0){
    perror(filename);
    exit(1); 
}
printf("%d\n", fd);

默认文件描述符

当我们用shell运行一个程序的时候,默认会自动产生三个文件描述符。
0 标准输入
1 标准输出
2 标准错误

我们平时也是使用的这三个描述符来操作的重定向,比如下面这条命令:
./a.out 1>f1 2>f2

这三个文件描述符都是要关联设备的:
0 键盘 标准输入
1 显示器 标准输出
2 显示器 标准错误

我们在程序里面进行标准输出的时候,是输出给标准输出文件,也就是文件描述符
1代表的那个文件,内核通过文件描述符1去访问这个文件。

至于内容最后输出到屏幕,我猜想是通过显示器这个设备的驱动程序来完成的从标准输出文件到屏幕的显示。

文件描述符的产生规则

POSIX标准要求,我们的进程每次打开文件(包括socket)时,必须使用当前进程中最小可用的文件描述符。

POSIX是一个可移植操作系统借口,定义了一套标准,wiki地址

深入了解文件描述符

我们前面已经知道,每一个文件描述符会与一个打开的文件相对应,除此之外,还有很多其他情况,比如,不同的文件描述符可能指向同一个文件,相同的文件可以被不同的进程打开,也可以在同一个进程中被多次打开。下面,我们来依次看看这些情况是怎样产生的。

在进一步了解之前,我们需要了解Linux内核中维护的3个数据结构。

1.文件描述符表-进程级
2.打开文件表-系统级
3.i-node表-文件系统级

我们来看下面这个图示:

文件描述符表

内核为每一个进程维护一个文件描述符表,这个表中的每一个条目代表了一个文件描述符的相关信息。这个信息包括两部分,一个是文件描述符标志,一个是文件指针。

1.文件描述符标志:当前只定义了一个文件描述符标志FDCLOEXEC,为0时exec操作不关闭已经打开的文件描述符,为1时exec操作关闭已经打开的文件描述符。
2.文件指针:指向打开文件表中对应的文件条目。

我们可以看到,fd2 和 fd30指向了同一个文件,这说明文件描述符是可以复制的,不同的文件描述符可以指向同一个文件,这个操作可以由dup系列方法操作,为什么叫做系列呢,是因为由dup、dup2、dup3三个方法。

我们上面说的不同进程执行同一个文件,这是fork操作时,产生的一种情况,我们这里暂时不做详细分析,文章后面再进行补充。

打开文件表

内核对所有打开的文件维护一个系统级别的打开文件描述表(open file description table),简称打开文件表。表中条目称为打开文件描述体(open file description),存储了与一个打开文件相关的全部信息。这个信息包括三部分:

1.文件偏移量:调用read()和write()更新,调用lseek()直接修改
2.访问模式:由open()调用设置,例如:只读、只写或读写等
3.i-node对象指针

我们看这个表的话,就明白我们平时在程序中,操作文件时文件的偏移量、各种访问模式,背后的原理原来是系统通过这个表来为我们维护的。

i-node表

每个文件系统会为存储于其上的所有文件(包括目录)维护一个i-node表,i-node存储在磁盘设备上,内核在内存中维护了一个副本,这里的i-node表为后者。副本除了原有信息,还包括:引用计数(从打开文件描述体)、所在设备号以及一些临时属性,例如文件锁。这个表包括以下信息:

1.文件类型
2.访问权限
3.文件锁列表
4.文件大小

通过以上三个表,我们彻底了解了Linux内核对于文件维护相关的信息,也明白了我们平时编程时操作的文件背后的技术支撑。

我们已经了解了文件描述符背后的知识,下面,我们用编程的方式,来写几个文件描述符常用的demo,以便更深层次的来理解文件描述符。

操作文件描述符

我们可以通过内核提供我们的dup、dup2、dup3这三个函数来操作文件描述符,这三个函数的详细介绍,我们可以通过man手册来查看,以下是部分解释:

NAME
       dup, dup2, dup3 - duplicate a file descriptor

SYNOPSIS
       #include <unistd.h>

       int dup(int oldfd);
       int dup2(int oldfd, int newfd);

       #define _GNU_SOURCE             /* See feature_test_macros(7) */
       #include <fcntl.h>              /* Obtain O_* constant definitions */
       #include <unistd.h>

       int dup3(int oldfd, int newfd, int flags);

DESCRIPTION
       The dup() system call creates a copy of the file descriptor oldfd, using the lowest-numbered unused descriptor for the new descriptor.

       After a successful return, the old and new file descriptors may be used interchangeably.  They refer to the same open file description (see
       open(2)) and thus share file offset and file status flags; for example, if the file offset is modified by using  lseek(2)  on  one  of  the
       descriptors, the offset is also changed for the other.

       The two descriptors do not share file descriptor flags (the close-on-exec flag).  The close-on-exec flag (FD_CLOEXEC; see fcntl(2)) for the
       duplicate descriptor is off.

   dup2()
       The dup2() system call performs the same task as dup(), but instead of using the  lowest-numbered  unused  file  descriptor,  it  uses  the
       descriptor number specified in newfd.  If the descriptor newfd was previously open, it is silently closed before being reused.

       The steps of closing and reusing the file descriptor newfd are performed atomically.  This is important, because trying to implement equiv‐
       alent functionality using close(2) and dup() would be subject to race conditions, whereby newfd might be  reused  between  the  two  steps.
       Such reuse could happen because the main program is interrupted by a signal handler that allocates a file descriptor, or because a parallel
       thread allocates a file descriptor.

       Note the following points:

       *  If oldfd is not a valid file descriptor, then the call fails, and newfd is not closed.

       *  If oldfd is a valid file descriptor, and newfd has the same value as oldfd, then dup2() does nothing, and returns newfd.

   dup3()
       dup3() is the same as dup2(), except that:

       *  The caller can force the close-on-exec flag to be set for the new file descriptor by specifying O_CLOEXEC in flags.  See the description
          of the same flag in open(2) for reasons why this may be useful.

       *  If oldfd equals newfd, then dup3() fails with the error EINVAL.

RETURN VALUE
       On success, these system calls return the new descriptor.  On error, -1 is returned, and errno is set appropriately.

ERRORS
       EBADF  oldfd isn't an open file descriptor.

       EBADF  newfd is out of the allowed range for file descriptors (see the discussion of RLIMIT_NOFILE in getrlimit(2)).

       EBUSY  (Linux only) This may be returned by dup2() or dup3() during a race condition with open(2) and dup().

       EINTR  The dup2() or dup3() call was interrupted by a signal; see signal(7).

       EINVAL (dup3()) flags contain an invalid value.

       EINVAL (dup3()) oldfd was equal to newfd.

       EMFILE The per-process limit on the number of open file descriptors has been reached (see the discussion of RLIMIT_NOFILE in getrlimit(2)).


输出重定向DEMO

我们来写一个输出重定向的小demo,如下:

#include<stdio.h>
#include<fcntl.h>
#include<unistd.h>

void redirect_stdout(const char* filename){
    int fd;
    if ((fd = open(filename, O_CREAT|O_WRONLY, 0666)) < 0){
        perror(filename);
        return;
    }
    
    close(1);

    if (dup2(fd, STDOUT_FILENO) != 1){
        fprintf(stderr, "Unexpected dup failure\n");
        return;
    }

    close(fd);
}

int main(){
    printf("Hello world\n");
    fflush(stdout);

    redirect_stdout("foo");

    printf("Hello to you too, foo\n");
    fflush(stdout);

    return 0;
}

这个程序的执行结果是,Hello world输出到屏幕,Hello to you too, foo 输出到 foo 文件

如上,我们通过打开 foo 文件,获得这个文件的文件描述符,然后,我们通过dup2函数,把这个文件夹的描述符复制给标准输出,STDOUT_FILENO代表的就是标准输出文件描述符1,这样,我们再通过printf输出时,就会把内容输出到 foo 文件中。fflush是清空缓冲区的作用,以免内容没有及时输出。

不同进程操作文件描述符DEMO

我们上面还提到了另一种清空,就是不同进程操作同一个文件,这个我们可以通过fork函数来实现,比如,我们通过fork一个子进程的时候,这个子进程就会继承父进程的文件描述法。然后我们就可以在不同的进程中操作相同的文件。

这个示例我们暂时不写,等后面有机会深入了解fork的时候,再来补充。

总结

要是想对Linux IO模型有一个深入的学习,文件描述符这块知识是必须掌握的,后面随着学习的深入,还会继续来补充这篇文章。


原创文章,转载请注明地址: 文章地址