add os[1-8]-ref for os refereces, add guide, add README

This commit is contained in:
Yu Chen
2022-06-27 22:22:44 +08:00
parent 7c1679774c
commit d752a67137
360 changed files with 32863 additions and 1 deletions

View File

@@ -0,0 +1,159 @@
引言
=========================================
本章导读
-----------------------------------------
本章我们将实现一个简单的文件系统 -- easyfs能够对 **持久存储设备** (Persistent Storage) I/O 资源进行管理;将设计两种文件:常规文件和目录文件,它们均以文件系统所维护的 **磁盘文件** 形式被组织并保存在持久存储设备上。
实践体验
-----------------------------------------
获取本章代码:
.. code-block:: console
$ git clone https://github.com/LearningOS/rCore-Tutorial-Code-2022S.git
$ cd rCore-Tutorial-Code-2022S
$ git checkout ch6
在 qemu 模拟器上运行本章代码:
.. code-block:: console
$ cd os
$ make run
内核初始化完成之后就会进入shell程序在这里我们运行一下本章的测例 ``ch6b_filetest_simple``
.. code-block::
>> ch6b_filetest_simple
file_test passed!
Shell: Process 2 exited with code 0
>>
它会将 ``Hello, world!`` 输出到另一个文件 ``filea`` ,并读取里面的内容确认输出正确。我们也可以通过命令行工具 ``ch6b_cat`` 来查看 ``filea`` 中的内容:
.. code-block::
>> ch6b_cat
Hello, world!
Shell: Process 2 exited with code 0
>>
本章代码树
-----------------------------------------
.. code-block::
:linenos:
├── easy-fs(新增:从内核中独立出来的一个简单的文件系统 EasyFileSystem 的实现)
│   ├── Cargo.toml
│   └── src
│   ├── bitmap.rs(位图抽象)
│   ├── block_cache.rs(块缓存层,将块设备中的部分块缓存在内存中)
│   ├── block_dev.rs(声明块设备抽象接口 BlockDevice需要库的使用者提供其实现)
│   ├── efs.rs(实现整个 EasyFileSystem 的磁盘布局)
│   ├── layout.rs(一些保存在磁盘上的数据结构的内存布局)
│   ├── lib.rs
│   └── vfs.rs(提供虚拟文件系统的核心抽象,即索引节点 Inode)
├── easy-fs-fuse(新增:将当前 OS 上的应用可执行文件按照 easy-fs 的格式进行打包)
│   ├── Cargo.toml
│   └── src
│   └── main.rs
├── os
   ├── build.rs(修改:不再需要将用户态程序链接到内核中)
   ├── Cargo.toml(修改:新增 Qemu 的块设备驱动依赖 crate)
   ├── Makefile(修改:新增文件系统的构建流程)
   └── src
   ├── config.rs(修改:新增访问块设备所需的一些 MMIO 配置)
   ├── ...
   ├── drivers(新增Qemu 平台的块设备驱动)
   │   ├── block
   │   │   ├── mod.rs(将不同平台上的块设备全局实例化为 BLOCK_DEVICE 提供给其他模块使用)
   │   │   └── virtio_blk.rs(Qemu 平台的 virtio-blk 块设备)
   │   └── mod.rs
   ├── fs(新增:对文件系统及文件抽象)
   │   ├── inode.rs(新增:将 easy-fs 提供的 Inode 抽象封装为内核看到的 OSInode
   │   │ 并实现 fs 子模块的 File Trait)
   │   ├── mod.rs
   │   └── stdio.rs(新增:将标准输入输出也抽象为文件)
   ├── loader.rs(移除:应用加载器 loader 子模块,本章开始从文件系统中加载应用)
   ├── mm
   │   ├── address.rs
   │   ├── frame_allocator.rs
   │   ├── heap_allocator.rs
   │   ├── memory_set.rs(修改:在创建地址空间的时候插入 MMIO 虚拟页面)
   │   ├── mod.rs
   │   └── page_table.rs(新增:应用地址空间的缓冲区抽象 UserBuffer 及其迭代器实现)
   ├── syscall
   │   ├── fs.rs(修改:新增 sys_open修改sys_read、sys_write)
   │   ├── mod.rs
   │   └── process.rs(修改sys_exec 改为从文件系统中加载 ELF)
   ├── task
      ├── context.rs
      ├── manager.rs
      ├── mod.rs(修改:初始进程 INITPROC 的初始化)
      ├── pid.rs
      ├── processor.rs
      ├── switch.rs
      ├── switch.S
      └── task.rs(修改:在任务控制块中加入文件描述符表的相关机制)
cloc easy-fs os
-------------------------------------------------------------------------------
Language files blank comment code
-------------------------------------------------------------------------------
Rust 41 306 418 3349
Assembly 4 53 26 526
make 1 13 4 48
TOML 2 4 2 23
-------------------------------------------------------------------------------
SUM: 48 376 450 3946
-------------------------------------------------------------------------------
.. 本章代码导读
.. -----------------------------------------------------
.. 本章涉及的代码量相对较多且与进程执行相关的管理还有直接的关系。其实我们是参考经典的UNIX基于索引的文件系统设计了一个简化的有一级目录并支持创建/打开/读写/关闭文件一系列操作的文件系统。这里简要介绍一下在内核中添加文件系统的大致开发过程。
.. 第一步是能够写出与文件访问相关的应用。这里是参考了Linux的创建/打开/读写/关闭文件的系统调用接口,力图实现一个 :ref:`简化版的文件系统模型 <fs-simplification>` 。在用户态我们只需要遵从相关系统调用的接口约定,在用户库里完成对应的封装即可。这一过程我们在前面的章节中已经重复过多次,读者应当对其比较熟悉。其中最为关键的是系统调用可以参考 :ref:`sys_open 语义介绍 <sys-open>` ,此外我们还给出了 :ref:`测例代码解读 <filetest-simple>` 。
.. 第二步就是要实现 easyfs 文件系统了。由于 Rust 语言的特点,我们可以在用户态实现 easyfs 文件系统,并在用户态完成文件系统功能的基本测试并基本验证其实现正确性之后,就可以放心的将该模块嵌入到操作系统内核中。当然,有了文件系统的具体实现,还需要对上一章的操作系统内核进行扩展,实现与 easyfs 文件系统对接的接口,这样才可以让操作系统拥有一个简单可用的文件系统。从而,内核可以支持允许文件读写功能的更复杂的应用,在命令行参数机制的加持下,可以进一步提升整个系统的灵活性,让应用的开发和调试变得更为轻松。
.. easyfs 文件系统的整体架构自下而上可分为五层。它的最底层就是对块设备的访问操作接口。在 ``easy-fs/src/block_dev.rs`` 中,可以看到 ``BlockDevice`` trait 代表了一个抽象块设备,该 trait 仅需求两个函数 ``read_block`` 和 ``write_block`` 分别代表将数据从块设备读到内存中的缓冲区中或者将数据从内存中的缓冲区写回到块设备中数据需要以块为单位进行读写。easy-fs 库的使用者需要负责为它们看到的实际的块设备具体实现 ``BlockDevice`` trait 并提供给 easy-fs 库的上层,这样的话 easy-fs 库的最底层就与一个具体的执行环境对接起来了。至于为什么块设备层位于 easy-fs 的最底层,是因为文件系统仅仅是在块设备上存储的结构稍微复杂一点的数据,但无论它的操作变换如何复杂,从块设备的角度终究可以被分解成若干次块读写。
.. 尽管在最底层我们就已经有了块读写的能力,但从编程方便性和性能的角度,仅有块读写这么基础的底层接口是不足以实现如此复杂的文件系统的,虽然它已经被我们大幅简化过了。比如,将一个块的内容读到内存的缓冲区,对缓冲区进行修改,并尚未写回的时候,如果由于编程上的不小心再次将该块的内容读到另一个缓冲区,而不是使用已有的缓冲区,这将会造成不一致问题。此外还有可能增加很多不必要的块读写次数,大幅降低文件系统的性能。因此,通过程序自动而非程序员手动对块的缓冲区进行统一管理也就势在必行了,该机制被我们抽象为 easy-fs 自底向上的第二层,即块缓存层。在 ``easy-fs/src/block_cache.rs`` 中, ``BlockCache`` 代表一个被我们管理起来的块的缓冲区,它带有缓冲区本体以及块的编号等信息。当它被创建的时候,将触发一次 ``read_block`` 将数据从块设备读到它的缓冲区中。接下来只要它驻留在内存中,便可保证对于同一个块的所有操作都会直接在它的缓冲区中进行而无需额外的 ``read_block`` 。块缓存管理器 ``BlockManager`` 在内存中管理有限个 ``BlockCache`` 并实现了类似 FIFO 的缓存替换算法,当一个块缓存被换出的时候视情况可能调用 ``write_block`` 将缓冲区数据写回块设备。总之,块缓存层对上提供 ``get_block_cache`` 接口来屏蔽掉相关细节,从而可以透明的读写一个块。
.. 有了块缓存我们就可以在内存中方便地处理easyfs文件系统在磁盘上的各种数据了这就是第三层文件系统的磁盘数据结构。easyfs文件系统中的所有需要持久保存的数据都会放到磁盘上这包括了管理这个文件系统的 **超级块 (Super Block)**,管理空闲磁盘块的 **索引节点位图区** 和 **数据块位图区** ,以及管理文件的 **索引节点区** 和 放置文件数据的 **数据块区** 组成。
.. easyfs文件系统中管理这些磁盘数据的控制逻辑主要集中在 **磁盘块管理器** 中,这是文件系统的第四层。对于文件系统管理而言,其核心是 ``EasyFileSystem`` 数据结构及其关键成员函数:
.. - EasyFileSystem.create创建文件系统
.. - EasyFileSystem.open打开文件系统
.. - EasyFileSystem.alloc_inode分配inode dealloc_inode未实现所以还不能删除文件
.. - EasyFileSystem.alloc_data分配数据块
.. - EasyFileSystem.dealloc_data回收数据块
.. 对于单个文件的管理和读写的控制逻辑主要是 **索引节点** 来完成,这是文件系统的第五层,其核心是 ``Inode`` 数据结构及其关键成员函数:
.. - Inode.new在磁盘上的文件系统中创建一个inode
.. - Inode.find根据文件名查找对应的磁盘上的inode
.. - Inode.create在根目录下创建一个文件
.. - Inode.read_at根据inode找到文件数据所在的磁盘数据块并读到内存中
.. - Inode.write_at根据inode找到文件数据所在的磁盘数据块把内存中数据写入到磁盘数据块中
.. 上述五层就构成了easyfs文件系统的整个内容。我们可以把easyfs文件系统看成是一个库被应用程序调用。而 ``easy-fs-fuse`` 这个应用就通过调用easyfs文件系统库中各种函数并用Linux上的文件模拟了一个块设备就可以在这个模拟的块设备上创建了一个easyfs文件系统。
.. 第三步我们需要把easyfs文件系统加入到我们的操作系统内核中。这还需要做两件事情第一件是在Qemu模拟的 ``virtio`` 块设备上实现块设备驱动程序 ``os/src/drivers/block/virtio_blk.rs`` 。由于我们可以直接使用 ``virtio-drivers`` crate中的块设备驱动所以只要提供这个块设备驱动所需要的内存申请与释放以及虚实地址转换的4个函数就可以了。而我们之前操作系统中的虚存管理实现中以及有这些函数导致块设备驱动程序很简单具体实现细节都被 ``virtio-drivers`` crate封装好了。
.. 第二件事情是把文件访问相关的系统调用与easyfs文件系统连接起来。在easfs文件系统中是没有进程的概念的。而进程是程序运行过程中访问资源的管理实体这就要对 ``easy-fs`` crate 提供的 ``Inode`` 结构进一步封装,形成 ``OSInode`` 结构,以表示进程中一个打开的常规文件。对于应用程序而言,它理解的磁盘数据是常规的文件和目录,不是 ``OSInode`` 这样相对复杂的结构。其实常规文件对应的 OSInode 是文件在操作系统内核中的内部表示,因此需要为它实现 File Trait 从而能够可以将它放入到进程文件描述符表中,并通过 sys_read/write 系统调用进行读写。这样就建立了文件与 ``OSInode`` 的对应关系,并通过上面描述的三个步骤完成了包含文件系统的操作系统内核,并能给应用提供基于文件的系统调用服务。
.. 完成包含文件系统的操作系统内核后我们在shell程序和内核中支持命令行参数的解析和传递这样可以让应用根据灵活地通过命令行参数来动态地表示要操作的文件。这需要扩展对应的系统调用 ``sys_exec`` ,主要的改动就是在创建新进程时,把命令行参数压入用户栈中,这样应用程序在执行时就可以从用户栈中获取到命令行的参数值了。
.. 在上一章,我们提到了把标准输出设备在文件描述符表中的文件描述符的值规定为 1 ,用 Stdin 表示;把标准输入设备在文件描述符表中的文件描述符的值规定为 0用 stdout 表示 。另外,还有一条文件描述符相关的重要规则:即进程打开一个文件的时候,内核总是会将文件分配到该进程文件描述符表中编号 最小的 空闲位置。利用这些约定,只实现新的系统调用 ``sys_dup`` 完成对文件描述符的复制,就可以巧妙地实现标准 I/O 重定向功能了。
.. 具体思路是,在某应用进程执行之前,父进程(比如 user_shell进程要对子应用进程的文件描述符表进行某种替换。以输出为例父进程在创建子进程前提前打开一个常规文件 A然后 ``fork`` 子进程,在子进程的最初执行中,通过 ``sys_close`` 关闭 Stdout 文件描述符,用 ``sys_dup`` 复制常规文件 A 的文件描述符,这样 Stdout 文件描述符实际上指向的就是常规文件A了这时再通过 ``sys_close`` 关闭常规文件 A 的文件描述符。至此,常规文件 A 替换掉了应用文件描述符表位置 1 处的标准输出文件,这就完成了所谓的 **重定向** ,即完成了执行新应用前的准备工作。
.. 接下来是子进程调用 ``sys_exec`` 系统调用,创建并开始执行新子应用进程。在重定向之后,新的子应用进程认为自己输出到 fd=1 的标准输出文件,但实际上是输出到父进程(比如 user_shell进程指定的文件A中。文件这一抽象概念透明化了文件、I/O设备之间的差异因为在进程看来无论是标准输出还是常规文件都是一种文件可以通过同样的接口来读写。这就是文件的强大之处。

View File

@@ -0,0 +1,243 @@
文件与文件描述符
===========================================
文件简介
-------------------------------------------
文件可代表很多种不同类型的I/O 资源,但是在进程看来,所有文件的访问都可以通过一个简洁统一的抽象接口 ``File`` 进行:
.. code-block:: rust
// os/src/fs/mod.rs
pub trait File : Send + Sync {
fn readable(&self) -> bool;
fn writable(&self) -> bool;
fn read(&self, buf: UserBuffer) -> usize;
fn write(&self, buf: UserBuffer) -> usize;
}
这个接口在内存和I/O资源之间建立了数据交换的通道。其中 ``UserBuffer`` 是我们在 ``mm`` 子模块中定义的应用地址空间中的一段缓冲区,我们可以将它看成一个 ``&[u8]`` 切片。
``read`` 指的是从文件即I/O资源中读取数据放到缓冲区中最多将缓冲区填满即读取缓冲区的长度那么多字节并返回实际读取的字节数``write`` 指的是将缓冲区中的数据写入文件,最多将缓冲区中的数据全部写入,并返回直接写入的字节数。
回过头来再看一下用户缓冲区的抽象 ``UserBuffer`` ,它的声明如下:
.. code-block:: rust
// os/src/mm/page_table.rs
pub fn translated_byte_buffer(
token: usize,
ptr: *const u8,
len: usize
) -> Vec<&'static mut [u8]>;
pub struct UserBuffer {
pub buffers: Vec<&'static mut [u8]>,
}
impl UserBuffer {
pub fn new(buffers: Vec<&'static mut [u8]>) -> Self {
Self { buffers }
}
pub fn len(&self) -> usize {
let mut total: usize = 0;
for b in self.buffers.iter() {
total += b.len();
}
total
}
}
它只是将我们调用 ``translated_byte_buffer`` 获得的包含多个切片的 ``Vec`` 进一步包装起来,通过 ``len`` 方法可以得到缓冲区的长度。此外,我们还让它作为一个迭代器可以逐字节进行读写。有兴趣的读者可以参考类型 ``UserBufferIterator`` 还有 ``IntoIterator````Iterator`` 两个 Trait 的使用方法。
标准输入和标准输出
--------------------------------------------
其实我们在第二章就对应用程序引入了基于 **文件** 的标准输出接口 ``sys_write`` ,在第五章引入标准输入接口 ``sys_read`` 。我们提前把标准输出设备在文件描述符表中的文件描述符的值规定为 ``1`` ,用 ``Stdout`` 表示;把标准输入设备文件描述符规定为 ``0``,用 ``Stdin`` 表示 。现在,我们重写这些系统调用,先为标准输入和标准输出实现 ``File`` Trait
.. code-block:: rust
:linenos:
// os/src/fs/stdio.rs
pub struct Stdin;
pub struct Stdout;
impl File for Stdin {
fn readable(&self) -> bool { true }
fn writable(&self) -> bool { false }
fn read(&self, mut user_buf: UserBuffer) -> usize {
assert_eq!(user_buf.len(), 1);
// busy loop
let mut c: usize;
loop {
c = console_getchar();
if c == 0 {
suspend_current_and_run_next();
continue;
} else {
break;
}
}
let ch = c as u8;
unsafe { user_buf.buffers[0].as_mut_ptr().write_volatile(ch); }
1
}
fn write(&self, _user_buf: UserBuffer) -> usize {
panic!("Cannot write to stdin!");
}
}
impl File for Stdout {
fn readable(&self) -> bool { false }
fn writable(&self) -> bool { true }
fn read(&self, _user_buf: UserBuffer) -> usize{
panic!("Cannot read from stdout!");
}
fn write(&self, user_buf: UserBuffer) -> usize {
for buffer in user_buf.buffers.iter() {
print!("{}", core::str::from_utf8(*buffer).unwrap());
}
user_buf.len()
}
}
可以看到,标准输入文件 ``Stdin`` 是只读文件,只允许进程通过 ``read`` 从里面读入,目前每次仅支持读入一个字符,其实现与之前的 ``sys_read`` 基本相同,只是需要通过 ``UserBuffer`` 来获取具体将字节写入的位置。相反,标准输出文件 ``Stdout`` 是只写文件,只允许进程通过 ``write`` 写入到里面,实现方法是遍历每个切片,将其转化为字符串通过 ``print!`` 宏来输出。
文件描述符与文件描述符表
--------------------------------------------
为简化操作系统设计实现,可以让每个进程都带有一个线性的 **文件描述符表** ,记录所有它请求内核打开并可以读写的那些文件集合。而 **文件描述符** (File Descriptor) 则是一个非负整数,表示文件描述符表中一个打开的 **文件描述符** 所处的位置(可理解为数组下标)。进程通过文件描述符,可以在自身的文件描述符表中找到对应的文件记录信息,从而也就找到了对应的文件,并对文件进行读写。当打开( ``open`` )或创建( ``create`` 一个文件的时候,如果顺利,内核会返回给应用刚刚打开或创建的文件对应的文件描述符;而当应用想关闭( ``close`` )一个文件的时候,也需要向内核提供对应的文件描述符。
文件I/O操作
-------------------------------------------
在进程控制块中加入文件描述符表的相应字段:
.. code-block:: rust
:linenos:
:emphasize-lines: 12
// os/src/task/task.rs
pub struct TaskControlBlockInner {
pub trap_cx_ppn: PhysPageNum,
pub base_size: usize,
pub task_cx: TaskContext,
pub task_status: TaskStatus,
pub memory_set: MemorySet,
pub parent: Option<Weak<TaskControlBlock>>,
pub children: Vec<Arc<TaskControlBlock>>,
pub exit_code: i32,
pub fd_table: Vec<Option<Arc<dyn File + Send + Sync>>>,
}
可以看到 ``fd_table`` 的类型包含多层嵌套,我们从外到里分别说明:
- ``Vec`` 的动态长度特性使得我们无需设置一个固定的文件描述符数量上限;
- ``Option`` 使得我们可以区分一个文件描述符当前是否空闲,当它是 ``None`` 的时候是空闲的,而 ``Some`` 则代表它已被占用;
- ``Arc`` 首先提供了共享引用能力。后面我们会提到,可能会有多个进程共享同一个文件对它进行读写。此外被它包裹的内容会被放到内核堆而不是栈上,于是它便不需要在编译期有着确定的大小;
- ``dyn`` 关键字表明 ``Arc`` 里面的类型实现了 ``File/Send/Sync`` 三个 Trait ,但是编译期无法知道它具体是哪个类型(可能是任何实现了 ``File`` Trait 的类型如 ``Stdin/Stdout`` ,故而它所占的空间大小自然也无法确定),需要等到运行时才能知道它的具体类型。
.. note::
**Rust 语法卡片Rust 中的多态**
在编程语言中, **多态** (Polymorphism) 指的是在同一段代码中可以隐含多种不同类型的特征。在 Rust 中主要通过泛型和 Trait 来实现多态。
泛型是一种 **编译期多态** (Static Polymorphism),在编译一个泛型函数的时候,编译器会对于所有可能用到的类型进行实例化并对应生成一个版本的汇编代码,在编译期就能知道选取哪个版本并确定函数地址,这可能会导致生成的二进制文件体积较大;而 Trait 对象(也即上面提到的 ``dyn`` 语法)是一种 **运行时多态** (Dynamic Polymorphism),需要在运行时查一种类似于 C++ 中的 **虚表** (Virtual Table) 才能找到实际类型对于抽象接口实现的函数地址并进行调用,这样会带来一定的运行时开销,但是更为灵活。
当新建一个进程的时候,我们需要按照先前的说明为进程打开标准输入文件和标准输出文件:
.. code-block:: rust
:linenos:
:emphasize-lines: 19-26
// os/src/task/task.rs
impl TaskControlBlock {
pub fn new(elf_data: &[u8]) -> Self {
...
let task_control_block = Self {
pid: pid_handle,
kernel_stack,
inner: unsafe {
UPSafeCell::new(TaskControlBlockInner {
trap_cx_ppn,
base_size: user_sp,
task_cx: TaskContext::goto_trap_return(kernel_stack_top),
task_status: TaskStatus::Ready,
memory_set,
parent: None,
children: Vec::new(),
exit_code: 0,
fd_table: vec![
// 0 -> stdin
Some(Arc::new(Stdin)),
// 1 -> stdout
Some(Arc::new(Stdout)),
// 2 -> stderr
Some(Arc::new(Stdout)),
],
})
},
};
...
}
}
此外,在 fork 时,子进程需要完全继承父进程的文件描述符表来和父进程共享所有文件。这样,即使我们仅手动为初始进程 ``initproc`` 打开了标准输入输出,所有进程也都可以访问它们。
文件读写系统调用
---------------------------------------------------
基于文件抽象接口和文件描述符表,我们终于可以让文件读写系统调用 ``sys_read/write`` 变得更加具有普适性,不仅仅局限于之前特定的标准输入输出:
.. code-block:: rust
// os/src/syscall/fs.rs
pub fn sys_write(fd: usize, buf: *const u8, len: usize) -> isize {
let token = current_user_token();
let task = current_task().unwrap();
let inner = task.acquire_inner_lock();
if fd >= inner.fd_table.len() {
return -1;
}
if let Some(file) = &inner.fd_table[fd] {
let file = file.clone();
// release Task lock manually to avoid deadlock
drop(inner);
file.write(
UserBuffer::new(translated_byte_buffer(token, buf, len))
) as isize
} else {
-1
}
}
pub fn sys_read(fd: usize, buf: *const u8, len: usize) -> isize {
let token = current_user_token();
let task = current_task().unwrap();
let inner = task.acquire_inner_lock();
if fd >= inner.fd_table.len() {
return -1;
}
if let Some(file) = &inner.fd_table[fd] {
let file = file.clone();
// release Task lock manually to avoid deadlock
drop(inner);
file.read(
UserBuffer::new(translated_byte_buffer(token, buf, len))
) as isize
} else {
-1
}
}
我们都是在当前进程的文件描述符表中通过文件描述符找到某个文件,无需关心文件具体的类型,只要知道它一定实现了 ``File`` Trait 的 ``read/write`` 方法即可。Trait 对象提供的运行时多态能力会在运行的时候帮助我们定位到 ``read/write`` 的符合实际类型的实现。

View File

@@ -0,0 +1,112 @@
文件系统接口
=================================================
简易文件与目录抽象
-------------------------------------------------
与课堂所学相比,我们实现的文件系统进行了很大的简化:
- 扁平化:仅存在根目录 ``/`` 一个目录,所有的文件都放在根目录内。直接以文件名索引文件。
- 不设置用户和用户组概念,不记录文件访问/修改的任何时间戳,不支持软硬链接。
- 只实现了最基本的文件系统相关系统调用。
打开与读写文件的系统调用
--------------------------------------------------
打开文件
++++++++++++++++++++++++++++++++++++++++++++++++++
.. code-block:: rust
/// 功能:打开一个常规文件,并返回可以访问它的文件描述符。
/// 参数path 描述要打开的文件的文件名(简单起见,文件系统不需要支持目录,所有的文件都放在根目录 / 下),
/// flags 描述打开文件的标志,具体含义下面给出。
/// dirfd 和 mode 仅用于保证兼容性,忽略
/// 返回值:如果出现了错误则返回 -1否则返回打开常规文件的文件描述符。可能的错误原因是文件不存在。
/// syscall ID56
fn sys_openat(dirfd: usize, path: &str, flags: u32, mode: u32) -> isize
目前我们的内核支持以下几种标志(多种不同标志可能共存):
- 如果 ``flags`` 为 0则表示以只读模式 *RDONLY* 打开;
- 如果 ``flags`` 第 0 位被设置0x001表示以只写模式 *WRONLY* 打开;
- 如果 ``flags`` 第 1 位被设置0x002表示既可读又可写 *RDWR*
- 如果 ``flags`` 第 9 位被设置0x200表示允许创建文件 *CREATE* ,在找不到该文件的时候应创建文件;如果该文件已经存在则应该将该文件的大小归零;
- 如果 ``flags`` 第 10 位被设置0x400则在打开文件的时候应该清空文件的内容并将该文件的大小归零也即 *TRUNC*
在用户库 ``user_lib`` 中,我们将该系统调用封装为 ``open`` 接口:
.. code-block:: rust
// user/src/lib.rs
bitflags! {
pub struct OpenFlags: u32 {
const RDONLY = 0;
const WRONLY = 1 << 0;
const RDWR = 1 << 1;
const CREATE = 1 << 9;
const TRUNC = 1 << 10;
}
}
pub fn open(path: &str, flags: OpenFlags) -> isize {
sys_openat(AT_FDCWD as usize, path, flags.bits, OpenFlags::RDWR.bits)
}
借助 ``bitflags!`` 宏我们将一个 ``u32`` 的 flags 包装为一个 ``OpenFlags`` 结构体,可以从它的 ``bits`` 字段获得 ``u32`` 表示。
顺序读写文件
++++++++++++++++++++++++++++++++++++++++++++++++++
在打开一个文件之后,我们就可以用之前的 ``sys_read/sys_write`` 两个系统调用来对它进行读写了。本教程只实现文件的顺序读写,而不考虑随机读写。
以本章的测试用例 ``ch6b_filetest_simple`` 来介绍文件系统接口的使用方法:
.. code-block:: rust
:linenos:
// user/src/bin/ch6b_filetest_simple.rs
#![no_std]
#![no_main]
#[macro_use]
extern crate user_lib;
use user_lib::{
open,
close,
read,
write,
OpenFlags,
};
#[no_mangle]
pub fn main() -> i32 {
let test_str = "Hello, world!";
let filea = "filea\0";
let fd = open(filea, OpenFlags::CREATE | OpenFlags::WRONLY);
assert!(fd > 0);
let fd = fd as usize;
write(fd, test_str.as_bytes());
close(fd);
let fd = open(filea, OpenFlags::RDONLY);
assert!(fd > 0);
let fd = fd as usize;
let mut buffer = [0u8; 100];
let read_len = read(fd, &mut buffer) as usize;
close(fd);
assert_eq!(
test_str,
core::str::from_utf8(&buffer[..read_len]).unwrap(),
);
println!("file_test passed!");
0
}
- 第 20~25 行,我们以 *只写 + 创建* 的模式打开文件 ``filea`` ,向其中写入字符串 ``Hello, world!`` 而后关闭文件。
- 第 27~32 行,我们以只读 的方式将文件 ``filea`` 的内容读取到缓冲区 ``buffer`` 中。 ``filea`` 的总大小不超过缓冲区的大小,因此通过单次 ``read`` 即可将内容全部读出来而更常见的情况是需要进行多次 ``read`` ,直到返回值为 0 才能确认文件已被读取完毕。

View File

@@ -0,0 +1,674 @@
简易文件系统 easy-fs (上)
=======================================
松耦合模块化设计思路
---------------------------------------
内核的功能越来越多,代码量也越来越大。出于解耦合考虑,文件系统 easy-fs 被从内核中分离出来,分成两个不同的 crate
- ``easy-fs`` 是简易文件系统的本体;
- ``easy-fs-fuse`` 是能在开发环境(如 Ubuntu中运行的应用程序用于将应用打包为 easy-fs 格式的文件系统镜像,也可以用来对 ``easy-fs`` 进行测试。
easy-fs与底层设备驱动之间通过抽象接口 ``BlockDevice`` 来连接,采用轮询方式访问 ``virtio_blk`` 虚拟磁盘设备避免调用外设中断的相关内核函数。easy-fs 避免了直接访问进程相关的数据和函数,从而能独立于内核开发。
``easy-fs`` crate 以层次化思路设计,自下而上可以分成五个层次:
1. 磁盘块设备接口层:以块为单位对磁盘块设备进行读写的 trait 接口
2. 块缓存层:在内存中缓存磁盘块的数据,避免频繁读写磁盘
3. 磁盘数据结构层:磁盘上的超级块、位图、索引节点、数据块、目录项等核心数据结构和相关处理
4. 磁盘块管理器层:合并了上述核心数据结构和磁盘布局所形成的磁盘文件系统数据结构
5. 索引节点层:管理索引节点,实现了文件创建/文件打开/文件读写等成员函数
本节将介绍前三层,下一节将介绍后两层。
.. image:: easy-fs-demo.png
:align: center
块设备接口层
---------------------------------------
``easy-fs`` 库的最底层声明了块设备的抽象接口 ``BlockDevice``
.. code-block:: rust
// easy-fs/src/block_dev.rs
pub trait BlockDevice : Send + Sync + Any {
fn read_block(&self, block_id: usize, buf: &mut [u8]);
fn write_block(&self, block_id: usize, buf: &[u8]);
}
它定义了两个抽象方法:
- ``read_block`` 可以将编号为 ``block_id`` 的块从磁盘读入内存中的缓冲区 ``buf``
- ``write_block`` 可以将内存中的缓冲区 ``buf`` 中的数据写入磁盘编号为 ``block_id`` 的块。
``easy-fs`` 的使用者将负责提供抽象方法的实现。
块缓存层
---------------------------------------
为了加速 IO内存可以作为磁盘的缓存。实现磁盘块缓存功能的代码在 ``block_cache.rs``
块缓存
+++++++++++++++++++++++++++++++++++++++++
块缓存 ``BlockCache`` 的声明如下:
.. code-block:: rust
// easy-fs/src/lib.rs
pub const BLOCK_SZ: usize = 512;
// easy-fs/src/block_cache.rs
pub struct BlockCache {
cache: [u8; BLOCK_SZ],
block_id: usize,
block_device: Arc<dyn BlockDevice>,
modified: bool,
}
其中:
- ``cache`` 是一个 512 字节的数组,表示位于内存中的缓冲区;
- ``block_id`` 记录了这个块的编号;
- ``block_device`` 记录块所属的底层设备;
- ``modified`` 记录自从这个块缓存从磁盘载入内存之后,它有没有被修改过。
创建 ``BlockCache`` 时,将一个块从磁盘读到缓冲区 ``cache``
.. code-block:: rust
// easy-fs/src/block_cache.rs
impl BlockCache {
/// Load a new BlockCache from disk.
pub fn new(
block_id: usize,
block_device: Arc<dyn BlockDevice>
) -> Self {
let mut cache = [0u8; BLOCK_SZ];
block_device.read_block(block_id, &mut cache);
Self {
cache,
block_id,
block_device,
modified: false,
}
}
}
``BlockCache`` 向上提供以下方法:
.. code-block:: rust
:linenos:
// easy-fs/src/block_cache.rs
impl BlockCache {
fn addr_of_offset(&self, offset: usize) -> usize {
&self.cache[offset] as *const _ as usize
}
pub fn get_ref<T>(&self, offset: usize) -> &T where T: Sized {
let type_size = core::mem::size_of::<T>();
assert!(offset + type_size <= BLOCK_SZ);
let addr = self.addr_of_offset(offset);
unsafe { &*(addr as *const T) }
}
pub fn get_mut<T>(&mut self, offset: usize) -> &mut T where T: Sized {
let type_size = core::mem::size_of::<T>();
assert!(offset + type_size <= BLOCK_SZ);
self.modified = true;
let addr = self.addr_of_offset(offset);
unsafe { &mut *(addr as *mut T) }
}
}
- ``addr_of_offset`` 可以得到一个 ``BlockCache`` 内部的缓冲区中指定偏移量 ``offset`` 的字节地址;
- ``get_ref`` 是一个泛型方法,它可以获取缓冲区中的位于偏移量 ``offset`` 的一个类型为 ``T`` 的磁盘上数据结构的不可变引用。该泛型方法的 Trait Bound 限制类型 ``T`` 必须是一个编译时已知大小的类型,我们通过 ``core::mem::size_of::<T>()`` 在编译时获取类型 ``T`` 的大小并确认该数据结构被整个包含在磁盘块及其缓冲区之内。这里编译器会自动进行生命周期标注,约束返回的引用的生命周期不超过 ``BlockCache`` 自身,在使用的时候我们会保证这一点。
- ``get_mut````get_ref`` 的不同之处在于它会获取磁盘上数据结构的可变引用,由此可以对数据结构进行修改。由于这些数据结构目前位于内存中的缓冲区中,我们需要将 ``BlockCache````modified`` 标记为 true 表示该缓冲区已经被修改,之后需要将数据写回磁盘块才能真正将修改同步到磁盘。
我们可以将 ``get_ref/get_mut`` 进一步封装为更为易用的形式:
.. code-block:: rust
// easy-fs/src/block_cache.rs
impl BlockCache {
pub fn read<T, V>(&self, offset: usize, f: impl FnOnce(&T) -> V) -> V {
f(self.get_ref(offset))
}
pub fn modify<T, V>(&mut self, offset:usize, f: impl FnOnce(&mut T) -> V) -> V {
f(self.get_mut(offset))
}
}
它们的含义是:在 ``BlockCache`` 缓冲区偏移量为 ``offset`` 的位置,获取一个类型为 ``T`` 不可变/可变引用,将闭包 ``f`` 作用于这个引用,返回 ``f`` 的返回值。 中所定义的操作。
这里我们传入闭包的类型为 ``FnOnce`` ,这是因为闭包里面的变量被捕获的方式涵盖了不可变引用/可变引用/和 move 三种可能性,故而我们需要选取范围最广的 ``FnOnce`` 。参数中的 ``impl`` 关键字体现了一种类似泛型的静态分发功能。
.. warning::
**Rust 语法卡片:闭包**
闭包是持有外部环境变量的函数。所谓外部环境, 就是指创建闭包时所在的词法作用域。Rust中定义的闭包按照对外部环境变量的使用方式借用、复制、转移所有权分为三个类型: Fn、FnMut、FnOnce。Fn类型的闭包会在闭包内部以共享借用的方式使用环境变量FnMut类型的闭包会在闭包内部以独占借用的方式使用环境变量而FnOnce类型的闭包会在闭包内部以所有者的身份使用环境变量。由此可见根据闭包内使用环境变量的方式即可判断创建出来的闭包的类型。
``BlockCache`` 的生命周期结束后,缓冲区也会被回收, ``modified`` 标记将会决定数据是否需要写回磁盘:
.. code-block:: rust
// easy-fs/src/block_cache.rs
impl Drop for BlockCache {
fn drop(&mut self) {
if self.modified {
self.modified = false;
self.block_device.write_block(self.block_id, &self.cache);
}
}
}
块缓存全局管理器
+++++++++++++++++++++++++++++++++++++++++
内存只能同时缓存有限个磁盘块。当我们要对一个磁盘块进行读写时,块缓存全局管理器检查它是否已经被载入内存中,如果是则直接返回,否则就读取磁盘块到内存。如果内存中驻留的磁盘块缓冲区的数量已满,则需要进行缓存替换。这里使用一种类 FIFO 的缓存替换算法,在管理器中只需维护一个队列:
.. code-block:: rust
// easy-fs/src/block_cache.rs
use alloc::collections::VecDeque;
pub struct BlockCacheManager {
queue: VecDeque<(usize, Arc<Mutex<BlockCache>>)>,
}
队列 ``queue`` 维护块编号和块缓存的二元组。块缓存的类型是一个 ``Arc<Mutex<BlockCache>>`` ,这是 Rust 中的经典组合,它可以同时提供共享引用和互斥访问。这里的共享引用意义在于块缓存既需要在管理器 ``BlockCacheManager`` 保留一个引用,还需要将引用返回给块缓存的请求者。而互斥访问在单核上的意义在于提供内部可变性通过编译,在多核环境下则可以帮助我们避免可能的并发冲突。
``get_block_cache`` 方法尝试从块缓存管理器中获取一个编号为 ``block_id`` 的块缓存,如果找不到的话会读取磁盘,还有可能会发生缓存替换:
.. code-block:: rust
:linenos:
// easy-fs/src/block_cache.rs
impl BlockCacheManager {
pub fn get_block_cache(
&mut self,
block_id: usize,
block_device: Arc<dyn BlockDevice>,
) -> Arc<Mutex<BlockCache>> {
if let Some(pair) = self.queue
.iter()
.find(|pair| pair.0 == block_id) {
Arc::clone(&pair.1)
} else {
// substitute
if self.queue.len() == BLOCK_CACHE_SIZE {
// from front to tail
if let Some((idx, _)) = self.queue
.iter()
.enumerate()
.find(|(_, pair)| Arc::strong_count(&pair.1) == 1) {
self.queue.drain(idx..=idx);
} else {
panic!("Run out of BlockCache!");
}
}
// load block into mem and push back
let block_cache = Arc::new(Mutex::new(
BlockCache::new(block_id, Arc::clone(&block_device))
));
self.queue.push_back((block_id, Arc::clone(&block_cache)));
block_cache
}
}
}
- 第 9 行,遍历整个队列试图找到一个编号相同的块缓存,如果找到,将块缓存管理器中保存的块缓存的引用复制一份并返回;
- 第 13 行对应找不到的情况,此时必须将块从磁盘读入内存中的缓冲区。读取前需要判断已保存的块数量是否达到了上限。是,则执行缓存替换算法,替换的标准是其强引用计数 :math:`\eq 1` ,即除了块缓存管理器保留的一份副本之外,在外面没有副本正在使用。
- 第 27 行开始,创建一个新的块缓存(会触发 ``read_block`` 进行块读取)并加入到队尾,最后返回给请求者。
磁盘布局及磁盘上数据结构
---------------------------------------
磁盘数据结构层的代码在 ``layout.rs````bitmap.rs`` 中。
easy-fs 磁盘布局概述
+++++++++++++++++++++++++++++++++++++++
easy-fs 磁盘按照块编号从小到大顺序分成 5 个连续区域:
- 第一个区域只包括一个块,它是 **超级块** (Super Block),用于定位其他连续区域的位置,检查文件系统合法性。
- 第二个区域是一个索引节点位图,长度为若干个块。它记录了索引节点区域中有哪些索引节点已经被分配出去使用了。
- 第三个区域是索引节点区域,长度为若干个块。其中的每个块都存储了若干个索引节点。
- 第四个区域是一个数据块位图,长度为若干个块。它记录了后面的数据块区域中有哪些已经被分配出去使用了。
- 最后的区域则是数据块区域,其中的每个被分配出去的块保存了文件或目录的具体内容。
easy-fs 超级块
+++++++++++++++++++++++++++++++++++++++
超级块 ``SuperBlock`` 的内容如下:
.. code-block:: rust
// easy-fs/src/layout.rs
#[repr(C)]
pub struct SuperBlock {
magic: u32,
pub total_blocks: u32,
pub inode_bitmap_blocks: u32,
pub inode_area_blocks: u32,
pub data_bitmap_blocks: u32,
pub data_area_blocks: u32,
}
其中, ``magic`` 是一个用于文件系统合法性验证的魔数, ``total_block`` 给出文件系统的总块数。后面的四个字段则分别给出 easy-fs 布局中后四个连续区域的长度各为多少个块。
下面是它实现的方法:
.. code-block:: rust
// easy-fs/src/layout.rs
impl SuperBlock {
pub fn initialize(
&mut self,
total_blocks: u32,
inode_bitmap_blocks: u32,
inode_area_blocks: u32,
data_bitmap_blocks: u32,
data_area_blocks: u32,
);
pub fn is_valid(&self) -> bool {
self.magic == EFS_MAGIC
}
}
- ``initialize`` 用于在创建一个 easy-fs 的时候初始化超级块,各个区域的块数由更上层的磁盘块管理器传入。
- ``is_valid`` 则可以通过魔数判断超级块所在的文件系统是否合法。
位图
+++++++++++++++++++++++++++++++++++++++
在 easy-fs 布局中存在两类不同的位图,分别对索引节点和数据块进行管理。每个位图都由若干个块组成,每个块大小 4096 bits。每个 bit 都代表一个索引节点/数据块的分配状态。
.. code-block:: rust
// easy-fs/src/bitmap.rs
pub struct Bitmap {
start_block_id: usize,
blocks: usize,
}
type BitmapBlock = [u64; 64];
``Bitmap`` 是位图区域的管理器,它保存了位图区域的起始块编号和块数。而 ``BitmapBlock`` 将位图区域中的一个磁盘块解释为长度为 64 的一个 ``u64`` 数组。
首先来看 ``Bitmap`` 如何分配一个bit
.. code-block:: rust
:linenos:
// easy-fs/src/bitmap.rs
const BLOCK_BITS: usize = BLOCK_SZ * 8;
impl Bitmap {
pub fn alloc(&self, block_device: &Arc<dyn BlockDevice>) -> Option<usize> {
for block_id in 0..self.blocks {
let pos = get_block_cache(
block_id + self.start_block_id as usize,
Arc::clone(block_device),
)
.lock()
.modify(0, |bitmap_block: &mut BitmapBlock| {
if let Some((bits64_pos, inner_pos)) = bitmap_block
.iter()
.enumerate()
.find(|(_, bits64)| **bits64 != u64::MAX)
.map(|(bits64_pos, bits64)| {
(bits64_pos, bits64.trailing_ones() as usize)
}) {
// modify cache
bitmap_block[bits64_pos] |= 1u64 << inner_pos;
Some(block_id * BLOCK_BITS + bits64_pos * 64 + inner_pos as usize)
} else {
None
}
});
if pos.is_some() {
return pos;
}
}
None
}
}
其主要思路是遍历区域中的每个块再在每个块中以bit组每组 64 bits为单位进行遍历找到一个尚未被全部分配出去的组最后在里面分配一个bit。它将会返回分配的bit所在的位置等同于索引节点/数据块的编号。如果所有bit均已经被分配出去了则返回 ``None``
第 7 行枚举区域中的每个块(编号为 ``block_id`` 在循环内部我们需要读写这个块在块内尝试找到一个空闲的bit并置 1 。一旦涉及到块的读写,就需要用到块缓存层提供的接口:
- 第 8 行我们调用 ``get_block_cache`` 获取块缓存,注意我们传入的块编号是区域起始块编号 ``start_block_id`` 加上区域内的块编号 ``block_id`` 得到的块设备上的块编号。
- 第 12 行我们通过 ``.lock()`` 获取块缓存的互斥锁从而可以对块缓存进行访问。
- 第 13 行我们使用到了 ``BlockCache::modify`` 接口。它传入的偏移量 ``offset`` 为 0这是因为整个块上只有一个 ``BitmapBlock`` ,它的大小恰好为 512 字节。因此我们需要从块的开头开始才能访问到完整的 ``BitmapBlock`` 。同时,传给它的闭包需要显式声明参数类型为 ``&mut BitmapBlock`` ,不然的话, ``BlockCache`` 的泛型方法 ``modify/get_mut`` 无法得知应该用哪个类型来解析块上的数据。在声明之后,编译器才能在这里将两个方法中的泛型 ``T`` 实例化为具体类型 ``BitmapBlock``
总结一下,这里 ``modify`` 的含义就是:从缓冲区偏移量为 0 的位置开始将一段连续的数据(数据的长度随具体类型而定)解析为一个 ``BitmapBlock`` 并要对该数据结构进行修改。在闭包内部,我们可以使用这个 ``BitmapBlock`` 的可变引用 ``bitmap_block`` 对它进行访问。 ``read/get_ref`` 的用法完全相同,后面将不再赘述。
- 闭包的主体位于第 14~26 行。它尝试在 ``bitmap_block`` 中找到一个空闲的bit并返回其位置如果不存在的话则返回 ``None`` 。它的思路是,遍历每 64 bits构成的组一个 ``u64`` ),如果它并没有达到 ``u64::MAX`` (即 :math:`2^{64}-1` ),则通过 ``u64::trailing_ones`` 找到最低的一个 0 并置为 1 。如果能够找到的话bit组的编号将保存在变量 ``bits64_pos``而分配的bit在组内的位置将保存在变量 ``inner_pos`` 中。在返回分配的bit编号的时候它的计算方式是 ``block_id*BLOCK_BITS+bits64_pos*64+inner_pos`` 。注意闭包中的 ``block_id`` 并不在闭包的参数列表中,因此它是从外部环境(即自增 ``block_id`` 的循环)中捕获到的。
我们一旦在某个块中找到一个空闲的bit并成功分配就不再考虑后续的块。第 28 行体现了提前返回的思路。
回收 bit 的方法类似,感兴趣的读者可自行阅读源代码。
磁盘上索引节点
+++++++++++++++++++++++++++++++++++++++
在磁盘上的索引节点区域,每个块上都保存着若干个索引节点 ``DiskInode``
.. code-block:: rust
// easy-fs/src/layout.rs
const INODE_DIRECT_COUNT: usize = 28;
#[repr(C)]
pub struct DiskInode {
pub size: u32,
pub direct: [u32; INODE_DIRECT_COUNT],
pub indirect1: u32,
pub indirect2: u32,
type_: DiskInodeType,
}
#[derive(PartialEq)]
pub enum DiskInodeType {
File,
Directory,
}
每个文件/目录在磁盘上均以一个 ``DiskInode`` 的形式存储。其中包含文件/目录的元数据: ``size`` 表示文件/目录内容的字节数, ``type_`` 表示索引节点的类型 ``DiskInodeType`` ,目前仅支持文件 ``File`` 和目录 ``Directory`` 两种类型。其余的 ``direct/indirect1/indirect2`` 都是存储文件内容/目录内容的数据块的索引,这也是索引节点名字的由来。
为了尽可能节约空间,在进行索引的时候,块的编号用一个 ``u32`` 存储。索引方式分成直接索引和间接索引两种:
- 当文件很小的时候,只需用到直接索引, ``direct`` 数组中最多可以指向 ``INODE_DIRECT_COUNT`` 个数据块,当取值为 28 的时候,通过直接索引可以找到 14KiB 的内容。
- 当文件比较大的时候,不仅直接索引的 ``direct`` 数组装满,还需要用到一级间接索引 ``indirect1`` 。它指向一个一级索引块,这个块也位于磁盘布局的数据块区域中。这个一级索引块中的每个 ``u32`` 都用来指向数据块区域中一个保存该文件内容的数据块,因此,最多能够索引 :math:`\frac{512}{4}=128` 个数据块,对应 64KiB 的内容。
- 当文件大小超过直接索引和一级索引支持的容量上限 78KiB 的时候,就需要用到二级间接索引 ``indirect2`` 。它指向一个位于数据块区域中的二级索引块。二级索引块中的每个 ``u32`` 指向一个不同的一级索引块,这些一级索引块也位于数据块区域中。因此,通过二级间接索引最多能够索引 :math:`128\times 64\text{KiB}=8\text{MiB}` 的内容。
为了充分利用空间,我们将 ``DiskInode`` 的大小设置为 128 字节,每个块正好能够容纳 4 个 ``DiskInode`` 。在后续需要支持更多类型的元数据的时候,可以适当缩减直接索引 ``direct`` 的块数,并将节约出来的空间用来存放其他元数据,仍可保证 ``DiskInode`` 的总大小为 128 字节。
通过 ``initialize`` 方法可以初始化一个 ``DiskInode`` 为一个文件或目录:
.. code-block:: rust
// easy-fs/src/layout.rs
impl DiskInode {
/// indirect1 and indirect2 block are allocated only when they are needed.
pub fn initialize(&mut self, type_: DiskInodeType) {
self.size = 0;
self.direct.iter_mut().for_each(|v| *v = 0);
self.indirect1 = 0;
self.indirect2 = 0;
self.type_ = type_;
}
}
需要注意的是, ``indirect1/2`` 均被初始化为 0 。因为最开始文件内容的大小为 0 字节,并不会用到一级/二级索引。为了节约空间,我们会完全按需分配一级/二级索引块。此外,直接索引 ``direct`` 也被清零。
``is_file````is_dir`` 两个方法可以用来确认 ``DiskInode`` 的类型为文件还是目录:
.. code-block:: rust
// easy-fs/src/layout.rs
impl DiskInode {
pub fn is_dir(&self) -> bool {
self.type_ == DiskInodeType::Directory
}
pub fn is_file(&self) -> bool {
self.type_ == DiskInodeType::File
}
}
``get_block_id`` 方法体现了 ``DiskInode`` 最重要的数据块索引功能,它可以从索引中查到它自身用于保存文件内容的第 ``block_id`` 个数据块的块编号,这样后续才能对这个数据块进行访问:
.. code-block:: rust
:linenos:
:emphasize-lines: 10,12,18
// easy-fs/src/layout.rs
const INODE_INDIRECT1_COUNT: usize = BLOCK_SZ / 4;
const INDIRECT1_BOUND: usize = DIRECT_BOUND + INODE_INDIRECT1_COUNT;
type IndirectBlock = [u32; BLOCK_SZ / 4];
impl DiskInode {
pub fn get_block_id(&self, inner_id: u32, block_device: &Arc<dyn BlockDevice>) -> u32 {
let inner_id = inner_id as usize;
if inner_id < INODE_DIRECT_COUNT {
self.direct[inner_id]
} else if inner_id < INDIRECT1_BOUND {
get_block_cache(self.indirect1 as usize, Arc::clone(block_device))
.lock()
.read(0, |indirect_block: &IndirectBlock| {
indirect_block[inner_id - INODE_DIRECT_COUNT]
})
} else {
let last = inner_id - INDIRECT1_BOUND;
let indirect1 = get_block_cache(
self.indirect2 as usize,
Arc::clone(block_device)
)
.lock()
.read(0, |indirect2: &IndirectBlock| {
indirect2[last / INODE_INDIRECT1_COUNT]
});
get_block_cache(
indirect1 as usize,
Arc::clone(block_device)
)
.lock()
.read(0, |indirect1: &IndirectBlock| {
indirect1[last % INODE_INDIRECT1_COUNT]
})
}
}
}
这里需要说明的是:
- 第 10/12/18 行分别利用直接索引/一级索引和二级索引,具体选用哪种索引方式取决于 ``block_id`` 所在的区间。
- 在对一个索引块进行操作的时候,我们将其解析为磁盘数据结构 ``IndirectBlock`` ,实质上就是一个 ``u32`` 数组,每个都指向一个下一级索引块或者数据块。
- 对于二级索引的情况,需要先查二级索引块找到挂在它下面的一级索引块,再通过一级索引块找到数据块。
在初始化之后文件/目录的 ``size`` 均为 0 ,此时并不会索引到任何数据块。它需要通过 ``increase_size`` 方法逐步扩充容量。在扩充的时候,自然需要一些新的数据块来作为索引块或是保存内容的数据块。我们需要先编写一些辅助方法来确定在容量扩充的时候额外需要多少块:
.. code-block:: rust
// easy-fs/src/layout.rs
impl DiskInode {
/// Return block number correspond to size.
pub fn data_blocks(&self) -> u32 {
Self::_data_blocks(self.size)
}
fn _data_blocks(size: u32) -> u32 {
(size + BLOCK_SZ as u32 - 1) / BLOCK_SZ as u32
}
/// Return number of blocks needed include indirect1/2.
pub fn total_blocks(size: u32) -> u32 {
let data_blocks = Self::_data_blocks(size) as usize;
let mut total = data_blocks as usize;
// indirect1
if data_blocks > INODE_DIRECT_COUNT {
total += 1;
}
// indirect2
if data_blocks > INDIRECT1_BOUND {
total += 1;
// sub indirect1
total += (data_blocks - INDIRECT1_BOUND + INODE_INDIRECT1_COUNT - 1) / INODE_INDIRECT1_COUNT;
}
total as u32
}
pub fn blocks_num_needed(&self, new_size: u32) -> u32 {
assert!(new_size >= self.size);
Self::total_blocks(new_size) - Self::total_blocks(self.size)
}
}
``data_blocks`` 方法可以计算为了容纳自身 ``size`` 字节的内容需要多少个数据块。计算的过程只需用 ``size`` 除以每个块的大小 ``BLOCK_SZ`` 并向上取整。而 ``total_blocks`` 不仅包含数据块,还需要统计索引块。计算的方法也很简单,先调用 ``data_blocks`` 得到需要多少数据块,再根据数据块数目所处的区间统计索引块即可。 ``blocks_num_needed`` 可以计算将一个 ``DiskInode````size`` 扩容到 ``new_size`` 需要额外多少个数据和索引块。这只需要调用两次 ``total_blocks`` 作差即可。
下面给出 ``increase_size`` 方法的接口:
.. code-block:: rust
// easy-fs/src/layout.rs
impl DiskInode {
pub fn increase_size(
&mut self,
new_size: u32,
new_blocks: Vec<u32>,
block_device: &Arc<dyn BlockDevice>,
);
}
其中 ``new_size`` 表示容量扩充之后的文件大小; ``new_blocks`` 是一个保存了本次容量扩充所需块编号的向量,这些块都是由上层的磁盘块管理器负责分配的。 ``increase_size`` 的实现有些复杂,在这里不详细介绍。大致的思路是按照直接索引、一级索引再到二级索引的顺序进行扩充。
有些时候我们还需要清空文件的内容并回收所有数据和索引块。这是通过 ``clear_size`` 方法来实现的:
.. code-block:: rust
// easy-fs/src/layout.rs
impl DiskInode {
/// Clear size to zero and return blocks that should be deallocated.
///
/// We will clear the block contents to zero later.
pub fn clear_size(&mut self, block_device: &Arc<dyn BlockDevice>) -> Vec<u32>;
}
它会将回收的所有块的编号保存在一个向量中返回给磁盘块管理器。它的实现原理和 ``increase_size`` 一样也分为多个阶段,在这里不展开。
接下来需要考虑通过 ``DiskInode`` 来读写它索引的那些数据块中的数据。这些数据可以被视为一个字节序列,而每次我们都是选取其中的一段连续区间进行操作,以 ``read_at`` 为例:
.. code-block:: rust
:linenos:
// easy-fs/src/layout.rs
type DataBlock = [u8; BLOCK_SZ];
impl DiskInode {
pub fn read_at(
&self,
offset: usize,
buf: &mut [u8],
block_device: &Arc<dyn BlockDevice>,
) -> usize {
let mut start = offset;
let end = (offset + buf.len()).min(self.size as usize);
if start >= end {
return 0;
}
let mut start_block = start / BLOCK_SZ;
let mut read_size = 0usize;
loop {
// calculate end of current block
let mut end_current_block = (start / BLOCK_SZ + 1) * BLOCK_SZ;
end_current_block = end_current_block.min(end);
// read and update read size
let block_read_size = end_current_block - start;
let dst = &mut buf[read_size..read_size + block_read_size];
get_block_cache(
self.get_block_id(start_block as u32, block_device) as usize,
Arc::clone(block_device),
)
.lock()
.read(0, |data_block: &DataBlock| {
let src = &data_block[start % BLOCK_SZ..start % BLOCK_SZ + block_read_size];
dst.copy_from_slice(src);
});
read_size += block_read_size;
// move to next block
if end_current_block == end { break; }
start_block += 1;
start = end_current_block;
}
read_size
}
}
它的含义是:将文件内容从 ``offset`` 字节开始的部分读到内存中的缓冲区 ``buf`` 中,并返回实际读到的字节数。如果文件剩下的内容还足够多,那么缓冲区会被填满;不然的话文件剩下的全部内容都会被读到缓冲区中。具体实现上有很多细节,但大致的思路是遍历位于字节区间 ``start,end`` 中间的那些块,将它们视为一个 ``DataBlock`` (也就是一个字节数组),并将其中的部分内容复制到缓冲区 ``buf`` 中适当的区域。 ``start_block`` 维护着目前是文件内部第多少个数据块,需要首先调用 ``get_block_id`` 从索引中查到这个数据块在块设备中的块编号,随后才能传入 ``get_block_cache`` 中将正确的数据块缓存到内存中进行访问。
在第 14 行进行了简单的边界条件判断,如果要读取的内容超出了文件的范围那么直接返回 0 表示读取不到任何内容。
``write_at`` 的实现思路基本上和 ``read_at`` 完全相同。但不同的是 ``write_at`` 不会出现失败的情况,传入的整个缓冲区的数据都必定会被写入到文件中。当从 ``offset`` 开始的区间超出了文件范围的时候,就需要调用者在调用 ``write_at`` 之前提前调用 ``increase_size`` 将文件大小扩充到区间的右端保证写入的完整性。
目录项
+++++++++++++++++++++++++++++++++++++++
对于文件而言,它的内容在文件系统或内核看来没有任何既定的格式,只是一个字节序列。目录的内容却需要遵从一种特殊的格式,它可以看成一个目录项的序列,每个目录项都是一个二元组,包括目录下文件的文件名和索引节点编号。目录项 ``DirEntry`` 的定义如下:
.. code-block:: rust
// easy-fs/src/layout.rs
const NAME_LENGTH_LIMIT: usize = 27;
#[repr(C)]
pub struct DirEntry {
name: [u8; NAME_LENGTH_LIMIT + 1],
inode_number: u32,
}
pub const DIRENT_SZ: usize = 32;
目录项 ``Dirent`` 保存的文件名长度不能超过 27。目录项自身长 32 字节,每个数据块可以存储 16 个目录项。可以通过 ``empty````new`` 方法生成目录项,通过 ``name````inode_number`` 方法取出目录项中的内容:
.. code-block:: rust
// easy-fs/src/layout.rs
impl DirEntry {
pub fn empty() -> Self;
pub fn new(name: &str, inode_number: u32) -> Self;
pub fn name(&self) -> &str;
pub fn inode_number(&self) -> u32
}
在从目录中读取目录项,或将目录项写入目录时,需要将目录项转化为缓冲区(即字节切片)的形式来符合 ``read_at OR write_at`` 接口的要求:
.. code-block:: rust
// easy-fs/src/layout.rs
impl DirEntry {
pub fn as_bytes(&self) -> &[u8] {
unsafe {
core::slice::from_raw_parts(
self as *const _ as usize as *const u8,
DIRENT_SZ,
)
}
}
pub fn as_bytes_mut(&mut self) -> &mut [u8] {
unsafe {
core::slice::from_raw_parts_mut(
self as *mut _ as usize as *mut u8,
DIRENT_SZ,
)
}
}
}

View File

@@ -0,0 +1,591 @@
简易文件系统 easy-fs (下)
=======================================
磁盘块管理器
---------------------------------------
本层的代码在 ``efs.rs`` 中。
.. code-block:: rust
// easy-fs/src/efs.rs
pub struct EasyFileSystem {
pub block_device: Arc<dyn BlockDevice>,
pub inode_bitmap: Bitmap,
pub data_bitmap: Bitmap,
inode_area_start_block: u32,
data_area_start_block: u32,
}
``EasyFileSystem`` 包含索引节点和数据块的两个位图 ``inode_bitmap````data_bitmap`` ,还记录下索引节点区域和数据块区域起始块编号方便确定每个索引节点和数据块在磁盘上的具体位置。我们还要在其中保留块设备的一个指针 ``block_device`` ,在进行后续操作的时候,该指针会被拷贝并传递给下层的数据结构,让它们也能够直接访问块设备。
通过 ``create`` 方法可以在块设备上创建并初始化一个 easy-fs 文件系统:
.. code-block:: rust
:linenos:
// easy-fs/src/efs.rs
impl EasyFileSystem {
pub fn create(
block_device: Arc<dyn BlockDevice>,
total_blocks: u32,
inode_bitmap_blocks: u32,
) -> Arc<Mutex<Self>> {
// calculate block size of areas & create bitmaps
let inode_bitmap = Bitmap::new(1, inode_bitmap_blocks as usize);
let inode_num = inode_bitmap.maximum();
let inode_area_blocks =
((inode_num * core::mem::size_of::<DiskInode>() + BLOCK_SZ - 1) / BLOCK_SZ) as u32;
let inode_total_blocks = inode_bitmap_blocks + inode_area_blocks;
let data_total_blocks = total_blocks - 1 - inode_total_blocks;
let data_bitmap_blocks = (data_total_blocks + 4096) / 4097;
let data_area_blocks = data_total_blocks - data_bitmap_blocks;
let data_bitmap = Bitmap::new(
(1 + inode_bitmap_blocks + inode_area_blocks) as usize,
data_bitmap_blocks as usize,
);
let mut efs = Self {
block_device: Arc::clone(&block_device),
inode_bitmap,
data_bitmap,
inode_area_start_block: 1 + inode_bitmap_blocks,
data_area_start_block: 1 + inode_total_blocks + data_bitmap_blocks,
};
// clear all blocks
for i in 0..total_blocks {
get_block_cache(
i as usize,
Arc::clone(&block_device)
)
.lock()
.modify(0, |data_block: &mut DataBlock| {
for byte in data_block.iter_mut() { *byte = 0; }
});
}
// initialize SuperBlock
get_block_cache(0, Arc::clone(&block_device))
.lock()
.modify(0, |super_block: &mut SuperBlock| {
super_block.initialize(
total_blocks,
inode_bitmap_blocks,
inode_area_blocks,
data_bitmap_blocks,
data_area_blocks,
);
});
// write back immediately
// create a inode for root node "/"
assert_eq!(efs.alloc_inode(), 0);
let (root_inode_block_id, root_inode_offset) = efs.get_disk_inode_pos(0);
get_block_cache(
root_inode_block_id as usize,
Arc::clone(&block_device)
)
.lock()
.modify(root_inode_offset, |disk_inode: &mut DiskInode| {
disk_inode.initialize(DiskInodeType::Directory);
});
Arc::new(Mutex::new(efs))
}
}
- 第 10~21 行根据传入的参数计算每个区域各应该包含多少块。根据 inode 位图的大小计算 inode 区域至少需要多少个块才能够使得 inode 位图中的每个bit都能够有一个实际的 inode 可以对应,这样就确定了 inode 位图区域和 inode 区域的大小。剩下的块都分配给数据块位图区域和数据块区域。我们希望数据块位图中的每个bit仍然能够对应到一个数据块但是数据块位图又不能过小不然会造成某些数据块永远不会被使用。因此数据块位图区域最合理的大小是剩余的块数除以 4097 再上取整,因为位图中的每个块能够对应 4096 个数据块。其余的块就都作为数据块使用。
- 第 22 行创建我们的 ``EasyFileSystem`` 实例 ``efs``
- 第 30 行首先将块设备的前 ``total_blocks`` 个块清零,因为我们的 easy-fs 要用到它们,这也是为初始化做准备。
- 第 41 行将位于块设备编号为 0 块上的超级块进行初始化,只需传入之前计算得到的每个区域的块数就行了。
- 第 54~63 行我们要做的事情是创建根目录 ``/`` 。首先需要调用 ``alloc_inode`` 在 inode 位图中分配一个 inode ,由于这是第一次分配,它的编号固定是 0 。接下来需要将分配到的 inode 初始化为 easy-fs 中的唯一一个目录,我们需要调用 ``get_disk_inode_pos`` 来根据 inode 编号获取该 inode 所在的块的编号以及块内偏移,之后就可以将它们传给 ``get_block_cache````modify`` 了。
通过 ``open`` 方法可以从一个已写入了 easy-fs 镜像的块设备上打开我们的 easy-fs
.. code-block:: rust
// easy-fs/src/efs.rs
impl EasyFileSystem {
pub fn open(block_device: Arc<dyn BlockDevice>) -> Arc<Mutex<Self>> {
// read SuperBlock
get_block_cache(0, Arc::clone(&block_device))
.lock()
.read(0, |super_block: &SuperBlock| {
assert!(super_block.is_valid(), "Error loading EFS!");
let inode_total_blocks =
super_block.inode_bitmap_blocks + super_block.inode_area_blocks;
let efs = Self {
block_device,
inode_bitmap: Bitmap::new(
1,
super_block.inode_bitmap_blocks as usize
),
data_bitmap: Bitmap::new(
(1 + inode_total_blocks) as usize,
super_block.data_bitmap_blocks as usize,
),
inode_area_start_block: 1 + super_block.inode_bitmap_blocks,
data_area_start_block: 1 + inode_total_blocks + super_block.data_bitmap_blocks,
};
Arc::new(Mutex::new(efs))
})
}
}
它只需将块设备编号为 0 的块作为超级块读取进来,就可以从中知道 easy-fs 的磁盘布局,由此可以构造 ``efs`` 实例。
``EasyFileSystem`` 知道整个磁盘布局,即可以从 inode位图 或数据块位图上分配的 bit 编号来算出各个存储inode和数据块的磁盘块在磁盘上的实际位置。
.. code-block:: rust
// easy-fs/src/efs.rs
impl EasyFileSystem {
pub fn get_disk_inode_pos(&self, inode_id: u32) -> (u32, usize) {
let inode_size = core::mem::size_of::<DiskInode>();
let inodes_per_block = (BLOCK_SZ / inode_size) as u32;
let block_id = self.inode_area_start_block + inode_id / inodes_per_block;
(block_id, (inode_id % inodes_per_block) as usize * inode_size)
}
pub fn get_data_block_id(&self, data_block_id: u32) -> u32 {
self.data_area_start_block + data_block_id
}
}
inode 和数据块的分配/回收也由它负责:
.. code-block:: rust
// easy-fs/src/efs.rs
impl EasyFileSystem {
pub fn alloc_inode(&mut self) -> u32 {
self.inode_bitmap.alloc(&self.block_device).unwrap() as u32
}
/// Return a block ID not ID in the data area.
pub fn alloc_data(&mut self) -> u32 {
self.data_bitmap.alloc(&self.block_device).unwrap() as u32 + self.data_area_start_block
}
pub fn dealloc_data(&mut self, block_id: u32) {
get_block_cache(
block_id as usize,
Arc::clone(&self.block_device)
)
.lock()
.modify(0, |data_block: &mut DataBlock| {
data_block.iter_mut().for_each(|p| { *p = 0; })
});
self.data_bitmap.dealloc(
&self.block_device,
(block_id - self.data_area_start_block) as usize
)
}
}
注意:
- ``alloc_data````dealloc_data`` 分配/回收数据块传入/返回的参数都表示数据块在块设备上的编号而不是在数据块位图中分配的bit编号
- ``dealloc_inode`` 未实现,不支持文件删除。
索引节点
---------------------------------------
服务于文件相关系统调用的索引节点层的代码在 ``vfs.rs`` 中。
``EasyFileSystem`` 实现了我们设计的磁盘布局并能够将所有块有效的管理起来。但是对于文件系统的使用者而言,他们往往不关心磁盘布局是如何实现的,而是更希望能够直接看到目录树结构中逻辑上的文件和目录。为此我们设计索引节点 ``Inode`` 暴露给文件系统的使用者,让他们能够直接对文件和目录进行操作。 ``Inode````DiskInode`` 的区别从它们的名字中就可以看出: ``DiskInode`` 放在磁盘块中比较固定的位置,而 ``Inode`` 是放在内存中的记录文件索引节点信息的数据结构。
.. code-block:: rust
// easy-fs/src/vfs.rs
pub struct Inode {
block_id: usize,
block_offset: usize,
fs: Arc<Mutex<EasyFileSystem>>,
block_device: Arc<dyn BlockDevice>,
}
``block_id````block_offset`` 记录该 ``Inode`` 对应的 ``DiskInode`` 保存在磁盘上的具体位置方便我们后续对它进行访问。 ``fs`` 是指向 ``EasyFileSystem`` 的一个指针,因为对 ``Inode`` 的种种操作实际上都是要通过底层的文件系统来完成。
仿照 ``BlockCache::read/modify`` ,我们可以设计两个方法来简化对于 ``Inode`` 对应的磁盘上的 ``DiskInode`` 的访问流程,而不是每次都需要 ``get_block_cache.lock.read/modify``
.. code-block:: rust
// easy-fs/src/vfs.rs
impl Inode {
fn read_disk_inode<V>(&self, f: impl FnOnce(&DiskInode) -> V) -> V {
get_block_cache(
self.block_id,
Arc::clone(&self.block_device)
).lock().read(self.block_offset, f)
}
fn modify_disk_inode<V>(&self, f: impl FnOnce(&mut DiskInode) -> V) -> V {
get_block_cache(
self.block_id,
Arc::clone(&self.block_device)
).lock().modify(self.block_offset, f)
}
}
下面我们分别介绍文件系统的使用者对于文件系统的一些常用操作:
获取根目录的 inode
+++++++++++++++++++++++++++++++++++++++
文件系统的使用者在通过 ``EasyFileSystem::open`` 从装载了 easy-fs 镜像的块设备上打开 easy-fs 之后,要做的第一件事情就是获取根目录的 ``Inode`` 。因为我们目前仅支持绝对路径,对于任何文件/目录的索引都必须从根目录开始向下逐级进行。等到索引完成之后,我们才能对文件/目录进行操作。事实上 ``EasyFileSystem`` 提供了另一个名为 ``root_inode`` 的方法来获取根目录的 ``Inode`` :
.. code-block:: rust
// easy-fs/src/efs.rs
impl EasyFileSystem {
pub fn root_inode(efs: &Arc<Mutex<Self>>) -> Inode {
let block_device = Arc::clone(&efs.lock().block_device);
// acquire efs lock temporarily
let (block_id, block_offset) = efs.lock().get_disk_inode_pos(0);
// release efs lock
Inode::new(
block_id,
block_offset,
Arc::clone(efs),
block_device,
)
}
}
// easy-fs/src/vfs.rs
impl Inode {
/// We should not acquire efs lock here.
pub fn new(
block_id: u32,
block_offset: usize,
fs: Arc<Mutex<EasyFileSystem>>,
block_device: Arc<dyn BlockDevice>,
) -> Self {
Self {
block_id: block_id as usize,
block_offset,
fs,
block_device,
}
}
}
``root_inode`` 中,主要是在 ``Inode::new`` 的时候将传入的 ``inode_id`` 设置为 0 ,因为根目录对应于文件系统中第一个分配的 inode ,因此它的 ``inode_id`` 总会是 0 。同时在设计上,我们不会在 ``Inode::new`` 中尝试获取整个 ``EasyFileSystem`` 的锁来查询 inode 在块设备中的位置,而是在调用它之前预先查询并作为参数传过去。
文件索引
+++++++++++++++++++++++++++++++++++++++
为了尽可能简化我们的实现,所有的文件都在根目录下面。于是,我们不必实现目录索引。文件索引的查找比较简单,仅需在根目录的目录项中根据文件名找到文件的 inode 编号即可。由于没有子目录的存在,这个过程只会进行一次。
.. code-block:: rust
// easy-fs/src/vfs.rs
impl Inode {
pub fn find(&self, name: &str) -> Option<Arc<Inode>> {
let fs = self.fs.lock();
self.read_disk_inode(|disk_inode| {
self.find_inode_id(name, disk_inode)
.map(|inode_id| {
let (block_id, block_offset) = fs.get_disk_inode_pos(inode_id);
Arc::new(Self::new(
block_id,
block_offset,
self.fs.clone(),
self.block_device.clone(),
))
})
})
}
fn find_inode_id(
&self,
name: &str,
disk_inode: &DiskInode,
) -> Option<u32> {
// assert it is a directory
assert!(disk_inode.is_dir());
let file_count = (disk_inode.size as usize) / DIRENT_SZ;
let mut dirent = DirEntry::empty();
for i in 0..file_count {
assert_eq!(
disk_inode.read_at(
DIRENT_SZ * i,
dirent.as_bytes_mut(),
&self.block_device,
),
DIRENT_SZ,
);
if dirent.name() == name {
return Some(dirent.inode_number() as u32);
}
}
None
}
}
``find`` 方法只会被根目录 ``Inode`` 调用,文件系统中其他文件的 ``Inode`` 不会调用这个方法。它首先调用 ``find_inode_id`` 方法尝试从根目录的 ``DiskInode`` 上找到要索引的文件名对应的 inode 编号。这就需要将根目录内容中的所有目录项都读到内存进行逐个比对。如果能够找到的话, ``find`` 方法会根据查到 inode 编号对应生成一个 ``Inode`` 用于后续对文件的访问。
这里需要注意的是,包括 ``find`` 在内所有暴露给文件系统的使用者的文件系统操作(还包括接下来将要介绍的几种),全程均需持有 ``EasyFileSystem`` 的互斥锁(相对的,文件系统内部的操作如之前的 ``Inode::new`` 或是上面的 ``find_inode_id`` 都是假定在已持有 efs 锁的情况下才被调用的,因此它们不应尝试获取锁)。这能够保证在多核情况下,同时最多只能有一个核在进行文件系统相关操作。这样也许会带来一些不必要的性能损失,但我们目前暂时先这样做。如果我们在这里加锁的话,其实就能够保证块缓存的互斥访问了。
文件列举
+++++++++++++++++++++++++++++++++++++++
``ls`` 方法可以收集根目录下的所有文件的文件名并以向量的形式返回,这个方法只有根目录的 ``Inode`` 才会调用:
.. code-block:: rust
// easy-fs/src/vfs.rs
impl Inode {
pub fn ls(&self) -> Vec<String> {
let _fs = self.fs.lock();
self.read_disk_inode(|disk_inode| {
let file_count = (disk_inode.size as usize) / DIRENT_SZ;
let mut v: Vec<String> = Vec::new();
for i in 0..file_count {
let mut dirent = DirEntry::empty();
assert_eq!(
disk_inode.read_at(
i * DIRENT_SZ,
dirent.as_bytes_mut(),
&self.block_device,
),
DIRENT_SZ,
);
v.push(String::from(dirent.name()));
}
v
})
}
}
文件创建
+++++++++++++++++++++++++++++++++++++++
``create`` 方法可以在根目录下创建一个文件,该方法只有根目录的 ``Inode`` 会调用:
.. code-block:: rust
:linenos:
// easy-fs/src/vfs.rs
impl Inode {
pub fn create(&self, name: &str) -> Option<Arc<Inode>> {
let mut fs = self.fs.lock();
if self.modify_disk_inode(|root_inode| {
// assert it is a directory
assert!(root_inode.is_dir());
// has the file been created?
self.find_inode_id(name, root_inode)
}).is_some() {
return None;
}
// create a new file
// alloc a inode with an indirect block
let new_inode_id = fs.alloc_inode();
// initialize inode
let (new_inode_block_id, new_inode_block_offset)
= fs.get_disk_inode_pos(new_inode_id);
get_block_cache(
new_inode_block_id as usize,
Arc::clone(&self.block_device)
).lock().modify(new_inode_block_offset, |new_inode: &mut DiskInode| {
new_inode.initialize(DiskInodeType::File);
});
self.modify_disk_inode(|root_inode| {
// append file in the dirent
let file_count = (root_inode.size as usize) / DIRENT_SZ;
let new_size = (file_count + 1) * DIRENT_SZ;
// increase size
self.increase_size(new_size as u32, root_inode, &mut fs);
// write dirent
let dirent = DirEntry::new(name, new_inode_id);
root_inode.write_at(
file_count * DIRENT_SZ,
dirent.as_bytes(),
&self.block_device,
);
});
let (block_id, block_offset) = fs.get_disk_inode_pos(new_inode_id);
// return inode
Some(Arc::new(Self::new(
block_id,
block_offset,
self.fs.clone(),
self.block_device.clone(),
)))
// release efs lock automatically by compiler
}
}
- 第 6~13 行,检查文件是否已经在根目录下,如果找到的话返回 ``None``
- 第 14~25 行,为待创建文件分配一个新的 inode 并进行初始化;
- 第 26~39 行,将待创建文件的目录项插入到根目录的内容中使得之后可以索引过来。
文件清空
+++++++++++++++++++++++++++++++++++++++
在以某些标志位打开文件(例如带有 *CREATE* 标志打开一个已经存在的文件)的时候,需要首先将文件清空。在索引到文件的 ``Inode`` 之后可以调用 ``clear`` 方法:
.. code-block:: rust
// easy-fs/src/vfs.rs
impl Inode {
pub fn clear(&self) {
let mut fs = self.fs.lock();
self.modify_disk_inode(|disk_inode| {
let size = disk_inode.size;
let data_blocks_dealloc = disk_inode.clear_size(&self.block_device);
assert!(data_blocks_dealloc.len() == DiskInode::total_blocks(size) as usize);
for data_block in data_blocks_dealloc.into_iter() {
fs.dealloc_data(data_block);
}
});
}
}
这会将之前该文件占据的索引块和数据块在 ``EasyFileSystem`` 中回收。
文件读写
+++++++++++++++++++++++++++++++++++++++
从根目录索引到一个文件之后可以对它进行读写,注意,和 ``DiskInode`` 一样,这里的读写作用在字节序列的一段区间上:
.. code-block:: rust
// easy-fs/src/vfs.rs
impl Inode {
pub fn read_at(&self, offset: usize, buf: &mut [u8]) -> usize {
let _fs = self.fs.lock();
self.read_disk_inode(|disk_inode| {
disk_inode.read_at(offset, buf, &self.block_device)
})
}
pub fn write_at(&self, offset: usize, buf: &[u8]) -> usize {
let mut fs = self.fs.lock();
self.modify_disk_inode(|disk_inode| {
self.increase_size((offset + buf.len()) as u32, disk_inode, &mut fs);
disk_inode.write_at(offset, buf, &self.block_device)
})
}
}
具体实现比较简单,需要注意在 ``DiskInode::write_at`` 之前先调用 ``increase_size`` 对自身进行扩容:
.. code-block:: rust
// easy-fs/src/vfs.rs
impl Inode {
fn increase_size(
&self,
new_size: u32,
disk_inode: &mut DiskInode,
fs: &mut MutexGuard<EasyFileSystem>,
) {
if new_size < disk_inode.size {
return;
}
let blocks_needed = disk_inode.blocks_num_needed(new_size);
let mut v: Vec<u32> = Vec::new();
for _ in 0..blocks_needed {
v.push(fs.alloc_data());
}
disk_inode.increase_size(new_size, v, &self.block_device);
}
}
这里会从 ``EasyFileSystem`` 中分配一些用于扩容的数据块并传给 ``DiskInode::increase_size``
将应用打包为 easy-fs 镜像
---------------------------------------
在第六章中我们需要将所有的应用都链接到内核中,随后在应用管理器中通过应用名进行索引来找到应用的 ELF 数据。这样做有一个缺点,就是会造成内核体积过度膨胀。同时这也会浪费内存资源,因为未被执行的应用也占据了内存空间。在实现了我们自己的文件系统之后,终于可以将这些应用打包到 easy-fs 镜像中放到磁盘中当我们要执行应用的时候只需从文件系统中取出ELF 执行文件格式的应用 并加载到内存中执行即可,这样就避免了上面的那些问题。
``easy-fs-fuse`` 的主体 ``easy-fs-pack`` 函数就实现了这个功能:
.. code-block:: rust
:linenos:
// easy-fs-fuse/src/main.rs
use clap::{Arg, App};
fn easy_fs_pack() -> std::io::Result<()> {
let matches = App::new("EasyFileSystem packer")
.arg(Arg::with_name("source")
.short("s")
.long("source")
.takes_value(true)
.help("Executable source dir(with backslash)")
)
.arg(Arg::with_name("target")
.short("t")
.long("target")
.takes_value(true)
.help("Executable target dir(with backslash)")
)
.get_matches();
let src_path = matches.value_of("source").unwrap();
let target_path = matches.value_of("target").unwrap();
println!("src_path = {}\ntarget_path = {}", src_path, target_path);
let block_file = Arc::new(BlockFile(Mutex::new({
let f = OpenOptions::new()
.read(true)
.write(true)
.create(true)
.open(format!("{}{}", target_path, "fs.img"))?;
f.set_len(8192 * 512).unwrap();
f
})));
// 4MiB, at most 4095 files
let efs = EasyFileSystem::create(
block_file.clone(),
8192,
1,
);
let root_inode = Arc::new(EasyFileSystem::root_inode(&efs));
let apps: Vec<_> = read_dir(src_path)
.unwrap()
.into_iter()
.map(|dir_entry| {
let mut name_with_ext = dir_entry.unwrap().file_name().into_string().unwrap();
name_with_ext.drain(name_with_ext.find('.').unwrap()..name_with_ext.len());
name_with_ext
})
.collect();
for app in apps {
// load app data from host file system
let mut host_file = File::open(format!("{}{}", target_path, app)).unwrap();
let mut all_data: Vec<u8> = Vec::new();
host_file.read_to_end(&mut all_data).unwrap();
// create a file in easy-fs
let inode = root_inode.create(app.as_str()).unwrap();
// write data to easy-fs
inode.write_at(0, all_data.as_slice());
}
// list apps
for app in root_inode.ls() {
println!("{}", app);
}
Ok(())
}
- 为了实现 ``easy-fs-fuse````os/user`` 的解耦,第 6~21 行使用 ``clap`` crate 进行命令行参数解析,需要通过 ``-s````-t`` 分别指定应用的源代码目录和保存应用 ELF 的目录而不是在 ``easy-fs-fuse`` 中硬编码。如果解析成功的话它们会分别被保存在变量 ``src_path````target_path`` 中。
- 第 23~38 行依次完成:创建 4MiB 的 easy-fs 镜像文件、进行 easy-fs 初始化、获取根目录 inode 。
- 第 39 行获取源码目录中的每个应用的源代码文件并去掉后缀名,收集到向量 ``apps`` 中。
- 第 48 行开始,枚举 ``apps`` 中的每个应用,从放置应用执行程序的目录中找到对应应用的 ELF 文件(这是一个 HostOS 上的文件)并将数据读入内存。接着需要在我们的 easy-fs 中创建一个同名文件并将 ELF 数据写入到这个文件中。这个过程相当于将 HostOS 上的文件系统中的一个文件复制到我们的 easy-fs 中。
尽管没有进行任何同步写回磁盘的操作,我们也不用担心块缓存中的修改没有写回磁盘。因为在 ``easy-fs-fuse`` 这个应用正常退出的过程中,块缓存因生命周期结束会被回收,届时如果 ``modified`` 标志为 true 就会将修改写回磁盘。

View File

@@ -0,0 +1,313 @@
在内核中使用 easy-fs
===============================================
块设备驱动层
-----------------------------------------------
``drivers`` 子模块中的 ``block/mod.rs`` 中,我们可以找到内核访问的块设备实例 ``BLOCK_DEVICE``
.. code-block:: rust
// os/src/drivers/block/mod.rs
type BlockDeviceImpl = virtio_blk::VirtIOBlock;
lazy_static! {
pub static ref BLOCK_DEVICE: Arc<dyn BlockDevice> = Arc::new(BlockDeviceImpl::new());
}
在 qemu 上,我们使用 ``VirtIOBlock`` 访问 VirtIO 块设备,并将它全局实例化为 ``BLOCK_DEVICE`` ,使内核的其他模块可以访问。
在启动 Qemu 模拟器的时候,我们可以配置参数来添加一块 VirtIO 块设备:
.. code-block:: makefile
:linenos:
:emphasize-lines: 11-12
# os/Makefile
FS_IMG := ../user/target/$(TARGET)/$(MODE)/fs.img
run: build
@qemu-system-riscv64 \
-machine virt \
-nographic \
-bios $(BOOTLOADER) \
-device loader,file=$(KERNEL_BIN),addr=$(KERNEL_ENTRY_PA) \
-drive file=$(FS_IMG),if=none,format=raw,id=x0 \
-device virtio-blk-device,drive=x0,bus=virtio-mmio-bus.0
- 第 11 行,我们为虚拟机添加一块虚拟硬盘,内容为我们之前通过 ``easy-fs-fuse`` 工具打包的包含应用 ELF 的 easy-fs 镜像,并命名为 ``x0``
- 第 12 行,我们将硬盘 ``x0`` 作为一个 VirtIO 总线中的一个块设备接入到虚拟机系统中。 ``virtio-mmio-bus.0`` 表示 VirtIO 总线通过 MMIO 进行控制,且该块设备在总线中的编号为 0 。
**内存映射 I/O** (MMIO, Memory-Mapped I/O) 指通过特定的物理内存地址来访问外设的设备寄存器。查阅资料,可知 VirtIO 总线的 MMIO 物理地址区间为从 0x10001000 开头的 4KiB 。
``config`` 子模块中我们硬编码 Qemu 上的 VirtIO 总线的 MMIO 地址区间(起始地址,长度)。在创建内核地址空间的时候需要建立页表映射:
.. code-block:: rust
// os/src/config.rs
pub const MMIO: &[(usize, usize)] = &[
(0x10001000, 0x1000),
];
// os/src/mm/memory_set.rs
use crate::config::MMIO;
impl MemorySet {
/// Without kernel stacks.
pub fn new_kernel() -> Self {
...
println!("mapping memory-mapped registers");
for pair in MMIO {
memory_set.push(MapArea::new(
(*pair).0.into(),
((*pair).0 + (*pair).1).into(),
MapType::Identical,
MapPermission::R | MapPermission::W,
), None);
}
memory_set
}
}
这里我们进行的是透明的恒等映射,让内核可以兼容于直接访问物理地址的设备驱动库。
由于设备驱动的开发过程比较琐碎,我们这里直接使用已有的 `virtio-drivers <https://github.com/rcore-os/virtio-drivers>`_ crate感兴趣的同学可以自行了解。
内核索引节点层
-----------------------------------------------
内核将 ``easy-fs`` 提供的 ``Inode`` 进一步封装为 OS 中的索引节点 ``OSInode``
.. code-block:: rust
// os/src/fs/inode.rs
pub struct OSInode {
readable: bool,
writable: bool,
inner: UPSafeCell<OSInodeInner>,
}
pub struct OSInodeInner {
offset: usize,
inode: Arc<Inode>,
}
``OSInode`` 就表示进程中一个被打开的常规文件或目录。 ``readable/writable`` 分别表明该文件是否允许通过 ``sys_read/write`` 进行读写,读写过程中的偏移量 ``offset````Inode`` 则加上互斥锁丢到 ``OSInodeInner`` 中。
文件描述符层
-----------------------------------------------
``OSInode`` 也是要一种要放到进程文件描述符表中,通过 ``sys_read/write`` 进行读写的文件,我们需要为它实现 ``File`` Trait
.. code-block:: rust
// os/src/fs/inode.rs
impl File for OSInode {
fn readable(&self) -> bool { self.readable }
fn writable(&self) -> bool { self.writable }
fn read(&self, mut buf: UserBuffer) -> usize {
let mut inner = self.inner.lock();
let mut total_read_size = 0usize;
for slice in buf.buffers.iter_mut() {
let read_size = inner.inode.read_at(inner.offset, *slice);
if read_size == 0 {
break;
}
inner.offset += read_size;
total_read_size += read_size;
}
total_read_size
}
fn write(&self, buf: UserBuffer) -> usize {
let mut inner = self.inner.lock();
let mut total_write_size = 0usize;
for slice in buf.buffers.iter() {
let write_size = inner.inode.write_at(inner.offset, *slice);
assert_eq!(write_size, slice.len());
inner.offset += write_size;
total_write_size += write_size;
}
total_write_size
}
}
``read/write`` 的实现也比较简单,只需遍历 ``UserBuffer`` 中的每个缓冲区片段,调用 ``Inode`` 写好的 ``read/write_at`` 接口就好了。注意 ``read/write_at`` 的起始位置是在 ``OSInode`` 中维护的 ``offset`` ,这个 ``offset`` 也随着遍历的进行被持续更新。在 ``read/write`` 的全程需要获取 ``OSInode`` 的互斥锁,保证两个进程无法同时访问同个文件。
本章我们为 ``File`` Trait 新增了 ``readable/writable`` 两个抽象接口,从而在 ``sys_read/sys_write`` 的时候进行简单的访问权限检查。
文件系统相关内核机制实现
-----------------------------------------------
文件系统初始化
+++++++++++++++++++++++++++++++++++++++++++++++
为了使用 ``easy-fs`` 提供的抽象,内核需要进行一些初始化操作。我们需要从块设备 ``BLOCK_DEVICE`` 上打开文件系统,并从文件系统中获取根目录的 inode 。
.. code-block:: rust
// os/src/fs/inode.rs
lazy_static! {
pub static ref ROOT_INODE: Arc<Inode> = {
let efs = EasyFileSystem::open(BLOCK_DEVICE.clone());
Arc::new(EasyFileSystem::root_inode(&efs))
};
}
这之后就可以使用根目录的 inode ``ROOT_INODE`` ,在内核中调用 ``easy-fs`` 的相关接口了。例如,在文件系统初始化完毕之后,调用 ``list_apps`` 函数来打印所有可用应用的文件名:
.. code-block:: rust
// os/src/fs/inode.rs
pub fn list_apps() {
println!("/**** APPS ****");
for app in ROOT_INODE.ls() {
println!("{}", app);
}
println!("**************/")
}
通过 sys_open 打开文件
+++++++++++++++++++++++++++++++++++++++++++++++
在内核中也定义一份打开文件的标志 ``OpenFlags``
.. code-block:: rust
// os/src/fs/inode.rs
bitflags! {
pub struct OpenFlags: u32 {
const RDONLY = 0;
const WRONLY = 1 << 0;
const RDWR = 1 << 1;
const CREATE = 1 << 9;
const TRUNC = 1 << 10;
}
}
impl OpenFlags {
/// Do not check validity for simplicity
/// Return (readable, writable)
pub fn read_write(&self) -> (bool, bool) {
if self.is_empty() {
(true, false)
} else if self.contains(Self::WRONLY) {
(false, true)
} else {
(true, true)
}
}
}
它的 ``read_write`` 方法可以根据标志的情况返回要打开的文件是否允许读写。简单起见,这里假设标志自身一定合法。
接着,我们实现 ``open_file`` 内核函数,可根据文件名打开一个根目录下的文件:
.. code-block:: rust
// os/src/fs/inode.rs
pub fn open_file(name: &str, flags: OpenFlags) -> Option<Arc<OSInode>> {
let (readable, writable) = flags.read_write();
if flags.contains(OpenFlags::CREATE) {
if let Some(inode) = ROOT_INODE.find(name) {
// clear size
inode.clear();
Some(Arc::new(OSInode::new(
readable,
writable,
inode,
)))
} else {
// create file
ROOT_INODE.create(name)
.map(|inode| {
Arc::new(OSInode::new(
readable,
writable,
inode,
))
})
}
} else {
ROOT_INODE.find(name)
.map(|inode| {
if flags.contains(OpenFlags::TRUNC) {
inode.clear();
}
Arc::new(OSInode::new(
readable,
writable,
inode
))
})
}
}
这里主要是实现了 ``OpenFlags`` 各标志位的语义。例如只有 ``flags`` 参数包含 `CREATE` 标志位才允许创建文件;而如果文件已经存在,则清空文件的内容。
在其基础上, ``sys_open`` 也就很容易实现了。
通过 sys_exec 加载并执行应用
+++++++++++++++++++++++++++++++++++++++++++++++
有了文件系统支持后, ``sys_exec`` 所需的表示应用 ELF 格式数据改为从文件系统中获取:
.. code-block:: rust
:linenos:
:emphasize-lines: 17-25
// os/src/syscall/process.rs
pub fn sys_exec(path: *const u8, mut args: *const usize) -> isize {
let token = current_user_token();
let path = translated_str(token, path);
let mut args_vec: Vec<String> = Vec::new();
loop {
let arg_str_ptr = *translated_ref(token, args);
if arg_str_ptr == 0 {
break;
}
args_vec.push(translated_str(token, arg_str_ptr as *const u8));
unsafe {
args = args.add(1);
}
}
if let Some(app_inode) = open_file(path.as_str(), OpenFlags::RDONLY) {
let all_data = app_inode.read_all();
let task = current_task().unwrap();
let argc = args_vec.len();
task.exec(all_data.as_slice(), args_vec);
argc as isize
} else {
-1
}
注意上面代码片段中的高亮部分。当执行获取应用的 ELF 数据的操作时,首先调用 ``open_file`` 函数,以只读的方式在内核中打开应用文件并获取它对应的 ``OSInode`` 。接下来可以通过 ``OSInode::read_all`` 将该文件的数据全部读到一个向量 ``all_data`` 中:
之后,就可以从向量 ``all_data`` 中拿到应用中的 ELF 数据,当解析完毕并创建完应用地址空间后该向量将会被回收。
同样的,我们在内核中创建初始进程 ``initproc`` 也需要替换为基于文件系统的实现:
.. code-block:: rust
// os/src/task/mod.rs
lazy_static! {
pub static ref INITPROC: Arc<TaskControlBlock> = Arc::new({
let inode = open_file("ch6b_initproc", OpenFlags::RDONLY).unwrap();
let v = inode.read_all();
TaskControlBlock::new(v.as_slice())
});
}

View File

@@ -0,0 +1,114 @@
chapter6练习
================================================
编程作业
-------------------------------------------------
硬链接
++++++++++++++++++++++++++++++++++++++++++++++++++
硬链接要求两个不同的目录项指向同一个文件,在我们的文件系统中也就是两个不同名称目录项指向同一个磁盘块。
本节要求实现三个系统调用 ``sys_linkat、sys_unlinkat、sys_stat``
**linkat**
* syscall ID: 37
* 功能:创建一个文件的一个硬链接, `linkat标准接口 <https://linux.die.net/man/2/linkat>`_
* C接口: ``int linkat(int olddirfd, char* oldpath, int newdirfd, char* newpath, unsigned int flags)``
* Rust 接口: ``fn linkat(olddirfd: i32, oldpath: *const u8, newdirfd: i32, newpath: *const u8, flags: u32) -> i32``
* 参数:
* olddirfdnewdirfd: 仅为了兼容性考虑,本次实验中始终为 AT_FDCWD (-100),可以忽略。
* flags: 仅为了兼容性考虑,本次实验中始终为 0可以忽略。
* oldpath原有文件路径
* newpath: 新的链接文件路径。
* 说明:
* 为了方便,不考虑新文件路径已经存在的情况(属于未定义行为),除非链接同名文件。
* 返回值:如果出现了错误则返回 -1否则返回 0。
* 可能的错误
* 链接同名文件。
**unlinkat**:
* syscall ID: 35
* 功能:取消一个文件路径到文件的链接, `unlinkat标准接口 <https://linux.die.net/man/2/unlinkat>`_
* C接口: ``int unlinkat(int dirfd, char* path, unsigned int flags)``
* Rust 接口: ``fn unlinkat(dirfd: i32, path: *const u8, flags: u32) -> i32``
* 参数:
* dirfd: 仅为了兼容性考虑,本次实验中始终为 AT_FDCWD (-100),可以忽略。
* flags: 仅为了兼容性考虑,本次实验中始终为 0可以忽略。
* path文件路径。
* 说明:
* 注意考虑使用 unlink 彻底删除文件的情况此时需要回收inode以及它对应的数据块。
* 返回值:如果出现了错误则返回 -1否则返回 0。
* 可能的错误
* 文件不存在。
**fstat**:
* syscall ID: 80
* 功能:获取文件状态。
* C接口: ``int fstat(int fd, struct Stat* st)``
* Rust 接口: ``fn fstat(fd: i32, st: *mut Stat) -> i32``
* 参数:
* fd: 文件描述符
* st: 文件状态结构体
.. code-block:: rust
#[repr(C)]
#[derive(Debug)]
pub struct Stat {
/// 文件所在磁盘驱动器号,该实验中写死为 0 即可
pub dev: u64,
/// inode 文件所在 inode 编号
pub ino: u64,
/// 文件类型
pub mode: StatMode,
/// 硬链接数量初始为1
pub nlink: u32,
/// 无需考虑,为了兼容性设计
pad: [u64; 7],
}
/// StatMode 定义:
bitflags! {
pub struct StatMode: u32 {
const NULL = 0;
/// directory
const DIR = 0o040000;
/// ordinary regular file
const FILE = 0o100000;
}
}
实验要求
+++++++++++++++++++++++++++++++++++++++++++++
- 实现分支ch6。
- 实验目录要求不变。
- 通过所有测例。
在 os 目录下 ``make run BASE=2`` 加载所有测例, ``ch6_usertest`` 打包了所有你需要通过的测例,你也可以通过修改这个文件调整本地测试的内容。
你的内核必须前向兼容,能通过前一章的所有测例。
.. note::
**如何调试 easy-fs**
如果你在第一章练习题中已经借助 ``log`` crate 实现了日志功能,那么你可以直接在 ``easy-fs`` 中引入 ``log`` crate通过 ``log::info!/debug!`` 等宏即可进行调试并在内核中看到日志输出。具体来说,在 ``easy-fs`` 中的修改是:在 ``easy-fs/Cargo.toml`` 的依赖中加入一行 ``log = "0.4.0"``,然后在 ``easy-fs/src/lib.rs`` 中加入一行 ``extern crate log``
你也可以完全在用户态进行调试。仿照 ``easy-fs-fuse`` 建立一个在当前操作系统中运行的应用程序,将测试逻辑写在 ``main`` 函数中。这个时候就可以将它引用的 ``easy-fs````no_std`` 去掉并使用 ``println!`` 进行调试。
问答作业
----------------------------------------------------------
1. 在我们的easy-fs中root inode起着什么作用如果root inode中的内容损坏了会发生什么
报告要求
-----------------------------------------------------------
- 简单总结你实现的功能200字以内不要贴代码
- 完成问答题。
- (optional) 你对本次实验设计及难度/工作量的看法,以及有哪些需要改进的地方,欢迎畅所欲言。

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

View File

@@ -0,0 +1,13 @@
第六章文件系统与I/O重定向
==============================================
.. toctree::
:maxdepth: 4
0intro
1file-descriptor.rst
1fs-interface
2fs-implementation-1
2fs-implementation-2
3using-easy-fs-in-kernel
4exercise