1. 回忆文件操作相关知识
我们首先回忆一下关于文件的一些知识。
如果一个文件没有内容,那它到底有没有再磁盘中存在?答案是存在,因为文件 = 内容 + 属性,即使文件内容为空,但属性信息也是要记录的。就像进程的组成是代码和数据 + PCB一样。
下面看一段经典的文件操作的代码,回忆一下文件操作的函数。
这是向文件 log.txt 中写入5行 hello file ,如果当前工作目录没有log.txt那就新建一个。
我们发现如果我们想对一个文件进行读写操作,首先就要打开文件,那为什么要先打开文件,打开文件到底是在干什么呢?
我们知道文件是在机器的磁盘上存储的,但是对文件进行操作的却是在进程当中,而进程的所有信息都应该在内存上存储的,也就是说如果想操作文件就要把它加载到内存中通过进程去操作,而加载内存这一行为在代码上的表现就是fopen。
文件被加载到内存之后肯定也要被OS管理起来,也就是文件也要被先表述再组织 ,具体来说,在内存中文件肯定被OS用 文件的内核结构+文件的内容挂起来再用数据结构串起来,便于之后进程对文件进行操作,也就是说我们研究文件操作,就是在研究文件和进程的关系。
文件有两种:1. 被打开的文件(在内存中) 2. 没被打开的文件(在磁盘中) 这两种文件我们后面都要研究。
fopen函数
选项 r:只读打开文件,从文件开头处开始
选项 r+:读写打开文件,从文件开头处开始
选项 w:将文件截断(清空),若不存在则新建文件,从文件开头处开始
选项 w+:读写打开文件,将文件截断(清空),若不存在则新建文件,从文件开头处开始
选项 a(append):向文件追加,如果不存在则新建文件,从文件末尾处开始
选项 a+:读写打开,如果不存在则新建文件,读从文件开头处开始,写从文件末尾处开始
在此我们回忆一下关于输出重定向的内容
符号 > 就相当于选项 w 打开文件,符号 >> 就相当于选项 a 打开文件
任何一个进程 在启动的时候都默认要启动三个输入输出流 ,分别是 stdin(标准输入) stdout(标准输出) stderr(标准错误)。这三个文件流对应的设备分别是键盘、显示器、显示器,我们又知道Linux下一切皆文件,因此这三个设备其实就是三个文件,它们有对应的文件流也是合理操作。
到目前为止我们学过的打印到显示器的方法有如下四种
这些都是C语言提前给我们封装好的文件操作接口,并不是系统层面上的接口,接下来我们看看系统级别的文件操作函数。
2. 文件操作系统接口
2.1 open
2.1.1 参数
第一个参数 pathname 一个字符串,可以选择带或不带路径的文件名,不带路径的话就只能打开当前工作路径的文件。
第二个参数 flags 标记位,常见的标记位一共有5个
O_RDONLY(只读) O_RDONLY(只写) O_RDWR(读写) O_CREAT(创建) O_APPEND(追加)
O_TRUNC(清空)
这5个标记位都是宏,它们之间是可以随意组合的。组合的目的就是一次性传递更多信息,而实现的方法就是 flag 是一32位的位图,每一个比特位都代表一个开关。
这五个标记位每人对应一个比特位,将它们 按位或也就是将它们组合,最后的结果给到位图flag分析,通过每一位上的1和0标记出某一个功能的开启或关闭。
第三个参数 mode 设置权限。系统接口没有默认权限这一说的,因此我们在O_CREAT 创建文件的时候,一定要带上第三个参数来设定好权限,否则权限就会出现乱码。而mode的值就是权限码 ,当然这个值后来会被权限掩码所影响。
关于文件的权限,权限码,权限掩码是什么参考:
Linux·权限与工具(上)-CSDN博客文章浏览阅读798次,点赞22次,收藏21次。本节讲解了,shell操作系统的外壳程序是什么,Linux中怎么设置删除用户,文件和目录的权限属性:读写可执行,拥有者拥有组其他人,以及如何修改这些参数,umask权限掩码是什么,粘滞位t是什么权限https://blog.csdn.net/atlanteep/article/details/140466975?spm=1001.2014.3001.5502 真实权限码 = 设置权限码 & umask按位取反
下面我们实操一下
可以看到我们设置的权限码虽然是 0666 但是因为权限掩码是 0002 因此最后表现出来的真实权限码就是 0664 ,这个666前面必须要有0表示这是一个八进制数。
如果不想让OS使用权限掩码影响我们对文件权限的表述
可以在代码中使用 **umask()**接口更改权限掩码
可以看到文件的权限变成了666 ,因为这个程序是在子进程中跑的,因此更改的umask并没有影响到shell上的umask值。
2.1.2 返回值
我们前面看到open函数的返回值是int类型的数字
打开失败返回 -1
打开成功返回descriptor(文件描述符),跳转到 2.2 close 看看文件描述符是什么
2.2 close
man 2 close查询
通过文件描述符关闭某个文件。
我们这里直接说结论fd就是数组的下标,这个数组是让进程数据结构和文件数据结构产生关联的一张表(文件描述符表)。
我们使用open多次打开多个文件之后会发现这样一个现象:
三个默认启动的输入输出流也都有各自对应的硬件文件,但因为它们是默认启动的,因此它们的fd是固定的0、1、2
stdin对应标准输入匹配是键盘文件,fd=0
stdout对应标准输出显示器文件,fd=1
stderror对应标准错误显示器文件,fd=2
之后再打开的文件直接pushback到数组结尾,也就是从fd=3开始依次增加。
最后我们说一下出现这种现象的原理,以及fd所在的表到底是什么
当文件被加载进内存之后并不是在进程模块中,而是自成一派。由file结构体存储文件的打开信息,就像PCB一样,加载一个进程进来就用链表穿起来,最后组织成一个链表file list
那进程和文件又是如何耦合起来的呢?
PCB中有一个指向 file_struct结构体 的 指针files,这个结构体中有一个非常重要的指针数组 fd_array[ ]文件描述符表 ,文件描述符表中每个元素指向在该进程中打开的文件结构体地址,因此这个数组的下标就是所谓的 fd ,因为三个文件读写流是默认启动的,因此这个数组的前三项总是它们,也就是0、1、2总对应着这三个流
2.3 write
man 2 write查询
w w+ 式写入
O_TRUNC控制每次打开先截断文件。
这里write写入文件的时候不要把字符串的\0也写进去,因为这个字符串结束符是C语言定义的,文件不认,因此只要把干干净净的字符串送进文件中就行。
a a+ 式写入
2.4 read
man 2 read查询
··
把fd文件中的内容读到buf中去,期望读到的字节数位count,最多能读count个数据
返回值是实际读到的字节数,sssize_t就是有符号的整数
这段代码的功能就是从标准输入流也就是键盘中读取数据到buffer数组中。
3. VFS虚拟文件系统
VFS(virtual file system) 虚拟文件系统,是使用函数指针的方法屏蔽底层不同硬件操作上的差异的方法。也是我们所说Linux下一切皆文件的核心思路,下面我们看看这个VFS到底是怎么实现一切皆文件的。
首先每个硬件都由自己的驱动把自己的信息加载到OS中的device结构体中,但是每个硬件的读写方法是不一样的,但这都不是问题。在VFS虚拟文件系思路中,将为硬件也创建 file文件结构体 这个结构体中包括了 read 和 write 函数指针,指向对应硬件的读写方式。此时就可以调用read write函数调用到对应的硬件读写方法,从而完成对硬件的读写。
通过虚拟文件系统的思路将硬件模拟成文件,然后将硬件的操作方法映射到文件的操作函数上,此时就可以通过操作文件的方法操作硬件了,使用函数指针屏蔽底层不同硬件操作上的差异。这就是Linux下一切皆文件的实现方案。
其实这也是C下实现多态的思路,虽然都是read函数,但是作用在不同的文件结构体中可以产生不同的效果。
4. IO的基本过程
我们知道 file 结构体用来描述文件,包括其属性集和操作方法表等,同时还由一个指向文件内核缓冲区的指针。
文件的IO过程都要通过这个缓冲区作为中间商,平衡磁盘和内存之间读写速度上的差异。
读就是将文件内容从磁盘加载到文件缓冲区,然后通过read函数将文件缓冲区的内容提取到某个数组或者变量中去。
写就是通过write函数先将变量或者数组中的内容存放到文件缓冲区中,之后OS自己决定何时将文件缓冲区中的内容刷新到磁盘中去。
这也是为什么我们在操作word文档的时候,如果突然文档进程挂掉了,我们没保存的东西可能就没了,因为我们写文档的时候只是把内容write到了文件缓冲区中,而文件缓冲区是在被进程管理着的,进程挂了缓冲区直接就被释放了,根本来不及把内容写到磁盘中去。
5. 深入理解重定向
首先我们了解一共简单的概念,进程打开文件需要给文件分配文件描述符fd,fd的分配原则是分配最小的未被使用的fd
可以看到正常情况下,因为文件描述符 0、1、2 都被占用了,因此后续进程打开的文件,其描述符是从3开始递增的。
可以看到当我们把0号文件关闭之后,第一个我们自己打开的文件就从0号描述符开始排队了,这就是所谓分配最小的未被占用的文件描述符。
在继续谈重定向问题之前我们再明确一个问题,为什么 printf 就是直接把内容打印到显示器上,但是 fprintf 既可以选择stdout打印到显示器,也可以通过 FILE* 指针打印到某个文件中去。
道理很简单,因为printf函数中写死了写入的文件,就是 fd == 1 的文件也就是显示器文件,因此我们printf的内容就都被固定输出到 1 文件中去了。
由此我们获得灵感,如果 close(1) 此时 1 号文件(显示器)被关闭,但是 open log1.txt 的时候就会把这个文件的 fd 设置成 1 ,之后的printf又指认 1 号文件,也就是说这么做是不是就完成了printf的重定向
我们实践一下
当我们编译运行之后发现并没有在屏幕上打印fd的信息,这还算正常,因为现在 1 号位中的文件已经不是显示器,而是 log1.txt 了。但是当我们查看 log1.txt 的时候发现这个文件里也啥都没有,这一点就很奇怪了。
但是我们使用fflush函数刷新一下就可以看到该写入的东西就写入了,也就是完成了重定向的现象。
之所以会出现这种现象是因为我们选择的打印函数printf是C语言的函数,而语言级的IO函数也有一块配套的用户级缓冲区,其作用是暂时保存要写入的内容,之后等待时机写入文件的内核缓冲区,而进入文件内核缓冲区之后要写入的数据就被OS管理起来了,什么时候刷新到显示器上就是OS自己决定的了。
之所以有用户级缓冲区是因为使用系统调用函数的成本是很高的,至少比一般我们在语言层上写的那些的函数要高。因此不能频繁的调用系统调用将数据插入到文件内核缓冲区,于是就诞生了用户级缓冲区暂存数据。
用户级缓冲区刷新数据的机制:1.如果是显示器文件,遇到\n就刷新,这种机制也叫行刷新。 2. 普通文件,将用户级缓冲区填满再刷新 3. 不缓冲,直接使用系统调用将数据存入文件的内核缓冲区。
因此之所以会出现我们刚才那种现象,是因为使用printf函数的时候,先是将数据暂存到了用户级缓冲区,到合适的时候会调用write接口刷新到文件内核缓冲区。但是因为我们要写入的内容实在太少了,没有触发写入文件内核缓冲区的机制,但是printf完之后我们就使用系统调用close将文件关闭了,此时用户级缓冲区的内容根本来不急将内容刷新到文件内核缓冲区了。
当然,如果我们使用C语言提供的fclose函数,在关闭的文件的时候会自动刷新一下用户级缓冲区,或者使用fflush向上面的例子一样在调用系统调用之前手动刷新一下用户级缓冲区。
fsync
将文件内核缓冲区的内容直接刷新到外设文件中。
因此重定向的过程实际上就是对文件描述符指向的替换,递增的文件描述符指向变化了,但是上层不知道因此使用相同的文件描述符时操作的文件却不同
这里还有一个后面有用的小细节,就是当描述符指向替换过后原指向还是有效的,也就是说一个文件是可以同时被多个指针指向的,这个东西后面进程间通讯是有用的。
5.1 dup2 文件重定向
前面那种手动重定向的方案还是太麻烦了,系统调用中提供了重定向函数man dup2直接查看
这个函数就是将oldfd指向的文件重定向到newfd的位置。
dup2重定向之后
看这里打印的fd值也可以印证我们前面说的一共文件被两个指针同时指向,之前是3号指针指向log.txt文件,但是我们dup2重定向了之后1号指针也指向了这个文件,但是3号指针的指向并没有被抹除。
最后我们明确两点,第一重定向应该让子进程自己做,第二程序替换不会影响重定向,因为它们在进程PCB中属不同的模块,互不影响。
5.2 fsync 刷新文件内核缓冲区
我们知道fflush函数可以将用户级缓冲区的内容手动刷新到文件内核级缓冲区中。同理系统调用 fsync 函数可以将文件内核级缓冲区的内容刷新到文件中去。
参数很简单,就是文件描述符fd