什么是文件
文件从狭义上面理解就是文件是存储在磁盘上的
而磁盘又是永久性存储介质,文件在磁盘上面是永久保存的
磁盘上的⽂件 本质是对⽂件的所有操作,都是对外设的输⼊和输出 简称 IO
从linux角度里,一切皆文件像(键盘、显⽰器、⽹卡、磁盘...... )这些都是抽象化的过程,
后面会讲解为什么
文件的认知
我们举一个例子看看下面的文件

我们新建了一个文件,里面没有存放任何东西上面显示它的大小是0,那么它的大小真的是0吗,其实不是的,文件由两部分组成,一部分是内容,另一部分是属性,文件=内容+属性 而属性也是要占用空间的,所以对于 0KB 的空⽂件是占⽤磁盘空间的
我们所有的⽂件操作本质是⽂件内容操作和⽂件属性操作
从linux系统角度,操作文件的本质是进程再操作文件,磁盘的管理者是操作系统,操作系统怎么管理?先描述再组织!
⽂件的读写本质不是通过 C 语⾔ / C++ 的库函数来操作的(这些库函数只是为⽤⼾提供⽅便),⽽ 是通过⽂件相关的系统调⽤接⼝来实现的,像fopen fclose 里面封装了底层的系统调用、
下面我们来慢慢理解
回顾c语言的文件操作
我们来看一个代码


我们这里以读方式打开了文件(这里本来没有text文件会自动创建一个),那么运行代码的进程是怎么知道我要在此路径下面创建文件的?
我们可以来看看

我们查找这个进程发现对应的cwd下面有一个文件路径,这个正是当前我们写的程序的运行路径,而有了这个路径就可以直接在这个路径下面创建文件
所以打开⽂件,本质是进程打开,所以,进程知道⾃⼰在哪⾥,即便⽂件不带路径,进程也知道。由此OS 就能知道要创建的⽂件放在哪⾥。
我们再来想一个问题
输出到显示器可以有哪些方法
我们一般想到的是printf cout这一类的函数,我们可以来观察下面的代码
cpp
#include <stdio.h>
extern FILE *stdin;
extern FILE *stdout;
extern FILE *stderr;

这里我们发现stdin,stdout,stderr这三个默认流标准输出,标准输入,标准错误,他的类型其实是一个文件指针,那么可以判断这三个流是一个文件,我们可以用文件操作的方式进行写入


我们可以得到一个结论,往显示器上面写入,本质就是像文件写入,因为linux下一切皆文件!
那么为什么操作系统会默认打开这三个流呢,我们要知道,我们的程序本质是做数据处理的,要有输入也要有输出,所以要打开。
文件的打开方式
|------|--------------------------------------------------------|
| "r" | 打开一个用于读取的文件。该文件必须存在。 |
| "w" | 创建一个用于写入的空文件。如果文件名称与已存在的文件相同,则会删除已有文件的内容,文件被视为一个新的空文件。 |
| "a" | 追加到一个文件。写操作向文件末尾追加数据。如果文件不存在,则创建文件。 |
| "r+" | 打开一个用于更新的文件,可读取也可写入。该文件必须存在。 |
| "w+" | 创建一个用于读写的空文件。 |
| "a+" | 打开一个用于读取和追加的文件。 |
我们上面看到的表格是我们传统c语言提供的打开文件的方式,c语言的库函数里面封装了系统调用,那么系统调用也会像这样吗? 答案是不是的,系统调用文件打开的方式本质是传递标志位
系统文件IO
什么是标志位,我们可以来看看下面的代码

这里通过按位与运算和按位或运算利用这两个运算的逻辑巧妙的设置了一个标志位传参方法
或运算:见一为一
与运算:全一为一

open
下面我们来说说系统调用open函数

这里第一个参数是文件名,第二个就是标志位,第三个权限位
flags有众多选项O_RDONLY表示只读,O_WRONLY表示只写,O_RDWR表示读写,O_TRUNC表示打开文件时,如果文件已存在 ,直接把文件长度截断为 0 字节(清空原有全部内容)
这个权限就是linux文件的权限对于拥有者,所属组,其他人的权限,这个权限是非常重要的我们再创建文件的时必须要设置权限,如果不设置就是乱码


我们一般把权限设置成0666就行了,但是要注意一点再设置之前要先把umask设置成0,这样最终权限才是想设置的0666 因为在linux系统中实际权限等于umask&想要设置的权限
write
说完了open,我们再来谈谈write
这个第一个参数是一个文件描述符,也就是open的返回值,第二个是要写入的buffer,第三个是实际写入的长度,写入失败返回-1。


这里为什么返回实际写入的值是10,但我们文件里面实际只写入了9个字符,答案是因为有一个换行符
这里还有一个问题当我们想要往文件中写入字符串的时候需不需要加\0呢,答案是不需要,因为\0是c语言当中的,和我文件没有任何关系。
有了上面对于标志位的理解,我们反过来去看看c语言提供的几种打开文件的方式我们先来说一下w方式
- 文件不存在:创建文件
- 文件已存在:清空文件
- 只能写,不能读
w方式其实对应的就是标志位的O_WRONLY | O_CREAT | O_TRUNC吗
我们可以得到一个结论c语言文件操作是把系统调用给封装了一遍!
这里我们再谈一个问题
文本写入vs二进制写入
我们知道c语言对于fopen,fwrite也提供了这两个参数来区分文本写入和二进制写入
cpp
fopen("a.txt", "w"); // w+t 文本写
fopen("a.txt", "rt"); // 显式文本读
fopen("a.txt", "wt+"); // 读写文本
cpp
fopen("a.bin", "wb"); // 二进制覆盖写
fopen("a.bin", "rb"); // 二进制读
fopen("a.bin", "ab+"); // 二进制追加读写
我们用系统调用来写入一点东西,这里我们把一个整形变量写入文档


我们发现这个是一个乱码,为什么因为这个写入是二进制写入,把1234567890这个整形变量当作二进制进行写入,在实际写入过程中把这个整形变量写入到了文件里面,所以文件大小是4字节
但是我们写入的1234567890不可显,所以就变成了乱码了,如果我们要看到这个整数,那该怎么办呢我们用snprintf就可以把整形格式化成字符串然后进行写入


我们可以从上面得出一个结论,系统其实根本不关心你是文本写入还是二进制写入,怎么写入由你来确定,二进制还是文本只是语言层面的概念,所以我们推断,c语言的文本写入和二进制写入其实就是做了一层封装,然后再去系统调用
所谓格式化的输入输出,其实就是把内存中的二进制文件转换成字符的形式进行写入,要么我们自己来做这个转换,要么语言来做
read
我们来说一下read的参数类型,第一个是文件描述符,第二个是buffer(用来存放数据的缓冲区) ,第三个是指定期望读取的字节个数


有了上面的基础之后,我们再来说说一个关于文件很重要的东西
文件的本质
我们先看一个问题,我们在程序中打开一个文件,为什么这里的文件描述符是3呢,0,1,2,又是谁,答案是0,1,2是标准输入标准输出,标准错误

c语言我们打开文件的返回值是一个FILE* 的指针,这个是一个结构体他是被typedef出来的结构体里面包含了文件的属性,我们知道在操作系统里面只认文件描述符,我们可以试着在里面提取出文件描述符。


我们可以得出一个结论以前写的c语言代码都是对系统调用进行了一次封装,屏蔽了底层,只提供选项,底层里面的细节全在FILE的结构体对象里面,
任何语言的文件操作底层都封装了文件描述符,和对应系统的系统调用文件操作规则,c,c++语言在windows中使用windows的文件操作规则,linux中使用linux文件操作规则,屏蔽底层,给用户一个通用的接口,让用户使用,这就体现了代码的可移植性,提供代码的占有率

文件描述符
下面我们来正式说一说文件夹描述符,为什么要有这个东西,我们知道在linux当中文件的管理方式是先描述,再组织,在进程创建之初,我们会创建一个task_struct这是一个进程控制块,而这里面也会创建一个叫struct file的结构体来管理文件,一个进程可能会打开多个文件,他们之间用指针进行链接,就像一个链表一样
那么找一个文件的所有内容或者任何一个属性就都能通过struct file找到。也就是说未来想访问文件就只需要找到对应struct file结构体队对象就可以了。

当我们的进程打开文件时,我们的文件属性就会用来初始化struct file 然后也会创建对应的缓冲区,每一个struct file都有自己的文件缓冲区

文件中的内容就会加载到缓冲区里面,我们一旦想读取文件的内容一定是先在内存中打开文件创建对应的struct file结构体然后找到结构体里面的指针指向文件缓冲区的指针,操作系统会预先把文件里的一部分内容加载到缓冲区里面,读取哪些加载哪些这个叫(缺页加载),然后跑到文件缓冲区里面进行读取操作,读写的本质就是拷贝 把对
应缓冲区里面的内容给拷贝到指定内存中去,或者把指定内存的内容给拷贝到文件缓冲区里面 ,有了以上的了解之后,进程是怎么做到快速定位一个文件的呢?我们知道进程创建会创建对应的地址空间表,还有一个就是truct files_struct文件管理结构,文件描述符表里面有个struct file* fd_array\[\]的一个指针数组,数组就会有下标, 这个时候就轮到文件操作符大显身手的时候了,进进程拿着 fd 这个数组下标,去自己的 files_struct 的 fd_array 数组中索引拿到 struct file * 指针,去访问对应的文件,文件操作符的编号就是数组下标

下面我们从底层理解open函数总结一下
进程的 task_struct 中维护struct files_struct文件管理结构,其内部存在struct file* fd_array指针数组;open 调用成功后内核分配一个空闲整型数字作为文件描述符 fd,把内核struct file的指针存入fd_array[fd],并将 fd 数值返回给程序。后续进程进行文件操作时,凭借 fd 这个数组下标,索引 fd_array 数组即可快速获取对应的 struct file 结构体地址,以此完成文件的快速定位访问。

理解重定向
我们知道,linux对于每一个打开的文件都会分配一个文件描述符,从而让进程快速定位到自己想访问的文件,而文件描述符就是struct file* fd_array\[\]的数组下标,文件描述符的分配规则就是找到 当前没有被使⽤的最⼩的⼀个下标,作为新的⽂件描述符。
有了上面的理解之后我们来看代码


我们这里关闭了标准错误流,所以新创建文件分配的默认文件描述符为2
有了上面的理解,假设我们关闭了标准输出流,再开启一个文件,然后用prinf打印一段字符,这个prinf函数内部其实就是往文件描述符为1的标准输出流里面打印,现在我们的文件描述符1指向的是我们新打开的文件,这个新的文件里面会不会写入我们本来想输出到屏幕上的字符串呢
来看现象


答案是会的,其实这个就是重定向的原理

那么我们想实现重定向,就只能用这个办法实现吗,答案是不是的,linux提供了一个系统调用dup2
dup2函数

来看看这个函数的参数,oldfd 和newfd 一看就非常明了,就叫我们传递文件描述符,那么哪个是oldfd,哪个是newfd呢,我们来看看这个函数的本质,就是通过传递对应的值去定位到数组下标然后通过改变指针从而指向新的文件,我们设想一个场景,我们想让一个fd = 3 的文件重定向到fd = `1 上面去,是oldfd = 3 ,newfd = 1吗?
目标:让 newfd 这个文件描述符,变成 oldfd 的副本。
记住一句话:
newfd 最终指向 oldfd 指向的文件;oldfd 本身不变。
所以newfd= 3 oldfd = 1
我们来试一下

这里关闭 fd 是为了释放不用的文件描述符资源。close 并不是直接销毁内核打开的文件对象,只是将当前进程内对应的文件表引用计数减一;只有所有指向该文件表的 fd 全部关闭,引用计数归 0 时,内核才真正释放文件表。

这样的效果是一样的
所以重定向的本质就是把操作系统中系统级别的文件如stdin,out,err等的地址的拷贝,给拷贝到想要重定向的文件地址去。
有了上面的理解,我们再看看下面的三个重定向的原理
输入重定向
cpp
void func()
{
int fd = open("log.txt", O_WRONLY | O_CREAT | O_TRUNC, 0666);
if(fd == -1)
{
perror("open failed");
return 1;
}
dup2(fd, 0);
close(fd);
}
输出重定向
cpp
void func()
{
int fd;
fd = open("output.txt",O_WRONLY|O_CREAT|O_TRUNC,0644);
if(fd == -1)
{
perror("open");
exit(1);
}
dup2(fd,1);
close(fd);
}
追加重定向
cpp
void func()
{
int fd;
fd = open("output.txt",O_WRONLY|O_CREAT|O_APPEND,0666);
if(fd == -1)
{
perror("open");
exit(1);
}
dup2(fd,1);
close(fd);
}
结合上面的列子,我们知道了,想要实现重定向,就是打开文件的方式+dup2函数
理解一切皆文件
这个概念只要是linux老师,都会提及
⾸先,在windows中是⽂件的东西,它们在linux中也是⽂件;其次⼀些在windows中不是⽂件的东
西,⽐如进程、磁盘、显⽰器、键盘这样硬件设备也被抽象成了⽂件,你可以使⽤访问⽂件的⽅法访 问它们获得信息;甚⾄管道,也是⽂件;使⽤的接⼝跟⽂件接⼝也是⼀致的。 这样做最明显的好处是,开发者仅需要使⽤⼀套 API 和开发⼯具,即可调取 Linux 系统中绝⼤部分的 资源。举个简单的例⼦,Linux 中⼏乎所有读(读⽂件,读系统状态,读PIPE)的操作都可以⽤ read 函数来进⾏;⼏乎所有更改(更改⽂件,更改系统参数,写 PIPE)的操作都可以⽤ write 函 数来进⾏。
我们想一想,显示器,键盘这些东西都是文件,这些设备都有自己的读写方法,操作系统会把这些设备用struct device描述组织管理起来,

操作系统是怎么做到让我们觉得这是一个文件的呢,我们来看看下面图片

这就是一切皆文件的原理
缓冲区
什么是缓冲区
缓冲区是内存空间的⼀部分。也就是说,在内存空间中预留了⼀定的存储空间,这些存储空间⽤来缓 冲输⼊或输出的数据,这部分预留的空间就叫做缓冲区。缓冲区根据其对应的是输⼊设备还是输出设 备,分为输⼊缓冲区和输出缓冲区。
为什么要有缓冲区,因为这样可以提高系统的运行效率
我们来看看下面的代码

此时默认会往文件上写东西,下面我们在打印之后关闭文件


发现里面没东西

此时文件大小为0
这时候我们再来调用write系统函数

此时fd没有关闭

发现东西全部写进来了,如果关闭了fd呢

发现只有系统调用起效果了
那么原来的printf里面的内容在哪去了,答案是在缓冲区里面,这里我们可以推断出缓冲区一定不在系统里,而是在c标准库里面,
现象发生的原因,来看看下面的图片

这下上面的情况就说得通了,当close关闭时,进程还没结束,刷新条件也没满足,当进程结束时,刷新条件满足,但文件已经关闭了,所以没有写入进去,如果我们想在进程退出之前就刷新到缓冲区呢,用fflush就可以了。
那么c语言的缓冲区到底在哪呢我们使用的c库函数fwrite fread的底层都是FILE*的文件指针,FILE*是c语言typedef出来的一个结构体,里面封装了缓冲区和fd
根据上面的例子所谓的读取写入,其实就是数据的拷贝,从一个内存空间,拷贝到另一个内存空间,数据的流动本质就是拷贝,这个就是缓冲区存在的意义
当然,还有一个现象就是函数fock之后子进程也会继承父进程的缓冲区,我们来看看下面的代码

