0


Linux 文件描述符

                                    ![](https://img-blog.csdnimg.cn/ea40e8b946b54f209dbf74d5f27de258.png)

    我们曾经学过语言层面的文件操作,是不是感觉都不知道在干啥?因为在语言层面上,是肯定解释不清楚原因的,必须要结合OS的知识才可以。今天我们来探索一下它的底层原理。

回顾C语言文件操作知识

C程序运行起来,会默认打开三个输入输出流:stdin、stdout、stderr。

stdin:键盘

stdout:显示器

stderr:显示器

我们可以先来一段C文件操作的代码:

#include<stdio.h>

int main()
{
    FILE* fp = fopen("./myfile", "w");
    if(fp == NULL)
    {
        perror("fopen");
        return 1;
    }
    const char* msg = "hello world\n";
    fputs(msg, fp);
    return 0;
}

运行结果:

可能大家对上面的C函数接口都见过, 我们有没有好奇过FILE*这个类型?

我们来看几个文件操作接口:

通过上面的接口,我们发现都要传一个FILE的指针,那这个FILE的指针到底是什么呢?

我们来看C语言默认给我们打开的三个流的类型是什么:

我们发现这三个流的类型竟然也是FILE*,那我们是不是也可以传stdout来打印内容到显示器上呢?

我们对上面的代码进行简单修改测试:

int main()
{
    const char* msg = "hello world\n";
    fputs(msg, stdout);
    return 0;
}

测试结果:

我们发现使用stdout也能向显示器上打印。

   上面例子说明了什么?fputs向一般文件或者硬件都可以写入!!那么站在OS的角度就是,一切皆文件。

系统文件IO

系统接口和语言层IO接口的关系

 语言层文件操作的接口实际上就是对系统调用接口的封装,它必须遵守OS系统调用的规则。 

系统调用接口

open

**pathname: **要打开或创建的目标文件
**flags: **打开文件时,可以传入多个参数选项,用下面的一个或者多个常量进行“或”运算,构成flags。
参数:
**O_RDONLY: **只读打开
**O_WRONLY: **只写打开
**O_RDWR : **读,写打开
这三个常量,必须指定一个且只能指定一个
**O_CREAT : **若文件不存在,则创建它。需要使用mode选项,来指明新文件的访问权限

通常是传mode参数,如果是普通文件,比如我们给0644(-rw-r--r--)。
**O_APPEND: **追加写

返回值:
成功:新打开的文件描述符
失败:-1

close

fd:文件描述符(后面再讲)

read

read() attempts to read up to count bytes from file descriptor fd into the buffer starting at buf.

功能:把从fd指向的文件中读到的内容写到buf缓冲区中,最多读取count字节。

write

write() writes up to count bytes from the buffer pointed buf to the file referred to by the file descriptor fd.

功能:把buf缓冲区里面的内容写到fd指向的文件中。

lseek

通过对比上面的系统调用接口,我们可以发现他们传参时都必须要有一个fd整型。这个整型是什么呢?我们接着往下思考:

fd

open返回值

我们通过下面的代码实验,创建文件时,open的返回值是多少:

int main()
{
    int fd0 = open("./myfile0", O_WRONLY | O_CREAT, 0644);
    int fd1 = open("./myfile1", O_WRONLY | O_CREAT, 0644);
    int fd2 = open("./myfile2", O_WRONLY | O_CREAT, 0644);
    int fd3 = open("./myfile3", O_WRONLY | O_CREAT, 0644);

    printf("fd: %d\n", fd0);
    printf("fd: %d\n", fd1);
    printf("fd: %d\n", fd2);
    printf("fd: %d\n", fd3);
    return 0;
}

运行结果:

我们发现当我们创建多个文件时,他们会从3开始依次往后增。

那么 0 1 2呢??实际上0 1 2就是OS默认帮我们打开的输入输出三个流。

那么我们就有了很重要的发现,fd的顺序是0,1,2,3,4,5...看到这个我们能想起什么??

有点抽象?那我再画个图更清晰一些:

**我们就能惊奇发现,这不就是数组吗?答案是,是的! **

struct_file* fd_array[]

那么如何描述呢?

实际上OS创建了一个struct file的一个结构体,来保存文件相关的属性信息、连接信息等。

那么如何管理呢?

   我们知道,进程中可能存在多个文件,那么多个文件就会被struct file的结构体描述起来,同时用一定的数据结构(比如双向链表)组织起来。同时,OS会创建一个struct files_struct这样的结构体,里面有一个struct file* fd_arrey[]这样的指针数组,它里面存放的是对应描述文件的结构体(struct file)的地址。

0下标对应的struct file*是描述标准输入的结构体的地址。

1下标对应的struct file*是描述标准输出的结构体的地址。

2下标对应的struct file*是描述标准错误的结构体的地址。

这时候我们就明白了,为什么我们常说的0、1、2对应的输入输出流了。

    另外,PCB(struct task_struct)里面要存放struct files_struct的地址,这样OS就能通过struct file_struct*找到结构体,然后就可以找到**对应的下标:fd**。然后就可以找到对应的struct file的文件,进行各自的读写操作了。

OS角度 --- 一切皆文件

   ** 每个struct file里面都有自己的函数指针,可以找到对应的驱动层上的读写方法!**这样struct file上层看来,所有的文件,读,写,根本就不关心你到底是什么文件。因为每个struct file会调用自己对应的函数指针,执行不同硬件的读写方法,这种特性和C++的多态很类似。

所以,在vfs层看来,就是一切皆文件,对硬件的读写,也不过就是调用不同的函数而已。

总结:

   文件在系统层面有一个vfs(虚拟文件系统),里面包含了每一个文件的struct file结构体,它里面有一批函数指针,这批函数指针帮我们指向底层方法的。在vfs看待(统一视角看待文件读写),一切皆文件。(通过fd来找到对应的struct file)。

文件描述符的分配规则

我们先用两组代码测试一下:

我们关闭文件描述符0,然后新创建一个文件,来看看,新创建文件的文件描述符是多少:

int main()
{
    close(0);
    int fd = open("./myfile", O_WRONLY | O_CREAT, 0644);
    if(fd < 0)
    {
        perror("open");
        return 1;
    }
    printf("fd: %d\n", fd);
    return 0;
}

运行结果:

我们发现这时候fd就不是从3开始了,而是fd变成了0!

我们接下来关闭文件描述符2,再来试试?

int main()
{
    close(2);
    int fd = open("./myfile", O_WRONLY | O_CREAT, 0644);
    if(fd < 0)
    {
        perror("open");
        return 1;
    }
    printf("fd: %d\n", fd);
    return 0;
}

运行结果:

这时候我们发现,fd也不是3,而是2!

结论:

在files_struct里的数组当中,找到当前没有被使用的最小的一个下标,作为新的文件描述符。

重定向

输出重定向

我们在上面知道了,通过文件描述符可以找到对应的struct file。再根据文件描述符的分配规则,我们是不是可以这么做:

创建一个文件描述符为1的文件,我们再向stdout里面写内容,那这时候写的内容是在文件里,还是在标准输出上?

代码测试:

int main()
{
    close(1);
    int fd = open("./myfile", O_WRONLY | O_CREAT, 0644);
    if(fd < 0)
    {
        perror("open");
        return 1;
    }
    printf("hello world\n");
    printf("hello world\n");
    printf("hello world\n");
    printf("hello world\n");
    return 0;
}

运行结果:

我们发现,当文件描述符1里面对应的指针改成新创建文件struct file的地址之后,我们再向stdout上打印,就不行了,而是变成了向文件写入!

不难理解,我们画个图展示这个过程:

   当我们接着向标准输出打印内容时,根据文件描述符分配规则,这时候通过文件描述符1就找到的不是标准输出的struct file,而是创建的新文件的struct file,,这时候再写内容时,就变成了向myfile的文件里写内容了。

这就是完后才能重定向的过程!

追加重定向

我们要实现追加重定向的功能,也很简单,就是在传flags参数时,或上O_APPEND,就表示追加字符串的意思:

代码和上面改动如下:

运行结果:

总结

我们这时候再来分析一下C语言中的FILE*指针。

通过FILE*可以找到一个结构体FILE,FILE实际上就是C语言层面上的结构体!

语言无论怎么封装,底层一定是调用了系统接口,并且,语言层就一定有表示该文件的fd!

1、因为IO相关函数与系统调用接口对应,并且库函数封装系统调用,所以本质上,访问文件都是通过fd访问的。

2、所以C库当中的FILE结构体内部,必定封装了fd。

dup2系统调用

功能:old->new,把old(文件描述符)里面指向的地址拷贝给new(文件描述符)对应的指针。

怎么用呢?举个栗子:

int main()
{
    int fd = open("./myfile", O_WRONLY | O_CREAT, 0644);
    if(fd < 0)
    {
        perror("open");
        return 1;
    }
    dup2(fd, 1);// fd 拷贝给 1
    printf("hello wmm\n");
    printf("hello wmm\n");
    printf("hello wmm\n");
    printf("hello wmm\n");
    return 0;
}

运行结果:

这时候我们发现,经过dup2(fd, 1);操作,把标准输出的指向改成了myfile的文件,我们再往stdout打印时,就变成了往文件里写内容了,实际上,这也是完成重定向的过程!

缓冲区

stderr和stdout区别

我们发现stderr和stdout对应的硬件设备都是显示器,那么他们之间有什么差别呢?

我们先用一段代码来演示:

int main()
{
    fprintf(stdout,"%s\n","hello 标准输出");
    fprintf(stderr, "%s\n", "hello 标准错误");
    return 0;
}

测试:

   我们通过测试结果可以发现,当我们直接向显示器上打印时,标准输出和标准错误都打印出来了。

   但是我们把打印结果重定向到文件中时,这时候我们发现标准错误还在显示器上打印,标准输出被写入了文件中。 
   我们发现标准错误不能被重定向到文件中,它是直接打印到显示器上,stderr中间是没有缓冲区的。

./tset > myfile 2>&1

如果我们想让标准错误也写入文件中,可以拷贝fd:

[cyq@VM-0-7-centos 文件描述符]$ ./test > myfile 2>&1

实验结果:

2>&1:把1号文件描述符拷贝给2号文件描述符,类似于dup2(1,2)。

这样stderr对应的地址,就是标准输出的struct file了。

现象一

我们来看一段代码:

int main()
{
    close(1);
    int fd = open("./myfile", O_WRONLY | O_CREAT, 0644);
    if(fd < 0)
    {
        perror("open");
        return 1;
    }
    fprintf(stdout, "hello wmm\n");
    fprintf(stdout, "hello wmm\n");
    fprintf(stdout, "hello wmm\n");
    fprintf(stdout, "hello wmm\n");
    fprintf(stdout, "hello wmm\n");

    close(fd);
    return 0;
}

这段代码和之前写的差别就是,在最后加了close(fd)

我们来看一下运行结果:

我们发现我们close(fd)后,我们往myfile文件里没有写入成功??是我们出现幻觉了吗?这是怎么回事?

原因

我们先了解一下缓冲区刷新策略:

结合语言层接着分析:

    我们的代码中实现了重定向的功能,也就是本来向显示器上打印,变成了向文件中写入。这时候,就会发生刷新策略的改变!**由行缓冲变成全缓冲! **

    这时候我们向缓冲区里面写内容时,这时候并没有写满,并且在进程退出前,我们关闭了fd,那么缓冲区里面的数据就找不到要刷新的地方了,没办法通过系统调用把数据刷新到OS层面的缓冲区中。所以,这时候我们就发现并没有往文件中写入数据。

进一步理解FILE

这个语言层面的缓冲区是在哪呢?在FILE中维护!

也就是说,FILE里面不仅封装了一个fd,而且维护了与C缓冲区相关的内容。

解决方法

在关闭fd前,使用fflush刷新用户层缓冲区的数据到系统文件缓冲区中。

实验结果:

现象二

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

    printf("hello printf\n");
    fprintf(stdout, "hello fprintf\n");
    fputs("hello fputs\n", stdout);

    fork();
    return 0;
}

实现结果:

我们对比发现,当打印到文件中时,printf、fprintf、fputs的打印多了一份。这是为什么?

原因

   我们通过之前的知识知道,从往显示器上打印,转换到往文件中打印后,缓冲区的刷新策略会从行缓冲变成全缓冲,所以在进程退出前,FILE维护的缓冲区里面是数据的!

   fork创建子进程后,子进程会共享父进程的代码和数据,包括FILE维护的缓冲区里面的数据。刚开始时父子进程是共享的,如果父进程先退出,这时候父进程的缓冲区里面的数据就会被刷新到指定的文件中。**就在数据被刷新前的时候,子进程会发生写时拷贝,**当子进程退出的时候,它也会把自己进程里面缓冲区的数据刷新到文件中,这时候就出现了(printf、fprintf、fputs)打印两遍的情况。

那write接口为什么只打印了一遍呢?

因为write是系统接口,是没有用户层缓冲区的,它是直接把数据刷新到指定的文件中。

系统接口是不受影响的!

解决方法:

实验结果:

** 看到这里,支持博主一下吧~**

                        ![](https://img-blog.csdnimg.cn/e642d041802c4fddaca1d3dc5f513d62.png)
标签: linux 服务器 运维

本文转载自: https://blog.csdn.net/qq_58724706/article/details/125147580
版权归原作者 暴走的橙子~ 所有, 如有侵权,请联系我们删除。

“Linux 文件描述符”的评论:

还没有评论