从文件到文件描述符:理解程序与文件的交互本质

发布于:2025-07-29 ⋅ 阅读:(17) ⋅ 点赞:(0)

一、理解文件

抛一个概念:

文件 = 内容 + 属性

1. 那么,空文件有大小吗?答案是有的。因为空文件指的是文件内容为空,文件属性也要占据大小啊。

将来对文件操作,无非分为两类:

1.对文件内容做修改

2.对文件属性做修改

2. 在学习C语言时,我们访问一个文件,都要先把对应的文件打开,为什么?

因为要访问文件的内容和属性,内容和属性都是数据,所谓的访问就是对文件进行增删查改,都是由CPU执行你的代码进行访问,根据冯诺依曼体系结构,要对文件进行操作,你的文件必须在物理内存里,所以,我们把文件打开本质上是把文件加载到内存中

3. 文件有很多啊,被打开的文件在物理内存上,如果一个文件没有被打开呢?没有被打开的文件就在磁盘上。

所以,将来学习文件就被分为两种:被打开的文件和没有被打开的文件(文件系统)

4. 谁打开的文件?

CPU在调度这个进程时,执行到 fopen 的时候就打开文件。文件是在磁盘上的,磁盘是硬件,谁能去访问磁盘呢?是操作系统。所以,是用户通过bash,启动进程,进程通过操作系统打开文件的

那么,这时候就有人说了,我用的是C语言的库函数啊,没调用系统调用怎么访问的操作系统。是因为库函数底层对系统调用进行了封装

5. 一个进程可以打开多个文件吗?答案是可以的。那如果有多个进程呢?

所以,OS内一定同时存在大量被打开的文件,OS要不要对这些文件进行管理呢肯定是要的。怎么管理?先描述在组织

所以,OS一定存在一种数据结构体,描述被打开的文件,如同PCB一样

6. 进程有 task_struct ,未来进程也有打开的文件,我们平时研究打开文件,是在研究什么?

本质是研究进程与文件的关系

二、回顾C文件接口

1. 回顾C接口

//pathname (路径)+文件名
//mode 打开模式
//成功返回文件指针,失败返回NULL
FILE* fopen(const char* pathname, const char* mode);//打开文件
//关闭文件,成功返回0,失败返回EOF
int fclose(FILE* stream);
mode:
r:以读的方式打开文件,定位在文件的开始
r+:以读写的方式打开文件,定位在文件的开始
w:以写的方式打开文件,文件不存在就创建文件,或者清空文件,定位在文件开始
w+:以读写的方式打开文件,文件不存在就创建文件,反之清空文件,文件指针定位在文件开始
a:以追加(写)的方式打开文件,文件不存在就创建文件,定位在文件末尾
a+:以读写的方式打开文件,文件不存在就创建文件,写入时,在文件末尾,读取时,文件开始。

. 以 w 模式打开文件

在这里插入图片描述

. 以 a 模式打开文件

在这里插入图片描述

剩下的就不再枚举了,大家可以自己验证一下。

2. 文件读取是有读取位置的,那么怎么理解这个呢?

所谓的文件我们可以把它看做成一个“一维数组”,文件的位置不就是数组下标吗

这时候就有人不理解了,文件是怎么被看做是一个一维数组的

我们可以把文件里的内容看做是一个长字符串,只不过这个长字符串里面有许多换行符而已。这样应该理解了吧。

接下来看下面的现象,又是怎么回事呢?

在这里插入图片描述

这是怎么回事呢?当我们向文件里写入内容时,肯定要先打开文件,当识别到 > 符号时,就会以 w 的方式打开文件,所以就可以进行写入或者清空文件了

在这里插入图片描述

相同的道理,>> 符号就会以 a 的方式打开文件,在文件末尾进行追加数据

补充:

向显示器写入12345,是写入了一个int 12345 还是向显示器写入了 ‘1’,‘2’,‘3’,‘4’,‘5’?

通过键盘输入12345,输入了一个 ‘1’,‘2’,‘3’,‘4’,‘5’,还是输入了 int 12345?

答案是输入了一个个字符。所以显示器和键盘也叫字符设备

int x = 100 , printf(“%d”,x); ,int 占4个字节,%d表明是一个整数,所以 printf 在向显示器打印的时候会将数据拆分成一个个字符,输出到显示器上,所以 printf 也叫格式化输出。同理,scanf 函数从字符设备上一个个读取字符,所以也叫做格式化输入

那么,显示器和键盘是文件吗当然是文件。它和我们在软件层上创建的文件获取数据和输出数据是类似的,它就是一个文本文件

那么什么是二进制文件呢?

以二进制形式存储数据的文件

我们在C语言中学习的stdin,stdout,strerr是文件吗当然也是了

在这里插入图片描述

它们都是FILE* 的文件指针。我们常说进程在启动的时候会默认打开这三个文件流(其实是打开三个文件),这是为什么呢

大部分进程是需要使用CPU资源进行计算的,而计算就需要数据,计算结果有时候也是需要输出的,也有可能计算错误。所以为了方便起见,进程会默认打开对应的文件

看下面的几个函数。

在这里插入图片描述

这说明了什么呢?本质向显示器打印,就是向stdout中写入,就如同向文件写入,因为stdout也是FILE*

文件是在磁盘上的,而打开文件就需要将文件加载到内存里,只有操作系统才可以,访问操作系统就必须经过系统调用。所以接下来,我们就来看看打开文件的系统调用。

3. 系统调用

//pathname 文件路径+文件名
//flags 打开文件的方式
int open(const char* pathname, int flags);
//mode 文件的权限
int open(const char* pathname, int flags, mode_t mode);

open可以打开文件,如果文件不存在,是否会创建,取决于标志位(flags)

在这里插入图片描述

flags(标志位)有很多,我们列举几个最常用的。

. O_APPEND 追加,文件不存在也会创建

. O_CREAT 创建文件

. O_RDONLY 只读方式打开文件

. O_WRONLY 只写方式打开文件

. O_RDWR 读写方式打开文件

. O_TRUNC 清空文件

在这里插入图片描述

可以看到,O_WRONLY是不会创建文件的

在这里插入图片描述

O_CREAT会创建文件

在这里插入图片描述

但是,创建的文件权限怎么是乱码的呢?这是需要你自己手动设置的。

在这里插入图片描述
在这里插入图片描述

但是,文件的权限怎么是644呢还记得umask吗?就是因为它。每个系统的umask可能不一样。

我们也可以调用系统调用来设置umask,并且不会影响系统的umask。按就近原则执行umask

在这里插入图片描述

剩下的选项就不再做详细介绍了。现在,就来聊聊 open 系统调用的返回值吧

在这里插入图片描述

4. 理解文件描述符

可以看到,open 的返回值文件描述符是一个个整数它是什么呢

一个进程是可以打开多个文件的,OS内一定有大量的文件被打开,这些文件也是要被管理的

在OS内,如何描述被打开的文件呢?struct file这个结构体内一定直接或间接的包含被打开文件的内容和属性,以双链表的形式进行管理

自此以后,对文件进行管理就转变为对链表的增删查改

文件是由进程通过OS打开的所以文件与进程之间也是有着密不可分的联系

一个进程可以打开多个文件,多个进程也可以打开多个文件,那么,被打开的文件是属于哪个进程呢

在进程PCB里有一个结构体指针struct file_struct* files,它指向一个struct file-struct(文件描述符表)的结构体,这个结构体内有一个成员struct file* fd_array[],这是一个指针数组,指向struct file。打开文件时,OS分配 struct file 结构体,链入到struct file的链表中,将新申请的struct file结构体的地址填入到fd_array数组中,给用户返回数组下标

总结文件描述符的本质就是数组下标

也就是说,在OS角度,识别打开的文件,只认int fd 文件描述符

在这里插入图片描述

那么,数组下标 0,1,2去哪里了呢?我们说进程默认打开了三个文件流:stdin, stdout, stderr,这不刚好三个吗?没错,0,1,2就是分别给它们三个文件流了

那这时候有人就有疑问了,在学习C语言的时候,我们使用文件接口并没有用到文件描述符 fd 呀,不是说OS只认 fd 吗?C语言的文件接口返回类型是FILE*呀。

那么,FILE是什么呢?它其实就是C语言标准库定义的一个结构体

所以,推测,FILE 结构体里面,一定要封装一个整数,这个整数就是 fd

现在看来,封装,不仅仅是对于系统调用接口的封装,连数据类型也做了封装

在这里插入图片描述

打开文件时,OS只给我们做了上述的工作吗?当然不是了。它还要把磁盘上的文件加载到内存里

文件 = 属性 + 内容OS会给文件属性开辟一段空间,给文件内容开辟一段空间(文件内核缓冲区)。而struct file 结构体里会有指针指向这两段空间

在这里插入图片描述

我们在使用 write(3, "hello world") 系统调用的时候,是在干什么呢

进程会找到文件描述符表,拿着3号文件描述符找到对应的文件,将数据拷贝到文件内核缓冲区里

所以:write的本质根本就不是写入到文件里,write的本质是拷贝函数,把数据从用户空间拷贝到对应文件的内核缓冲区中

那如果读取文件呢只能从文件缓冲区里面读取

修改文件呢从内核缓冲区里拷贝数据到用户缓冲区,进行修改,再拷贝到内核缓冲区,刷新到磁盘上

所以,我们对任何文件内容进行增删查改,都必须把文件的内容提前预加载到该文件的内核缓冲区中

5. 文件描述符的分配规则

在这里插入图片描述

我们关闭了文件描述符 0 所指向的文件,所以OS给我们分配了 0,那么如果关闭2号文件呢?我们来验证一下。

在这里插入图片描述

由此可见,文件描述符的分配规则是给新打开的文件分配 fd ,从文件描述符表数组中寻找,最小的,没有被使用的数组下标,作为该文件的 fd

那么,有没有人疑惑呢?为什么跳过了 1 号描述符呢

我们来试一下。

在这里插入图片描述

没有打印,这是为什么呢?因为1号文件描述符指向的文件是 stdout ,我们把它关闭了,然后创建了 log.txt 文件,1号文件描述符就被分配给了 log.txt文件,所以就无法向显示器打印了那么,既然 1 号文件描述符分配给了 log.txt,数据是不是在 log.txt 里呢

在这里插入图片描述

可以看到,是没有的,那是怎么回事呢?我们对代码做出些许改动,看看结果。

在这里插入图片描述

现在,我们换一种方式检验结果。

在这里插入图片描述

我们用了两种方式,表达的都是同一个问题。这是为什么呢?

这与缓冲区有关。我们需要等到后面才能解释。

一般,我们不采用这种关闭某个文件,打开另一个文件的做法。所以,接下来,我们需要先了解一个系统调用。

//用于复制文件描述符
//oldfd文件描述符复制到newfd文件描述符上
//如果newfd已经打开,dup2会先关闭它,然后再进行复制。
//成功,返回新的文件描述符newfd
//失败,返回-1,并设置errno
int dup2(int oldfd, int newfd);

利用这个系统调用,我们可以写一个输出重定向的代码。

在这里插入图片描述

有眼尖的伙伴就发现了,它打印的顺序怎么和我们代码所写的顺序不一样呢?这也是因为缓冲区的缘故。

6. 如果我们创建子进程,子进程是如何看待父进程打开的文件的?

父进程创建子进程,OS会给子进程分配PCB,虚拟地址空间,页表...,当然了,也包括今天的文件描述符表(struct file_struct)

PCB,,虚拟地址空间,页表是以父进程为模板的,文件描述符表当然也没有例外,也就是说,父进程打开的文件,子进程也会打开,那么,OS会重新再加载一份文件吗当然不会了。我们是创建了一个新的进程,又不是打开了新的文件。

那么,子进程以父进程为模板,文件描述符表中的struct file* fd_array[]就是浅拷贝,也就意味着子进程和父进程指向的是同一个文件

不知道大家看到这里有没有问题呢?

既然子进程和父进程指向的是同一个文件,那如果子进程关闭了某些文件,是不是父进程也无法使用呢

当然不会了进程是具有独立性的。父子进程指向同一个文件,为了保证进程的独立性,OS采用了引用计数的方法,子进程关闭了某个文件,OS就对计数做 - - 操作。直到计数为0,就会关闭该文件

我们常说进程默认会打开 stdin, stdout, stderr 这三个文件流,我们在命令行上启动的进程,我们是没有打开这几个文件流的。因此,进程都是通过父进程继承来的

我们可以验证一下。

在这里插入图片描述

7. 如果程序替换,不会创建新进程,会影响我们历史打开的文件吗?

答案是不会

程序替换,加载的是新的代码和数据,跟文件有什么关系。所以,程序替换不会影响文件

在这里插入图片描述

今天的文章分享到此结束,觉得不错的给个一键三连吧。


网站公告

今日签到

点亮在社区的每一天
去签到