Linux - 第5节 - 基础IO

目录

1.复习C文件IO相关操作

1.1.当前路径深入理解

1.2.fopen函数的选项

2.系统文件I/O

2.1.预备知识

2.2.系统接口

3.文件描述符fd

3.1.文件描述符的概念和底层理解

3.2.文件描述符的应用

3.2.1.文件描述符的分配规则

3.2.2.重定向深入理解

3.2.3.缓冲区理解

3.3.模拟实现c标准库I/O接口函数

3.4.命令行中重定向的实现

3.5.重定向与标准输出、标准错误

4.文件系统

4.1.磁盘的物理结构

4.2.磁盘的存储结构

4.3.磁盘的逻辑抽象结构

4.4.软硬链接


1.复习C文件IO相关操作

1.1.当前路径深入理解

准备工作:

1.文件=文件内容+文件属性

文件属性也是数据,因此即使创建一个空文件也要占据磁盘空间。

2.文件操作=文件内容操作+文件属性操作

在操作文件的过程中有可能既改变内容又改变属性,即改变内容的同时属性可能跟着改变(例如向文件写入的时候,文件的最新修改时间和文件的大小都会改变)。

3.问题:所谓的“打开”文件究竟在干什么?

答:“打开”就是将文件的属性或内容加载到内存中(由冯诺依曼体系决定)。

4.问题:是不是所有的文件都会处于被打开的状态?没有被打开的文件在哪里?

答:不是所有的文件都会处于被打开的状态,没有被打开的文件在磁盘中存储着。

5.文件可以分为内存文件(打开的文件)和磁盘文件。

6.问题:通常我们打开文件,访问文件,关闭文件,是谁在进行相关操作?

答:通过使用fopen、fclose、fread、fwrite形成代码,代码编译得到程序,将程序运行起来的时候才会执行对应的代码,才是真正的对文件进行相关操作,因此本质上是进程在对文件进行相关操作。学习文件操作也就是学习进程和打开文件的关系。

当前路径的深入理解:

创建一个myfile.c文件写入下图一所示的代码,创建一个makefile文件写入下图二所示的代码。使用make命令生成可执行程序,然后使用./myfile执行该程序。进程的信息在/proc路径下,proc是一个内存级文件系统,可以在里面查看进程信息,再打开一个终端,使用命令ls /proc/10505 -l查看进程信息,如下图三所示。

fopen("log.txt", "w")打开文件没有带路径,那么文件默认会在当前路径形成。如上图三所示的cwd的内容就是当前进程所处的工作路径,即当前路径。

对myfile.c文件里面的代码进行修改,使用chdir函数修改当前进程的工作路径,如下图一所示,然后使用make命令生成可执行程序,使用./myfile执行该程序。打开一个终端,使用命令ls /proc/10505 -l查看进程信息,如下图二所示。如下图三所示,myfile.c文件所处的路径下没有log.txt文件,在/home/dxf路径下可以看到log.txt文件。

总结:我们通常所说的当前路径准确的说应该是当前进程所处的工作路径,当前进程所处的工作路径默认是进程对应可执行程序所处的路径,当前进程所处的工作路径可以手动进行修改。

注:我们使用cd和pwd其实就是修改了当前进程所处的工作路径,也就是修改了文件默认生成的路径。

1.2.fopen函数的选项

fopen函数的选项:

字符串说明

r

以只读方式打开文件,该文件必须存在。

r+

以读/写方式打开文件,该文件必须存在。

w

打开只写文件,若文件存在则文件长度清为零,即该文件内容会消失;若文件不存在则创建该文件。

w+

打开可读/写文件,若文件存在则文件长度清为零,即该文件内容会消失;若文件不存在则创建该文件。

a

以附加的方式打开只写文件。若文件不存在,则会创建该文件;如果文件存在,则写入的数据会被加到文件尾后,即文件原先的内容会被保留(EOF 符保留)。

a+

以附加方式打开可读/写的文件。若文件不存在,则会创建该文件,如果文件存在,则写入的数据会被加到文件尾后,即文件原先的内容会被保留(EOF符不保留)。

创建一个myfile.c文件写入下图一所示的代码,创建一个makefile文件写入下图二所示的代码。使用make命令生成可执行程序,然后连续使用./myfile和cat log.txt命令进行追加写入和打印,如下图三所示。

fopen函数的a选项是追加写入,不断的往文件中新增内容,与追加重定向类似。

对myfile.c文件里面的代码进行修改,fopen函数使用w选项打开文件并且不再写入内容,如下图一所示,然后使用make命令生成可执行程序,先cat log.txt发现文件里面有内容,使用./myfile执行该程序后再cat log.txt发现文件里面内容被清空,如下图二所示。

当以w方式打开文件准备写入时,其实文件已经先被清空了。

对myfile.c文件里面的代码进行修改,fopen函数使用r选项打开文件并且使用fgets函数读取数据,如下图二所示,然后使用make命令生成可执行程序,打开log.txt写入一些内容,如下图三所示,使用./myfile执行该程序,如下图四所示。

fgets函数的声明如下图一所示,参数stream是读取的文件流,参数s用来保存读取的数据,参数size是读取数据的个数,如果读取成功则返回保存读取数据的起始地址即参数s,如果读取失败则返回NULL。

实现一个打印文件内容的程序(类似cat指令):

创建一个myfile.c文件写入下图一所示的代码,创建一个makefile文件写入下图二所示的代码。使用make命令生成可执行程序,使用./myfile+文件名即可打印对应文件的内容,如下图三所示。


2.系统文件I/O

2.1.预备知识

补充知识:

当我们向文件写入的时候,最终其实是向磁盘写入。磁盘是硬件,只有操作系统有资格向硬件写入,所有上层访问文件的操作,都必须贯穿操作系统。

操作系统是如何被上层使用的呢?必须使用操作系统提供的相关系统调用。像printf这种函数内部就一定封装了操作系统提供的系统接口。

为什么我们从来没有见过这些操作系统提供的系统接口呢?那是因为所有的语言都对系统接口做了封装。

为什么要对系统接口进行封装?原因一是原生系统接口的使用成本比较高。原因二是Linux的系统接口与windows等其他操作系统的系统接口并不相同,操作系统的系统接口无法跨平台使用,语言对各种操作系统的系统接口进行封装使得语言可以跨平台(语言提供的函数接口内部穷举所有的底层系统接口+条件编译,使得语言提供的函数接口在Linux下运行调用Linux的系统接口,在Windows下运行调用Windows的系统接口)。

学习系统文件I/O的原因:

各种语言库提供的文件访问接口(不同的语言有不同的文件访问接口)都是调用各个操作系统的系统接口,如下图所示,因此我们必须学习文件级别的系统接口。

2.2.系统接口

open接口:

参数pathname是要打开文件的路径;参数flags是打开文件要传递的选项,选项有只读方式打开O_RDONLY、只写方式打开O_WRONLY、读写方式打开O_RDWR、追加方式打开O_APPEND、文件不存在则创建文件O_CREAT、打开文件后清空文件内容O_TRUNC;如果对应文件不存在会创建文件,参数mode涉及创建新文件的初始权限,采用文件权限八进制位的方式表示,最终文件的权限是这里的八进制位&(~权限掩码umask),即最终文件权限还要考虑权限掩码。

返回值int是文件描述符,文件描述符我们第三节详细讲解,如果返回-1代表文件打开错误。

注:

1.使用open接口需要包含<sys/types.h>、<sys/stat.h>、<fcntl.h>头文件

2.open接口的参数flags对应可传递的那些选项(O_RDONLY、O_WRONLY、O_RDWR、O_APPEND、O_CREAT等)都是宏,每一个宏对应一个标记位,系统传递标记位,是使用位图结构来进行传递的,例如0000 0000中最低位的0/1代表是否只读的宏O_RDONLY,次低位的0/1代表是否只写的宏O_WRONLY等等,0001 0010代表既要写又要文件不存在创建文件。每一个宏标记一般只需要一个比特位,并且和其他宏对应的值不能重叠。如果同时需要多个宏标记位,则这些宏标记位之间用符号或|隔开。open接口参数flags的实现类似下图一所示,运行结果如下图二所示,其中show函数模拟系统接口open。

open接口的使用:

创建一个myfile.c文件写入下图一所示的代码,创建一个makefile文件写入下图二所示的代码。使用make命令生成可执行程序,然后使用./myfile执行该程序,如下图三所示。

我们发现log.txt文件的权限是664并不是我们设置的666,这是因为权限掩码umask为2,如下图一所示。如果一定要创建权限为666的log.txt文件,我们可以使用umask接口(umask不仅是一个命令也是一个系统级接口),umask接口声明如下图二所示,用来设置文件的权限掩码。

我们对myfile.c文件进行修改,利用umask接口设置文件的权限掩码,如下图三所示。运行结果如下图四所示,可以看到此时的log.txt文件权限为666。

close接口:

参数fd为要关闭对应文件open打开时返回的整数值。

注:使用close接口需要包含<unistd.h>头文件

close接口的使用:

创建一个myfile.c文件写入下图一所示的代码,创建一个makefile文件写入下图二所示的代码。使用make命令生成可执行程序,然后使用./myfile执行该程序,如下图三所示。

write接口:

参数fd为要写入对应文件open打开时返回的整数值;参数buf为写入的缓冲区内容的起始地址;要写入的缓冲区内容的大小。

注:使用write接口需要包含<unistd.h>头文件

write接口的使用1(利用open模拟fopen选项w进行写入):

创建一个myfile.c文件写入下图一所示的代码,创建一个makefile文件写入下图二所示的代码。使用make命令生成可执行程序,然后使用./myfile执行该程序,如下图三所示,log.txt文件中写入的内容如下图四所示。

注:这里write(fd,str,strlen(str))代码中,write接口的count参数应该是strlen(str)而不是strlen(str)+1,因为字符串结尾为/0作为结束符是c语言的标准,文件不认/0,文件会认为/0也是有效字符内容,因此/0不应该写入。

在上面写入log.txt内容的基础上,修改myfile.c文件代码,如下图一所示。使用make命令生成可执行程序,再次使用./myfile执行该程序,将内容写入log.txt文件中,如下图二所示。log.txt文件内容如下图三所示。

我们的open选项使用的是O_WRONLY | O_CREAT,我们发现新的内容覆盖了之前旧的内容,旧的内容没有被清空,这是因为我们没有带O_TRUNC(打开文件后清空文件内容)选项。

在上面写入log.txt内容的基础上,修改myfile.c文件代码,在open函数中带上O_TRUNC选项,如下图一所示。使用make命令生成可执行程序,再次使用./myfile执行该程序,将内容写入log.txt文件中,如下图二所示。log.txt文件内容如下图三所示。

因此c语言的fopen函数使用选项w打开,其在底层调用系统函数open对应open函数的选项为O_WRONLY | O_CREAT | O_TRUNC。

write接口的使用2(利用open模拟fopen选项a进行写入):

创建一个myfile.c文件写入下图一所示的代码,创建一个makefile文件写入下图二所示的代码。使用make命令生成可执行程序,然后使用./myfile执行该程序,如下图三所示,log.txt文件中原本的内容如下图四所示,写入后的内容如下图五所示。

因此c语言的fopen函数使用选项a打开,其在底层调用系统函数open对应open函数的选项为O_WRONLY | O_CREAT | O_APPEND。

read接口:

参数fd为要读取对应文件open打开时返回的整数值;参数buf为要读取到的缓冲区的起始地址;要读取内容的大小。

返回值是ssize_t类型的,为实际读取到的字节数。

注:使用read接口需要包含<unistd.h>头文件 

read接口的使用:

创建一个myfile.c文件写入下图一所示的代码,创建一个makefile文件写入下图二所示的代码。log.txt文件中的内容如下图三所示,使用make命令生成可执行程序,然后使用./myfile执行该程序,如下图四所示。


3.文件描述符fd

3.1.文件描述符的概念和底层理解

系统函数open的返回值就是文件描述符fd,当fd<0代表文件打开失败,当fd>0代表文件打开成功。

如果我们在一个进程中同时打开多个文件,代码和运行结果如下图一二所示,可以看到打开多个文件,文件描述符从3开始对应依次增加。

问题1:为什么文件描述符是从3开始的?

答:文件描述符的0、1、2被操作系统默认打开了。0是标准输入,对应的标准设备是键盘;1是标准输出,对应的标准设备是显示器;2是标准错误,对应的标准设备是显示器。

要注意的是:我们之前说的标准输入stdin、标准输出stdout、标准错误stderr是c语言的概念,这里提到的文件描述符0标准输入、1标准输出、2标准错误是操作系统的概念。C库函数认识的是标准输入stdin、标准输出stdout、标准错误stderr,系统接口认识的是文件描述符0标准输入、1标准输出、2标准错误。C库函数内部调用的是系统接口,对于文件操作而言系统接口只认fd,c语言中的文件指针FILE本质是C库提供的结构体,结构体内封装了多个成员,在FILE内部必定封装了fd。

验证fd为0、1、2是标准IO:

如下图一所示的代码,运行结果如下图二所示,从键盘中输入的内容放在标准输入中,read读取fd为0的文件内容并打印,发现与我们从键盘输入的内容相同。

如下图一所示的代码,运行结果如下图二所示,利用write将字符串内容写入到fd为1、2的文件中,运行后发现字符串内容打印了出来。

验证fd为0、1、2和stdin、stdout、stderr的对应关系:

如下图一所示的代码,运行结果如下图二所示。这里可以得出stdin、stdout、stderr其实是c语言定义的结构体指针,stdin、stdout、stderr各自指向结构体的fileeno成员变量就是各自封装对应的fd值。

关系图:

问题2:为什么文件描述符fd要使用0、1、2、3、4等等数字表示?

答:其实fd使用的0、1、2、3、4等等数字本质是数组下标。返回文件描述符fd的接口(例如open接口)都是系统接口,是操作系统提供的返回值。

一个进程可以打开多个文件,所以在内核中进程和打开文件的的比例为1:n,在系统运行的过程中,有可能会存在大量的被打开文件,操作系统要对这些被打开的文件进行管理,管理就需要先描述再组织。

一个文件被打开,在内核中要创建该被打开文件的内核数据结构file对象,内核数据结构file对象中包含了文件大部分的内容和属性,内核数据结构file对象中还有类似struct file *next和struct file *prev的成员变量,因此数据结构file对象之间使用类似链表的方式链接起来,操作系统对所有被打开文件的管理就转化成了对链表的增删改查。我们将这种设计出一套struct file对象来对应表示一个个文件的系统称作VFS,即虚拟文件系统。

进程如何与该进程中打开的文件建立映射关系呢?如下图所示,进程的task_struct中有一个struct files_struct* fs结构体指针成员变量,指向进程自己的files_struct结构体,在进程对应的files_struct结构体中有一个struct file*fd_arry[ ]指针数组,指针数组里面的内容依次指向了操作系统中所有被打开的文件file结构体对象,这样如果一个进程想访问某些文件,只需要知道这些文件在files_struct映射表中的数组下标。

当我们在进程中使用open接口打开磁盘中某个文件的时候,操作系统会创建一个struct file结构体对象并链接在链表中,在files_struct映射表中分配一个没有被使用的下标,下标的内容保存file结构体对象地址,下标值就是文件描述符fd,其通过open接口的返回值返回给用户。

进程中用户调用read、write接口读写某文件时一定传入了对应的fd,进程task_struct通过struct files_struct* fs结构体指针成员变量找到files_struct结构体,通过传入的文件描述符fd在files_struct结构体中找到文件file结构体对象,找到了file结构体对象就可以对文件进行相关操作了。

问题3:struct file*fd_arry[ ]指针数组下标0、1、2(即文件标识符fd为0、1、2)的内容分别指向标准输入stdin、标准输出stdout、标准错误stderr的struct file对象,标准输入stdin、标准输出stdout、标准错误stderr分别对应键盘、显示器、显示器,这些都是硬件难道也是和普通文件一样用struct file对象来标识吗?

补充:使用c语言也可以实现面向对象,如下图所示,使用struct file定义一个结构体,对应面向对象的类,定义结构体内的成员变量对应类中的成员变量,定义结构体内的函数指针成员变量对应类中的成员函数,在struct结构体的外面定义struct内函数指针成员变量所指向的函数,函数定义中第一个参数设置成struct file*类型的变量指向结构体对象自己,类似于面向对象的this指针。这样在使用时struct file f定义一个结构体对象,通过f.readp(&f,...)即可实现类似面向对象中的类方法。

答:答案是肯定的,在Linux下一切皆文件,硬件可以看作是文件。

如下图所示,在计算机体系结构中键盘、显示器、网卡、磁盘、其他硬件统一称为外设或I/O设别,每一个外设硬件都有属于自己的读方法和写方法(如果某个外设不需要读或写,那么读或写方法设为空即可),不同的设备读写方法一定不同。操作系统给每一个外设设备都分配一个struct file对象,struct file对象内部的readp函数指针指向对应外设的读接口,writep函数指针指向对应外设的写接口。某进程要调用外设时,进程的task_struct内struct files_struct* fs结构体指针成员变量找到files_struct结构体,在files_struct结构体中找到对应外设的struct file对象,通过struct file对象内的函数指针调用对应外设自己的读写方法,这样进程就可以对外设进行操作。

在硬件中对硬件设备进行读写是硬件的驱动程序做的,然而通过这样的体系结构使得在操作系统层面,可以以统一的文件视角来看待所有的设备(即使是硬件也是和普通文件一样通过struct file对象进行管理),因此在Linux操作系统中一切皆文件。

注:下图将文件描述符fd为0对应成了磁盘设备、fd为2对应成了键盘设备等,现实中并不是这样的,这样对应只是为了帮助理解。

3.2.文件描述符的应用

3.2.1.文件描述符的分配规则

文件描述符的分配规则:

从头遍历数组fd_array[],找到一个最小的且没有被使用的下标,分配给新的文件。

验证:

创建一个myfile.c文件写入下图一所示的代码,创建一个makefile文件写入下图二所示的代码。使用make命令生成可执行程序,然后使用./myfile执行该程序,运行结果如下图三所示。

因为之前fd的0、1、2被占用了,因此打开文件后分配到的fd是从3开始的,如果我们把fd为0的文件(即标准输入stdin)关闭,然后open打开一个文件后分配的fd为0。

修改myfile.c文件的代码,将fd为2的文件关闭,如下图一所示,使用make命令生成可执行程序,然后使用./myfile执行该程序,运行结果如下图二所示。

如果我们把fd为2的文件(即标准错误stderr)关闭,然后open打开一个文件后分配的fd为2。

修改myfile.c文件的代码,将fd为1的文件关闭,如下图一所示,使用make命令生成可执行程序,然后使用./myfile执行该程序,运行结果如下图二所示。

如果我们把fd为1的文件(即标准输出stdout)关闭,然后open打开一个文件后对应的fd为1,但是运行结果并没有打印我们open接口的fd返回值,这是因为printf函数打印是往stdout中打印的,把fd为1的文件关闭,即将stdout关闭自然无法打印内容。

这里有一个问题,printf函数要往fd为1的文件中写入fd的值,此时fd是1应该往log.txt文件中写入fd的值,可是我们打开log.txt文件发现里面没有内容,如下图一所示,这是为什么呢?

其实并不是log.txt文件里面没有内容,而是没有刷新stdout,我们修改myfile.c文件的代码,如下图二所示,使用fflush(stdout)代码刷新stdout。使用make命令生成可执行程序,然后使用./myfile执行该程序,可以看到log.txt文件里面有内容了,如下图三所示。

至于为什么要刷新stdout缓冲区,我们下面在缓冲区的理解部分会讲到。printf函数是向标准输出打印内容,为什么打印到了log.txt文件中,我们下面重定向深入理解部分会讲到。

3.2.2.重定向深入理解

重定向的本质:

举例:创建一个myfile.c文件写入下图一所示的代码,创建一个makefile文件写入下图二所示的代码。使用make命令生成可执行程序,然后使用./myfile执行该程序,运行结果如下图三所示,log.txt文件里面的内容如下图四所示。

现象解释:如下图五所示,虽然这里关闭了fd为1的标准输出文件,然后open打开log.txt文件,此时log.txt文件就是fd为1的文件,printf("fd:%d\n",fd)和fprintf(stdout,"fd:%d\n",fd)打印的时候默认fd为1就是标准输出stdout,因此会向fd为1的文件中打印,实际此时fd为1的文件是log.txt,因此就打印到了log.txt文件中。

引出重定向的本质:这里本来应该要往显示器打印,最终却变成了往指定文件打印,这就是重定向,如果我们要进行重定向,上层只认0、1、2、3、4...这样的fd,我们可以在操作系统内部通过一定的方式调整file* fd_arry[]数组的特定下表内容(指向),就可以完成重定向操作。

这里为什么要fflush(stdout)刷新缓冲区我们在下面在缓冲区的理解部分会解释。

重定向具体操作:

类似结构体files_struct等都是内核数据结构,只有操作系统有权限修改,因此操作系统必须提供接口,这个接口就是dup系列接口,其中dup2接口是最常用的,因此这里只介绍dup2接口,其声明如下图所示。

dup2接口的功能是进行拷贝,将file* fd_arry[]数组中下标为oldfd里面的file*类型的内容拷贝到下标为newfd里面。

dup2拷贝成功则返回newfd,如果失败返回-1。

输出重定向:如果要进行输出重定向,open打开文件时使用O_WRONLY、O_CREAT、 O_TRUNC选项,在file* fd_arry[]数组中,将重定向目标文件对应fd下标里面的内容拷贝到标准输出stdout对应fd为1下标里面,即dup2(fd,1),oldfd为重定向目标文件对应fd,newfd为1。

追加重定向:如果要进行追加重定向,open打开文件时使用O_WRONLY、O_CREAT、 O_APPEND选项,在file* fd_arry[]数组中,将重定向目标文件对应fd下标里面的内容拷贝到标准输出stdout对应fd为1下标里面,即dup2(fd,1),oldfd为重定向目标文件对应fd,newfd为1。

输入重定向:如果要进行追加重定向,open打开文件时使用O_RDONLY选项,在file* fd_arry[]数组中,将重定向目标文件对应fd下标里面的内容拷贝到标准输入stdin对应fd为0下标里面,即dup2(fd,0),oldfd为重定向目标文件对应fd,newfd为0。

注:使用dup系列接口需要包含<unistd.h>头文件。

输出重定向验证:

创建一个myfile.c文件写入下图一所示的代码,创建一个makefile文件写入下图二所示的代码。使用make命令生成可执行程序,然后使用./myfile执行该程序,运行结果如下图三所示,log.txt文件里面的内容如下图四所示。

可以看到log.txt文件里面的内容fd不再是1而是3,这里完成了本身应该向显示器打印的东西打印到指定的文件中,多次./myfile执行程序,文件log.txt里面的内容只有一次打印的结果,实现了输出重定向。

追加重定向验证:

创建一个myfile.c文件写入下图一所示的代码,创建一个makefile文件写入下图二所示的代码。使用make命令生成可执行程序,然后多次使用./myfile执行该程序,运行结果如下图三所示,log.txt文件里面的内容如下图四所示。

可以看到log.txt文件里面的内容fd不再是1而是3,这里完成了本身应该向显示器打印的东西打印到指定的文件中,多次./myfile执行程序,文件log.txt里面的内容有每一次打印的结果,实现了追加重定向。

输入重定向验证:

创建一个myfile.c文件写入下图一所示的代码,创建一个makefile文件写入下图二所示的代码。log.txt文件里面的内容如下图三所示,使用make命令生成可执行程序,然后使用./myfile执行该程序,运行结果如下图四所示。

可以看到log.txt文件里面的内容fd不再是1而是3,这里完成了本身应该从键盘中读取内容变成了从指定文件中读取内容,从文件log.txt里面读取内容打印读取的内容到显示器中,实现了输入重定向。

3.2.3.缓冲区理解

缓冲区的本质:一段内存空间

缓冲区的功能:

功能一:内存与外设数据交互效率较低,因此进程直接与外设交互效率较低,缓冲区可以解放使用缓冲区的进程的时间。

功能二:缓冲区的存在可以集中处理数据刷新,减少IO次数,从而达到提高整机效率的目的。

问题1:缓冲区在哪里?

答:缓冲区不是操作系统内核级别的概念,缓冲区是由c语言提供的,是语言级别的概念。在c语言中标准输入stdin、标准输出stdout、标准错误stderr都是FILE*类型的,FILE是c语言的一个结构体,里面封装了很多属性,前面学过的文件描述符fd就封装在其中,除了fd该FILE对应的语言级别的缓冲区也被封装在其中。

缓冲区在FILE内部,在c语言中,我们每一次fopen打开一个文件,都要有一个FILE*返回,这就意味着每一个文件都有一个fd和属于他自己的语言级别的缓冲区。

如下图所示,当我们使用printf、fprintf、fputs函数将数据写入时,在printf、fprintf、fputs函数内部会先将数据写入FILE结构体的cache成员变量中,cache就是缓冲区,当符合cache的刷新策略时,会通过FILE结构体里面的fd成员变量调用write接口刷新到对应的文件或硬件显示器中。

验证:创建一个myfile.c文件写入下图一所示的代码,创建一个makefile文件写入下图二所示的代码。使用make命令生成可执行程序,然后使用./myfile执行该程序,运行结果如下图三所示,程序刚跑起来立马打印hello write,五秒钟之后才打印hello printf。

printf函数是c语言函数,没有立即打印出来是因为有缓冲区的存在,printf打印hello printf其实是向缓冲区写入,缓冲区没有立刻刷新,所以sleep的时候hello printf没有显示出来。

write函数是系统接口,立即打印出来是因为没有缓冲区的存在,write打印hello write直接向显示器写入。

我们知道c语言的printf函数在内部一定调用了系统接口write,这就说明我们的数据在printf函数内部并不会直接调用系统接口write进行打印,而是将数据先暂存在某一个地方(缓冲区),等到合适的时候(符合缓冲区刷新策略时)再通过write接口将数据刷新在显示器上。

修改myfile.c文件的代码如下图一所示,增加了fprintf和fputs函数进行打印,运行结果如下图二所示,程序刚跑起来立马打印hello write,五秒钟之后才打印hello printf、hello fprintf、hello fputs。

这里可以看出fprintf和fputs函数与printf函数相同,他们都是c语言函数,都存在缓冲区。

问题2:前面我们提到,当我们使用printf、fprintf、fputs函数将数据写入时,在printf、fprintf、fputs函数内部会先将数据写入cache缓冲区中,当符合cache的刷新策略时,会调用write接口刷新到对应的文件或硬件显示器中。那么如果在刷新之前关闭fd(即close关闭对应的文件或硬件)会有什么问题?

答:如果在刷新之前关闭fd,那么刷新失败,相当于write函数写入失败了。

验证:创建一个myfile.c文件写入下图一所示的代码,创建一个makefile文件写入下图二所示的代码。使用make命令生成可执行程序,然后使用./myfile执行该程序,运行结果如下图三所示,程序只打印了hello write。

修改myfile.c文件代码如下图三所示。使用make命令生成可执行程序,然后使用./myfile执行该程序,运行结果如下图四所示,程序只打印了hello write。

这就说明printf、fprintf、fputs之后数据没有被写到操作系统中,而只是存在了自己的cache缓冲区中,在程序退出刷新缓冲区之前将对应要刷新的目标文件或硬件关闭,则刷新失败。

问题3:前面我们提到,当我们使用printf、fprintf、fputs函数将数据写入时,在printf、fprintf、fputs函数内部会先将数据写入cache缓冲区中,当符合cache的刷新策略时,会调用write接口刷新到对应的文件或硬件显示器中。那么cache的刷新策略是什么呢?什么时候刷新?

答:具体刷新策略可以分为三个常规情况两个特殊情况。

常规情况:(1)无缓冲,即立即刷新(2)行缓冲,即逐行刷新(3)全缓冲,即缓冲区慢刷新。

特殊情况:(1)进程退出(2)用户强制刷新

如果刷新对应的是显示器文件,那么就执行行缓冲;如果刷新对应的是块设备文件,即磁盘文件,那么就执行全缓冲。

注:

1.往显示器中打印时,遇到\n缓冲区会立即刷新,这就是常规情况中行缓冲的情况。

2.刷新的本质是把缓冲区的数据write到操作系统内部,然后清空缓冲区。

引出一个奇怪的例子:如下图一所示的代码,运行结果如下图二所示,为什么往显示器打印打印了4行,而往文件中打印却打印了7行呢?

原因:数据直接向显示器打印时,要打印的四个字符串都有\n,c库函数将字符串内容写入缓冲区之后行缓冲会立即刷新到显示器中,系统接口没有缓冲区,也会立即刷新到显示器中。

将打印的数据重定向到文件中时,前面讲到如果刷新到显示器,执行行缓冲;如果刷新到磁盘,执行全缓冲。这里重定向到文件中是全缓冲的情况,即使有\n缓冲区也不会立即刷新,所以执行完printf、fprintf、fputs函数后,打印的内容被写入缓冲区中保存,并没有刷新出去,执行完write,write是系统接口没有缓冲区,因此会立即打印到显示器中。fork之后,子进程继承了父进程的代码和数据(数据以写时拷贝的方式继承),缓冲区是自己的FILE内部维护的,属于父进程内部的数据区域,因此子进程以写时拷贝的方式继承了父进程的缓冲区。fork之后没有代码,此时父子进程退出,父子进程刷新各自的缓冲区,因此printf、fprintf、fputs函数打印的内容最终在文件中被写入了两遍。

解决前面的问题:

创建一个myfile.c文件写入下图一所示的代码,创建一个makefile文件写入下图二所示的代码。使用make命令生成可执行程序,然后使用./myfile执行该程序,运行结果如下图三所示,log.txt文件里面的内容如下图四所示。

这里要fflush(stdout)刷新缓冲区,是因为fprintf函数先将要打印的数据写入缓冲区中,在close关闭文件之前需要将缓冲区的内容刷新到文件中,再关闭文件。

注:这里因为fprintf实际上是向文件中写入的,因此满足全缓冲的情况,即使字符串最后有\n也不会立即刷新。

3.3.模拟实现c标准库I/O接口函数

myfile.c文件:

makefile文件:

myfile.c代码:

#include <stdio.h>
#include <unistd.h>
#include <stdlib.h>
#include <string.h>
#include <sys/stat.h>
#include <sys/types.h>
#include <fcntl.h>
#include <assert.h>

#define NUM 1024

#define NONE_FLUSH 0x0
#define LINE_FLUSH 0x1
#define FULL_FLUSH 0x2

typedef struct _MyFILE{
    int _fileno;
    char _buffer[NUM];
    int _end;
    int _flags; //fflush method
}MyFILE;

MyFILE *my_fopen(const char *filename, const char *method)
{
    assert(filename);
    assert(method);

    int flags = O_RDONLY;

    if(strcmp(method, "r") == 0)
    {}
    else if(strcmp(method, "r+") == 0)
    {}
    else if(strcmp(method, "w") == 0)
    {
        flags = O_WRONLY | O_CREAT | O_TRUNC;
    }
    else if(strcmp(method, "w+") == 0)
    {}
    else if(strcmp(method, "a") == 0)
    {
        flags = O_WRONLY | O_CREAT | O_APPEND;
    }
    else if(strcmp(method, "a+") == 0)
    {}

    int fileno = open(filename, flags, 0666);
    if(fileno < 0)
    {
        return NULL;
    }

    MyFILE *fp = (MyFILE *)malloc(sizeof(MyFILE));
    if(fp == NULL) return fp;
    memset(fp, 0, sizeof(MyFILE));
    fp->_fileno = fileno;
    fp->_flags |= LINE_FLUSH;
    fp->_end = 0;
    return fp;
}

void my_fflush(MyFILE *fp)
{
    assert(fp);

    if(fp->_end > 0)
    {
        write(fp->_fileno, fp->_buffer, fp->_end);
        fp->_end = 0;
        syncfs(fp->_fileno);
    }

}


void my_fwrite(MyFILE *fp, const char *start, int len)
{
    assert(fp);
    assert(start);
    assert(len > 0);

    // abcde123
    // 写入到缓冲区里面
    strncpy(fp->_buffer+fp->_end, start, len); //将数据写入到缓冲区了
    fp->_end += len;

    if(fp->_flags & NONE_FLUSH)
    {}
    else if(fp->_flags & LINE_FLUSH)
    {
        if(fp->_end > 0 && fp->_buffer[fp->_end-1] == '\n')
        {
            //仅仅是写入到内核中
            write(fp->_fileno, fp->_buffer, fp->_end);
            fp->_end = 0;
            syncfs(fp->_fileno);
        }
    }
    else if(fp->_flags & FULL_FLUSH)
    {

    }
}

void my_fclose(MyFILE *fp)
{
    my_fflush(fp);
    close(fp->_fileno);
    free(fp);
}

int main()
{
    MyFILE *fp = my_fopen("log.txt", "w");
    if(fp == NULL)
    {
        printf("my_fopen error\n");
        return 1;
    }

    const char *s = "hello my 111\n";
    my_fwrite(fp, s, strlen(s));
    printf("消息立即刷新\n");
    sleep(3);

    const char *ss = "hello my 222";
    my_fwrite(fp, ss, strlen(ss));
    printf("写入了一个不满足刷新条件的字符串\n");
    sleep(3);

    const char *sss = "hello my 333";
    my_fwrite(fp, sss, strlen(sss));
    printf("写入了一个不满足刷新条件的字符串\n");
    sleep(3);


    const char *ssss = "end\n";
    my_fwrite(fp, ssss, strlen(ssss));
    printf("写入了一个满足刷新条件的字符串\n");
    sleep(3);

    my_fclose(fp);
}

makefile代码:

myfile:myfile.c
	gcc -o $@ $^
.PHONY:clean
clean:
	rm -f myfile

运行结果:

在./myfile运行的同时,使用命令while :; do cat log.txt; sleep 1; echo "##################";done作为监控脚本进行监控。

注:

1.使用write函数只是将数据内容写入到操作系统内核中,如果非要将数据内容写入硬件中需要使用syncfs接口,其声明如下图所示。

syncfs接口的功能是将数据内容写入对应的硬件中,参数fd就是硬件所对应的文件描述符。

2.main函数中虽然是往磁盘文件写入内容,应该执行全缓冲,但是我们这里只实现了行缓冲,所以这里往磁盘文件写入内容,我们让其执行行缓冲。当字符内容有\n时缓冲区立即刷新,当字符内容没有\n时缓冲区不刷新,字符内容保存在缓冲区中。

3.4.命令行中重定向的实现

前面我们实现了一个简易的shell,在简易shell中增加重定向功能。

重定向相关命令的格式为命令>文件名等、命令<文件名等、命令>>文件名等,我们可以使用\0代替重定向符号(>、<、>>)进行分割,前半部分命令继续执行后面的指令分析,后半部分文件名负责打开文件和重定向相关工作。

myshell.c文件:

makefile文件:

myshell.c代码:

#include <stdio.h>
#include <string.h>
#include <string.h>
#include <stdlib.h>
#include <unistd.h>
#include <sys/types.h>
#include <sys/wait.h>
#include <sys/stat.h>
#include <fcntl.h>
#include <assert.h>
#include <ctype.h>

#define SEP " "
#define NUM 1024
#define SIZE 128

#define DROP_SPACE(s) do { while(isspace(*s)) s++; }while(0)

char command_line[NUM];
char *command_args[SIZE];

char env_buffer[NUM]; //for test

#define NONE_REDIR  -1
#define INPUT_REDIR  0
#define OUTPUT_REDIR 1
#define APPEND_REDIR 2

int g_redir_flag = NONE_REDIR;
char *g_redir_filename = NULL;

extern char**environ;

//对应上层的内建命令
int ChangeDir(const char * new_path)
{
    chdir(new_path);

    return 0; // 调用成功
}

void PutEnvInMyShell(char * new_env)
{
    putenv(new_env);
}

void CheckDir(char *commands)
{
    assert(commands);
    //[start, end)
    char *start = commands;
    char *end = commands + strlen(commands);
    // ls -a -l
    while(start < end)
    {
        if(*start == '>')
        {
            if(*(start+1) == '>')
            {
                //ls -a -l>>  log.txt --追加重定向
                *start = '\0';
                start += 2;
                g_redir_flag = APPEND_REDIR;
                DROP_SPACE(start);
                g_redir_filename = start;
                break;
            }
            else{
                //ls -a -l > log.txt -- 输出重定向
                *start = '\0';
                start++;
                DROP_SPACE(start);
                g_redir_flag = OUTPUT_REDIR;
                g_redir_filename = start;
                break;
            }
        }
        else if(*start == '<')
        {
            // 输入重定向
            *start = '\0';
            start++;
            DROP_SPACE(start);
            g_redir_flag = INPUT_REDIR;
            g_redir_filename = start;
            break;
        }
        else 
        {
            start++;
        }
    }
}

int main()
{
    //shell 本质上就是一个死循环
    while(1)
    {
        g_redir_flag = NONE_REDIR;
        g_redir_filename = NULL;

        //不关心获取这些属性的接口, 搜索一下
        //1. 显示提示符
        printf("[张三@我的主机名 当前目录]# ");
        fflush(stdout);

        //2. 获取用户输入
        memset(command_line, '\0', sizeof(command_line)*sizeof(char));
        fgets(command_line, NUM, stdin); //键盘,标准输入,stdin, 获取到的是c风格的字符串, '\0'
        command_line[strlen(command_line) - 1] = '\0';// 清空\n

        //2.1 ls -a -l>log.txt or cat<file.txt or ls -a -l>>log.txt or ls -a -l
        // ls -a -l>log.txt -> ls -a -l\0log.txt
        CheckDir(command_line);

        //3. "ls -a -l -i" -> "ls" "-a" "-l" "-i" 字符串切分
        command_args[0] = strtok(command_line, SEP);
        int index = 1;
        // 给ls命令添加颜色
        if(strcmp(command_args[0]/*程序名*/, "ls") == 0 ) 
            command_args[index++] = (char*)"--color=auto";
        // strtok 截取成功,返回字符串其实地址
        // 截取失败,返回NULL
        while(command_args[index++] = strtok(NULL, SEP));
    
        // 4. TODO, 编写后面的逻辑, 内建命令
        if(strcmp(command_args[0], "cd") == 0 && command_args[1] != NULL)
        {
            ChangeDir(command_args[1]); //让调用方进行路径切换, 父进程
            continue;
        }
        if(strcmp(command_args[0], "export") == 0 && command_args[1] != NULL)
        {
            // 目前,环境变量信息在command_line,会被清空
            // 此处我们需要自己保存一下环境变量内容
            strcpy(env_buffer, command_args[1]);
            PutEnvInMyShell(env_buffer); //export myval=100, BUG?
            continue;
        }

        // 5. 创建进程,执行
        pid_t id = fork();
        if(id == 0)
        {
            int fd = -1;
            switch(g_redir_flag)
            {
                case NONE_REDIR:
                    break;
                case INPUT_REDIR:
                    fd = open(g_redir_filename, O_RDONLY);
                    dup2(fd, 0);
                    break;
                case OUTPUT_REDIR:
                    fd = open(g_redir_filename, O_WRONLY | O_CREAT | O_TRUNC);
                    dup2(fd, 1);
                    break;
                case APPEND_REDIR:
                    fd = open(g_redir_filename, O_WRONLY | O_CREAT | O_APPEND);
                    dup2(fd, 1);
                    break;
                default:
                    printf("Bug?\n");
                    break;
            }
            //child
            // 6. 程序替换, 会影响曾经子进程打开的文件吗?不影响
            //exec*?
            execvp(command_args[0]/*不就是保存的是我们要执行的程序名字吗?*/, command_args);
            exit(1); //执行到这里,子进程一定替换失败
        }
        int status = 0;
        pid_t ret = waitpid(id, &status, 0);
        if(ret > 0)
        {
            printf("等待子进程成功: sig: %d, code: %d\n", status&0x7F, (status>>8)&0xFF);
        }
    }// end while
}

makefile代码:

myshell:myshell.c
	gcc -o $@ $^ -std=c99
.PHONY:clean
clean:
	rm -f myshell

注:

1.我们使用CheckDir函数识别重定向的类型,将重定向符号使用\0替代进行拆分,全局变量g_redir_flag标识重定向类型,全局变量g_redir_filename标识文件名。

2.我们使用DROP_SPACE宏跳过重定向符号后面的空格,使得全局变量g_redir_filename准确的指向文件名。

3.在第五步创建子进程执行命令的部分,我们根据不同的重定向的类型,执行不同选项的open打开文件,借助dup2系统接口实现不同类型的重定向功能。

4.我们先在子进程中打开文件,然后子进程进行了程序替换,程序替换只会影响该进程的代码和数据,不会影响子进程打开的文件。

3.5.重定向与标准输出、标准错误

创建一个myfile.cc文件写入下图一所示的代码,使用命令g++ myfile.cc编译myfile.cc文件,然后使用./a.out执行该程序,如下图二所示。

cin是往标准输入打印,cout是往标准输出打印,cerr是往标准错误打印。

c语言中printf函数和参数为stdout的fprintf、fputs函数是往标准输出打印,perror函数和参数为stderr的fprintf、fputs函数是往标准错误打印。

注:

1..cpp、.cc.、cxx都是c++源文件的后缀。

2.使用perror函数往标准错误输出打印,从下图二可以看到多打印了success。c语言有一个全局变量errno,该变量会记录最近一次c库函数调用失败的原因,perror函数将内容往标准错误输出打印的同时也会将errno对应的错误描述打印出来,其实现原理与下图三类似,运行结果如下图四所示,这里open系统调用接口不是c库函数,但是其如果出错errno也会被设置。

使用命令./a.out > stdout.txt,打印内容只有标准错误的部分,使用命令cat stdout.txt,可以看到标准输出的部分都在stdout.txt中,如下图所示。

标准输出stdout、标准错误stderr的文件描述符fd分别为1、2,也就是说struct file* fd_arry[]中下标1和2的内容都指向显示器的file,最终虽然标准输出和标准错误都打印到了显示器上,但是通过不同的文件描述符打印的,我们使用命令./a.out > stdout.txt重定向只是对fd为1的标准输出重定向,fd为2的标准错误没有重定向,所以最终往标准错误打印的内容仍然打印到显示器上,往标准输入打印的内容重定向打印到了stdout.txt文件中。

使用命令./a.out > stdout.txt 2> stderr.txt,屏幕上没有打印内容,使用命令cat stdout.txt,可以看到标准输出的部分都在stdout.txt中,使用命令cat stderr.txt,可以看到标准错误的部分都在stderr.txt中,如下图所示。

注:./a.out > stdout.txt是对标准输出进行重定向,其实其标准写法应该为./a.out 1> stdout.txt

使用命令./a.out > all.txt 2>&1,其功能是将标准输出fd为1和标准错误fd为2都重定向到all.txt文件中,如下图所示。

命令./a.out > all.txt 2>&1中,./a.out > all.txt部分是对标准输出fd为1进行重定向,fd为1的内容指向all.txt,2>&1部分表示将fd为1里面的内容拷贝到fd为2的里面,这样fd为2的内容也指向all.txt。

问题:将标准输出和标准错误用不同的文件描述符fd标识,其意义在哪里呢?

答:将标准输出和标准错误用不同的文件描述符fd标识,可以区分哪些是程序日常输出,哪些是错误。


4.文件系统

前面文件描述符部分讲的内容都是在内存中,并不是所有的文件都被打开而载入内存中,没有被打开的文件才是大多数,这些文件在磁盘中静静躺着,其特点是多、杂、乱。磁盘中的文件也需要管理,做磁盘级别文件管理的系统称为文件系统。

4.1.磁盘的物理结构

磁盘是我们电脑上唯一的一个存储式的机械设备,目前我们笔记本上可能已经不再使用磁盘了,而是使用固态硬盘SSD,但是相对固态硬盘而言,磁盘的价格便宜且耐用,公司的服务器基本上都是磁盘式的服务器。

磁盘定义:磁盘(英语:Hard Disk Drive,简称HDD)是电脑上使用坚硬的旋转盘片为基础的非挥发性存储设备,它在平整的磁性表面存储和检索数字数据,信息通过离磁性表面很近的磁头,由电磁流来改变极性方式被电磁流写到磁盘上,信息可以通过相反的方式读取。磁盘包括一至数片高速转动的磁盘以及放在执行器悬臂上的磁头。

磁盘的外部结构:

从外部看,磁盘的结构主要包括金属固定面板、控制电动板和接口三部分。

\bullet 金属固定面板 :磁盘外部总会有一个金属的面板,用于保护整个硬盘,一般其正面贴有产品标签,标注产品的型号、产地、参数等,这些信息是正确使用磁盘的基本依据。金属面板和底板结合成一个密封的整体,保证磁盘稳定运行。 

\bullet 控制电路板 :在磁盘的金属盖上会固定有一个电路板,这个电路板就是磁盘的驱动电路板。为节省空间,一般采用贴片式元件焊接,这些电子元器件组成了功能不同的电子电路,这些电路包括主轴调速电路、磁头驱动、伺服电机定位、读写电路、控制和接口电路等。在电路板上有几个主要的芯片:主控芯片、BIOS芯片、缓存芯片、电机驱动芯片。

\bullet 接口 :在硬盘的顶端会有几个不同的硬盘接口,这些接口主要包括电源插座接口、数据接口、主从跳线接口。其中电源插口和主机电源相连,为硬盘工作提供电力保证。数据接口则是硬盘数据和主板控制电路质检进行传输交换的纽带。中间的主、从盘跳线接口,用以设置主从硬盘,即设置硬盘驱动器的访问顺序。

磁盘的内部结构:

磁盘内部结构主要包括磁头组件、磁头驱动组件、盘片与主轴组件和前置控制电路。

\bullet 磁头组件 :磁头组件包括读写磁头、传动手臂、传动轴三部分组成。其中最主要的部分是磁头,另外两部分可以看作是磁头的辅助装置。传动轴带动传动手臂,使磁头到达制定位置。磁盘在工作时,磁头通过感应旋转的盘片上磁场的变化来读取数据,通过改变盘片上的磁场来写入数据。为避免磁头和盘片的磨损,在工作状态时,磁头悬浮在盘片上方,间隙仅有0.1 -0.3um。

\bullet 磁头驱动组件 :磁头的移动是靠驱动组件实现的,磁盘的寻址时间长短与磁头驱动组件关系密切。高精度的磁头驱动机构能够对磁头进行正确的驱动和定位,并能在很短的时间内精确定位系统指令指定的磁道,保证数据读写的可靠性。

\bullet 盘片与主轴组件 :盘片是硬盘存储数据的载体,将在下段重点表述。主轴组件包括主轴部件轴瓦和驱动电机等。随着硬盘容量的扩大和速度提高,主轴电机的速度也在不断提高。

\bullet 前置控制电路 :前置控制电路放大磁头感应的信号、主轴电机调速、磁头定位和伺服定位等,由于磁头读取的信号微弱,将放大电路密封在腔体内可减少外来信号的干扰,提高操作指令的准确性。

4.2.磁盘的存储结构

磁盘的盘面结构:

盘片是硬盘存储数据的载体。一块磁盘实际上并不是只有一块盘片,而是由多块盘片组成,每块盘片的正反两面皆可以存放数据。每个盘面上都配有一个读写磁头,而所有的读写磁头连在一根共享的磁臂上。当磁臂运动时,所有的磁头均做相同的运动。盘片则以常速不停地旋转。为方便存储数据,人们将每块盘面分为磁道和扇面。

\bullet 磁道(Tracker) :磁道是一个个的同心圆。当磁盘旋转时,磁头若保存在一个位置上,则每个磁头都会在磁盘表面划出一个圆形轨迹,这些圆形轨迹就叫做磁道。通常盘片的一面有成千上万个磁道,磁盘上面的信息是沿轨道存放的,但相邻的磁道间保持一定距离,避免磁化单元相近时磁性相互影响。

\bullet 扇区(Sector) :每个盘片的每一面都会划分很多同心圆的磁道,而且还会将每个同心圆进一步分割为多个相等的圆弧,这些圆弧就是扇区(也称扇面),扇区是磁道的一段。数据则以扇区进行存储,扇区也是磁盘I/O操作的最小单位。磁盘上存储的基本单位是扇区,一个扇区一般是512字节。扇区通常包含标题、数据、ECC纠错信息,其中标题包含同步和位置信息,ECC功用是对数据段提供错误侦测和纠正。

\bullet 柱面(Cylinder) :硬盘通常由多个盘片构成,而且每个面都被划分为数目相等的磁道,并从外边缘开始编号(即最边缘的磁道为0磁道,往里面依次累加)。如此磁道中具有相同编号的磁道会形成一个圆柱,此圆柱称为柱面。磁盘的柱面数与一个盘面上的磁道数是相等的。

磁盘的读写方式:

读写磁盘的时候,磁头找的是某一个盘片的某一个盘面的某一个磁道的某一个扇区。磁头决定了读取磁盘的盘面和磁道,盘面的旋转决定了读取磁道的哪一个扇区。

只要我们能找到磁盘上某一个盘面的某一个磁道的某一个扇区,就能找到一个存储单元,用同样的方法,我们可以找到所有的基本单元。

注:通过磁头(Head)、柱面(Cylinder)、扇区(Sector)在物理上查找某个数据的地址叫做CHS地址。

4.3.磁盘的逻辑抽象结构

我们把盘片想象成为一个线性的结构,如下图所示,可以将这个线性结构看成一个sector disk[N]数组,数组每一个元素代表一个扇区sector,其大小为512字节。这样如果要定位一个扇区sector,只要找到对应的下标即可,通过这种方式对磁盘的管理就转化成为了对数组空间的管理。

sector disk[N]数组中扇区的下标一般称为LBA逻辑块地址,是操作系统对磁盘寻址时所认识的地址。如果内存中有一批数据要往磁盘中写入,在内存中只有LBA地址,LBA地址首先映射转化为CHS地址,然后将要写入的数据写入磁盘对应的CHS地址处。

问题:LBA地址如何映射转化为CHS地址?

答:假设sector disk[N]数组的下标为0-3999,即LBA地址的范围为0-3999;磁盘的盘面有4面,每一面有1000个扇区,四面对应4000个扇区,与LBA地址的0-3999对应;磁盘的每一个盘面有20个磁道;每一个磁道有50个扇区。

如果LBA地址为3234,3234/1000=3,应该存储在磁盘盘面的第三面,即H=3;3234%1000=234,234/50=4,应该存储在第4磁道上,即C=4,234%50=34,应该存储在第34扇区,即S=34。

磁盘每一个扇区对应512字节,对于操作系统来说512字节有点小,操作系统以八个扇区为一个block单位,作为操作系统IO的基本单位,因此操作系统IO的基本单位是4KB。

问题:磁盘的基本单位是扇区,常规是512字节,文件系统访问磁盘的基本单位是4KB,为什么要这么设计呢?

原因一:IO读写是机械式的操作效率较低,因此IO读写越少越好,一个扇区512字节较小,操作系统一次IO访问磁盘,能够读写到8个扇区4KB数据,这样有效减少了IO次数,提高IO读写效率。

原因二:对于软硬件来说,不要让软件设计和硬件具有强相关性,这里不能让操作系统和磁盘具有强相关性,需要进行解耦合。磁盘一个扇区正常为512字节,随着磁盘的发展可能存在不正常的情况,如果操作系统一次读取的是一个扇区的数据,那么如果扇区不再是512字节操作系统的代码得跟着修改,这样不利于软硬件发展。软硬件之间要解耦合,操作系统不管硬件一个扇区多少字节,反正自己一次读取只读取4KB数据,这样即使磁盘一个扇区不是512字节也不会影响操作系统的读取。

思想:如果磁盘有500GB空间,这500GB空间直接让操作系统管理对于操作系统来说有些困难,操作系统将这500GB空间拆分成5个100GB的中等区域(这里只是举例,不一定非要拆分成相同大小的区域),分别将每一个100GB空间中等区域管理好就相当于管理好了这500GB空间的大区域。100GB的中等区域对于操作系统来说还是较难管理,那么就继续拆分下去,直到拆分成操作系统能够管理的小区域,那么操作系统将每一个小区域管理好,就相当于管理好了这500GB空间的大区域。

这样将一个大空间拆分成一个个小空间的过程叫做分区的过程,而这种分区的思想我们可以理解为分而治之思想。最后拆分成操作系统能够管理的小区域,这一个小区域我们称为一个磁盘块组,操作系统将一个个磁盘组管理好就能够管理好整个500GB空间。

实际:文件=内容+属性,内容和属性都是数据都要存储,Linux采用的是将内容和属性数据分开存储的方案。文件的内容存储在很多个大小为4KB的block叠加的空间中,文件的属性存储在inode中,其中inode就是磁盘上的另一份空间,其大小为128字节。文件的内容部分随着内容的增多其占用空间会跟着不断增多,文件的属性部分稳定在128字节,即一个inode大小的空间。
一个磁盘可能被划分为几个分区(windows下的C盘D盘等),磁盘的分区又被划分为数个磁盘块组B lock group。
如下图所示,一个分区的开始是Boot Block,其余的部分被分成了数个磁盘块组B lock group。磁盘块组B lock group再细分可以分为Super Block、Group Descriptor Table、Block Bitmap、inode Bitmap、inode Table、Data blocks。

\bullet Boot Block:Boot Block里面存储着计算机的开机信息,包括操作系统软件的位置。硬件层面计算机启动时会读取Boot Block,进而找到并加载操作系统。

\bullet Data blocks:Data blocks以block块为单位,由一个个4KB的block组成,保存一个个文件的内容。每一个block都有自己的编号。

\bullet inode Table:inode Table以inode为单位,由一个个128字节的inode组成,保存一个个文件的属性。

\bullet Block Bitmap:Block Bitmap是一个位图,用位图中的比特位内容来表示对应block块是否被占用,也就是说Block Bitmap记录着Data Block中哪个数据块已经被占用,哪个数据块没有被占用。

\bullet inode Bitmap:inode Bitmap是一个位图,用位图中的比特位内容来表示对应inode是否被占用,也就是说inode Bitmap记录着inode Table中哪个inode已经被占用,哪个inode没有被占用。

\bullet Group Descriptor Table(GDT):块组描述符,描述Block group块组属性信息,例如块组中inode个数,起始的inode编号,被使用的inode个数等。

\bullet Super Block:分区描述符,描述本分区属性信息,例如整个分区的Block group块组个数,每一个块组的inode Table使用情况,每一个块组的Data blocks使用情况等。 

注:

1.inode里面保存文件的属性,其中有一个属性为inode编号,inode编号是在分区的层面分配的,一个分区对应一个区间内的inode编号,在一个分区内inode编号具有唯一性,一般而言一个文件对应一个inode编号。如下图所示,使用ll -i命令可以看到文件对应的inode编号。

2.Super Block是分区的描述符,其本身应该与Boot Block平级,但是Super Block却在Block group块组的开头,其主要原因是为了做备份。Super Block如果坏了那么整个分区都会有问题,因此要对Super Block做多个备份。其实并不是每一个块组开头都有Super Block,Super Block只在一部分组块的开头有,这些Super Block内容完全相同。

问题1:inode中存储文件的属性,一个inode如何与自己对应的文件内容关联起来呢?

答:可以将inode理解为一个结构体struct inode,结构体中有一个blocks[15]数组,数组0到11下标对应的内容中直接保存该文件或inode对应的block编号。blocks[15]数组的0到11下标只能保存12个block编号,那么一个文件的内容最多只能是12×4KB=48KB吗?答案肯定是否定的,block中也可以保存其他block块的编号,blocks[15]数组中12到15下标用来指向block块,指向的block块不保存有效数据,而是保存该文件所使用的其他块的编号,这相当于用二级索引的方式扩大了文件存储空间,如果这样空间还是不够可以进行三级索引以此类推。

补充内容:对于一个文件来说,文件名也算文件的属性,但是在inode里面并不保存文件名,Linux下底层实际都是通过inode编号标识文件的。

要找到一个文件,必须要找到文件的inode编号,知道了inode编号就知道了该文件在分区内的哪一个块组Block group,在块组内根据inode编号可以找到对应的inode,找到了inode就找到了文件的全部属性,inode内部blocks[15]数组映射的全部block里面的存储着文件的内容。因此知道了inode编号就能得到对应文件的属性和内容,那么如何找到文件对应的inode编号呢?

对于一个目录来说,目录也是文件,因此目录也有自己的内容和属性,目录有属性部分因此也有自己的inode存储其属性,目录有内容部分因此也有很多block存储其内容,目录的内容部分存储的就是文件名与其inode编号之间的映射关系。一个文件名与一个inode编号具有一一对应关系,因此Linux同一目录下不可以创建多个同名文件。

问题2:当我们创建一个文件,操作系统做了什么?

答:操作系统首先查找要存储分区的块组Block group的inode Bitmap,选择一个没有被使用的inode,将其对应比特位由0置1,然后将创建文件的属性值写入对应的inode中。刚创建的文件是空文件,Block Bitmap中对应比特位可以全部置为0,当写入内容时,分配block块往里面写入内容,与inode Table中对应inode的blocks[15]数组建立映射关系,并且对应Block Bitmap比特位由0置1。此时操作系统具有创建文件的文件名和对应的inode编号。

操作系统找到创建文件所处的目录,根据目录的inode找到目录的block块,将创建的文件名和inode编号的映射关系写入到目录的数据块中。

问题3:当我们删除一个文件,操作系统做了什么?

答:操作系统找到文件所处目录,根据目录的inode找到目录的block块,在目录的block中找到文件名和inode编号的映射关系,有了inode编号,找到文件inode对应inode Bitmap中的那个比特位由1置0,找到文件全部block对应Block Bitmap中的那些比特位由1置0,最后在文件所处目录的block中将文件名和inode编号的映射关系删除。

从这里可以看出,在Linux下删除文件时,其实并没有真正的清除数据。因此只要内容没有被覆盖,被删除的文件是可以恢复的,要恢复被删除文件只需要找到被删除文件的inode编号,根据inode编号进而找到被删除文件在inode Bitmap中对应的比特位,将该比特位由0置1,然后根据inode的blocks[15]数组中的映射关系找到所有block对应Block Bitmap中的比特位,将这些比特位由0置1。

4.4.软硬链接

在学习软硬链接之前我们先了解如何创建软硬连接。

建立硬链接:

使用命令ln my.txt my.txt.hard建立my.txt的一个硬链接my.txt.hard,如下图所示。

建立软链接:

使用命令ln -s my.txt my.txt.soft建立my.txt的一个软链接my.txt.soft,如下图所示。

软硬链接的区别:软链接是一个独立文件,有自己独立的inode和inode编号,硬链接不是一个独立文件,它和目标文件使用的是同一个inode和inode编号。如下图所示,软链接与目标文件inode编号不同,硬链接与目标文件inode编号相同。

软链接:

软链接相当于Linux下的快捷方式。

如下图一所示,在test2023_3_7目录下创建一个比较深的路径,在该路径下创建mytest.c文件写入下图二所示的代码,然后编译mytest.c文件形成mytest.exe可执行程序。

如下图三所示,如果在test2023_3_7目录下要执行mytest.exe可执行程序,需要使用命令./d1/d2/d3/mytest.exe,这样需要带很长的路经很麻烦,我们使用ln -s ./d1/d2/d3/mytest.exe my.exe命令对目标文件mytest.exe在test2023_3_7目录下建立了一个软链接my.exe,这样在test2023_3_7目录下使用./my.exe命令运行mytest.exe的软链接my.exe,就相当于运行了mytest.exe。

硬链接:

硬链接就是单纯的在Linux指定的目录下,给指定的文件新增一份文件名和inode编号的映射关系。

如下图一所示,使用命令ln my.txt my.txt.hard建立my.txt的一个硬链接my.txt.hard。ll命令显示目录下文件的属性,可以看到硬链接前my.txt硬链接数为1,硬链接后my.txt硬链接数为2,my.txt.hard硬链接数为2。

硬链接数本质就是该文件inode属性中的一个计数器count,标识有几个文件名和该文件inode建立了映射关系。inode编号类似指针的概念,换句话说硬链接数就是表征有几个文件指向该文件的inode。

问题1:硬链接是一个映射关系,软链接是一个文件,那么软链接的文件内容是什么呢?

答:软链接的文件内容保存的是指向文件的所在路径。

问题2:如下图所示,为什么创建普通文件硬链接数是1,为什么创建目录硬链接数是2?

答:普通文件的文件名本身就和自己的inode具有映射关系,且只有一个,因此创建普通文件硬链接数是1。创建一个目录,该目录的目录名和其inode具有映射关系,在任何目录中都有.目录,.目录表示当前目录,创建目录下的.目录也会与创建的目录inode建立映射关系。如下图所示,新创建的目录inode编号与.目录的inode编号相同。所以./可执行程序可以执行当前目录下的程序。

补充:

1.该目录下的..目录与该目录的父目录inode建立映射关系,所以cd ..可以进入父目录中。

2.如果创建一个目录mkdir,在mkdir中再创建一个目录dir1,那么mkdir目录的硬链接数为3,与mkdir目录inode建立映射关系的有mkdir目录、mkdir目录下的.目录、dir1目录下的..目录。

删除软硬链接:

使用命令unlink my.txt.hard删除硬链接文件my.txt.hard。

使用命令unlink my.txt.soft删除软链接文件my.txt.soft。

注:unlink也可以用来删除普通文件,如下图所示。unlink命令与rm命令区别不大,只不过一般使用unlink命令删除软硬链接。

  • 1
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 1
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

随风张幔

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值