文件和目录
概念
随着工夫的推移,无关存储虚拟化造成了两个要害的形象。第一个是文件(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 > fileprompt> cat filehelloprompt> ln file file2 prompt> cat file2 hello
link只是在要创立链接的目录中创立了另一个名称,并将其指向原有文件的雷同inode号(即低级别名称)。当初就有了两个可读的名称(file和file2),都指向同一个文件。通过打印每个文件的inode号,咱们能够在目录中看到这一点:
prompt> ls -i file file267158084 file67158084 file2 prompt>
创立一个文件时,实际上做了两件事。首先,要构建一个构造(inode),它将跟踪简直所有对于文件的信息,包含其大小、文件块在磁盘上的地位等等。其次,将人类可读的名称链接到该文件,并将该链接放入目录中。
让咱们回到删除文件所提到的unlink()调用上来。当文件系统勾销链接文件时,它查看inode号中的援用计数(reference count)。该援用计数(有时称为链接计数,link count)容许文件系统跟踪有多少不同的文件名已链接到这个inode。调用unlink()时,会删除人类可读的名称与给定inode号之间的“链接”,并缩小援用计数。只有当援用计数达到零时,文件系统才会开释inode和相干数据块,从而真正“删除”该文件。
符号链接
还有一种十分有用的链接类型,称为符号链接(symbolic link),有时称为软链接(soft link)。事实表明,硬链接有点局限:你不能创立目录的硬链接(因为放心会在目录树中创立一个环)。你不能硬链接到其余磁盘分区中的文件(因为inode号在特定文件系统中是惟一的,而不是跨文件系统),等等。因而,人们创立了一种称为符号链接的新型链接。
要创立这样的链接,能够应用雷同的程序ln,但须要应用-s标记。
prompt> echo hello > fileprompt> ln -s file file2 prompt> cat file2hello
除了外表类似之外,符号链接实际上与硬链接齐全不同。第一个区别是符号链接自身实际上是一个不同类型的文件。运行ls也揭示了这个事实,能够看到惯例文件最左列中的第一个字符是“-”,目录是“d”,软链接是“l”。你还能够看到符号链接的大小,以及链接指向的内容。
prompt> ls -aldrwxr-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 filelrwxrwxrwx 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(即数据位图和新目录块)。