Linux 文件系统
From: https://segmentfault.com/a/1190000023615225
文件系统的基本组成
文件系统是操作系统中负责管理持久数据的子系统,说简单点,就是负责把用户的文件存到磁盘硬件中,因为即使计算机断电了,磁盘里的数据并不会丢失,所以可以持久化的保存文件。文件系统的基本数据单位是文件
,它的目的是对磁盘上的文件进行组织管理,那组织的方式不同,就会形成不同的文件系统。Linux 最经典的一句话是:「一切皆文件」,不仅普通的文件和目录,就连块设备、管道、socket 等
,也都是统一交给文件系统管理的。
Linux 文件系统会为每个文件
分配两个数据结构:索引节点(*index node*)和目录项(*directory entry*),它们主要用来记录文件的元信息和目录层次
结构。
索引节点
,也就是 inode,用来记录文件的元信息,比如 inode 编号、文件大小、访问权限、创建时间、修改时间、**数据在磁盘的位置**等等
。索引节点是文件的唯一标识,它们之间一一对应,也同样都会被存储在硬盘中,所以索引节点同样占用磁盘空间。目录项
,也就是 dentry,用来记录文件的名字、**索引节点指针**以及与其他目录项的层级关联关系
。多个目录项关联起来,就会形成目录结构,但它与索引节点不同的是,目录项是由内核维护的一个数据结构,不存放于磁盘,而是缓存在内存。
由于索引节点唯一标识一个文件,而目录项记录着文件的名,所以目录项和索引节点的关系是多对一
,也就是说,一个文件可以有多个别字。比如,硬链接的实现就是多个目录项中的索引节点指向同一个文件
。
注意,目录也是文件,也是用索引节点唯一标识,和普通文件不同的是,普通文件在磁盘里面保存的是文件数据,而目录文件在磁盘里面保存子目录或文件
。
目录项和目录是一个东西吗?
虽然名字很相近,但是它们不是一个东西,目录是个文件,持久化存储在磁盘
,而目录项是内核一个数据结构,缓存在内存
。如果查询目录频繁从磁盘读,效率会很低,所以内核会把已经读过的目录用目录项这个数据结构缓存在内存,下次再次读到相同的目录时,只需从内存读就可以,大大提高了文件系统的效率。
注意,目录项这个数据结构不只是表示目录,也是可以表示文件的。
那文件数据是如何存储在磁盘的呢?
磁盘读写的最小单位是扇区,扇区的大小只有 512B 大小
,很明显,如果每次读写都以这么小为单位,那这读写的效率会非常低。所以,文件系统把多个扇区组成了一个逻辑块,每次读写的最小单位就是逻辑块(数据块),Linux 中的逻辑块大小为 4KB
,也就是一次性读写 8 个扇区,这将大大提高了磁盘的读写的效率。
索引节点是存储在硬盘上的数据,那么为了加速文件的访问,通常会把索引节点加载到内存中。
另外,磁盘进行格式化的时候,会被分成三个存储区域,分别是超级块、索引节点区和数据块区。
*超级块*,用来存储文件系统的详细信息,比如块个数、块大小、空闲块等等。
- *
索引节点区*,用来存储索引节点;
- *
数据块区*,用来存储文件或目录数据;
我们不可能把超级块和索引节点区全部加载到内存,这样内存肯定撑不住,所以只有当需要使用的时候,才将其加载进内存,它们加载进内存的时机是不同的:
超级块:当文件系统挂载时进入内存;
索引节点区:当文件被访问时进入内存;
虚拟文件系统
文件系统的种类众多,而操作系统希望对用户提供一个统一的接口,于是在用户层与文件系统层引入了中间层,这个中间层就称为**虚拟文件系统(*Virtual File System,VFS*)。**VFS 定义了一组所有文件系统都支持的数据结构和标准接口,这样程序员不需要了解文件系统的工作原理,只需要了解 VFS 提供的统一接口即可。
在 Linux 文件系统中,用户空间、系统调用、虚拟机文件系统、缓存、文件系统以及存储之间的关系如下图:
Linux 支持的文件系统也不少,根据存储位置的不同,可以把文件系统分为三类:
磁盘的文件系统
,它是直接把数据存储在磁盘中,比如 Ext 2/3/4、XFS 等都是这类文件系统。*内存的文件系统
*,这类文件系统的数据不是存储在硬盘的,而是占用内存空间,我们经常用到的/proc
和/sys
文件系统都属于这一类,读写这类文件,实际上是读写内核中相关的数据数据。网络的文件系统
,用来访问其他计算机主机数据的文件系统,比如 NFS、SMB 等等。
VFS的抽象接口
上述示例中提到VFS也有自己的文件模型,用来支持操作系统的系统调用。下面是VFS抽象模型支持的所有Linux系统调用:
- 文件系统相关:mount, umount, umount2, sysfs, statfs, fstatfs, fstatfs64, ustat
- 目录相关:chroot,pivot_root,chdir,fchdir,getcwd,mkdir,rmdir,getdents,getdents64,readdir,link,unlink,rename,lookup_dcookie
- 链接相关:readlink,symlink
- 文件相关:chown, fchown,lchown,chown16,fchown16,lchown16,hmod,fchmod,utime,stat,fstat,lstat,acess,oldstat,oldfstat,oldlstat,stat64,lstat64,lstat64,open,close,creat,umask,dup,dup2,fcntl, fcntl64,select,poll,truncate,ftruncate,truncate64,ftruncate64,lseek,llseek,read,write,readv,writev,sendfile,sendfile64,readahead
Linux系统VFS支持的文件系统
- Disk-based 文件系统:Ext2, ext3, ReiserFS,Sysv, UFS, MINIX, VxFS,VFAT, NTFS,ISO9660 CD-ROM, UDF DVD,HPFS, HFS, AFFS, ADFS,
- Network 文件系统:NFS, Coda, AFS, CIFS, NCP
- 特殊文件系统:/proc,/tmpfs等
统一文件模型(common file model)
VFS为了提供对不同底层文件系统的统一接口,需要有一个高度的抽象和建模,这就是VFS的核心设计——统一文件模型。目前的Linux系统的VFS都是源于Unix家族,因此这里所说的VFS对所有Unix家族的系统都适用。Unix家族的VFS的文件模型定义了四种对象,这四种对象构建起了统一文件模型。
superblock
:存储文件系统基本的元数据
。如文件系统类型、大小、状态
,以及其他元数据相关的信息(元元数据)index node(inode)
:保存一个文件相关的元数据
。包括文件的所有者(用户、组)、访问时间、文件类型等
,但不包括这个文件的名称。文件和目录均有具体的inode对应directory entry(dentry)
:保存了文件(目录)名称和具体的inode的对应关系
,用来粘合二者,同时可以实现目录与其包含的文件之间的映射关系。另外也作为缓存的对象,缓存最近最常访问的文件或目录,提示系统性能file
:一组逻辑上相关联的数
据,被一个进程打开并关联使用
文件的使用
我们从用户角度来看文件的话,就是我们要怎么使用文件?首先,我们得通过系统调用来打开一个文件。
fd = open(name, flag); # 打开文件
...
write(fd,...); # 写数据
...
close(fd); # 关闭文件
上面简单的代码是读取一个文件的过程:
- 首先用
open
系统调用打开文件,open
的参数中包含文件的路径名和文件名。 - 使用
write
写数据,其中write
使用open
所返回的文件描述符,并不使用文件名作为参数。 - 使用完文件后,要用
close
系统调用关闭文件,避免资源的泄露。
我们打开了一个文件后,操作系统会跟踪进程打开的所有文件,所谓的跟踪呢,就是操作系统为每个进程维护一个打开文件表
,文件表里的每一项代表「文件描述符」,所以说文件描述符是打开文件的标识。
操作系统在打开文件表中维护着打开文件的状态和信息:
文件指针
:系统跟踪上次读写位置作为当前文件位置指针,这种指针对打开文件的某个进程来说是唯一的;文件打开计数器
:文件关闭时,操作系统必须重用其打开文件表条目,否则表内空间不够用。因为多个进程可能打开同一个文件,所以系统在删除打开文件条目之前,必须等待最后一个进程关闭文件,该计数器跟踪打开和关闭的数量,当该计数为 0 时,系统关闭文件,删除该条目;文件磁盘位置
:绝大多数文件操作都要求系统修改文件数据,该信息保存在内存中,以免每个操作都从磁盘中读取;访问权限
:每个进程打开文件都需要有一个访问模式(创建、只读、读写、添加等),该信息保存在进程的打开文件表中,以便操作系统能允许或拒绝之后的 I/O 请求;
在用户视角里,文件就是一个持久化的数据结构,但操作系统并不会关心你想存在磁盘上的任何的数据结构,操作系统的视角是如何把文件数据和磁盘块对应起来。
所以,用户和操作系统对文件的读写操作是有差异的,用户习惯以字节的方式读写文件,而操作系统则是以数据块来读写文件
,那屏蔽掉这种差异的工作就是文件系统了。
我们来分别看一下,读文件和写文件的过程:
- 当用户进程从文件读取
1 个字节大小
的数据时,文件系统则需要获取字节所在的数据块
,再返回数据块对应的用户进程所需的数据部分
。 - 当用户进程把 1 个字节大小的数据写进文件时,文件系统则找到需要
写入数据的数据块的位置
,然后修改数据块中对应的部分
,最后再把数据块写回磁盘
。
所以说,文件系统的基本操作单位是数据块。
文件的存储
文件的数据是要存储在硬盘上面的,数据在磁盘上的存放方式,就像程序在内存中存放的方式那样,有以下两种:
连续空间存放方式
非连续空间存放方式
其中,非连续空间存放方式又可以分为「链表方式」和「索引方式」。不同的存储方式,有各自的特点,重点是要分析它们的存储效率和读写性能,接下来分别对每种存储方式说一下。
连续空间存放方式
连续空间存放方式顾名思义,文件存放在磁盘「连续的」物理空间中。这种模式下,文件的数据都是紧密相连,读写效率很高,因为一次磁盘寻道就可以读出整个文件。使用连续存放的方式有一个前提,必须先知道一个文件的大小,这样文件系统才会根据文件的大小在磁盘上找到一块连续的空间分配给文件。所以,文件头里需要指定「起始块的位置」和「长度」,有了这两个信息就可以很好的表示文件存放方式是一块连续的磁盘空间。
注意,此处说的文件头,就类似于 Linux 的 inode。
连续空间存放的方式虽然读写效率高,但是有「磁盘空间碎片」和「文件长度不易扩展」的缺陷。
如下图,如果文件 B 被删除,磁盘上就留下一块空缺,这时,如果新来的文件小于其中的一个空缺,我们就可以将其放在相应空缺里。但如果该文件的大小大于所有的空缺,但却小于空缺大小之和,则虽然磁盘上有足够的空缺,但该文件还是不能存放。当然了,我们可以通过将现有文件进行挪动来腾出空间以容纳新的文件,但是这个在磁盘挪动文件是非常耗时,所以这种方式不太现实。
另外一个缺陷是文件长度扩展不方便,例如上图中的文件 A 要想扩大一下,需要更多的磁盘空间,唯一的办法就只能是挪动的方式,前面也说了,这种方式效率是非常低的。
那么有没有更好的方式来解决上面的问题呢?答案当然有,既然连续空间存放的方式不太行,那么我们就改变存放的方式,使用非连续空间存放方式来解决这些缺陷。
非连续空间存放方式
非连续空间存放方式分为「链表方式」和「索引方式」。
我们先来看看链表的方式。
链表的方式存放是离散的,不用连续的,于是就可以消除磁盘碎片,可大大提高磁盘空间的利用率,同时文件的长度可以动态扩展。根据实现的方式的不同,链表可分为「隐式链表」和「显式链接」两种形式。
文件要以「隐式链表」的方式存放的话,实现的方式是文件头要包含「第一块」和「最后一块」的位置,并且每个数据块里面留出一个指针空间,用来存放下一个数据块的位置,这样一个数据块连着一个数据块,从链头开是就可以顺着指针找到所有的数据块,所以存放的方式可以是不连续的。
隐式链表的存放方式的缺点在于无法直接访问数据块,只能通过指针顺序访问文件,以及数据块指针消耗了一定的存储空间。隐式链接分配的稳定性较差,系统在运行过程中由于软件或者硬件错误导致链表中的指针丢失或损坏,会导致文件数据的丢失。如果取出每个磁盘块的指针,把它放在内存的一个表中,就可以解决上述隐式链表的两个不足。那么,这种实现方式是「显式链接」,它指把用于链接文件各数据块的指针,显式地存放在内存的一张链接表中,该表在整个磁盘仅设置一张,每个表项中存放链接指针,指向下一个数据块号。
对于显式链接的工作方式,我们举个例子,文件 A 依次使用了磁盘块 4、7、2、10 和 12 ,文件 B 依次使用了磁盘块 6、3、11 和 14 。利用下图中的表,可以从第 4 块开始,顺着链走到最后,找到文件 A 的全部磁盘块。同样,从第 6 块开始,顺着链走到最后,也能够找出文件 B 的全部磁盘块。最后,这两个链都以一个不属于有效磁盘编号的特殊标记(如 -1 )结束。内存中的这样一个表格称为文件分配表(*File Allocation Table,FAT*)。
由于查找记录的过程是在内存中进行的,因而不仅显著地提高了检索速度,而且大大减少了访问磁盘的次数。但也正是整个表都存放在内存中的关系,它的主要的缺点是不适用于大磁盘。
比如,对于 200GB 的磁盘和 1KB 大小的块,这张表需要有 2 亿项,每一项对应于这 2 亿个磁盘块中的一个块,每项如果需要 4 个字节,那这张表要占用 800MB 内存,很显然 FAT 方案对于大磁盘而言不太合适。
接下来,我们来看看索引的方式。
链表的方式解决了连续分配的磁盘碎片和文件动态扩展的问题,但是不能有效支持直接访问(FAT除外),索引的方式可以解决这个问题。
索引的实现是为每个文件创建一个「索引数据块」,里面存放的是指向文件数据块的指针列表,说白了就像书的目录一样,要找哪个章节的内容,看目录查就可以。
另外,文件头需要包含指向「索引数据块」的指针,这样就可以通过文件头知道索引数据块的位置,再通过索引数据块里的索引信息找到对应的数据块。
创建文件时,索引块的所有指针都设为空。当首次写入第 i 块时,先从空闲空间中取得一个块,再将其地址写到索引块的第 i 个条目。
索引的方式优点在于:
- 文件的创建、增大、缩小很方便;
- 不会有碎片的问题;
- 支持顺序读写和随机读写;
由于索引数据也是存放在磁盘块的,如果文件很小,明明只需一块就可以存放的下,但还是需要额外分配一块来存放索引数据,所以缺陷之一就是存储索引带来的开销。
如果文件很大,大到一个索引数据块放不下索引信息,这时又要如何处理大文件的存放呢?我们可以通过组合的方式,来处理大文件的存。
先来看看链表 + 索引
的组合,这种组合称为「链式索引块」,它的实现方式是在索引数据块留出一个存放下一个索引数据块的指针,于是当一个索引数据块的索引信息用完了,就可以通过指针的方式,找到下一个索引数据块的信息。那这种方式也会出现前面提到的链表方式的问题,万一某个指针损坏了,后面的数据也就会无法读取了。
还有另外一种组合方式是索引 + 索引的方式,这种组合称为「多级索引块」,实现方式是通过一个索引块来存放多个索引数据块,一层套一层索引,像极了俄罗斯套娃是吧。
Unix 文件的实现方式
我们先把前面提到的文件实现方式,做个比较:
那早期 Unix 文件系统是组合了前面的文件存放方式的优点,如下图:
它是根据文件的大小,存放的方式会有所变化:
- 如果存放文件所需的数据块小于 10 块,则采用直接查找的方式;
- 如果存放文件所需的数据块超过 10 块,则采用一级间接索引方式;
- 如果前面两种方式都不够存放大文件,则采用二级间接索引方式;
- 如果二级间接索引也不够存放大文件,这采用三级间接索引方式;
那么,文件头(Inode)就需要包含 13 个指针:
- 10 个指向数据块的指针;
- 第 11 个指向索引块的指针;
- 第 12 个指向二级索引块的指针;
- 第 13 个指向三级索引块的指针;
所以,这种方式能很灵活地支持小文件和大文件的存放:
- 对于小文件使用直接查找的方式可减少索引数据块的开销;
- 对于大文件则以多级索引的方式来支持,所以大文件在访问数据块时需要大量查询;
这个方案就用在了 Linux Ext 2/3 文件系统里,虽然解决大文件的存储,但是对于大文件的访问,需要大量的查询,效率比较低。
为了解决这个问题,Ext 4 做了一定的改变,具体怎么解决的,本文就不展开了。
空闲空间管理
前面说到的文件的存储是针对已经被占用的数据块组织和管理,接下来的问题是,如果我要保存一个数据块,我应该放在硬盘上的哪个位置呢?难道需要将所有的块扫描一遍,找个空的地方随便放吗?
那这种方式效率就太低了,所以针对磁盘的空闲空间也是要引入管理的机制,接下来介绍几种常见的方法:
- 空闲表法
- 空闲链表法
- 位图法
空闲表法
空闲表法就是为所有空闲空间建立一张表,表内容包括空闲区的第一个块号和该空闲区的块个数,注意,这个方式是连续分配的。如下图:
当请求分配磁盘空间时,系统依次扫描空闲表里的内容,直到找到一个合适的空闲区域为止。当用户撤销一个文件时,系统回收文件空间。这时,也需顺序扫描空闲表,寻找一个空闲表条目并将释放空间的第一个物理块号及它占用的块数填到这个条目中。
这种方法仅当有少量的空闲区时才有较好的效果。因为,如果存储空间中有着大量的小的空闲区,则空闲表变得很大,这样查询效率会很低。另外,这种分配技术适用于建立连续文件。
空闲链表法
我们也可以使用「链表」的方式来管理空闲空间,每一个空闲块里有一个指针指向下一个空闲块,这样也能很方便的找到空闲块并管理起来。如下图:
当创建文件需要一块或几块时,就从链头上依次取下一块或几块。反之,当回收空间时,把这些空闲块依次接到链头上。
这种技术只要在主存中保存一个指针,令它指向第一个空闲块。其特点是简单,但不能随机访问,工作效率低,因为每当在链上增加或移动空闲块时需要做很多 I/O 操作,同时数据块的指针消耗了一定的存储空间。
空闲表法和空闲链表法都不适合用于大型文件系统,因为这会使空闲表或空闲链表太大。
位图法
位图是利用二进制的一位来表示磁盘中一个盘块的使用情况,磁盘上所有的盘块都有一个二进制位与之对应。
当值为 0 时,表示对应的盘块空闲,值为 1 时,表示对应的盘块已分配。它形式如下:
1111110011111110001110110111111100111 ...
在 Linux 文件系统就采用了位图的方式来管理空闲空间,不仅用于数据空闲块的管理,还用于 inode 空闲块的管理,因为 inode 也是存储在磁盘的,自然也要有对其管理。
文件系统的结构
struct file_system_type {
const char *name; //文件系统类型名称,唯一标识一种文件系统
int fs_flags; //文件系统类型标志
#define FS_REQUIRES_DEV 1 //文件系统保存在外部块设备中
#define FS_BINARY_MOUNTDATA 2
#define FS_HAS_SUBTYPE 4
#define FS_USERNS_MOUNT 8 /* Can be mounted by userns root */
#define FS_RENAME_DOES_D_MOVE 32768 /* FS will handle d_move() during rename() internally. */
struct dentry *(*mount) (struct file_system_type *, int,
const char *, void *); //挂载函数
void (*kill_sb) (struct super_block *); //删去超级块实例函数,在卸载文件时调用
struct module *owner; //模块指针
struct file_system_type * next; //单链表成员,指向下一个文件系统类型实例
struct hlist_head fs_supers; //散列链表,链接已挂载相同类型文件系统的超级块实例
struct lock_class_key s_lock_key; //没有选择LOCKDEP配置选项为空结构体
struct lock_class_key s_umount_key;
struct lock_class_key s_vfs_rename_key;
struct lock_class_key s_writers_key[SB_FREEZE_LEVELS];
struct lock_class_key i_lock_key;
struct lock_class_key i_mutex_key;
struct lock_class_key i_mutex_dir_key;
};
- mount():
文件系统类型定义的挂载函数
,第一个参数为指向文件系统类型的指针
,第二个参数为挂载标记
,第三个参数为文件系统所在块设备文件名称字符串
,第四个参数为文件系统私有数据指针
。主要完成超级块super_block结构体、文件系统根目录项dentry和节点inode结构体实例的创建和初始化
- fs_supers:
散列链表头
,链接内核挂载的同类型文件系统的超级块实例
。内核可以挂载同类型的多个文件系统,例如:硬盘中有两个分区被格式化成ext2文件系统,将两个分区都挂载到内核根文件系统后,具有两个ext2文件系统的超级块实例,它们被链接到ext2文件系统类型实例的fs_supers链表中。 - next:
指向下一个file_system_type实例
,所有注册的file_system_type实例在内核中组成单链表。 - register_filesystem(struct file_system_type * fs):向内核注册文件系统类型实例,函数只是简单地将file_system_type实例添加到file_systems单链表。
- unregister_filesystem(struct file_system_type * fs):将file_system_type实例从全局单链表中移出。
- struct file_system_type get_fs_type(const char name): 由文件系统类型名称字符串查找结构体实例。
前面提到 Linux 是用位图的方式管理空闲空间,用户在创建一个新文件时,Linux 内核会通过 inode 的位图找到空闲可用的 inode,并进行分配。要存储数据时,会通过块的位图找到空闲的块,并分配,但仔细计算一下还是有问题的。
数据块的位图是放在磁盘块里的,假设是放在一个块里,一个块 4K,每位表示一个数据块,共可以表示 4 * 1024 * 8 = 2^15
个空闲块,由于 1 个数据块是 4K 大小,那么最大可以表示的空间为 2^15 * 4 * 1024 = 2^27
个 byte,也就是 128M。
也就是说按照上面的结构,如果采用「一个块的位图 + 一系列的块」,外加「一个块的 inode 的位图 + 一系列的 inode 的结构」能表示的最大空间也就 128M,这太少了,现在很多文件都比这个大。
在 Linux 文件系统,把这个结构称为一个块组,那么有 N 多的块组,就能够表示 N 大的文件。
下图给出了 Linux Ext2 整个文件系统的结构和块组的内容,文件系统都由大量块组组成,在硬盘上相继排布:
最前面的第一个块是引导块,在系统启动时用于启用引导
,接着后面就是一个一个连续的块组了,块组的内容如下:
*超级块*
,包含的是文件系统的重要信息,比如inode 总个数、块总个数、每个块组的 inode 个数、每个块组的块个数等等。
- *
块组描述符*
,包含文件系统中各个块组的状态
,比如块组中空闲块和 inode 的数目等
,每个块组都包含了文件系统中「所有块组的组描述符信息」。 数据位图和 inode 位图
, 用于表示对应的数据块或 inode 是空闲的,还是被使用中。*inode 列表
*,包含了块组中所有的 inode,inode 用于保存文件系统中与各个文件和目录相关的所有元数据。- *
数据块*
,包含文件的有用数据。
你可以会发现每个块组里有很多重复的信息,比如超级块和块组描述符表,这两个都是全局信息,而且非常的重要,这么做是有两个原因:
- 如果系统崩溃破坏了超级块或块组描述符,有关文件系统结构和内容的所有信息都会丢失。如果有冗余的副本,该信息是可能恢复的。
- 通过使文件和管理数据尽可能接近,减少了磁头寻道和旋转,这可以提高文件系统的性能。
不过,Ext2 的后续版本采用了稀疏技术。该做法是,超级块和块组描述符表不再存储到文件系统的每个块组中,而是只写入到块组 0、块组 1 和其他 ID 可以表示为 3、 5、7 的幂的块组中。
在前面,我们知道了一个普通文件是如何存储的,但还有一个特殊的文件,经常用到的目录,它是如何保存的呢?
基于 Linux 一切皆文件的设计思想,目录其实也是个文件,你甚至可以通过 vim
打开它,它也有 inode,inode 里面也是指向一些块。
和普通文件不同的是,普通文件的块里面保存的是文件数据,而目录文件的块里面保存的是目录里面一项一项的文件信息。
在目录文件的块中,最简单的保存格式就是列表,就是一项一项地将目录下的文件信息(如文件名、文件 inode、文件类型等)列在表里。
列表中每一项就代表该目录下的文件的文件名和对应的 inode,通过这个 inode,就可以找到真正的文件。
通常,第一项是「.
」,表示当前目录,第二项是「..
」,表示上一级目录,接下来就是一项一项的文件名和 inode。
如果一个目录有超级多的文件,我们要想在这个目录下找文件,按照列表一项一项的找,效率就不高了。
于是,保存目录的格式改成哈希表,对文件名进行哈希计算,把哈希值保存起来
,如果我们要查找一个目录下面的文件名,可以通过名称取哈希。如果哈希能够匹配上,就说明这个文件的信息在相应的块里面。
Linux 系统的 ext 文件系统就是采用了哈希表,来保存目录的内容,这种方法的优点是查找非常迅速,插入和删除也较简单,不过需要一些预备措施来避免哈希冲突。
目录查询是通过在磁盘上反复搜索完成,需要不断地进行 I/O 操作,开销较大。所以,为了减少 I/O 操作,把当前使用的文件目录缓存在内存,以后要使用该文件时只要在内存中操作,从而降低了磁盘操作次数,提高了文件系统的访问速度。
superblock
静态(磁盘上):superblock保存了一个文件系统的最基础的元信息,一般都保存在底层存储设备的开头
;动态(内存中,一般是xxx_super_block_info)
:挂载之后会读取文件系统的superblock并常驻内存,部分字段是动态创建时设置的。superblock的具体定义见linux/include/fs/fs.h
struct super_block {
struct list_head s_list; //将实例链接到全局双链表super_blocks
dev_t s_dev; //所在块设备(分区)设备号
unsigned char s_blocksize_bits; //数据块大小以2的底取对数
unsigned long s_blocksize; //数据块大小,字节数
loff_t s_maxbytes; //最大文件长度,字节数
struct file_system_type *s_type; //指向文件系统类型实例
const struct super_operations *s_op; //超级块操作结构指针
const struct dquot_operations *dq_op; //用户磁盘配额管理
const struct quotactl_ops *s_qcop;
const struct export_operations *s_export_op;
unsigned long s_flags;
unsigned long s_iflags; /* internal SB_I_* flags */
unsigned long s_magic; //魔数,内核为每种文件系统类型分配唯一的标识数字
struct dentry *s_root; //指向文件系统根目录项dentry实例
struct rw_semaphore s_umount;
int s_count;
atomic_t s_active;
#ifdef CONFIG_SECURITY
void *s_security;
#endif
const struct xattr_handler **s_xattr; //扩展属性处理函数
const struct fscrypt_operations *s_cop; //
struct hlist_bl_head s_anon; /* anonymous dentries for (nfs) exporting */
struct list_head s_mounts; //挂载mount结构体实例链表,一个分区可以执行多个挂载操作
struct block_device *s_bdev; //块设备(分区)对应块设备数据结构指针
struct backing_dev_info *s_bdi; //后备存储设备信息
struct mtd_info *s_mtd; //MTD设备信息
struct hlist_node s_instances; //散列表节点,链入文件系统类型散列链表,表头fs_supers
unsigned int s_quota_types; /* Bitmask of supported quota types */
struct quota_info s_dquot; /* Diskquota specific options */
struct sb_writers s_writers;
char s_id[32]; /* Informational name */
u8 s_uuid[16]; /* UUID */
void *s_fs_info; //具体文件系统私有数据指针
unsigned int s_max_links;
fmode_t s_mode;
/* Granularity of c/m/atime in ns.
Cannot be worse than a second */
u32 s_time_gran;
/*
* The next field is for VFS *only*. No filesystems have any business
* even looking at it. You had been warned.
*/
struct mutex s_vfs_rename_mutex; /* Kludge */
/*
* Filesystem subtype. If non-empty the filesystem type field
* in /proc/mounts will be "type.subtype"
*/
char *s_subtype;
/*
* Saved mount options for lazy filesystems using
* generic_show_options()
*/
char __rcu *s_options;
const struct dentry_operations *s_d_op; //赋予所有目录项dentry实例d_op成员
/*
* Saved pool identifier for cleancache (-1 means none)
*/
int cleancache_poolid;
struct shrinker s_shrink; //slab缓存收缩器,用于页面回收机制
/* Number of inodes with nlink == 0 but still referenced */
atomic_long_t s_remove_count;
/* Being remounted read-only */
int s_readonly_remount;
/* AIO completions deferred from interrupt context */
struct workqueue_struct *s_dio_done_wq; //直接读写操作工作队列
struct hlist_head s_pins; //散列链表头
/*
* Owning user namespace and default context in which to
* interpret filesystem uids, gids, quotas, device nodes,
* xattrs and security labels.
*/
struct user_namespace *s_user_ns;
/*
* Keep the lru lists last in the structure so they always sit on their
* own individual cachelines.
*/
struct list_lru s_dentry_lru ____cacheline_aligned_in_smp; //dentry实例LRU链表
struct list_lru s_inode_lru ____cacheline_aligned_in_smp; //inode实例LRU链表
struct rcu_head rcu;
struct work_struct destroy_work;
struct mutex s_sync_lock; /* sync serialisation lock */
/*
* Indicates how deep in a filesystem stack this SB is
*/
int s_stack_depth;
/* s_inode_list_lock protects s_inodes */
spinlock_t s_inode_list_lock ____cacheline_aligned_in_smp;
struct list_head s_inodes; /* all inodes */
spinlock_t s_inode_wblist_lock;
struct list_head s_inodes_wb; /* writeback inodes */
}
Index node
静态:创建文件系统时生成inode,保存在具体存储设备上,记录了文件系统的元信息;动态:VFS在内存中使用inode数据结构,来管理文件系统的文件对象,记录了文件对象的详细信息,部分字段与关联的文件对象有关,会动态创建。
Directory entry
dentry是用来记录具体的文件名与对应的inode间的对应关系的,同时可以用来实现硬链接、缓存、多级目录等树状文件系统的特性。VFS的dentry设计上就是为了实现整个文件系统树状层次结构的,每个文件系统拥有一个没有父dentry的根目录(root dentry),这个dentry会被superblock引用,用来作为进行树形结构的查找入口。
dentry没有在磁盘等底层持久化存储设备上存储,是一个动态创建的内存数据结构
,主要是为了构建出树状组织结构而设计,用来进行文件、目录的查找。dentry创建之后会被操作系统进行缓存,目的是为了提升对文件系统进行操作的性能
dentry在需要使用时动态创建,并会被缓存。每个dentry有三种状态:
- used:
与一个inode关联,正处于被VFS使用的状态,不能被损坏和丢弃
- unused:
与inode关联,但处于被缓存状态,没有被VFS使用
- negative:
没有与具体的inode关联
(相当于是一个无效的路径)
dentry由于会被动态创建,为了提升系统性能,设计了一个dentry cache进行缓存,包括三个部分:
used dentries 链表
:记录每个正在使用的dentry,将其关联的inode的i_dentry字段指向的dentry链表连接起来形成一个dentry链表的链表LRU双向环链表
:用于维护unused和negative状态的dentry对象
,从头部插入,离头部越近就是最近访问过的。当需要删除dentry时,从队列尾部删除最旧的dentryhash table和hash function
:用来快速查询一个给定的路径到dentry对象
File
文件对象是打开一个具体文件之后创建的一个内存数据结构
,与具体的进程和用户相联系。一个文件对象包括的内容就是编程语言支持设置的各种文件打开的flag、mode,文件名称、当前的偏移等,其中非常重要的一个字段就是f_op,指向了当前文件所支持的操作集合。
软链接和硬链接
有时候我们希望给某个文件取个别名,那么在 Linux 中可以通过硬链接(*Hard Link*) 和软链接(*Symbolic Link*) 的方式来实现,它们都是比较特殊的文件,但是实现方式也是不相同的。
硬链接是多个目录项中的「索引节点」指向一个文件,也就是指向同一个 inode,但是 inode 是不可能跨越文件系统的,每个文件系统都有各自的 inode 数据结构和列表,所以硬链接是不可用于跨文件系统的。由于多个目录项都是指向一个 inode,那么只有删除文件的所有硬链接以及源文件时,系统才会彻底删除该文件。
软链接相当于重新创建一个文件,这个文件有独立的 inode,但是这个文件的内容是另外一个文件的路径,所以访问软链接的时候,实际上相当于访问到了另外一个文件,所以软链接是可以跨文件系统的,甚至目标文件被删除了,链接文件还是在的,只不过指向的文件找不到了而已。
文件 I/O
文件的读写方式各有千秋,对于文件的 I/O 分类也非常多,常见的有
- 缓冲与非缓冲 I/O
- 直接与非直接 I/O
- 阻塞与非阻塞 I/O VS 同步与异步 I/O
接下来,分别对这些分类讨论讨论。
缓冲与非缓冲 I/O
文件操作的标准库是可以实现数据的缓存,那么根据「是否利用标准库缓冲」,可以把文件 I/O 分为缓冲 I/O 和非缓冲 I/O:
- 缓冲 I/O,利用的是标准库的缓存实现文件的加速访问,而标准库再通过系统调用访问文件。
- 非缓冲 I/O,直接通过系统调用访问文件,不经过标准库缓存。
这里所说的「缓冲」特指标准库内部实现的缓冲。
比方说,很多程序遇到换行时才真正输出,而换行前的内容,其实就是被标准库暂时缓存了起来,这样做的目的是,减少系统调用的次数,毕竟系统调用是有 CPU 上下文切换的开销的。
直接与非直接 I/O
我们都知道磁盘 I/O 是非常慢的,所以 Linux 内核为了减少磁盘 I/O 次数,在系统调用后,会把用户数据拷贝到内核中缓存起来,这个内核缓存空间也就是「页缓存」,只有当缓存满足某些条件的时候,才发起磁盘 I/O 的请求。
那么,根据是「否利用操作系统的缓存」,可以把文件 I/O 分为直接 I/O 与非直接 I/O:
- 直接 I/O,不会发生内核缓存和用户程序之间数据复制,而是直接经过文件系统访问磁盘。
- 非直接 I/O,读操作时,数据从内核缓存中拷贝给用户程序,写操作时,数据从用户程序拷贝给内核缓存,再由内核决定什么时候写入数据到磁盘。
如果你在使用文件操作类的系统调用函数时,指定了 O_DIRECT
标志,则表示使用直接 I/O。如果没有设置过,默认使用的是非直接 I/O。
如果用了非直接 I/O 进行写数据操作,内核什么情况下才会把缓存数据写入到磁盘?
以下几种场景会触发内核缓存的数据写入磁盘:
- 在调用
write
的最后,当发现内核缓存的数据太多的时候,内核会把数据写到磁盘上; - 用户主动调用
sync
,内核缓存会刷到磁盘上; - 当内存十分紧张,无法再分配页面时,也会把内核缓存的数据刷到磁盘上;
- 内核缓存的数据的缓存时间超过某个时间时,也会把数据刷到磁盘上;
阻塞与非阻塞 I/O VS 同步与异步 I/O
为什么把阻塞 / 非阻塞与同步与异步放一起说的呢?因为它们确实非常相似,也非常容易混淆,不过它们之间的关系还是有点微妙的。
先来看看阻塞 I/O,当用户程序执行 read
,线程会被阻塞,一直等到内核数据准备好,并把数据从内核缓冲区拷贝到应用程序的缓冲区中,当拷贝过程完成,read
才会返回。
注意,阻塞等待的是「内核数据准备好」和「数据从内核态拷贝到用户态」这两个过程。过程如下图:
知道了阻塞 I/O ,来看看非阻塞 I/O,非阻塞的 read 请求在数据未准备好的情况下立即返回,可以继续往下执行,此时应用程序不断轮询内核,直到数据准备好,内核将数据拷贝到应用程序缓冲区,read
调用才可以获取到结果。过程如下图:
注意,这里最后一次 read 调用,获取数据的过程,是一个同步的过程,是需要等待的过程。这里的同步指的是内核态的数据拷贝到用户程序的缓存区这个过程。
举个例子,访问管道或 socket 时,如果设置了 O_NONBLOCK
标志,那么就表示使用的是非阻塞 I/O 的方式访问,而不做任何设置的话,默认是阻塞 I/O。
应用程序每次轮询内核的 I/O 是否准备好,感觉有点傻乎乎,因为轮询的过程中,应用程序啥也做不了,只是在循环。
为了解决这种傻乎乎轮询方式,于是 I/O 多路复用技术就出来了,如 select、poll,它是通过 I/O 事件分发,当内核数据准备好时,再以事件通知应用程序进行操作。
这个做法大大改善了应用进程对 CPU 的利用率,在没有被通知的情况下,应用进程可以使用 CPU 做其他的事情。
下图是使用 select I/O 多路复用过程。注意,read
获取数据的过程(数据从内核态拷贝到用户态的过程),也是一个同步的过程,需要等待:
实际上,无论是阻塞 I/O、非阻塞 I/O,还是基于非阻塞 I/O 的多路复用都是同步调用。因为它们在 read 调用时,内核将数据从内核空间拷贝到应用程序空间,过程都是需要等待的,也就是说这个过程是同步的,如果内核实现的拷贝效率不高,read 调用就会在这个同步过程中等待比较长的时间。
而真正的异步 I/O 是「内核数据准备好」和「数据从内核态拷贝到用户态」这两个过程都不用等待。
当我们发起 aio_read
之后,就立即返回,内核自动将数据从内核空间拷贝到应用程序空间,这个拷贝过程同样是异步的,内核自动完成的,和前面的同步操作不一样,应用程序并不需要主动发起拷贝动作。过程如下图:
下面这张图,总结了以上几种 I/O 模型:
在前面我们知道了,I/O 是分为两个过程的:
- 数据准备的过程
- 数据从内核空间拷贝到用户进程缓冲区的过程
阻塞 I/O 会阻塞在「过程 1 」和「过程 2」,而非阻塞 I/O 和基于非阻塞 I/O 的多路复用只会阻塞在「过程 2」,所以这三个都可以认为是同步 I/O。
异步 I/O 则不同,「过程 1 」和「过程 2 」都不会阻塞。