关于操作系统:操作系统持久化文件系统

3次阅读

共计 9829 个字符,预计需要花费 25 分钟才能阅读完成。

文件和目录

概念

随着工夫的推移,无关存储虚拟化造成了两个要害的形象。第一个是文件(file)。文件就是一个线性字节数组,每个字节都能够读取或写入。每个文件都有某种低级名称,通常是某种数字,用户通常不晓得这个名字。因为历史起因,文件的低级名称通常称为 inode 号(inode number)。每个文件都有一个与其关联的 inode 号。

第二个形象是目录(directory)。一个目录,像一个文件一样,也有一个低级名字(即 inode 号),然而它的内容十分具体:它蕴含一个(用户可读名字,低级名字)对的列表。例如,假如存在一个低级别名称为“10”的文件,它的用户可读的名称为“foo”。“foo”所在的目录因而会有条目(“foo”,“10”),将用户可读名称映射到低级名称。目录中的每个条目都指向文件或其余目录。通过将目录放入其余目录中,用户能够构建任意的目录树(directory tree,或目录层次结构,directory hierarchy),在该目录树下存储所有文件和目录。

创立文件

通过调用 open()并传入 O_CREAT 标记,程序能够创立一个新文件。上面是示例代码,用于在当前工作目录中创立名为“foo”的文件。

int fd = open("foo", O_CREAT | O_WRONLY | O_TRUNC);

函数 open()承受一些不同的标记。在本例中,程序创立文件(O_CREAT),只能写入该文件,因为以(O_WRONLY)这种形式关上,并且如果该文件曾经存在,则首先将其截断为零字节大小,删除所有现有内容(O_TRUNC)。

open()的一个重要方面是它的返回值:文件描述符(file descriptor)。文件描述符只是一个整数,是每个过程公有的,在 UNIX 零碎中用于拜访文件。因而,一旦文件被关上,如果你有权限的话,就能够应用文件描述符来读取或写入文件。这样来看,一个文件描述符就是一种权限(capability),即一个不通明的句柄,它能够让你执行某些操作。另一种对待文件描述符的办法,是将它作为指向文件类型对象的指针。一旦你有这样的对象,就能够调用其余“办法”来拜访文件,如 read()和 write()。

读写文件

文件胜利关上后,就能够对文件进行读写。read()是读取文件的零碎调用,它的原型如下:

size_t read(int fildes, void *buf, size_t nbytes);

read()的第一个参数是文件描述符,一个过程能够同时关上多个文件,因而描述符使操作系统可能晓得某个特定的读取援用了哪个文件。第二个参数指向一个用于搁置 read()后果的缓冲区。第三个参数是缓冲区的大小。对 read()的胜利调用返回它读取的字节数。

零碎调用 write()的原型如下:

size_t write(int fildes, const void *buf, size_t nbytes);

它的作用是把缓冲区 buf 的前 nbytes 个字节写入与文件描述符 fildes 关联的文件中,它返回理论写入的字节数。

扭转文件偏移量

有时可能读取或写入文件中的特定偏移量是有用的。例如,如果你在文本文件上构建了索引并利用它来查找特定单词,最终可能会从文件中的某些随机偏移量中读取数据。为此,咱们能够应用 lseek()零碎调用。上面是函数原型:

off_t lseek(int fildes, off_t offset, int whence);

第一个参数是一个文件描述符。第二个参数是偏移量,它将文件偏移量定位到文件中的特定地位。第三个参数,因为历史起因而被称为 whence,指定了搜寻的执行形式。

对于每个过程所有关上的文件,操作系统都会跟踪一个“以后”偏移量,这将决定在文件中下一次读取或写入开始的地位。因而,关上文件的形象包含它以后的偏移量,偏移量的更新有两种形式。第一种是当产生 N 个字节的读或写时,N 被增加到以后偏移,因而每次读取或写入都会隐式更新偏移量。第二种是 lseek,它显式扭转下面指定的偏移量。

请留神,lseek()调用只是在 OS 内存中更改一个变量,该变量跟踪特定过程的下一个读取或写入开始的偏移量 。调用 lseek() 与挪动磁盘臂的磁盘的寻道(seek)操作无关,执行 I / O 时,依据磁头的地位,磁盘可能会也可能不会执行理论的寻道来实现申请。

同步写入

大多数状况下,当程序调用 write()时,它只是通知文件系统:在未来的某个时刻,将此数据写入长久存储。出于性能的起因,文件系统会将这些写入在内存中缓冲(buffer)一段时间。在稍后的工夫点,才会将写入理论发送到存储设备。

从应用程序的角度来看,写入仿佛很快实现,并且只有在极少数状况下(例如,在 write()调用之后但写入磁盘之前,机器解体)数据会失落。然而,有些应用程序须要的不只是这种保障。例如,在数据库管理系统(DBMS)中,常常要求可能强制写入磁盘。

为了反对这些类型的应用程序,大多数文件系统都提供了一些额定的管制 API。在 UNIX 中,提供给应用程序的接口被称为 fsync。当过程针对特定文件描述符调用 fsync()时,文件系统通过强制将所有脏数据写入磁盘来响应。

文件重命名

罕用的 Linux 命令 mv,就应用了零碎调用 rename(char old, char new),它只须要两个参数:文件的原来名称和新名称。

rename()调用提供了一个保障:它通常是一个原子(atomic)调用。如果零碎在重命名期间解体,文件将被命名为旧名称或新名称,不会呈现奇怪的中间状态。因而,对于反对某些须要对文件状态进行原子更新的应用程序,rename()十分重要。

获取文件信息

除了文件拜访之外,咱们还心愿文件系统可能保留对于它正在存储的每个文件的信息,咱们通常将这些数据称为文件元数据(metadata)。要查看特定文件的元数据,咱们能够应用 stat()或 fstat()零碎调用。

每个文件系统通常将这种类型的信息保留在一个名为 inode 的 stat 构造体中。stat 构造体的详细信息如下所示:

struct stat {
    dev_t    st_dev;        /* ID of device containing file */ 
    ino_t    st_ino;        /* inode number */
    mode_t    st_mode;      /* protection */
    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;   /* blocksize for filesystem I/O */
    blkcnt_t st_blocks;    /* number of blocks allocated */
    time_t    st_atime;     /* time of last access */
    time_t    st_mtime;     /* time of last modification */
    time_t    st_ctime;     /* time of last status change */
};

你能够看到有对于每个文件的大量信息,包含其大小、低级名称(即 inode 号)、一些所有权信息以及无关何时文件被拜访或批改的一些信息等等。

删除文件

如果用过 UNIX,你晓得只需运行程序 rm 就能够删除一个文件。然而,rm 应用什么零碎调用来删除文件?

答案是 unlink,unlink()只须要待删除文件的名称,并在胜利时返回零。

创立目录

除了文件外,还能够应用一组与目录相干的零碎调用来创立、读取和删除目录。请留神,你永远不能间接写入目录。因为目录的格局被视为文件系统元数据,所以你只能间接更新目录,例如通过在其中创立文件、目录或其余对象类型。通过这种形式,文件系统能够确保目录的内容始终合乎预期。

要创立目录,能够用零碎调用 mkdir()。新创建的目录被认为是“空的”,空目录有两个条目:一个援用本身的条目,一个援用其父目录的条目。前者称为“.”目录,后者称为“..”目录。

读取目录

既然咱们创立了目录,也可能心愿读取目录。上面是一个打印目录内容的示例程序。该程序应用了 opendir()、readdir()和 closedir()这 3 个调用来实现工作。咱们只需应用一个简略的循环就能够一次读取一个目录条目,并打印目录中每个文件的名称和 inode 编号。

int main(int argc, char *argv[]) {DIR *dp = opendir("."); 
    assert(dp != NULL);
    struct dirent *d;
    while ((d = readdir(dp)) != NULL) {printf("%d %s\n", (int) d->d_ino, d->d_name);
    }
    closedir(dp); 
    return 0;
}

因为目录只有大量的信息(基本上,只是将名称映射到 inode 号,以及大量其余细节),程序可能须要在每个文件上调用 stat()以获取每个文件的更多信息,例如长度或其余详细信息。

删除目录

你能够通过调用 rmdir()来删除目录。然而,与删除文件不同,删除目录更加危险,因为你能够应用单个命令删除大量数据。因而,rmdir()要求该目录在被删除之前是空的(只有“.”和“..”条目)。如果你试图删除一个非空目录,那么对 rmdir()的调用就会失败。

硬链接

咱们来议论一种在文件系统树中创立条目标新办法,即 link()零碎调用。link()零碎调用有两个参数:一个旧路径名和一个新路径名。当你将一个新的文件名“链接”到一个旧的文件名时,实际上创立了另一种援用同一个文件的办法。命令行程序 ln 用于执行此操作,如上面的例子所示:

prompt> echo hello > file
prompt> cat file
hello
prompt> ln file file2 
prompt> cat file2 
hello

link 只是在要创立链接的目录中创立了另一个名称,并将其指向原有文件的雷同 inode 号(即低级别名称)。当初就有了两个可读的名称(file 和 file2),都指向同一个文件。通过打印每个文件的 inode 号,咱们能够在目录中看到这一点:

prompt> ls -i file file2
67158084 file
67158084 file2 
prompt>

创立一个文件时,实际上做了两件事。首先,要构建一个构造(inode),它将跟踪简直所有对于文件的信息,包含其大小、文件块在磁盘上的地位等等。其次,将人类可读的名称链接到该文件,并将该链接放入目录中。

让咱们回到删除文件所提到的 unlink()调用上来。当文件系统勾销链接文件时,它查看 inode 号中的援用计数(reference count)。该援用计数(有时称为链接计数,link count)容许文件系统跟踪有多少不同的文件名已链接到这个 inode。调用 unlink()时,会删除人类可读的名称与给定 inode 号之间的“链接”,并缩小援用计数。只有当援用计数达到零时,文件系统才会开释 inode 和相干数据块,从而真正“删除”该文件。

符号链接

还有一种十分有用的链接类型,称为符号链接(symbolic link),有时称为软链接(soft link)。事实表明,硬链接有点局限:你不能创立目录的硬链接(因为放心会在目录树中创立一个环)。你不能硬链接到其余磁盘分区中的文件(因为 inode 号在特定文件系统中是惟一的,而不是跨文件系统),等等。因而,人们创立了一种称为符号链接的新型链接。

要创立这样的链接,能够应用雷同的程序 ln,但须要应用 - s 标记。

prompt> echo hello > file
prompt> ln -s file file2 
prompt> cat file2
hello

除了外表类似之外,符号链接实际上与硬链接齐全不同。第一个区别是符号链接自身实际上是一个不同类型的文件。运行 ls 也揭示了这个事实,能够看到惯例文件最左列中的第一个字符是“-”,目录是“d”,软链接是“l”。你还能够看到符号链接的大小,以及链接指向的内容。

prompt> ls -al
drwxr-x--- 2 remzi remzi    29 May 3 19:10 ./
drwxr-x--- 27 remzi remzi 4096 May 3 15:14 ../
-rw-r----- 1 remzi remzi    6 May 3 19:10 file
lrwxrwxrwx 1 remzi remzi    4 May 3 19:10 file2 -> file

file2 是 4 个字节,起因在于造成符号链接的形式,行将链接指向文件的路径名作为链接文件的数据

最初,因为创立符号链接的形式,有可能造成所谓的悬空援用(dangling reference)。删除名为 file 的原始文件会导致符号链接指向不再存在的路径名。

创立并挂载文件系统

咱们当初曾经理解了拜访文件、目录和特定类型链接的根本接口。咱们再来探讨另一个话题:如何从许多底层文件系统组建残缺的目录树。这项工作的实现是先制作文件系统,而后挂载它们,使其内容能够拜访。

为了创立一个文件系统,大多数文件系统提供了一个工具,通常名为 mkfs。思路如下:作为输出,为该工具提供一个设施(例如磁盘分区,例如 /dev/sda1),一种文件系统类型(例如 ext3),它就在该磁盘分区上写入一个空文件系统,从根目录开始。

然而,一旦创立了这样的文件系统,就须要在对立的文件系统树中进行拜访。这个工作是通过 mount 程序实现的。mount 的作用很简略:以现有目录作为指标挂载点(mount point),实质上是将新的文件系统粘贴到目录树的这个点上。

文件系统实现

咱们将介绍一个简略的文件系统实现,称为 VSFS(Very Simple File System,简略文件系统),它是典型 UNIX 文件系统的简化版本。

整体组织

咱们须要做的第一件事是将磁盘分成块(block)。简略的文件系统只应用一种块大小,这里正是这样做的,咱们抉择罕用的 4KB。

因而,咱们对构建文件系统的磁盘分区的认识很简略:一系列块,每块大小为 4KB。在大小为 N 个 4KB 块的分区中,这些块的地址为从 0 到 N−1。假如咱们有一个十分小的磁盘,只有 64 块:

为了构建文件系统,须要在这些块中存储什么。当然,首先想到的是用户数据。咱们将用于寄存用户数据的磁盘区域称为数据区域(data region),简略起见,将磁盘的固定局部留给这些块,例如磁盘上 64 个块的最初 56 个:

文件系统还必须记录每个文件的信息,该信息是元数据(metadata)的要害局部。为了存储这些信息,文件系统通常有一个名为 inode 的构造。为了寄存 inode,咱们还须要在磁盘上留出一些空间。咱们将这部分磁盘称为 inode 表(inodetable),它只是保留了一个磁盘上 inode 的数组。因而,假如咱们将 64 个块中的 5 块用于 inode,磁盘当初看起来如下:

inode 通常不是那么大,假如每个 inode 有 256 字节,一个 4KB 块能够包容 16 个 inode,而咱们下面的文件系统则蕴含 80 个 inode。在咱们简略的文件系统中,这个数字示意文件系统中能够领有的最大文件数量。

咱们还须要某种办法来记录 inode 或数据块是闲暇还是已调配。当然,可能有许多调配记录办法。咱们抉择一种简略而风行的构造,称为位图(bitmap),一种用于数据区域(数据位图,data bitmap),另一种用于 inode 表(inode 位图,inode bitmap)。位图是一种简略的构造:每个位用于批示相应的对象 / 块是闲暇(0)还是正在应用(1)。因而新的磁盘布局如下,蕴含 inode 位图(i)和数据位图(d):

在极简文件系统的磁盘结构设计中,还有一块。咱们将它保留给超级块(superblock),在下图中用 S 示意。超级块蕴含对于该特定文件系统的信息,包含例如文件系统中有多少个 inode 和数据块、inode 表的开始地位等等。它可能还包含一些幻数,来标识文件系统类型。

在挂载文件系统时,操作系统将首先读取超级块,初始化各种参数,而后将该卷增加到文件系统树中。当卷中的文件被拜访时,零碎就会晓得在哪里查找所需的磁盘上的构造。

文件组织:inode

文件系统最重要的磁盘构造之一是 inode,简直所有的文件系统都有相似的构造。每个 inode 都由一个数字(称为 inumber)隐式援用,咱们之前称之为文件的低级名称(low-levelname)。在 VSFS 中,给定一个 inumber,你应该可能间接计算磁盘上相应节点的地位。假如 inode 区域从 12KB 开始(即超级块从 0KB 开始,inode 位图在 4KB 地址,数据位图在 8KB,因而 inode 表紧随其后)。因而,在 VSFS 中,咱们为文件系统分区的结尾提供了以下布局:

要读取 inode 号 32,文件系统首先会计算 inode 区域的偏移量(32×inode 的大小,即 8192),将它加上磁盘 inode 表的起始地址(inodeStartAddr = 12KB),从而失去心愿的 inode 块的正确字节地址:20KB。回忆一下,磁盘不是按字节可寻址的,而是由大量可寻址扇区组成,通常是 512 字节。因而,为了获取蕴含索引节点 32 的索引节点块,文件系统将向节点(即 40)收回一个读取申请,获得冀望的 inode 块。

在每个 inode 中,实际上是所有对于文件的信息:文件类型(例如,惯例文件、目录等)、大小、调配给它的块数、爱护信息(如谁领有该文件以及谁能够拜访它)、一些工夫信息(包含文件创建、批改或上次访问的工夫文件下),以及无关其数据块驻留在磁盘上的地位的信息(如某种类型的指针)。咱们将所有对于文件的信息称为元数据(metadata)。

设计 inode 时,最重要的决定之一是它如何援用数据块的地位。一种简略的办法是在 inode 中有一个或多个间接指针(磁盘地址)。每个指针指向属于该文件的一个磁盘块。这种办法有局限:例如,如果你想要一个十分大的文件,那就无奈实现了。

为了反对更大的文件,文件系统设计者必须在 inode 中引入不同的构造。一个常见的思路是有一个称为间接指针(indirect pointer)的非凡指针。它不是指向蕴含用户数据的块,而是指向蕴含更多指针的块,每个指针指向用户数据。因而,inode 能够有一些固定数量(例如 12 个)的间接指针和一个间接指针。如果文件变得足够大,则会调配一个间接块(来自磁盘的数据块区域),并将 inode 的间接指针设置为指向它。假如一个块是 4KB,磁盘地址是 4 字节,那就减少了 1024 个指针。文件能够增长到(12 + 1024)×4KB,即 4144KB。

另一种办法是应用范畴(extent)而不是指针。范畴就是一个磁盘指针加一个长度(以块为单位)。因而,不须要指向文件的每个块的指针,只须要指针和长度来指定文件的磁盘地位。不过只有一个范畴是有局限的,因为调配文件时可能无奈找到间断的磁盘可用空间块。因而,基于范畴的文件系统通常容许多个范畴,从而在文件调配期间给予文件系统更多的自在。

在间接指针这种办法中,你可能心愿反对更大的文件。为此,只需增加另一个指向 inode 的指针:双重间接指针(double indirect pointer)。该指针指的是一个蕴含间接块指针的块,每个间接块都蕴含指向数据块的指针。因而,双重间接块提供了可能性,容许应用额定的 1024×1024 个 4KB 块来增长文件,换言之,反对超过 4GB 大小的文件。

这种不均衡树被称为指向文件块的多级索引(multi-level index)办法。许多文件系统应用多级索引,包含罕用的文件系统,如 Linux ext2 和 ext3,以及原始的 UNIX 文件系统。其余文件系统,包含 Linux ext4,应用范畴而不是简略的指针。

为什么应用这样的不均衡树?其中一个起因是,大多数文件很小。这种不均衡的设计反映了这样的事实。如果大多数文件的确很小,那么为这种状况优化是有意义的。

目录组织

在 VSFS 中(像许多文件系统一样),目录的组织很简略。一个目录基本上只蕴含一个二元组(条目名称,inode 号)的列表。对于给定目录中的每个文件或目录,目录的数据块中都有一个字符串和一个数字。对于每个字符串,可能还有一个长度(假设采纳可变大小的名称)。

通常,文件系统将目录视为非凡类型的文件。因而,目录有一个 inode,位于 inode 表中的某处(inode 表中的 inode 标记为“目录”的类型字段,而不是“惯例文件”)。

读取和写入

咱们假如文件系统曾经挂载,因而超级块曾经在内存中。其余所有内容(如 inode、目录)仍在磁盘上。

从磁盘读取文件

当你收回一个 open(“/foo/bar”, O_RDONLY)调用时,文件系统首先须要找到文件 bar 的 inode,从而获取对于该文件的一些根本信息(权限信息、文件大小等等)。为此,文件系统必须可能找到 inode,但它当初只有残缺的路径名。文件系统必须遍历路径名,从而找到所需的 inode。

所有遍历都从文件系统的根开始,即根目录(root directory),它就记为 /。因而,文件系统的第一次磁盘读取是根目录的 inode。然而这个 inode 在哪里?要找到 inode,咱们必须晓得它的 i -number。通常,咱们在其父目录中找到文件或目录的 i -number。根没有父目录(依据定义)。因而,根的 inode 号必须是“家喻户晓的”。在挂载文件系统时,文件系统必须晓得它是什么。在大多数 UNIX 文件系统中,根的 inode 号为 2。因而,要开始该过程,文件系统会读入 inode 号 2 的块(第一个 inode 块)。

一旦 inode 被读入,文件系统能够在其中查找指向数据块的指针,数据块蕴含根目录的内容。因而,文件系统将应用这些磁盘上的指针来读取目录,寻找 foo 的条目。通过读入一个或多个目录数据块,它将找到 foo 的条目。一旦找到,文件系统也会找到下一个须要的 foo 的 inode 号。

下一步是递归遍历路径名,直到找到所需的 inode。在这个例子中,文件系统读取蕴含 foo 的 inode 及其目录数据的块,最初找到 bar 的 inode 号。open()的最初一步是将 bar 的 inode 读入内存。而后文件系统进行最初的权限查看,在每个过程的关上文件表中,为此过程调配一个文件描述符,并将它返回给用户。

关上后,程序能够收回 read()零碎调用,从文件中读取。第一次读取(除非 lseek()已被调用,则在偏移量 0 处)将在文件的第一个块中读取,查阅 inode 以查找这个块的地位。它也会用新的最初拜访工夫更新 inode。读取将进一步更新此文件描述符在内存中的关上文件表,更新文件偏移量,以便下一次读取会读取第二个文件块,等等。

整个流程总结起来就是:先关上文件,而后递归地屡次读取,以便最终找到文件的 inode。之后,读取每个块须要文件系统首先查问 inode,而后读取该块,再更新 inode 的最初拜访工夫字段

写入磁盘

写入文件是一个相似的过程。首先,文件必须关上。其次,应用程序能够收回 write()调用以用新内容更新文件。最初,敞开该文件。

与读取不同,写入文件也可能会调配(allocate)一个块(除非块被覆写)。当写入一个新文件时,每次写入操作不仅须要将数据写入磁盘,还必须首先决定将哪个块调配给文件,从而相应地更新磁盘的其余构造(例如数据位图和 inode)。因而,每次写入文件在逻辑上会导致 5 个 I /O:一个读取数据位图(而后更新以标记新调配的块被应用),一个写入位图(将它的新状态存入磁盘),再是两次读取,而后写入 inode(用新块的地位更新),最初一次写入真正的数据块自身。

思考简略和常见的操作(例如文件创建),写入的工作量更大。要创立一个文件,文件系统不仅要调配一个 inode,还要在蕴含新文件的目录中调配空间。这样做的 I / O 工作总量十分大:一个读取 inode 位图(查找闲暇 inode),一个写入 inode 位图(将其标记为已调配),一个写入新的 inode 自身(初始化它),一个写入目录的数据(将文件的高级名称链接到它的 inode 号),以及一个读写目录 inode 以便更新它。如果目录须要增长以包容新条目,则还须要额定的 I /O(即数据位图和新目录块)。

正文完
 0