FS 文件系统部分

FS 文件系统部分

内容来源于阅读 NPUcore 操作系统内核构建实践书籍的笔记。

写在〇之前

因为操作系统是一个工程性的问题,要实现同一个功能可能有(也应当有)许多种不同的方法,这些方法可能大同小异,也可能相距甚远。在笔者目前学习操作系统设计所面临的最大的问题,就是要记忆一大堆管理逻辑。这些管理逻辑或许有其他的平替,也可能这些管理逻辑可以组成一个耦合度不低的管理逻辑集合,同样可以被其他的集合平替,很多时候不知道为什么这样设计(当然有可能是学习操作系统这门课时没有好好学的问题),所以理解记忆起来会十分麻烦,当务之急是先将这些逻辑记忆起来,再不济记忆逻辑之间的关系,后面再来细究(毕竟再过一个礼拜就到自己写的截止期了)

〇、概述

内核添加文件系统的大致开发过程

第一步:能够写出与文件访问相关的应用

第二步:实现 easyfs 文件系统

可以先在用户态实现文件系统,并在用户态完成文件系统功能的基本测试并基本验证其实现正确性之后,将该模块嵌入到系统内核中。然后实现文件系统对接的接口,这样就可以让操作系统拥有一个简单可用的文件系统.

文件系统的整体架构自下而上可分为五层:

  • 磁盘块设备接口层:读写磁盘块设备的trait接口
  • 块缓存层:位于内存的磁盘块数据缓存
  • 磁盘数据结构层:表示磁盘文件系统的数据结构
  • 磁盘块管理器层:实现对磁盘文件系统的管理
  • 索引节点层:实现文件创建/打开/读写等操作

最底层磁盘块设备接口层)就是对块设备的访问操作接口。包括:

  • read_block 函数:将数据从设备读取到内存缓冲区中
  • write_block 函数:将数据从内存缓冲区写入到设备中

数据需要以块为单位进行读写。

然后,为了让程序可以自动化管理,以及增加编程正确性——当一个块内容被程序读取到缓冲区,并修改,还未写回到块设备的时候,另一个程序将该块的内容再读到另一个缓冲区,而不是使用第一个程序的缓冲区内的数据,将会造成数据不一致。所以添加第二层抽象层——块缓存层

块缓存基础之上,在内存中方便地处理文件系统在磁盘上的各种数据,就是第三层抽象层——磁盘数据结构层

文件系统中所有需要持久保存的数据都会放到磁盘上,包括管理文件系统的超级块(Super Block)索引节点位图区数据块位图区(管理空闲磁盘块),以及索引节点区(管理文件)和数据块区(放置文件数据)。管理上述这些磁盘数据的控制逻辑主要集中在第四层抽象层——磁盘块管理器层

对于单个文件的管理和读写的控制逻辑主要是索引节点(文件控制块)来完成,即第五层抽象层——索引节点层

第三步:把 easyfs 文件系统加入到内核中

先在 Qemu 模拟的 virtio 块设备上实现块设备驱动程序。因为要直接使用 virtio-drivers crate 中的块设备驱动,所以只要提供这个块设备驱动程序所需要的内存申请与释放以及虚实地址转换的4个函数就可以。在之前操作系统中的虚存管理实现中已经有这些函数。

然后是将文件访问相关的系统调用与 easyfs 为俄舰系统连接起来。在easyfs文件系统中没有进程概念,进程是程序运行过程中访问资源的管理实体,而之前的进程没有管理文件这种资源。琐细需要扩展进程的管理范围,将文件也纳入到进程的管理之中。因为需要多个进程访问文件,所以需要文件有共享的属性,随之伴生的是 open/close/read/write 系统调用,便于进程通过互斥或共享方式访问文件。

内核中的进程看到的文件是一个便于访问的 Inode,在此需要对 Inode 结构进一步封装,形成 OSInode 结构,以表示进程中一个打开的常规文件。进程为了进一步管理多个文件,需要扩展文件描述符表(给进程新加一个属性)。这样进程通过系统调用打开一个文件后,会讲文件加入到自身的文件描述符表中,并进一步通过文件描述符(也就是某个特定文件)来读写该文件。对于应用程序而言,它理解的磁盘数据是常规的文件和目录,不是 OSInode 这样相对复杂的结构。常规文件对应的 OSInode 是操作系统内核中的文件控制块数据结构的实例。其实现了 File Trait 定义的函数接口。这些 OSInode 实例会放入到进程文件描述符表中,并通过 sys_read/write 系统调用来完成读写文件的服务。这样就建立了文件与 OSInode 的对应关系,通过上面描述的三个开发步骤将形成包含文件系统的操作系统内核,可给应用提供基于文件的系统调用服务。


一、块设备接口层

easy-fs/src/block_dev.rs

1
2
3
4
5
6
7
8
9
pub trait BlockDevice: Send + Sync + Any {
// 将编号为 block_id 的块从磁盘读入内存的缓冲区buf
fn read_block(&self, block_id: usize, buf: &mut [u8]);
// 将内存缓冲区buf中的数据写入磁盘编号为 block_id 的块
fn write_block(&self, block_id: usize, buf: &[u8]);
// 对磁盘编号为 block_id 的块进行擦除
fn clear_block(&self, block_id: usize, num: u8){...}
fn clear_mult_block(&self, block_id: usize, cnt: usize, num: u8){...}
}

二、块缓存层(buffer cache)

块缓存层的几个关键作用(其实原理与 CPU cache 一样):

  • 减少磁盘 IO 操作:通过在内存中缓存最近或频繁访问的磁盘块来减少对磁盘的直接访问(因为内存访问速度远快于磁盘)。
  • 加速数据访问:应用请求数据时,操作系统先在缓存层查找,如果找到所需的数据块,就可以直接从内存中读取,而无需等待磁盘的慢速读取。
  • 合并写操作性:写操作先被写入到块缓存中,然后在稍后的某个时刻一起写入磁盘。这样可以将多个小的写操作小合并为一个大的磁盘 IO 操作,提高写入效率
  • 减少重复数据读取:如果多个应用或进程读取相同数据块,这些数据块只需要在磁盘内部读取依次,之后可以从块缓存中获取,减少重复的磁盘访问。
  • 提供一致性和同步机制:块缓存可以保证文件系统中数据的一致性,比如在崩溃恢复期间,可以帮助恢复未完成的写操作,确保文件系统的完整性(那如果突然断电了呢?)
  • 支持异步写操作:数据可以先缓存在块缓存中,然后异步写入磁盘,这将允许程序继续执行而不必等待磁盘 IO 操作完成。(意思是内存这边自己执行,然后程序不用管,继续原有的操作)
  • 减轻磁盘的负载:通过减少对磁盘的访问次数,块缓存有助于降低磁盘的工作负载,延长其使用寿命。

简而言之,言而总之,就是为了减少对磁盘的访问——因为它慢!

NPUcore 中对于文件系统同样实现了buff cache层,主要定义于 easy-fs/src/block_cache.rs 中。

1
2
3
4
5
6
7
8
9
pub trait Cache {
// 允许只读方式访问缓存,接受一个偏移量和一个闭包 f,闭包作用域缓存中的数据,并返回一个结果。
fn read<T, V>(&self, offset: usize, f: impl FnOnce(&T) -> V) -> V;
// 类似于read,但用于可变地修改缓存。也接受一个偏移量和一个闭包,但闭包可以修改数据。
fn modify<T, V>(&mut self, offset: usize, f: impl FnOnce(&mut T) -> V) -> V;
// 将缓存中的数据写回磁盘,接受一个块ID列表和一个指向块设备的引用。
fn sync(&self, _block_ids: Vec<usize>, _block_device: &Arc<dyn BlockDevice>){}
}
// 此处的 FnOnce是指该闭包只能被调用一次。

为了更好的管理缓存空间,需要实现一个 CacheManager 来对缓存进行管理。这样做可以统一管理不同类型的缓存,如块缓存和页表缓存。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
pub trait CacheManager {
const CACHE_SZ: usize;
type CacheType: Cache;
// 静态方法,用于创建一个新的缓存管理器实例
fn new() -> Self
where
Self: Sized;
// 用于系统内存不足时,释放缓存,接受一个邻居闭包来获取块ID,以及一个指向块设备的引用。
fn oom<FUNC>(
&self,
_neighbor: FUNC,
_block_device: &Arc<dyn BlockDevice>
) -> usize
where
FUNC: Fn(usize) -> Vec<usize>
{
unreachable!()
}
// 当文件大小变化时,通知缓存管理器释放一些缓存块。
fn notify_new_size(
&self,
_new_size: usize
) {
unreachable!()
}
}

因为有块缓存和页缓存两种缓存块,所以如下对上述的CacheManager 进一步封装实现了 BlockCacheManager 以及 PageCacheManager

1
2
3
4
5
6
7
8
9
10
11
12
13
pub trait BlockCacheManager: CacheManager {
// 尝试获取传入参数 block_id (磁盘编号) 和 inner_cache_id (管理器内部编号)对应的Cache
fn try_get_block_cache(
&self,
block_id: usize,
) -> Option<Arc<Mutex<Self::CacheType>>>;
// 该函数获取传入参数 block_id (磁盘编号) 和 inner_cache_id (管理器内部编号)对应的 Cache , 如果内存中没有,则会通过 block_device 从块设备中读入对应内容。
fn get_block_cache<FUNC>(
&self,
block_id: usize,
block_device: &Arc<dyn BlockDevice>,
) -> Arc<Mutex<Self::CacheType>>
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
pub trait PageCacheManager: CacheManager {
fn try_get_page_cache(
&self,
inner_cache_id: usize,
) -> Option<Arc<Mutex<Self::CacheType>>>;
fn get_page_cache<FUNC>(
&self,
inner_id: usize,
neighbor: FUNC,
block_device: &Arc<dyn BlockDevice>,
) -> Arc<Mutex<Self::CacheType>>
where
FUNC: Fn() -> Vec<usize>;
}

三、索引节点层

文件系统的构成

image-20241115132822809

最开始是一个磁盘扇区组成的引导块。该部分的主要目的是用于对操作系统的引导。一般只在启动操作系统时使用。

然后就是块组0,其最开始是超级块

超级块(Super Block):主要存放了该物理磁盘中文件系统结构的相关信息。并且对各个部分的大小进行说明。

inode 块位图(inode Bitmap):记录了 inode 块中哪些块已被使用

data 块位图 :记录了data 块中那些块已被使用

为了对超级块、inode 块和数据块三部分进行高效的管理,Linux 创建了几种不同的数据结构,分别是文件系统类型、inode、dentry(data entry)等几种。

超级块反映了文件系统整体的控制信息。

dentry 反映出某个文件系统在全局文件系统树中的位置。

inode 反映了文件系统对象中的一般元数据信息。

inode 存放了文件元数据信息,并指向文件的内容,所以在 Linux 中,“ls -l” 实际就是使用了inode 的文件元数据。NPUcore中虽然使用了 fat32 文件系统,但在代码层面上同样引入了 inode这个概念,而服务于文件相关系统调用的索引节点层的代码在 vfs.rs 中。

简单文件系统层实现了磁盘布局并能够将磁盘块有效的管理起来,但是对于文件系统的使用者而言,他们往往不关心磁盘布局是如何实现的(你想关心吗?),而是希望能够直接看到目录树结构中逻辑上的文件和目录(就是平时打开文件管理器时看到的那样),所以需要设计索引节点“Inode”,它屏蔽了简单文件系统层对磁盘的操作,让文件系统的使用者能够直接对文件和目录进行操作。代码如下所示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
pub struct Inode {
// 该inode的读写锁
inode_lock: RwLock<InodeLock>,
// 指向文件的内容
file_content: RwLock<FileContent>,
// 指向管理该 inode 的 PageCacheManager
file_cache_mgr: PageCacheManager,
// 文件类型
file_type: Mutex<DiskInodeType>,
// 指向父目录的 inode 以及该 inode 在父目录的位置
parent_dir: Mutex<Option<(Arc<Self>, u32)>>,
// 指向该 inode 所属的简单文件系统
fs: Arc<EasyFileSystem>,
// 该文件的相关时间属性
time: Mutex<InodeTime>,
// 控制在该 inode 执行析构函数时是否释放文件内容的区域的标识
deleted: Mutex<bool>,
}

通过上述这个 Inode 结构体,文件系统的使用者就可以对文件系统进行一些常用操作,如:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
// 创建目录项
fn create_dir_ent(
&self,
inode_lock: &RwLockWriteGuard<InodeLock>,
short_ent: FATShortDirEnt,
long_ents: Vec<FATLongDirEnt>,
) -> Result<u32, ()>

// 删除目录项
fn delete_dir_ent(
&self,
inode_lock: &RwLockWriteGuard<InodeLock>,
parent_dir: &Arc<Self>,
parent_inode_lock: &RwLockWriteGuard<InodeLock>,
name: String,
) -> Result<(), ()>

// 删除硬链接(unlink)
pub fn unlink_lock(
&self,
_inode_lock: &RwLockWriteGuard<InodeLock>,
delete: bool,
) -> Result<(), isize>

// ls
pub fn ls_lock(
&self,
inode_lock: &RwLockWriteGuard<InodeLock>,
) -> Result<Vec<(String, FATShortDirEnt)>, ()>

// 获取文件时间相关信息
pub fn time(&self) -> MutexGuard<InodeTime>

// ...

四、目录和路径名

4.1 目录

最早的文件系统没有现今的文件夹,只用文件名来标识文件,这种方法明显会带来管理上的麻烦。后来慢慢改变为使用按功能、按属性分类文件的方式,即将文件整理到不同层级的目录中,这样使得我们能够更轻松地定位所需文件(多级页表是不是也是这样的道理?)。

在Linux下可以使用 stat 工具来获取一个文件或者目录的相关信息,这些信息包括目录的权限、所有者、大小以及创建时间等。实际上,stat 工具就是直接获取一个目录文件(或者其他类型文件)的 inode 来获取如上提到的这些信息。

他有如下字段:

  • File:文件的文件名
  • Size:文件的字节大小
  • Blocks:占据的块数
  • IO Block:该操作系统一个块所占据的字节大小
  • 文件类型字段:根据获取到的文件类型信息打印
  • Access: (xxxx/xxxxxxxxxx) :访问的权限
  • Access:访问时间
  • Modify:修改时间
  • Uid & Gid:用户id 和用户所在组id
  • Device:如果文件是一个特殊文件,那么打印该特殊文件的 major/minor ID
  • Inode:该文件的底层编号,即用于索引的Inode节点
  • Links:硬链接数,即一个文件的不同文件名的数量,当两个文件硬链接到一个Inode时(实际上硬链接就是新创建一个文件,这个文件与原有文件共享一个Inode,且这个文件的数据与原文件一模一样,或者说就是一个原文件的引用),对其中一个的改变都会作用到另一个上面。

目录也是一种文件,它也有自己的Inode,其中保存着若干目录项(Dirent,Directory Entry)。根据它下面的子文件名和子目录名可以查询到子文件和子目录在文件系统中的底层编号(Inode)。

4.2 路径

以下内容不考虑软链接!

在有了目录之后,就可以将文件和目录组织为一种被称为目录树(Directory Tree)的有根树结构(在有了基层党组织结构后,顶层党组织就可以不直接对底层党员进行管辖,而是分由基层党组织进行管理,那么顶层组织就只需要对基层组织进行管理即可),这个目录就是党组织,目录树就是党组织结构。

树中的每个节点都是一个文件或者目录,一个目录下面的所有的文件和子目录都是它的孩子。

所以,所有非目录文件都是目录树的叶子节点。

目录树的根节点就是一个目录,即我们日常所见的 “根目录”,也即“Root Directory”,在类Unix系统中,就是一个文件的绝对路径 /xxx/xxx/.../xxfiile 中的第一个 “/“。

路径的分隔符也是 “/”。

相对路径绝对路径太简单了,这里就不加描述了。

在此,需要提出一个新的概念——CWD

Current Working Directory,当前所在的工作目录,是一个绝对路径。

每个进程都会记录自己的 CWD,当进程在索引文件或目录的时候,如果传给它的路径并未以“/”开头,就会被内核认为是一个相对于进程当前工作目录的相对路径,这个路径会被拼接在CWD后面,组成一个绝对路径,实际上索引用的是这个绝对路径对应的文件或者目录。

在文件系统的底层实现中,先将路径转化为一个文件或目录的Inode,再通过这个Inode具体索引文件或者目录。该过程是逐级进行的——对于绝对路径,先从根目录出发,每次根据当前目录Inode获取到信息,根据下一级子目录的目录名查到该子目录的底层编号,然后从该子目录继续向下遍历;对于相对路径,实际上就是绝对路径的过程中,跳过从根目录到当前目录的逐级遍历过程,直接从当前目录Inode获取信息,然后再如上述过程往下遍历。在这个过程中,目录的权限控制位会起到保护作用,阻止无权限的用户进行访问。

image-20241115153834638

4.3 NPUcore中目录树的数据结构及具体实现

NPUcore 中的这部分代码位于

os/src/fs/directory_tree.rs

image-20241115162022627

DirectoryTreeNode 的数据结构

1
2
3
4
5
6
7
8
9
pub struct DirectoryTreeNode {
spe_usage: Mutex<usize>,
name: String,
filesystem: Arc<FileSystem>,
file: Arc<dyn File>,
selfptr: Mutex<Weak<Self>>,
father: Mutex<Weak<Self>>,
children: RwLock<Option<BTreeMap<String, Arc<Self>>>>,
}

OSInode 的数据结构

1
2
3
4
5
6
7
8
9
pub struct OSInode {
readble: bool,
writable: bool,
special_use: bool,
append: bool,
inner: Arc<InodeImpl>,
offset: Mutex<usize>,
dirnode_ptr: Arc<Mutex<Weak<DirectoryTreeNode>>>,
}

可以看到这个OSInode实际上是对内部的InodeImpl进行了封装,而这个InodeImpl实际是定义于 fat32/vfs.rs 下的Inode数据结构

在内核中全局定义了一个目录节点向量 DIRECTORY_VEC,记录当前系统所在的目录

1
2
3
static ref DIRECTORY_VEC
: Mutex<(Vec<Weak<DirectoryTreeNode>>, usize)>
= Mutex::new((Vec::new(), 0));

同时还记录了根目录节点 ROOT,其目录是空字符串(毕竟类Unix系统下,根目录不是“/”吗,前面是不是啥都没有?)

1
2
3
4
5
6
7
8
9
10
pub static ref ROOT: Arc<DirectoryTreeNode> = {
let inode = DirectoryTreeNode::new(
"".to_string(),
Arc::new(FileSystem::new(FS::Fat32)),
OSInode::new(InodeImpl::root_inode(&FILE_SYSTEM)),
Weak::new()
);
inode.add_special_use();
inode,
};

在通过文件树寻找指定文件的过程中,首先会判断文件的路径前缀是否在路径缓存中,这样使大量文件操作效率更高,同时NPUcore也对一些默认路径进行了路径的转化。

五、文件描述符层

写在前面

文件描述符是进程访问文件的 接口,而 Inode 是文件在文件系统中的 描述符。具体来说:

  • 当一个文件被打开时,操作系统首先根据文件名查找文件的 Inode,然后为该进程分配一个文件描述符,并通过文件描述符来访问文件。
  • 文件描述符在进程的文件描述符表中,而 Inode 存储在文件系统的 Inode 表中。

一个进程能访问多个文件,所以现在需要有一个结构,能够用于管理这个进程访问的多个文件。

所以,文件描述符表(File Descriptor Table,简称 FDT) 诞生了。

它包括了多个 文件描述符(File Descriptor,简称 FD),每个FD代表了一个特定读写属性的IO资源。

为了简化操作系统设计实现,可以让每个进程都带有一个线性的文件描述符表,记录该进程请求内核打开并读写的那些文件集合。每个文件描述符是一个非负整数,表示文件描述符表中一个打开的文件描述符所处的位置(可以理解为数组下标)。进程通过FD,可以在自身的FDT中找到对应的文件记录信息,从而也就找到对应文件,并对文件进行读写。

当打开或创建一个文件的时候,一般情况下内核会返回给应用打开或创建的文件对应的FD;当应用想关闭一个文件的时候,也需要向内核提供FD,以完成对应文件相关资源的回收操作。

OSInode要放到进程文件描述符表中,并将通过 sys_read/write 系统调用进行读写,因此需要为其实现 File Trait,如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
fn readable(&self) -> bool {
self.readable
}
fn writable(&self) -> bool {
self.writable
}
fn read(&self, offset: Option<&mut usize>, buffer: &mut [u8]) -> usize {
match offset {
Some(offset) => {
let len =
self.inner.read_at_block_cache(*offset, buffer);
*offset += len;
len
}
None => {
let mut offset =
self.offset.lock();
let len =
self.inner.read_at_block_cache(*offset,
buffer);
*offset += len;
len
}
}
}
fn write(&self, offset: Option<&mut usize>, buffer: &[u8]) -> usize {
match offset {
Some(offset) => {
let len =
self.inner.write_at_block_cache(*offset, buffer);
*offset += len;
len
}
None => {
let mut offset =
self.offset.lock();
let inode_lock =
self.inner.write();
if self.append {
*offset =
self.inner.get_file_size_wlock(&inode_lock)
as usize;
}
let len = self
.inner
.write_at_block_cache_lock(&inode_lock,
*offset, buffer);
*offset += len;
len
}
}
}

read 将数据从文件读取到参数提供的缓冲区中,offset参数是一个可选的usize可变引用,即允许指定开始读取的偏移量。

write 将提供的缓冲区中的数据写入文件,与read类似。

在NPUcore中,使用文件描述符表 FdTable 来进行对文件描述符的管理,该数据结构定义于

os/src/fs/poll.rs

1
2
3
4
5
6
7
8
pub struct FdTable {
// 文件描述符数组,使用fd作为其索引值
inner: Vec<Option<FileDesciptor>>,
// 回收的文件描述符
recycled: Vec<u8>,
soft_limit: usize,
hard_limit: usize,
}

可以理解为 FDT 内部存在一个向量组,通过 open 等操作得到的 FD(非负整数),对应的就是该数组的下标。

1
2
3
4
5
pub struct FileDescriptor {
cloexec: bool,
nonblock: bool,
pub file: Arc<dyn File>,
}

因此,在一个进程中,活跃的文件被存储在 FDT 中。我们通过访问这个 FDT 获得所需的 FD ,进而定位并处理我们想要操作的特定文件。

注意,在代码中 fd 一般指 FileDescriptorFDT 中的索引值

六、文件系统相关调用

写在前面

NPUcore 实现了许多POSIX规定的系统调用,本章主要介绍几个与文件相关的系统调用,包括文件的打开、关闭、读取和写入。

6.1 open 调用

该系统调用用于打开文件并返回一个FD,应用可以通过该FD执行各种操作,如读取、写入、关闭文件等。

NPUcore提供给应用的接口为用户态下的sys_open系统调用(位于 user/src/syscall.rs),在读写一个常规文件之前,应用首先通过内核提供的 sys_open 系统调用让该文件在进程的FDT中占一项,并得到操作系统的返回值——FD,即文件关联的表项在FDT中的索引值。

user/src/syscall.rs

1
2
3
4
5
6
7
8
// 接受两个参数:
// path 文件名(包括路径)
// flags 打开标志
// 打开成功则返回对应的FD
// 失败则返回对应的错误码
pub fn sys_open(path: &str, flags: u32) -> isize {
syscall(SYSCALL_OPEN, [path.as_str() as usize, flags as usize, 0])
}

NPUcore中的flags可以为如下几种(多种标志可以共存):

  • 0:RDONLY 只读模式
  • 0x001(即第0位被设置):WRONLY 只写模式
  • 0x002(即第1位被设置):RDWR 可读可写
  • 0x200(即第9位被设置):CREATE 允许创建
    • 找不到该文件时创建
    • 找得到该文件时截断(将文件大小清零)
  • 0x400(即第10位被设置):TRUNC 截断(清空并将文件大小置零)

user/src/lib.rs

教材错了!(其中一版)
1
2
3
4
5
6
7
8
9
bitflags! {
pub struct OpenFlags: u32 {
const RDONLY = 0;
const WRONLY = 1 << 0;
const RDWR = 1 << 1;
const CREATE = 1 << 9;
const TRUNC = 1 << 10;
}
}

user/src/user_call.rs

1
2
3
pub fn open(path: &str, flags: OpenFlags) -> isize {
sys_open(path, flags.bits)
}

sys_open 传给内核的参数只有待打开文件的文件名字符串的起始地址(需要保证该字符串以 \0 结尾)和标志位。

由于每个通用寄存器为64位,需要先将u32的flags转换为usize来对齐。sys_open在NPUcore内核中调用了sys_openat函数(实验环节中自行编写):

os/src/syscall/mod.rs

1
SYSCALL_OPEN => sys_openat(AT_FDCWD, args[0] as *const u8, args[1] as u32, 0o777u32),

6.2 close 调用

该系统调用用于关闭文件(打开文件做了相应操作之后,需要关闭该文件,让进程释放被该文件占用的内核资源)。

与open一样,NPUcore提供用户态的sys_close系统调用给应用。作用机制与sys_open相似。

其内核中的函数签名:

1
pub fn sys_close(fd: usize) -> isize

接收一个参数 fd ,即FD(文件描述符)类型(在此处为 usize)。fd 表示要关闭的FD,当文件被关闭后,该文件在内核中的资源会被释放,FD 会被回收,这样做之后,进程就不能继续使用该FD进行文件读写了。如果成功关闭返回 SUCCESS,否则返回对应错误码。

src/os/syscall.rs

1
2
3
4
5
6
7
8
9
pub fn sys_close(fd: usize) -> isize {
info!("[sys_close] fd: {}", fd);
let task = current_task().unwrap();
let mut fd_table = task.files.lock();
match fd_table.remove(fd) {
Ok(_) => SUCCESS,
Err(errno) => errno,
}
}

只需要将进程控制块中的FDT对应的一项改为None,代表其已经空闲,就可以实现sys_close

具体实现

os/src/fs/mod.rs/impl FdTable

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
#[inline]
pub fn remove(&mut self, fd: usize) -> Result<FileDescriptor, isize> {
if fd >= self.inner.len() {
return Err(EBADF);
}
// 先尝试将fd从inner数组中取出
match self.inner[fd].take() {
// 成功则将fd push到recycled数组中
Some(file_descriptor) => {
self.recycled.push(fd as u8);
Ok(file_descriptor)
}
None => Err(EBADF),
}
}

6.3 read 调用

6.4 write 调用

6.5 fstat 调用

fstat 调用用于获取打开文件的状态信息及元数据,它查询有给定文件描述符打开的文件的元数据信息,返回的信息存储在stat结构中,包含了文件的类型、节点号、权限、所有者、组、大小、时间戳等属性。fstat系统调用是用于查询给定目录下某个文件状态(元数据 metadata)信息的一种方法。

fstat函数签名

1
pub fn sys_fstat(fd: usize, statbuf: *mut u8) -> isize;

接受两个参数,文件描述符(fd,实际上是文件描述符的索引)和缓冲区(statbuf)。它使用一个fd作为参数,将返回的文件metadata写入statbuf中。

这个系统调用适用于查询一个指定文件描述符或者文件描述符的元数据

文件元数据信息定义在 os/src/fs/layout.rs

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
#[derive(Clone, Copy, Debug)]
#[repr(C)]
/// Store the file attributes from a supported file.
/// 从一个支持的文件里面保存文件的属性
pub struct Stat {
/// ID of device containing file
st_dev: u64,
/// Inode number
st_ino: u64,
/// File type and mode
st_mode: u32,
/// Number of hard links
st_nlink: u32,
/// User ID of the file's owner.
st_uid: u32,
/// Group ID of the file's group.
st_gid: u32,
/// Device ID (if special file)
st_rdev: u64,
__pad: u64,
/// Size of file, in bytes.
st_size: i64,
/// Optimal block size for I/O.
st_blksize: u32,
__pad2: i32,
/// Number 512-byte blocks allocated.
st_blocks: u64,
/// Backward compatibility. Used for time of last access.
st_atime: TimeSpec,
/// Time of last modification.
st_mtime: TimeSpec,
/// Time of last status change.
st_ctime: TimeSpec,
__unused: u64,
}

sys_fstat 具体实现

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
pub fn sys_fstat(fd: usize, statbuf: *mut u8) -> isize{
let task = current_task().unwrap();
let token = task.get_user_token();
info!("[sys_fstat] fd: {}", fd);
// 首先检查 fd 是否是当前工作目录(AT_FDCWD)
let file_descriptor = match fd{
// fd 是 当前工作目录
// 通过如下函数获取当前任务的文件系统的锁,并获取到工作节点的克隆
// 从而得到一个心得fd
AT_FDCWD => task.fs.lock().working_inode.as_ref().clone(),
// 其他的fd
fd => {
// 获取fdtable
let fd_table = task.files.lock();
// 使用fd_table的get_ref()方法通过fd来获取file_descriptor
match fd_table.get_ref(fd) {
Ok(file_descriptor) => file_descriptor.clone(),
// 发生错误,返回错误码
Err(errno) => return errno,
}
}
};
// 读取获取的fd对应文件的状态信息并复制到用户定义的statbuf缓冲区
if copy_to_user(token, &file_descriptor.get_stat(), statbuf as *mut Stat).is_err() {
log::error!("[sys_fstat] Failed to copy to {:?}", statbuf);
return EFAULT;
};
SUCCESS
}

info!():使用info! 宏记录一条日志信息,显示正在操作文件的fd.

6.6 fstatat 调用

fstat系统调用不同,fstatat允许通过打开的文件描述符或者目录文件名指定文件路径。如果指定了相对路径名,它会根据第一个参数中的打开目录解析给路径。这两个系统调用中,sys_fstat 更直观,直接用文件描述符作为参数,可以方便地查询打开文件的元数据。而 sys_fstat 更强大、更具有通用性,可以指定不同的文件路径。

fstatat函数签名

1
pub fn sys_fstatat(dirfd: usize, path: *const u8, buf: *mut u8, flags: u32) -> isize;

sys_fstatat 接受四个参数,文件描述符(dirfd),路径(path), 缓冲区(buf)以及标志(flags)。

适用于查询一个指定目录下的特定文件或者目录的元数据信息

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
pub fn sys_fstatat(dirfd: usize, path: *const u8, buf: *mut u8, flags: u32) -> isize {
let token = current_user_token();
// translated_str(): 使用该函数将用户提供的path从用户空间地址
// 转换为内核空间可处理的格式。
// 如果转换失败(如由于权限问题或者无效地址),
// 则直接返回错误码
let path = match translated_str(token, path) {
Ok(path) => path,
Err(errno) => errno,
};
// 将用户提供的flags参数转换为FstatatFlags枚举
let flags = match FstatatFlags::from_bits(flags) {
Some(flags) => flags,
None => {
warn!("[sys_fstatat] unknown flags");
return EINVAL;
}
};
info!(
"[sys_fstatat] dirfd: {}, path: {:?}, flags: {:?}",
dirfd as isize, path, flags,
);
let task = current_task().unwrap();
let file_descriptor = match dirfd {
AT_FDCWD => task.fs.lock().working_inode.as_ref().clone(),
fd => {
let fd_table = task.files.lock();
match fd_table.get_ref(fd) {
Ok(file_descriptor) => file_descriptor.clone(),
Err(errno) => return errno,
}
}
};
match file_descriptor.open(&path, OpenFlags::O_RDONLY, false) {
Ok(file_descriptor) => {
if copy_to_user(token, &file_descriptor.get_stat(), buf as *mut Stat).is_err() {
log::error!("[sys_fstatat] Failed to copy to {:?}", buf);
return EFAULT;
};
SUCCESS
}
Err(errno) => errno,
}
}

七、虚拟文件系统及接口

7.1 VFS简介

Virtual File System(简称 VFS,即虚拟文件系统),也可称虚拟文件转换,是一个内核软件层,用于处理与Unix标准文件系统相关的所有系统调用。为用户程序提供文件和文件系统操作的统一接口,屏蔽不同文件系统的差异和操作细节

借助VFS可以直接使用 open、read、write此类系统调用操作而无需考虑具体的文件系统和实际的存储介质。

新的文件系统、新类型的存储介质可以无须编译的情况下,动态加载到内核中。

VFS的思想是把不同类型文件的共同信息放入内核中,具体思路是通过在用户进程和文件系统之间引入抽象层。用户可以直接通过该抽象层的接口使用不同文件系统。新的文件系统仅需支持这些接口就能直接加载到内核中。

7.2 VFS组成

为了实现对不同文件系统的抽象,VFS需要通过数据结构完成对于不同文件系统的统一描述。Linux中定义了如下内容来完成:

  • 超级块:即 Super Block,用于存储已安装的文件系统的相关信息,该内容在src/fs/fat32/efs.rs中。
  • 目录项:管理路径的目录项结构,存储该目录下所有的文件的inode号和文件名等信息。内部为树形结构,操作系统检索文件,都是从根目录开始,按层次解析路径中的所有目录,直接定位到文件。
  • inode:存放具体文件的一般信息(内核在操作文件或者目录时所需要的全部信息)。一个索引节点代表文件系统中的一个文件,但是索引节点仅当文件被访问时,才会在内存中创建。
  • 文件对象:代表由进程打开的文件。存放打开文件与进程之间进行交互的有关信息。这类信息仅当进程访问期间存在于内核内存中。文件对象(非物理文件,而是内存上的操作)由相应的open系统调用创建。由close调用撤销。

对于超级块,NPUcore在VFS中定义了一个filesystem结构体,用来存储文件系统的信息。

1
2
3
4
5
6
7
8
9
10
pub enum FS {
Null,
Fat32,
Ext4, // 我加的,还没实现
}

pub struct FileSystem {
pub fs_id: usize,
pub fs_type: FS,
}

对于目录项inode,NPUcore使用了 DirectoryTreeNode 结构体

1
2
3
4
5
6
7
8
9
pub struct DirectoryTreeNode {
spe_usage: Mutex<usize>,
name: String,
filesystem: Arc<FileSystem>,
file: Arc<dyn File>,
selfptr: Mutex<Weak<Self>>,
father: Mutex<Weak<Self>>,
children: RwLock<Option<BTreeMap<String, Arc<Self>>>>,
}

对于文件对象,有如下结构体(File_descriptor)(p203 有误)

1
2
3
4
5
pub struct FileDescriptor {
cloexec: bool,
nonblock: bool,
pub file: Arc<dyn File>,
}

file接口(特征)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
pub trait File: DowncastSync {
fn deep_clone(&self) -> Arc<dyn File>;
fn readable(&self) -> bool;
fn writable(&self) -> bool;
fn read(&self, offset: Option<&mut usize>, buf: &mut [u8]) -> usize;
fn write(&self, offset: Option<&mut usize>, buf: &[u8]) -> usize;
fn r_ready(&self) -> bool;
fn w_ready(&self) -> bool;
fn read_user(&self, offset: Option<usize>, buf: UserBuffer) -> usize;
fn write_user(&self, offset: Option<usize>, buf: UserBugger) -> usize;
fn get_size(&self) -> usize;
fn get_stat(&self) -> Stat;
fn get_file_type(&self) -> DiskInodeType;
fn is_dir(&self) -> bool {
self.get_file_type() == DiskInodeType::File
}
fn info_dirtree_node(&self, dirnode_ptr: Weak<DirectoryTreeNode>);
fn get_dirtree_node(&self) -> Option<Arc<DirectoryTreeNode>>;
fn open(&self, flags: OpenFlags, special_use: bool) -> Arc<dyn File>;
fn open_subfile(&self) -> Result<Vec<(String, Arc<dyn File>)>, isize>;
fn create(&self, name: &str, file_type: DiskInodeType) -> Result<Arc<dyn File>, isize>;
fn link_child(&self, name: &str, child: &Self) -> Result<(), isize>
where
Self: Sized;
fn unlink(&self, delete: bool) -> Result<(), isize>;
// 获取目录树信息
fn get_dirent(&self, count: usize) -> Vec<Dirent>;
fn get_offset(&self) -> usize{
self.lseek(0, SeekWhence::SEEK_CUR).unwrap()
}
fn lseek(&self, offset: isize, whence: SeekWhence) -> Result<usize, isize>;
// 修改大小(增加量修改)
fn modify_size(&self, diff:isize) -> Result<(), isize>;
// 截断大小(设置量修改)
fn truncate_size(&self, new_size: usize) -> Result<(), isize>;
// 时间戳操作
fn set_timestamp(&self, ctime: Option<usize>, atime: Option<usize>, mtime: Option<usize>);
// 缓存操作,获取单个缓存
fn get_single_cache(&self) -> Result<Vec<Arc<Mutex<PageCache>>>, ()>;
// 内存相关操作
fn oom(&self) -> usize;
// 文件挂起状态
fn hang_up(&self) -> bool;
fn ioctl(&self, _cmd: u32, _argp: usize) -> isize{
ENOTTY
}
fn fcntl(&self, cmd: u32, arg: u32) -> isize;
}

只要实现了上述特征,VFS就可以识别并进行操作。

7.3 VFS提供的接口

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
// 根据传入的目录描述符选择要打开的文件描述符
// 然后通过文件描述符的open方法尝试打开文件
// 然后将心得文件描述符插入到当前任务的文件描述符表中
// 返回新文件描述符的整数值(索引)
pub fn sys_openat(dirfd: usize, path: *const u8, flags: u32, mode: u32)

// 将进程控制块(实际上在NPUcore是TCB)中的fd_table
// 中的对应的一项改为None,代表已经空闲
pub fn sys_close(fd: usize)

// 根据fd找到对应的文件描述符,使用文件描述符的read_user()方法
// 尝试从文件中读取数据到用户空间的缓冲区buf中
// 读取count个字节
pub fn sys_read(fd: usize, buf: usize, count: usize)

// 与read类似
pub fn sys_write(fd: usize, buf: usize, count: usize)

// 先找到文件描述符,然后利用其提供的方法获取文件信息并写入到statbuf中
pub fn sys_fstat(fd: usize, statbuf: *mut u8)

// source为要挂载的文件系统的源路径或标识
// target 为文件系统将要挂载到的目标位置
// filesystemtype为要挂载的文件系统的类型
// mountflags是挂载选项和标志
// data为挂载所需要的其他数据
pub fn sys_mount(source: *const u8, target: *const u8, filesystemtype: *const u8, mountflags: usize, data: *const u8)

// 根据fd找到文件描述符,然后以whence为偏移的基准,offset为偏移量,返回操作后的文件指针位置
pub fn sys_lseek(fd: usize, offset: isize, whence: u32)

// 在指定路径dirfd下创建路径为path的目录
pub fn sys_mkdirat(dirfd: usize, path: *const u8, mode: u32)

八、文件共享与PIPE

文件共享和管道为进程之间的通信的协同提供了有效的手段,在NPUcore中,这些机制是构建多任务、多进程应用程序的基础。

文件共享:多个进程之间共享数据的一种机制。这里将深入研究硬链接和软链接。

POSIX 提供了一种特殊的文件描述符,使得可以实现父子进程之间的通信,叫做管道

8.1 软链接与硬链接

8.1.1 硬链接

即 hard link,在同一个文件系统中,将另一个文件关联到一个已经存在的文件上,使得该文件也可以访问原来已经存在的文件。

两个文件共享一个inode。即它们拥有相同的inode号以及相同的device号。它们的访问权限、所有者、大小等属性都是相同的。

硬链接只能在同一个分区内创建,不能跨越不同的文件系统。

硬链接是通过在文件系统中创建一个新的目录项来实现的,这个目录项指向同一个inode,即原始文件的inode。

当创建一个硬链接时,文件系统会在相应的目录创建一个新的目录项。实际上这个新的目录项与原始文件是同一个文件,只是拥有不同的文件名(当然也可以是相同文件名)和不同的目录路径(但是如果将文件名定义为包含路径的全称的话,那么这两个文件确实拥有不同的文件名)

如果尝试在不同的文件系统中创建硬链接,那么会创建一个新的文件副本,而不是一个硬链接。

在NPUcore中通过系统调用sys_linkat来创建硬链接

(没找到这段代码在哪里)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
pub fn sys_linkat(old_dirfd: usize, old_path: *cosnt u8, new_dirfd: usize, new_path: *cosnt u8, flags: u32) -> isize {
// 获取当前任务的上下文信息
let task = current_task().unwrap();
// 获取用户令牌(与当前任务关联的)
let token = task.get_user_token();
// 将old_path 与 new_path 从原始的指针形式转换为Rust中的字符串形式
let old_path = match translated_str(token, old_path) {
Ok(path) => path,
Err(errno) => return errno,
};
let new_path = match translated_str(token, new_path) {
Ok(path) => path,
Err(errno) => return errno,
};
info!("[sys_linkat] old_dirfd: {}, old_path: {}, new_dirfd: {}, new_path: {}, flags: {:?}", old_dirfd as isize, old_path, new_dirfd as isize, new_path, flags);
// 处理文件描述符,通过传入的两个dirfd来获取相应的文件描述符。
let old_file_descriptor = match old_dirfd {
AT_FDCWD => task.fs.lock().working_inode.as_ref().clone(),
fd => {
let fd_table = task.files.lock();
match fd_table.get_ref(fd as usize) {
Ok(file_descriptor) => file_descriptor.clone(),
Err(errno) => return errno,
}
}
};
let new_file_descriptor = match new_dirfd {
AT_FDCWD => task.fs.lock().working_inode.as_ref().clone(),
fd => {
let fd_table = task.files.lock();
match fd_table.get_ref(fd as usize) {
Ok(file_descriptor) => file_descriptor.clone(),
Err(errno) => return errno,
}
}
};
// 链接创建
match FileDescriptor::linkat(&old_file_descriptor, &old_path, &new_file_descriptor, &new_path) {
Ok(_) => SUCCESS,
Err(errnno) => errno,
}
}

linkat1

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
pub fn linkat(old_fd: &Self, old_path: &str, new_fd: &Self, new_path: &str) -> Result<(), isize> {
if old_fd.file.is_file() && !old_path.starts_with('/') {
return Err(ENOTDIR);
}
if new_fd.file.is_file() && !new_path.starts_with('/') {
return Err(ENOTDIR);
}
// 获取旧文件和新文件路径对应的inode节点
let old_inode = old_fd.file.get_dirtree_node();
let old_inode = match old_inode{
Some(inode) => inode,
None => return Err(ENOENT),
};
let mut old_abs_path = [old_inode.get_cwd(), old_path.to_string()].join("/");
if old_path.starts_with('/') {
old_abs_path = old_path.to_string();
} else {
old_abs_path = [old_inode.get_cwd(), old_path.to_string()].join("/");
}

let mut new_abs_path = [new_inode.get_cwd(), new_path.to_string()].join("/");
if new_path.starts_with('/') {
new_abs_path = new_path.to_string();
} else {
new_abs_path = [new_inode.get_cwd(), new_path.to_string()].join("/");
}
DirectoryTreeNode::linkat(&old_abs_path, &new_abs_path)
}

linkat2

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
pub fn linkat(old_path: &str, new_path: &str) -> Result<(), isize> {
assert!(old_path.starts_with('/'));
assert!(new_path.starts_with('/'));
let mut old_comps = Self::parse_dir_path(old_path);
let mut new_comps = Self::parse_dir_path(new_path);
let old_last_comp = old_comps.pop().unwrap();
let new_last_comp = new_comps.pop().unwrap();
let old_par_inode = match ROOT.cd_comp(&old_comps) {
Ok(inode) => inode,
Err(errno) => return Err(errno),
};
let new_par_inode = match ROOT.cd_comp(&new_comps) {
Ok(inode) => inode,
Err(errno) => return Err(errno),
};
type ChildLockType<'a> =
RwLockWriteGuard<'a, Option<BtreeMap<String, Arc<
DirectoryTreeNode>>>>;
let old_lock: Arc<Mutex<ChildLockType<'_>>>;
let new_lock: Arc<Mutex<ChildLockType<'_>>>;
if old_comps == new_comps {
old_lock = Arc::new(Mutex::new(old_par_inode.children.write()));
new_lock = old_lock.clone();
} else if old_comps < new_comps {
old_lock = Arc::new(Mutex::new(old_par_inode.children.write()));
new_lock = Arc::new(Mutex::new(old_par_inode.children.write()));
} else {
new_lock = Arc::new(Mutex::new(new_par_inode.children.write()));
old_lock
}
}

附录

附录一、实验

实现 open 系统调用

相关知识
open 系统调用的作用

open 系统调用是一种常用的系统调用,用于打开文件并返回一个文件描述符,应用程序可以使用该文件描述符执行各种操作,如读取、写入、关闭文件等。具体而言,open 系统调用接受两个参数:文件名和打开模式。文件名指定要打开的文件的路径和名称,其中包括文件的类型(可执行文件、文本文件、二进制文件等)。打开模式指定打开文件的方式,可以是只读、只写、读写、追加等模式。

如何实现 open 系统调用

待实现的函数签名为 sys_openat(dirfd: usize, path: *const u8, flags: u32, mode: u32) -> isize ,分别接收四个参数,分别为目录描述符(文件所在目录的文件描述符)、路径、打开标志和文件权限模式。
函数需要按照 flags 指定的打开模式来查找 path 指定的文件,并以相应权限打开,将文件描述符插入到请求的 task 中,若失败需要返回相应错误码。


FS 文件系统部分
http://xjimlinx.github.io/2025/07/09/操作系统/NPUcore/FS 文件系统部分/
作者
Xein
发布于
2025年7月9日
许可协议