文件系统
branch ch6
抽象file
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;
}
UserBuffer 是我们在 mm 子模块中定义的app address space中的一段缓冲区
看成一个 &[u8] 切片
read 指的是从文件(即I/O资源)中读取数据放到缓冲区中,最多将缓冲区填满(即读取缓冲区的长度那么多字节),并返回实际读取的字节数;
write 指的是将缓冲区中的数据写入文件,最多将缓冲区中的数据全部写入,并返回直接写入的字节数
pub fn translated_byte_buffer(
只是将我们调用 translated_byte_buffer 获得的包含多个切片slice的 Vec 进一步包装起来
pub fn len(&self) -> usize {
通过 len 方法可以得到缓冲区的长度
作为一个迭代器可以逐字节进行读写
stdin/stdout
前面已有过的
sys_write
sys_read
重写系统调用
提前把标准输出stdout设备在文件描述符表中的文件描述符file descriptor的值规定为 1
impl File for Stdout {
输出文件 Stdout 是只写文件
只允许进程通过 write 写入到里面
遍历每个切片,
将其转化为字符串通过 print! 宏来输出。
标准输入stdin设备文件描述符file descriptor规定为 0
impl File for Stdin {
标准输入文件 Stdin 是只读文件
只允许进程通过 read 从里面读入
每次仅支持读入一个字符
基本与sys_read相同
只是需要通过 UserBuffer 来获取具体将字节写入的位置
文件描述符与文件描述符表
简化操作系统设计实现
每个进程都带有一个线性的文件描述符表
记录所有它请求内核打开并可以读写的那些文件集合
文件描述符 (File Descriptor) 则是一个非负整数
表示文件描述符表中一个打开的 文件描述符 所处的位置(可理解为数组下标)
进程通过文件描述符,可以在自身的文件描述符表中找到对应的文件记录信息
找到了对应的文件
对文件进行读写
当打开( open )或创建( create ) 一个文件的时候,
如果顺利,内核会返回给应用刚刚打开或创建的文件对应的文件描述符
关闭( close )一个文件的时候,也需要向内核提供对应的文件描述符
文件I/O操作
进程tcb中加入文件描述符表fb_table的相应字段
Vec>>
多态 (Polymorphism) 指的是在同一段代码中可以隐含多种不同类型的特征。
在 Rust 中主要通过泛型和 Trait 来实现多态
泛型是一种 编译期多态 (Static Polymorphism),在编译一个泛型函数的时候,编译器会对于所有可能用到的类型进行实例化并对应生成一个版本的汇编代码,在编译期就能知道选取哪个版本并确定函数地址,这可能会导致生成的二进制文件体积较大;
而 Trait 对象(也即上面提到的 dyn 语法)是一种 运行时多态 (Dynamic Polymorphism),需要在运行时查一种类似于 C++ 中的 虚表 (Virtual Table) 才能找到实际类型对于抽象接口实现的函数地址并进行调用,这样会带来一定的运行时开销,但是更为灵活。
TODO
暂时算给rust
新建一个进程process/tcb的时候,
我们需要按照先前的说明为进程process打开标准输入文件stdin和标准输出文件stdout
fork 时
子进程child需要完全继承父parent进程的文件描述符表来和父进程共享所有文件。
即使我们仅手动为初始进程 initproc 打开了标准输入输出,所有进程也都可以访问它们。
fd_table: vec![
// 0 -> stdin
Some(Arc::new(Stdin)),
// 1 -> stdout
Some(Arc::new(Stdout)),
// 2 -> stderr
Some(Arc::new(Stdout)),
],
文件读写系统调用
pub fn sys_write(fd: usize, buf: *const u8, len: usize) -> isize {
pub fn sys_read(fd: usize, buf: *const u8, len: usize) -> isize {
在当前进程tcb的文件描述符表fd_table中通过文件描述符file descriptor找到某个文件
无需关心文件具体的类型
它一定实现了 File Trait 的 read/write 方法
easy-fs
简化
扁平化
仅存在根目录 / 一个目录,
所有的文件都放在根目录内。
直接以文件名索引文件。
不设置用户和用户组概念,
不记录文件访问/修改的任何时间戳,
不支持软硬链接。
最基本的文件系统相关系统调用。
打开与读写文件的系统调用
fn sys_openat(dirfd: usize, path: &str, flags: u32, mode: u32) -> isize
打开一个常规文件,并返回可以访问它的文件描述符。
path 描述要打开的文件的文件名(简单起见,文件系统不需要支持目录,所有的文件都放在根目录 / 下),
flags 描述打开文件的标志,
如果 flags 为 0,则表示以只读模式 RDONLY 打开;
如果 flags 第 0 位被设置(0x001),表示以只写模式 WRONLY 打开;
如果 flags 第 1 位被设置(0x002),表示既可读又可写 RDWR ;
如果 flags 第 9 位被设置(0x200),表示允许创建文件 CREATE ,在找不到该文件的时候应创建文件;如果该文件已经存在则应该将该文件的大小归零;
如果 flags 第 10 位被设置(0x400),则在打开文件的时候应该清空文件的内容并将该文件的大小归零,也即 TRUNC 。
dirfd 和 mode 仅用于保证兼容性,忽略
果出现了错误则返回 -1,否则返回打开常规文件的文件描述符。可能的错误原因是:文件不存在。
syscall ID:56
pub fn open(path: &str, flags: OpenFlags) -> isize {
sys_openat(AT_FDCWD as usize, path, flags.bits, OpenFlags::RDWR.bits)
上面的封装
顺序读写文件示例(不考虑随机读写),ch6b_filetest_simple
以 只写 + 创建 的模式打开文件 filea
向其中写入字符串 Hello, world! 而后关闭文件。
再只读 的方式将文件 filea 的内容读取到缓冲区 buffer 中
filea 的总大小不超过缓冲区的大小
通过单次 read 即可将内容全部读出来
更常见的情况是需要进行多次 read
直到返回值为 0 才能确认文件已被读取完毕
松耦合模块化设计思路
easy-fs 是简易文件系统的本体
easy-fs-fuse 是能在开发环境(如 Ubuntu)中运行的应用程序
将应用打包为 easy-fs 格式的文件系统镜像
可以用来对 easy-fs 进行测试
easy-fs与底层设备驱动之间通过抽象接口 BlockDevice 来连接
采用轮询方式访问 virtio_blk 虚拟磁盘设备
避免调用外设中断的相关内核函数
避免了直接访问进程process相关的数据和函数
能独立于内核开发
五个层次
磁盘块设备接口层:以块为单位对磁盘块设备进行读写的 trait 接口
块缓存层:在内存中缓存磁盘块的数据,避免频繁读写磁盘
磁盘数据结构层:磁盘上的超级块、位图、索引节点、数据块、目录项等核心数据结构和相关处理
磁盘块管理器层:合并了上述核心数据结构和磁盘布局所形成的磁盘文件系统数据结构
索引节点层:管理索引节点,实现了文件创建/文件打开/文件读写等成员函数
块设备接口层
块设备的抽象接口BlockDevice
read_block 可以将编号为 block_id 的块从磁盘读入内存中的缓冲区 buf ;
write_block 可以将内存中的缓冲区 buf 中的数据写入磁盘编号为 block_id 的块。
easy-fs 的使用者将负责提供抽象方法的实现。
块缓存层
内存可以作为磁盘的缓存
block_cache.rs
pub struct BlockCache {
cache: [u8; BLOCK_SZ], cache 是一个 512 字节的数组,表示位于内存中的缓冲区;
block_id: usize, block_id 记录了这个块的编号
block_device: Arc, block_device 记录块所属的底层设备;
modified: bool, modified 记录自从这个块缓存从磁盘载入内存之后,它有没有被修改过。
}
创建 BlockCache 时,将一个块从磁盘读到缓冲区 cache :
pub fn new(
block_device.read_block(block_id, &mut cache);
impl BlockCache
fn addr_of_offset(&self, offset: usize) -> usize {
&self.cache[offset] as *const _ as usize
得到一个 BlockCache 内部的缓冲区中指定偏移量 offset 的字节地址
pub fn get_ref(&self, offset: usize) -> &T where T: Sized {
获取缓冲区中的位于偏移量 offset 的一个类型为 T 的磁盘上数据结构的不可变引用。
该泛型方法的 Trait Bound 限制类型 T 必须是一个编译时已知大小的类型
通过 core::mem::size_of::() 在编译时获取类型 T 的大小并确认该数据结构被整个包含在磁盘块及其缓冲区之内。
pub fn get_mut(&mut self, offset: usize) -> &mut T where T: Sized {
get_mut 上面的可变引用
将 BlockCache 的 modified 标记为 true 表示该缓冲区已经被修改
之后需要将数据写回磁盘块才能真正将修改同步到磁盘。
pub fn read(&self, offset: usize, f: impl FnOnce(&T) -> V) -> V {
pub fn modify(&mut self, offset:usize, f: impl FnOnce(&mut T) -> V) -> V {
get_ref和get_mut的进一步封装
BlockCache 的生命周期结束后,缓冲区也会被回收
odified 标记将会决定数据是否需要写回磁盘
impl Drop for BlockCache {
块缓存全局管理器
内存只能同时缓存有限个磁盘块
当我们要对一个磁盘块进行读写时,块缓存全局管理器检查它是否已经被载入内存中,如果是则直接返回,否则就读取磁盘块到内存。
如果内存中驻留的磁盘块缓冲区的数量已满,则需要进行缓存替换。
里使用一种类 FIFO 的缓存替换算法,在管理器中只需维护一个队列
BlockCacheManager
queue: VecDeque<(usize, Arc>)>,
queue 维护块编号和块缓存的二元组
共享引用意义在于块缓存既需要在管理器 BlockCacheManager 保留一个引用,还需要将引用返回给块缓存的请求者
互斥访问在单核上的意义在于提供内部可变性通过编译
多核环境下则可以帮助我们避免可能的并发冲突
pub fn get_block_cache(
尝试从块缓存管理器中获取一个编号为 block_id 的块缓存
遍历整个队列试图找到一个编号相同的块缓存,
到,将块缓存管理器中保存的块缓存的引用复制一份并返回
找不到,将块从磁盘读入内存中的缓冲区。
读取前需要判断已保存的块数量是否达到了上限
是,则执行缓存替换算法,替换的标准是其强引用计数 =1
即除了块缓存管理器保留的一份副本之外,在外面没有副本正在使用。
创建一个新的块缓存(会触发 read_block 进行块读取)并加入到队尾,最后返回给请求者。
磁盘布局及磁盘上数据结构
layout.rs 和 bitmap.rs
按照块block编号从小到大顺序分成 5 个连续区域
第一个区域1个block,它是 超级块 (Super Block),用于定位其他连续区域的位置,检查文件系统合法性。
第二个区域是一个索引节点位图inode bitmap,长度为几个block。它记录了索引节点区域中有哪些索引节点inode已经被分配出去使用了。
第三个区域是索引节点区域inode,长度为若干个块block。其中的每个块block都存储了若干个索引节点inodes。
第四个区域是一个数据块data block位图bitmaps,长度为若干个块。它记录了后面的数据块区域中有哪些已经被分配出去使用了。
最后的区域则是数据块区域data block,其中的每个被分配出去的块保存了文件或目录的具体内容。
超级块super block
#[repr(C)]
pub struct SuperBlock {
magic: u32, magic 是一个用于文件系统合法性验证的魔数
pub total_blocks: u32, total_block 给出文件系统的总块数
pub inode_bitmap_blocks: u32, 后面的四个字段则分别给出 easy-fs 布局中后四个连续区域的长度各为多少个块
pub inode_area_blocks: u32,
pub data_bitmap_blocks: u32,
pub data_area_blocks: u32,
}
pub fn initialize(
pub fn is_valid(&self) -> bool {
通过魔数判断超级块所在的文件系统是否合法。
bitmap
两种,索引节点inode和数据data块
每个位图bitmap都由若干个块block组成,
每个块大小 4096 bits。
每个 bit 都代表一个索引节点/数据块的分配状态。
pub struct Bitmap {
start_block_id: usize,
blocks: usize,
}
存了位图bitmap区域的起始块编号和块数
type BitmapBlock = [u64; 64];
将位图区域中的一个磁盘块block解释为长度为 64 的一个 u64 数组
每个u64都是64个bit标志位,可以用来表示对应bit的block有没有占用,所以一共可以表示64*64=4096个blocks是否占用
BLOCK_BITS=BLOCK_SZ * 8
BLOCK_SZ的大小是bytes吧
impl Bitmap
pub fn alloc(&self, block_device: &Arc) -> Option {
分配一个bit
遍历区域中的每个块block
循环内部我们需要读写这个块,块内尝试找到一个空闲的bit并置 1 。一旦涉及到块的读写,就需要用到块缓存block cache层提供的接口:
get_block_cache 获取块缓存
传入的块编号是区域起始块编号 start_block_id 加上区域内的块编号 block_id 得到的块设备上的块编号
通过 .lock() 获取块缓存的互斥锁从而可以对块缓存进行访问
使用到了 BlockCache::modify 接口
传入的偏移量 offset 为 0
因为整个块上只有一个 BitmapBlock
大小恰好为 512 字节/4096 bits,(u64)8bytes * 64
需要从块的开头开始才能访问到完整的 BitmapBlock
传给它的闭包需要显式声明参数类型为 &mut BitmapBlock
不然BlockCache 的泛型方法 modify/get_mut 无法得知应该用哪个类型来解析块上的数据
再在每个块block中以bit组(每组 64 bits)为单位进行遍历
找到一个尚未被全部分配出去的组group
在里面分配一个bit
会返回分配的bit所在的位置
等同于索引节点/数据块的编号
如果所有bit均已经被分配出去了,则返回 None
尝试在 bitmap_block 中找到一个空闲的bit并返回其位置
遍历每 64 bits构成的组(一个 u64 )
如果没有达到2^64-1说明有空位
通过 u64::trailing_ones 找到最低的一个 0 并置为 1
这个位置保存在inner_pos里
u64所在BitmapBlock的pos保存在bits64_pos中
最后的bit编号计算方式是 block_id*BLOCK_BITS+bits64_pos*64+inner_pos
闭包中的 block_id 从外部捕获
回收类似
磁盘上索引节点inode
inode索引节点区域 每个块上都保存着若干个索引节点 DiskInode
包含文件/目录的元数据
pub struct DiskInode {
pub size: u32, 表示文件/目录内容的字节数
pub direct: [u32; INODE_DIRECT_COUNT], 存储文件内容/目录内容的数据块的索引
pub indirect1: u32,
pub indirect2: u32,
type_: DiskInodeType, 表示索引节点的类型 DiskInodeType
File
Directory
}
为了尽可能节约空间,在进行索引的时候,块block的编号用一个 u32 存储
索引方式分成直接索引direct和间接索引indirect两种
文件很小的时候,只需用到直接索引,
direct 数组中最多可以指向 INODE_DIRECT_COUNT 个数据块,
当取值为 28 的时候,通过直接索引可以找到 14KiB 的内容。28 * 512 = 14kb
文件比较大的时候,不仅直接索引的 direct 数组装满,还需要用到一级间接索引 indirect1 。
指向一个一级索引块,这个块也位于磁盘布局的数据块data block区域中。
这个一级索引块中的每个 u32/4bytes 都用来指向数据块区域中一个保存该文件内容的数据块,
最多能够索引512/4=128个data block
对应64kb
文件大小超过直接索引和一级索引支持的容量上限 14+64=78KiB 的时候,就需要用到二级间接索引 indirect2
指向一个位于数据块区域中的二级索引块
二级索引块中的每个 u32 指向一个不同的一级索引块
这些一级索引块也位于数据块区域中
通过二级间接索引最多能够索引 128 x64kb = 8mb
为了充分利用空间,我们将 DiskInode 的大小设置为 128 字节
每个块正好能够容纳 4 个 DiskInode
在后续需要支持更多类型的元数据的时候
适当缩减直接索引 direct 的块数
将节约出来的空间用来存放其他元数据
仍可保证 DiskInode 的总大小为 128 字节
pub fn initialize(&mut self, type_: DiskInodeType) {
初始化一个 DiskInode 为一个文件或目录
indirect1/2 均被初始化为 0
最开始文件内容的大小为 0 字节
并不会用到一级/二级索引
为了节约空间,我们会完全按需分配一级/二级索引块。
此外,直接索引 direct 也被清零
pub fn is_dir(&self) -> bool {
pub fn is_file(&self) -> bool {
pub fn get_block_id(&self, inner_id: u32, block_device: &Arc) -> u32 {
最重要的数据块索引功能
从索引中查到它自身用于保存文件内容的第 block_id 个数据块的块编号
后续才能对这个数据块进行访问
分别利用直接索引/一级索引和二级索引,具体选用哪种索引方式取决于 block_id 所在的区间。
对一个索引块进行操作的时候,我们将其解析为磁盘数据结构 IndirectBlock ,
实质上就是一个 u32 数组,
每个都指向一个下一级索引块或者数据块
对于二级索引的情况,需要先查二级索引块找到挂在它下面的一级索引块,再通过一级索引块找到数据块。
pub fn increase_size(
初始化之后文件/目录的 size 均为 0
并不会索引到任何数据块
需要通过 increase_size 方法逐步扩充容量
pub fn increase_size(
&mut self,
new_size: u32, 容量扩充之后的文件大小
new_blocks: Vec, new_blocks 是一个保存了本次容量扩充所需块编号的向量,这些块都是由上层的磁盘块block管理器manager负责分配的。
block_device: &Arc,
);
大致的思路是按照直接索引、一级索引再到二级索引的顺序进行扩充
扩充的时候,自然需要一些新的数据块来作为索引块或是保存内容的数据块
编写一些辅助方法来确定在容量扩充的时候额外需要多少块
pub fn data_blocks(&self) -> u32 {
fn _data_blocks(size: u32) -> u32 {
(size + BLOCK_SZ as u32 - 1) / BLOCK_SZ as u32
data_blocks 方法可以计算为了容纳自身 size 字节的内容需要多少个数据块
size 除以每个块的大小 BLOCK_SZ 并向上取整
pub fn total_blocks(size: u32) -> u32 {
total_blocks 不仅包含数据块,还需要统计索引块
先调用 data_blocks 得到需要多少数据块
据数据块data blocks数目所处的区间统计索引块即可
pub fn blocks_num_needed(&self, new_size: u32) -> u32 {
blocks_num_needed 可以计算将一个 DiskInode 的 size 扩容到 new_size 需要额外多少个数据和索引块
这只需要调用两次 total_blocks 作差即可
pub fn clear_size(&mut self, block_device: &Arc) -> Vec;
清空文件的内容并回收所有数据和索引块
将回收的所有块的编号保存在一个向量中返回给磁盘块管理器
实现原理和 increase_size 一样也分为多个阶段
pub fn read_at(&self,offset: usize,buf: &mut [u8],block_device: &Arc,) -> usize {
将文件内容从 offset 字节开始的部分读到内存中的缓冲区 buf 中,
并返回实际读到的字节数。
如果文件剩下的内容还足够多,那么缓冲区会被填满;
不然的话文件剩下的全部内容都会被读到缓冲区中。
大致的思路是遍历位于字节区间 start,end 中间的那些块,将它们视为一个 DataBlock (也就是一个字节数组),
并将其中的部分内容复制到缓冲区 buf 中适当的区域。
start_block 维护着目前是文件内部第多少个数据块,
首先调用 get_block_id 从索引中查到这个数据块在块设备中的块编号
随后才能传入 get_block_cache 中将正确的数据块缓存到内存中进行访问
简单的边界条件判断,
如果要读取的内容超出了文件的范围那么直接返回 0
表示读取不到任何内容
write_at 的实现思路基本上和 read_at 完全相
不同的是 write_at 不会出现失败的情况,传入的整个缓冲区的数据都必定会被写入到文件中。
当从 offset 开始的区间超出了文件范围的时候,就需要调用者在调用 write_at 之前提前调用 increase_size 将文件大小扩充到区间的右端保证写入的完整性
目录项
文件而言,它的内容在文件系统或内核看来没有任何既定的格式,只是一个字节序列
目录的内容却需要遵从一种特殊的格式,
它可以看成一个目录项的序列,
每个目录项都是一个二元组,
包括目录下文件的文件名和索引节点编号
#[repr(C)]
pub struct DirEntry {
name: [u8; NAME_LENGTH_LIMIT + 1],
const NAME_LENGTH_LIMIT: usize = 27;
保存的文件名长度不能超过 27
inode_number: u32,
}
目录项自身长 32 字节, pub const DIRENT_SZ: usize = 32;
每个数据块可以存储 16 个目录项, 512/32 = 16
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 接口的要求:
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,
)
}
}
磁盘块管理器
efs.rs
pub struct EasyFileSystem {
pub block_device: Arc,
还要在其中保留块设备的一个指针 block_device
进行后续操作的时候,该指针会被拷贝并传递给下层的数据结构
让它们也能够直接访问块设备
pub inode_bitmap: Bitmap, 索引节点bitmap
pub data_bitmap: Bitmap, 数据块bitmap
inode_area_start_block: u32, 索引节点区域和
data_area_start_block: u32, 数据块区域起始块编号
}
pub fn create(block_device: Arc, total_blocks: u32,inode_bitmap_blocks: u32,) -> Arc> {
根据传入的参数计算每个区域各应该包含多少块。
根据 inode 位图的大小计算 inode 区域至少需要多少个块才能够使得 inode 位图中的每个bit都能够有一个实际的 inode 可以对应
这样就确定了 inode 位图区域bitmap和 inode 区域的大小
剩下的块都分配给数据块位图区域和数据块区域。
希望数据块位图bitmap中的每个bit仍然能够对应到一个数据块,
但是数据块位图bitmap又不能过小,不然会造成某些数据块永远不会被使用。
因此数据data块位图bitmap区域最合理的大小是剩余的块数除以 4097 再上取整,
因为位图bitmap中的每个块能够对应 512*84096 个数据块。
其余的块就都作为数据块使用。
创建我们的 EasyFileSystem 实例 efs
首先将块设备的前 total_blocks 个块清零,
因为我们的 easy-fs 要用到它们,这也是为初始化做准备。
位于块设备编号为 0 块上的超级块进行初始化,
只需传入之前计算得到的每个区域的块数就行了。
创建根目录 /
首先需要调用 alloc_inode 在 inode 位图bitmap中分配一个 inode ,由于这是第一次分配,它的编号固定是 0 。
接下来需要将分配到的inode初始化为 easy-fs 中的唯一一个目录,
我们需要调用 get_disk_inode_pos 来根据 inode 编号获取该 inode 所在的块block的编号以及块内偏移offset,
之后就可以将它们传给 get_block_cache 和 modify 了。
pub fn open(block_device: Arc) -> Arc> {
从一个已写入了 easy-fs 镜像的块设备上打开我们的 easy-fs
只需将块设备编号为 0 的块作为超级块读取进来
可以从中知道 easy-fs 的磁盘布局
由此可以构造 efs 实例
pub fn get_disk_inode_pos(&self, inode_id: u32) -> (u32, usize) {
pub fn get_data_block_id(&self, data_block_id: u32) -> u32 {
EasyFileSystem 知道整个磁盘布局
可以从 inode位图 或数据块位图上分配的 bit 编号
来算出各个存储inode和数据块的磁盘块在磁盘上的实际位置
pub fn alloc_inode(&mut self) -> u32 {
pub fn alloc_data(&mut self) -> u32 {
pub fn dealloc_data(&mut self, block_id: u32) {
alloc_data 和 dealloc_data 分配/回收数据块
传入/返回的参数都表示数据块在块设备block device上的编号,而不是在数据块位图bitmap中分配的bit编号;
索引节点
服务于文件相关系统调用的索引节点层的代码在 vfs.rs 中。
EasyFileSystem 实现了我们设计的磁盘布局并能够将所有块有效的管理起来
但是对于文件系统的使用者而言,他们往往不关心磁盘布局是如何实现的,而是更希望能够直接看到目录树结构中逻辑上的文件和目录。
设计索引节点 Inode 暴露给文件系统的使用者
让他们能够直接对文件和目录进行操作。
Inode 和 DiskInode 的区别从它们的名字中就可以看出:
DiskInode 放在磁盘块中比较固定的位置,
而 Inode 是放在内存中的记录文件索引节点信息的数据结构。
pub struct Inode {
block_id: usize,
block_id 和 block_offset 记录该 Inode 对应的 DiskInode 保存在磁盘上的具体位置
方便我们后续对它进行访问
block_offset: usize,
fs: Arc>,
fs 是指向 EasyFileSystem 的一个指针
因为对 Inode 的种种操作实际上都是要通过底层的文件系统来完成
block_device: Arc,
}
仿照 BlockCache::read/modify ,
我们可以设计两个方法来简化对于 Inode 对应的磁盘上的 DiskInode 的访问流程,(虽然只是单纯封装了一下)
而不是每次都需要 get_block_cache.lock.read/modify :
fn read_disk_inode(&self, f: impl FnOnce(&DiskInode) -> V) -> V {
fn modify_disk_inode(&self, f: impl FnOnce(&mut DiskInode) -> V) -> V {
常用操作
获取根目录的 inode
文件系统的使用者在通过 EasyFileSystem::open 从装载了 easy-fs 镜像的块设备block device上打开 easy-fs 之后
要做的第一件事情就是获取根目录的 Inode
因为我们目前仅支持绝对路径,对于任何文件/目录的索引都必须从根目录开始向下逐级进行。
等到索引完成之后,我们才能对文件/目录进行操作。
事实上 EasyFileSystem 提供了另一个名为 root_inode 的方法来获取根目录的 Inode
pub fn root_inode(efs: &Arc>) -> Inode {
主要是在 Inode::new 的时候将传入的 inode_id 设置为 0
因为根目录对应于文件系统中第一个分配的 inode ,因此它的 inode_id 总会是 0
同时在设计上,我们不会在 Inode::new 中尝试获取整个 EasyFileSystem 的锁来查询 inode 在块设备中的位置
而是在调用它之前预先查询并作为参数传过去。
文件索引
尽可能简化我们的实现,所有的文件都在根目录下面。
不必实现目录索引
文件索引的查找比较简单,
仅需在根目录的目录项中根据文件名找到文件的 inode 编号即可
由于没有子目录的存在,这个过程只会进行一次。
fn find_inode_id(&self,name: &str,disk_inode: &DiskInode,) -> Option {
pub fn find(&self, name: &str) -> Option> {
只会被根目录 Inode 调用
首先调用 find_inode_id 方法尝试从根目录的 DiskInode 上找到要索引的文件名对应的 inode 编号
需要将根目录内容中的所有目录项都读到内存进行逐个比对
如果能够找到的话, find 方法会根据查到 inode 编号对应生成一个 Inode 用于后续对文件的访问。
包括 find 在内所有暴露给文件系统的使用者的文件系统操作(还包括接下来将要介绍的几种),全程均需持有 EasyFileSystem 的互斥锁(相对的,文件系统内部的操作如之前的 Inode::new 或是上面的 find_inode_id 都是假定在已持有 efs 锁的情况下才被调用的,因此它们不应尝试获取锁)。
这能够保证在多核情况下,同时最多只能有一个核在进行文件系统相关操作。
这样也许会带来一些不必要的性能损失,但我们目前暂时先这样做。
如果我们在这里加锁的话,其实就能够保证块缓存的互斥访问了。
文件列举
pub fn ls(&self) -> Vec {
收集根目录下的所有文件的文件名并以向量的形式返回,这个方法只有根目录的 Inode 才会调用:
文件创建 pub fn create(&self, name: &str) -> Option> {
create 方法可以在根目录下创建一个文件,该方法只有根目录的 Inode 会调用:
检查文件是否已经在根目录下,如果找到的话返回 None ;
为待创建文件分配一个新的 inode 并进行初始化;
将待创建文件的目录项插入到根目录的内容中使得之后可以索引过来。
文件清空
pub fn clear(&self) {
在以某些标志位打开文件(例如带有 CREATE 标志打开一个已经存在的文件)的时候
需要首先将文件清空
索引到文件的 Inode 之后调用 clear 方法
将之前该文件占据的索引块和数据块在 EasyFileSystem 中回收
文件读写
从根目录索引到一个文件之后可以对它进行读写
和 DiskInode 一样,这里的读写作用在字节序列的一段区间上
pub fn read_at(&self, offset: usize, buf: &mut [u8]) -> usize {
pub fn write_at(&self, offset: usize, buf: &[u8]) -> usize {
在 DiskInode::write_at 之前先调用 increase_size 对自身进行扩容
fn increase_size(&self,new_size: u32,disk_inode: &mut DiskInode,fs: &mut MutexGuard,) {
从 EasyFileSystem 中分配一些用于扩容的数据块并传给 DiskInode::increase_size
将应用打包为 easy-fs 镜像
将所有的应用都链接到内核中
随后在应用管理器中通过应用名进行索引来找到应用的 ELF 数据
有一个缺点,就是会造成内核体积过度膨胀
同时这也会浪费内存资源,因为未被执行的应用也占据了内存空间
实现了我们自己的文件系统之后,终于可以将这些应用打包到 easy-fs 镜像中放到磁盘中
要执行应用的时候只需从文件系统中取出ELF 执行文件格式的应用 并加载到内存中执行即可
easy-fs-fuse 的主体 easy-fs-pack 函数就实现了这个功能:
fn easy_fs_pack() -> std::io::Result<()> {
为了实现 easy-fs-fuse 和 os/user 的解耦,使用 clap crate 进行命令行参数解析,
需要通过 -s 和 -t 分别指定应用的源代码目录和保存应用 ELF 的目录而不是在 easy-fs-fuse 中硬编码。
如果解析成功的话它们会分别被保存在变量 src_path 和 target_path 中。
创建 4MiB 的 easy-fs 镜像文件、进行 easy-fs 初始化、获取根目录 inode 。
获取源码目录中的每个应用的源代码文件并去掉后缀名,收集到向量 apps 中。
枚举 apps 中的每个应用,从放置应用执行程序的目录中找到对应应用的 ELF 文件(这是一个 HostOS 上的文件)并将数据读入内存。
接着需要在我们的 easy-fs 中创建一个同名文件并将 ELF 数据写入到这个文件中。
这个过程相当于将 HostOS 上的文件系统中的一个文件复制到我们的 easy-fs 中。
easy-fs-fuse 不用担心块缓存中的修改没有写回磁盘,
因为在 easy-fs 操作过程中实现了block_cache_sync_all 函数用以写回每次操作的结果。
内核中使用 easy-fs
块设备驱动层
drivers 子模块中的 block/mod.rs 中
找到内核访问的块设备实例 BLOCK_DEVICE :
pub static ref BLOCK_DEVICE: Arc = Arc::new(BlockDeviceImpl::new());
qemu 上,我们使用 VirtIOBlock 访问 VirtIO 块设备
将它全局实例化为 BLOCK_DEVICE
使内核的其他模块可以访问。
启动 Qemu 模拟器的时候,我们可以配置参数来添加一块 VirtIO 块设备:
1# os/Makefile
2
3FS_IMG := ../user/target/$(TARGET)/$(MODE)/fs.img
4
5run: build
6 @qemu-system-riscv64 \
7 -machine virt \
8 -nographic \
9 -bios $(BOOTLOADER) \
10 -device loader,file=$(KERNEL_BIN),addr=$(KERNEL_ENTRY_PA) \
11 -drive file=$(FS_IMG),if=none,format=raw,id=x0 \
为虚拟机添加一块虚拟硬盘,
内容为我们之前通过 easy-fs-fuse 工具打包的包含应用 ELF 的 easy-fs 镜像,并命名为 x0
12 -device virtio-blk-device,drive=x0,bus=virtio-mmio-bus.0
将硬盘 x0 作为一个 VirtIO 总线中的一个块设备接入到虚拟机系统中。
virtio-mmio-bus.0 表示 VirtIO 总线通过 MMIO 进行控制,且该块设备在总线中的编号为 0
内存映射 I/O (MMIO, Memory-Mapped I/O)
指通过特定的物理内存地址来访问外设的设备寄存器。
VirtIO 总线的 MMIO 物理地址区间为从 0x10001000 开头的 4KiB
config 子模块中我们硬编码 Qemu 上的 VirtIO 总线的 MMIO 地址区间(起始地址,长度)
创建内核地址空间的时候需要建立页表映射:
进行的是透明的恒等映射
让内核可以兼容于直接访问物理地址的设备驱动库
由于设备驱动的开发过程比较琐碎,我们这里直接使用已有的 virtio-drivers crate
pub const MMIO: &[(usize, usize)] = &[
(0x10001000, 0x1000),
];
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
}
}
内核索引节点层
内核将 easy-fs 提供的 Inode 进一步封装为 OS 中的索引节点 OSInode
pub struct OSInode {
readable: bool,
writable: bool,
inner: UPSafeCell,
}
OSInode 就表示进程中一个被打开的常规文件或目录。
readable/writable 分别表明该文件是否允许通过 sys_read/write 进行读写
读写过程中的偏移量 offset 和 Inode 则加上互斥锁丢到 OSInodeInner 中
pub struct OSInodeInner {
offset: usize,
inode: Arc,
}
文件描述符层
OSInode 也是要一种要放到进程文件描述符表中,
通过 sys_read/write 进行读写的文件,
需要为它实现 File Trait :
impl File for OSInode {
read/write 的实现也比较简单,
只需遍历 UserBuffer 中的每个缓冲区片段,
调用 Inode 写好的 read/write_at 接口就好了。
注意 read/write_at 的起始位置是在 OSInode 中维护的 offset ,
这个 offset 也随着遍历的进行被持续更新。
在 read/write 的全程需要获取 OSInode 的互斥锁,保证两个进程无法同时访问同个文件。
File Trait 新增了 readable/writable 两个抽象接口
在 sys_read/sys_write 的时候进行简单的访问权限检查
文件系统相关内核机制实现
文件系统初始化
pub static ref ROOT_INODE: Arc = {
lazy_static! {
pub static ref ROOT_INODE: Arc = {
let efs = EasyFileSystem::open(BLOCK_DEVICE.clone());
Arc::new(EasyFileSystem::root_inode(&efs))
};
}
这之后就可以使用根目录的 inode ROOT_INODE
在内核中调用 easy-fs 的相关接口
pub fn list_apps() { 打印所有可用应用的文件名
pub fn read_write(&self) -> (bool, bool) {
read_write 方法可以根据标志的情况返回要打开的文件是否允许读写
简单起见,这里假设标志自身一定合法。
pub fn open_file(name: &str, flags: OpenFlags) -> Option> {
根据文件名打开一个根目录下的文件
主要是实现了 OpenFlags 各标志位的语义
例如只有 flags 参数包含 CREATE 标志位才允许创建文件;
而如果文件已经存在,则清空文件的内容
pub fn sys_exec(path: *const u8, mut args: *const usize) -> isize {
当执行获取应用的 ELF 数据的操作时,首先调用 open_file 函数,
以只读的方式在内核中打开应用文件并获取它对应的 OSInode
接下来可以通过 OSInode::read_all 将该文件的数据全部读到一个向量 all_data 中:
之后,就可以从向量 all_data 中拿到应用中的 ELF 数据,
当解析完毕并创建完应用地址空间后该向量将会被回收。
lazy_static! {
pub static ref INITPROC: Arc = Arc::new({
let inode = open_file("ch6b_initproc", OpenFlags::RDONLY).unwrap();
let v = inode.read_all();
TaskControlBlock::new(v.as_slice())
});
}
内核中创建初始进程 initproc 也需要替换为基于文件系统的实现

Generated 2025-05-05 04:17:35 +0000
25bcfca-dirty 2025-05-04 14:47:56 +0000