个人主页:Lei宝啊
愿所有美好如期而遇
前言
本篇文章将会介绍,磁盘中是如何存储一个文件,磁盘中的文件是如何从磁盘中加载进内存,与进程又有怎样的关系,我们写的代码变成可执行程序执行起来时如何完成对文件的一系列操作。
那么接下来我们将会是一块整体长篇大论去提及。
详谈
我们这里提到的磁盘是机械磁盘,他大概长这样:
我们可以看到几个盘片,这些盘片就是磁盘,用来保存数据,在微观上,他是一块块的小磁块,分为南北极,也对应着我们常说的比特位的0和1,对每一块盘片,我们又给他分了磁道和扇区,就像这样:
这样一个扇区能够存储数据的大小为512字节,当然,也可以更多,我们这里提及的是一般情况下。
那么磁盘是如何定位数据的呢?我们上面的图中,有一个磁头,通过磁头定位盘面,再确定磁道,最后是扇区(磁盘IO的基本单位是扇区)。
上述就是磁盘的物理层面,那么我们是如何管理这些数据的?这就要提到操作系统,由操作系统对他们进行管理,怎么管理呢?一个磁盘能够保存的数据并不小,几百GB到几个T都有。
于是有了逻辑层面上,将磁盘的物理存储抽象为逻辑存储,操作系统对磁盘的管理也就变成了对一个个结构体的管理,先描述,再组织,而这种管理方式就需要加载文件系统来进行管理。
我们说,磁盘很大,我们不好管理,于是就有了分区管理,将磁盘进行分区,每个分区的管理可以借由不同或相同的文件系统进行管理,而每个分区又有分组,借由这种方式来管理磁盘。
分组中的管理方式,不同的文件系统有不同,我们这里介绍ext2文件系统中分组的管理方式:
super Block,超级块,存储着文件系统的信息,这个块如果损坏,这个区的信息就损坏了,但是,并不是每个分组都会有这个超级块,由操作系统决定部分分组有这个超级块,并且他们的信息是同步的,一个损坏了,去其他分组再同步过来。
Group Descriptor Table(GDT),块组描述符,保存着这个组的一系列信息,块损坏,这个组信息损坏。
Block Bitmap,Data Blocks,Data Blocks就是操作系统分配的只存储文件内容的数据块,里面有一个个的基本数据块,对应着逻辑抽象的8块扇区,能够存储数据的大小为4KB。Bitmap的作用就是标记使用和未使用的数据块,在保存文件内容时直接遍历位图,查询bit位为0的位置,并记录下这个位置在位图中相对于起始位置的偏移量,在Data BLocks中直接根据这个偏移量索引找到空闲数据块。
inode Bitmap,inode Table,inode是一个结构体,保存着文件的各种信息,同时里面保存着一个inode编号,以及int block[15]。
我们可以根据这个block[15]数组来将文件的内容和属性对应起来,但是block看起来只能存储60KB的内容?文件不是可以很大吗?这个数组有15个下标,从0~14,其中0~11存储的LBA地址对应的数据块里,存放的就是文件内容,而下标为12,13的LBA地址对应的数据块里,存放的不是文件内容,他存放的仍然是LBA地址,而这个地址再去索引找到的数据块里,存放着的才是文件内容,我们也将这个过程叫做二级索引,但是即使这样,文件的内容依然不是很大,也就扩大了1024倍,也就是60KB*1024,那么最后一个下标14,他是三级索引,这样就再次扩大了文件大小,我们可以依据这样去存放更大的文件。
inode编号,在一个分区内唯一标识一个文件,关于inode编号,我们需要到后面串起来才能理解,这里暂时不做过多解释。
inode Bitmap,这个位图标识着哪个inode是空闲的,未存储文件信息。每一个分组中,都有一个inode起始编号,同时GDT中会保存着这个分组中可以保存多少文件信息,我们在创建一个文件时,操作系统会扫描一个分组的inode Bitmap,找到在位图中为0的位置,并记录下偏移量,那么这个文件的inode编号就是当前分组的起始inode编号加上这个偏移量。
在我们查询一个文件时,也是先由文件的inode编号确定分组,再由inode编号减去分组起始inode编号,剩下的值就是在位图中的偏移量,根据这个值我们可以在inode Block中找到文件的信息,并由int block[15]找到文件的内容。
其实这里有一个小点,就是我们怎么知道文件的inode编号?inode里虽然保存着inode编号,但是我要找的就是inode,我怎么知道inode编号?这个问题我们后面解释。
现在我们知道了文件在磁盘中的存储,现在,我们要打开一个文件了,怎么打开?写一个代码,编译链接成可执行,跑起来就打开了?表面上看起来的确是这样,现在我们深入来探究一下。
好,我们先来写一个代码:
我们以只读方式打开一个文件,并且读取他的数据,可是这些代码在整个流程中扮演了怎样的角色,底层到底是怎样完成这一切的?
头文件,里面保存着一系列库函数的声明,这个我们是知道的。库函数的实现自然就在库中保存着,在我们编译这个代码后,和库文件进行链接形成可执行程序。
接下来我们介绍库,库分为动态库和静态库,在Linux中,库默认是动态库,静态库需要我们自己下载,在Linux中,动态库的后缀是.so,静态库的后缀是.a。
库也是文件,是由一些.o目标文件打包形成的,那么为什么要打包? 我们假设,今天老师布置了一个任务,写一个程序,只要可执行程序和main.c就行,这在你看来非常轻松,你分分钟在一个项目工程中写了7个源文件,6个头文件,编译运行,通过了,非常轻松,但是你的同学他不会啊,就问你要源代码,但是呢,你辛苦写的,不想给他源文件,可是又不好意思不给,于是你转念一想,反正老师只要可执行和main.c,那好说,我把头文件和除main.o的其他.o文件发给他,他自己写一个main.c,编译链接一下就行了,于是你发给了他这些东西,于是你的同学拿到了这些头文件和.o文件,结合他自己的main.c,gcc main.c xxx.o xxx.o ...... -o xxx,写了一长串,可算弄完了。
但是如果是很大的一个工程项目,几百上千的文件,还这么编译,写都写烦了,于是,你又转念一想,那怎么办?那好,把.o文件打包成库吧,于是,这样你就自己搞了一个库,通过某种方式,你分别弄出了动静态库。
将来你的同学再次使用时,就很方便了。
我们写的代码也是如此,在下载好一个编译器时,他把一系列库文件也下载了下来,当我们写代码时,只需要包含头文件,头文件中函数的实现我们链接时找库要就行。
接下来到了FILE* fp = fopen...,fopen就是一个库函数,返回值是FILE*,我们直接看来,是这行代码他打开了文件,实际上不是的,而是可执行程序跑起来变成进程,进程执行到这行代码时,才打开了文件。
这里我们需要说到,事实上,fopen这种函数,是为了更好的给初学者使用,所以封装了这么一层,对于访问硬件这种事情,操作系统是不允许普通用户进行访问的,他会提供一个系统调用(也是函数,由C/C++编写,大部分是C),只有使用这个系统调用才能够进行访问,操作系统认为这才是安全的,因为这个函数是操作系统提供的,所以我们要想访问一个文件,就需要在fopen中封装访问硬件中文件的系统调用,而这个系统调用就是open()。
open这个系统调用,返回值是int类型的fd,叫做文件描述符,参数是文件路径和打开方式,以及一个模式:
文件路径似乎我们挺熟啊,是的,其实我们不熟,什么是文件路径? /dir/dir1/dir2,是这样吗?这么说是没错,但是我们这里要谈到的路径,在磁盘中是没有这个概念的,也没有目录这样的概念,只有文件,只有操作系统将他们的信息加载进内存中,才形成了目录以及路径这个概念,那么现在我们来解释inode编号的问题。
目录是不是文件,是的,那么目录也有内容,他的内容保存什么呢?文件的inode编号和文件名的映射关系。
我们假设有这样一个路径:/dir/test/haha.c,那么haha.c的inode编号和他的文件名就保存在test目内容中,可是test目录也是文件啊,所以test的inode编号和文件名保存在dir内容中,dir也是文件,dir的inode编号和文件名也保存在根目录下/,那么根目录呢?他的inode编号是确定的,在操作系统加载后,他就确定了,我们之前提到过分区,操作系统管理文件不是通过分区吗?怎么会根据根目录呢?是的,根目录是挂载到分区上的,分区的访问,都是通过挂载到一个目录下进行访问的!
于是我们就可以根据文件路径逆向递归般的可以找到文件inode编号。
可是,我怎么找到文件的路径呢?通过进程,进程的路径是确定的,在运行起来时,就已经确定了,所以在我们查找一个文件时,如果我们给出文件的绝对路径,那么我们就使用这个绝对路径,如果没有给出,那么就使用这个进程的路径+文件名去查找这个文件。
于是,到这里,我们就清楚了路径的问题。
flag又是什么鬼?他是宏,通过一系列宏来决定打开文件的方式,只写,只读,读写,清空,追加等等,mode就是创建的文件的权限,决定了拥有者,所属组,其他人对文件的读写执行权限。
于是,通过这个系统调用,我们就打开了文件,但是怎么打开的呢?这里就提到文件描述符,我们说open这个系统调用返回了文件描述符,文件描述符又是什么?
这里提到stdin(标准输入),stdout(标准输出),stderr(标准错误),三者都是FILE*类型,这三个文件在进程启动时会自动打开,并默认分配0,1,2这三个文件描述符,保存在他们指向的FILE中的fileno这个值中。
流程是这样的,open打开一个文件,这个文件被从磁盘加载进内存中,创建struct file结构体,里面保存着这个文件的信息和内容,接着这个文件的地址被填入一个数组中,填入位置的下标就是文件描述符,接着这个下标被返回给open,open被封装在FILE中,于是这个fd给到了FILE中的fileno,最后由fopen返回这个FILE的地址,最终我们就打开了这个文件。
fwrite,fread等等函数,与文件相关的函数,都需要这个fd,所以这也就是为什么我们打开文件后返回的fp要传递给他们,因为它里面封装了fd。
当我们向文件中写入内容的时候,也并不是直接就写进了struc file结构体里,而是写进了一个缓冲区,这个缓冲区就是FILE里的一个buffer[],然后由write系统调用将这个缓冲区里的内容写进struct file里的缓冲区,再由操作系统刷新到磁盘。
fclose就是通过fd清空fd_arrry对应位置的内容,这个文件也就被关闭了。
现在我们解释了代码后,代码就要开始编译链接成可执行程序了,那么可执行程序内部是什么样子呢?我们对他进行反汇编,可以看到这样样一张图:
我们可以看到一些汇编语言,以及一些地址,事实上,这些地址就是我们在调试程序时所看到的地址,都是已经提前编译好的,这些地址也被叫做虚拟地址,注意,不是物理地址。
当程序要执行时,操作系统会为他创建一个进程控制块task_struct,然后将程序的代码和数据加载进内存,这两者结合起来就叫做进程。
当我们的可执行程序的代码和数据加载进内存时也就有了物理地址,此时操作系统会将他的物理地址和虚拟地址在页表中建立映射关系,并使用表头初始化进程地址空间。
如果程序链接使用的是静态库,那么在可执行程序在链接时就已经将所需代码从库中拷贝进来,在执行时就已经不需要这个库了,如果使用的是动态库,那么在执行时仍然需要将动态库加载进内存,下面这张图就是链接静态库的程序。
那么现在,我们来整体理一理这个过程:
首先,我们写好一份代码,代码里使用了操作文件的函数(如果没写,那其实解释起来更简单点),于是我们开始编译链接文件(假设我们链接静态库,这个说起来省事),在形成可执行程序文件后,这个文件里就有了一系列信息,被分成了几个段,代码段,数据段,以及表头等等,同时里面的代码和数据都会有相应的地址,在磁盘中我们称逻辑地址,加载进内存后我们称虚拟地址,其实二者是等价的;接着要开始执行这个程序,创建进程控制块,加载程序代码和数据,进程形成,页表构建完成,进程地址空间以及CPU内的寄存器初始化完成,开始执行我们的代码, 由于main函数地址在初始化时就将地址交给了cpu中的程序计数器,所以操作系统他是可以找到主函数入口的,当执行到打开文件的指令时(如果是读文件),首先根据我们提供的路径(绝对路径就使用,不是就拼接当前进程路径+文件名)逆向递归找到这个文件的根目录,也就找到了这个文件所在的分区,因为分区是挂载在这个根目录下的,而根目录的inode编号是确定的,于是我们可以从磁盘中读到根目录的内容,里面存放着他下面一系列文件的inode编号和文件名的映射关系,这样一路解析下去,我们就找到了我们要打开的文件;(如果是写文件,并且文件不存在),那么操作系统就先创建文件,在一个分区的一个分组下去创建一个文件,并返回inode编号,将这个编号和我们输入的文件名建立映射关系填到父目录内容中,一个文件也就创建好了。找到要打开的文件后,将要打开的文件读进内存中,建立struct_file结构体,里面填充这个文件的信息和内容,然后将这个结构体的地址填入到进程控制块中的struct files_struct *file指向的struct files_struct中的fd_arrry中,并返回这个填入位置的下标,于是后面对于文件的操作也就可以完成了。当代码全部执行完成后,释放文件,释放程序的代码和数据,接着释放进程控制块,程序执行完成。