使用Rust的RISC-V操作系统:文件系统

2020-05-13 00:22:44

这是关于用Rust编写RISC-V操作系统的多篇系列文章的第10章。

我在我的大学里教过操作系统,所以我将在这里链接我在该课程中关于virt I/O协议的笔记。该协议在过去几年中发生了变化,但QEMU实现的是传统的MMIO接口。

上面的注释是对流程作为一个概念的总体概述。我们在这里构建的操作系统可能会做不同的事情。这很大程度上是因为它是用铁锈写的--在这里插入笑话。

这个操作系统现在可以单独由铁锈编译。我已经更新了关于配置Rust以编译此操作系统而不需要工具链的过程的第0章!

存储是操作系统的重要组成部分。当我们运行一个shell,执行另一个程序时,我们会从某种辅助存储(如硬盘或U盘)重新加载。我们在上一章中讨论了块驱动程序,但它只读写存储。存储本身按照一定的顺序排列它的0和1。此顺序称为文件系统。我选择使用的文件系统是Minix3文件系统,我将在这里描述实际应用程序。有关Minix3文件系统或一般文件系统的更多概述,请参阅我在上面发布的课程笔记和/或视频。

我将详细介绍Minix3文件系统的每个部分,但下图描述了Minix3文件系统的所有方面和结构。

Minix3文件系统的第一个块是引导块,是为诸如引导加载程序之类的东西保留的,但是我们可以将第一个块用于任何我们想要的东西。然而,第二个块是超级块。超级数据块是描述整个文件系统的元数据,包括数据块大小和信息节点数量。

每当创建文件系统时,都会设置Minix3文件系统的整个结构。根据辅助存储的容量提前知道信息节点和数据块的总数。

因为超级块有一个已知的位置,所以我们可以查询文件系统的这一部分,告诉我们在哪里可以找到描述单个文件的索引节点(Inode)。文件系统的大部分内容可以通过使用基于块大小的简单数学来定位。下面的Rust结构描述了Minix3文件系统的超级块。

#[repr(C)]pub struct Superblock{pub ineodes:u32,pub pad0:u16,pub IMAP_BLOCKS:u16,pub zmap_block:u16,pub first_data_zone:u16,pub log_zone_size:u16,pub PAD1:u16,pub max_size:u32,pub zone:u32,pub Magic:u16,pub pad2:u16,pub block_。

超级数据块不会改变,除非我们重新调整文件系统的大小,否则我们永远不会写入超级数据块。我们的文件系统代码只需要从这个结构中读取。回想一下,我们可以在引导块之后找到此结构。Minix3文件系统的默认块大小是1,024字节,这就是为什么我们可以要求块驱动程序使用以下命令获取超级块。

超级块本身只有大约32字节;但是,回想一下,块驱动程序必须接收扇区中的请求,扇区是512字节的组。是的,我们读取超级块浪费了相当多的内存,但由于I/O限制,我们必须这样做。这就是我在大部分代码中使用缓冲区的原因。我们可以将结构指向存储器的顶部。如果字段正确对齐,我们只需引用Rust结构即可读取超级块。这就是为什么您会看到#[repr(C)]将铁锈的结构更改为C样式的结构。

为了跟踪已经分配了哪些节点和区域(块),Minix3文件系统使用位图。位图紧跟在超级块之后,每个位(因此是位图)代表一个inode或一个块!因此,该位图的一个字节可以引用8个索引节点或8个块(8位=1字节)。

为了演示这是如何工作的,我计算出172区是被占用还是空闲。

当我们测试字节21的第4位时,我们将得到0或1。如果该值为0,则意味着该区域(块)已经被分配。如果是1,则表示该区域(块)是空闲的。如果我们想要向文件中添加一个块,我们会扫描位图,找到我们遇到的第一个1。每个位正好是一个块,因此我们可以将第一个1位的位置乘以1,024(或块大小),以查看文件系统上的哪个位置可供使用!

Minix3文件系统有两个位图,一个用于如上所述的区域(块),另一个用于inode(如下所述)。

超级积木中一个有趣的部分被称为魔术。这是具有定义值的两个字节的序列。当我们到达此位置时,应该会看到0x4d5a。如果我们不这样做,它要么不是Minix3文件系统,要么就是有什么东西被损坏了。您可以看到,我检查了下面铁锈代码中的幻数。

我们需要能够按名称引用存储元素。在UNIX风格的文件系统中,元数据(如文件的大小和类型)包含在所谓的索引节点或简称索引节点中。Minix 3 inode包含有关文件的信息,包括模式(文件的权限和类型)、大小、文件所有者、访问、修改和创建时间,以及一组指向磁盘上实际文件数据所在位置的区域指针。注意,inode将文件的大小存储为u32,即4个字节。但是,2^32大约是4 GiB,因此我们不能在任何单个文件中寻址超过4 GB的数据。

这些文件系统中的大多数都遵循所谓的索引分配。这与操作系统教程中常用的文件系统不同,后者是文件分配表(FAT)。使用索引分配时,我们的指针指向磁盘上的某些数据块。您知道,块是文件系统中最小的可寻址单元。所有的东西都是由积木组成的。对于我们的操作系统而言,所有块都是1024字节的块。因此,如果我有两个文件,一个是10字节,另一个是41字节,那么这两个文件正好都需要1024字节。对于第一个文件,前10个字节包含文件的数据,其余的什么也不包含。该文件可以在此块内展开。如果文件超过块大小,则必须分配另一个文件,并由另一个区域指针指向该文件。

Minix3文件系统中有四种类型的区域指针:直接、间接、双重间接和三重间接。直接区域就是一个数字,我们可以将其乘以区块大小,从而得到确切的区块位置。但是,我们只有7个,这意味着我们只能处理7*1,024(数据块大小)=7KiB的数据!这就是间接区指针的来源。间接区域指针指向可以找到更多区域指针的块。实际上,每个块有1024个(块大小)/4=256个指针。每个指针正好是4个字节。

红色和橙色块不包含任何与该文件相关的数据。相反,这些块包含256个指针。单间接指针可以寻址1,024*1,024/4=1,024*256=262 KiB的数据。请注意,我们从具有7个直接指针的7 KiB到具有单个间接指针的262 KiB!

双重间接指针甚至更多。双间接指针指向256个指针的块。这256个点中的每一个都指向另一个256个指针的块。然后,这些指针中的每个指针都指向该文件的一个数据块。这给我们提供了1,024*256*256=67MiB,大约是67兆字节的数据。三重间接指针是1,024*256*256*256=17GiB,大约是17G的数据!

当我们到达双重和三重间接指针时,Rust代码变得有点杂乱无章。我们在嵌套循环中有嵌套循环!直接指针非常简单,如下所示。

直接指针乘以块大小。这就给了我们块的偏移量。就是这样!但是,如下面的代码所示,在读取间接区域时,我们必须更进一步,它是第8个区域(索引7)。

SYC_read(desc,Indirect_Buffer.get_mut(),block_size,block_size*inode.zones[7]);让izones=Indirect_Buffer.get()as*const u32;for i in 0..num_indirect_Pointers{if izones.add(I).read()!=0{if offset_block<;=block_see{//描述符,缓冲区,大小,偏移量syc_read(desc,block_Buffer.get_mut(),block_size,block_size*izones.add(I).read());}。

为什么是0?在Minix3中,当我们写入文件或覆盖部分文件时,它们将变得碎片化,这意味着文件的数据将分布在不连续的块中。有时,我们只是想完全摆脱一个街区。在本例中,我们可以将区域指针设置为0,这在Minix3中意味着";跳过";。

双重和三重间接区域分别多有一个和两个for循环。是的,很好读,很容易读,对吧?

请注意,inode没有任何与其相关联的名称,但我们始终按名称访问文件。这就是DirEntry结构发挥作用的地方。您知道,一个文件可以有多个名称。这些称为硬链接。下面的铁锈结构显示了目录条目是如何布局的。

再次使用#[repr(C)]将结构表示为C样式结构。请注意,我们有一个4字节(32位)的inode,后跟一个60字节的名称。该结构本身为64字节。这就是我们将inode与名称相关联的方式。

这些目录条目存储在哪里?回想一下,inode的模式告诉我们它是什么类型的文件。一种特殊类型的文件称为目录。它们仍然有大小和与之相关联的块。然而,当我们进入这些块时,我们会发现一堆这样的DirEntry结构。我们可以从称为根inode的inode 1开始。它是一个目录,所以当我们读取块时,我们会发现每个块有1,024/64=16个目录项。每个文件,无论其类型如何,都会在某个位置获得一个目录项。根节点只有紧接在根目录下的目录条目。然后,我们将转到另一个目录,比如/usr/bin/,并读取";bin";';的目录项,以查找该目录中包含的所有文件。

对目录条目进行布局使文件系统具有分层(树状)结构。要转到/usr/bin/shell,我们必须首先转到根目录/,然后转到usr目录/usr,然后找到bin目录/usr/bin,最后找到文件shell/usr/bin/shell。usr和bin将有一个与它们相关联的inode,它们的模式将告诉我们这些是目录。当我们到达shell时,它将是一个常规文件,也是在模式中描述的。

这些目录可以放在任何块中,根目录/除外。这始终是inode#1。这为我们提供了一个可靠且已知的起点。

回想一下,块驱动程序发出请求并将其发送到块设备。块设备为请求提供服务,然后在请求完成时发送中断。我们真的不知道中断会在什么时候到来,我们不能坐等。这就是我决定将文件系统读取器作为内核进程的原因。我们可以将该内核进程置于等待状态,以便在从块设备接收到中断之前不会对其进行调度。否则,我们将不得不旋转和轮询,看看是否发送过中断。

pub FN process_read(PID:u16,dev:usize,node:u32,buffer:*mut u8,大小:u32,偏移量:u32){let args=talloc::().unwork();args.PID=PID;args.dev=dev;args.buffer=buffer;args.size=size;args.Offset=Offset;args.node=node;set_waiting(PID);let_=add_kernel_process_args(read_proff。

因为我们的块驱动程序现在需要知道等待中断的进程,所以我们必须添加一个监视器,这是它将通知中断的进程。因此,我们的块操作函数的原型如下:

pub FN block_op(dev:usize,buffer:*mut u8,size:u32,Offset:u64,write:bool,watcher:u16)->;result<;u32,BlockErrors>;

如您所见,我们已经添加了另一个参数,即观察者。这是观察者的PID,而不是参考。如果我们使用引用,进程将被要求在处于等待状态时保持驻留状态。否则,我们将取消对无效内存的引用!使用PID,我们可以根据进程ID查找进程。如果找不到,我们可以静默丢弃结果。这仍然保留了空缓冲区的可能性,但是一次只做一件事,好吗?

现在,当我们处理块设备的中断时,我们必须匹配观察者并唤醒它。

让rq=queue e.desc[elem.id as usize].addr as*const request;let pid_of_watcher=(*rq).watcher;if PID_of_watcher>;0{set_running(PID_Of_Watcher);let proc=get_by_pid(PID_Of_Watcher);(*(*proc).get_frame_mut()).regs[10]=(*rq).status.status。

REGS[10]是RISC-V中的A0寄存器。它用作函数的第一个参数,但也用作返回值。因此,当进程在系统调用后唤醒并继续时,A0寄存器将包含状态。状态0表示正常,1表示I/O错误,2表示不支持的请求。这些数字在块设备的VirtIO规范中定义。

这里要做的一件好事是检查get_by_pid是否确实返回了有效的进程指针,因为我们绕过了Rust的检查。

我们需要hdd.dsk才能拥有有效的Minix3文件系统。您可以在Linux机器上执行此操作,比如我使用的Arch Linux。您可以使用losetup将文件设置为数据块设备。

falocate命令将分配一个空文件。在本例中,我们将长度(大小)指定为32 MiB。然后,我们告诉Linux使用losetup使我们的hdd.dsk成为块设备,这是一个循环设置。每当我们读/写块设备/dev/loop0时,它实际上都会读/写文件hdd.dsk。然后,我们可以通过键入mkfs.minix-3/dev/loop0在这个块设备上创建Minix3文件系统。-3很重要,因为我们使用的是Minix 3文件系统。MINIX1和MINX2没有遵循我在本教程中介绍的所有结构。

构建文件系统后,我们可以使用mount/dev/loop0/mnt将其挂载到Linux中。就像任何文件系统一样,我们可以读/写这个新文件系统。因为我们的操作系统不能按名称找到索引节点,所以我们必须通过索引节点编号来指定它。如果我们查看中间stat之后的第三行,我们可以看到inode:2。这意味着如果我们读取inode#2,我们将找到读出Hello所需的所有信息,这是我在Minix3的文件系统上的第一个文件。当我们读取该inode时,我们应该得到的文件大小为53,模式为10644(八进制)。权限为644,类型为10(普通文件)。

现在,在main.rs中,我们可以编写一个小测试来看看这是否起作用。我们需要添加一个内核进程,这与正常进程类似,只是我们在机器模式下运行。在此RISC-V模式中,MMU处于关闭状态,因此我们仅处理物理地址。

fn test_read_proc(){let buffer=kmalloc(100);//device,inode,buffer,size,Offset let bytes_read=syscall_fs_read(8,2,buffer,100,0);if bytes_read!=53{println!(";读取{}字节,但我以为文件是53字节。";,bytes_read);}Else{for i in 0..53{print!(";{}&。,unsafe{Buffer.add(I).read()as char});}println!();}kfree(Buffer);syscall_exit();}。

如您所见,我们使用上面的syscall_fs_read和syscall_exit作为内核进程。我们可以在syscall.rs中定义它们,如下所示。

fn do_make_syscall(sysno:usize,arg0:usize,arg1:usize,arg2:usize,arg3:usize,arg4:usize,arg5:usize)->;usize{unsafe{make_syscall(sysno,arg0,arg1,arg2,arg3,arg4,arg5)}}pub FN syscall_fs_read(dev:usize,innode:u32。usize{do_make_syscall(63,dev,inode as usize,buffer as usize,size as usize,0)}pub fn syscall_exit(){let_=do_make_syscall(93,0,0,0,0,0,0,0);}

如果我们查看asm/trap.S中的代码,在底部会发现make_syscall。我将63指定为read系统调用,将93指定为exit系统调用,以匹配newlib的libgoss。

.global make_syscallmake_syscallmake_syscall:mv a7,a0 mV a0,a1 mV a1,a2 mV a2,a3 mV a3,a4 mV a4,a5 mV a5,a6 eCall ret。

数字63是libgoss库中的read()系统调用,该库是newlib的一部分。这将执行以下操作:

let_=Minixfs::process_read((*frame).pid as u16,(*frame).regs[10]as usize,(*frame).regs[11]as u32,Physical_Buffer as*mut u8,(*frame).regs[13]as u32,(*frame).regs[14]as u32);

系统调用号现在位于寄存器A7中(由make_syscall程序集函数移动)。寄存器10到14(A0-A4)存储此系统调用的参数。当我们在这里调用process_read时,它将创建一个新的内核进程来处理文件系统的读取。回想一下,我们这样做是为了让块驱动程序在等待块设备回复时让我们进入睡眠状态。

Minix3文件系统是在Minix版本1和2之后接管的,这两个版本都很有教育意义。Minix3文件系统是相当合法的文件系统。您在这里看到的所有结构都是合法的,并且可以从Linux机器上使用mkfs.minix创建的文件系统中读取。