重读 xv6(VI)

xv6 文件系统

#

现在我们讨论文件系统,分七层抽象:

  1. 文件描述符,作为 Unix 对文件、设备等资源的抽象
  2. 路径、目录、inode,用于描述文件位置
  3. 日志层,用于崩溃恢复,提供事务服务
  4. buffer,缓存区,减少磁盘 IO
  5. disk,存储文件的物理媒介

“文件系统层次”
文件系统层次

我们采用自底向上的顺序进行分析

buffer 层 #

源码文件:bio.c

bufferCache 的结构很简单

  1. 固定大小的 block 缓冲区
  2. block 内部存字节,记录对应的磁盘位置
  3. 通过 virtio_disk 驱动与下一层 disk 交互

buffer 填满时,用 LRU 进行汰换,因此采用双向链表组织 block,同时用 pin/unpin 为每个块维护引用次数

buffer 为上一层提供了读写封装,用户只需要调用 bread/bwrite 即可,与磁盘的 IO 交互是透明的

注意这里,每个 buffer block 可能会面对多个进程的并发读写,因此需要锁控制,可以采用类似之前 kalloc 的方法,利用哈希映射减小锁粒度

log 层 #

源码文件:log.c

日志层的目的是实现 WAL,写入磁盘前先将操作日志记录下来,崩溃时可以恢复

磁盘布局
磁盘布局

多进程进行并发读写时,log层会记录当前的事务计数,直到所有事务都结束时,才会进行 commit,因此 xv6 一次提交包含多个事务的内容

log 记录的信息:

  1. 当前事务计数
  2. log 涉及的 block 表
  3. log 块在磁盘上的位置

log 层读写流程如下:

  1. 启动事务后,先写入 buffer,在 block 表中记录本次写入的 buffer 位置,钉住该块防止汰换
  2. 当允许 commit 后,将数据从 buffer 复制到磁盘上的 log block
  3. 接着,向 log block 写入本次 commit 的元数据,指示 log 块数据完好
  4. 最后才是从 log block 向真正的磁盘地址写入数据
  5. 抹除 log block 中的元数据,指示 commit 结束,准备接收新 log

分成这几步,目的是为崩溃恢复提供可靠信息

log 为上层提供了 beginop/endop 逻辑与 log_write 接口,用户只需要启用事务,并写入日志即可

这里 log 层隐藏了延时 commit 到磁盘的过程,用户只需要写入,而不关心真正完成的时间

inode 层 #

源码文件:fs.c

bufferlog 仅仅是磁盘 IO 的行为抽象,只是隐藏了一些与磁盘交互的细节,换言之,我们目前对磁盘空间并没有任何的抽象,inode 层就是解决这一问题的

我认为 bufferlog 应该是与 inode 层互相独立的抽象,inode 只是调用这些接口与磁盘进行交互,在概念上不存在上下层的关系

该层分三块:

  1. block,对磁盘上字节块的抽象
  2. inode,对文件的抽象
  3. path,对文件树的抽象

block 层提供 balloc/bfree 接口,通过位图管理磁盘 block 资源,本质与 kalloc 的链表没有区别

inode 层记录 file 的长度、类型、位置等元数据

  • inode 存储在两个位置,磁盘上的 iblock 与内存中的 itable 缓存
  • 读写 inode 的流程与 buffer 的设计类似
  • 这里实现管理 inode 资源的方式,其实就是遍历 table,与 proctable 的进程资源管理没区别

inodeaddrs[] 存储文件 block 的地址,在此基础上设计了两个辅助函数:

  1. bmap 为给定的 file block 号查找到磁盘地址的映射,调用 balloc 接口获取资源
  2. itrunc 则相反,用于递归释放 inode 占用的 block 资源映射,调用 bfree 接口

这两个函数共用一套逻辑,我们这里讨论下多层映射的设计

实际上,这里的映射与虚拟内存中用到的多级页表一样,只不过 inode 使用的 key 是内部的 block 编号,而不是绝对地址
因此 bmap 本质是对 block num 进行了分类讨论,大于一个临界值,我们就认为这个地址采用了多层映射,就通过预留的地址递归查询下去

xv6 配置 NDIRECT = 11, NINDIRECT = 256,借此实现大文件 inode
但这也仅仅是一层映射,我们可以再多设计一层 NSUPER = 256 ,从而实现超大文件的存储映射

借助 bmap 接口,系统隐藏了 inode 偏移量与物理地址的映射关系

因此我们现在可以直接操控 inode 的文件内容,通过偏移量进行内存操作

在此基础上,实现读写文件的 readi/writei 接口,readi 流程:

  1. block size 为尺度,枚举偏移量
  2. 调用 bmap 获取 block 在磁盘上的真实地址映射
  3. 调用 bread 获取 block 的内容缓存
  4. buffer 调用 copyout,将内容从 buffer 写入到用户空间

这层接口就完成了 用户空间到磁盘文件 的读写抽象

path 层 #

既然已经完成了直接读写 file 的抽象,path 的实现就好理解了,利用 inode 存储 filenamechildfile 即可

目录 directory 实现:

  1. dirent 结构,存储 inode 号与 filename
  2. 调用 readi 对目录文件进行读写,依次读出所有 filename
  3. 查找需要的 inode,进行处理即可

在此基础上,加一些字符串处理,再递归查询 filename,就是 path

软连接也好,硬链接也好,本质上都是 inodefilename 之间的映射关系,没有 path 的层级映射,文件就只是磁盘上散落的单层 inode 而已

文件描述符 #

源码文件:file.c

file 之上,我们统一了文件描述符这一抽象,用来描述:

  1. 文件 inode
  2. 管道 pipe
  3. 设备 device

file 对象维护以下信息:

  1. 描述类型
  2. 当前偏移量
  3. 读写权限
  4. 对应资源的访问地址

我们采用 file table 管理 file 资源,与之前的实现一致

而在读写时,我们先检查 file->type 再进行处理,即可对上层屏蔽 file 的具体类型

明确 path 的查找原理后,我们便可以实现符号链接了,只需要增加一个 F_SYMBOL 类型,存储对应的 pathnamesymbolinode 映射,再进行递归查找即可

在此基础上,我们提供了以下接口:

  • fileread / filewrite 读写
  • filestat 状态
  • filealloc / fileclose 资源管理

调用 filewrite 时,必须要调用 begin_op 启用 log 层,包裹下层读写操作,因为已经是文件系统的最上层抽象了

重新启动 #

加载已有的文件系统时,如何得知 inode blockbitmap block 的位置呢,就是通过预先配置的 superblock 所记录的偏移量进行初始化

那么现在我们就有能力得知 xv6 启动时,是如何初始化文件系统的

  1. binit 初始化 buffer 的双向链表
  2. iinit 初始化 inode table,用于管理 inode 缓存
  3. fileinit 初始化 file table
  4. 第一次运行 forkret 时调用 fsinit,从磁盘上读出 superblock,调用 initlog 恢复先前崩溃中断的 log

我们将 superblock 固定分配到 block 1 ,因此可以直接从磁盘上读出信息
此时将 superblock 加载到内存对象中,之后就不需要反复读取

配置好 inode 工作的环境,存储在磁盘上的 path 也就成功投入使用

xv6 存储在磁盘上的对象,如 dinodedirent,全部都是固定大小的,方便计算偏移量


这节我们分析了整个文件系统的层级结构,理解 xv6 是如何将磁盘 IO 进行不断的抽象封装,进而实现精简接口的,认识到每层封装的目的性,这是最有价值的部分