Bootstrap

Linux:深入了解fd文件描述符

目录

1. 文件分类

2. IO函数

2.1 fopen读写模式

2.2 重定向

2.3 标准文件流

3. 系统调用

3.1 open函数认识

3.2 open函数使用

3.3 close函数

3.4 write函数

3.5 read函数

4. fd文件描述符

4.1 标准输入输出

4.2 什么是文件描述符

4.3 语言级文件操作


1. 文件分类

文件=文件内容+文件属性

因此,一个文件里面没有写入内容,也是占据空间的。

在C语言中,我们访问一个文件之前,都必须先调用fopen接口打开文件,这是为什么呢?下面是一份访问文件操作的C语言代码。

#include <stdio.h>

int main()
{
    FILE* fp = fopen("log.txt", "w");
    if (fp == NULL)
    {
        perror("fopen");
        return 1;
    }

    const char *message = "hello file\n";
    int i = 0;
    while(i < 4)
    {
        fputs(message, fp);
        i++;
    }

    fclose(fp);
    return 0;
}

当执行可执行程序时,会在当前目录下创建一个log.txt文件,并往log.txt文件写入几行文字。但是这并不意味着文件在代码编写完毕或编译成可执行程序时已经打开。实际上,文件时在程序运行并且执行到fopen函数调用时,如果调用成功,文件才会被打开。所以是谁在访问文件?本质上是进程在访问。

进程是运行程序的实例,保存在内存当中。在文件没打开之前,它是存储在磁盘中。程序的执行本质上是给CPU执行程序里的代码并进行操作。根据冯诺依曼体系结构,CPU不能直接访问外设数据,它只能访问并处理内存数据。因此,为了实现对文件的读写操作,进程需要将文件从磁盘中加载到内存当中,这个过程被称之为打开文件。

文件从磁盘中加载到内存中,加载的通常是文件的内容或属性。一个进程可以同时打开多个文件,那么多个进程打开的文件数量可能会有上百个。这么多的文件在内存当中肯定也要被管理起来。如何管理文件?先描述,在组织。 在内核中,文件=文件内核数据结构+文件内容

而进程也有自己的内核数据结构task_struct。所以研究打开的文件,是在研究进程和文件的关系,可以转换成进程内核数据结构和文件内核数据结构的关系。

2. IO函数

2.1 fopen读写模式

fopen函数的第二个参数,是决定文件打开后读写的方式。 带上+号表示即可读也可写。

#include <stdio.h>

int main()
{
    FILE* fp = fopen("log.txt", "w");
    if (fp == NULL)
    {
        perror("fopen");
        return 1;
    }

    const char *message = "hello world\n";
    int i = 0;
    while(i < 4)
    {
        fputs(message, fp);
        i++;
    }

    fclose(fp);
    return 0;

}

 上面的代码中,fopen函数读写方式是“w”,它会将文件内容长度截断为零,或者创建不存在的文件,再进行写入。log.txt文件原本文本内容是“hello file”,运行filecode程序之后,变成了“hello world”。

#include <stdio.h>

int main()
{
    FILE* fp = fopen("log.txt", "w");
    if (fp == NULL)
    {
        perror("fopen");
        return 1;
    }

    fclose(fp);
    return 0;

}

上面的代码使用“w”模式打开文件后,什么都不做。原本log.txt文件内部有四行文本,运行该程序后,什么都没打印出来,说明已经被清空。

#include <stdio.h>

int main()
{
    FILE* fp = fopen("log.txt", "a");
    if (fp == NULL)
    {
        perror("fopen");
        return 1;
    }

    const char *message = "hello file\n";
    int i = 0;
    while(i < 4)
    {
        fputs(message, fp);
        i++;
    }

    fclose(fp);
    return 0;

}

fopen函数的第二个参数传入“a”,表示再原有的文件内容末尾追加信息。如下面所示。

2.2 重定向

Linux中,也有类似fopen的“w”模式下的操作。如echo命令后面加上字符串,默认是打印在显示屏上的,如果加上“>”符号并在后面跟上文件名,就会将该文件内容先清空,再写入字符串。

如果你只想清空文件内容,可以直接再命令行上输入">"符号并加上文件名。

如果想做到在文件末尾追加内容,可以使用“>>”符号,类似fopen的append模式。

2.3 标准文件流

任何一个程序启动时,会打开三个标准文件流。C语言中有三个标准输入输出文件流,分别是:

  • stdin,标准输入,对应的是键盘,从键盘获取。
  • stdout,标准输出,对应的是显示器,输出到显示器。
  • stderr,标准错误,对应的是显示器,输出到显示器。

标准文件流的数据类型就是C语言标准库提供的文件流指针FILE*,C语言对键盘和显示器进行包装,成为三个文件流,之后我们对键盘和显示器就可以通过文件指针的形式进行访问。

实际上,其实是进程会打开这个三个标准输入输出流。C++中的标准文件流是cin、cout、cerr。java也会有对应的标准输入输出。其他任何编程语言都会带有标准输入输出,所有语言都具有的性质,那么就要上升到系统的高度来看待这个问题。

那么我们有几种方法打印文本到显示器上?

#include <stdio.h>

int main()
{
    printf("printf\n");
    fputs("fputs\n", stdout);
    fwrite("fwrite\n", 1, 7, stdout);
    fprintf(stdout, "fprintf\n");

    return 0;
}

3. 系统调用

我们使用C语言提供的标准库,会对键盘,显示器和磁盘进行访问,访问的对象都是硬件。操作系统作为硬件和软件的管理者,不会允许用户直接访问硬件。所以,C语言文件操作接口函数,不可能直接越过操作系统直接访问硬件,底层一定要封装对应的文件类系统调用。

3.1 open函数认识

open函数就是fopen函数封装的系统调用接口。第一个参数是文件的路径。

第二个参数是一个标记位。上面五个是常见的选项,都是宏。O_RDONLY表示只读,O_WRONLY表示只写。O_RDWR表示可读可写。O_APPEND表示追加。O_CREAT表示传入路径文件不存在,新建该文件。

flags是一个整型变量,一般有32个比特位,每个比特位0和1就能代表某个选项的存在,所以说flags实际上是一个32比特位的位图。上面的选项是只有一个比特位为1的值。

那怎么实现的呢,下面有代码实例。

#include <stdio.h>

#define ONE (1 << 0)   // 00001
#define TWO (1 << 1)   // 00010
#define THREE (1 << 2) // 00100
#define FOUR (1 << 3)  // 01000
#define FIVE (1 << 4)  // 10000

void Test(int flags)
{
    if (flags & ONE)
    {
        printf("one\n");
    }
    if (flags & TWO)
    {
        printf("two\n");
    }
    if (flags & THREE)
    {
        printf("three\n");
    }
    if (flags & FOUR)
    {
        printf("four\n");
    }
    if (flags & FIVE)
    {
        printf("five\n");
    }
}

int main()
{
    printf("-----------------------------\n");
    Test(ONE);
    printf("-----------------------------\n");
    Test(TWO);
    printf("-----------------------------\n");
    Test(ONE | THREE);
    printf("-----------------------------\n");
    Test(FIVE | TWO | ONE);
    printf("-----------------------------\n");
    Test(ONE | TWO |THREE | FOUR);
    printf("-----------------------------\n");

    return 0;
}

 首先需要定义几个宏值,使用左移符号,可以获得只有特定比特位为1的值。Test函数通过按位与实现判断。main函数中,如果想打印多个值,可以将宏选项通过按位或组合起来。

3.2 open函数使用

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

int main()
{
    open("log.txt", O_WRONLY | O_CREAT);

    return 0;
}

 使用open函数暂时不考虑返回值,使用O_WRONLY和O_CREAT选项的组合,会发现创建出来的文件权限是乱码。

int main()
{
    open("log.txt", O_WRONLY | O_CREAT, 0666);

    return 0;
}

 如果打开的文件不存在,需要传入第三个参数设置文件读写权限,一般都是0666。如果打开的文件已经存在,可以不传第三个参数。我们设置文件为所有人可读可写,但是其他人只能读。因为系统存在umask掩码,该进程会使用系统的umask掩码,最终权限跟umask有关。

int main()
{
    umask(0);
    open("log.txt", O_WRONLY | O_CREAT, 0666);

    return 0;
}

 我们可以使用umask函数设置自己的umask值。如果设置成0,新建文件的权限就是open函数第三个参数。需要注意,进程内部修改umask值,不会影响系统外的umask值。

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

int main(int argc, char *argv[])
{
    open(argv[1], O_WRONLY | O_CREAT, 0666);

    return 0;
}

此时,我们可以自己实现一个touch指令,touch指令用来创建新文件,新文件权限664。使用命令行参数表,将第二个命令行参数当做文件名,默认传入权限0666。

open函数的返回值是一个整数。我们随便打开一个文件,返回值是3。该返回值叫做文件描述符,用来表示打开的文件。

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

int main()
{
    int fd1 = open("log.txt", O_WRONLY | O_CREAT, 0666);
    if(fd1 < 0)
    {
        perror("open");
        return 1;
    }
    printf("fd1: %d\n", fd1);

    return 0;
}

3.3 close函数

close函数用来关闭打开文件的文件描述符。

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

int main()
{
    int fd1 = open("log.txt", O_WRONLY | O_CREAT, 0666);
    if(fd1 < 0)
    {
        perror("open");
        return 1;
    }
    close(fd1);

    return 0;
}

3.4 write函数

write函数可以向打开的文件进行写入。第一参数是打开文件的文件描述符,第二个参数是指向写入内容的指针变量,第三个参数是写入内容的大小。

write函数的参数跟C语言的文件操作函数非常类似。

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

int main()
{
    int fd1 = open("log.txt", O_WRONLY | O_CREAT, 0666);
    if(fd1 < 0)
    {
        perror("open");
        return 1;
    }

    const char *message = "helloworld\n";
    write(fd1, message, strlen(message));

    close(fd1);

    return 0;
}

但是调用write写入之前是清空文件,还是追加,或者覆盖,要看open函数传入的标记。当我们把上面代码中的字符指针变量内容改为“xxxxx”时,执行该程序,会发现log.txt文件里只是覆盖了helloworld前面的字符。

    const char *message = "xxxxx";

添加O_TRUNC到open函数的第二个参数中,Truncate表示截断,向文件写入前会先清空文件内容。

    int fd1 = open("log.txt", O_WRONLY | O_CREAT | O_TRUNC, 0666);

如果添加O_APPEND选项,就会在文件末尾追加写入内容。

    int fd1 = open("log.txt", O_WRONLY | O_CREAT | O_APPEND, 0666);
    const char *message = "xxxxx";

3.5 read函数

read函数可以读取打开的文件内容。第一参数是打开文件的文件描述符,第二个参数是指针变量,

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

int main()
{
    int fd1 = open("log.txt", O_RDONLY, 0666);

    char buffer[128];
    ssize_t s = read(fd1, buffer, sizeof(buffer));
    if(s > 0)
    {
        buffer[s] = 0;
        printf("%s", buffer);
    }

    return 0;
}

4. fd文件描述符

4.1 标准输入输出

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

int main()
{
    int fd1 = open("log.txt1", O_WRONLY | O_CREAT, 0666);
    int fd2 = open("log.txt2", O_WRONLY | O_CREAT, 0666);
    int fd3 = open("log.txt3", O_WRONLY | O_CREAT, 0666);
    int fd4 = open("log.txt4", O_WRONLY | O_CREAT, 0666);

    printf("fd1: %d\n", fd1);
    printf("fd1: %d\n", fd2);
    printf("fd1: %d\n", fd3);
    printf("fd1: %d\n", fd4);

    return 0;
}

 当我们使用open函数打开四个文件,它们的文件描述符从3开始,依次递增。为什么新建的文件描述符会从3开始呢?

因为进程启动时,默认打开三个标准输入输流。文件描述符0,1,2分别被stdin标准输入,stdout标准输出,stderr标准错误占据。那怎么证明呢?可以使用write函数,对文件描述符1里写入。

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

int main()
{
    const char *message = "hello stdout\n";
    write(1, message, strlen(message));

    return 0;
}

还可以读取键盘输入内容,使用read函数,填上0文件描述符。启动程序,界面会卡在那里,等待键盘输入。

int main()
{
    char buffer[128];
    ssize_t s = read(0, buffer, sizeof(buffer));
    if(s > 0)
    {
        buffer[s] = 0;
        printf("%s", buffer);
    }

    return 0;
}

 

4.2 什么是文件描述符

根据上面的讲解,我们知道文件描述符是个整数,并且从0开始,不断递增。碰巧的是数组下标也是从0开始的整数,不断递增。那么文件描述符和数组下标有什么关系呢?

  • 一个程序启动,被加载到内存中,操作系统会分配给一个task_struct结构体对象。如果有多个进程,那么操作系统会使用一张链表管理所有的进程的task_struct对象,这是进程管理。
  • 当CPU执行到open函数,打开log.txt文件,那么需要将磁盘上的文件加载到内存中。为了方便管理,操作系统会创建一个名为file的结构体对象给该文件,file结构体里面包含许多属性,一部分来自磁盘上文件的属性,还有一部分是操作系统所赋予的。
  • 进程启动时,默认打开三个标准输入输出流,对应键盘和显示器。操作系统也会给它们创建file结构体对象。当进程打开的文件变多了,内存中就存在许多file结构体对象。此时,操作系统也会使用一张链表,来管理所有的file结构体对象,这就是文件管理。
  • 而进程管理和文件管理必须是松耦合的,如果直接使用一个数据结构统一链接,将难以管理。

  • 可进程与文件的关系通常是一对多,一个进程可以打开多个文件。进程要与文件产生关联,只需要加些字段到task_struct结构体中。
  • task_struct结构体中有struct file_struct结构体指针对象,file_struct结构体中有个重要的对象,即fd_array数组。它是一个指针数组,存储的是struct file结构体指针,N的大小通常是32或者是64。
  • 当进程启动时,自动将fd_array数组前三位填入标准输入输出的file结构体对象地址。如果进程打开其他文件,填充该文件的file结构体对象地址到fd_array数组中空余的位置,然后再返回对应的下标。这就是为什么使用open函数打开的文件,得到的文件描述符从3开始。
  • fd_array也叫做文件描述符表,它构建了进程和文件之间的联系。
  • 所以文件描述符就是fd_array函数对应的下标。当进程使用某个文件描述符进行操作,会到fd_array数组中对应下标位置中的file结构体地址,找到该文件的file结构体对象。

4.3 语言级文件操作

通过上面的认识,进程对文件的操作,是通过文件描述符进行关联的。那么语言级的文件操作肯定是通过封装系统级文件操作来实现的。

C语言也提供了许多文件操作,如fopen,fwrite,fget等函数。它们的返回值类型都是FILE*,FILE也是个结构体类型,它内部会有许多字段,其中必定有文件描述符的字段。

我们可以通过打印其内部_fileno变量获取文件的文件描述符。

#include <stdio.h>
#include <string.h>

int main()
{
    printf("stdin: %d\n", stdin->_fileno);
    printf("stdout: %d\n", stdout->_fileno);
    printf("stderr: %d\n", stderr->_fileno);

    FILE* fp = fopen("log.txt". "w");
    printf("fp: %d\n", fp->_fileno);

    return 0;
}

C语言的文件操作进行了两层封装,第一层是类型的封装,使用FILE封装文件描述符,第二层是接口函数的封装,fopen函数封装了系统调用级open函数。

不管是什么语言,如果要进行文件操作,它的文件类型里面必定包含文件描述符,并对系统调用函数进行封装。


创作充满挑战,但若我的文章能为你带来一丝启发或帮助,那便是我最大的荣幸。如果你喜欢这篇文章,请不吝点赞、评论和分享,你的支持是我继续创作的最大动力!

;