一、理解文件
抛一个概念:
文件 = 内容 + 属性
。
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. 如果程序替换,不会创建新进程,会影响我们历史打开的文件吗?
答案是不会。
程序替换,加载的是新的代码和数据,跟文件有什么关系。所以,程序替换不会影响文件
。
今天的文章分享到此结束,觉得不错的给个一键三连吧。