深刻理解文件系统(linux和EXT*为例)

++这篇文章主要是从外(外设)到里(OS)地讲述文件系统是如何工作的。外设我们以磁盘为例。++

关于磁盘

磁盘的物理结构

如上图,磁盘是多层结构(有多个盘片用于存储),每个盘片的正反两个盘面都是可以存储数据的,且每个盘面都对应一个磁头(用于数据读写)。需要注意的是,这些磁头并不是独立移动的,所有的磁头在传动臂的驱动下一起移动,是一个整体。

单个盘面可以分为多圈磁道,每个磁道又可以分成多个多个扇区,而扇区是磁盘读写的最小单位。所有盘面的相同半径的磁道可以组成一个面,叫柱面。

注: 磁盘的容量 = 柱面(磁道)数 * 每个磁道的扇区数 * 每个扇区的字节数 *盘面(磁头)数


磁盘寻址方式

基本的磁盘寻址是CHS寻址法:先确定哪一个柱面(cylinder),再确定哪一个磁头(head),再确定哪一个扇区(sector),这样就可以确定一个具体的扇区进行读写。而OS需要给出C、H、S的值。

然而,如果OS内使用CHS方式寻址磁盘,那么兼容性是很不好的,因为我们知道,外部存储设备不止是磁盘,还有硬盘,未来还会有新的存储设备,而这些存储设备不见得都有柱面、磁头、扇区,那么就要不断修改OS的代码。

因此,磁盘的寻址方式其实是LBA寻址法:

  • 将每一个盘面上的每个磁道看做一维数组,其中每一个元素代表本磁道上的一个扇区。
  • 一个柱面上有多个磁道,就可以将整个柱面看做一个二维数组。而这个二维数组也可以展开成一个大的一维数组,因此整个柱面的扇区可以看做一个大的一维数组。
  • 整个磁盘由多个柱面组成,那么就可以将磁盘看做多张二维数组表,也就是三维数组。同理,也可以转换成一维数组。

自此,整个磁盘就被抽象为了一个一维数组,而OS就把磁盘当做线性数组访问,OS只需要给出想访问的下标,磁盘自动把下标转换成CHS地址并访问相应位置,如果换了别的种类的外部存储设备,磁盘依旧使用下标访问,只要让这个存储设备有相应的转换功能即可。这方便了OS的使用以及管理。

LBA和CHS如何互相转换:


OS访问磁盘的基本单位

如上所述,OS像访问一维数组一样访问整个磁盘。不过,一个扇区大小一般是512B,对于OS来说,以扇区为单位和磁盘进行IO交互有些太少了(我们都知道,IO交互是比较耗费时间的),因此OS一般把磁盘抽象出来的一维数组每8个扇区划分成一个"块",这个块的大小是8*512B,即4KB,每次IO操作都是以块为单位

如下图所示,OS以块为基本单位进行IO操作,并把块号转换成若干个扇区号交给磁盘,由磁盘进行扇区号和CHS地址的转换,总共两次转换


++至此,我们知道了OS如何与磁盘等外设进行交互。然而,磁盘只是存储介质,它本身没有管理自己空间的功能,更没有管理存储在其中的文件的功能。只有安装了文件系统才能实现这种功能++


文件系统

注:记住了,从此刻开始,磁盘就是一维数组(经过抽象);它的基本存储单位是"块",而不是扇区。抽象与实际之间的转换是由OS和磁盘做的,我们不再关心。

**首先明白,尽管管理文件和磁盘确实是OS的任务,但是每次重启后内存中的数据都不会保存(不可能说,一旦重启,OS就把之前的文件存在哪儿都忘记了),因此文件和磁盘的管理信息是要保存到磁盘上相对固定的位置(因为OS一开始不知道读哪儿个位置),每次计算机开机,OS都会从磁盘中读出被保存下来的管理信息。**也就是说磁盘是是具有一定结构的(一部分用来存放管理信息,一部分存放数据)。

文件系统在磁盘上的体现

磁盘被分成几个分区,每个分区都是完全独立,互不影响的,他们甚至可以安装不同的文件系统进行管理。每个分区内部又被按组为单位划分,一个分组****内包含若干个块,每个分组存放有管理这个分组内所有块的管理结构。类似于管理一个国家,不同的分区代表不同的国家;不同的组代表一个国家中不同的省,方便管理嘛。

下面是磁盘的管理结构:

SuperBlock(超级块):

  • 数量:每个分区都有超级块,存放在每个分区内固定的某一个或某几个分组的开头(SuperBlock要存储多份的原因是可以在文件系统受损后利用拷贝来恢复文件系统而不至于使系统直接崩溃)。
  • 内容:里面存储的是该分区使用的文件系统的信息,以及整个分区的使用情况,分组情况等。
  • 功能:存放文件系统。

BootBlock(引导块):

  • 数量:每个磁盘只有一个,一般存放在磁盘的前几个扇区(也就是第一个分区的最前面的位置
  • 内容:它里面存放的是引导程序(不讨论)以及分区表(整个磁盘分为哪几个分区,每个分区的起止位置是什么云云)等开机信息
  • 功能:大体来说,在第一次使用磁盘的时候,他会引导你分区,安装文件系统等初始化操作。之后,他就是记录分区等信息,帮助OS初始化管理磁盘的数据结构。

GDT(Group Descriptor Table):

  • 数量:每个组都有,存放在每个组内相对固定的位置。
  • 内容:里面存储的是这个组的整体管理信息,比方说该组多少块被使用,还剩下的容量,以及该分组的大小等信息。
  • 功能:存放一个分组的管理信息。

BlockBitmap:

  • 数量:每个组都有,存放在每个组内相对固定的位置。
  • 内容:里面存储的是一个位图,每一位代表组中的一个存储块在DateBlock中的相对位置(例如第0个bit代表的是DateBlock中的第一个存储块)。
  • 功能:表示本组中的块的使用情况(使用了就把相应位置置1,反之置0)。

InodeBitmap:

  • 数量:每个组都有,存放在每个组内相对固定的位置。
  • 内容:里面存储的是一个位图,每一位代表组中的一个属性块在InodeTable中的相对位置(例如第0个bit代表的是InodeTable中的第一个属性块,即struct inode)。
  • 功能:表示本组中的InodeTable中struct inode的使用情况(使用了就把相应位置置1,反之置0)。

InodeTable:

  • 数量:每个组都有,存放在每个组内相对固定的位置。
  • 内容:里面存储的是该组所有文件的struct inode(也就是每个文件的属性部分,在linux中文件内容和属性是分开存储的)****。
  • 功能:存放组中文件的属性。

DateBlock:

  • 数量:每个组都有,存放在每个组内相对固定的位置。
  • 内容:这是一个分组中占比最大的部分,这部分包含的是用来存储文件内容的存储块。
  • 功能:存储数据。

属性块(struct inode)的内部结构:

注:

  • 一旦文件系统被安装,整个分区的所有结构就固定了,包括每个分组的大小,分组内的结构划分,分组的存储块以及属性块个数,等等。总之,一切都变成了固定的。
  • inode号(i节点编号)用来唯一的标识一个文件。块号用来唯一标识一个存储块(这不是使用的寻址中的块号,而是文件系统自己指定的块号,以此解耦)。

上面只是描述结构,下面是进一步解释:

我们试着模拟一下创建一个文件的过程:

  1. 首先确定自己在那个分区,然后查找该分区中的inode属性块是否用完。如果用完了,那就创建失败,否则,进入下一步。
  2. 遍历分区中所有组的GDT,查看哪个组有足够的存储块和属性块。
  3. 利用本组中的InodeBitmap中找一个bit位为0的位置,记作offset_1(也就是找到一个空闲的struct inode),并把该bit位置1(表示已经占用)。
  4. 在本组中的InodeBlock中找到第offset_1个struct inode,并把想要创建的文件的属性写到这个属性块里面(包括文件的inode号)。
  5. 利用本组中的BlockBitmap中找一个****bit位为0的位置,记作offset_2(也就是找到一个空闲的存储块),并把该bit位置1(表示已经占用)。
  6. 在本组的DateBlock中找到第offset_2个存储块,写入文件数据。
  7. 将使用的存储块号也写入之前找到的struct inode里,建立文件属性和内容之间的映射
  8. 更新管理结构(GDT,SuperBlock等),返回文件的inode号。

思考一个问题:新文件的inode号和块号应该分别设置成offset_1和offset_2吗?

  • 当然不是,offset_1和offset_2说白了是偏移量,同一个分区有多个分组,每个分组都有offset_1和offset_2,这样就会导致一个分区内文件标识符(inode号)以及块号冲突,linux给出的解决方案是:为每个分组设置不同的起始inode号和块号(保存在GDT中),inode号 = 本组起始inode号+offset_1,块号 = 本组起始块号+offset_2。比如:****

再来模拟一下读取一个文件的过程:

  1. 首先确定自己所在的分区。并拿到文件的inode号
  2. 可以通过查看每个分组的起始inode号找到该inode号所属的组。
  3. 利用inode减去起始inode号得到offset_1。
  4. 在InodeTable中找到第offset_1个struct inode,即可获取文件的属性交给调用者,如果想获取文件内容,则读取struct inode中的存储块号。
  5. 存储块号减去起始块号得到offset_2。
  6. 读取DataBlock中第offset_2个存储块的内容并返回给调用者。

思考一个问题:一个文件最大的大小是一个分组中所有存储块大小的和吗?

  • 远远不是,由于起始号块号,使得整个分区中的所有存储块的块号都是不同的,这也就是说,如果一个分组中的文件想使用另一个分组的存储块非常简单,只需要读取另一个分组中的BlockBitmap,找到空闲块并给相应bit位置1,然后把块号写入到文件的struct inode中维护映射即可。理论上说,一个文件最大可以达到整个分区那么大

最后模拟一下删除一个文件的过程:

  1. 首先确定自己所在的分区。并拿到文件的inode号
  2. 利用inode减去起始inode号得到offset_1。
  3. 在InodeTable中找到第offset_1个struct inode,读取struct inode中的存储块号。
  4. 将所有存储块号都减去分组起始块号,得到offset_2.1、offset_2.2、offset_2.3... ...
  5. 将本分组****BlockBitmap中第offset_2.1、offset_2.2、offset_2.3... ...位置的bit位置0。
  6. 将本分组InodeBitmap中第offset_1位置的bit位置0
  7. 更新管理结构(GDT,SuperBlock等)。

思考一个问题:删除是将存储空间都置0吗?

  • 不,本质上删除就是把Bitmap的相应位置置0罢了,至于存储块以及属性块的数据是不会动的,因此才有数据恢复这一说。

正如我们前文所说,计算机启动时要把这些管理信息读入到内存中,以便于对文件和外存的管理。实际上,在OS中,这些管理信息被描述为内核数据结构,OS通过对他们操作来管理文件和外存。例如:


++至此,我们明白了,文件系统如何利用管理结构来工作,但是我们还应该将文件系统和我们用户的文件操作关联起来。++


文件系统在OS中的体现

到目前为止,我们仍然无法把文件系统和我们平时使用的文件挂起钩来。

一个明显的疑问就是,我们日常都是通过文件名来操作文件,而不是上面所用的inode号:

  • 在linux中,文件可以分为目录和普通文件,而目录文件存放的数据存放的就是文件名和inode号的映射关系,所以,当我们想要打开一个文件的时候,先要读取当前所在目录的数据,然后查找文件名所对应的inode号,进而通过inode号访问文件。
    可以想到,在我们创建文件的时候也会给其所在目录文件中写入新文件名:inode号映射。

那么问题又来了,目录也是文件,要打开目录也需要inode号,它的inode号谁提供:

  • 由上级目录提供,因为目录的文件名和inode号的映射关系存放在目录的目录里。具体来说,如果我们正在尝试打开一个/a/b/c/d文件,那么首先需要打开c目录,而打开c目录需要首先打开b目录....在这种逆向路径解析下最终我们会发现,我们需要打开根目录,我们需要知道根目录的inode号,而linux中,根目录以及其与inode号的映射关系在开机的时候就被加载到内存中了,他是固定的由此,我们可以想到,为什么每个进程都要维护CWD(当前工作目录),因为只有知道了要访问文件的完整路径,才能进行逆向路径解析

每次访问文件都要进行逆向路径解析,打开多个目录,这样效率会不会太低呢?

  • 是的 ,因此linux内核中使用dentry树结构来缓存路径,dentry的根节点就存放着根目录以及其对应的inode(所以逆向路径解析到根目录就停止了,因为找到了)。具体来说,假如你访问了/a/b以及/c/d两个路径,dentry树会变成下图中的样子:
    每个struct dentry都关联一个struct inode结构体**,而struct inode结构体磁盘中文件的属性部分的内核结构,里面有inode号,文件权限,时间等属性。也就是说dentry缓存的实际上是文件名和inode结构体的关系。当访问一个文件的时候首先会查询dentry树,没有再进行逆向路径解析并缓存路径(注意:dentry树不是也缓存普通文件哦)。**

现在,我们明白了:在开机时,我们自动就在根目录下,在我们不断打开目录的时候,进程也帮我们维护着CWD。当我们通过文件名打开一个文件的时候,首先查询dentry树,如果找得到缓存就直接打开,找不到就利用CWD逆向解析路径(也就是从根目录层层打开目录)并在dentry树上缓存路径。


如何理解分区

在上面的讨论中,我们忽略了一个问题,那就是------分区。之前我们讲到了,磁盘是被分区使用的,并且各个分区是独立的。那么在OS中如何区分并独立使用这些分区呢?

由上述叙述不难知道,用户访问文件是基于dentry树的。因此,在Linux中,分区在dentry树上得以被区分,如下图:

当我们将分区2"挂载"到/mnt/dev2目录(这个不是死的,可以随便)下,那么dentry树的结构就变为上面的样子。一般情况下我们都是在分区1中操作,而一旦通过分区1的根目录进了/mnt/dev2/目录,那么就算是进入了分区2,自此以后所创建的所有文件都在分区2中也就是说,通过文件路径的前缀就能判断当前应该在哪个分区进行操作

注:我们访问其他分区,都是从第一个分区跳转过来的。


下面是一张结构图,可以大致参考一下:

相关推荐
埃伊蟹黄面2 天前
磁盘级文件系统核心原理解析
linux·文件
Wang's Blog2 天前
Nodejs-HardCore: 操作系统与命令行实用技巧详解
nodejs·os·cli
CAU界编程小白3 天前
Linux系统编程系列之文件fd
linux·文件
启扶农3 天前
lecen:一个更好的开源可视化系统搭建项目--模块、路由、字典、文件--全低代码|所见即所得|利用可视化设计器构建你的应用系统-做一个懂你的人
低代码·文件管理·路由管理·模块管理·页面可视化·页面设计器·字典管理
rayylee6 天前
从零开始安装Asterinas NixOS操作系统
rust·操作系统·os
杰瑞不懂代码6 天前
结合os模块和shutil模块实现本地文件自动化操作
android·java·自动化·办公自动化·shutil·os
2401_841495647 天前
【Python高级编程】Python中常见的运算符、函数与方法总结
字符串·集合·文件·列表·元组·字典·运算符
heartbeat..7 天前
Java NIO 详解(Channel+Buffer+Selector)
java·开发语言·文件·nio
heartbeat..7 天前
Java IO 流完整解析:原理、分类、使用规范与最佳实践
java·开发语言·io·文件