一、缓冲区
1.缓冲区的概念
我们以前一直在提缓冲区,但是缓冲区到底是什么,却并没有说过。其实缓冲区的本质,就是“一段内存”。既然缓冲区是一段内存,那么必定是有人申请的,是属于某个部分管理并且有自己的作用。
为了更好的理解缓冲区,我们来举一个例子:
假如你在四川,你有一个很好的朋友叫小王,他在上海的读书。你们平时关系非常的好,经常将自己的东西与对方分享。有一天你买了一个新的键盘,于是你就想以前旧的键盘送小王。刚好小王也想买一个键盘,于是此时你为了将东西送给他,就自己骑车从四川去往上海,亲手将东西送给了他。但是这这样反复了几次后,你觉得这样太费时间了,每次想送点东西给对方,都需要自己骑车几个月的送给小王。而此时你的室友看到了便告诉你,在我们宿舍里离宿舍不远出处有一个顺丰快递,你可以直接将快递交给顺丰,让他们帮你寄,只用一两天就可以到,省时省力。
你一听,觉得这方法很好啊,于是将自己送给小王的礼物拿到了顺丰,让他们帮你寄。第二天你想去看看自己的礼物到哪里时,你发现自己的快递还没有出发,于是你又跑到顺丰,去质问工作人员,为什么一天了还没有发货。工作人员告诉你,他们的快递都是需要积累到一定数量,比如有个几十件几百件的时候才会统一开始运送,不会为你了这一件快递就帮你寄,毕竟一件一件的送太费成本。你听了后觉得有道理,就离开了。
在这个例子中,四川就是内存,上海是磁盘,你是进程,小王是文件,你要寄的礼物是数据,而顺丰则是缓冲区。你向小王赠送礼物这个动作,就可以看做是内存中的进程要向磁盘中的文件传输数据

我们以前也说过,磁盘的传输效率是很低的,当然,这个效率低是相对于内存而言。在这种情况下,如果我们一有数据就向缓冲区,就很可能因为磁盘读写速度太慢导致进程堵塞,传输效率下降。为了解决这一问题,便有了缓冲区的存在。当内存需要向磁盘读写数据时,会先将进程中对应的数据拷贝到缓冲区中,当缓冲区中的数据满足一定条件后,再统一将缓冲区内的数据刷新到磁盘中。
此时大家就可能会有点疑惑,为什么是将进程中的数据拷贝到缓冲区中,我们明明一直是用write()、printf()、fputs()这些函数,并没有使用拷贝函数。这就涉及到一个理解上的问题。以fwrite()为例,与其将fwrite()函数理解为写数据到文件中的函数,不如将其理解为是将数据从进程中拷贝到缓冲区或外设中的一个拷贝函数。其他类似函数同理。因此,缓冲区存在的意义其实就是为了“节省进程进行数据IO的时间”。
2.缓冲区刷新策略
缓冲区刷新的策略,一般都会根据不同的设备定制不同的刷新策略。而在通常情况下,会有三种刷新策略。而在某些特殊情况下,又会有两种特殊的刷新策略
(1)立即刷新——无缓冲
例如我们向磁盘里面写了一块数据,在写完后立即就调用了fflush()函数进行缓冲区刷新,此时就是立即刷新缓冲区
(2)行刷新——行缓冲
在某些情况下,例如我们的显示器,在显示东西时,为了适应人一行阅读的阅读习惯,就会才采取行刷新的刷新策略,这样就既可以适应人的阅读习惯,又不至于刷新效率太低。
(3)缓冲区满——全缓冲
为了利用好缓冲区,我们还有一种全缓冲的刷新策略,即当缓冲区满了的时候,才会将数据刷新出来。例如我们打开我们的磁盘文件时,都是直接显示完或者一片一片的显示,这其实就是全缓冲刷新策略导致的。
(4)用户强制刷新
某些情况下,用户可能会想在缓冲区未满的情况下就将缓冲区内的数据刷新出来,如调用fflush(),此时就是用户自行进行强制刷新
(5)进程退出
在进程退出时,也会将缓冲区内的数据刷新出来,避免进程退出了但缓冲区还没满,导致缓冲区内的数据没刷新出来进而出现数据丢失的情况
3.缓冲区存在位置
现在我们知道了缓冲区的本质其实就是“一块内存”,并且在不同情况下有不同的刷新策略,但是却并不知道缓冲区到底在哪里。
由此,我们先来看以下代码:

在上面的程序中,我们分别以C和系统接口的方式,向stdout打印和写数据。我们运行该程序:

可以看到,运行正常。然后我们再将该程序的结果重定向到“log.txt”文件中并打开该文件:

此时得到的结果也是正常的。此时我们修改该程序,在程序结束之前创建一个“子进程”:

我们运行该程序:

此时该程序运行结果没有任何问题。但是,此时我们再将该程序的结果重定向到“log.txt”文件中:

可以看到,此时该文件中出现了7条打印结果,其中用C接口写的打印和输出函数打印了两次。但是用系统借口写的write()函数却并没有重复打印。
导致这一结果产生的原因,肯定是“缓冲区”的存在。
但是既然缓冲区存在,那为什么在上面的程序中,C接口的打印重复了两次,而系统接口的write却没有重复呢?通过这一现象我们其实可以得到一个结论:这里的缓冲区其实是由C提供的。也就是说,缓冲区其实是用户级语言层面给我们提供的。因为如果这一内存存在于内核中,那么write()也应该重复打印。
现在我们知道了缓冲区是由用户级语言提供的。但是它们到底存在于哪里我们还不清楚。以C为例,我们无论是进行printf()还是fprintf(),都会显式或隐式的涉及到stdin、stdout和stderr这三个标准流。在使用时,我们都会传入一个“FILE*”指针。而FILE我们之前也也说过,它其实是一个“FILE结构体”,其中一定包括了文件描述符fd,其实这里面还一定存在一个东西,即缓冲区。
因此,我们在使用fflush()或fclose()时,都需要传入一个文件指针,这其实就是为了使用缓冲区。
我们可以在linux中打开“libio.h”头文件查看:

在该头文件中我们可以查到到以下内容:

在这张图中,圈出来的部分其实就是我们的缓冲区,其中的每个参数都负责维护一定的缓冲区。上面的flags就是缓冲区的刷新策略。
有了上面的关于缓冲区的概念,我们就可以解释为什么上面的程序在进行重定向后,C接口的函数会出现两份

在该程序中,在代码结束前,就会进行一次创建子进程。因此,在我们没有进行重定向时,该程序会将数据传输到显示器上,此时stdout采用的刷新策略是“行缓冲”,也就是一行一行的刷新,当运行到创建子进程时,我们的进程内部的缓冲区内已经不存在对应的数据了,子进程中的缓冲区也就不存在对应的数据,因此只会有一份。
当我们进行重定向时,我们要写入数据的对象就不再是显示器,而是“文件”。此时stdout采用的刷新策略是“全缓冲”,而我们用C写的这四条数据并不能填满缓冲区。这也就导致C写的数据在进程即将结束时,数据还存在于缓冲区内,并没有被刷新。
由此我们来到创建子进程,而创建完子进程后,就是进程退出。我们上面说过,在进程退出时也需要刷新缓冲区。因此,当父进程和子进程需要退出时,就会发生“写时拷贝”,父子进程同时拥有了缓冲区内的数据,此时进程退出,就导致父子进程内的缓冲区数据都需要被刷新,因此就出现了两份相同的数据。
而用系统接口write()写的数据没有两份,是因为write()中并没有使用FILE,而是使用的fd,并没有缓冲区,创建的子进程没有write()函数的数据残留,因此只有一份数据。
4.模拟实现缓冲区(demo代码)
为了更好的理解缓冲区,我们在这里会借用系统调用接口自行模拟实现fopen()、fwrite()和fclose()三个函数。当然,这里的仅仅只是demo代码,与真正的系统接口函数有着较大的差距。
(1)头文件
#include <stdio.h>
#include <string.h>
#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>
#include <stdlib.h>
#include <errno.h>
#include <unistd.h>
#include <assert.h>
#define SIZE 1024//字符串类型
//三种缓冲区刷新方案
#define SYNC_NOW 1<<0
#define SYNC_LINE 1<<1
#define SYNC_FULL 1<<2
typedef struct FILE_{//创建一个FILE_结构体,用以存储文件属性,如刷新方式,文件描述符,数据存储地点
int flags;//缓冲区刷新方式
int fileno;//文件描述符
int size;//缓冲区中存在的数据量
int cup;//缓冲区中的最大数据容量
char buffer[SIZE];
}FILE_;
FILE_* fopen_(const char* path_name, const char* mode);//文件打开函数
void fwrite_(const char* ptr, int size, FILE_* fp);//数据写入函数
void fclose_(FILE_* fp);//文件关闭函数
void fflush_(FILE_* fp);//刷新缓冲区
(2)函数定义文件
#include "myStdio.h"
FILE_* fopen_(const char* path_name, const char* mode)//自行利用系统接口进行封装的文件打开函数
{
int flags = 0;
int defaultmode = 0666;
if(strcmp(mode, "r") == 0)//以只读方式打开
flags |= O_RDONLY;
else if(strcmp(mode, "w") == 0)//以只写方式打开
flags |= (O_WRONLY | O_CREAT | O_TRUNC);
else if(strcmp(mode, "a") == 0)//以追加方式打开
flags |= (O_WRONLY | O_CREAT | O_APPEND);
else
perror("is mistake");
int fd = 0;
if(flags & O_RDONLY)//当以只读方式打开时,无需带权限
fd = open(path_name, flags);
else//以只写或追加方式打开时,需要加权限
fd = open(path_name, flags, defaultmode);
if(fd < 0)//文件未打开时报错
{
const char* err = strerror(errno);
write(2, err, strlen(err));
return NULL;
}
FILE_* fp = (FILE_*)malloc(sizeof(FILE_));//申请堆空间建立缓冲区
assert(fp);
//初始化堆空间
fp->flags = SYNC_LINE;//刷新方式默认设置为行刷新
fp->fileno = fd;//获取文件描述符
fp->size = 0;
fp->cup = SIZE;
memset(fp->buffer, 0, SIZE);//将申请的堆空间内的数据全部设置成0
return fp;
}
void fwrite_(const char* ptr, int num, FILE_* fp)
{
memcpy(fp->buffer+fp->size, ptr, num);//将要写入的数据拷贝到缓冲区中
fp->size += num;
//根据不同的刷新策略做出不同的反应
if(fp->flags & SYNC_NOW)
fflush_(fp);
else if(fp->flags & SYNC_LINE)
{
if(fp->buffer[fp->size - 1] == '\n')
fflush_(fp);
}
else if(fp->flags & SYNC_FULL)
{
if(fp->size == fp->cup)
fflush_(fp);
}
else
perror("write fail");
}
void fflush_(FILE_* fp)
{
if(fp->size > 0)
write(fp->fileno, fp->buffer, fp->size);//将缓冲区内的数据写到文件描述符所指定的文件中,数据个数为size所记录的个数
fsync(fp->fileno);//将内核缓冲区内的数据强行刷新到外设中
fp->size = 0;
}
void fclose_(FILE_* fp)
{
fflush_(fp);
close(fp->fileno);
}
(3)测试
当我们模拟写好对应的程序后,就可以开始测试了。
行刷新测试
我们写出以下测试程序:

我们运行该程序,并打开对应的“log.txt”文件:

可以看到,程序运行正常
进程退出刷新测试
我们修改程序为下图所示:

然后我们再在linux中写上以下脚本:

叫脚本会重复打开文件“log.txt”,并显示其中的内容
此时我们再打开一个窗口,然后运行程序并运行该脚本:

可以看到,此时该程序能够正常运行,且是在经过了10s开始显示文件“log.txt”中的内容
(4)缓冲区与操作系统的关系
我们之前一直都在讲内存向磁盘中写入数据时,这些数据会先被拷贝到用户级语言形成的缓冲区,然后在满足刷新缓冲区的条件后加载到磁盘。但其实,这些在缓冲区内的数据并不是直接刷新到磁盘中的,而是会先被拷贝存在于操作系统中的系统层面的内核缓冲区中,在满足缓冲区刷新条件后再被拷贝到磁盘对应的文件中。
不同于用户级语言的缓冲区存在于FILE结构体中,内核缓冲区存在于对应文件的struct file,即内核结构体中。我们说过,文件的pcb中一定存在有读写方法,其实pcb中还存有一个内核缓冲区,用于临时存放数据。内核缓冲区是由系统控制的,和用户没有关系。且其刷新策略的算法也非常复杂,我们前面说过的几种刷新策略都仅仅是用户级语言层面的,与系统层面的刷新策略无关。

但是内核缓冲区的刷新策略可能出现问题,比如在内存非常紧张的情况下,系统就可能为了腾内存,直接将内核缓冲区内的数据刷新到其他文件呢中。因此,如果我们不想让数据停留在内核缓冲区中等待刷新,就可以调用fsync()函数。该函数会强制将内核缓冲区内的数据刷新到外设中。

二、理解文件系统
在前面,我们讲的一直都是对被打开文件的管理。但是如果一个文件并没有被打开呢?未打开的文件并未加载到内存中,仅仅只是静静的躺在磁盘当中。而这些文件,其实也是需要被管理的起来以便我们随时打开。而管理这些文件的,也是文件系统
为了更好的理解文件系统,我们首先需要对存储文件的磁盘有一定的认知。
1.磁盘的物理结构
首先我们要知道,磁盘是计算机中的唯一一个机械机构。而正因为它是机械机构,就导致了磁盘的读写速度比较慢。在现在,我们其实很少能亲眼看到磁盘。同时由于固态硬盘属于电子元件,读写速度要比磁盘快,因此我们现在的计算机中一般用的都是固态硬盘SSD。
但是在公司中,磁盘依然是主流。有几个原因。一个是固态硬盘的价格太贵,一般要比磁盘贵到一半甚至一倍。而公司里面一般都有大量的数据需要存储,从成本上来看,磁盘更加便宜。第二个原因就是固态硬盘有读写次数限制,如果超过了读写限制,就可能导致SSD被击穿,出现数据丢失的情况。当然磁盘也是可能出现数据丢失。而随着当今时代的网络数据越来越多,无论是从成本还是从存储数据的容量上来看,都是磁盘更好。当然,某些公司又想提高读写效率又想要大容量存储,因此会采取固态硬盘集群或混盘。但公司存储数据的主流依然是磁盘。

上图是一个磁盘拆开后的样子。在磁盘当中,我们主要关注两个部件,一个是磁头,另一个就是盘面。从上图中我们可以看到,盘面是非常光滑的。要注意,这里的光滑是指肉眼看上去光滑,但是在微观环境下,如用显微镜观察的话,其实是凹凸不平的。就好比从宇宙上看上海,上海是一片平地,但是实际身处上海时,却是高楼大厦。同时要注意,盘面并不是只有这一面,而是两面。可以将盘面看做一个两面都光滑的光碟。同时磁头也并不是只有一个,磁头的数量和盘面的数量是一样的,如果有10个盘面,就会有10个磁头。在运作时,盘面会高速旋转,磁头则会左右来回摆动。盘面的旋转速度一般是每秒上万转。
同时磁盘还存有自己的硬件电路和伺服系统。磁盘通过硬件电路和伺服系统接受和发送二进制信息。
要注意,从图里面看磁盘中的磁头和盘面好像是贴在一起的,但实际上磁头和盘面是间隔开的,只是从肉眼上看好像是贴合的。这两个部件之间的距离就好比一家波音飞机在贴着地面几十厘米的地方飞行,看起来好像飞机是贴地飞行的,但实际并不是。而这个特点,也就导致了磁盘是绝对不能进灰尘的。只要有一点灰尘进入磁盘中,就可能会挡住磁头,导致磁头将盘面刮花或影响磁头的定位,进而导致磁盘报废。同时因为磁盘中的磁头和盘面离得非常近,这就导致磁盘一定需要“防抖动”。因为磁盘中如果出现了抖动,就可能导致磁头和盘面接触导致刮花盘面。
2.磁盘的存储结构
(1)磁盘的写数据方式
不知道大家以前有没有一个这样的疑惑,就是磁盘为什么叫做磁盘?一个物件的名字一般都是有来源的,磁盘也不例外。想必大家在以前都玩过磁铁:

我们知道,磁铁上有南北两极,上面存在磁性。南北两极刚好就可以被视作0,1信号。在磁盘的盘面上拥有着无数的南北极,假设南极代表0,那么北极就代表1,。而我们在磁盘上写数据时,都是以二进制方式写的,因此当需要写数据到磁盘上时,就是通过磁头来进行定位并改变盘面上对应位置的南北极以达到写入数据的目的。
(2)磁盘的读数据方式
在了解磁盘怎么读数据前,我们先来看看磁盘上的数据是如何划分存储的。
上文中说过,盘面的光滑只是从宏观上来看的。实际上,盘面是被一个个圆圈划分开来的。这一个个小圆圈就被称为磁道,而盘面上的扇状结构,就被称为扇区。

以上图为例,红色圆圈就是一根磁道,蓝色弧线就是一个扇区。上图中一共有七根磁道,蓝色区域中有七个扇区。
在磁盘上,磁盘寻址时的基本单位不是bit,也不是byte,而是“扇区”。一个扇区的大小一般是512byte。注意,尽管从图上看,每个扇区的面积都不一样,但是其上面存储的数据容量一般都是一样的,个别特殊用途的磁盘可能不一样。由此,每个扇区上存储数据的容量都是一样,那么每根磁道上存储的数据容量就也是一样的。
现在我们知道了盘面上是用于存储数据的。但是我们还不知道磁头是如何定位的。在实际上,每根磁道和每个扇面都是有自己的编号的。当我们要用某根磁道上的某个扇区上数据时,就会磁头去进行定位。磁头来回摆动其实就是一个定位的过程。当磁头定位到磁道上时,磁头就保持不动;此时盘面依然在高速旋转,而在旋转的这个过程中,每当指定的扇面转到磁头下时,磁头就会从这个扇面上获取数据。因此,评判一个磁盘好坏的标准之一,就是磁头定位和获取指定扇面上数据的速度。不要以为这很容易,我们说过,盘面旋转的速度一般是每秒上万转,因此磁头要在尽量短的时间内精准定位到扇面上其实很困难的。
上面我们仅仅只是讲了一块盘面上的情况。但是我们的磁盘其实是由很多个盘面和磁头组成的,如下图。

在上图中,就是磁盘的一个剖面图,可以看到,磁盘的每块盘都是两面,并且每面都有一个磁头,在磁盘中间的圆柱体,就被称为柱面。简单来讲,柱面就是一块磁盘上的每个扇面上的同一水平面的磁道。因此,可以认为,柱面等价于磁道。
有人可能觉得每个磁头都会在自己的盘面上定位,柱面没有什么意义。但是实际中,一块磁盘上的所有磁头都是共进退的。它们不像我的手指那样可以随意活动,要将其看做一个整体。假如一个磁头定位在了一号的磁道的一号扇区,那么其他所有的磁头都会定位在一号磁道的一号扇区。因此在磁盘上查找数据时,是所有磁头同时在各自盘面的同一位置查找获取数据。
3.磁盘的逻辑结构
(1)磁盘定位分区逻辑
我们现在已经基本了解了磁盘的物理结构和存储结构,接下来,我们就要了一下在磁盘中我们是如何定位分区的。
在过去想必大家都见过卷尺,如下图所示:

可以看到,在这个卷尺里面,里面的尺子是被卷成一个圆圈存放在里面的。但是如果我们拉动环扣将卷尺拉出来,它就会以线性结构的方式向前延伸,就如图中拉出来的那一部分。
同样的,对于磁盘我们也可以将其抽象成一个线性结构。我们将磁盘里面的每个盘面都想象成一个个线性结构,再将其连接在一起:

假设上图的磁盘只有4个盘面,我们就可以将看成是如上图所示的4个连续的线性结构。每个线性结构就是一个盘面。而我们在前文中讲过,磁盘的基本单位是扇区,因此通过将磁盘抽象成一个连续的线性结构的方式,我们就可以将一个个磁道想象成在这个线性结构上的一个个连续的分区,而扇区则是更小一级的分区:

通过这种方式,我们就可以将整个磁盘从逻辑上抽象为一个sector arr[n]数组,该数组上的每个元素都是一个扇区。因此,如果我们想定位一个扇区,只需要知道这个扇区的下标即可。在操作系统内部,将这种用数组下标来代表扇区地址的地址叫做"LBA地址”。
通过这种方法就能够很轻松的定位扇区。当我们想要找到某块使用了LBA地址的数据时,只需要用LBA地址除以一块盘面上的扇区总数就能得到它位于哪块盘面。再用该地址除以每块磁道上的扇区数就可以得到它所处的磁道;最后用该地址模上每块磁道上的扇区数就可以知道它所处的扇区。
例如我们此时有一块磁盘,这个磁盘上有4个盘面,每个盘面有10根磁道,每根磁道上有100个扇区。那个这个磁盘的扇区下标范围就是[0, 4000]。现在我们想找到156号数据的位置,只需如下图即可:

通过这个方法就可以知道156号数据在0号盘面上的1号磁道上的56号扇区。这个磁盘定位命名的方法就叫做“HCS法”
有些人可能就会觉得疑惑,为什么系统要对磁盘上的分区进行这样的逻辑抽象呢?其实有两个原因,一个是为了便于管理。另一个则是不想让OS的代码和磁盘强耦合。采用这种方法,哪怕以后磁盘换成了SSD,操作系统依然能正常运行,不至于强行要求使用磁盘。
(2)磁盘的数据读取逻辑
虽然一块磁盘中访问数据的基本单位是扇区(512byte),但是与磁盘动辄上百G的总容量比起来,依然太小了。因此,为了提升读取数据的效率,在一般情况下,都会以1KB、2KB、4KB为基本单位。而用的最多的就是4KB,即一次性读取8个扇区。在这里的4KB是读取磁盘中数据时必须加载到内存中的,哪怕你只读取1bit的数据,也需要一次性加载4KB数据。在读取完后,如果有必要的话,会重新将这些数据写回磁盘中。
这种一次性读取多个扇区的数据并加载到内存中的方法其实是运用了“局部性原理”的。即经过计算和实验,当内存中的进程需要磁盘中的数据,该数据存在的扇区的周围扇区的数据也有较大概率需要被读取,因此采用一次性读取4K的方法不仅提高了读取数据的效率,也在一定程度上提高了数据读取的命中率
我们的内存其实也是被划分成了4KB大小的空间,叫做“页框”。而磁盘中的文件尤其是可执行文件也是按照4KB大小划分成了块,叫做“页帧”。当我们将外设中的数据向内存中加载时,其实就是将页帧中的数据加载到页框中
我们可以在linux中输入“stat 任意文件”就可以查看到该文件的页帧大小

可以看到,在linux下的文件就是以4KB的大小分成的数据块“页帧”组成的
(3)磁盘的数据管理逻辑
在学校里,学校为了便于管理,会将每一个人都分配到一个宿舍,再分配到一个班、专业、年级、学院。通过这样层层分治的方式,学校能对我们每一个人都比较有效的进行管理。
在磁盘中,同样也需要管理。但是一块磁盘动则上百G的容量,如果要一次性管理好上百G的数据,无疑是非常困难的。因此,磁盘的管理方式其实也是层层分治。但是不同于学校,受每个学院、专业、班级的状况不同,可能需要不同的管理方法。而磁盘的管理则没有这个烦恼。磁盘中的每块空间在宏观上都是一样的,只需要采用相同的管理方法即可。
例如我们现在有一块500G的磁盘,要一次性管理好这500G是很困难的,但是我们可以将其分成4个部分,两个部分100G,两个部分150G,这就叫做分区。只要管理好这四个区里面的一个区,其他区就都可以用同样的方法进行管理。但是管理100G依然太困难,于是我们将这100G分成20组,每组5G,这叫做“分组”。要管理5G的内容虽然也比较困难,但是却比管理100G乃至500G要轻松的多。当然,如果觉得5G依然难以管理,还可以继续向下划分。

通过这种分区分组的方式,就可以更加便捷高效的对磁盘进行管理
现在我们知道了磁盘中的数据是通过分区来进行管理的。但是,磁盘是如何对这些块数据进行管理的呢?我们先来看下图:

我们可以将上图看成磁盘中的一份分区分组模式。第一层是分区,第二层则是基于分区所形成的组。在这里,我们可以将上图中的Block Group 0看成分区形成的100GB,而下面的则是分组形成的5GB。我们在这里就以这分组形成的5GB来讲解磁盘如何管理数据
在这个图中,我们先来看Boot Block,这是一个启动块,从名字就知道,这里面保存的数据是我们的计算机开机时需要加载的操作系统、图形化界面、命令行等内容,每次开机时都需要等待启动块里面的数据加载好后才能够登录
Super Block,简称SB,即超级块。简单来讲,SB里面保存的是整个文件系统的信息。例如在上图中的SB中保存的就是它所属的整个分区的各类属性,比如这个分区里面的组数、已使用组的数量、分区内数据的健康状态、可用容量等等。
SB并不是在所有的组中都存在,而是存在于部分组中。有些人可能会感到奇怪,既然SB里面保存的是整个文件系统的信息,那为什么会存储在块组中,而不是存在整个区内。其目的主要是为了备份。因为如果文件系统中的某个分区坏了,就可能会导致很严重的问题。而多份SB的存在就是为了在如果某天某个分区内的SB出现损坏时,可以将该分区内其他组中的SB拷贝过来,进行数据恢复
由此,在理解磁盘如何进行数据管理时,我们只用关注“Group Descriptor Table”、“Block Bitmap”、“inode Bitmap”、“inode Table”、“Data Blocks”五个部分即可

1.inode Table
我们说过,文件 = 属性 + 内容。在磁盘中,文件的属性是由inode存储的。可以将inode看做一个小的存储分区,一般是128byte或256byte,里面保存了一个文件的几乎所有属性(inode中并不会保存文件名)。而inode在一般情况下,是“一个文件一份inode”。也就是说,在磁盘中的文件,一般情况下都会有一个属于自己的inode。因此,为了保存这些inode,便诞生了“inode Table”。在这里面保存了分组内的所有inode,包括已经使用和未使用的inode。而为了区分不同文件的inode,于是每个inode都会有一个属于自己的inode编号。inode编号可以跨组查找,但是不能跨分区。例如一个分区内的inode编号范围为1000~10000,那么这些编号由该分组内的所有组使用,在其他分区内的inode就不能使用这个范围内的编号
我们在linux中输入“ls -li”命令,就可以看到当前路径下所有文件的inode编号:

inode编号并不是随机生成的,而是在划分分区的时候就已经确定好了该分区内所有组的inode编号范围。
2.Data Blocks
虽然文件 = 内容 + 属性。但是文件的内容和属性是分开存储的。属性我们已经知道它保存在inode里面,而内容则是保存在“Data Blocks”。Data Blocks里面保存了分组内部所有文件的数据块。Data Blocks的大小是动态增长的,会随着数据块和数据量的变化而变化。
3.inode Bitmap
虽然inode Table中保存了分组中所有inode,但是我们怎么知道这些inode里面有哪些可用哪些不可用呢?由此就诞生了“inode Bitmap”。inode Bitmap中保存了inode对应的位图结构。简单来讲,就是inode Bitmap中保存了大量的二进制序列。这些二进制序列与inode Table中所有的inode一一对应,假如0代表该inode未使用,1则代表inode已使用。由此,通过inode Bitmap就可以知道整个分组中所有的inode使用情况。

由此,当我们创建一个文件时,就是先去inode Bitmap中查找第一个不为1的bit位,找到后就将该bit位由0置1,然后再拿着该bit位对应的inode编号去inode Table中找到对应的inode,将该文件的属性填到这个inode中
4.Block Bitmap
既然inode有对应的位图结构,那么为了分辨数据块的使用情况,Data Blocks也有对应的位图结构,即“Block Bitmap”。Block Bitmap和inode Bitmap是相似的,里面保存了大量的二进制序列,用0表示该数据块未使用,用1表示该数据块已使用。Block Bitmap的位图结构和数据块也是一一对应的。

创建文件时会先去Bolck Bitmap找到未使用的数据块的位图,根据填入数据所需要的数据块进行申请,然后将对应数据块的位图由0置1,最后再去Block Table中找到对应的数据块并将内容填入其中。
5.Group Descriptor Table
我们现在知道了inode和数据块的位置,如何找到inode和数据块,如何区分它们是否已被使用。但是,对于整个分组中的这些数据块和inode一共有多少个,有多少个被使用,又有多少个未使用我们并不知道。当然,我们可以通过计算得到这些数据,但是要计算的话就一定会在一定程度上拉低效率。因此,为了知道这些数据,就有了“Group Descriptor Table”,简称为GDT,即块组描述表。这里面保存了对应块组的的所有宏观信息,如整个块组中0inode和数据块有多少个,已经被使用了多少,还有多少未被使用等宏观信息
6.文件读取
现在我们知道要查找一个文件首先要拿着该文件的inode编号去inode Bitmap中看该文件的位图是否为1,为1就表示该文件存在,然后再拿着该文件的inode编号去inode Table中找到对应的inode,获取该文件的各类属性。但是如果我们要读取一个文件的内容呢?inode编号仅仅只能帮我们找到该文件的属性,无法找到属于该文件的所有数据块。
为了解决这一问题,在inode中,其实还存在一个“Data Block Blocks[]”数组,这个数组里面就保存了该文件的数据块在Block Bitmap中的编号。为了便于理解,我们假设这个数组是“int Blocks[]”,大小是15。当然,根据系统的不同,该数组的大小也会有所不同。
但是,我们说过,磁盘中的数据块的大小一般是4KB。15个元素的话,也仅仅只能存储60KB的数据,实在是太小了。因此在实际中,该数组的最后几个元素所指向的数据块中存储的并不是文件数据,而是其他数据块的编号。我们假设该数组中的最后三个数据块中存储的是其他数据块的编号。为了扩大可用数据容量,一般这三个数据块中的第一个数据采用间接存储,存储的编号指向的数据块就是存储文件数据,;第二个数据块采用双间接存储,一级数据块中指向的数据块编号中也是存储的其他数据块的编号,二级数据块指向的数据块用来存储文件数据;第三个数据块则采用三间接存储,一级二级和三级数据块中都用来存储数据块编号,三级数据块指向的数据块才会用来存储数据。通过这种方式,就可以获得极为庞大可用容量,当数据块大小为4KB是,采用三间接存储,其最大可用容量能够达到2TB。

7.文件删除
想必大家都有过这样的经历,在下载一个比较大的文件,例如几十GB的文件,我们要下很长时间,网速慢的话可能需要下载好几个小时。但是当我们要删除它的时候,却只需要几秒十几秒就可以了。原因就在于,在下载文件时,我们是需要确确实实的将数据通过网络从其他服务器中将数据下载到本地,然后分配inode和数据块,将这些文件的属性放到inode中,再将其数据放到Data Blocks中。但是,在要删除一个文件时,我们并不需要删除它的数据,仅仅只需要根据它的inode编号和数据块编号,到inode Bitmap和block Bitmap中去找到它们对应的bit位,再将对应的bit位由1置0,此时该文件内的inode和数据块的映射关系映射表就会失效,inode和数据块可被其他文件使用,以达到删除文件的目的。
因此如果我们误删了文件,其实是可以恢复的,我们只需要找到该文件的inode编号,根据inode编号到inode Bitmap中找到对应的bit位,将其由0置1,然后到inode Table中找到对应的inode重新建立映射关系,再根据inode中保存的属性,找到对应的数据块,并到Block Bitmap中将对应数据块的bit位由0置为1,重新建立文件与数据块的映射关系,就能够完成文件恢复。
由此,当我们不小心误删文件后,最好什么都不要做,不要去新建文件和传输数据。这类操作可能导致inode被其他文件覆用,或导致对应数据块内的数据被修改,最后使得文件无法恢复或只能恢复部分数据。
(4)文件与目录
在上面我们讲了要查找一个文件,统一使用该文件的inode编号进行查找。但是我们在实际查找文件时,并不是用的inode编号,而是文件名。
现在我们在linux下尝试用inode编号查找文件:

可以看到,此时系统告诉我们对应的文件不存在。那就奇怪了,既然要查找文件需要使用inode编号,那为什么我们自己用inode编号却找不到对应文件?
在上文中我们说过,“linux下一切皆文件”。既然一切皆文件,那么目录也是文件。目录是文件的话,就会有自己的数据块,那么目录的数据块里面装的是什么呢?其实就是当前目录下的文件的文件名及其inode映射关系。也就是说,我们在一个目录下查找文件时,需要该目录有读权限,以便我们可以访问该目录的数据块,然后与数据块中存储的文件名进行比对并根据其与inode的映射关系找到对应文件的位置。我们之前说要在一个目录下创建文件需要有该目录的写权限,也是因为要创建文件就需要向目录的数据块中写入该文件的文件名和形成对应的inode映射关系。