1

文件和目录

概念

随着时间的推移,有关存储虚拟化形成了两个关键的抽象。第一个是文件(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块:

image.png

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

image.png

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

image.png

inode通常不是那么大,假设每个inode有256字节,一个4KB块可以容纳16个inode,而我们上面的文件系统则包含80个inode。在我们简单的文件系统中,这个数字表示文件系统中可以拥有的最大文件数量。

我们还需要某种方法来记录inode或数据块是空闲还是已分配。当然,可能有许多分配记录方法。我们选择一种简单而流行的结构,称为位图(bitmap),一种用于数据区域(数据位图,data bitmap),另一种用于inode表(inode位图,inode bitmap)。位图是一种简单的结构:每个位用于指示相应的对象/块是空闲(0)还是正在使用(1)。因此新的磁盘布局如下,包含inode位图(i)和数据位图(d):

image.png

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

image.png

在挂载文件系统时,操作系统将首先读取超级块,初始化各种参数,然后将该卷添加到文件系统树中。当卷中的文件被访问时,系统就会知道在哪里查找所需的磁盘上的结构。

文件组织:inode

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

image.png

要读取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(即数据位图和新目录块)。


与昊
225 声望636 粉丝

IT民工,主要从事web方向,喜欢研究技术和投资之道