一、文件系统的底层存储与寻址
当我们谈到文件系统的底层结构时,最关键的问题是:文件的数据与元数据(属性)如何存储在磁盘上,以及系统是如何定位这些数据的?在谈及文件系统之前,我们要先对储存文件的硬件有一些基本了解
首先,我们来看一下传统磁盘的基础结构
我们可以看到这种传统的机械磁盘的组成,那么它是如何运转的呢,又是如何进行文件里的内容存储的呢?
首先,计算机是不是只能识别二进制语言啊,而这个二进制语言说起来又有些广泛,有人说二进制不就是0和1吗? 0和1又是什么呢?其实啊,0和1也只是相对的一种概念,它也可以由我们自己替代,比如阴和阳?水和火?这些相对的概念也可以称之为0和1,因此在磁盘中,磁盘由于具有磁性,所以就代表他有南北极,我们就可以近似的把它的磁性之分代表对应的0和1,通过修改它的磁场,是不是就可以达到对"0"和"1"的修改呢?也就可以轻松完成数据的存储。
那具体它又是如何通过以上图片里的这些电子元件完成我们的目的的呢?以下是它的工作步骤:
有同学看到这里就可能没太看懂,那我们来总结以下,大致如下:
磁盘呢其实是一个里面结构,它每个面都是光滑的,都是可读可写的,我们家用的以前的DVD,只是单面且只读的,也就是在盘片高速旋转的过程中,磁头通过左右摇摆和前后移动来到达对应的位置然后改变它下面盘片的磁性,从而达到对数据进行更改存储,那么问题来了,它怎么知道我要改的区域是哪里,这么大一个盘片,怎么能精确的我所要更改的数据存储在哪个区域呢,因此我们来细致的讲解以下盘片的区域划分:
这里我们就要提到一些概念:
首先是磁道 ,一个大的盘面,可以由若干个同心圆由内向外扩散,这每一个圆圈都叫做一个磁道,而这个磁道由圆心开始﹐在同一表面上分别画出无数条半径﹐然後每两条半径所分割的磁轨﹐我们称为磁区 (Sector),也就是扇区,所以最终经过层层确认,每个扇区内的那一小块就是我们要寻找的地方。
因此,传统磁盘内如何找到一个指定位置的扇区?
也就是我们的"CHS定址法"
a. 首先确定我们在第几个磁头(Header),也就是第几个盘片
b.找到磁头后确定对应的磁道(Cylinder)
c.找到磁道后确认在哪个扇区(Sector)
通过以上步骤我们就可以找到文件所对应在磁盘的准确位置了,是不是很神奇,这是不是也意味着
文件的大小归根结底就是占用多少个扇区的地方嘛!
可如今随着科技的发展,我们所采用的已经不是这种很老的磁盘了,一般企业会采用这种,因为它不会挪动,拿来做服务器刚刚好,一旦做成便携式笔记本,磁头和盘片一接触直接就会造成不可避免的数据损失,我们如今所采用的是固态硬盘(SSD),无需运动,并且有更高的效率。
那么话归正传,既然我们已经知道如何在磁盘里寻找对应的文件地址了,是不是意味着每次我都要进行计算啊,这完全依赖于硬件配置啊,难道我作为一家企业要为每一个不同磁盘规格都编译一份操作系统吗,这肯定不行,因此我们要封装一下这个步骤,让我们的操作系统不需要去依赖硬件才能找到地址,那我们的操作系统是如何做的呢?
之前我们是不是说过,机械磁盘里盘面其实是链接在一起的,大家小学时应该都见过这个东西
它内部其实就是类似两个盘片,中间用线将他们连接起来,反复播放,其实我们的磁盘也同理,n个盘片是不是也可以以线性的方式将他们连接到一起啊!
这是不是相当于将他们都整合成了一个近似的数组啊,这个数组里存放的是不是就是对应的扇区啊!
也就是说我只需要存放对应位置的下标,然后进行一些列的计算就可以转换成机械硬盘所需要的CHS地址啊,而我操作系统内部就可以按照计算方式来进行管理,是不是很方便
假设我们当前一共有1000个扇区,10个磁道,那么此时我们的对应下标是500,那么现在我们要计算出在第几个磁头,然后转化成当前盘面的对应扇区下标,然后计算出在哪个磁道后计算出对应扇区,步骤如下
通过这样的计算我们就可以成功找到对应的位置了
可是我们可以思考一下,一个扇区一个扇区的计算,对于操作系统的消耗是不是太繁琐了啊,因为扇区的单位实在是太小了,每次都要去计算出这个很小的精确位置十分消耗资源,因此操作系统在于磁盘交互的时候其基本单位是4kb,也就是八个扇区的大小。
操作系统和文件系统通常使用"块(block)"作为基本分配和访问单位。一个块可能由若干扇区组成(例如4KB=8个512字节扇区)。文件系统通过块号(Block Number)来定位文件数据,从而屏蔽底层扇区的复杂定位。假设有N个连续的块可以使用,则文件系统在逻辑上将磁盘空间看成一个块数组(block0, block1, block2, ...
)。通过块号,我们可以快速找到对应的扇区,进而从磁盘中读写数据。 如图所示
所以也意味着我们只需要一个起始地址,磁盘的总大小,就可以知道磁盘每个单位的下标,然后通过计算就可以取到对应的CHS地址!
所以也就是现代方法"逻辑区块地址(Logical Block Address, LBA)"
LBA是非常单纯的一种定址模式﹔从0开始编号来定位区块,第一区块LBA=0,第二区块LBA=1,依此类推。这种定址模式取代了原先操作系统必须面对存储设备硬件构造的方式。最具代表性的首推CHS(cylinders-heads-sectors,磁柱-磁头-扇区)定址模式,区块必须以硬盘上某个磁柱、磁头、扇区的硬件位置所合成的地址来指定。CHS模式对硬盘以外的设备来说没什么作用(例如磁带或是网络存储设备),所以通常也不会用在这些地方。过去MFM(Modified Frequency Modulation, 改良调频式)和RLL(Run Length Limited)存储设备都曾使用CHS模式,ATA-1设备更将延伸CHS(Extended Cylinders-Heads-Sectors, ECHS)也派上了用场。
那仅仅到这里就完成我们文件系统的管理了吗?
我们的整个磁盘大小可是有800GB啊,难道每次就对这800GB的磁盘大小进行挨个查询吗,这显然也是不现实的,因此我们的操作系统采取了"分治思想"
要管理好我们的磁盘,其实内存是很大的,这个时候我们采用的就是分区,800GB分成若干个区域,有管理好200GB的机制,就可以以此类推管理好剩余区域,分区下面再分组,就完成了文件系统的管理
大型磁盘往往被分割为多个分区(Partition),每个分区作为独立的逻辑存储区域,安装(mount)为独立的文件系统。例如,一个1TB磁盘可以分成200GB、200GB、300GB、100GB等多个分区。这样做的目的是"分而治之",简化管理和提升灵活性。
在类UNIX文件系统(如ext系列)中,分区内部还分成块组(block group)。块组内包含inode表、块位图、inode位图以及数据块区域。通过将文件系统分块组管理,可以提升文件系统的本地性和查找效率。
如图所示,Linux ext2 文件系统,上图为磁盘文件系统图(内核内存映像肯定有所不同),磁盘是典型的块设备,硬盘分区被划分为一个个的block 。一个 block 的大小是由格式化的时候确定的,并且不可以更改。例如 mke2fs 的 -b 选项可以设定block 大小为 1024 、 2048 或 4096 字节。而上图中启动块( Boot Block )的大小是确定的
那我们每个分组内的这些信息都代表什么呢?
- Block Group:ext2文件系统会根据分区的大小划分为数个Block Group。而每个Block Group都有着相同的结构组成。政府管理各区的例子
- 超级块(Super Block):存放文件系统本身的结构信息。记录的信息主要有:bolck 和 inode的总量,未使用的block和inode的数量,一个block和inode的大小,最近一次挂载的时间,最近一次写入数据的时间,最近一次检验磁盘的时间等其他文件系统的相关信息。Super Block的信息被破坏,可以说整个文件系统结构就被破坏了
- GDT,Group Descriptor Table:块组描述符,描述块组属性信息
- 块位图(Block Bitmap):Block Bitmap中记录着Data Block中哪个数据块已经被占用,哪个数据块没有被占用
- inode位图(inode Bitmap):每个bit表示一个inode是否空闲可用。
- inode表:存放文件属性 如 文件大小,所有者,最近修改时间等
- 数据区:存放文件内容
我们之前说过Linux内文件是由什么组成的?
文件 = 内容 + 属性 ,那么文件在磁盘中的存储就是文件的内容+文件的属性数据,本质上都是数据,存储在磁盘里,一个正常的文件,就要有他的对应的属性啊,这个属性就是inode,也就是一个正常文件就要有对应的inode属性集合。
那么先说我们的inode,
inode表是存放文件元数据(属性)的区域。这里的inode是一个结构体,记录了文件的:
- 文件大小(以字节为单位)
- 文件所有者和所属组ID
- 文件权限和类型(普通文件、目录、符号链接等)
- 文件的时间戳(访问、修改、变化时间)
- 指向文件数据块的索引指针(直接、间接、双重间接、三重间接指针)等信息
需要注意的是,在Linux的ext2/3/4文件系统中,inode并不存放文件名。文件名存储在目录的data block中,而inode指针是通过目录项(directory entry)将文件名映射到inode号(number)上。
inode Bitmap(inode位图)
inode位图(inode Bitmap)与块位图类似,只不过它是标记inode的使用情况。
1
表示对应的inode已被使用(对应某个文件或目录)0
表示该inode空闲可用
当需要创建新文件时,文件系统会在inode位图中查找空闲inode(即bit为0的位置),然后分配给新文件,并将该位清为1表示已占用。
Block Bitmap(块位图)
块位图(Block Bitmap)是用于记录本块组中哪些数据块已经被分配,哪些还空闲的位图数据结构。每一位(bit)对应一个block:
1
位代表该block已被占用0
位代表该block空闲可用
当文件需要分配新的数据块时,文件系统会在对应块组的Block Bitmap中找到一个置0的bit,将其置1,并分配给文件。由于是位图结构,查找和修改都很高效,只需简单的位操作即可。
数据区(Data Area)
其次是我们的Data Blocks,这就是我们的数据区,也就是存放文件的内容的地方,里面的一个个基本单位就是4KB,其实就是我们磁盘最开始一个个小扇区单元块,完全一致。实际上我们在寻找文件时必须用inode号来寻找,inode是以分区为单位分配的而不是以分组的形式分配的,每个组只需要记录自己的起始inode和结束inode即可,也就是在上层确定一个inode后直接在下面的分组确定自己在哪个分组里就行,可是问题是我们每个分组里不都是inode要从0开始吗,这也怎么区分呢?其实可以用inode号减去当前分组的起始inode,剩下的数字就可以进去bitinode查看是否被占用和后续使用了.
超级块(Super Block)
超级块(Super Block)是整个文件系统的"全局信息表",其中记录了文件系统的关键信息,包括但不限于:
- 文件系统总的block数量和可用block数量
- 文件系统总的inode数量和可用inode数量
- 每个block的大小、每个inode的大小
- 文件系统创建时间、最近挂载/写入/检查时间
- 文件系统的特性标志(例如是否有日志扩展)
- 检查间隔(每隔多少次挂载或多少时间后需要进行fsck)等信息
在ext2中,超级块不仅在分区开头的特定位置存储,还会在每个Block Group中存放一份副本(除非格式化时指定了不存放备份)。这些副本作为冗余信息存在,一旦主超级块损坏,可以从块组的副本中恢复文件系统的结构信息。
如果超级块信息被全部破坏,那么文件系统的结构信息就无法找回,整个文件系统相当于"失去了地图"。
可是到这里为止,大家有没有想过,inode编号的存在我们已经掌握了,但是你真正对文件进行操作的时候什么时候用inode号了,是不是都说以文件名的方式来进行操作啊,跟inode有什么关系呢?
那么到了这里我们就不得不谈一个相关的概念了:目录
我们说Linux里是不是一切皆文件啊,我要组织这些文件,那我目录是不是就担任这个角色呢,所以目录也有自己的内容+属性,他的属性是不是和inode差不多,内容呢,我们之前说过inode里不存文件名,其实文件名是存放到目录里的,我们通过目录里文件名对应的inode来进行寻找文件,其实是文件名和inode建立了一层映射关系,让我们可以通过目录直接分配对应的Inode来进行后续的管理啊,所以我们可以引申出一些结论
一个目录下不能建立同名文件的关系是什么,是不是无法区分对应的是哪个inode号啊,
而查文件的本质就是 文件名-> inode号
所以对一个文件没权限写的本质是什么,是不是没有访问inode的权利啊,找不到映射关系怎么写入呢?
所以我们每次访问是不是都要从目录里进行访问,那么我们的目录不也是文件吗?
目录名不也是一个inode吗,他也有对应的数据块啊,所以实际上我们在访问一个路径下的文件时,其实是对路径进行一个逆向解析,先找到根目录,然后通过根目录的inode找到下一个目录的Inode,紧接着一步步在inode里找inode,最后找到文件的inode就可以进行操作了,所以根目录的inode一定是刚打开操作系统的时候就已经确定好了,
这些操作都是操作系统自己做的
这就是为什么我们在打开文件时都必须要有路径!
一个文件访问前都是先访问目录,这个目录就已经确定好分区了
所以目录是谁提供的呢,进程在启动时就已经携带了,都是由操作系统提供的
为了提高效率,Linux内核有dentry
(directory entry)缓存,用于缓存最近解析的路径信息。这样在多次访问同一路径时,无需重复进行层层目录查找,能显著减少系统开销。
这些组件是如何协同工作的?
-
查找文件时的步骤 :
当我们通过路径名(如
/home/user/file.txt
)访问文件时,操作系统会从根目录开始:- 根目录的inode号通常是固定的(比如2号)。内核根据根目录的inode从inode表中加载该inode信息(通过超块和GDT可以快速找到inode表位置)。
- 根据该inode的块指针找到存放该目录内容的data block,读取其中的目录项(文件名与inode号的映射)。
- 找到
home
目录名对应的inode号,接着打开home
目录的inode,同理找到user
目录inode号,再找到file.txt
的inode号。 - 一旦确定了
file.txt
的inode号,就通过inode表读取file.txt的inode信息,从而得到该文件的数据块指针,最终从数据区中读取该文件的内容。
-
分配新文件时的步骤 :
当新建一个文件时,文件系统需要:
- 从inode位图中找到一个空闲inode号,分配给新文件,并在inode表中初始化文件的属性信息。
- 如果文件需要写入数据,为文件分配数据块。即在块位图中找到一个空闲块,将其标记为已占用,并写入文件数据内容到该块中。
- 将新文件的名称与其inode号写入父目录的数据区(即父目录文件的data block中),使得路径解析时能正确找到它。
-
在内核内存映射中的对应关系 :
在内核中,超级块信息会被读入内存的
struct super_block
,GDT会读入struct group_desc
结构数组中,inode表的信息会被内核通过struct inode
对象描述,目录项会通过struct dentry
缓存,数据块会映射到页缓存中(page cache
)以减少频繁磁盘I/O。虽然内存中结构体与磁盘上的数据结构不完全相同,但逻辑对应关系保持一致,内核通过特定的读写操作和缓冲机制(buffer head、page cache)来将磁盘数据结构映射和加载到内存中,进行缓存和同步。
下面将通过一个较为完整、详细的流程来描述当我们在用户程序中使用 fwrite()
向一个新建文件中写入数据时,在底层发生了什么。从用户态的C标准库调用开始,一直到数据最终被写入到磁盘文件系统的实际块中,以及新文件创建过程中的元数据分配与更新流程。
cpp
#include <stdio.h>
#include <stdlib.h>
int main() {
FILE *fp = fopen("newfile.txt", "w");
if (fp == NULL) {
perror("fopen");
return 1;
}
const char *data = "Hello, fwrite!\n";
size_t wrote = fwrite(data, 1, sizeof("Hello, fwrite!\n") - 1, fp);
if (wrote < sizeof("Hello, fwrite!\n") - 1) {
perror("fwrite");
}
fclose(fp);
return 0;
}
实验过程分解
1. 文件创建(fopen阶段)
当你执行 fopen("newfile.txt", "w")
时,底层流程大致如下:
-
用户态C库层面 :
fopen()
是C标准库函数,它并不直接完成文件创建,而是调用系统调用(如open()
)完成底层操作。此时:fopen()
会根据模式"w"
决定调用open("newfile.txt", O_WRONLY|O_CREAT|O_TRUNC, 0666)
系统调用(精确标志可能略有差异)。- 若文件不存在,传入的
O_CREAT
标志会使内核尝试新建文件。
-
内核VFS层与文件系统层面 :
内核接收到
open()
系统调用后,会进行路径解析:- 从根目录开始查找
newfile.txt
所在目录(比如当前工作目录),通过目录项(dentry)和inode查找目标目录的inode。 - 找到该目录的inode后,内核在该目录的数据块中搜索
newfile.txt
的目录项。如果文件不存在,内核就要创建一个新的inode来表示新文件。
- 从根目录开始查找
-
inode与block分配(新文件诞生) :
在ext2文件系统中,新文件创建需要分配一个空闲的inode:
- 内核通过对应块组的inode位图(inode bitmap)找到一个空闲的inode号,将其标记为已用。
- 在inode表(inode table)中初始化该inode结构:设置文件类型为普通文件、初始大小为0、时间戳、权限(0666受umask影响),链接计数等。
- 不需要立即分配数据块,空文件初始大小为0;数据块将在后续写入时分配(延迟分配策略在ext4中更明显,在ext2中则可能马上分配第一个块)。
同时,内核需要在目标目录的数据块中添加一个新的目录项:
- 目录的数据块是一个文件名到inode号的映射。
- 内核找到该目录文件的数据块,在合适位置插入
"newfile.txt"
对应的目录项(包含文件名和新分配的inode号)。
目录项更新后,
newfile.txt
就被记录在文件系统中,并且它有自己的inode,但文件内容还为空。 -
返回文件描述符与FILE指针 :
内核为此新文件分配一个file对象(
struct file
),并在进程的文件描述符表中加入一条指向该file对象的记录。open()
系统调用返回一个整数文件描述符(fd)。C库的
fopen()
接收到fd后,会为其分配一个FILE *
结构,设置用户态缓冲区并返回给用户。至此,fp
指针就有效地指向了一个新创建的空文件。
2. 写入数据(fwrite阶段)
当执行 fwrite(data, 1, len, fp)
时,发生了如下过程:
-
用户态C库缓冲 :
fwrite()
首先将要写入的数据拷贝到fp
所管理的用户态输出缓冲区(这是C标准库提供的缓冲区)。此时并未发生真正的内核写入。
fwrite()
的返回值表示成功写入用户态缓冲区的字节数。如果用户调用
fflush(fp)
或者缓冲区满、文件关闭(fclose)或者程序结束后,才会实际调用write()
系统调用将数据从用户态缓冲写入内核态。若使用的是行缓冲或全缓冲,fwrite写入的数据可能先暂存在用户态,不立即刷新到内核。
-
刷入内核(write系统调用) :
当
fclose(fp)
或缓冲策略触发刷新时,C库将调用write(fd, buffer, length)
系统调用。内核处理:
- 内核接收
write()
系统调用,使用文件描述符找到对应的struct file
对象。 struct file
通过f_inode
成员可找到对应的inode。- 文件系统(ext2)检查该文件inode。如果当前文件大小为0,还没有分配数据块,则需要为该文件分配数据块:
- 内核通过Block Bitmap查找一个空闲数据块,将其标记为已用。
- 在inode结构中更新
i_block[]
或间接块指针信息,指向分配的新数据块。
- 将用户传递的写入数据(通过copy_from_user)拷贝到内核页缓存(page cache)对应的数据页中。页缓存是内核中缓存文件内容的区域,以减少频繁的磁盘IO。
- inode的大小被更新(增加写入的字节数),时间戳(mtime、ctime)更新。
- 此时数据仍在内核页缓存中,还没有真正写入磁盘。
- 内核接收
-
缓存与延迟写回 :
数据先写入内核的页缓存,这提高了性能,因为内核可以合并多次写入,将其在合适时机一次性写入磁盘。
写回由内核的同步机制触发(pdflush或kworker线程)或在特定条件下(同步文件系统、文件关闭、缓冲区满、调用
fsync()
)执行。
3. 文件系统同步与落盘
当内核需要将数据实际写入磁盘(可能是延迟一段时间后):
-
块映射与缓冲区写入 :
内核知道文件所在的块组、inode表和数据块位置。需要将数据缓存页中的内容写入对应的磁盘块:
- 使用块设备驱动,将页缓存中的数据发出I/O请求(通过I/O调度器)传给磁盘控制器。
- 磁盘控制器将相应的扇区读写操作发给硬盘。
-
元数据同步 :
为了保证一致性,除了数据块本身外,对inode表、Block Bitmap、inode Bitmap的更改也需写回磁盘。例如:
- 分配新inode和数据块时修改的位图需要同步到磁盘对应的块。
- 修改inode表中的文件大小和时间戳信息也会写回。
在ext2文件系统中,这些元数据的更新没有日志,因此如果系统在更新中崩溃,可能需要
fsck
来恢复一致性。
4. 文件关闭(fclose阶段)
当你调用 fclose(fp)
:
fclose()
会先调用fflush()
将用户态缓冲中剩余数据写入内核。- 然后调用
close()
系统调用关闭文件描述符。 - 内核将减少file对象的引用计数,如果是最后一个关闭该文件的进程,则file对象会被释放。
- inode和dentry缓存保留一定时间,以便下次访问文件时加快速度,但最终也会被回收。
此时,从应用程序的角度看,文件已经成功写入并关闭。数据最终会在一定时间内被写回磁盘(如果还未写回),从而持久地存储在分区对应的data block中。
总结底层过程
-
路径解析与inode分配 :
fopen
与open
系统调用通过内核的VFS层、dentry、inode找到目录并创建新文件的inode,分配其inode号,并在目录数据块中新建文件名->inode号的目录项。 -
用户态I/O缓冲 :
fwrite
仅将数据写入用户态缓冲区,不立即到内核。当执行fflush
或fclose
或缓冲策略触发时,才调用write()
系统调用。 -
内核缓冲(页缓存)与块分配 :内核
write()
将数据写入内核的页缓存中。如需为文件增加空间,会通过Block Bitmap找到空闲块,将其分配给该文件的inode,并更新inode表。inode大小、时间戳更新。这些修改还在内核内存中。 -
延迟写回与磁盘同步 :内核稍后会将页缓存数据块、更新后的位图块、inode表块写入磁盘中对应的扇区。如果系统调用
fsync()
或定期同步进程(例如sync
)执行,就会强制立即落盘。 -
文件关闭 :
fclose()
最终导致close()
系统调用,内核释放文件描述符和相关数据结构引用。
通过上述过程,从调用fwrite()
往新建文件中写入数据,到文件数据真正写入磁盘,中间经过了用户态缓冲--->内核态缓冲--->磁盘的多层抽象,期间涉及路径解析、inode与block分配、目录项更新、位图修改和inode表修改等关键步骤。这就是使用 fwrite
和新建文件这两个实验在底层全面展开的实际流程。
总体来说,这些元数据结构(超级块、GDT、位图、inode表)与数据区紧密协作,形成了ext2文件系统的"管理层"(元数据)与"内容层"(数据区),借助块组的分层次管理和位图的高效查找,使得文件系统在底层能够有效组织和管理海量文件与数据,保证文件读写、创建、删除和寻址的高效与可靠。