一.理解"⽂件"
1-1 狭义理解
⽂件在磁盘⾥
磁盘是永久性存储介质,因此⽂件在磁盘上的存储是永久性的
磁盘是外设(即是输出设备也是输⼊设备)
磁盘上的⽂件 本质是对⽂件的所有操作,都是对外设的输⼊和输出 简称 IO
这是我们需要先了解的五条原则,访问一个文件,都必须先把这个文件打开,没有被打开的文件在磁盘上,被打开的文件才在内存中,最后一条就是我们一个进程可以打开多个文件,我们操作系统是存在多个进程的,所以我们的操作系统内一定存在大量被打开的文件。
所以我们研究文件本质就是研究进程和文件之间的关系。
1-2 代码理解和部分接口![]()
我们直接来看一个代码,这个代码就是通过打开一个文件,然后关闭它,我们先看这个fopen函数。
第一个还是传入文件路径,第二个传入属性。
大概这几个属性,首先我们先来看看,我们图中传入的第一个参数是一个文件名字,这个文件在我们的当前目录中并不存在,是这样子的一个过程,首先就是现在当前路径下查找一下这个文件,如果没有找到的话,那么就创建这个文件,给一个写入属性就行。
我们下面来运行一下这个代码。
为什么会在当前路经下运行呢?
因为我们每个进程都有自己的cwd我们的这个代码就是一个进程,他有自己的cwd。
跑一下。
我们通过如图的指令就能找到我们的cwd了。
我们这样也就修改了当前路径。
我们通过这个代码也能清晰的了解到,打开文件本质就是我们的进程打开文件。
cwd就是我们打开文件的默认路径。
我们之前写的这个,第一个我们“”表示的就是我们从当前路径中去找,<>表示从系统指定的路径下去找,谁帮我们找啊,就是编译器,编译器跑起来也是一个进程,他也有cwd等路径。
这个fwrite接口的作用就是往我们的文件中写入内容的,我们创建了log.txt文件,然后在文件中写入内容,第一个参数表示我们写入内容的起始地址,第二个参数表示我们需要写入的单个参数的大小,第三个表示我们要写入元素的个数,第四个参数是我们要写入的文件的指针。
以w方式创建的文件,在写入之前都是需要先清空文件内容再写入的。
那么如果我们要想写一个专门清空文件中内容的软件该怎么设计呢?
就是类似一个这样子的代码了,此时我们log.txt中的内容就被清空了,我们写的比较简陋。
如果是以a方式打开的文件,此时就不会清空原来的数据了。
这是这几个打开方式的手册。
文件位置我们怎么理解呢?
你可以把文件看成一个一维数组,存放的都是string类型的字符串,我们的代码换行就相当于我们给当前位置的string数组中的下一个位置一个\n的,所以我们之前用的c语言的一些接口,都是把文件的起始位置设为0的,意思就是从第一行开始读取。
你像之前我们往文件中写入内容的时候,这个我们通过这个>重定向的方式打开文件,打开文件的方式就是以w方式打开的,先清空再写入的。
这种就是以a方式打开的文件。
我们经常使用cat这个命令,我们下面自己通过学习fread接口来自己实现一个简单的cat指令吧。
这是我们实现这个操作所使用的代码,首先就是先判断了你使用这个命令是否正确,应该是cat+文件名的用法,如果你的argc!=2说明你错误使用了我们的cat指令,然后就是通过读方式打开一个文件,然后判断是否成功打开了,buff[0]=0就能快速的初始化我们的buff数组,然后通过调用我们的fread函数,第一个就是传入一个指针,我们传了数组首元素的地址,然后再你传入的单个数据元素的字节数,然后是想要读取的元素个数,最后是你指向打开文件的指针传进去就行了,这里的返回值最主要要看第三个参数,如果失败 / 遇到 EOF 时会小于nmemb,甚至为 0,如果正确读完了,但是你的文件中的元素的个数小于你的第三个参数,那么就返回你的文件中元素的个数,否则才返回第三个参数,那个fwrite也是一样的。
这个feof(fp)就是判断你的文件是否读完的,我们来使用一下这个。
此时符合我们的需求。
我们来理解一下这些东西,首先我们之前也就说过,我们不管是输入还是向显示屏打印一些数字,本质都是一些字符串一个一个的字符,你是如何通过%d什么的再把这些字符变成我们的整型的数字呢?
就是通过调用一些接口来实现的,通过putchar这些接口再把一个一个的字符变成整形的数字,你的scanf函数也是一样的键盘输入的全是字符,内部调用了接口才把这些字符都转换为了整形存放到我们的变量里面的,这叫做格式化输入,我们的prinf叫做格式化输出,我们也说过键盘和显示器的本质都是文件,也可以叫做文本文件,那么文本文件和我们的二进制文件是通过什么确定的呢?
是你调用了不同的接口才导致的这些性质还是文件本身自己的属性决定的呢?
它是这样子的一种关系首先就是你是什么类型的文件是你本身自己的性质决定的,然后通过你的这些性质再选择调用不同的接口,这是一个因果关系。
所以现在问大家一个问题。
就是我们输出信息到显示器,你都有哪些方法呢?
通过printf标准化输出我们就不说了,下面再来说几种其他的方法。
fputs,fprintf,fwrite这几个方法。
就是通过下面的几种方法来实现,就是直接向文件中写入一些东西,比如你直接向显示器上写入一些内容,此时就会在显示屏上显示出来了。
我们使用一下它们。
这个fprintf就是向指定的文件中打印,我们把第一个参数传入显示器文件也是一样的。
这个fputs就是第一个参数传入我们的字符串,第二个参数还是传入我们的需要打印到的文件。
这个fwrite我们之前讲过。
下面我们用代码来看一下这几个函数的使用。
就是这样子的,这四个函数都是向显示器输出的。
1.2.1 open
这个方法是系统调用打开文件所使用的函数。第一个open函数是你要打开的文件存在,第二个参数就是设置打开的方式。
第二个open就是打开的文件不存在,最后一个参数是给这个新创建的文件一个权限。
这个就是open有一个返回值,正确了就返回文件描述符,错误就返回了-1。
这是我们一些常见的打开方式。
我们的int flags是拥有32个比特位的,一个比特位一个标志为,但是我们的这些宏都是只有一个比特位的。
下面我们通过一个代码来理解一下。
这里我们就是定义个几个宏,分别将我们的1向左边移动多少个比特位,我们都知道int是32个比特位,所以我们的第一个宏就是....00001表示的就是1,第二个宏就是.....00010表示的就是2,以此类推,我们接下来来揭秘一下它为什么可以传入多个宏呢?
因为我们用的是按位或(|)这个符号,比如我们传入的是VERSION1 | VERSION2,此时二进制是0001和0010按位或,就是只要对应位有一个是 1,结果就是 1,否则为0,按位与之后就是0011就是3了,我们传入3之后,再通过按位与(&)操作来找到我们传入的宏,这个只有对应位都为 1,结果才是 1,否则是 0。我们传入了3也就是0011,我们给VERSION1 也就是0001按位与之后就是0001结果为1,我们与VERSION2也就是0010按位与是0010也就是2,也符合if中的条件,我们按位与VERSION3也就是0100按位与之后就是0000,不符合if语句条件所以进不去。
这就是我们的使用, 我们要注意几个地方,如果文件不存在,如图所示,我们就必须给它O_CREAT这个打开方式,否则会找不到文件,也要把umask设置为0,否则默认的umask会干扰我们给文件设置的权限,因为系统存在一个默认的umask,因为我们文件的最终权限 = 预设权限 & (~umask)(预设权限 按位与 取反后的 umask)。
这个close函数就是通过这个fd这个返回值来关闭文件的。
我们想向log.txt中写入内容。
这个就是写入,我们系统调用使用的是write函数写入的,c语言是fwrite,都是封装了操作系统中的方法实现的,我们第三个打开方式O_TRUNC的作用就是我们再写入的时候会先清空这个文件中的内容,然后再写入,要不然就是覆盖了,举个例子,第一次写入abcd,此时log.txt中就是abcd如果没有第三个打开方式的话,此时你再次写入123的话,此时就会变成123d了。
还有你c语言的fopen也是封装我们上面的open方法来实现的。
下面我们来看一下这个这个返回值也就是文件操作符。
首先我们这里是调用了四次这个open方法,有了四个返回值,然后打印了四个返回值我们来看拿一下结果。
这里出现了3,4,5,6整齐排列的整数,为什么会是这样呢?它到底是什么呢?
我们打开的文件都是存在一个struct file的结构体来管理这些进程的,也就是我们打开的文件,它是通过双链表的形式管理的。
我们的struct task_struct的结构体中存在一个struct files_struct结构体,这个结构体中存在一个fd_array数组,专门存放这些进程的地址的,它就会把这些打开的文件放入到我们的这个数组当中,返回值就是我们的数组的下标。
这是我们通过创建进程打开文件和对文件如何管理的一个过程,首先创建了进程,这个进程的PCB就是我们的task_struct,这个结构体中存在一个files_struct结构体,这个files_struct结构体中又存在一个fd_array数组,这个数组就是来管理我们通过进程打开的文件的。
那为什么不是从0,1,2开始啊,为什么直接就从3开始了啊。
下面我们来解释一下,我们在之前也说过,打开文件是通过进程打开的,那么进程在运行的时候,必然会先打开三个流,一个stdin,stdout和stderr,这三个文件都是进程的标准流,这三个进程会占据前三个位置,也就是0,1,2,但是这三个文件都是FILE* 类型的啊
我们也说了我们的操作系统只认我们的文件操作符,就是我们上面写的代码的fd,所以说我们的FILE这个类型必然是一个结构体,我们的结构体中必然存在一个变量存放我们的文件操作符,否则操作系统无法操作这几个文件,就像我们上面写的代码一样它是通过我们fd来关闭和打开文件的,我们写个代码验证一下我们的猜想。
我们的猜想得到了验证,发现是正确的。
1.2.2 操作系统是如何往文件中写入内容的呢
通过这张图我们来理解一下,首先就是左边,打开进程创建内核数据结构,如何打开了三个流文件,然后进行管理,这就不说了,我们要是通过open打开文件的时候,首先文件是存在在磁盘当中的,文件包含内容加属性,然后就是我们的属性会通过我们的file结构体是管理我们打开的文件的,它其中的变量会关联,你可以理解为可以找到我们的文件属性,你也可以理解为储存了文件属性,而我们的内容则是存放在 内存中的一块叫做文件内核缓冲区的东西,我们的用户往文件写入的时候,你调用的接口都封装了write(文件操作符,内容)这样的用法直接把内容拷贝到我们的文件内核缓冲区的,起始write的本质就是拷贝,所以此时拷贝完之后,你的内容存在在文件内核缓冲区当中,而不是磁盘当中的。
如果我们读内容,也只能从内存缓冲区当中读取而不是从磁盘中读取,此时如果没有内容就会阻塞到这里,存在内容就拷贝到用户空间中让我们看到。
我们进行增删查改操作的时候都需要先让文件的内容从磁盘中拷贝到内存缓冲区当中,此时从内存缓冲区当中进行操作,然后剩下的就交给操作系统来刷新我们的磁盘了。
1.2.3 文件描述符分配的规则
这个就是规则,我们来验证一下。
这样子的一个代码,大家可以猜一下结果。
答案变成0了,因为我们把stdin关闭了,0的位置就腾出来了,我们打开的文件就可以放到此处了。
这里不要关闭fd为1的因为它是stdout。
我们原来1指向的是我们的标准输出,printf就是往1中打印的,此时你把1关闭了,打开了一个文件,此时这个1指向的就是我们新打开的文件了,但是我们的printf还是依然会往1指向的内容中打印结果,此时结果就打印到了新打开的文件当中了,这个现象叫做输出重定向。
这是输入重定向,首先就是先关闭我们的stdin,然后通过读方式打开文件,此时你要输入的内容就不是从键盘获取了,而是从我们的log.txt中获取了,你可以给log.txt内容设为10 20 30,此时a就是10后面的依次类推了,和我们从键盘获取我们输入达到的效果一样,这叫输入重定向。
我们理论上应该是可以在log.txt中看到打印的结果的,下面我们来看一下。
我们发现上面都没有啊,下面我先说两种解决办法,后面再讲原理。
此时就可以看到了。
1.2.4 dup2
就是输入两个fd,我们简单来说一下用法,输入两个fd然后旧的fd会把新的fd的内容给覆盖掉,比如我们输入1,3,此时3的内容就会被覆盖为1的内容,此时1,3一摸一样,此时都是原来1的内容,就是这样子用的。
就是这样子使用的,原来的都一样,此时我们不用关闭1了,而是打开新文件之后,直接用新文件的fd区覆盖我们的1位置的内容,此时效果和我们上面通过close写的效果一样。
我们来思考一个问题?它俩指向同一个文件,此时如果你关闭其中一个文件,此时是否还可以往里面写入内容呢?
下面看例子
大家可以思考一下,哪个111不会被追加到我们的log.txt中呢?
我就直接来讲了,答案是第二个不会,因为打印操作系统默认找到的是1位置打印的,如果你把1关闭了,此时1位置不指向任何文件此时肯定不会打印啊,但是我们关闭了3,由于1,3指向同一个文件,你通过一个指针把这个文件关闭了,为什么1指向的还可以往里面写入内容呢?
这是因为文件中还存在一个计数器的变量,两个指针指向同一个文件,计数器就是2,关闭一个此时计数器变为1,只要不为0就还可以往里面写入内容。
思考两个问题:
子进程会直接继承父进程的所有东西,此时都会指向同一块内容,只要你不做出修改,它俩的数组指向的内容是一样的。
此时父子进程会向同一个文件中打印内容。
所以这里也可以解释了为什么默认打开了0,1,2呢?
这是因为bash是开始所有进程的父进程,你创建的进程都是继承bash的,所以都会默认打开这三个,因为bash打开了。
第二个问题也是不会影响的,因为你程序替换仅替换进程的用户空间(代码 / 数据),不会修改内核中的文件描述符表,因此历史打开的文件描述符会被完整保留。只有给文件描述符设置FD_CLOEXEC标志时,exec才会关闭该描述符(默认不关闭)。这种设计的核心是 “资源继承”,让程序替换后能复用已打开的文件、管道、网络套接字等资源。
二.理解“⼀切皆⽂件
⾸先,在windows中是⽂件的东西,它们在linux中也是⽂件;其次⼀些在windows中不是⽂件的东
西,⽐如进程、磁盘、显⽰器、键盘这样硬件设备也被抽象成了⽂件,你可以使⽤访问⽂件的⽅法访 问它们获得信息;甚⾄管道,也是⽂件;将来我们要学习⽹络编程中的socket(套接字)这样的东西, 使⽤的接⼝跟⽂件接⼝也是⼀致的。 这样做最明显的好处是,开发者仅需要使⽤⼀套 API 和开发⼯具,即可调取 Linux 系统中绝⼤部分的 资源。举个简单的例⼦,Linux 中⼏乎所有读(读⽂件,读系统状态,读PIPE)的操作都可以⽤ read 函数来进⾏;⼏乎所有更改(更改⽂件,更改系统参数,写 PIPE)的操作都可以⽤ write函 数来进⾏。
你像我们底层的这些硬件都是谁来调用使用的呢?
答案是我们的进程,我们的进程通过调用这些方法来实现这些硬件的使用,每个硬件都有很多相似的方法,如果没有这个功能就不在这个函数体中写内容,但是还是存在的,类似于多态,比如我们的基类是动物,动物可以是猪和鱼,他们都会吃饭,睡觉,但是鱼会游泳,猪不会,但是也要给猪游泳的这个方法。
我们看一下上层是怎么样的。
上层就是创建进程,进程的内核结构通过打开不同的文件,因为Linux下一切皆文件吗,所以键盘什么的全是文件,打开之后通过数组管理,然后这些文件都有自己的结构体,通过结构体中的函数指针来指向这些硬件的方法,这就是c语言实现多态的方式,基类的函数指针都是一样的,以下的硬件全是子类,都有上层函数指针的全部方法如果自己并没有这个功能,就不写那个函数即可,这就是C语言实现多态的方式。
三.缓冲区
我们先来一个例子来简单了解一下缓冲区。
缓冲区的本质就是一段内存空间,举个例子张三要送一个礼物给李四,如果自己去送的话,可能需要一个月的时间,但是如果给了菜鸟驿站,虽然菜鸟驿站可能也需要一个月的时间,但是张三的时间节省了,这个菜鸟驿站就相当于缓冲区,缓存的意义就是提高使用缓存的进程的效率的。
但是菜鸟驿站的老板不可能说来一个快递就发走一个快递,这是不可能的,而是需要积压几天然后一块发送好多快递,我们的缓冲区也是,积压多个数据,然后一块刷新到磁盘上,这样提高了双方的效率。
这是几个刷新策略。
3.1 FILE缓冲区
下面我们来看一个我们之前一直疑惑的问题,现在我们来解决一下。
我们加了\n和不加这个为什么会有不同的结果呢,我们之前只是粗糙的说了一下,现在我们展开说一下,首先我们printf执行完成之后,数据会在缓冲区中,哪个缓冲区呢?FILE缓冲区,那么它和内核缓冲区有什么区别呢?现在我先来说说这个FILE缓冲区是什么
FILE缓冲区的本质就是这个结构体中的一些数组。
这是操作系统源码中的实现的,就是通过这些变量来构成一个缓冲区。
进行了如图的操作才有了我们的FILE。
我们的缓冲过程我来讲一下,就是我们的FILE打开了一个文件,然后往文件中写入内容,此时就是通过键盘,键盘这个文件调用fgets写入到我们的缓冲区当中,这个缓冲区就是我们的buff数组,我们的文件操作符给了fd,此时这个buff数组中存放的数据刷新策略和内核的刷新策略一样,此时如果满足刷新策略就会调用fputs中它封装了write方法来写入到了我们的内核文件缓冲区当中了,然后再刷新,我们有\n的时候,此时就是满足了行刷新,此时直接进入到内核缓冲区中,然后再刷新到磁盘,就能看见了,我们就能在显示器中看见了,但是没有的话只能等进程结束再刷新了。
我们看一下第三个问题,因为我们如果没有语言级别的缓冲区的话,你每次写入都是需要调用write这个系统调用的方法的,这样才能将内容写入到内核文件缓冲区中的,此时就会频繁的调用系统调用,会导致效率低下浪费时间,而我们语言级别的缓冲区只有在我们满足刷新策略的时候才会刷新到内核中,只调用一次系统调用,提高了效率。
为什么会提高效率呢?
以我们的printf为例子。
如果没有这个缓冲区,此时我们的这每一个printf都需要调用系统调用使用write函数写入到内核缓冲区当中,此时效率就会变得很低。
我们此时这个格式化输出的时候也是格式化到我们的语言级别的缓冲区当中了。
我们看一下上面没有解决的一个问题,这个我们疑惑为什么没有显示到这个文件当中呢,注释去掉之后,这是因为我们打印的东西此时都是存在在语言级别的缓冲区当中的,你通过fd把1这个文件关闭了,那么我怎么通过fd调用write方法加载到内核当中呢,所以肯定不会在这个文件当中了我们加了个fflush,这个方法的本质就是在关闭之前调用了一下write的。
下面我们再来思考几个问题。
可能大家还是存在疑问我不是\n了吗为什么没有直接从用户缓冲区加载到内核当中呢?
可以参考一下这个解释,它就是说,如果你往显示器上打,他确实是行刷新,但是如果你是往普通文件什么的打的话,默认是全缓冲的,不是行刷新,但其实操作系统刷新策略是非常复杂的,原来我们的stdout指向的是显示屏文件,但是现在指向的是我们普通文件。
那么我们既然说了用户缓冲区的刷新策略,那么内核缓冲区呢?
这是我们内核的刷新策略。
我们看一下这个代码,再看一下两个不同的结果分析一下。
上面的是我们打印到我们的log.txt中,下面是打印到我们的显示器上,为什么会出现这种情况的,我们来分析一下。
首先打印到显示屏上就是行刷新的,每一行都刷新到内核,然后刷新到磁盘可以让我们看见。
但是打印到普通文件的时候就不一样了,需要全缓冲,所以当我们前三个进入到用户缓冲区的时候会在里面停留,然后我们最后一个调用的是系统调用,直接进入到内核中,所以最后一个会先进入到内核当中等待,然后我们其他的还在用户缓冲区,创建子进程,此时父子进程公用一个缓冲区,此时子进程结束需要将用户缓冲区的内容刷新到内核缓冲区中然后删除用户缓冲区,此时对缓冲区进行了修改需要发生写时拷贝,此时父子进程都有独立的用户缓冲区,当我们的父子进程都结束的时候,就会刷新进入内核了。
此时前三个就会存在两份了,这是由于子进程造成的。内核缓冲区只有一个,它是属于操作系统的不属于父子进程个人,不会继承。
再看一下这个代码。
为什么会造成这个现象呢?
往log.txt中写入的时候,它会先把标准错误给输出出来,然后我们的log.txt中只有标准输入啊。看一下图中的代码,为什么我们写入到普通文件的时候标准错误并没有写入进去呢
这是因为我们./a.out > log.txt是./a.out 1> log.txt的简写,所以我们只是把标准输入stdout打印到普通文件了,我们的标准错误对应的是2,stderr还是打印到显示屏中的,所以才会出现这个现象。
这个就是分别打印到不同文件中。
这个是都打印到一个文件中的操作。
那么为什么我们的perror没有换行符,也能比下面有换行符的先打印出来呢?
这是因为我们的perror标准错误是直接进入到内核的,然后尽快刷新到磁盘的,你可以理解为自动加上换行符了。
相信上面的图大家还是存在疑问,为什么perror先打印了,这个cerr后打印了呢?
首先执行第一句,此时它会进入到我们的用户缓冲区等待全缓冲,但是我们的perror不需要等待,所以直接进入内核先打印,然后我们的cout再进入,又因为这个std::endl能强制刷新缓冲区,所以我们用户缓冲区的两个语句就会进入到我们的内核缓冲区了,再次触法强制刷新刷新到磁盘,然后最后才是cerr的刷新。