Bootstrap

文件描述符与文件操作函数(open、write……)--Linux

🚩什么是文件描述符?

文件描述符字面意思就是描述文件的符号。说起文件描述符,就得和进程联系起来讲。

总所周知,对文件进行操作首先得在进程中打开一个文件,然后才能对文件进行读写更改属性之类的操作。说起来简单,但对于操作系统来说,做的事就比较多了。首先要解决的就是文件在一个或多个进程中以某种形式打开之后,文件怎么被管理起来。是的,文件只有被管理起来才能有序正常的被使用!

由于被打开的文件隶属于被打开时所在的进程,因此文件的管理是操作系统透过进程来实现的,而文件在进程中被管理起来的形式就是文件操作符。文件描述符形式上其实很简单,就是一个一个的自然数(0、1、2……),每个数代表一个已经打开并被管理起来的文件。注意这里要和文件指针区分开,我们以前使用的fopen函数返回值是一个文件指针FILE*,并不是文件描述符。而文件描述符具体是怎么管理文件的,我们接下来继续讲。

🚩进程中的文件管理结构

文件描述符既然被进程管理起来,就肯定要被组织起来然后再被管理起来。进程我们知道是由PCB结构来管理,而在task_struct中有一个结构体files_struct专门用来管理文件。

image-20221103231642683

可以看出fd_array是一个文件指针的数组,文件的管理都又一个文件数组来完成。而数组形式的管理其实就是下标的管理,所以文件指针数组的下标就成为了文件描述符。因此我们对应的打开文件,读写文件,关闭文件等等操作都是通过文件指针数组来实现的。

🚩文件描述符的使用

既然清楚了文件描述符的性质,那么我们可以试着在实际的应用当中去验证一下。

🍁区别fopen等语言级别函数

以往我们对文件的操作形式有fopen、fread、fwrite、fprintf……,这些都是c语言中给我们用户封装好的函数接口,让我们可以很轻松的去使用,不需要文件描述符之类的概念辅助使用。

image-20221104162355799

返回值都是文件指针,相当于直接将文件的结构体地址给我们使用了,好处就是够方便,不需要用户自己去找,坏处就不够底层,我们用户想要干预其他文件时必须得拿到对应的文件指针才行,而获取文件指针又得操作系统来做,相当于绕了个圈子来完成管理工作。如果直接使用文件数组的下标来管理那就方便很多了,毕竟只是对数字进行操作,少了获取文件指针的步骤。

🍁文件描述符相关的系统级别函数

上面我们提到了文件的c语言封装的fopen等函数,事实上它们都是由系统级别的函数封装形成的。这里我们谈到了“系统”二字,文件的操作与操作系统的联系关系又是为何呢?

首先,我们得清楚一点,文件最终的存储位置都是磁盘上。其次磁盘是属于硬件的,硬件的管理必须是透过操作系统用户才能够实现,因此文件的操作就离不开操作系统。所以系统级别的函数的出现就一点也不奇怪了。

接下来,我们将了解四个最基本的文件系统级函数:open、write、read、close,并且会有一定的细节讨论。

🍂open

image-20221104170751070

需要包含的头文件为:sys/types.h、sys/stat.h、fcntl.h

参数:pathname–路径名,又可以称之为文件名

​ flags–文件打开方式,有O_RDONLY、O_WRONLY、O_RDWR打开方式,并可以与更多的打开方式 按位或实现更多打开的操作。

​ mode–文件的权限设置,例如0600对应的就是- rw- — —,即只有usr有权限去读写。

返回值:整数–文件描述符,且是文件指针数组中能取到的最小下标值。打开文件失败返回-1,并在errno中设置错误信息。

⌨接下来我们可以写点代码测试一下。

#include <iostream>
#include <sys/types.h>
#include <sys/stat.h>
#include <sys/fcntl.h>
#include <cstring>
#include <cerrno>
#define PATH_NAME1 "log1.txt"
#define PATH_NAME2 "log2.txt"
#define PATH_NAME3 "log3.txt"

using namespace std;
int main()
{
    umask(0);//重置掩码,方便后续的文件权限设置
    int fd1 = open(PATH_NAME1, O_CREAT | O_WRONLY, 0600);
    if (fd1 < 0)
    {
        //打开失败
        cerr<<strerror(errno)<<endl;
        return 2;
    }
    cout << "open success -->fd: " << fd1 << endl;
    int fd2 = open(PATH_NAME2, O_CREAT | O_WRONLY, 0600);
    if (fd2 < 0)
    {
        //打开失败
        cerr<<strerror(errno)<<endl;
        return 2;
    }
    cout << "open success -->fd: " << fd2 << endl;
    int fd3 = open(PATH_NAME3, O_CREAT | O_WRONLY, 0600);
    if (fd3 < 0)
    {
        //打开失败
        cerr<<strerror(errno)<<endl;
        return 2;
    }
    cout << "open success -->fd: " << fd3 << endl;
    return 0;
}

我们在上面打开了多个文件,并获得了多个文件的文件描述符。看一下运行结果

💻:

image-20221104172034885

可以观察到的是三个依次创建的文件描述符的下标从3开始依次递增1,假设0、1、2三个文件描述符已经有了,那么这种情况就符合我们上文提到的文件描述符获取的机制(获取最小可用的)。🔺但是为什么进程一开始就会有三个已打开的文件呢?答案就是标准输入输出流和错误输出流的文件在进程开始的时候操作系统已经给我们默认打开了。

我们可以再次验证一下是不是三个流文件,但是得用到close函数才好验证,所以先讲一下close函数的用法。放心,很简单的啦。

🍂close

image-20221104173604439

头文件:unistd.h

参数:fd–文件描述符,表示要关闭哪个文件。

返回值:0–成功,-1–失败

🔺IO流文件在进程开始时被打开的验证

#include <iostream>
#include <sys/types.h>
#include <sys/stat.h>
#include <sys/fcntl.h>
#include <cstring>
#include <cerrno>
#include <unistd.h>
#define PATH_NAME1 "log1.txt"
using namespace std;

int main()
{
    umask(0);
    close(1); 
    int fd1 = open(PATH_NAME1, O_CREAT | O_WRONLY, 0600);
    if (fd1 < 0)
    {
        //打开失败
        cerr << strerror(errno) << endl;
        return 2;
    }
    cout << "open success -->fd: " << fd1 << endl;
    close(fd1);
    return 0;
}

我们先把1给关闭,也就是标准输出给关闭。之后我们再打开log1.txt时,按照分配文件描述符的规则,应该把1分给文件log1.txt,之后输出open success -->fd: 1。那么我们接下来看看运行情况如何

💻:

image-20221104174613564

问原因之前先看图:

image-20221104175313423

在使用cout输出流时,会去找fd为1的文件指针,但是由于此时的1对应的文件指针指向了log1.txt,所以最终的输出内容都在log1.txt中。🔺因此也可以自此更深层次的理解,关闭文件并不是真的把文件从进程中删除了,而是将文件指针数组中的文件指针给删除了,然后将该位置腾给后来要打开的文件。

🍂write

与c语言的fwrite相似,都是往一个文件里面进行写入操作。只不过write函数不用再使用文件名而是使用文件描述符寻找对应的文件写入,从这里我们可以看出文件描述符的便利性。

image-20221104205433991

头文件:unistd.h

参数:fd–文件描述符

​ buf–要写入的内容的地址,无视类型,转换成const void*类型。

​ count–要写入的内容的字节数大小

返回值:成功时返回写入字节的个数,失败时返回-1或0

⌨整点代码测试测试:

#include <iostream>
#include <sys/types.h>
#include <sys/stat.h>
#include <sys/fcntl.h>
#include <cstring>
#include <cerrno>
#include <unistd.h>
#define PATH_NAME1 "log1.txt"
using namespace std;

int main()
{
    umask(0);
    int fd1 = open(PATH_NAME1, O_CREAT | O_WRONLY, 0600);
    if (fd1 < 0)
    {
        //打开失败
        cerr << strerror(errno) << endl;
        return 2;
    }
    cout << "open success -->fd: " << fd1 << endl;


    int cnt=5;
    const char* str="It's impossible to not fall in love with you.\n";
    while(cnt--)//写入了五次
    {
        ssize_t size=write(fd1,str,strlen(str));
        if(size==0) break;
    }
    close(fd1);
    return 0;
}

💻:

image-20221104210118435

这个函数其实没有什么难理解的,只要学过fwrite就会很好上手,毕竟只是把文件名位置的参数给换了。

🍂read

从文件中读取数据

image-20221104210656557

参数:fd–文件描述符

​ buf–要读取内容的存放地址,无视类型,转换成void*类型。

​ count–要读取的内容的字节数大小

返回值:成功时返回读取字节的个数,失败时返回-1

⌨整点代码测试下:

#include <iostream>
#include <sys/types.h>
#include <sys/stat.h>
#include <sys/fcntl.h>
#include <cstring>
#include <cerrno>
#include <unistd.h>
#define PATH_NAME1 "log1.txt"
using namespace std;

int main()
{
    umask(0);
    int fd1 = open(PATH_NAME1, O_CREAT | O_RDONLY, 0600);
    if (fd1 < 0)
    {
        //打开失败
        cerr << strerror(errno) << endl;
        return 2;
    }
    cout << "open success -->fd: " << fd1 << endl;
    char line[1024];
    while (true)
    {
        ssize_t s = read(fd1, line, sizeof(line) - 1);
        if (s <= 0)
        {
            cout << "文件读取完毕" << endl;
            break;
        }
        line[s] = '\0';//数据读取之后加上\0才能被认定为是字符串
        cout<< line;
    }
    close(fd1);
    return 0;
}

💻:

image-20221104214733076

🔺

🙋‍♂️:为什么不把写和读放在一起呢?数据写完就给读出来,不是挺方便吗?

👨‍🏫:好问题,不是不可以,但这里就涉及另一个概念了:文件偏移量,而这也不得不谈起另一个函数–lseek

🍂lseek(文件偏移量)

无论我们在进行读或者是写的操作,操作系统都要知道一个东西:文件光标的当前位置(这里的光标类似于数组的下标)

没有当前位置,就没办法正确的接着上次的写入往文件里写入数据,就没法判断文件是否读取到末尾了。

试想一下,当我们把数据写入文件后,文件的当前位置指向文件的末尾,此时直接去读取数据,会出现直接结束读取的现象,这当然不是我们想要的。此时,如果我们把文件的当前位置直接调整到文件的开头,那么再读取的时候,就可以正常的读取数据了。

关于上面提到的文件光标当前位置距离文件的起始位置差值,有一个更加标准的说法–文件偏移量

改变文件的偏移量,就是改变文件光标的当前位置。用户要想改变文件的偏移量,只能借助操作系统提供的接口。

image-20221104223643964

头文件:sys/types、unistd.h

参数:fd–文件描述符

​ whence–文件偏移量:SEEK_SET(文件开头)、SEEK_CUR(文件当前位置)、SEEK_END(文件结尾)

​ offset–对文件偏移量进行的调整,传正整数就是在whence的基础上往后挪动offset个字节数,传负整数就是在whence的基础上 往前挪动offset个字节数。

返回值:最终的文件偏移量

off_t类型的数据就是 long int

多层跳转定义😵:

image-20221104225212385

⌨整点代码测试:

#include <iostream>
#include <sys/types.h>
#include <sys/stat.h>
#include <sys/fcntl.h>
#include <cstring>
#include <cerrno>
#include <unistd.h>
#define PATH_NAME1 "log1.txt"
using namespace std;

int main()
{
    umask(0);
    int fd1 = open(PATH_NAME1, O_CREAT | O_RDWR | O_TRUNC, 0600);
    if (fd1 < 0)
    {
        //打开失败
        cerr << strerror(errno) << endl;
        return 2;
    }
    cout << "open success -->fd: " << fd1 << endl;
    int cnt = 5;
    char line[1024];
    const char *str = "It's impossible to not fall in love with you.\n";
    while (cnt--)
    {
        ssize_t size = write(fd1, str, strlen(str));
        if (size <= 0)
            break;
        lseek(fd1, -strlen(str), SEEK_END);//往前追溯新读写的字节个数
        ssize_t s = read(fd1, line, sizeof(line) - 1);
        if (s <= 0)
        {
            cout << "文件读取完毕" << endl;
            break;
        }
        line[s] = '\0';
        cout << cnt << ": " << line;
    }
    close(fd1);
    return 0;
}

💻测试结果:

image-20221104231951821

完成读写依次循环操作!

🚩总结

文件描述符说了那么多,其实还是绕不开操作系统,这很重要。事实上,我们只是调用了系统给我们的函数,而操作系统才是真正的干活者。我们想要使用的得心应手,实际上还得去了解文件管理的底层。今天的分享就到这啦,期待我们一起成长!🍀~~

;