文件系统及相关操作简介

我们知道,虚拟是操作系统的基本特征之一,虚拟具体体现在三个方面:进程(对CPU的虚拟)、地址空间(对内存的虚拟)、文件和目录(对持久存储介质的虚拟)。
再具体讲解文件系统的实现之前,我们先以centos linux系统为例,看看文件系统应该包含哪些信息,以及文件系统提供的操作。

一.文件系统基础

配置如下:
file

1.创建文件

在这里插入图片描述
文件系统及相关操作简介_第1张图片
这里简单介绍下open()和openat()系统调用:

//函数原型  函数执行成功返回文件描述符,失败返回-1.
int open(const char *path, int oflag, mode_t mode);
int openat(int fd, const char *path, int oflag,  mode_t mode );

/*参数说明:
path: 表示要打开的文件路径,可以是绝对路径也可以是相对路径;
oflags: 表示打开文件所采用的操作,用于控制可读、可写、创建、截断等
注:以下模式有且只能指定其中一个
	O_RDONLY:只读模式打开
	O_WRONLY:只写模式打开
	O_RDWR:可读可写打开
指定读写模式后还能使用一些其他控制标志,来控制打开的操作。
	O_APPEND:表示追加内容至文件末尾
	O_CREAT:表示如果指定文件不存在,则创建这个文件
	O_TRUNC:表示截断,如果文件存在,则将其长度截断为0
	O_NONBLOCK:表示将 I/O设置为非阻塞模式(nonblocking mode)
	阻塞IO与非阻塞IO以后抽空专门写篇文章讲解一下
mode: 表示设置文件访问权限的初始值,可按位与,S_IRWXU、S_IRUSR、S_IWUSR等选项,实际文件权限还受umask值影响。
总的来说,open()函数所做的事情就是将传进去的字符串的路径在内核里面转换成相应的inode节点和dentry结构体。执行这一任务的标准过程就是分析路径名并把它拆分成一个文件名序列,除了最后一个文件名以外,所有的文件名都必定是目录。
*/
//相同点
//当传给函数的路径名是绝对路径时,二者无区别.(openat()自动忽略第一个参数fd)
/*不同点
当传给函数的是相对路径时,如果openat()函数的第一个参数fd是常量AT_FDCWD时,则其后的第二个参数路径名是以当前工作目录为基址的;否则以fd指定的目录文件描述符为基址。
目录文件描述符的取得通常分为两步,先用opendir()函数获得对应的DIR结构的目录指针,再使用int dirfd(DIR*)函数将其转换成目录描述符,此时就可以作为openat()函数的第一个参数使用了。*/

注意:文件描述符是一个整数,且是每个进程私有的,在UNIX系统中可用于访问文件,你可以把它理解为一个句柄或者是指针

2.顺序读写文件


文件系统及相关操作简介_第2张图片

针对上图有几个需要注意的地方。
1.open()调用返回的文件描述符为什么是3?因为标准输入 标准输出 标准错误输出已经占用了0 1 2,这三个文件时每个进程运行时默认打开的,因此下一个文件的文件描述符只能从3开始。
2.为什么会有write调用呢?因为将文件描述符3对应的文件中的内容读取到缓冲区后,cat还需要将内容显示到屏幕上(标准输出),因此需要调用write()。

//函数原型
size_t read(int fildes,void *buf,size_t nbytes);
/*
fildes:文件描述符
buf:缓冲区
nbytes:缓冲区大小
返回值类型为size_t(unsigned int) 返回值含义为读取到的字节数,如果 read 调用返回 0,就表示没有读入任何数据。
*/
size_t write(int fildes,const void *buf,size_t nbytes);
/*
fildes:文件描述符
buf:缓冲区
nbytes:写入字节数
返回值:它返回实际写入的字节数。如果文件描述符有错或者底层的设备驱动程序对数据块长度比较敏感,该返回值可能会小于 nbytes。如果函数返回值为 0,就表示没有写入任何数据;如果返回值为 -1,则表明 write 系统调用出现了错误,错误代码保存在全局变量 errno 里。
*/

3.随机读写文件

由于lseek()操作比较麻烦,这里就不演示具体例子,来简单看一下lseek()的原型和注意事项。
所有打开的文件都有一个当前文件偏移量(current file offset),以下简称为 cfo。cfo 通常是一个非负整数,用于表明文件开始处到文件当前位置的字节数。读写操作通常开始于 cfo,并且使 cfo 增大,增量为读写的字节数。文件被打开时,cfo 会被初始化为 0,除非使用了O_APPEND 。cfo决定了文件读写时下一次读取或写入开始的位置。

//返回值:新的偏移量(成功),-1(失败)
off_t lseek(int filedes, off_t offset, int whence)
/*
filedes:文件描述符
offset:偏移量
whence:搜索的执行方式
	1. 如果 whence 是 SEEK_SET,文件偏移量将被设置为 offset。
	2. 如果 whence 是 SEEK_CUR,文件偏移量将被设置为 cfo 加上 offset,
       offset 可以为正也可以为负。
	3. 如果 whence 是 SEEK_END,文件偏移量将被设置为文件长度加上 offset,
       offset 可以为正也可以为负
以上可以看出,cfo有两种更新方式:
1.隐式更新:每次读写N个字节时,N便被加入当前cfo中。
2.使用lseek明确指出cfo的值。
*/

这里需要注意一点,lseek()与磁盘寻道操作并无直接关系,lseek()只是改变内核中的变量的值,具体在执行IO操作时,会根据磁头的位置来决定是否执行寻道操作。

4.写入操作中存在的问题

在现代操作系统中,都会在内核中存在缓冲区,当调用write()后并不是立即执行磁盘操作(这样做的好处可以查看磁盘简介文章,这里简单提及一下,包括避免执行无效操作,合理的利用调度算法等),而是在等待一段时间(几秒)后再执行IO操作,这样应用程序收到write()返回的结果后以为数据已经存入磁盘,实际上可能还没有,如果此时发生系统崩溃等不可控事件,就会导致数据丢失。而有些应用程序又对数据准确性要求极高(比如DBMS),我们可以提供一个操作强制执行写入磁盘操作。
在UNIX系统中,这个接口被称为fsync(int fd),操作系统会将指定的文件描述符对应的文件中的脏数据都立刻写会磁盘,当写入完成后fsync才会返回。
补充

//sync系统调用:将所有修改过的缓冲区排入写队列,然后就返回了,它并不等实际的写磁盘的操作结束。所以它的返回并不能保证数据的安全性。通常会有一个update系统守护进程每隔30s调用一次sync。
int sync();
//sync系统调用:需要你在入参的位置上传递给他一个fd,然后系统调用就会对这个fd指向的文件起作用。fsync会确保一直到写磁盘操作结束才会返回,所以当你的程序使用这个函数并且它成功返回时,就说明数据肯定已经安全的落盘了。所以fsync适合数据库这种程序。
int fsync(int filedes);
//fdatasync系统调用:和fsync类似但是它只会影响文件的一部分,因为除了文件中的数据之外,fsync还会同步文件的属性(元数据)。
int fdatasync(int filedes);

5.文件重命名

文件系统及相关操作简介_第3张图片
函数原型

 //函数的返回值:若成功,返回0;若出错,返回-1.
 #include 
 /*
如果oldpath是一个文件而不是目录,那么为该文件更名。如果newpath已存在,而且是一个目录,则出错,如果不是目录,则先将该目录项删除,然后将oldpath更名为newpath
如果oldpath是一个目录,那么为该目录更名。如果newpath已存在,则它必须引用一个目录,而且该目录应当是空目录,此时,内核先将其删除,然后将oldpath更名为newpath。另外,当为一个目录更名时,newpath不能包含oldpath作为其路径前缀
作为一个特例,如果oldpath和newpath引用同一文件,则函数不做任何更改而成功返回。
 */
 int rename(const char *oldpath, const char *newpath);
 
 
 #include            /* Definition of AT_* constants */
 #include 
 /*
 如果oldpath中给出的路径名是相对的,那么它是相对于文件引用的目录进行解释的,描述符olddirfd(而不是像rename()那样相对于调用进程的当前工作目录。
对于相对路径名
如果oldpath是相对的,而olddirfd是特殊值AT_FDCWD,则oldpath被解释为相对于当前路径调用进程的工作目录(如rename())。
如果oldpath是绝对值,则olddirfd被忽略。
 */
 int renameat(int olddirfd, const char *oldpath,int newdirfd, const char *newpath);
 
 
 /*
 Renameat2()有一个额外的flags参数。 带有零标志参数的renameat2()调用相当于renameat()。
flags参数是由0个或多个以下标志组成的位掩码:
1.RENAME_EXCHANGE
2.RENAME_NOREPLACE
3.RENAME_WHITEOUT (since Linux 3.18)
*/
 int renameat2(int olddirfd, const char *oldpath,int newdirfd, const char *newpath, unsigned int flags);

rename通常是一个原子操作,它有一个有趣的用途,假如你正在使用文件编辑器编辑foo文件,将一行新的文字插入文件中间,编辑器的实现逻辑如下:

int fd = open("foo.txt.tmp",O_WRONLY|O_CREATE|O_TRUNC);
write(fd,buffer,size);
fsync(fd);
close(fd);
rename("foo.txt.tmp","foo.txt");

当应用程序接收到fsync的返回结果后,就会将临时文件命名为原有文件的名称。而rename()保证了操作的原子性。

6.获取文件信息

有时我们需要查看文件系统中存储的文件的信息,比如文件有多大,文件名称,修改日期等,这也称为文件的元数据。
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-ysRWB9AK-1664533195945)(https://zk1023.com/wp-content/uploads/2022/03/image-1646908152113.png)]
Size:文件大小
Blocks:物理块大小 一个物理块512B
IO Block:逻辑块大小 这里是4096B 4096/512 = 8 所以是8个磁盘块
Inode:inode号
Links:硬链接数
Access:访问权限
Access time(atime):表示我们最后一次访问(仅仅是访问,没有改动)文件的时间。读一次这个文件的内容,这个时间就会更新,比如对这个文件运用 grep、sed、more、cat 、less、tail、head等命令,ls、stat命令都不会修改文件的访问时间。
Modify time(mtime):表示我们最后一次修改文件的时间。比如:vim后保存文件。ls -l列出的时间就是这个时间。
Change time(ctime):表示我们最后一次对文件属性改变的时间,包括权限,大小,属性等等。如使用chmod,chown,mv,ln,就会改变文件的Change time。

stat在linux中被定义为一个结构体

struct stat {
               dev_t     st_dev;         /* ID of device containing file */
               ino_t     st_ino;         /* Inode number */
               mode_t    st_mode;        /* File type and mode */
               nlink_t   st_nlink;       /* Number of hard links */
               uid_t     st_uid;         /* User ID of owner */
               gid_t     st_gid;         /* Group ID of owner */
               dev_t     st_rdev;        /* Device ID (if special file) */
               off_t     st_size;        /* Total size, in bytes */
               blksize_t st_blksize;     /* Block size for filesystem I/O */
               blkcnt_t  st_blocks;      /* Number of 512B blocks allocated */

               /* Since Linux 2.6, the kernel supports nanosecond
                  precision for the following timestamp fields.
                  For the details before Linux 2.6, see NOTES. */

               struct timespec st_atim;  /* Time of last access */
               struct timespec st_mtim;  /* Time of last modification */
               struct timespec st_ctim;  /* Time of last status change */

           #define st_atime st_atim.tv_sec      /* Backward compatibility */
           #define st_mtime st_mtim.tv_sec
           #define st_ctime st_ctim.tv_sec
           };

7.删除文件

删除文件的过程其实是有点玄妙的,我们先来看一下它使用了什么系统调用。
文件系统及相关操作简介_第4张图片
为什么是这个系统调用,而不是remove或delete呢?要回答这个问题,我们需要先了解目录。

//unlink 只可以删除文件。unlinkat 可以删除文件(默认)或文件夹(需要设置flags为AT_REMOVEDIR)。
int unlink(const char *pathname);
int unlinkat(int dirfd, const char *pathname, int flags);
/*
unlink()从文件系统中删除一个名字。如果该名称是文件的最后一个链接,并且没有进程打开该文件,则该文件将被删除,并且它所使用的空间可供重用。
如果名称是文件的最后一个链接,但是其他进程仍然打开该文件,则该文件将一直存在,直到引用它的最后一个文件描述符被关闭。
如果名称引用一个符号链接,链接将被删除。
如果名称引用套接字,FIFO或设备,则名称将被删除,但是打开该对象的进程可能会继续使用该名称。
成功执行时,返回0。失败返回-1,errno被设为某个值
*/

8.创建目录

前面说过操作系统对存储介质的抽象主要体现在目录和文件两方面,因此操作系统也提供了相关的系统调用来创建、读取和删除目录。**但是目录不能直接进行编辑,因为目录的格式被视为文件系统的元数据。**只能间接的更新目录,比如在目录中创建文件、新目录等。
文件系统及相关操作简介_第5张图片

file
文件系统及相关操作简介_第6张图片

9.读取目录

读取目录很简单,之前我们已经操作过了,就是ls命令。

10.删除目录

file

11.硬链接和软连接

文件系统及相关操作简介_第7张图片

#include 
//将newpath链接到oldpath,成功返回0,失败返回-1,并且会设置errno。
int link(const char *oldpath, const char *newpath);
#include            /* Definition of AT_* constants */
#include 
int linkat(int olddirfd, const char *oldpath,int newdirfd, const char *newpath, int flags);
/*
linkat()系统调用的操作方式与link()基本相同。
如果oldpath中给出的路径名是相对的,那么它是相对于文件描述符olddirfd引用的目录进行解释的(而不是像link()那样相对于调用进程的当前工作目录相对路径名)。
如果oldpath是相对的,而olddirfd是特殊值AT_FDCWD,则oldpath被解释为相对于当前路径,即调用进程的工作目录(同link())。
如果oldpath是绝对值,则olddirfd被忽略。
newdirfd与newpath与上述相同。
flags取值可以为: AT_EMPTY_PATH AT_SYMLINK_FOLLOW 或0(2.6.18内核以前)
*/

到这里我们就可以解释删除文件为什么是用unlink()系统调用了,创建一个文件时,OS实际做了两件事。
1.首先创建一个inode结构,它包含了文件几乎所有的元数据信息(大小,磁盘块数,在磁盘上的位置等等)。
2.将文件的可读名称链接到该结构,并将这个(文件名,inode)条目放入目录中。
结合上述操作,你应该明白file和file2没有任何区别,他们都是将文件名与文件底层的inode结构向链接,因此它们具有相同的inode号。所以在删除文件时,调用unlink调用,减少inode中的硬链接数,只有当链接数达到0时,才会真正逻辑上“删除”该文件。

硬链接的优点:
使用硬连接(hard link)设置连接文件,磁盘的空间与inode的数目是不会变的,它只是在某个目录下的block多写入一个关联数据而已,既不会耗用inode也不会耗用block数量。

**硬链接存在一些固有的缺点:

  1. 不允许给目录创建硬链接。(不太理解,应该是硬性规定,ls命令可以看到目录是有硬链接的,ln -d貌似也可以创建目录硬链接。有的资料说是为了防止在目录树中创建一个环。)
  2. 不可以在不同文件系统的文件间建立链接。因为 inode 是这个文件在当前分区中的索引值,是相对于这个分区的,当然不能跨越文件系统了。**
    说完硬链接后,我们再来看另外一种链接—符号链接(软连接)
    文件系统及相关操作简介_第8张图片
    这种情况也称为软连接的“悬空引用”。
    软连接的一些注意事项:
    软链接有自己的文件属性及权限等;
    可对不存在的文件或目录创建软链接;
    软链接可交叉文件系统;
    软链接可对文件或目录创建;
    创建软链接时,链接计数 i_nlink 不会增加;
    删除软链接并不影响被指向的文件,但若被指向的原文件被删除,则相关软连接被称为死链接(即 dangling link,若被指向路径文件被重新创建,死链接可恢复为正常的软链接)

    此外需要注意不管是硬链接还是软链接都是文件共享的一种方式而已.

12.格式化和挂载

格式化
低级格式化:就是把为磁盘建立磁道和扇区,将磁盘划分成一个个小区域并编号,供电脑存储、读取数据。没有这个工作,电脑就不知在哪里写、从哪里读。这个格式化动作是在硬盘分区和高级格式化之前进行的,而且是由硬盘生产商完成的,用户不必关心。可以理解为给每个存储区域一个唯一的ID,便于查找。
高级格式化:又称逻辑格式化,它是指根据用户选定的文件系统(如FAT12、FAT16、FAT32、NTFS、EXT2、EXT3等),在磁盘的特定区域写入特定数据。
以FAT文件系统简单介绍高级格式化:
主要是清除硬盘上的数据、生成引导区信息、初始化 FAT表、标注逻辑坏道等,一般重装系统时都会进行高级格式化。根据作用的不同,高级格式化又可分为完全格式化和快速格式化两种。
完全格式化:执行这种格式化操作,格式化程序会在当前分区的文件分配表中将分区的每个扇区标记为可用,并对硬盘进行扫描,以检测是否有坏扇区。由于需要对坏道进行检查,所以通常花费的时间较长。
快速格式化:与完全格式化相比,快速格式化并没有真正地抹去硬盘中的数据,只是在文件分配表中做删除标记,不会对磁盘的坏道进行检查,其格式速度非常快。只有在硬盘以前曾被格式化过并且在能确保硬盘没有损坏的情况下,才可以使用快速格式化。
举一个不太严谨的比喻,完全格式化和快速格式化就类似于惰性的思想。同时多说两句,惰性思想很有用,COW(写时复制)、写缓冲、以及动态链接等都有这种思想的运用。
挂载
挂载原理不太理解,有兴趣的朋友可以参考https://zhuanlan.zhihu.com/p/67686817 里面的文章。

二.文件系统的实现(实现时采用的算法以及数据结构)之VSFS

VSFS(Very Simple File System):他是典型的UNIX文件系统的简化版本,虽然真正的文件系统实现要远比VSFS麻烦,但是很多基础概念却是相通的,我们通过VSFS来理解这些概念。
在介绍VSFS之前,请你先思考一下应该如何实现一个文件系统?我们说过文件和目录是对存储设备的抽象,那应该在磁盘上用什么结构来表达这种抽象?需要记录什么信息?如何来访问这些信息?
文件系统的构建主要体现在两个方面:
1.文件系统在磁盘上使用什么类型的结构来组织其数据和元数据?
2.如何访问这些信息?比如怎么样将open()、read()、wrie()这些调用映射到文件系统的结构上?
此外还有一个性能方面的因素需要考虑:这些结构和访问方法对文件系统的效率有什么影响?

VSFS存取的基本单位是块,一个块的典型大小为4KB(连续的)。为什么要以块为单位来进行存取呢?个人看法:
1.如果以扇区作为交互单位,则存取文件时可能要跨越多个扇区,从而导致寻道时间和旋转延迟占比增加,影响传输效率。
2.如果块太大,可能导致一个文件只能使用块的一部分,内部碎片变大,空间利用率变小。
因此以块大小为4KB来进行存取是权衡空间存储效率和文件读写速度等诸多因素后的结果。
假设我们有一个很小的磁盘,它仅包含64个块,这64个磁盘块在逻辑上排列如下:
文件系统及相关操作简介_第9张图片
此时我们准备在这块磁盘上建立一个简单的文件系统,那么他需要包含什么内容?首先想到的应该是数据,实际上磁盘的大部分空间都是用来保存用户数据的,我们将用于存放用户数据的区域叫做数据区域(data region),为了便于描述,我们将磁盘上后56个块作为数据区域,如下蓝色区域表示数据区域:
文件系统及相关操作简介_第10张图片
前面我们介绍过使用stat可以查看文件的元数据信息,记录了文件包含哪些数据块(数据区域中)、文件的大小、访问权限、修改时间、访问时间等信息,这也需要一个结构来保存,通常我们将这个结构成为inode,因此我们也需要一部分区域来存放inode,我们假设用5个磁盘块来保存inode,如下红色表示inode区域:
文件系统及相关操作简介_第11张图片
通常inode只有128或256个字节,假设一个inode256字节,那么4KB的块可以保存16个inode,则5个磁盘块能保存80个inode,这个数字表示文件系统可以用于的最大的文件数量(实际要结合磁盘容量)。
到目前为止我们只考虑了inode和数据块,但有一个很明显的问题,怎么样知道inode和数据块是否分配?如果分配属于同一个文件的磁盘块怎么组织?如果未分配则下次分配时怎么快速高效的分配符合要求的空闲区域?接下来我们来回答这些问题。

空闲空间管理

怎样知道inode和数据块是否分配?以及如果未分配则下次分配时怎么快速高效的分配符合要求的空闲区域?
答案是在磁盘上添加一种结构,用来记录数据块是空闲还是已分配,这个结构有很多种,下面介绍几种常用的机构。

1.空闲表法

空闲表法属于连续分配方式,它与内存的动态分配十分相似,为每个文件分配一块连续的空间。即系统为外存上的所有空闲区建立一张空闲表,每个空闲区对应一个空闲表项,其中包括表项序号、该空闲区的第一个盘快号、该区的空闲盘块数等信息,再将空闲分区按一定规则组织在空闲表中,下面以起始盘快号递增的次序为例:
文件系统及相关操作简介_第12张图片
采用这种方法时,盘块的分配与内存的动态分配类似,同样可以采用首次适应、最佳适应、最坏适应等算法。同样存在外部碎片,需要进行空间整理。这种分配方式的好处在于具有较高的分配速度,可以减少访问磁盘的IO频率,常用于对换空间的分配方式。用于文件系统时,只适用于文件较小的情况(1~4个盘块),此外多媒体文件可能也会采用连续分配的方式。

2.空闲链表法

这种方法思想很简单,就是将所有空闲盘块拉成一条空闲链,根据构成链所用基本元素不同,可分为:空闲盘块链、空闲盘区链。
空闲盘块链:就是以盘块为单位拉成一条链。这种做法分配时只需要丛链表头开始寻找足够的盘块即可,回收时挂在链表尾部即可,但是分配时可能要多次执行IO操作,分配回收效率低。
空闲盘区链:与上面类似,只不过是以盘区为单位(每个盘区包含几个盘块),此外每个盘区还需要额外存储表示本盘区大小的信息。同时可以使用显示链接法,在内存中保留一张表,因此它的分配与回收方式可参考内存的动态分配。这种方式分配回收时较为麻烦,但是执行IO操作少效率较高,每次都为文件分配几个连续的盘块。同时还存在一个致命的缺点,如果空闲盘块不连续,这种方法会退化称为空闲盘块链。

3.位示图法

位示图用二进制的一位来表示磁盘中一个盘块的使用情况,当值为“0”时,表示对应盘块空闲,为“1”时表示已分配。通常可用m×n个位数来构成位示图,并使m×n等于磁盘的总块数,结构如下图
文件系统及相关操作简介_第13张图片
盘块分配:
根据位示图分配盘块时分为三步:
(1) 顺序扫描位示图,从中找出一个或一组值为“0”的二进制位
(2) 将所找到的一个或一组二进制位转换为与之相应的盘块号。假定找到“0”位于位示图的第i行、第j列,则盘块号转换公式如下:
b = n(i - 1) + j 其中n表示每行的位数。
(3) 修改位示图 使得map[i,j] = 1
盘块回收
盘块回收分为两步
(1) 将回收盘块的盘块号转换为位示图中的行号和列号,如下:
i = (b - 1) / n + 1
j = (b - 1) % n + 1
(2) 修改位示图 令map[i,j] = 0
这种方式的优点是,很容易找到一个或一组空闲的盘块,此外在小型文件系统中位示图占用空间较小,可以将其保存在内存中,后续分配时无需再从磁盘中读入。常用于微型机和小型机。缺点在于增加了计算负担,而且如果空闲磁盘块很少时搜索空闲块需要花费一些时间,同时大型文件系统中位示图可能很大。
比如一个4KB的块能表示32K的对象是否分配,如果存储空间是以G T为单位,可以看到位示图将会很大。

4.成组链接法

下述文字均来自《计算机操作系统》第四版 汤子瀛等著。
文件系统及相关操作简介_第14张图片
1、空闲盘块的组织
(1)空闲盘块号栈:用来存放当前可用的一组空闲盘块的盘块号(最多含100 个号),以及栈中尚有的空闲盘块号数N。顺便指出,N 还兼作栈顶指针用。例如,当N=100 时,它指向S.free(99)。由于栈是临界资源,每次只允许一个进程去访问,故系统为栈设置了一把锁。(只有这个是放在内存中的,其它是在磁盘上。)
(2) 文件区中的所有空闲盘块被分成若干个组,比如,将每100 个盘块作为一组。假定盘上共有10 000 个盘块,每块大小为1 KB,其中第201~7999 号盘块用于存放文件,即作为文件区,这样,该区的最末一组盘块号应为7901~7999;次末组为7801~7900……;第二组的盘块号为301~400;第一组为201~300,如上图右部所示。
(3) 将每一组含有的盘块总数N 和该组所有的盘块号记入其前一组的第一个盘块的
S.free(0)~S.free(99)中。这样,由各组的第一个盘块可链成一条链。
(4) 将第一组的盘块总数和所有的盘块号记入空闲盘块号栈中,作为当前可供分配的空闲盘块号。
(5) 最末一组只有99 个盘块,其盘块号分别记入其前一组的S.free(1) ~S.free(99)中,而在S.free(0)中则存放“0”,作为空闲盘块链的结束标志。(注:最后一组的盘块数应为99,不应是100,因为这是指可供使用的空闲盘块,其编号应为(1~99),0号中放空闲盘块链的结尾标志。)
2、空闲盘块的分配与回收
当系统要为用户分配文件所需的盘块时,须调用盘块分配过程来完成。该过程首先检查空闲盘块号栈是否上锁,如未上锁,便从栈顶取出一空闲盘块号,将与之对应的盘块分配给用户,然后将栈顶指针下移一格。若该盘块号已是栈底,即S.free(0),这是当前栈中最后一个可分配的盘块号。由于在该盘块号所对应的盘块中记有下一组可用的盘块号,因此,须调用磁盘读过程,将栈底盘块号所对应盘块的内容读入栈中,作为新的盘块号栈的内容,并把原栈底对应的盘块分配出去(其中的有用数据已读入栈中)。然后,再分配一相应的缓冲区(作为该盘块的缓冲区)。最后,把栈中的空闲盘块数减1 并返回。
在系统回收空闲盘块时,须调用盘块回收过程进行回收。它是将回收盘块的盘块号记入空闲盘块号栈的顶部,并执行空闲盘块数加1 操作。当栈中空闲盘块号数目已达100 时,表示栈已满,便将现有栈中的100个盘块号记入新回收的盘块中,再将其盘块号作为新栈底。
可参考视频:https://www.bilibili.com/video/av71840093/ 进行理解。
顺便说一下,成组链接法是结合了空闲表法和空闲链表法的产物,组间成链,组内成表(可连续分配)。同时占用内存空间也不大。
注意看位示图那里的磁盘分配图,还有一块没有使用,我们将它留个超级块,超级块包含特定文件系统的信息,比如文件系统的名称、包含多少个inode和数据块、inode表的起始位置等。最后的分配结果如下:
灰色为超级块,黄色为inode位图,绿色为数据位图,红色为inode块,蓝色为数据块
文件系统及相关操作简介_第15张图片

文件组织方式

如果分配属于同一个文件的磁盘块怎么组织?
要回答这个问题,我们先来认识一下inode这个结构到底是什么。
inode是index node的缩写,在上述我们给出的简单文件系统中inode被组织成一个数组。每一个inode结构都由一个数字隐式引用,这里为了不至于混淆我们称其为inumber。现在来看一下文件名和inumber和inode的关系,文件名—>inumber----->inode结构。给定一个inumber我们应该能够计算出它在磁盘上的相应位置。
inode中保存主要信息有:文件类型、大小、分配给它的快熟、保护信息、时间信息、有关数据块驻留在磁盘上的位置,这些信息都称为元数据信息。看一下linux中关于inode定义:

struct inode {
	struct hlist_node  i_hash;
	//在文件系统里,所有的inode都在哈希链表上,加快寻找速度。
	struct list_head  i_list;
	struct list_head  i_sb_list;
	struct list_head  i_dentry;
	//这分别是标识inode在使用中状态的链表,在superblock上记录的链表,在目录上链接的链表,当新建一个inode的时候,会将i_list加在所有的已使用的inode链表上,然后再加到超级块上,最后连接到对应的目录链表上。
	unsigned long   i_ino;/* inode的唯一标号 */
	atomic_t   i_count; /* inode引用计数 */
	unsigned int   i_nlink;/* inode的硬连接数 */
	uid_t  i_uid;/* inode的对应文件的用户id */
	gid_t  i_gid;/* inode的对应文件的用户的组id */
	dev_t  i_rdev;/* 设备标识 */
	unsigned long   i_version;/* 版本号 */
	loff_t   i_size;   /* 文件大小 */
	struct timespec  i_atime;/* 最后修改时间 */
	struct timespec  i_mtime;/* 文件内容更改时间 */
	struct timespec  i_ctime;/* 文件change time */
	unsigned int   i_blkbits; /* inode块大小位数 */
	blkcnt_t   i_blocks;/* 块数 */
	unsigned short          i_bytes;/* 已经使用的字节数 */
	umode_t   i_mode; /* 文件的打开模式 */
	spinlock_t   i_lock;  /* 自旋锁 */
	struct mutex   i_mutex;  /* 互斥量 */
	struct rw_semaphore  i_alloc_sem;  /* 读写信号量 */
	const struct inode_operations  *i_op;  /* inode的操作函数集合 */
	const struct file_operations  *i_fop;  /* 文件操作函数 */
	struct super_block  *i_sb;    /* 超级块指针 */
	struct file_lock  *i_flock;  /* 文件锁 */
	struct list_head  i_devices;  /* 连接到设备链表上 */
	__u32  i_generation; 
	unsigned long   i_state;    /* 文件状态位 */
	unsigned long   dirtied_when;  /* 数据变脏时间 */
	unsigned int   i_flags;  /* 状态位 */
	atomic_t   i_writecount;  /* 写入计数 */
	#ifdef CONFIG_SECURITY
	void  *i_security;  /* 如果定义了这个宏,就会有一个专门用作安全作用的指针 */
	#endif
	void  *i_private; /* 私有数据 */
};

设计inode时一个十分重要的因素是它如何组织文件引用的数据块,即同一个文件的磁盘块怎么组织
同样的我们介绍3中比较有代表性的组织方式。

1.连续组织方式

这种方式为每个文件分配一片连续的磁盘空间,因此在逻辑上上连续的文件在物理上也是连续的,这种文件结构也称为顺序文件结构,此时的物理文件也称为顺序文件。此时只需要在对应的inode结点中保存起始磁盘块的位置以及文件所占磁盘块的个数即可。
逻辑视图如下:
文件系统及相关操作简介_第16张图片
优点
1.顺序访问容易,只需要找到inode中第一个盘块的位置,从这个盘块读下去即可。同时也支持对定长记录文件(文件的逻辑结构的一种)进行随机存取。
2.顺序访问速度快,由于文件位于连续的几个磁道,定位与旋转延迟时间较少,大部分时间用于传输数据,对磁盘访问是顺序的,因此速度远远大于随机读写磁盘。
缺点
1.为文件分配连续存储空间,参考内存的动态分配,会产生外部碎片,降低空间利用率,必要使用需要进行紧凑,浪费大量时间。
2.必须事先知道文件的长度,如果无法准确知道只能事先估算,但是如果估算过小,需要重新分配(这个过程由数据的拷贝),。如果估算过大,浪费存储空间。
3.不能灵活的删除和插入记录,为了保持有序性在删除和插入记录时需要对文件物理位置进行移动,消耗过多磁盘IO。
4.对于动态增长的文件很难知道应该分配多少空间。
由于这种组织方式存在很多缺点,因此在实际实现时基本不采用。

2.链接组织方式

引起上述顺序文件的缺点的一个最重要的原因的就是必须要求文件在物理上连续,因此只要去掉这个限制,就能克服连续组织方式的缺点。很容易想到使用链表的方式将文件的各个盘块链接起来,由此形成的物理文件称为链接文件,链接又分为隐式链接和显示链接。
隐式链接
这种方式是将在每个盘块中保存下一个盘块的指针,而不是一次性给出各个盘块的物理块号。
具体来说,可以在inode结构中给出文件的第一个盘块号和最后一个盘块号(便于回收和分配),在后续盘块号中都保留其下一个盘块号的指针,最后一个盘块的指针域置位NULL或-1等来标志结尾。
逻辑视图如下:
文件系统及相关操作简介_第17张图片
缺点
1.只适用于顺序访问,随机访问效率极其低下,如要访问文件的第i个盘块,必须先读出第一个盘块…一直到第i个盘块,IO量极大。
2.不可靠,链中任何一个盘块出错都可能导致整个链断开。
3.存在内存碎片,内存碎片只存在于最后一个盘块中,因此不是太重要。
改进思路:类似于空闲盘区链,我们可以将几个连续盘块逻辑上放在一起,称为块或簇,进行分配是每个节点代表一个簇。这样减少了IO量同时也减少了指针域所占的存储空间,但是却增大了内存碎片。
显式链接
在隐式链接中对于随机访问效率极其低下,原因在于指针是存放在每一个块中,因此必须顺序读取每一个块才能读取后续块的指针。因此为了改善这一点我们可以将指针域单独拿出来组成一张索引表(类似于静态链表)。
文件系统及相关操作简介_第18张图片
请注意,在真正使用FAT的文件系统(如windows)并不存在inode概念,它是将文件的元数据信息保存在目录项中,这也导致了它无法实现硬链接。
使用FAT后我们在进行访问时,可以将FAT表读入内存,查找FAT表所形成的静态链表即可快速定位其在磁盘上的位置。(注意一点,对于随机访问我们知道了偏移量,很容易计算它应该位于第几个块中。)
在微软较早的MS-DOS、Window95、Window98中采用了FAT12 16 32等技术。在Windows NT/2000/XP以后则采用了NTFS(New Technology File System)文件系统。
接下来我们分析一下FAT技术的优缺点以及为什么微软后续不再采用这种技术。
在FAT中引入了“卷”的概念,支持将一个物理磁盘分为4个逻辑磁盘,每个逻辑磁盘就是一个卷(也称分区)。经过分区之后的每个卷都是一个可以单独格式化和使用的逻辑单元(分区的好处有很多,比如数据保护,减少读取数据是搜索范围等)。每个卷都有单独的区域存放自己的目录和FAT表,此外FAT表可以存放两份互为备份。FAT32结构详解见https://blog.csdn.net/li_wen01/article/details/79929730 。
注:在现代OS中一个物理磁盘可以划分为多个卷,一个卷也可以有多个物理磁盘组成。
在FAT12中每个FAT表项占12位,对于一个1.2MB的软盘,每个盘块大小是512B,那么FAT中共有2.4K个表项,因此FTA占用3.6KB的存储空间。我们来计算一下FAT12允许的最大磁盘容量:每个FAT12的表项占12位,即编制212 = 4096个表项,如果以扇区作为基本分配单位,那么每个磁盘分区的容量为2MB(4096 * 512B),若一个物理磁盘支持4个磁盘分区,则磁盘最大容量为8MB。这对于早期硬盘尚可应付,但是当磁盘容量超过8MB怎么办?
1.改变基本的分配单位,不是以扇区作为基本分配单位,而是以簇(多个连续扇区)为基本分配单位。
2.增加FAT的位宽,比如从12变为16 32。

对于第一种解决方法,我们引入簇的概念,如果每个簇(通常是2N个盘块)的容量为扇区的N倍,那么磁盘容量便可以增加N倍。以簇为单位的好处是,能适应磁盘容量不断增大的情况,还可以减少FAT表中的项数,使FAT表占用更少的存储空间以及访问时存取开销,但是簇过大会导致内存碎片过大。此外FAT12只能支持短文件名,即8+3格式的文件名。
对于第二种解决方案我们引入了FAT16,此时最大表项增加到216 = 65536个,磁盘容量增大到65536 * 512 * 4 = 128MB,如果再结合簇则能拥有更大的容量,但是容量到达GB以上时,FAT16簇很大从而导致内存碎片也很大。比如,当要求磁盘分区大小为8GB时,每个簇的大小达到128KB,即内部碎片可无限接近于128KB,对于1GB4GB的硬盘来说,大约会浪费10%20%的空间,为了改善这个问题,引入了FAT32.
FAT32是FAT系列最后一个产品,每个簇在FAT表的表项中占据4个字节,每个簇的大小固定位4KB,支持以簇和扇区两种方式管理磁盘,数据区使用簇。FAT32的单个磁盘空间可以达到4KB * 232 = 2TB。同时FAT32可以支持更小的簇,提高了存储器的空间利用率。同时FAT32支持长文件名。但是FAT32仍然存在明显的缺点:
1.文件分配表的增大,运行速度要变慢。
2.FAT32有最小管理空间限制,FAT32卷必须至少有65537个簇,因此FAT32不支持容量小于512MB的分区,对于小分区仍然采用FAT12 FAT16
3.FAT32单个文件长度不能大于4GB
4.FAT32不支持向下兼容。
基于以上缺点微软后续文件系统采用了NTFS,它的主要优点如下:
1.使用64位磁盘地址
2.很好的支持长文件名,单个文件名限制在255个字符以内,全路径名为32767个字符
3.具有系统容错功能
4.能保证系统数据的一致性
5.提供文件加密、文件压缩等功能。
关于NTFS更多细节,请查阅相关资料。

3.索引组织方式

借助于上述FAT的思路,试想我们读取一个文件时有必要将整个FAT读入内存吗?事实上,我们只需要文件对应的盘块号即可,这样既减少了内存压力,也减少了在FAT中搜索盘块号的步骤。索引组织方式就是基于这样的思想,将每个文件对应的盘块号一起调入内存,在创建一个文件时,只需要在inode中保存所需盘块的指针即可。
单级索引
这种组织方式中,只需要将inode中放置每个盘块的直接指针即可。但是思考一个很简单的问题,大文件占据的盘块很多,需要存储多个直接指针,必然存在inode存不下完整的指针域的情况!
多级索引
一个很简单的思路是旨在inode中保留一个指针,指针指向一个盘块,该盘块保存各个指针,如果一个盘块仍然放不下时可以采用相同的思路。这样做的好处是支持了大文件的存储,但是对于小文件而言却必须要用一个完整的盘块,这是很浪费的!
增量式索引
可以结合单击索引和多级索引的有点,在inode中保存几个(12个为例)单级索引,再保留二级索引,对于特别大的文件再保留三级索引。假设一个块是4KB,磁盘地址是4字节,一个二级索引增加了1024个指针,文件可以增长到(12 + 1024)*4KB = 4144KB。如果是三级索引,则(12 + 1024 +10242) * 4KB = 4198448KB > 4GB。
如你所见,索引方式可以很好的支持大文件、小文件,并且不会占用太多内存和多次磁盘IO,因此很多操作系统都采用了这种方式。比如Linux的ext2、ext3、NetApp的WAFL。
这种方式也有很明显的缺点,每个文件需要保留大量元数据(指针),特别是大文件,同样我们可对其进行改进。
使用基于范围的表示,即在inode中保留多个指针和范围,每个指针给出该范围的起始地址。但是这种方式可能再分配时无法找到连续的空间而导致失败,一种方案是范围可变(支持多种范围),从而在文件分配期间给予文件系统更多的自由。 这两种方式并无明显优劣,基于指针的方案更加灵活,但浪费空间,基于范围的方案,节省空间,文件分配紧凑,但不够灵活。使用基于范围的分配方式的文件系统比如Linux ext4、SGI XFS。
下面给出一些测量数据,能够更好的支持使用增量式索引。
文件系统及相关操作简介_第19张图片

目录组织

在VSFS中目录的组织很简单,一个目录基本只包含一个二元组(目录项名称,inode号)组成的列表,对于名称大小可变的文件系统,可以额外添加目录项总长度和名称长度的记录,即(inode号,记录总长度,名称长度,名称)。删除一个文件会在目录中留下空白,可以保留一个inode号(比如0)来表示该目录项已删除,后续新的文件可以重新使用这个目录项。
为了便于管理,我们可以将目录也作为一种特殊的文件,因此目录也对应一个inode(可以将inode中的某一块区域划分给目录使用,同时标记文件类型为目录),目录也有由inode指向的数据块,这样的结构使得我们之前对磁盘结构的划分保持不变。
同时我们应当明白,上述才用的逻辑上的线性结构的目录组织形式并不是唯一的方法,像XFS等采用B树形式存储目录,使得文件操作快于简单列表的组织形式。

访问路径

我们已经介绍了VSFS中的基本结构,那么接下来我们来看看在我们调用文件系统提供的接口操作文件时这些结构是如何被调用的,为什么要关注这些呢?因为这些结构的每一次调用,可能都是一次磁盘IO!
我们假设文件系统已经挂载,超级快已经在内存中,其它的所有内容仍在磁盘上。
读取文件
现在假设我们要打开文件(/foo/bar),假设文件大小只有4KB,首先需要发出一个open(“/foo/bar”,O_RDONLY)系统调用,最终的目的是为了找到bar的inode块,从而获取该文件的一些信息。目前我们只有超级快的信息,没有其它的任何信息,因此我们必须遍历目录树,找到所需的inode。这里我们从根目录(/)开始,要读取根目录的inode块,我们必须知道inumber号,这个根目录的inumber号是多少?在查找子目录时我们可以根据其父目录的目录项来找到inumber,但是问题在于/目录没有父目录了,因此我们必须显式给定/目录的inode块在什么位置(可以在超级块中告知),但是通常都约定根目录的inode号为2,因此该过程开始的时候文件系统会读入inode为2的块。
接下来文件系统可以在该inode中查找其数据块指向的指针,找到foo的条目,条目中包含foo的inode块所在位置。接下来递归遍历路径名,知道找到需要的inode,open()的最后一步是将bar的inode读入内存,然后进行权限检查,接着在每个进程的文件打开表中,为该进程分配一个指向该文件的文件描述符,并将它返回给用户。
最后程序可以调用read()进行读取,read()操作会改变文件最后访问时间,同时更新文件偏移量等等。
从这个过程给我们看出,open()导致的IO量与路径名的长度成正比。
文件读取过程的时间线如下(向下时间递增)
文件系统及相关操作简介_第20张图片
写入文件
写入文件时首先需要打开文件,其次程序会发出write()调用用以更新文件,最后关闭文件。
写入文件时可能灰分配一个新块,因此会涉及到位图和inode的变化,每次写入文件逻辑上会导致5个IO,一个读取数据位图(为了分配新的数据块),一个写入数据位图,接下来一次读取inode,一次写inode,最后一次写入真正的数据块本身。
此外如果是文件创建操作,工作量会更大,因为不仅要分配一个inode还要在包含新文件的目录中分配控件。
IO总操作为:一个读取inode位图,一个写入inode位图,一个写入新的inode本身(初始化inode),一个写入目录的数据和一个读取目录inode,如果目录需要扩容以容纳新条目,则还需要额外的IO操作(数据位图和新目录快)。
比如我们要创建/foo/bar,并向其写入一个个数据块,如图:
文件系统及相关操作简介_第21张图片

缓存和缓冲

从上面的读取写入等操作可以看出来,会产生大量IO操作,而且还是随机IO,文件系统应该怎样来降低这些IO成本呢?大多数系统使用系统内存(DRAM)来缓存数据。早期文件系统引入一个固定大小的缓存区来保存常用的块,LRU以及其其它变体可用来决定哪些块需要保存。但是这种固定的静态划分会导致浪费,比如文件系统在给定的时间段内不需要这么多缓存,但是这些缓存却不能用于其它的用途,因此浪费了资源。
现代OS更多采用动态划分的方法,具体来说现代OS将虚拟内存页面和文件系统页面集成到统一的页面缓存中,通过这种方式可以在虚拟内存和文件系统之间更灵活的分配内存。
在有缓存的文件系统中,第一次打开仍然无法避免许多IO操作,但是后续打开同一文件或目录是大部分缓存会命中,从而节省IO操作。
谈完缓存对读取的影响,我们再来谈谈缓存对写入的影响。首先要明白写入时必须要写入到磁盘,才能实现持久存储,因此高速缓存基本不能减少写入流量。但是写缓冲还是有一些优点:
1.通过延迟写入,可以将一些更新编成一批放入一组较小IO中。比如创建一个文件时inode位图被更新,稍后创建另一个文件时又被更新,则采用延迟写入可以节省一次IO。
2.通过写入缓冲,系统可以更加合理的执行调度算法来安排后续的IO操作顺序,从而提高性能。
3.写缓冲还可以导致一些写入完全避免。例如应用程序创建文件并将其删除,则通过延迟写入可以完全避免写入。
但是思考写缓冲也会导致一些问题的产生,最明显的就是不一致性。如果系统在更新传递到磁盘之前崩溃,这些更新就会丢失,因此较短的写入延迟可以降低这种不一致性造成的影响,较高的写入延迟可以提升性能。有些应用程序并不喜欢这种缓冲,比如(DBMS),为了避免写缓冲导致的意外数据丢失,他们使用fsync()来强制写入(写入的可能是日志,也可能是真是的数据,这取决于具体的实现)。
**补充
静态划分和动态划分没有优劣之分,只是应用的场景不同。如果你了解JVM就会知道TLAB的存在,这就是采用了静态划分带来的好处,提升了空间分配的效率,动态划分优势不得不考虑空间的回收,而且在并发情况下还需要考虑并发的安全性。因此不要单纯的认为动态划分就一定比静态划分好,还是那句话,这取决于具体的场景。
**
至此,我们简单介绍了文件系统的基本知识,其中用到的算法和一些设计思想,但VSFS知识最初始的文件系统,它存在许多不足,特别是它对磁盘的使用思想上的不正确导致这个文件系统并不能表现出良好的运行性能,有兴趣可以继续了解一些文件系统,看看他们如何改变VSFS的。包括:FFS LFS NFS AFS 以及Linux采用的ext2 ext3 ext4文件系统。

你可能感兴趣的:(操作系统基础知识,linux,服务器,运维)