|
|
|
# 简单虚拟文件系统
|
|
|
|
|
|
|
|
FAT32 是微软的文件系统,而且也有些年纪了,但最重要的是,它与类 Unix 文件系统的观念不同,
|
|
|
|
这就给我们带来了问题。我们最终还是决定实现一个简单的虚拟文件系统。我们重新建立了 `fs.c` 模块,并引入了 Linux 的 VFS 四大组件——超级块 `superblock`、索引节点 `inode`、目录项 `dentry` 和文件 `file`。
|
|
|
|
|
|
|
|
我称之为 VFS,因为它或许确实是一个 VFS,它不关心底层文件系统的类型,也不需要考虑 FAT32 结构带来的影响;
|
|
|
|
但我还以“简单”修饰之,因为目前来说底层还是只支持 FAT32…… 而且文件系统对象的操作函数的接口设计得较粗糙,
|
|
|
|
基本上是需要什么就加什么,这是以 FAT32 为基础进行设计的,因此,如果后续想要支持别的文件系统,
|
|
|
|
这些接口可能就很难用,只能扩展或修改。从这个角度来看,它显然不是一个合格的 VFS,但就目前看来应该足够了。
|
|
|
|
|
|
|
|
# 实现思路
|
|
|
|
|
|
|
|
<s>“抄袭”</s>学习Linux。当然,也只能学习 Linux 的结构体对象设计和操作函数的设计,对于内在逻辑,在已有的文件系统逻辑上,引入 VFS 成员结构,提供抽象封装,屏蔽底层实现。
|
|
|
|
|
|
|
|
这其实就像面向对象的思路。在 VFS 结构中,每类对象除了有自己的结构体定义,还有操作方法集合,
|
|
|
|
其实际上也是结构体,只不过成员变量是函数指针。这也就是抽象的基础,只要文件系统驱动提供函数,VFS
|
|
|
|
就可以通过绑定函数指针去操作文件系统。此外,除了对操作方法的抽象,对文件系统对象本身也有抽象。
|
|
|
|
因为这些结构体是确定的,所有 VFS 操作函数的接口都需要它们,而在同一个抽象层次上,
|
|
|
|
一个实际文件系统的对象需要的信息与这些原生的 VFS 对象不完全兼容。在 Linux 的实现中,
|
|
|
|
可能是由实际对象包含一个 VFS 对象,从而实现转换——在上层使用 VFS 对象,在下层使用包含这一对象的实际对象。
|
|
|
|
举个例子,文件系统上层调用者给下层传入了一个 `inode` 对象,实际上这个 `inode` 本身就是下层先前传给上层的,
|
|
|
|
它其实是一个下层对象的成员变量之一。Linux 通过 `offsetof()`,`container_of()` 等宏定义,
|
|
|
|
可以进行结构体与成员变量之间地址推算,以很灵活地转换它们,Linux 的双向链表就是一个很好的例子。
|
|
|
|
但我们不想做得太过复杂,我们只需要让 VFS 对象包含实际对象就好。
|
|
|
|
|
|
|
|
# 简单 VFS 的设计
|
|
|
|
|
|
|
|
## 一、超级块 `superblock`
|
|
|
|
|
|
|
|
超级块主要记录了文件系统的全局信息,以及相关的操作方法。其结构体定义如下:
|
|
|
|
```C
|
|
|
|
struct superblock {
|
|
|
|
uint blocksz; // 文件系统块大小
|
|
|
|
uint devnum; // 存储设备号
|
|
|
|
struct inode *dev; // 设备文件的信息
|
|
|
|
void *real_sb; // 实际的超级块结构,下层使用
|
|
|
|
struct superblock *next; // 下一个超级块的指针
|
|
|
|
int ref; // 该文件系统的所有 inode 引用计数的总和
|
|
|
|
struct sleeplock sb_lock; // 睡眠锁,通过该超级块调用函数时可能需要
|
|
|
|
struct fs_op op; // 文件系统操作方法
|
|
|
|
struct spinlock cache_lock; // 控制访问目录缓存的自旋锁
|
|
|
|
struct dentry *root; // 根目录目录项
|
|
|
|
};
|
|
|
|
|
|
|
|
struct fs_op {
|
|
|
|
struct inode *(*alloc_inode)(struct superblock *sb); // 创建 inode 的方法
|
|
|
|
void (*destroy_inode)(struct inode *ip); // 销毁 inode 的方法
|
|
|
|
int (*write)(struct superblock *sb, int usr, char *src, // 写文件设备的方法
|
|
|
|
uint64 blockno, uint64 off, uint64 len); // 'usr'表示数据源是否来自用户
|
|
|
|
int (*read)(struct superblock *sb, int usr, char *dst, // 读文件设备的方法
|
|
|
|
uint64 blockno, uint64 off, uint64 len);
|
|
|
|
int (*clear)(struct superblock *sb, uint64 blockno, uint64 blockcnt); // 清空若干块
|
|
|
|
};
|
|
|
|
```
|
|
|
|
为什么要定义这些字段和函数呢?很简单,因为目前需要使用这些信息,其他的很多信息目前还不需要,也就没有加入。
|
|
|
|
对其中的一些字段做一些解释:
|
|
|
|
+ `dev`:一切皆文件,设备也是文件,反过来文件也可以是设备。
|
|
|
|
这个字段的用处就是在 mount 时可以引用被挂载的镜像文件。
|
|
|
|
+ `real_sb`:对于 FAT32 驱动,就指向 FAT32 自身的 Boot Parameter Block 结构体,上层不关心这个字段的值。
|
|
|
|
+ `ref`:在执行 umount 时,根据这个值就可以知道是否有进程在使用该文件系统下的资源,以判断是否拒绝 umount。
|
|
|
|
+ `root`:指向该文件系统的根目录,也就是目录树缓存的根节点。
|
|
|
|
|
|
|
|
## 二、索引节点 `inode`
|
|
|
|
|
|
|
|
我曾对这个结构感到困惑,尤其是一开始编写 FAT32 文件系统支持时,因为 FAT32 上没有 i 节点,文件的信息直接、
|
|
|
|
全部存放在目录项中(因此先前的文件系统中只有目录项结构体)。实际上,这个结构体只是一个在内存中的抽象,
|
|
|
|
其集合了文件的状态、权限等各项属性,至于在磁盘上有没有 i 节点,由底层自行转换。从这个角度看,对于 FAT32,
|
|
|
|
其目录项就是所谓的 i 节点。`inode` 相关定义如下:
|
|
|
|
```C
|
|
|
|
struct inode {
|
|
|
|
uint64 inum; // i节点号,在 FAT32 上,用来记录一个目录项所在的簇号和相对簇的偏移
|
|
|
|
int ref; // 引用计数
|
|
|
|
int state; // inode 状态,我们主要用于记录对应文件是否为脏,是否被删除
|
|
|
|
int mode; // 文件类型和权限,也就是"drwxrw-r--",我们只使用到类型信息
|
|
|
|
int size; // 文件大小
|
|
|
|
int nlink; // 链接数,在 FAT32 上没用,但可以作为删除文件的依据
|
|
|
|
void *real_i;// 实际的 inode
|
|
|
|
struct superblock *sb; // 所属的超级块
|
|
|
|
struct sleeplock lock; // 读写该 inode 在磁盘上信息和对应文件的睡眠保护锁
|
|
|
|
struct inode_op *op; // 针对 inode 自身的相关操作
|
|
|
|
struct file_op *fop; // 对 inode 对应文件的相关操作
|
|
|
|
struct dentry *entry; // 对应的目录项
|
|
|
|
};
|
|
|
|
|
|
|
|
struct inode_op {
|
|
|
|
struct inode *(*create)(struct inode *ip, char *name, int mode); // 创建新文件
|
|
|
|
struct inode *(*lookup)(struct inode *dir, char *filename, uint *poff); // 在目录下搜索文件
|
|
|
|
int (*truncate)(struct inode *ip); // 截断文件,即删除文件内容(不删除 inode)
|
|
|
|
int (*unlink)(struct inode *ip); // 销毁磁盘的 inode(不一定引发文件内容删除)
|
|
|
|
int (*update)(struct inode *ip); // 将 inode 信息更新到磁盘
|
|
|
|
int (*getattr)(struct inode *ip, struct kstat *st); // 获取该 inode 的信息
|
|
|
|
};
|
|
|
|
|
|
|
|
struct file_op {
|
|
|
|
int (*read)(struct inode *ip, int usr, uint64 dst, uint off, uint n); // 读普通文件
|
|
|
|
int (*write)(struct inode *ip, int usr, uint64 src, uint off, uint n); // 写普通文件
|
|
|
|
int (*readdir)(struct inode *ip, struct dirent *dent, uint off); // 读目录文件
|
|
|
|
};
|
|
|
|
```
|
|
|
|
对于 FAT32,我们将一个文件对应的一组目录项看成是一个 i 节点,并将该目录项组的第一个目录项所在的簇、
|
|
|
|
簇内偏移,拼成 i 节点号(两个 uint32 拼成一个 uint64),这样就可以定位一个目录项组所在的位置。
|
|
|
|
如果目录项组跨簇,就查 FAT 表获取下一个簇的信息。由于目录项直接存放在父目录中,按理说在读写目录项时,
|
|
|
|
应当首先获取父目录的锁。但这个操作给抽象过程带来了不小麻烦,我们期望在读写 i 节点信息时,只获取自己的锁。
|
|
|
|
但是,如果不获取父目录的锁,那么当一个 i 节点在目录中修改信息时,就可能会与其父目录的读写发生竞争。
|
|
|
|
|
|
|
|
不过,基于以下观察和约定,我们是可以做到这一点的:
|
|
|
|
1. 通过 `inode` 访问任何文件时,首先需要获取锁,这是应当的;
|
|
|
|
|
|
|
|
2. 由于 FAT32 的目录项结构,我们规定目录项不能直接重命名(因为重命名操作是移动操作的一个特例),
|
|
|
|
而是需要建立新的目录项,再删除已有目录项。这就保证了在读取一组目录项时,对应的文件名不会变化。
|
|
|
|
|
|
|
|
3. 对于目录文件的读操作,在我们的内核中,只有以下场景:
|
|
|
|
|
|
|
|
+ 用户进程发出相关系统调用读取目录。在这种情况下,即使发生竞争,代价也不大,因为文件名不会随意变,
|
|
|
|
最多只是其他属性得不到最新值。在最坏情况下,即目录项正在被删除,读者可以检测到格式错误的目录项,
|
|
|
|
当前 hart 可以尝试重新读取该位置的目录项来获取最新结果;
|
|
|
|
|
|
|
|
+ 在一个目录下搜索文件。这是按名搜索,而文件名不会随意变化。并且,如果一个文件可以修改自己的目录项,
|
|
|
|
这就意味这它先前已经被打开过,如果它是目标文件,那么在上层的目录缓冲中应该能找到它,不会读其父目录;
|
|
|
|
也就是说需要在磁盘上寻找的文件,都是没被打开过的文件,也不会有其他访问者,因为目录锁被搜索者持有;
|
|
|
|
|
|
|
|
4. 通过目录自身的 `inode` 对目录的写操作只发生在创建文件时。考虑这三点:
|
|
|
|
|
|
|
|
+ 首先,文件创建者需要拿到目录的锁,这保证了不会有其他创建者;
|
|
|
|
|
|
|
|
+ 其次,创建文件前需要搜索目录以判断是否已存在同名文件,但这一情况在第 3 点已经说明;此外,
|
|
|
|
搜索过程中还需要查找空的目录项槽位。此时,某些 `inode` 可能正在删除自己,从而产生一些新的槽位。
|
|
|
|
但这能导致的冲突,无非就是创建者可能会错过这一空槽位,最坏情况下,创建者会认为读到了不合格式的目录项,
|
|
|
|
会考虑重读;
|
|
|
|
|
|
|
|
+ 最后,创建时只会向空的目录项位置写入,不会影响已存在的文件,这时也就允许已打开的文件更新自己的目录项。
|
|
|
|
|
|
|
|
综上可知,在我们的设定下,对目录的直接操作与子文件产生的目录操作所导致的冲突竞争是非常有限的,
|
|
|
|
稍加处理就可避免。实际上,这些冲突发生的概率应该不是很大(猜的),更何况,底层的磁盘缓冲还有一套锁。
|
|
|
|
因此,我们允许文件系统上层在更新 i 节点信息时可以忽略父目录的访问控制,仿佛 i 节点不在父目录中,
|
|
|
|
而在磁盘专门划分的 i 节点区域上,这就屏蔽了 FAT32 与索引文件系统的结构差异。
|
|
|
|
|
|
|
|
## 三、目录项 `dentry`
|
|
|
|
|
|
|
|
如果说 `inode` 关注的是单个文件的信息,那么目录项 `dentry` 关注的是则是文件的树状结构。在这一层,
|
|
|
|
打开过的文件会形成一个树状结构缓存。当需要打开文件时,首先在这一层进行查找,若找不到才进一步读磁盘。
|
|
|
|
相关定义如下:
|
|
|
|
```C
|
|
|
|
struct dentry {
|
|
|
|
char filename[MAXNAME + 1]; // 文件名
|
|
|
|
struct inode *inode; // 对应的 inode
|
|
|
|
struct dentry *parent; // 父目录
|
|
|
|
struct dentry *next; // 父目录中的其他文件
|
|
|
|
struct dentry *child; // 如果为目录,指向其中第一个文件
|
|
|
|
struct dentry_op *op;
|
|
|
|
struct superblock *mount; // mount 在该目录上的文件系统
|
|
|
|
};
|
|
|
|
|
|
|
|
struct dentry_op {
|
|
|
|
int (*delete)(struct dentry *de); // 从目录树中摘除
|
|
|
|
};
|
|
|
|
```
|
|
|
|
当 `mount` 不为空时,表明该目录项对应的目录文件被挂载了一个文件系统。若在搜索目录项缓存时找到这样的目录项,
|
|
|
|
就需要返回所挂载文件系统的根目录。
|
|
|
|
|
|
|
|
## 四、文件 `file`
|
|
|
|
|
|
|
|
虽说名称是文件,但本质上只是个文件句柄,严格来讲不属于文件系统管理的范围,文件系统中没有涉及 `file` 结构体。
|
|
|
|
因此我们直接沿用了 xv6 原本的设计。`file` 结构体与进程相关,在用户态,进程使用文件描述符,在内核态,
|
|
|
|
通过用户给定的文件描述符获得对应的 `file` 结构体,结构体中包含 `inode` 信息,文件的打开方式、读写位置等信息。
|
|
|
|
一个文件句柄对应的不一定是文件,也可以是设备、管道等可以抽象成文件的实体。
|
|
|
|
|
|
|
|
## 五、文件描述符表 `fdtable`
|
|
|
|
|
|
|
|
既然都认为 `file` 与文件系统无关了,那么文件描述符离文件系统就更远了。但总归还是文件相关概念,也就写在这了。
|
|
|
|
原始 xv6 的文件描述符表很简单,就是一个 `file` 指针数组,直接放在进程控制块中。但这个数组的大小就被定死了,
|
|
|
|
进程不能打开过多的文件。
|
|
|
|
|
|
|
|
为解决这个问题,我们设计了新的文件描述符表,其定义如下。
|
|
|
|
```C
|
|
|
|
#define NOFILE 16
|
|
|
|
struct fdtable {
|
|
|
|
uint16 basefd;
|
|
|
|
uint16 nextfd;
|
|
|
|
uint16 used;
|
|
|
|
uint16 exec_close;
|
|
|
|
struct file *arr[NOFILE];
|
|
|
|
struct fdtable *next;
|
|
|
|
};
|
|
|
|
```
|
|
|
|
由于结构体中包含 8 字节的指针类型,需要按 8 字节对齐,前四个成员都声明成 2 字节整型,正好凑成 8 字节。
|
|
|
|
描述符表是一个链表,每个节点中是一个数组,整体看来就是链表和数组的结合,链表提供扩展性,数组提升查询效率。
|
|
|
|
各个字段作用如下。
|
|
|
|
|
|
|
|
+ `basefd`:该表的基础描述符编号
|
|
|
|
|
|
|
|
一个表记录 [`basefd`, `basefd` + `NOFILE`) 范围内的描述符对应的文件指针,这为查表提供了依据。
|
|
|
|
整个描述表以此为排序的主键,当所需描述符对应的表不存在时,就建立新表并插入到对应位置中。
|
|
|
|
|
|
|
|
+ `nextfd`:下一个最小可用的位置
|
|
|
|
|
|
|
|
通常情况下,文件描述符应分配可用的最小值。该成员就记录了下一个可分配的位置,即 `basefd` + `nextfd。`
|
|
|
|
接着 `nextfd` 会循环自增查询下一个可用位置。如果没找到,就停在 `NOFILE` 这个值上,也就是循环结束条件,
|
|
|
|
同时也表示当前表已满。
|
|
|
|
|
|
|
|
如果需要指定一个确定的描述符值,首先会找到对应的表,接着判断这个描述符是否已被占用,以进行替换。若否,
|
|
|
|
则需要判断这个描述符是否对应了 `nextfd`,以更新 `nextfd` 的值。
|
|
|
|
|
|
|
|
当释放给定描述符时,如果描述符值比 `nextfd` 小,就可直接让 `nextfd` 变为这个值。
|
|
|
|
|
|
|
|
+ `used`:该表中的使用计数。
|
|
|
|
|
|
|
|
分配时自增,释放时自减,若释放后该值为 0,则可以将该表释放,但是不会释放头节点。
|
|
|
|
|
|
|
|
+ `exec_close`:记录在执行 `exec()` 时需要关闭的文件描述符。
|
|
|
|
|
|
|
|
一个表中有 `NOFILE` 个描述符,我们设定为 16 个,那么用一个 16 位的整型就可以记录这个信息。
|
|
|
|
|
|
|
|
+ `arr`:描述符表的内容,即文件指针数组。
|
|
|
|
|
|
|
|
+ `next`:指向下一个表。
|
|
|
|
|
|
|
|
每个进程控制块中有一个默认的描述符表实体,当需要更多描述符或指定的描述符不在表示范围内时,才扩展描述符链表。
|
|
|
|
|
|
|
|
# 简单 VFS 的功能
|
|
|
|
|
|
|
|
## 一、文件及其描述符层
|
|
|
|
这一层是用户与内核的交互层。在用户层面使用文件描述符,在内核中通过描述符找到对应文件的结构体,及其 i 节点,
|
|
|
|
进而完成文件操作。
|
|
|
|
|
|
|
|
### 1. 文件读写
|
|
|
|
用户进程通过文件描述符和 `open()`/`read()`/`write()` 等系统调用完成文件的操作。若文件结构对应的是 i 节点,
|
|
|
|
即一个真实的文件,那么就通过虚拟文件系统层的接口对底层文件进行读写。在这里,不需要关心文件的底层结构,
|
|
|
|
只需要给出读写位置的偏移和数据长度即可。
|
|
|
|
|
|
|
|
### 2. 设备读写
|
|
|
|
与普通文件的操作完全相同,对应的也是一个 i 节点,只不过文件操作函数的底层实现可能与普通文件不同,
|
|
|
|
这一点由下层负责。在原 xv6 中,文件层需要区分设备和普通文件,而在 xv6-k210 的 VFS 中,则不再需要区分。
|
|
|
|
我们对 `console` 设备就实现了这样的支持。控制台模块实现自己的读写操作,VFS 将这些操作函数与其 inode 绑定,
|
|
|
|
这样就可以访问 `console` 设备了。
|
|
|
|
|
|
|
|
### 3. 管道通信
|
|
|
|
实际上管道并不是真实的文件,而是内核中的一片内存缓冲区,只不过用户进程可以通过文件的方式来访问它。
|
|
|
|
一个管道对应两个文件以及其描述符,其一为写端,另一个则为读端,进程之间可以用其进行通信。
|
|
|
|
|
|
|
|
## 虚拟文件系统逻辑层
|
|
|
|
这一层为内核中的所有文件操作提供了接口,也就是说这一层在整个文件系统层次中,负责与内核进行耦合,
|
|
|
|
也就是需要实现访问控制、引用计数、文件查找与缓冲等功能,同时还需要定义文件操作的接口,具体实现由下层完成。
|
|
|
|
一些具体功能如下所述。
|
|
|
|
|
|
|
|
### 1. 文件查找与缓冲
|
|
|
|
在这一层中,我们维护了一个目录树,由 `dentry` 对象构成。每个文件系统的超级块持有对其根目录的引用,
|
|
|
|
当需要按绝对路径查找文件时,从根文件系统的根目录开始查找,也就是在目录树缓冲中查找,当查找失败时,
|
|
|
|
才会通过 VFS 接口调用底层文件系统所绑定的 `lookup()` 函数,在磁盘中查找。若能找到,则将这个文件加入到缓冲中。
|
|
|
|
这里的 `lookup()` 函数只是在一个目录下进行查找,而多层目录(即路径)的查找,则由 VFS 多次调用目录查找来完成。
|
|
|
|
xv6 中有 `namei()` 和 `nameiparent()` 函数,就是多层目录搜索的封装,支持从根目录和当前目录开始查找。
|
|
|
|
我们使用了这些函数的逻辑框架,并扩展了其接口,支持从任意一个给定 i 节点开始的查找,额外提供了 `nameifrom()`
|
|
|
|
和 `nameiparentfrom()` 函数。此外,我们还提供了 `namepath()` 函数,对与给定 i 节点,从目录树中向上搜索,
|
|
|
|
以获得其绝对路径。这也是 `getcwd()` 系统调用的实现基础。
|
|
|
|
|
|
|
|
### 2. 文件系统的挂载
|
|
|
|
只需要在目录树上“做手脚”,就可以很轻松地实现文件系统的简单挂载。在被挂载的目录项上记录对应的超级块信息,
|
|
|
|
就可以实现搜索重定向。对于向下搜索,只需要查询超级块中的根目录即可;对于向上返回,则稍微麻烦一些,
|
|
|
|
需要判断当前目录项是否为其超级块的根目录,以确定是否越过了挂载点。
|
|
|
|
|
|
|
|
### 3. 根文件系统的初始化
|
|
|
|
既然有了 VFS 的概念,我们就可以虚拟一个文件系统。没错,xv6-k210 的根文件系统是虚拟的。根文件系统初始化时,
|
|
|
|
首先初始化静态声明的根超级块结构,接着虚拟出几个 `inode` 和 `dentry` 内存对象,形成一个简单的目录结构。
|
|
|
|
目前来说,这个结构如下。
|
|
|
|
```
|
|
|
|
/
|
|
|
|
├── dev
|
|
|
|
│ ├── console
|
|
|
|
│ └── vda2
|
|
|
|
└── home
|
|
|
|
```
|
|
|
|
其中,`console` 就是控制台输入输出设备,`vda2` 就是磁盘设备。由于都是虚拟文件,它们的 `inode`
|
|
|
|
所绑定的函数大多都是直接返回。这个目录树建立好后,再把 `vda2` 设备中的文件系统挂载到 `home` 目录下。
|
|
|
|
这个真正的文件系统的初始化当然是通过调用底层函数完成。
|
|
|
|
|
|
|
|
在先前直接以 FAT32 文件系统作为根文件系统的版本,我们无法通过 `open()` 调用打开 `console` 设备,
|
|
|
|
除非在内核中假定 FAT32 中的 `console` 文件是设备文件,但我认为这样是不好的。因此当时我做了一个 `dev`
|
|
|
|
系统调用专门用来打开控制台设备,当然这样也是很不优雅的。但有了 VFS 后,我们就可以废除这个系统调用,
|
|
|
|
恢复用 `open()` 打开 `console` 的正常操作了。
|
|
|
|
|
|
|
|
## 实际文件系统驱动层
|
|
|
|
这一层就是具体文件系统的逻辑实现层,其实现需要适应上层定义的接口,不需要过多关注文件系统之外的内核细节。
|
|
|
|
在这一层中,我们只支持了 FAT32 驱动。
|
|
|
|
|
|
|
|
## 文件系统设备驱动层
|
|
|
|
这一层的内容不多,其实就是实际文件系统的所在的设备的访问接口。我们可以从设备上挂载文件系统,
|
|
|
|
也可以从文件镜像中挂载文件系统。实际文件系统只关心文件系统的逻辑组织,而不关心其所在设备的访问方式。
|
|
|
|
这一层就提供了这样的封装,不同的设备将它的对应操作绑定到文件系统的超级块中,就可以被上层调用。 |
|
|
|
\ No newline at end of file |