文件系统的目标是组织和保存数据。文件系统应支持在用户和程序之间共享数据,它也应该支持持久性(persistence)使得在重启之后数据也是可用的。
xv6的文件系统支持像Unix那样的文件、目录和路径名,并把数据保存在一个virtio硬盘上以支持持久性。文件系统解决了许多挑战:
- 文件系统需要磁盘上的数据结构来代表文件和目录的树,记录块的标识(块上记录了所有文件的内容),记录硬盘的哪些区域是空闲的。
- 文件系统必须支持故障恢复(crash recovery)。即,如果发生了故障(如,电源错误),文件系统必须在重启之后仍然能正确工作。风险在于故障可能中断一系列的更新并使硬盘上的数据结构不一致(如,一个块可能即被一个文件使用也被标记为空闲)。
- 可能会有不同进程同时操作文件系统,因此文件系统的代码必须协调以维护不变量。
- 访问磁盘要比访问内存慢几个数量级,因此文件系统必须维护常用块在内存里的缓冲区。
xv6的文件分为七层。如下图所示:
名称 | 描述 |
---|---|
文件描述符层 | 用文件系统抽象了许多Unix资源(管道、设备、文件等),应用程序员好过多了 |
路径名层 | 提供分层的路径名且用递归查找来解析它们 |
目录层 | 目录是特殊的inode,它的内容是一些目录的入口,每个入口包含了文件名和i-number |
inode层 | 提供用inode 代表的独立文件,且有唯一的i-number和一些保存了文件数据的块 |
日志层 | 让上层在一个事务中封装对多个块的更新,且确保发生故障时这些块是原子性地更新(如,全更新或全不更新) |
缓冲区缓存层 | 缓存硬盘块并同步对它们的访问,确保每次只有一个内核进程可以修改块上的数据 |
硬盘层 | 读写virtio硬盘上的块 |
文件系统必须有一个在磁盘上存储inode和内容块的方案。xv6的作法是把磁盘分为多个区。文件系统不使用0号块(它是启动区)。块1是超级块(superblock),包含了文件系统的元数据(文件系统里块的数量,数据块的数量,inode的数量,日志区里块的数量)。从2号块开始是日志区。日志区之后是inode区,每个块有多个inode。之后是位图区,用于追踪哪个块被使用了。剩下的是数据区,数据区里的块要么在位图区被标记为空闲,要么保存文件或目录的内容。超级块由一个叫mkfs
的单独的程序填充,这个程序构建了一个初始的文件系统。
硬盘层在virtio_disk.c,实际上就是硬盘驱动。
缓冲区缓存层用到的接口是
virtio_disc_rw
- 作用:对磁盘进行读或写
- *b : 缓冲区的指针
- write : 1把缓冲区的内容写入到磁盘,0把磁盘的内容读取到缓冲区
缓冲区缓存有两个工作(代码在bio.c):
- 同步访问磁盘块以确保内存里每个块只有一份复制,且每次只有一个内核线程可以使用那份复制。
- 缓存常用块使得不必每次都从硬盘上读取它们。
缓冲区缓存的主要接口是bread
和bwrite
:bread
获取一个buf
来包含一个块的复制,这样就可以在内存里进行读写;bwrite
把缓冲区中的内容写入到相应的块里。内核线程必须通过调用brelse
来释放一个缓冲区。缓冲区缓存使用每个缓冲区的睡眠锁来确保一次只有一个线程可以使用每个缓冲区(进而每个硬盘块);bread
返回一个上锁的缓冲区,然后brelse
释放那个锁。
我们再回到缓冲区缓存。缓冲区缓存有一个确定数量的缓冲区来保存磁盘块,这意味着如果文件系统请求了不在缓存中的磁盘块,缓冲区缓存必须回收一个已经保存了其它块的缓冲区。缓冲区缓存回收的是最近最少使用的缓冲区。
缓冲区缓存是缓冲区的双向链表。main
调用binit
来初始化这个列表,列表中的项来自于静态数组buf
里的NBUF
个缓冲区。所有对缓冲区缓存的其它访问都是通过bcache.head
引用链表来实现的,而不是buf
数组。
缓冲区缓存的物理表现形式是数组,逻辑表现形式是双向链表。每个缓冲区都有三个部分组成,data字段标示了它的内容,指针的字段用于组成链表,数值字段用于标示它的属性(如与磁盘和块的对应关系,如当前缓冲区是否记录了对应磁盘的内容等)。
在日志层里用到的本层接口有:
- bread
- bwrite
- brelse
与缓冲区相关的状态字段有两个。字段valid
的意思是那个缓冲区包含了一个块的复制。字段disk
的意思是缓冲区的内容已经被提交到了磁盘,这可能会改变缓冲区(如,把磁盘中的数据写到data
)。
bread
调用bget
来为给定的扇区分配缓冲区。如果缓冲区需要从磁盘读取数据,在返回前bread
会调用virtio_disk_rw
从磁盘把数据读出来。
bget
通过设备和扇区号在缓冲区列表里扫描对应的缓冲区。如果有那样的缓冲区,bget
请求那个缓冲区的睡眠锁,然后把带锁的缓冲区返回给调用者。如果没有对应的缓冲区,bget
必须生成一个,可能还需要新使用一个已保存了数据的缓冲区。它会第二次扫描缓冲区列表,查找没有被使用的缓冲区(b->refcnt == 0
);任何一个那样的缓冲区都是可用的。bget
编辑缓冲区的元数据来记录新设备、扇区号并请求它的睡眠锁。注意赋值语句b->valid = 0
,它确保了bread
将从磁盘读取块数据,而不是错误地使用缓冲区里之前的数据。
每个磁盘扇区最多只有一个缓冲区是十分重要的,这样可以确保读者可以看到写操作,并且文件系统也需要在缓冲区上使用锁来进行同步。通过从第一个循环里检查这个块是否被缓存,到第二循环里声明这个块已经被缓存(设置dev
,blockno
和refcnt
),bget
连续地持有bcache.lock
来确保这个不变量。这使得对块存在性的检查和(如果不存在)为保存那个块而进行的缓冲区分配是原子的。
bget
在bcache.lock
临界区之处请求缓冲区的睡眠锁是安全的,因为b->refcnt
非0使得那个缓冲区不会被重新使用。睡眠锁保护了对块缓冲区内容的读写,而bcache.lock
保护了哪个块被缓存的信息。
如果所有的缓冲区都忙,表明有太多的进程并发地执行文件系统调用,bget
就会panic。一个更优雅的响应可能是睡眠来等待一个缓冲区空闲,尽管那样可能会引发死锁。
一旦bread
读取了磁盘(如果需要的话)并把缓冲区返回给它的调用者,调用者就独占了缓冲区的使用,进行读写数据了。如果调用者修改了缓冲区,它在释放缓冲区之前必须调用bwrite
来把改变的数据写入到磁盘。bwrite
调用virtio_disk_rw
来与磁盘硬件对话。
如果调用者完成了对缓冲区的操作,它必须调用brelse
来释放它。(brelse
是b-release的缩写,它名字有点怪但值得学习一下:源自于Unix,在BSD、Linux和Solaris也都是这么用的)。
brelse
释放睡眠锁,并把缓冲区移到链表的前面。对缓冲区的移动引发了列表按照最近使用(释放)进行排序:列表里的第一个缓冲区是最近被使用的,最后一个则是最近最少被使用的。bget
里的两个循环利用了这点:在最坏的情况下,扫描一个已经存在的缓冲区必须处理整个列表,当引用处于良好的位置的时候,先检查最近使用的缓冲区将减少扫描时间。通过反向扫描(跟随prev
指针),对要重新使用的缓冲区的扫描查找到了最近使用的缓冲区。
在文件系统的设计里最有趣的问题之一是故障恢复。许多文件系统的操作包含了多次写磁盘的操作,在一串写操作之后的崩溃使得磁盘上的文件系统处于不一致的状态。比如,发生在文件裁切时的崩溃(设置文件长度为0且释放它的内容块)。依赖于写磁盘的次序,崩溃可能使引用到一个内容块的inode被标记为空闲,也可能使一个内容块被分配但未被引用。
后者相对来说是良性的,但引用到一个空闲块的inode在重启后可能会引发严重的问题。重启后,内核可能把那个块分配给了其它文件,现在两个不同的文件都指向了同一个块。如果xv6支持多用户,这就会产生安全问题,因为旧文件的属主可以读写新文件的块,而这个新文件的拥有者是不同的用户。
xv6通过简单的日志记录解决了文件系统操作期间的崩溃问题。一个xv6系统调用不直接写硬盘上文件系统的数据结构。相反,它把一个描述放在磁盘上,这个描述是它在一个log
里所期望的所有磁盘写操作。一旦系统调用日志记录了所有的写操作,它往磁盘上写入一个特殊的commit
记录用来表示那个日志包含了一个完整的操作。那时系统调用才会把复制写入到磁盘文件系统里的数据结构。当那些写操作都完成后,系统调用删除磁盘上的日志。
如果系统崩溃并重启,在运行任何进程之前,文件系统代码按如下描述从崩溃中恢复。如果日志被标记为包含一个完整的操作,则恢复代码把写操作复制到磁盘文件系统。如果日志不是标记为包含完整的操作,恢复代码忽略这个日志。恢复代码最后删除日志完成所有的操作。
为什么xv6的日志解决了在文件系统操作期间的崩溃问题?如果崩溃发生在操作提交之前,则磁盘上的日志不会标记为已完成,恢复代码会忽略它,磁盘状态就像操作从来没有开始过一样。如果崩溃发生在磁盘操作提交之后,则恢复代码将会重新执行所有的写操作,如果已经开始往磁盘数据结构中执行写操作,则可能会重新执行这些操作。不管是哪种情况,相对于崩溃来说日志都使得操作原子化了:恢复之后,要么所有的写操作都出现在磁盘上,要么它们都不出现在磁盘上。
日志驻留在固定的位置,这是在超级块里指定的。它是由一个头块(header block)和随后跟着的一些要更新的块的复制(updated block copies)组成的,要更新的块的复制也叫日志块(logged blocks)。头块由扇区编号的数组和日志块的计数组成,其中每个扇区对应着一个日志块。头块中的计数如果是0则表示日志中没有事务,如果非0则表示日志中包含一个完整的已提交事务。xv6在事务提交的时候(不是之前)写头块,在把日志块复制到文件系统之后将计数置0。因此事务进行到一半的崩溃将导致头块里计数为0;而提交之后的崩溃将导致计数非0。
上面一段描述了磁盘上日志区的结构,是理解日志层的关键。
每个系统调用代码都意味着,相对于崩溃来说,写序列的开始和结束是原子的。为了允许不同进程对文件系统操作的并发执行,日志记录系统可以把多个多个系统调用的写操作累积到一个事务中。即一个提交可能包含多个完整系统调用的写操作。为了避免在事务之间分割系统调用,日志系统只有在没有文件系统的系统调用发生时才提交。
把多个事务一块提交的想法被称为组提交(group commit)。组提交减少了磁盘操作的数量,因为它把一次提交的固定代价分摊到了多个操作上。组提交还有助于磁盘系统进行并发的写操作,这使得磁盘在一次旋转期间就可能把它们全部写入。xv6的virtio驱动不支持这样的批处理(batching),但xv6文件系统的设计允许这样做。
xv6在磁盘上占用固定的空间来保存日志。在一个事务中系统调用要写入的块的数量必须适合那个空间。这有两个后果。
- 不允许单个的系统调用写入比日志空间更多的块。对于大多数系统调用来说不存在这样的问题,但是
write
和unlink
可能会写许多块。一个大文件的写操作可能会许多数据块、许多位图块和一个inode块;取消到一个大文件的连接可能会写许多位图块和一个inode块。xv6的write
系统调用把大的写操作分解为多个小的写操作以适合日志,unlink
不会产生问题因为xv6的文件系统实际上只使用一个位图块。 - 日志系统不可以允许系统调用启动,除非确定系统调用的写操作符合日志的剩余空间。
日志层的代码在log.c里。数据结构log可以看做是由两部分组成的,其中lh记录了磁盘上日志区里头块的内容,其它整数则代表了一次事务的属性(如当前事务包含了多少个系统调用,如当前事务是否在提交等)。
本层向上层提供的接口是:
- begin_op
- log_wirte
- end_op
在系统调用里log的典型用法如下所示:
begin_op();
...
bp = bread(...);
bp->data[...] = ...;
log_write(bp);
...
end_op();
begin_op
一直在等待,直到日志系统当前没在提交,且直到有足够的日志空间来保存调用的写操作。log.outstanding
记录保留了日志空间的系统调用的数量;总的保留空间等于log.outstanding
乘以MAXOPBLOCKS
。递增的log.outstanding
即保留了空间也防止了在这个系统调用期间发生提交。代码里谨慎地假设每个系统调用都可能写入超过MAXOPBLOCKS
个块。
log_write
是bwrite
的代理。它在内存中记录块的扇区号,在硬盘上的日志中为它保留一个位置,并把缓冲区固定在块缓存里以防止块缓存驱逐它。块必须待在缓存里,直到提交:在那之前,被缓存的拷贝是修改的唯一记录;在提交之前,不得写入磁盘;且同一事务中的其它读操作必须可以看到这个修改。当一个块在单个事务中被多次写入时,log_write
会发现它,并在日志里为那个块分配相同的位置。这个优化被称为合并(absorption)。这很常见,比如,在一个事务里包含了多个文件的inode的磁盘块被多次写入。通过把多个磁盘写操作合并到一个,文件系统可以节省日志空间并获得更好的性能,因为只需将一个磁盘块的拷贝写到磁盘里。
end_op
首先对未完成的系统调用的计数进行递减操作。如果记数为0,则调用commit()
提交当前事务。这个过程分为四步。
write_log()
把在事务中修改过的每个块从缓冲区缓存复制到硬盘上日志分区对应的位置里。write_head()
把头块写到硬盘上。这是提交点,写操作之后的崩溃将导致从日志中恢复事务的写操作。install_trans
从日志中读取每个块并把它写入到文件系统的对应位置。- 把计数0写到日志的头块。必须在下一个事务写日志头块之前做这件事,这样当崩溃发生的时候,就不会产生头块属于一个事务而后续的日志块属于另一个事务的问题。
在第一个用户进程运行之前,fsinit
调用initlog
,initlog
调用recover_from_log
。它读取日志头,如果日志头表示如果日志包含了一个已提交的事务,则模仿end_op
的动作。
在filewrite
里有一个使用日志的例子。这个事务像下面这样:
begin_op();
ilock(f->ip);
r = writei(f->ip, ...);
iunlock(f->ip);
end_op();
这个代码被包装在一个循环中,把大的写操作拆分成多个独立的事务,以避免日志溢出。对writei
的调用写了许多块,这也作为事务的一部分:文件的inode,一个或多个位图块,和一些数据块。
文件和目录的内容保存在磁盘块上,必须从空闲池中分配这些磁盘块。xv6的块分配器管理着一个磁盘上的空闲位图,每个位对应着一个块。值为0代表着对应的块是空闲的,值为1代表着对应的块在被使用。程序mkfs
设置各种块对应的位,这些块有启动扇区、超级块、日志块、inode块,和位置块。
块分配器提供了两个函数:balloc
分配一个新的磁盘块,bfree
释放一个块。
balloc
里的循环考虑到了所有的块,从0到sb.size
,文件系统里所有的块。它查找位图里的位为0的块,一旦找到就更新位图并返回这个块。为提高效率,这个循环被分为两个部分。外层循环读取位图区里的每个块。内层循环检查单个位图块里的所有BPB
个比特。如果两个进程同时分配同一个块,可能会发生竞争,但缓冲区缓存一次只允许一个进程使用一个位图块,这就避免了这种竞争。bfree
找到正确的位图块,并清空正确的位。此外,bread
和brelse
里暗含的排它性,避免了显示地使用锁。
就像在本章其余部分描述的大部分代码一样,balloc
和bfree
必须在一个事务的内部调用。
术语inode
有两个相关的含义。
- 磁盘上的数据结构,包括文件大小和磁盘块编号的列表。
- 内存里的数据结构,包含一份磁盘上inode的拷贝以及内核需要的额外信息。
磁盘上的inode被填充在一个被称为inode块的连续磁盘区域上。每个inode都是一样大小,所以给定一个编号n,很容易就找到磁盘上的第n个inode。事实上,编号n(被称为inode编号或i-number),是在具体实现中inode的标识方式。
磁盘上的inode通过struct dinode
来定义。type
字段区分了文件、目录、和特殊文件(设备)。如类型值为0则表示磁盘inode是空闲的。字段nlink
记录了引用当前inode的目录条目的数量,用以识别这个磁盘inode和它的数据块何时应该被释放。size
字段记录了文件内容的字节数。addrs
数组记录了保存了文件内容的磁盘块的块号。
内核把活动inode的集合保存在内存中,即struct inode
。只有在一个C指针指向一个inode的时候内核才会把那个inode保存到内存里。ref
字段记录了C指针引用内存里inode的次数,当那个计数降为0的时候内核就会从内存中丢弃这个inode。iget
和iput
函数请求和释放到一个inode的指针,这会修改这个引用计数。到inode的指针可能来自于文件描述符、当前的工作目录,和事件内核代码(如exec
)。
xv6的inode代码里有四种锁或类似于锁的机制。
icache.lock
保护的不变量是inode最多在缓存中出现一次,以及inode.ref
计数的是到被缓存的inode的指针数量。inode.lock
是inode的睡眠锁,它用来确保对inode的字段、inode的文件或目录内容块的独占访问。inode.ref
如大于0,则使系统保留缓存里的这个inode,并且不会让其它的inode重新使用这个缓存条目。inode.nlink
,用于计数引用了一个文件的目录条目的数量,如果一个inode的连接数量(link count)大于0,xv6是不会释放这个inode的。
iget()
返回的struct inode
指针保证是有效的,直到调用相应的iput()
;这个inode不会被删除,指针所引用的内存也不会被也不会其它的inode重新使用。iget()
提供对inode的非独占访问,所以可以有多个指针指向同一个inode。文件系统代码里的许多部分都依赖于iget()
的这个行为,即可以保持对inode的长期引用(当打开文件和当前目录),又可以避免竞争,同时还可以在操作多个inode(如路径名查找)的时候避免死锁。
iget()
返回的struct inode
可能没有任何有用的内容。为了确保它确实保存了磁盘inode上的拷贝,代码必须调用ilock
。这会锁定inode(所以其它进程就无法ilock
它了),并从磁盘读取尚未读取的inode。iunlock
则释放inode上的锁。在某些情况下,把inode指针的获取与锁定分享有助于避免死锁,例如在目录查找期间。多个进程可以持有iget()
返回的inode的C指针,但一次只有一个进程可以锁定这个inode。
inode的缓存里缓存的,是内核代码或数据结构持有C指针的inode。它的主要工作是支持多进程的并行访问,缓存只是次要的功能。如果一个inode被频繁使用,且它不是由inode缓存保存,它可能会被缓冲区缓存保存在内存里。inode缓存是直写的(write-through),这意味着修改了被缓存的inode的代码必须用iupdate
立即写入磁盘。
在fs.c里。
xv6调用ialloc
来分配一个新的inode(如创建文件)。ialloc
类似于balloc
:它遍历磁盘上的inode结构,每次一个块,查找标记为空闲的块。当它找到空闲块,它就通过把新的type
写入磁盘的方式来声明这个块,然后通过调用iget
返回一个inode缓存的条目。ialloc
的正确操作依赖于这样的事实,一次只有一个进程可以持有对bp
的引用:ialloc
就可以确保其它进程不会同时发现这个inode的存在并尝试声明它。
iget
遍历inode缓存来查找一个活动的入口(ip->ref > 0
),这个入口要满足设备和inode编号两个要求。如果找到了这样的入口,它返回对那个inode的新的引用。当iget
扫描的时候,它会记录第一个空位的位置,这个空位用于分配一个缓存条目(如果需要的话)。
在读写inode的元数据或内容之前,必须使用ilock
来锁定inode。ilock
使用一个睡眠锁来实现此目的。一旦ilock
可以独占地访问这个inode,它会根据需要从磁盘(更有可能是缓冲区缓存)读取这个inode。函数iunlock
释放这个睡眠锁,这可能导致任何正在睡眠的进程被唤醒。
iput
通过检查引用计数(reference count)释放一个inode的C指针。如果这是最后一次引入,在inode缓存里这个inode的位置就是空闲的并且可以被其它inode重新使用了。
如果iput
发现一个inode没有C指针引用了,也没有到它的连接(不存在于任何目录中),则这个inode和它的数据块必须被释放。iput
调用itrunc
来把文件截为0字节,释放数据块;把inode的类型设为0(未分配);并把inode写入磁盘。
在iput
释放inode的时候,它里面的锁定规则(the locking protocol)值得仔细研究。
- 其中一个风险是一个并发线程为了使用这个inode可能会等待在
ilock
里(如,读取一个文件或列出一个目录),并且不会发现这个inode已经不再被分配了。这不会发生,如果一个缓存的inode的连接数为0而引用数为1,一个系统调用是没有办法获取到它的指针的。这1个引用被调用iput
的线程所拥有。iput
确实是会在它的icache.lock
临界区之外检查引用计数为1,但那时连接数已经是0,所以没有线程会尝试获取新的引用。 - 另一个主要的风险是对
ialloc
并发的调用可能会关闭inode,而这个inode已经被iput
释放掉了。在iupdate
写磁盘之后,从而inode的类型为0时,这个风险才有可能发生。这个竞争是良性的;在读写inode之前,分配线程会礼貌地等待以请求inode的睡眠锁,此时iput
已经完成了它。
iput()
可以写入磁盘。这意味着任何使用了文件系统的系统调用都可以写磁盘,因为系统调用可能是最后一个到文件的引用。即使是像read()
那样看上去只读的系统调用,也有可能会最后调用iput()
。这意味着,任何使用了文件系统的系统调用,即使它们是只读的,也必须封装在事务中。
iput()
和崩溃之间的交互具有挑战性。当文件的连接数降为0的时候,iput()
不会立即的截断文件,因为仍然可能有进程在内存中持有到这个inode的引用:一个进程可能仍然在读写这个文件,因为它成功打开了这个文件。但是,如果在最后一个进程关闭这个文件的文件描述符之前发生了崩溃,这个文件就会标记为分配到了磁盘上,却没有目录条目指向它。
文件系统有两种方法来处理这种情况。
- 简单点的方案是,在重启之后恢复的时候,寻找那些已经标记为分配但没有目录条目指向它们的文件。如果找到那样的文件就把它们释放。
- 这个方案不必扫描整个文件系统。如果一个文件的连接数降为0而引用数不为0,则把这个文件的inode编号记录到磁盘上(比如,超级块上)。当引用记数降为0的时候删除这个文件,再更新这个磁盘列表以从列表中删除那个inode。恢复的时候,文件系统释放列表中的所有文件。
xv6以上两个方案都没有使用,这意味着inode标记为在磁盘上已分配,即使它们不再被使用。这意味着随着时间的推移,xv6面临着耗尽磁盘空间的风险。
硬盘上的inode结构struct dinode
,其中size
字段用以表明文件大小,addrs
数组用以保存块号。那些块号所对应的块里保存着这个inode的数据。addrs
里的前NDIRECT
个块被称为直接块(direct blocks);第NDIRECT+1
个块记录了NINDIRECT
个块的数据,它被称为间接块(indirect block)。由于一个块的大小BSIZE
是1024字节,且NDIRECT
是12,所以一个文件可以直接载入的内容为12k字节。由于NINDIRECT
是256,所以读取间接块后可以载入的内容是256k字节。这有利于在磁盘上的表达,但对于用户程序来说比较复杂。函数bmap
管理这个表达。bmap
用于返回第bn
个数据块的硬盘块号,参数ip
用于表示inode的指针。如果ip
里没有那个块,bmap
会给它分配一个。
函数bmap
先从简单的情况开始:前NDIRECT
个块已经在inode它自己里列出了。接下来的NINDIRECT
个块在间接块ip->addrs[NDIRECT]
里列出。bmap
读取间接块,然后再从间接块里读取对应的块号。如果块号超过了NDIRECT+NINDIRECT
,bmap
会panic;writei
会进行检查以避免这种情况的发生。
bmap
按照需要来分配块。如果直接块或间接块的某个条目为0,则意味着那个条目没有分配块。当bmap
发现条目的值为0,它就把新块的编号填充进去,按需分配。
itrunc
释放一个文件的块,把inode的大小重置为0。itrunc
先释放直接块,再释放间接块里所列出的那些块,最后再释放间接块自身。
对于readi
和writei
,bmap
让它们获取inode的数据变的简单。readi
首先确保位移和大小没有超出文件的限制。读取的位移超出文件的限制会返回一个错误,读取的大小超出文件的限制则会返回少一些的字节。主循环处理文件的所有块,把缓冲区的数据复制到dst
。writei
和readi
相同,但有三个地方不一样:
- 写入的大小超出文件的限制会返回错误。
- 循环里不是读出,而是把数据复制进缓冲区。
- 如果写操作扩展了文件,
writei
必须更新它的大小。
在xv6-public的代码里,readi
和writei
都是从检查ip->type==T_DEV
开始。但在xv6-riscv的代码里,没有进行这样的检查。
函数stati
把inode的元数据复制进stat
结构体,stat
结构体最终会通过stat
系统调用暴露给用户程序。
in fs.c
目录的内部实现非常类似于文件。它的inode的类型是T_DIR
,它的数据是一些目录条目。每个条目都是一个struct dirent
,包含了一个名字和一个inode号。名字最多可以有DIRSIZ
(14)个字符;如果低于14个字符,则以NUL
(0)结尾。inode号为0的目录条目是空闲的目录条目。
函数dirlookup
通过名字来查找目录里的条目。如果找到一个,它返回对应inode的指针(未锁定),并把*poff
设置为目录里对应条目的位移,以防调用者期望能编辑它。如果dirlookup
找到了正确名称的条目,它更新*poff
并返回未上锁的inode(通过iget
获取)。iget
返回不上锁的inode就是因为dirlookup
。调用者锁定了dp
,所以如果查找的是.
(当前目录的别名),返回之前锁定inode会导致尝试重新锁定dp
从而导致死锁。还有更复杂的死锁涉及多进程和..
(上级目录的别名)。调用者可以解锁dp
然后锁定ip
,确保它一次只持有一个锁。
函数dirlink
向目录dp
里写入一个新的目录条目,参数*name
是新目录条目的名称,参数inum
是inode号。如果名称已经存在,dirlink
返回一个错误。主循环读取目录条目查找一个未分配的条目。当它找到一个,它就先停止循环,此时off
被设置为已知条目的位移。否则,off
将被设置为dp->size
。不管是哪种情况,dirlink
都把新的条目写入位移off
,这样新条目就填加到了目录里。
in fs.c
路径名查找包含了一系列对dirlookup
的调用,一个dirlookup
对应了路径名的一个部分。namei
计算path
并返回对应的inode
。函数nameiparent
是一个变量:它在最后一个元素之前停止,返回上级目录的inode并把最后的元素复制进name
。它们都是调用namex
来完成实际的工作的。
namex
首先决定路径计算从哪里开始。如果路径是从斜杠开始的,计算从根目录开始;否则,就从当前目录开始。然后它使用skipelem
来依次得到路径里的每个元素。每次循环都必须在当前inode(ip
)查找name
。循环首先锁定ip
并检查它是否是一个目录。如果不是,查找失败。(锁定ip
是必要的,不是因为ip->type
可以改变,它改变不了,而是因为在ilock
运行之前,不能保证已经从磁盘加载了ip->type
。)如果这个调用是nameiparent
并且这是最后一个路径元素,按照nameiparent
的定义,循环终止;最后的路径元素已经复制进了name
,所以namex
只需返回未锁定的ip
即可。最后,使用dirlookup
查找路径元素,并通过设置ip = next
为下一次循环做好准备。当循环遍历完所有的路径元素退出后,namex
返回ip
。
例程namex
可能需要很长的时间才能完成:它可能包含了多个磁盘操作来读取inode和目录块。xv6经过仔细的设计,使得如果一个内核线程调用namex
的时候被阻塞在磁盘I/O上,其它内核线程可以并发的查找不同的路径名。namex
给不同路径下的目录分别上锁,使得在不同目录下的查找可以并发地进行。
这样的并发带来一些挑战。例如,一个内核线程正在查找一个路径名,而其它内核线程可能正在改变这个目录树(通过取消链接一个目录)。一个潜在的风险是,一个查找可能在搜索已经被其它内核线程删除的目录,并且这个目录的块已经被其它目录或文件重新使用了。
xv6避免了那样的竞争。例如,在namex
里执行dirlookup
的时候,查找线程持有了目录的锁,并且dirlookup
返回一个来自于iget
的inode。iget
递增这个inode的引用计数。只有在从dirlookup
接收到这个inode之后,namex
才会释放这个目录的锁。现在其它线程可能会从目录里取消链接这个inode,但是xv6不会删除这个inode,因为这个inode的引用计数仍然大于0。
另一个风险是死锁。例如,当查询.
目录的时候,next
指向与ip
相同的inode。在释放对ip
的锁之前锁定next
应该会引发死锁。为了避免这种死锁,namex
在获取next
锁之前会先解锁当前目录。在这里我们再次看到了iget
和ilock
分离的重要性。
in file.c
Unix接口一个酷的地方在于一切皆是文件,包括设备(如控制台)、管道、和真正的文件。文件描述符层实现了这个特性。
每个进程有自己的打开文件(或者说是文件描述符)的表。每个打开文件都是一个struct file
,它是对inode或管道的封装,再加上一个I/O位移。每次对open
的调用都创建一个新的打开文件(一个新的struct file
):如果不同的进程各自打开同一个文件,不同的实例将会有不同的I/O位移。另一方面,一个单独的打开文件(相同的struct file
)可能在一个进程的文件表里多次出现,也可以出现在多个进程的文件表里。如果一个进程使用open
打开文件,然后使用dup
创建别名,或者使用fork
与子进程共享这个文件,就会发生这种情况。引用计数追踪一个特定的打开文件的引用数量。readable
和writable
字段追踪的是一个文件以什么方式打开(读,写,或读写)。
系统里的所有打开文件保存在一个全局文件表里,即ftable
。可以操作这个文件表的函数有:分配文件(filealloc
),创建一个重复的引用(filedup
),释放一个引用(fileclose
),读写数据(fileread
和filewrite
)。
filealloc
扫描文件表查找未引用的文件(f->ref == 0
)并返回一个新的引用。filedup
递增引用计数。fileclose
递减引用计数。当引用计数降为0,释放底层对应的管道或inode。filestat
实现了对文件的stat
操作。它只允许操作inode,然后调用stati
fileread
实现了对文件的read
操作。首先检查是否允许readable
模式,然后把调用传递给管道或inode的实现。如果文件是一个inode,它使用I/O位移作为读操作的位移,然后增加这个位移。管道没有位移的概念。filewrite
实现了对文件的write
操作。首先检查是否允许writeable
模式,然后把调用传递给管道或inode的实现。如果文件是一个inode,它使用I/O位移作为写操作的位移,然后增加这个位移。管道没有位移的概念。要记得inode函数需要调用者管理锁定。inode的锁定有一个方便的附加效果,即读写位移是自动更新的,因此同时对一个文件的多个写操作不会覆盖彼此的数据,但这些写操作可能会交错在一起。
in sysfile.c
底层实现了大部分功能,系统调用这一级的实现是微不足道的。只有少数的系统调用值得研究一下。
函数sys_link
和sys_unlink
编辑目录,创建或删除对inode的引用。它们是好的例子来展现使用事务的强大。sys_link
从获取它的两个字符串参数old
和new
开始。如果old
存在且不是目录,递增它的ip->nlink
计数。然后调用nameiparent
来找到new
的父目录和最终的路径元素,并创建一个新的目录条目来指向old
的inode。新的上级目录必须存在且和inode在同一设备上:inode号在单独的磁盘上有唯一的意义。如果发生了这样的错误,sys_link
必须回退且递减ip->nlink
。
事务简化了这个实现,因为它需要更新多个磁盘块,但我们不必担心更新的次序。要么全部更新,要么全不更新。如果没有事务,在创建一个连接之前更新ip->nlink
,可能会让文件系统临时进入一个不安全的状态,此时的崩溃可能导致严重的破坏。如果有了事务我们就不必担心这点了。
sys_link
为一个存在的inode创建了一个新的名称。函数create
为一个新inode创建一个新的名称。它是三个系统调用的通用操作:open
使用O_CREATE
标志创建一个新的普通文件,mkdir
创建一个新的目录,mkdev
创建一个新的设备文件。像sys_link
一样,create
首先调用nameiparent
来获取上级目录的inode。然后调用dirlookup
检查名称是否存在。如果名称存在,create
的行为依赖于它是被哪个系统调用使用:对于mkdir
和mkdev
来说open
有不同的语义。如果create
是代表open
(type == T_FILE
)使用的,并且名称存在且是一个常规文件,那么open
就算是成功了,create
也是。否则,就是一个错误。如果名称不存在,create
使用ialloc
创建一个分配个新的inode。如果新的inode是一个目录,create
使用.
和..
条目初始化它。最后,数据被正确初始化了,create
把它链接进上级目录。create
像sys_link
一样,同时持有了两个inode锁:ip
和dp
。没有死锁的可能,因为inodeip
是最新分配的:系统中没有其它进程会持有ip
的锁,所以也就不会尝试锁定dp
。使用create
,可以轻松实现sys_open
、sys_mkdir
和sys_mknod
。sys_open
是最复杂的,因为创建一个新文件只是它的工作的一小部分。如果给open
传递O_CREATE
,它会调用create
。否则,它调用namei
。create
返回一个上锁的inode,但namei
不会,所以sys_open
必须自己锁定这个inode。这可以方便的检查目录是为读而打开的,不是写。假定inode是这样或那样获得的,sys_open
分配一个文件和一个文件描述符,然后填满这个文件。注意没有其它进程可以访问部分初始化的文件,因为它只存在于当前进程的表中。
在调度那一章里,在没有文件系统前,就研究了管道的实现。函数sys_pipe
通过提供创建管道对的方法连接到文件系统的实现。它的参数是一个数组指针,数组里有两个整数,它将在里面记录两个新的文件描述符。然后它分配管道并保存文件描述符。