重读 xv6(VI)
xv6 文件系统
八 #
现在我们讨论文件系统,分七层抽象:
- 文件描述符,作为 Unix 对文件、设备等资源的抽象
- 路径、目录、inode,用于描述文件位置
- 日志层,用于崩溃恢复,提供事务服务
- buffer,缓存区,减少磁盘 IO
- disk,存储文件的物理媒介
我们采用自底向上的顺序进行分析
buffer 层 #
源码文件:bio.c
bufferCache 的结构很简单
- 固定大小的
block缓冲区 block内部存字节,记录对应的磁盘位置- 通过
virtio_disk驱动与下一层disk交互
buffer 填满时,用 LRU 进行汰换,因此采用双向链表组织 block,同时用 pin/unpin 为每个块维护引用次数
buffer为上一层提供了读写封装,用户只需要调用bread/bwrite即可,与磁盘的 IO 交互是透明的
注意这里,每个
buffer block可能会面对多个进程的并发读写,因此需要锁控制,可以采用类似之前kalloc的方法,利用哈希映射减小锁粒度
log 层 #
源码文件:log.c
日志层的目的是实现 WAL,写入磁盘前先将操作日志记录下来,崩溃时可以恢复
多进程进行并发读写时,log层会记录当前的事务计数,直到所有事务都结束时,才会进行 commit,因此 xv6 一次提交包含多个事务的内容
log 记录的信息:
- 当前事务计数
- log 涉及的 block 表
- log 块在磁盘上的位置
log 层读写流程如下:
- 启动事务后,先写入 buffer,在 block 表中记录本次写入的 buffer 位置,钉住该块防止汰换
- 当允许 commit 后,将数据从 buffer 复制到磁盘上的 log block
- 接着,向 log block 写入本次 commit 的元数据,指示 log 块数据完好
- 最后才是从 log block 向真正的磁盘地址写入数据
- 抹除 log block 中的元数据,指示 commit 结束,准备接收新 log
分成这几步,目的是为崩溃恢复提供可靠信息
log 为上层提供了
beginop/endop逻辑与log_write接口,用户只需要启用事务,并写入日志即可这里 log 层隐藏了延时 commit 到磁盘的过程,用户只需要写入,而不关心真正完成的时间
inode 层 #
源码文件:fs.c
buffer 与 log 仅仅是磁盘 IO 的行为抽象,只是隐藏了一些与磁盘交互的细节,换言之,我们目前对磁盘空间并没有任何的抽象,inode 层就是解决这一问题的
我认为
buffer、log应该是与inode层互相独立的抽象,inode只是调用这些接口与磁盘进行交互,在概念上不存在上下层的关系
该层分三块:
block,对磁盘上字节块的抽象inode,对文件的抽象path,对文件树的抽象
block 层提供 balloc/bfree 接口,通过位图管理磁盘 block 资源,本质与 kalloc 的链表没有区别
inode 层记录 file 的长度、类型、位置等元数据
inode存储在两个位置,磁盘上的iblock与内存中的itable缓存- 读写
inode的流程与buffer的设计类似 - 这里实现管理
inode资源的方式,其实就是遍历table,与proctable的进程资源管理没区别
inode 用 addrs[] 存储文件 block 的地址,在此基础上设计了两个辅助函数:
bmap为给定的file block号查找到磁盘地址的映射,调用balloc接口获取资源itrunc则相反,用于递归释放inode占用的block资源映射,调用bfree接口
这两个函数共用一套逻辑,我们这里讨论下多层映射的设计
实际上,这里的映射与虚拟内存中用到的多级页表一样,只不过
inode使用的key是内部的 block 编号,而不是绝对地址
因此bmap本质是对block num进行了分类讨论,大于一个临界值,我们就认为这个地址采用了多层映射,就通过预留的地址递归查询下去xv6 配置
NDIRECT = 11, NINDIRECT = 256,借此实现大文件inode
但这也仅仅是一层映射,我们可以再多设计一层NSUPER = 256,从而实现超大文件的存储映射
借助 bmap 接口,系统隐藏了 inode 偏移量与物理地址的映射关系
因此我们现在可以直接操控 inode 的文件内容,通过偏移量进行内存操作
在此基础上,实现读写文件的 readi/writei 接口,readi 流程:
- 以
block size为尺度,枚举偏移量 - 调用
bmap获取block在磁盘上的真实地址映射 - 调用
bread获取block的内容缓存 - 对
buffer调用copyout,将内容从buffer写入到用户空间
这层接口就完成了 用户空间到磁盘文件 的读写抽象
path 层 #
既然已经完成了直接读写 file 的抽象,path 的实现就好理解了,利用 inode 存储 filename 与 childfile 即可
目录 directory 实现:
dirent结构,存储inode号与filename- 调用
readi对目录文件进行读写,依次读出所有filename - 查找需要的
inode,进行处理即可
在此基础上,加一些字符串处理,再递归查询 filename,就是 path 了
软连接也好,硬链接也好,本质上都是
inode与filename之间的映射关系,没有path的层级映射,文件就只是磁盘上散落的单层inode而已
文件描述符 #
源码文件:file.c
在 file 之上,我们统一了文件描述符这一抽象,用来描述:
- 文件
inode - 管道
pipe - 设备
device
file 对象维护以下信息:
- 描述类型
- 当前偏移量
- 读写权限
- 对应资源的访问地址
我们采用 file table 管理 file 资源,与之前的实现一致
而在读写时,我们先检查 file->type 再进行处理,即可对上层屏蔽 file 的具体类型
明确
path的查找原理后,我们便可以实现符号链接了,只需要增加一个F_SYMBOL类型,存储对应的pathname、symbol、inode映射,再进行递归查找即可
在此基础上,我们提供了以下接口:
fileread / filewrite读写filestat状态filealloc / fileclose资源管理
调用
filewrite时,必须要调用begin_op启用log层,包裹下层读写操作,因为已经是文件系统的最上层抽象了
重新启动 #
加载已有的文件系统时,如何得知 inode block、bitmap block 的位置呢,就是通过预先配置的 superblock 所记录的偏移量进行初始化
那么现在我们就有能力得知 xv6 启动时,是如何初始化文件系统的
binit初始化buffer的双向链表iinit初始化inode table,用于管理inode缓存fileinit初始化file table表- 第一次运行
forkret时调用fsinit,从磁盘上读出superblock,调用initlog恢复先前崩溃中断的log
我们将
superblock固定分配到block 1,因此可以直接从磁盘上读出信息
此时将superblock加载到内存对象中,之后就不需要反复读取
配置好 inode 工作的环境,存储在磁盘上的 path 也就成功投入使用
xv6 存储在磁盘上的对象,如
dinode、dirent,全部都是固定大小的,方便计算偏移量
这节我们分析了整个文件系统的层级结构,理解 xv6 是如何将磁盘 IO 进行不断的抽象封装,进而实现精简接口的,认识到每层封装的目的性,这是最有价值的部分