关于I/O的前置知识
首先,问大家一个问题,我们在磁盘中创建一个空文件,这个文件是否会占据磁盘空间呢?
答案是当然会!这是因为空文件只能说明文件的内容为空,而一个文件除了内容部分,还有属性部分,即使是空文件,也有自己的属性,如文件名、大小、权限等等……
所以对于理解文件,我们要有一个基本的共识:文件 = 内容 + 属性!那么,再进一步,所有对文件的操作都可以归类为:a. 对内容操作 b.对属性操作
同理,我们保存文件的数据,保存的就是文件的内容数据和属性数据,这些数据将被保存在磁盘当中。接下来我要提出一个观点:我们要访问一个文件时,要先把这个文件打开!
你应该会觉得,这不是废话吗?访问文件肯定要打开文件啊!可是不妨想想几个问题:
1. 我们去访问文件,这里的我们指的是?
很明显我们不可能跑到磁盘中去把文件打开,在C语言中,我们使用过fopen函数来打开文件,但是在我们编的程序运行起来之前,是不会打开文件的,所以这里帮助我们访问文件的是运行中的程序,也就是:进程
研究文件操作,本质就是在研究进程和被打开文件的关系
2. 文件在打开前和打开后有什么区别?
首先,我们要明确一件事,我们打开文件的目的是:
读/写/修改文件的内容/属性
文件在被打开前是普通的磁盘文件,根据冯诺依曼体系,CPU不能直接访问磁盘中的文件,这会大大降低电脑的运行速度,所以要想让CPU访问文件,必须先让文件被加载到内存中,而负责访问磁盘设备的,就是我们的操作系统。
所以打开后的文件,是被加载到内存中的文件!
3. 一个进程可以打开多个文件吗,多个进程可以打开多个文件吗?
答案是肯定的,一个进程可能会打开非常多个文件,那么操作系统在运行中需要对这些打开的文件进行管理,根据面向对象的思想,操作系统对文件进行先描述,再组织
即一个文件被打开时,在操作系统中同时形成被打开的文件对象,所以对打开文件的管理就能够转化为对数据结构的增删查改
根据是否被打开,文件分为:被打开的文件、未被打开的文件,它们分别位于内存中、磁盘中。而我们关于基础I/O的学习,就是关于这两大类文件的!
常见的C语言接口
1. fopen
此函数传入的参数分别为文件所在路径、打开文件的方式,而返回值是文件指针类型,调用此函数能打开文件
2.fclose
此函数传入的参数为文件类型指针,调用此函数能将打开的文件关闭
3.fputs
此函数传入的参数分别为要写入的字符串、待写入的文件流,调用此函数能向文件流中写入内容
认识几个操作文件的系统接口
事实上,我们在前置知识中提到的:"文件是由进程打开的",并不严谨,应该说文件是由进程通过操作系统打开的,操作系统为了防止我们搞事情,不允许我们直接访问磁盘这样的硬件设备,而是提供了系统调用,我们只能通过系统调用接口来进行访问。
但是我们不是可以通过库函数fopen来打开文件吗?这也不是系统调用啊?所以我们学习的C语言打开文件的接口,底层一定封装了系统调用接口!
所以,接下来,我们要学习几个文件操作相关的系统调用接口
1. open函数
此函数的作用是:根据指定路径文件名,和打开文件的方式,打开文件,并返回文件描述符
函数传入的参数为:文件路径文件名、打开文件的标识位、(打开的文件的权限)
函数的返回值:当打开成功时返回文件描述符,打开失败时返回-1
值得一提的是,此处的flag是通过位图的方式来传参的——Linux中常用的函数传参方式
我们都知道,int有32个比特位,如果我们用比特位来传参,那么就可以更加简便地一次传入多个参数了
常用的标记位:
位图传参
这样解释起来还是太抽象了,我来举个例子:
所以,我们通过宏的方式,可以向函数批量化传入多种标志位!
2. close函数
此函数的功能为:根据传入的文件描述符,关闭指定的文件
函数的传入参数为:文件描述符
函数的返回值:取决于函数的结果,当关闭成功时,返回0;否则返回-1,并设定好errno的值
3. write函数
此函数的功能为:根据传入的文件描述符,向文件写入指定字节的常量数据
函数的传入参数为:文件描述符、指向常量数据的指针、写入的字节数
函数的返回值为:当写入成功时,返回写文件的字节数;当写入失败时,返回-1,并设置errno的值
4. read函数
此函数的功能为:根据传入的文件描述符,从文件中读取指定字节的常量数据到传入的缓冲区中
函数的传入参数:文件描述符、存读取数据的缓冲区、要读的字节数
函数的返回值:当写入成功时,返回真实读取到的字节数
文件描述符
1. 文件描述符是什么
大家可能发现了,在我对这两个函数的描述中,出现了一个陌生名词:文件描述符(fd:file descriptor),而且这还是个int类型的整数,这是什么呢?别着急,接下来我会解释你们的困惑,在这之前让我们先把这几个系统调用函数用起来!
通过这个例子,我们可以很明显地看出,以当前的标记位打开文件,写入时会从头开始写入,覆盖文件原有内容,而不是直接清空文件原有内容再写入
要想清空文件再写入,还需要加上这个标记位。
演示了这几个系统调用函数后,我来正式为大家讲解文件描述符。
来看看这段代码:
我们发现,连续打开几个文件,返回的文件描述符是连续的小整数,那我想问问大家,通常是在什么样的时候,会有这样连续的小整数返回呢?
是不是联想到了数组下标呢?没错!实际上就是数组,继续讲解文件描述符之前,我想先谈一谈文件在操作系统中的表现的理解。
2. 文件在操作系统中的表现
在前置知识部分,我们知道了文件是由进程帮我们打开的,且磁盘中可能有多个文件会被同时打开,操作系统中进程由 task_struct(Linux中的PCB进程控制块)来管理,被打开的文件则由struct file(描述被打开文件的结构体)来管理,我们都知道,操作系统必须要维护进程和被打开文件的关系,根据 "先描述,再组织",我们需要一个结构体来进行描述和管理,Linux中给出的解决方案是:struct_file 结构体,它包括了 struct file 结构体的指针数组 fd_array
open的实现过程:
1. 文件被打开后,操作系统生成struct file,将其管理起来
2. 在fd_array中,找到第一个没有被写入的位置进行写入
3. 将这个数组下标作为返回值,返回给上层使用者
而这个返回的下标,就是文件描述符!这个数组,叫做进程文件描述符表。
这也就解释了,为什么使用系统调用函数时,打开文件会返回文件描述符fd,向文件中写入和关闭文件也需要传入fd,因为进程要通过文件描述符来找到对应的打开的文件!
现在知道fd的本质是下标了,那么问题又来了,既然是数组下标,那为啥不从0开始而是从3开始呢?我们的0、1、2下标呢??难道进程自动为我们打开了3个文件吗???
没错!在进程启动时,就默认为我们打开了三个文件,依次分别是:
stdin(标准输入),stdout(标准输出),stderr(标准错误)
实际上stdin就是键盘文件,stdout和stderr是显示器文件,因为我们程序员在敲代码时最常用到的就是键盘和显示器,所以进程就默认我们需要,自动帮我们打开了。
在前面我们讲解C语言提供的文件操作接口中,fopen的返回值是个FILE*类型的指针,而我们刚刚明白了一件事:进程必须通过文件描述来找到对应的打开文件,所以FILE这个结构体中一定封装了文件描述符fd!
证明一下:
在这里我想提出一个结论,那就是:操作系统下,一切皆为文件!
理由:大家有没有发现一件事情,我们常常接触的外设,例如键盘、显示器、磁盘、网卡需要做的无非就是读和写,可能为只读、只写、既读又写……是不是和文件非常的相似?你可能会说,这些个设备,虽然进行的是读写,可是它们的功能各异呀,难道全部都能被用文件来概括么?
是这样没错,所以我们对这些不同的设备的读和写用的是不同的函数,但我们在管理文件时,想进行读或写时,利用函数指针来实现回调函数,用read指针指向指定设备的读方法,write指针指向指定设备的写方法
那么,对设备文件的管理是不是就和底层的方法无关了,管理设备只需要管理struct file结构体就行了!!我们将这层封装,称为虚拟文件系统,也正是因为有了虚拟文件系统,我们不用关心设备文件读写的各异,在上层就可以认为:操作系统下,一切皆文件!
3. 理解struct file内核对象
在上文中,我们一直说操作系统用struct file这个内核对象来管理被打开的文件,那么大家认为这个用来管理被打开文件的对象里应该有些什么呢?
不管大家心中的答案是什么,只有一点是可以确定的,里面肯定至少有着被管理文件的内容和属性,因为文件本质就是内容+属性!实际上,struct file结构体中除了包含打开文件的所有属性、文件的操作方法集、还有一块名为文件缓存区的内存空间,用以从磁盘中拷贝数据进行读/写数据。
大家也许会奇怪,读数据拷贝到内存中就算了,为什么写数据也要拷贝到缓冲区中呢?
那我想问问大家,所谓的写数据,是不是包括了增加、修改、删除呢?进程要进行这些操作,必须能够获得修改前的数据。所以说,无论读写,都必须先将数据拷贝到文件缓冲区中。至此,我们得出了一个结论:应用层进行的数据读写,本质是将内核缓冲区中的数据进行来回拷贝!
Linux源码中的文件系统是非常复杂的,我们现在肯定没办法全部理解,所以我仅给大家看看其中的几个先前提到过的部分。
这就是管理被打开文件信息的files_struct,转到定义:
看到了么,这就是我们之前提到的文件描述符表:
而当我们转到struct file(文件对象)的定义时:
其中包含了链式信息,用以将struct file进行链式存储,
文件的引用计数:代表了该文件被几个进程打开,当引用计数为0时才关闭文件
文件的打开方式(读/写)、文件的权限、文件的读写位置
从名字就可以看出这是个操作集,转到定义:
可以看出,这实际上就是一批函数指针的集合,通过这些个函数,进程就能实现对文件的种种操作
等等……
4. fd的分配规则
前面我说过,进程默认打开了0、1、2文件描述符,那如果我们把默认打开的文件描述符关掉会怎么样呢?让我们来试试:
将0号文件描述符关闭时,我们新打开的文件的文件描述符是0
将2号文件描述符关闭时,我们新打开的文件描述符是2
将1号文件描述符关闭时,显示器并没有打印内容,这是因为1号对应的就是标准输出
大家一定都发现了,文件描述符的分配规则是,寻找最小的没有被使用的数据的位置,分配给指定的打开文件!
那么printf的内容到哪去了呢,是不是在log.txt中?
发现并没有,这是怎么回事,1号文件描述符不是被log.txt占了吗?这就涉及到了缓冲区的问题,在下一篇文章基础I/O(下),我将会给大家解释缓冲区,我们先来解决printf的内容不在log.txt的问题
调用了刷新标准输出的函数之后,printf打印的信息就和我们预料的一样被打进了log.txt里
printf本来是向显示器中打印信息,经过这一通操作之后,打印到了文件里,这就是所谓的输出重定向
所以说printf向显示器打印内容,不是因为它想向显示器打印,而是因为fd1是显示器文件,把fd1换成文件它就转而向文件打印了!所以重定向实质上就是:上层fd不变,底层fd指向的内容发生改变
重定向的实质
1. 通过系统调用函数实现重定向
尽管我们将刚刚的关闭一个文件后打开另一个文件让文件占据该位置这样的操作,称为重定向,但这样做未免也太挫了吧!有没有不这么复杂的做法呢?所以我接下来要给大家讲讲关于重定向的系统调用接口!
我们刚刚的重定向的实现是根据文件描述符的分配规则来实现的,而系统调用接口的实现方式则是打开一个文件后,将新fd指向的内容拷贝到指定的fd中,所以操作系统实现重定向,实际上就是文件描述符表级别的数组内容的拷贝
实现这个动作的就是我们的dup2函数:
此函数传入的参数分别为:另外打开的文件的文件描述符oldfd,待被重定向的文件的文件描述符newfd,它做的事情就是把oldfd的指针内容拷贝覆盖到newfd中。
对dup2函数有了基本的了解之后,我们要做的事就是快速上手把它用起来!为了更好地展示出效果,我们对标准输出函数进行重定向。
2.我们曾经是如何使用重定向的
对于 > 和 >> 这两个重定向符号想必大家都不陌生,我们在命令行中输入指令可以把内容重定向到指定文件中
而在Linux的命令行解释器中,我们输入的指令是由bash的子进程来执行的,子进程通过识别我们输入的字符串,进行对应的处理
所以我们有理由认为:子进程应该也是通过调用dup2函数来完成重定向操作的。
所学内容总结
在本文结束之前,我们来复习一下今天我们学习到的内容:
我们明确了一件事:研究文件操作,本质是在研究进程和被打开文件的关系,而文件被打开其实就是文件被加载到内存中。因为进程大概率会打开多个文件,操作系统要管理这些文件, 就要创建文件对象来描述文件,然后用数据结构管理文件对象, 后面我们查看了Linux源码中文件对象的部分代码. 我们还学习到了几个文件操作方面的系统调用函数的使用方法和例子,搞清楚了文件描述符是什么、分配规则是怎样的,申明了操作系统下,一切皆文件!理解了重定向的实质