Introduction
在这个实验室中,你将实现一个名为“spawn”的库调用,用于加载和运行磁盘上的可执行文件。然后,你将进一步完善你的内核和库操作系统,以便在控制台上运行一个shell。这些功能需要一个文件系统,而本实验室将介绍一个简单的读/写文件系统。
Getting Started
fs/fs.c 操作文件系统磁盘结构的代码。 fs/bc.c 基于用户级页面错误处理设施的简单块缓存。 fs/ide.c 最小化PIO(程序输入/输出,非中断驱动)的IDE驱动代码。 fs/serv.c 文件系统服务器,使用文件系统IPC与客户端环境交互。 lib/fd.c 实现类UNIX文件描述符接口的代码。 lib/file.c 作为文件系统IPC客户端实现的磁盘文件类型驱动。 lib/console.c 控制台输入/输出文件类型的驱动。 lib/spawn.c spawn库调用的代码框架。
File system preliminaries
你将要处理的文件系统比大多数“真实”的文件系统(包括xv6 UNIX的文件系统)要简单得多,但它足够强大,能够提供基本功能:创建、读取、写入和删除文件,这些文件是按照层次化的目录结构组织的。
目前,我们正在开发的是一个单用户操作系统,它提供足够的保护来捕获错误,但不足以保护多个相互怀疑的用户免受彼此的影响。因此,我们的文件系统不支持UNIX中的文件所有权或权限概念。我们的文件系统目前也不支持硬链接、符号链接、时间戳,或者像大多数UNIX文件系统那样的特殊设备文件。
On-Disk File System Structure
大多数UNIX文件系统将可用磁盘空间划分为两种主要类型的区域:inode区域和数据区域。
UNIX文件系统为文件系统中的每个文件分配一个inode;文件的inode保存了关于该文件的关键元数据,例如其stat属性和指向其数据块的指针。
数据区域被划分为更大的(通常是8KB或更多)数据块,在其中,文件系统存储文件数据和目录元数据。目录项包含文件名和指向inodes的指针;如果文件系统中的多个目录项引用同一个文件的inode,则称该文件具有硬链接。
由于我们的文件系统不支持硬链接,我们不需要这种间接性,因此可以进行便利的简化:我们的文件系统根本不使用inodes,而是将文件(或子目录)的所有元数据简单地存储在描述该文件的(唯一的)目录项中。
文件和目录在逻辑上都由一系列数据块组成,这些数据块可能像环境的虚拟地址空间的页面一样分散在整个磁盘上。文件系统环境隐藏了块布局的细节,提供了在文件内任意偏移处读写字节序列的接口。文件系统环境在执行诸如文件创建和删除等操作时,内部处理对目录的所有修改。我们的文件系统允许用户环境直接读取目录元数据(例如,使用read),这意味着用户环境可以自行执行目录扫描操作(例如,实现ls程序),而不必依赖于对文件系统的额外特殊调用。这种目录扫描方式的缺点,以及大多数现代UNIX变体不鼓励它的原因,是它使应用程序依赖于目录元数据的格式,这使得在不更改或至少重新编译应用程序的情况下,很难改变文件系统的内部布局。
-
对上面那一段的解释
JOS的文件系统不使用inodes,所有文件的元数据都被存储在directory entry中。
文件和目录逻辑上都是由一系列数据blocks组成,这些blocks分散在磁盘中,文件系统屏蔽blocks分布的细节,提供一个可以顺序读写文件的接口。JOS文件系统允许用户读目录元数据,这就意味着用户可以扫描目录来像实现ls这种程序,UNIX没有采用这种方式的原因是,这种方式使得应用程序过度依赖目录元数据格式。
Sectors and Blocks
大多数磁盘不能以字节为单位进行读写,而是以扇区Sectors为单位进行读写。在JOS中,每个扇区为512字节。文件系统实际上是以块为单位分配和使用磁盘存储空间的。要注意两个术语之间的区别:扇区大小是磁盘硬件的属性,而块大小是使用磁盘的操作系统的一个方面。文件系统的块大小必须是底层磁盘扇区大小的倍数。 UNIX xv6文件系统使用512字节的块大小,与底层磁盘的扇区大小相同。然而,大多数现代文件系统使用更大的块大小,因为存储空间变得更便宜,以更大的粒度管理存储更为高效。我们的文件系统将使用4096字节的块大小,方便地与处理器的页面大小相匹配。
Superblocks
文件系统通常会在磁盘上的“容易找到”的位置(如最开始或最末尾)预留一些磁盘块,用于存储描述整个文件系统的元数据,例如块大小、磁盘大小、查找根目录所需的任何元数据、文件系统最后一次挂载的时间、文件系统最后一次检查错误的时间等。这些特殊的块被称为超级块(superblocks)。
我们的文件系统将有且仅有一个超级块,它始终位于磁盘上的块1。其布局由inc/fs.h中的结构体Super定义。块0通常被保留用于存放引导加载程序和分区表,因此文件系统通常不使用第一个磁盘块。许多“真实”的文件系统维护多个超级块,在磁盘的几个广泛分布的区域复制,这样如果其中一个超级块被损坏或磁盘在该区域发展出媒体错误,其他超级块仍然可以被找到并用来访问文件系统。
-
磁盘结构
-
Super结构
struct Super { uint32_t s_magic; // Magic number: FS_MAGIC uint32_t s_nblocks; // Total number of blocks on disk struct File s_root; // Root directory node };
File Meta-data
我们文件系统中描述文件的元数据的布局由inc/fs.h中的struct File定义。这些元数据包括文件的名称、大小、类型(普通文件或目录),以及指向组成文件的块的指针。如上所述,我们没有inodes,因此这些元数据存储在磁盘上的目录项中。与大多数“真实”的文件系统不同,为了简单起见,我们将使用这一个File结构来表示文件元数据,无论它是在磁盘上还是在内存中。
struct File中的f_direct数组提供了空间来存储文件的前10个(NDIRECT)块的块号,我们称之为文件的直接块。对于大小最多为10*4096 = 40KB的小文件来说,这意味着文件所有块的块号都可以直接放在File结构本身内。然而,对于更大的文件,我们需要一个地方来存放文件其余块的块号。因此,对于任何大小超过40KB的文件,我们会分配一个额外的磁盘块,称为文件的间接块 indirect block,用来存放多达4096/4 = 1024个额外块号。因此,我们的文件系统允许文件大小最多为1034个块,或略超过四兆字节。为了支持更大的文件,“真实”的文件系统通常还支持双重和三重间接块。
-
File 结构
这张图片展示了一个典型的文件系统中的文件结构,这个结构说明了**文件数据是如何存储在磁盘块中的。**这里是详细解释:
- struct File:这是一个文件元数据结构,它包含关于文件的信息。在这个例子中,结构体有256字节大小,包含以下字段:
- Name:文件的名字,这里是 "foo"。
- Size:文件的大小,这里是 54321 字节。
- Direct block pointers:这是一个指针数组,每个指针直接指向一个包含文件数据的磁盘块。在这个例子中,有10个直接指针,每个都指向一个4096字节大小的磁盘块(Block 0 到 Block 9)。
- Indirect block pointer:这是一个指向间接块的指针。间接块本身包含了更多的指针,这些指针再指向实际包含文件数据的磁盘块。
- Indirect Block:这是一个4096字节大小的块,它包含了指向其他数据块的指针。在这个例子中,它可以包含最多1024个指针(假设每个指针是4字节),每个指针又指向一个4096字节的数据块。在图片中,部分指针指向了 Block 10 到 Block 13。
这种结构允许文件系统管理比直接指针数组更大的文件。例如,如果一个文件大小超过了直接指针数组能指向的范围(这里是10个磁盘块,即40960字节),文件系统会开始使用间接指针。间接指针通过一个额外的间接块级别,允许文件系统访问更多的磁盘块。这就大大扩展了文件的潜在最大大小,允许它占用更多的磁盘空间,而不仅限于直接指针能指向的数量。
- struct File:这是一个文件元数据结构,它包含关于文件的信息。在这个例子中,结构体有256字节大小,包含以下字段:
-
File结构定义
struct File { char f_name[MAXNAMELEN]; // filename off_t f_size; // file size in bytes uint32_t f_type; // file type // Block pointers. // A block is allocated iff its value is != 0. uint32_t f_direct[NDIRECT]; // direct blocks uint32_t f_indirect; // indirect block // Pad out to 256 bytes; must do arithmetic in case we're compiling // fsformat on a 64-bit machine. uint8_t f_pad[256 - MAXNAMELEN - 8 - 4*NDIRECT - 4]; } __attribute__((packed)); // required only on some 64-bit machines
Directories versus Regular Files
我们文件系统中的File结构可以表示普通文件或目录;这两种类型的“文件”通过File结构中的type字段来区分。文件系统以完全相同的方式管理普通文件和目录文件,不同之处在于,文件系统根本不解释与普通文件关联的数据块的内容,而将目录文件的内容解释为一系列File结构,这些结构描述了目录内的文件和子目录。
我们文件系统中的 超级块 包含一个File结构(struct Super中的root字段),它保存了文件系统根目录的元数据。这个目录文件的内容是一系列File结构的顺序,描述了位于文件系统根目录内的文件和目录。根目录中的任何子目录也可能包含更多代表子子目录的File结构。
The File System
这个实验的目标不是让你实现整个文件系统,而是让你只实现某些关键组件。特别是,你将负责将块读入块缓存并将它们刷新回磁盘;分配磁盘块;将文件偏移映射到磁盘块;以及在IPC接口中实现read、write和open。因为你不会自己实现文件系统的所有部分,所以熟悉提供的代码和各种文件系统接口非常重要。
Disk Access
我们操作系统中的文件系统环境需要能够访问磁盘,但我们还没有在内核中实现任何磁盘访问功能。我们没有采取传统的“单体式”操作系统策略,即在内核中添加IDE磁盘驱动程序以及必要的系统调用来允许文件系统访问它,而是将IDE磁盘驱动程序作为用户级文件系统环境的一部分来实现。我们仍然需要稍微修改内核,以便设置事物,使文件系统环境具有实现磁盘访问自身所需的权限。
只要我们依赖轮询、“程序化I/O”(PIO)基础的磁盘访问,不使用磁盘中断,这样在用户空间实现磁盘访问就很容易。在用户模式下实现基于中断的设备驱动程序也是可能的(例如,L3和L4内核就是这样做的),但这更困难,因为内核必须处理设备中断并将它们分派到正确的用户模式环境。
x86处理器使用EFLAGS寄存器中的IOPL位来确定受保护模式的代码是否允许执行特殊的设备I/O指令,如IN和OUT指令。由于我们需要访问的所有IDE磁盘寄存器都位于x86的I/O空间而不是内存映射,因此向文件系统环境授予“I/O权限”是我们为了允许文 件系统访问这些寄存器所需做的唯一事情。实际上,EFLAGS寄存器中的IOPL位为内核提供了一个简单的“全有或全无”的方法来控制用户模式代码是否可以访问I/O空间。在我们的情况下,我们希望文件系统环境能够访问I/O空间,但我们不希望任何其他环境能够访问I/O空间。
-
对上面那段话的解释
这段话描述的是在一个操作系统中实现文件系统环境(负责管理文件和目录等)的方法,特别强调了如何处理磁盘访问的问题。
- 不采用传统的单体式操作系统策略:通常,操作系统会在其核心(内核)中直接集成磁盘驱动程序(如IDE驱动程序),从而允许文件系统访问磁盘。但在这里,他们选择了一种不同的方法,即将IDE磁盘驱动程序实现为用户级别的文件系统环境的一部分。这意味着磁盘驱动程序不是内核的一部分,而是运行在用户空间中,作为文件系统环境的一部分。
- 修改内核以提供特定权限:尽管IDE驱动程序是在用户级别实现的,他们仍需要对内核进行少量修改,以确保文件系统环境具有执行磁盘访问所需的特定权限。
- 轮询和程序化I/O(PIO)的磁盘访问:他们选择依赖于轮询和程序化I/O的方法来访问磁盘,而不是使用磁盘中断。这种方法相对简单,因为它不涉及内核处理来自磁盘的中断。
- I/O权限和EFLAGS寄存器中的IOPL位:在x86处理器中,EFLAGS寄存器的IOPL位用于控制受保护模式下代码是否可以执行特定的设备I/O指令(如IN和OUT指令)。由于需要访问的IDE磁盘寄存器位于I/O空间而不是内存映射空间中,因此只需向文件系统环境授予I/O权限。这样做可以使文件系统环境访问这些寄存器,而不允许其他环境访问。
Exercise 1.
i386_init
通过将类型ENV_TYPE_FS
传递给你的环境创建函数env_create
来识别文件系统环境。修改env.c
中的env_create
,使其为文件系统环境提供I/O权限,但不为任何其他环境提供该权限。确保你能够启动文件环境而不会导致通用保护错误。你应该通过
make grade
中的 “fs i/o” 测试。
代码
if (type == ENV_TYPE_FS) {
e->env_tf.tf_eflags |= FL_IOPL_MASK;
}
Question
Do you have to do anything else to ensure that this I/O privilege setting is saved and restored properly when you subsequently switch from one environment to another? Why?
-
回答
在确保这个I/O权限设置在后续从一个环境切换到另一个环境时被正确保存和恢复方面,你不需要做任何额外的事情。这是因为I/O权限是通过EFLAGS寄存器中的IOPL位来控制的,而这个寄存器的值会在进行环境切换时由处理器自动保存和恢复。
当操作系统切换环境(也就是进行上下文切换)时,处理器会保存当前环境(即当前执行线程)的状态,包括所有的寄存器值。当它切换回该环境时,这些值会被恢复到其之前的状态。由于IOPL位是EFLAGS寄存器的一部分,它也会在这个过程中被保存和恢复。因此,一旦为特定环境设置了I/O权限,这些设置将在环境切换过程中自动得到处理,无需进行额外的操作。
请注意,在这个实验室中,GNUmakefile 文件设置了 QEMU,使用文件 obj/kern/kernel.img
作为磁盘0(通常在DOS/Windows下是“驱动器C”)的镜像,和使用(新的)文件 obj/fs/fs.img
作为磁盘1(“驱动器D”)的镜像。在这个实验室中,我们的文件系统应该只接触磁盘1;磁盘0仅用于引导内核。如果你以某种方式损坏了任何一个磁盘镜像,你可以通过键入以下命令将它们重置为原始的、"未受损的"版本:
$ rm obj/kern/kernel.img obj/fs/fs.img
$ make
或者这样操作:
$ make clean
$ make
Challenge!
实现基于中断的IDE磁盘访问,无论是否使用DMA(直接内存访问)。你可以决定是将设备驱动程序移动到内核中,保留在用户空间中与文件系统一起,或者甚至(如果你真的想要进入微内核的精神)将其移动到它自己的独立环境中。
The Block Cache
在我们的文件系统中,我们将利用处理器的虚拟内存系统实现一个简单的“缓冲区缓存”(实际上就是块缓存)。块缓存的代码位于 fs/bc.c
。
我们的文件系统将仅限于处理3GB或更小容量的磁盘。我们在文件系统环境的地址空间中预留了一个大的、固定的3GB区域,从0x10000000(DISKMAP)到0xD0000000(DISKMAP+DISKMAX),作为磁盘的“内存映射”版本。例如,磁盘块0映射在虚拟地址0x10000000,磁盘块1映射在虚拟地址0x10001000,依此类推。fs/bc.c
中的 diskaddr
函数实现了从磁盘块号到虚拟地址的转换(以及一些合理性检查)。
由于我们的文件系统环境拥有自己独立于系统中所有其他环境的虚拟地址空间,而文件系统环境需要做的唯一事情就是实现文件访问,因此以这种方式预留大部分文件系统环境的地址空间是合理的。在32位机器上对真实的文件系统实现这样做会很尴尬,因为现代磁盘容量大于3GB。然而,在具有64位地址空间的机器上,这样的缓冲区缓存管理方法可能仍然是合理的。
当然,将整个磁盘读入内存会花费很长时间,所以我们将实现一种需求分页的形式,即只在磁盘映射区域分配页面,并在该区域发生页面错误时从磁盘读取相应的块。这样,我们可以假装整个磁盘都在内存中。
Exercise 2.
在
fs/bc.c
中实现bc_pgfault
和flush_block
函数。bc_pgfault
是一个页面错误处理程序,就像你在上一个实验为写时复制的fork写的那样,不同的是它的工作是响应页面错误从磁盘加载页面。编写此函数时,请记住:(1)addr
可能不会与块边界对齐,(2)ide_read
操作的是扇区,而不是块。
flush_block
函数应该在必要时将一个块写出到磁盘。如果块甚至不在块缓存中(即页面未被映射)或者没有被修改(不是脏的),那么flush_block
不应该执行任何操作。我们将使用虚拟内存硬件来跟踪自从磁盘上次读取或写入以来,磁盘块是否被修改。要查看一个块是否需要写入,我们可以查看 uvpt 条目中是否设置了 PTE_D “脏”位。(处理器在写入该页面时设置 PTE_D 位;参见386参考手册第5章的5.2.4.3节。)写入磁盘后,flush_block
应该使用sys_page_map
清除 PTE_D 位。使用
make grade
来测试你的代码。你的代码应该通过 “check_bc”、“check_super” 和 “check_bitmap” 测试。
bc_pgfault()
bc_pgfault(struct UTrapframe *utf) {
// 触发页面错误的地址
void *addr = (void *) utf->utf_fault_va;
// 计算出错误地址对应的磁盘块号
uint32_t blockno = ((uint32_t)addr - DISKMAP) / BLKSIZE;
int r;
// 检查页面错误是否在块缓存区域内
if (addr < (void*)DISKMAP || addr >= (void*)(DISKMAP + DISKSIZE))
panic("page fault in FS: eip %08x, va %08x, err %04x",
utf->utf_eip, addr, utf->utf_err);
// 检查块号是否超出范围
if (super && blockno >= super->s_nblocks)
panic("reading non-existent block %08x\\n", blockno);
// 将地址向下取整到页面边界,并分配一个新页面
addr = ROUNDDOWN(addr, PGSIZE);
sys_page_alloc(0, addr, PTE_W|PTE_U|PTE_P);
// 从磁盘读取相应的块到新分配的页面
if ((r = ide_read(blockno * BLKSECTS, addr, BLKSECTS)) < 0)
panic("ide_read: %e", r);
// 从磁盘读取块后,清除页面的脏位
if ((r = sys_page_map(0, addr, 0, addr, uvpt[PGNUM(addr)] & PTE_SYSCALL)) < 0)
panic("in bc_pgfault, sys_page_map: %e", r);
// 检查是否读取了未分配的块
if (bitmap && block_is_free(blockno))
panic("reading free block %08x\\n", blockno);
}
这个 bc_pgfault
函数是文件系统中用于处理页面错误的函数。当访问尚未加载到内存中的磁盘块时,会触发页面错误,该函数负责从磁盘加载相应的块到内存中。
输入参数:
struct UTrapframe *utf
: 指向一个UTrapframe
结构的指针,其中包含了页面错误发生时的相关信息,如引起错误的虚拟地址 (utf_fault_va
)、错误发生时的指令指针 (utf_eip
) 和错误代码 (utf_err
)。
函数的作用:
- 确定错误地址:首先检查触发页面错误的地址 (
addr
) 是否在块缓存区域内。如果不是,函数会调用panic
来报告错误。 - 检查块号的有效性:通过计算确定错误地址对应的块号 (
blockno
)。如果blockno
超出了文件系统的块数,同样会调用panic
来报告错误。 - 分配页面并从磁盘读取数据:
- 首先将
addr
向下取整到页面边界。 - 调用
sys_page_alloc
在磁盘映射区域分配一个新页面。 - 使用
ide_read
函数从磁盘读取相应的块到新分配的页面中。
- 首先将
- 清除脏位:读取磁盘块后,通过调用
sys_page_map
清除该页面的PTE_D脏位,因为该页面刚从磁盘读取,所以目前是“干净”的。 - 检查块分配情况:最后,检查刚刚读入的块是否已经被分配。这是为了避免读取未分配的块,如果块未被分配,函数同样会调用
panic
报告错误。
通过这个函数的实现,文件系统能够有效地处理对尚未载入内存的磁盘块的访问,实现了一种按需加载(demand paging)的机制。
flush_block
void flush_block(void *addr) {
// 根据地址计算磁盘块号
uint32_t blockno = ((uint32_t)addr - DISKMAP) / BLKSIZE;
int r;
// 检查地址是否在磁盘映射区域内
if (addr < (void*)DISKMAP || addr >= (void*)(DISKMAP + DISKSIZE))
panic("flush_block of bad va %08x", addr);
// 地址向下取整到页面边界
addr = ROUNDDOWN(addr, PGSIZE);
// 检查页面是否映射且脏
if (!va_is_mapped(addr) || !va_is_dirty(addr)) {
return; // 如果页面未映射或未修改,则不执行操作
}
// 将脏页面写回磁盘
if ((r = ide_write(blockno * BLKSECTS, addr, BLKSECTS)) < 0) {
panic("in flush_block, ide_write(): %e", r);
}
// 清除页面的脏位标记
if ((r = sys_page_map(0, addr, 0, addr, uvpt[PGNUM(addr)] & PTE_SYSCALL)) < 0)
panic("in bc_pgfault, sys_page_map: %e", r);
}
flush_block
函数的作用是将内存中的修改过的数据块写回到磁盘。这是文件系统中的重要功能之一,确保内存中的更改得到持久化。
输入参数:
void *addr
: 指向需要写回到磁盘的数据块的虚拟地址。
函数的作用:
- 计算磁盘块号:根据给定的虚拟地址
addr
计算出对应的磁盘块号blockno
。 - 地址合法性检查:确保
addr
位于磁盘映射的合法虚拟地址范围内。如果不是,函数会调用panic
来报告错误。 - 地址对齐和映射检查:
- 将
addr
向下取整到页面边界。 - 使用
va_is_mapped
和va_is_dirty
检查该地址是否已映射且自上次从磁盘读取后是否被修改。如果页面没有映射或没有修改(不是“脏”的),则函数不执行任何操作并返回。
- 将
- 写入磁盘:如果页面被修改了,使用
ide_write
将数据从内存中的地址写回到计算出的磁盘块号对应的磁盘位置。 - 清除脏位:写回磁盘后,使用
sys_page_map
清除页面的 PTE_D 脏位标记,表示该页面现在与磁盘上的数据一致。
-
JOS FS进程地址空间和磁盘映射
fs/fs.c
中的 fs_init
函数是使用块缓存的一个典型例子。在初始化块缓存之后,它只是简单地将指向磁盘映射区域的指针存储在全局变量 super
中。在这之后,我们可以直接从 super
结构体中读取数据,就好像它们已经在内存中一样,而我们的页面错误处理程序会根据需要从磁盘读取它们。
Challenge!
块缓存没有淘汰策略。一旦一个块被引入其中,它就永远不会被移除,将永远保留在内存中。为缓冲区缓存添加淘汰机制。利用页表中的 PTE_A “访问”位(硬件在访问页面时会设置这个位),你可以跟踪磁盘块的大致使用情况,而无需修改访问磁盘映射区域的代码中的每个位置。注意处理脏块。
The Block Bitmap
在 fs_init
设置了位图指针之后,我们可以将 bitmap
视为一个紧凑的位数组,每个磁盘块对应一个位。例如,参见 block_is_free
函数,它简单地检查给定的块在位图中是否被标记为自由。
fs_init()中已经初始化了bitmap,我们能通过bitmap访问磁盘的block 1,也就是位数组,每一位代表一个block,1表示该block未被使用,0表示已被使用。我们实现一系列管理函数来管理这个位数组
Exercise 3.
以
fs/fs.c
中的free_block
函数为模型,实现alloc_block
函数。该函数应该在位图中找到一个空闲的磁盘块,将其标记为已使用,并返回该块的编号。当你分配一个块时,应该立即使用flush_block
函数将更改过的位图块刷新到磁盘,以帮助保持文件系统的一致性。使用
make grade
来测试你的代码。你的代码现在应该通过 "alloc_block" 测试。
alloc_block(void)
{
// The bitmap consists of one or more blocks. A single bitmap block
// contains the in-use bits for BLKBITSIZE blocks. There are
// super->s_nblocks blocks in the disk altogether.
// LAB 5: Your code here.
uint32_t bmpblock_start = 2;
for (uint32_t blockno = 0; blockno < super->s_nblocks; blockno++) {
if (block_is_free(blockno)) { //搜索free的block
bitmap[blockno / 32] &= ~(1 << (blockno % 32)); //标记为已使用
flush_block(diskaddr(bmpblock_start + (blockno / 32) / NINDIRECT)); //将刚刚修改的bitmap block写到磁盘中
return blockno;
}
}
return -E_NO_DISK;
}
搜索bitmap位数组,返回一个未使用的block,并将其标记为已使用
File Operations
fs/fs.c文件提供了一系列函数用于管理File结构,扫描和管理目录文件,解析绝对路径。
基本的文件系统操作:
file_block_walk(struct File *f, uint32_t filebno, uint32_t **ppdiskbno, bool alloc)
:查找f指向文件结构的第filebno个block的存储地址,保存到ppdiskbno中。如果f->f_indirect还没有分配,且alloc为真,那么将分配要给新的block作为该文件的f->f_indirect。类比页表管理的pgdir_walk()。file_get_block(struct File *f, uint32_t filebno, char **blk)
:该函数查找文件第filebno个block对应的虚拟地址addr,将其保存到blk地址处。walk_path(const char *path, struct File **pdir, struct File **pf, char *lastelem)
:解析路径path,填充pdir和pf地址处的File结构。比如/aa/bb/cc.c那么pdir指向代表bb目录的File结构,pf指向代表cc.c文件的File结构。又比如/aa/bb/cc.c,但是cc.c此时还不存在,那么pdir依旧指向代表bb目录的File结构,但是pf地址处应该为0,lastelem指向的字符串应该是cc.c。dir_lookup(struct File *dir, const char *name, struct File **file)
:该函数查找dir指向的文件内容,寻找File.name为name的File结构,并保存到file地址处。dir_alloc_file(struct File *dir, struct File **file)
:在dir目录文件的内容中寻找一个未被使用的File结构,将其地址保存到file的地址处。
文件操作:
file_create(const char *path, struct File **pf)
:创建path,如果创建成功pf指向新创建的File指针。file_open(const char *path, struct File **pf)
:寻找path对应的File结构地址,保存到pf地址处。file_read(struct File *f, void *buf, size_t count, off_t offset)
:从文件f中的offset字节处读取count字节到buf处。file_write(struct File *f, const void *buf, size_t count, off_t offset)
:将buf处的count字节写到文件f的offset开始的位置。
Exercise 4.
实现
file_block_walk
和file_get_block
函数。file_block_walk
函数将文件中的块偏移量映射到struct File
中该块的指针或间接块上,这与pgdir_walk
函数对页表的操作非常相似。file_get_block
函数则更进一步,将其映射到实际的磁盘块上,必要时分配新块。使用
make grade
来测试你的代码。你的代码应该通过 "file_open"、"file_get_block"、"file_flush/file_truncated/file rewrite" 和 "testfile" 测试。
file_block_walk()
file_block_walk
函数的作用是在一个给定的 File
结构中找到与特定文件块偏移量 (filebno
) 相关联的磁盘块号,并可选择性地为该文件块分配一个新的磁盘块。
输入参数:
struct File *f
: 指向需要操作的File
结构的指针。uint32_t filebno
: 文件中的块偏移量。uint32_t **ppdiskbno
: 指向一个指针的指针,用于存储找到或分配的磁盘块号的地址。bool alloc
: 如果为true
,当所需块不存在时,函数将尝试分配一个新块。
函数的工作流程:
- 检查块偏移量的有效性:如果
filebno
超出了直接块和间接块的总和,返回错误E_INVAL
。 - 处理直接块:如果
filebno
在直接块的范围内,直接将ppdiskbno
指向f->f_direct
数组中相应的元素。 - 处理间接块:
- 如果
filebno
超出了直接块的范围,且File
结构中已经有一个间接块 (f->f_indirect
),则计算该间接块的虚拟地址,并将ppdiskbno
指向其中相应的元素。 - 如果间接块不存在且
alloc
为true
,则分配一个新的间接块,更新File
结构,并将ppdiskbno
指向新分配的间接块中相应的元素。
- 如果
- 返回结果:如果一切正常,返回
0
。
中文注释版本:
static int
file_block_walk(struct File *f, uint32_t filebno, uint32_t **ppdiskbno, bool alloc)
{
// LAB 5: Your code here.
int bn;
uint32_t *indirects;
if (filebno >= NDIRECT + NINDIRECT)
return -E_INVAL; // 检查块偏移量是否超出范围
if (filebno < NDIRECT) {
*ppdiskbno = &(f->f_direct[filebno]); // 直接块
} else {
if (f->f_indirect) {
indirects = diskaddr(f->f_indirect); // 获取间接块的地址
*ppdiskbno = &(indirects[filebno - NDIRECT]); // 间接块内的指定块
} else {
if (!alloc)
return -E_NOT_FOUND; // 无分配权限且间接块不存在
if ((bn = alloc_block()) < 0)
return bn; // 分配块失败
f->f_indirect = bn; // 更新间接块指针
flush_block(diskaddr(bn)); // 将更改刷新到磁盘
indirects = diskaddr(bn); // 获取新分配的间接块地址
*ppdiskbno = &(indirects[filebno - NDIRECT]); // 间接块内的指定块
}
}
return 0; // 成功返回
}
-
程序解释
在
file_block_walk
函数中提到的“特定文件块偏移量(filebno
)”和NDIRECT
、NINDIRECT
是文件系统中用于管理文件存储的关键概念:- 特定文件块偏移量(
filebno
):这是文件中的一个块的偏移量,表示从文件开始处计算的第几个块。文件系统中的文件通常被分割成多个固定大小的块(例如,每个块可能是4KB)。filebno
就是这些块的索引号,用于指定文件中特定的数据块。 NDIRECT
:这是一个常量,表示文件中可以直接访问的块的数量。所谓“直接访问”意味着文件的struct File
结构体直接包含了这些块的磁盘块号。例如,如果NDIRECT
为 10,那么每个文件可以直接在其结构体中存储10个磁盘块号。NINDIRECT
:这也是一个常量,表示通过一个间接块指针可以访问的块的数量。间接块是文件系统中用于扩展文件大小的一种机制。当一个文件的大小超过了NDIRECT
所能直接索引的范围时,会使用一个额外的磁盘块来存储更多的块号。NINDIRECT
就是这个额外磁盘块可以存储的块号数量。
总结来说,
filebno
用于指定文件中的一个特定块,而NDIRECT
和NINDIRECT
定义了文件结构中可以直接或间接索引的块的数量。这些概念是文件系统管理复杂文件和大型文件的基础。- 直接块(Direct Blocks):
- 定义:直接块是文件的一部分,其块号直接存储在文件的
struct File
结构体中。这意味着可以直接从文件的结构体中访问这些块号,无需任何间接步骤。 - 访问方式:直接块通过
f->f_direct
数组访问,这个数组直接包含了文件的前NDIRECT
个磁盘块号。访问直接块时,只需索引这个数组即可获得相应的磁盘块号。
- 定义:直接块是文件的一部分,其块号直接存储在文件的
- 间接块(Indirect Blocks):
- 定义:当文件大小超过直接块所能覆盖的范围时,间接块被使用。间接块本身是存储在磁盘上的一个块,包含了额外的磁盘块号。
- 访问方式:间接块通过
f->f_indirect
字段访问,这个字段存储了间接块的磁盘块号。为了获取间接块中存储的磁盘块号,首先需要通过f->f_indirect
找到间接块在磁盘上的位置,然后通过diskaddr
函数将磁盘块号转换为虚拟地址,最后通过这个虚拟地址访问间接块中的内容。
- 特定文件块偏移量(
file_get_block()
file_get_block
函数的作用是获取或分配给定文件的特定块在磁盘上的地址。
输入参数:
struct File *f
: 指向需要操作的File
结构的指针。uint32_t filebno
: 文件中的块偏移量。char **blk
: 指向字符指针的指针,用于存储找到或分配的磁盘块的虚拟地址。
函数的工作流程:
- 调用
file_block_walk
:使用file_block_walk
函数来获取文件中第filebno
块的磁盘块号指针。如果出错,返回相应的错误码。 - 检查磁盘块号:检查通过
file_block_walk
得到的磁盘块号(pdiskbno
)。如果这个块号为0,意味着该文件块尚未映射到磁盘块。 - 分配新块:如果需要,调用
alloc_block
分配一个新的磁盘块,并更新pdiskbno
为新分配的块号。 - 刷新块到磁盘:调用
flush_block
将更改后的块号刷新到磁盘。 - 设置返回的块地址:将
blk
设置为通过diskaddr
函数计算出的对应磁盘块的虚拟地址。 - 返回成功:返回0表示操作成功完成。
int
file_get_block(struct File *f, uint32_t filebno, char **blk)
{
// LAB 5: Your code here.
int r;
uint32_t *pdiskbno;
// 获取文件块的磁盘块号指针
if ((r = file_block_walk(f, filebno, &pdiskbno, true)) < 0) {
return r;
}
int bn;
// 检查磁盘块号,如果未映射则分配新块
if (*pdiskbno == 0) {
if ((bn = alloc_block()) < 0) {
return bn;
}
*pdiskbno = bn; // 更新磁盘块号
flush_block(diskaddr(bn)); // 刷新到磁盘
}
// 设置返回的块地址
*blk = diskaddr(*pdiskbno);
return 0; // 成功
}
Challenge!
如果文件系统在操作过程中被中断(例如,由于崩溃或重启),文件系统很可能会被损坏。实现软更新(Soft Updates)或日志记录(Journalling),以使文件系统具有抗崩溃能力,并演示一些在旧文件系统中可能会导致损坏的情况,但在你的实现中则不会。
踩坑记录
包括后面的Exercise 10都遇到相同的问题。
写完Exercise4后执行make grade,无法通过测试,提示"file_get_block returned wrong data"。在实验目录下搜索该字符串,发现是在fs/test.c文件中,
if ((r = file_open("/newmotd", &f)) < 0)
panic("file_open /newmotd: %e", r);
if ((r = file_get_block(f, 0, &blk)) < 0)
panic("file_get_block: %e", r);
if (strcmp(blk, msg) != 0)
panic("file_get_block returned wrong data");
也就是说只有当blk和msg指向的字符串不一样时才会报这个错,msg定义在fs/test.c中static char *msg = "This is the NEW message of the day!\\n\\n"
。blk指向/newmotd文件的开头。/newmotd文件在fs/newmotd中,打开后发现内容也是"This is the NEW message of the day!"。照理来说应该没有问题啊。但是通过xxd fs/newmotd指令查看文件二进制发现如下:
1. 00000000: 5468 6973 2069 7320 7468 6520 4e45 5720 This is the NEW
2. 00000010: 6d65 7373 6167 6520 6f66 2074 6865 2064 message of the d
3. 00000020: 6179 210d 0a0d 0a ay!....
最后的两个换行符是0d0a 0d0a,也就是\r\n\r\n。但是msg中末尾却是\n\n。\r\n应该是windows上的换行符,不知道为什么fs/newmotd中的换行符居然是windows上的换行符。找到问题了所在,我们用vim打开fs/newmotd,然后使用命令set ff=unix,保存退出。现在再用xxd fs/newmotd指令查看文件二进制发现,换行符已经变成了\n(0x0a)。这样就可以通过该实验了。在Exercise 10中同样需要将fs文件夹下的lorem,script,testshell.sh文件中的换行符转成UNIX下的。
The file system interface
到目前为止,文件系统进程已经能提供各种操作文件的功能了,但是其他用户进程不能直接调用这些函数。我们通过进程间函数调用(RPC)对其它进程提供文件系统服务。 IPC(Inter-Process Communication)机制是指在不同进程间进行数据交换的方法
-
RPC机制原理
Regular env FS env +---------------+ +---------------+ | read | | file_read | | (lib/fd.c) | | (fs/fs.c) | ...|.......|.......|...|.......^.......|............... | v | | | | RPC mechanism | devfile_read | | serve_read | | (lib/file.c) | | (fs/serv.c) | | | | | ^ | | v | | | | | fsipc | | serve | | (lib/file.c) | | (fs/serv.c) | | | | | ^ | | v | | | | | ipc_send | | ipc_recv | | | | | ^ | +-------|-------+ +-------|-------+ | | +-------------------+
虚线以下的内容主要是将读取请求从常规环境传送到文件系统环境的机制。从一开始,read(我们提供)适用于任何文件描述符,并简单地分派给相应的设备读取函数,在这种情况下是 devfile_read(我们可以有更多设备类型,如管道)。devfile_read 专门为磁盘上的文件实现读取操作。lib/file.c 中的这个和其他 devfile_* 函数实现了FS操作的客户端,并且都大致以相同的方式工作,即将参数打包到请求结构中,调用 fsipc 发送IPC请求,然后解包并返回结果。fsipc 函数只是处理将请求发送到服务器并接收回复的常见细节。
文件系统服务器代码可以在 fs/serv.c 中找到。它在 serve 函数中循环,无休止地通过IPC接收请求,将请求分派给适当的处理函数,并通过IPC发送回结果。在读取示例中,serve 将分派给 serve_read,serve_read 将处理读取请求特有的IPC细节,如解包请求结构,最终调用 file_read 来实际执行文件读取。
回想一下,JOS 的IPC机制允许一个环境发送单个32位数字,并且可选地共享一页。为了从客户端向服务器发送请求,我们使用32位数字来表示请求类型(文件系统服务器的RPC就像系统调用一样编号),并将请求的参数存储在通过IPC共享的页面上的一个联合体 Fsipc 中。在客户端,我们总是在 fsipcbuf 上共享页面;在服务器端,我们将传入的请求页面映射到 fsreq (0x0ffff000)。
服务器也通过IPC发送响应。我们使用32位数字来表示函数的返回代码。对于大多数RPC来说,这就是它们的全部返回内容。FSREQ_READ 和 FSREQ_STAT 还返回数据,它们只是写到客户端发送请求的页面上。由于客户端首先与文件系统服务器共享了这个页面,因此在响应的IPC中没有必要再发送这个页面。此外,在其响应中,FSREQ_OPEN 与客户端共享一个新的“Fd页面”。我们稍后会回到文件描述符页面。
本质上RPC还是借助IPC机制实现的,普通进程通过IPC向FS进程间发送具体操作和操作数据,然后FS进程执行文件操作,最后又将结果通过IPC返回给普通进程。从上图中可以看到客户端的代码在lib/fd.c和lib/file.c两个文件中。服务端的代码在fs/fs.c和fs/serv.c两个文件中。
-
相关数据结构之间的关系
图片展示了JOS操作系统中文件描述符的创建和管理过程。这个过程涉及到多个结构体和组件,如下所述:
- OpenFile 结构:这个结构包含一个文件描述符
o_fileid
,指向File
结构的指针o_file
,打开文件的模式o_mode
,以及指向Fd
结构的指针o_fd
。 - File 结构:包含文件的基本信息,如文件大小、文件类型和指向文件数据所在 block 的指针。
-
Dev 结构:代表设备文件,包括设备类型、设备打开和关闭操作的函数指针。
在操作系统中,特别是类Unix系统中,设备被抽象为文件,这样可以使用标准的文件操作接口来管理设备。这种抽象称为“一切皆文件”(everything is a file)。所谓的设备文件,通常指的是代表硬件设备的特殊文件,它们可以是字符设备或块设备。
- 字符设备:这类设备传输非缓冲的字符流,如键盘或串口。
- 块设备:这类设备传输有缓冲的数据块,如硬盘驱动器。
在图中所示的
Dev
结构体中,它可能包含了:- 设备类型:标识设备是字符设备还是块设备。
- 操作函数指针:这些是指向函数的指针,用于执行设备的打开、读取、写入、关闭等操作。这些函数通常是设备驱动程序提供的接口,允许操作系统核心以统一的方式与不同的硬件设备通信。
例如,如果操作系统要读取键盘输入,它会使用与键盘设备文件关联的
Dev
结构体中的函数指针来执行读取操作。同样,如果要写入到硬盘,它会通过与硬盘设备文件关联的Dev
结构体来进行写操作。通过这种方式,操作系统中的设备文件使得应用程序可以不用关心具体硬件的实现细节,而是通过标准的文件操作API与之交互。
Fd 结构:位于进程的文件描述符表(FD TABLE)中,包含文件描述符与设备ID
fd_dev_id
,文件的偏移量fd_offset
,和文件打开模式fd_omode
。这个结构体通常映射到一个固定的虚拟地址,如0x00800000
,大小为一个页面大小(PGSIZE)。在创建一个新的文件描述符时,会进行以下步骤:
a. 使用
fd_alloc
函数在 FD TABLE 中找到一个未使用的文件描述符页面。 b. 函数会设置相关的File
和Dev
结构体,并将其关联到Fd
结构。 c. 然后向文件服务器发送一个打开文件的请求,请求中包含文件路径和打开模式。 d. 文件服务器处理请求后,会将一个新的文件描述符页面映射到进程的文件描述符表的适当位置。整个过程涉及到进程、文件系统服务器和设备驱动之间的协作,以实现文件的打开、读写和关闭操作。这个过程是操作系统文件管理的一个典型例子,展示了如何通过文件描述符与文件系统交互,以及如何管理文件的访问和数据的流动。
- OpenFile 结构:这个结构包含一个文件描述符
文件系统服务端代码在fs/serv.c中,serve()中有一个无限循环,接收IPC请求,将对应的请求分配到对应的处理函数,然后将结果通过IPC发送回去。
对于客户端来说:发送一个32位的值作为请求类型,发送一个Fsipc结构作为请求参数,该数据结构通过IPC的页共享发给FS进程,在FS进程可以通过访问fsreq(0x0ffff000)来访问客户进程发来的Fsipc结构。
对于服务端来说:FS进程返回一个32位的值作为返回码,对于FSREQ_READ和FSREQ_STAT这两种请求类型,还额外通过IPC返回一些数据。
Exercise 5.
在
fs/serv.c
中实现serve_read
函数。
serve_read
的主要工作将由fs/fs.c
中已经实现的file_read
完成(后者又是一系列file_get_block
调用)。serve_read
只需为文件读取提供RPC接口。参考serve_set_size
中的注释和代码,以了解服务器函数应如何构建。使用
make grade
来测试你的代码。你的代码应该通过 "serve_open/file_stat/file_close" 和 "file_read",得分应为70/150。
serve_read
函数是在文件系统服务器(fs/serv.c)中处理文件读取请求的函数。
输入参数:
envid_t envid
: 发送请求的环境(进程)的环境ID。union Fsipc *ipc
: 包含文件系统IPC请求和返回值的联合体。
函数的作用:
- 解析请求:从
ipc
参数中获取读取请求的具体信息,包括要读取的文件ID (req->req_fileid
) 和读取的字节数 (req->req_n
)。 - 查找打开的文件:使用
openfile_lookup
函数根据文件ID和环境ID查找对应的OpenFile
结构。如果查找失败,返回相应的错误码。 - 执行读操作:调用
file_read
函数从指定的文件位置读取数据。读取的数据被存储在返回结构的缓冲区 (ret->ret_buf
) 中。 - 更新文件偏移量:读取操作完成后,更新文件的偏移量 (
o->o_fd->fd_offset
)。 - 返回读取的字节数:函数返回实际读取的字节数,这也是成功完成读取操作的标志。
int
serve_read(envid_t envid, union Fsipc *ipc)
{
struct Fsreq_read *req = &ipc->read;
struct Fsret_read *ret = &ipc->readRet;
if (debug)//处于调试模式的意思
cprintf("serve_read %08x %08x %08x\\\\n", envid, req->req_fileid, req->req_n);
// Lab 5: Your code here:
struct OpenFile *o;
int r;
// 通过文件ID找到 OpenFile 结构
// 这里通过环境ID和请求中的文件ID来查找对应的 OpenFile 结构体。
r = openfile_lookup(envid, req->req_fileid, &o);
if (r < 0)
return r;
// 调用 fs.c 中的函数进行真正的读操作
// 一旦找到 OpenFile 结构,使用 file_read 函数来从文件中读取数据。
// 读取的数据量是由请求中的 req_n 指定的,并将结果存储在 ret->ret_buf 中。
if ((r = file_read(o->o_file, ret->ret_buf, req->req_n, o->o_fd->fd_offset)) < 0)
return r;
// 更新文件偏移量
// 读取操作完成后,更新文件的偏移量,以便下一次读取操作可以从正确的位置开始。
o->o_fd->fd_offset += r;
return r; // 返回读取的字节数
// 函数最后返回读取的字节数,如果有错误发生,则返回错误代码。
}
这个函数是文件系统的关键组成部分,负责响应客户端环境的读取请求,并通过IPC机制在文件系统服务器和客户端之间传递数据。
Exercise 6.
fs/serv.c
中实现serve_write
函数,并在lib/file.c
中实现devfile_write
函数。使用
make grade
来测试你的代码。你的代码应该通过 "file_write"、"file_read after file_write"、"open" 和 "large file" 测试,得分应为90/150。
serve_write()
int
serve_write(envid_t envid, struct Fsreq_write *req)
{
// 如果处于调试模式,打印环境ID、文件ID和请求写入的字节数
if (debug)
cprintf("serve_write %08x %08x %08x\\n", envid, req->req_fileid, req->req_n);
// LAB 5: Your code here.
struct OpenFile *o;
int r;
// 根据环境ID和文件ID查找对应的 OpenFile 结构
// 如果查找失败(例如文件ID不存在),则返回错误代码
if ((r = openfile_lookup(envid, req->req_fileid, &o)) < 0) {
return r;
}
int total = 0;
while (1) {
// 向文件写入数据,并更新写入位置(文件偏移量)
// file_write 返回写入的字节数,如果有错误则返回负数
r = file_write(o->o_file, req->req_buf, req->req_n, o->o_fd->fd_offset);
if (r < 0) return r;
total += r;
o->o_fd->fd_offset += r;
// 当写入的总字节数达到请求的字节数时,停止写入
if (req->req_n <= total)
break;
}
// 返回总共写入的字节数
return total;
}
该函数的目的是将用户指定的数据写入到指定的文件中。它首先查找文件,然后循环地将数据写入文件,直到满足用户请求的数据量。每次写入操作都会更新文件的偏移量,以确保数据被连续地写入。最后,函数返回实际写入的总字节数。
devfile_write()
static ssize_t
devfile_write(struct Fd *fd, const void *buf, size_t n)
{
// 制作一个 FSREQ_WRITE 请求给文件系统服务器
// 注意:fsipcbuf.write.req_buf 的大小有限,
// 但是要记住,write 操作总是允许写入比请求少的字节数。
// LAB 5: Your code here
int r;
// 设置文件ID和请求写入的字节数
fsipcbuf.write.req_fileid = fd->fd_file.id;
fsipcbuf.write.req_n = n;
// 将要写入的数据从 buf 复制到请求缓冲区中
memmove(fsipcbuf.write.req_buf, buf, n);
// 发送 FSREQ_WRITE 请求,返回结果
return fsipc(FSREQ_WRITE, NULL);
}
作用是向文件系统服务器发送一个写入请求(FSREQ_WRITE)。这个函数是文件系统中设备文件写操作的一部分,主要用于将数据写入文件。函数接受三个参数:指向 Fd
结构的指针 fd
,指向要写入数据的缓冲区的指针 buf
,以及要写入的字节数 n
。
这个函数首先设置了文件系统写请求(FSREQ_WRITE)所需的参数,包括文件ID和请求写入的字节数。然后,它将用户提供的数据从 buf
复制到请求缓冲区 fsipcbuf.write.req_buf
中。这里使用 memmove
是因为它更安全地处理重叠的源和目标内存区域。最后,函数调用 fsipc
函数来发送写请求,并返回该调用的结果。这个结果通常是写入操作的状态,比如成功写入的字节数或者错误代码。
库函数open()实现
以打开一个文件为例,看下整体过程,read(), write()类似。open()在linux中也要实现定义在头文件<fcntl.h>中,原型如下:
int open(const char *pathname, int flags);
在JOS中open()实现在lib/file.c中
这段代码包含两个函数:open
和 fsipc
。这两个函数在一个文件系统中协同工作,用于打开文件和进行进程间通信(IPC)。
open
函数
open
函数用于打开一个文件,它接受两个参数:文件路径 path
和打开模式 mode
。
int open(const char *path, int mode)
{
// 寻找一个未使用的文件描述符页面,使用 fd_alloc。
// 然后向文件服务器发送一个文件打开请求。
// 请求中包括 'path' 和 'omode',
// 并且将返回的文件描述符页面映射到适当的文件描述符地址。
// FSREQ_OPEN 成功返回 0,失败返回负值。
//
// (fd_alloc 不分配页面,它只返回一个未使用的文件描述符地址。
// 你需要分配一个页面吗?)
//
// 返回文件描述符索引。
// 如果 fd_alloc 后的任何步骤失败,使用 fd_close 释放文件描述符。
int r;
struct Fd *fd;
if (strlen(path) >= MAXPATHLEN) // 检查文件路径长度是否超过最大长度
return -E_BAD_PATH;
if ((r = fd_alloc(&fd)) < 0) // 为当前进程分配一个未使用的文件描述符
return r;
strcpy(fsipcbuf.open.req_path, path); // 将路径复制到请求缓冲区
fsipcbuf.open.req_omode = mode; // 设置请求的打开模式
if ((r = fsipc(FSREQ_OPEN, fd)) < 0) { // 向文件系统进程发送打开文件的请求
fd_close(fd, 0); // 如果请求失败,关闭并释放文件描述符
return r;
}
return fd2num(fd); // 返回文件描述符的编号
}
fsipc
函数
fsipc
函数用于文件系统的进程间通信,它接受两个参数:请求类型 type
和目标虚拟地址 dstva
。
static int fsipc(unsigned type, void *dstva)
{
static envid_t fsenv;
if (fsenv == 0)
fsenv = ipc_find_env(ENV_TYPE_FS); // 查找文件系统环境
static_assert(sizeof(fsipcbuf) == PGSIZE);
ipc_send(fsenv, type, &fsipcbuf, PTE_P | PTE_W | PTE_U); // 向文件系统进程发送请求
return ipc_recv(NULL, dstva, NULL); // 接收文件系统进程返回的数据
}
open
函数的目的是打开一个文件。它首先验证路径长度,然后使用 fd_alloc
函数寻找一个未使用的文件描述符。之后,它将请求的路径和模式设置到全局请求缓冲区 fsipcbuf
,然后调用 fsipc
函数与文件系统进程进行通信。如果打开文件成功,它返回文件描述符的索引;如果失败,它使用 fd_close
函数释放文件描述符并返回错误码。
fsipc
函数负责进行进程间通信。它首先确定文件系统环境的环境ID,然后通过 ipc_send
向文件系统进程发送请求,并通过 ipc_recv
接收返回的数据。这个函数是一个通用的IPC接口,根据不同的 type
可以处理不同类型的文件系统请求。
fd_alloc
这段代码定义了一个名为 fd_alloc
的函数,其作用是在文件描述符表中为新的文件描述符分配空间。函数接受一个双指针参数 struct Fd **fd_store
,该参数用于存储分配的文件描述符的地址。以下是详细的中文注释和解释:
int fd_alloc(struct Fd **fd_store)
{
int i;
struct Fd *fd;
for (i = 0; i < MAXFD; i++) { // 遍历文件描述符表,从当前最小的未分配描述符开始
fd = INDEX2FD(i); // 将索引转换为对应的文件描述符的地址
// 检查该文件描述符是否未被使用(即页目录和页表项指示未映射)
if ((uvpd[PDX(fd)] & PTE_P) == 0 || (uvpt[PGNUM(fd)] & PTE_P) == 0) {
*fd_store = fd; // 如果未使用,将地址存储在 fd_store 中
return 0; // 返回成功
}
}
*fd_store = 0; // 如果所有的文件描述符都已被使用,设置 fd_store 为 0
return -E_MAX_OPEN; // 返回错误码,表示已达到最大打开文件数
}
- 函数作用:
fd_alloc
函数在文件描述符表中查找一个未使用的条目。如果找到,它会设置fd_store
指针指向该未使用的文件描述符,并返回0
表示成功。如果所有的文件描述符都已经被使用,它会返回一个错误码E_MAX_OPEN
,表示没有更多的文件描述符可用。 - 输入参数:
struct Fd **fd_store
:指向struct Fd *
的指针的地址。函数通过这个双指针参数输出找到的未使用的文件描述符的地址。
- 关键数据结构和宏:
MAXFD
:定义了进程可以打开的最大文件描述符数。INDEX2FD(i)
:一个宏,将文件描述符的索引转换为其对应的地址。uvpd
和uvpt
:分别是用户虚拟页目录和页表的数组,用于检查相应地址是否已经映射。PDX(fd)
和PGNUM(fd)
:宏,用于计算给定地址的页目录索引和页表索引。PTE_P
:页表项标志位,表示该页是存在的(Present)。
通过这个函数,操作系统能够管理文件描述符的使用情况,确保每个新的文件描述符都是唯一且未被使用的。
是的,您的理解是正确的。在 JOS(一个教学用的简易操作系统)中,open
函数的实现展示了如何通过进程间通信(IPC)来实现用户对文件的操作。这里的关键是操作系统的设计采用了微内核架构,在这种架构中,文件系统(FS)作为一个单独的服务进程运行,而不是直接集成在内核中。
让我们分步来理解这个过程:
open
函数:用户程序调用open
函数打开一个文件。这个函数首先检查路径长度,然后通过fd_alloc
分配一个未使用的文件描述符。- 文件描述符分配 (
fd_alloc
):fd_alloc
函数遍历文件描述符表,寻找一个未被使用的条目,并返回这个文件描述符。 - 向文件系统进程发起请求 (
fsipc
):一旦获得文件描述符,open
函数通过fsipc
函数向文件系统进程发送一个打开文件的请求。这个请求包含文件路径和打开模式。 - 进程间通信 (IPC):
fsipc
函数利用 IPC 机制与文件系统进程通信。它发送一个包含请求类型和必要信息的消息(在这个例子中是文件打开请求),然后等待文件系统进程的响应。 - 处理文件系统进程的响应:文件系统进程接收到请求后,执行相应的操作(比如打开文件),然后将结果返回给发起请求的进程。如果操作成功,
open
函数返回一个指向文件描述符的指针;如果操作失败,它将释放文件描述符并返回错误。
总结来说,这个 open
函数的实现展示了一个通过进程间通信实现的文件操作过程。用户程序通过发送请求给文件系统进程,并通过进程间通信接收响应,从而实现了对文件的操作。这种设计使得操作系统的各个部分可以更加模块化,降低了复杂性,并可能提高了系统的安全性和稳定性。
-
操作系统中用户环境(Regular env)和文件系统环境(FS env)之间进行文件操作
图片描述了一个操作系统中用户环境(Regular env)和文件系统环境(FS env)之间进行文件操作请求和响应的过程,具体是读取文件的操作。这个过程使用了远程过程调用(RPC)机制来在不同的环境或进程之间通信。下面是这个过程的详细解释:
- 用户环境(Regular env):
- 用户程序调用库函数
read
,这个函数通常定义在lib/fd.c
文件中。 - 库函数
read
进一步调用devfile_read
,这个函数实现在lib/file.c
文件中。devfile_read
函数负责设置文件读取请求的具体细节。 devfile_read
调用fsipc
函数(也在lib/file.c
中),这个函数实现了发送RPC请求的逻辑。
- 用户程序调用库函数
- 文件系统环境(FS env):
fsipc
发送请求后,文件系统环境通过ipc_recv
函数接收这个请求。ipc_recv
实现在fs/serv.c
文件中。ipc_recv
收到请求后,调用serve
函数(在fs/serv.c
中定义),这个函数负责确定是哪种类型的服务请求,并将其转发到相应的服务处理函数。- 对于文件读取请求,
serve
函数将调用serve_read
,它在fs/serv.c
文件中定义。serve_read
函数处理实际的文件读取操作。 serve_read
可能会调用file_read
函数(在fs/fs.c
中定义),file_read
函数负责从文件中读取数据。
在这个过程中,
ipc_send
和ipc_recv
是IPC机制的一部分,它们负责在用户环境和文件系统环境之间传递消息。- 文件描述符表:
- 图片还显示了用户进程的文件描述符表(Fd表),这个表从虚拟地址
0xD0000000
开始。每个文件描述符(如 Fd 0, Fd 1, Fd 31)都对应一个Fd
结构,它包含了文件描述符的详细信息(如fd_dev_id
,fd_offset
,fd_omode
)。
- 图片还显示了用户进程的文件描述符表(Fd表),这个表从虚拟地址
- 文件系统的文件描述符表:
- 文件系统环境也有它自己的文件描述符表(OpenFile表),它从
0xD0001000
开始。这个表包含了OpenFile
结构,每个结构对应一个打开的文件,并关联到用户环境的Fd
结构。
- 文件系统环境也有它自己的文件描述符表(OpenFile表),它从
整个过程是一个用户进程通过库函数调用与文件系统环境进行通信并执行文件操作的例子,这显示了操作系统如何在用户空间和文件系统空间之间协调和转发请求。
- 用户环境(Regular env):
每个进程从虚拟地址0xD0000000开始,每一页对应一个Fd结构,也就是说文件描述符0对应的Fd结构地址为0xD0000000,文件描述符1对应的Fd描述符结构地址为0xD0000000+PGSIZE(被定义为4096),以此类推,。可以通过检查某个Fd结构的虚拟地址是否已经分配,来判断这个文件描述符是否被分配。如果一个文件描述符被分配了,那么该文件描述符对应的Fd结构开始的一页将被映射到和FS进程相同的物理地址处。
FS进程收到FSREQ_OPEN请求后,将调用serve_open(),该函数定义在fs/serv.c中。
int
serve_open(envid_t envid, struct Fsreq_open *req, void **pg_store, int *perm_store) {
char path[MAXPATHLEN]; // 存储文件路径
struct File *f; // 文件结构指针
int fileid; // 文件ID
int r; // 用于错误处理的变量
struct OpenFile *o; // 打开的文件结构指针
// 如果开启调试模式,打印调试信息
if (debug)
cprintf("serve_open %08x %s 0x%x\\n", envid, req->req_path, req->req_omode);
// 复制请求中的路径,并确保路径以null结尾
memmove(path, req->req_path, MAXPATHLEN);
path[MAXPATHLEN-1] = 0;
// 从打开文件表中分配一个OpenFile结构
if ((r = openfile_alloc(&o)) < 0) {
if (debug)
cprintf("openfile_alloc failed: %e", r);
return r;
}
fileid = r;
// 处理文件打开或创建
if (req->req_omode & O_CREAT) {
// 如果请求中包含O_CREAT标志,尝试创建文件
if ((r = file_create(path, &f)) < 0) {
// 如果不是“排他创建”且文件已存在,则尝试打开文件
if (!(req->req_omode & O_EXCL) && r == -E_FILE_EXISTS)
goto try_open;
if (debug)
cprintf("file_create failed: %e", r);
return r;
}
} else {
// 如果没有O_CREAT标志,则直接尝试打开文件
try_open:
if ((r = file_open(path, &f)) < 0) {
if (debug)
cprintf("file_open failed: %e", r);
return r;
}
}
// 如果请求中包含O_TRUNC标志,截断文件
if (req->req_omode & O_TRUNC) {
if ((r = file_set_size(f, 0)) < 0) {
if (debug)
cprintf("file_set_size failed: %e", r);
return r;
}
}
// 再次尝试打开文件
if ((r = file_open(path, &f)) < 0) {
if (debug)
cprintf("file_open failed: %e", r);
return r;
}
// 将文件结构保存到OpenFile结构中
o->o_file = f;
// 填充Fd结构
o->o_fd->fd_file.id = o->o_fileid;
o->o_fd->fd_omode = req->req_omode & O_ACCMODE;
o->o_fd->fd_dev_id = devfile.dev_id;
o->o_mode = req->req_omode;
// 如果开启调试模式,打印成功信息
if (debug)
cprintf("sending success, page %08x\\n", (uintptr_t) o->o_fd);
// 将文件描述符页面共享给调用者,并设置其权限
*pg_store = o->o_fd;
*perm_store = PTE_P|PTE_U|PTE_W|PTE_SHARE;
return 0;
}
envid_t envid
: 这个参数是发送请求的环境(进程)的环境标识符(Environment Identifier)。在 JOS 中,每个进程或环境都有一个唯一的标识符,用于区分不同的进程。struct Fsreq_open *req
: 这个参数是一个指向Fsreq_open
结构的指针,该结构包含了文件打开请求的详细信息。这通常包括:req_path
:要打开或创建的文件的路径。req_omode
:打开文件的模式,例如只读、只写、读写、创建、截断等。
void **pg_store
: 这是一个双重指针,用于在函数执行成功后返回给调用者一个指向文件描述符页的指针。这个机制通常用于在内核与用户空间之间共享数据。int *perm_store
: 这个参数用于返回文件描述符页的权限设置。权限通常包括读、写和执行权限,以及是否共享这个页。
这个函数的主要作用是处理文件的打开请求。它根据 req
中提供的信息来打开或创建文件,并通过 pg_store
和 perm_store
将文件描述符和其权限返回给请求者。在整个过程中,envid
用于标识请求的来源。
该函数首先从opentab这个OpenFile数组中寻找一个未被使用的OpenFile结构,上图中假设找到数据第一个OpenFile结构就是未使用的。如果open()中参数mode设置了O_CREAT选项,那么会调用fs/fs.c中的file_create()根据路径创建一个新的File结构,并保存到OpenFile结构的o_file字段中。
结束后,serve()会将OpenFile结构对应的Fd起始地址发送个客户端进程,所以客户进程从open()返回后,新分配的Fd和FS进程Fd共享相同的物理页。
Spawning Processes
我们已经给出了 spawn
函数的代码(见 lib/spawn.c),它创建一个新的环境(environment),从文件系统中加载程序镜像到这个新环境中,并启动子环境运行这个程序。父进程会继续独立于子进程运行。spawn
函数的效果类似于UNIX中的 fork
,随后在子进程中立即执行 exec
。
我们实现了 spawn
而不是UNIX风格的 exec
,因为在“exokernel模式”下,spawn
更容易从用户空间实现,不需要内核的特殊帮助。思考一下如果要在用户空间实现 exec
,你需要做什么,并确保你理解为什么这更难。
spawn(const char *prog, const char **argv)
做如下一系列动作:
- 从文件系统打开prog程序文件
- 调用系统调用sys_exofork()创建一个新的Env结构
- 调用系统调用sys_env_set_trapframe(),设置新的Env结构的Trapframe字段(该字段包含寄存器信息)。
- 根据ELF文件中program herder,将用户程序以Segment读入内存,并映射到指定的线性地址处。
- 调用系统调用sys_env_set_status()设置新的Env结构状态为ENV_RUNNABLE。
Exercise 7.
spawn
函数依赖一个新的系统调用sys_env_set_trapframe
来初始化新创建环境的状态。在 kern/syscall.c 中实现sys_env_set_trapframe
(不要忘记在syscall()
中分发新的系统调用)。通过在 kern/init.c 中运行 user/spawnhello 程序来测试你的代码,它将尝试从文件系统中产生(spawn)
/hello
。使用
make grade
来测试你的代码。
这段代码定义了一个名为 sys_env_set_trapframe
的函数,它是一个系统调用,用于在操作系统的内核中设置一个进程(环境)的陷阱帧(trap frame)。陷阱帧是一个包含进程所有寄存器状态的数据结构,在上下文切换或系统调用时使用。以下是函数的中文注释和解释:
static int
sys_env_set_trapframe(envid_t envid, struct Trapframe *tf)
{
// LAB 5: Your code here.
// 记住检查用户是否提供了一个有效的地址!
int r;
struct Env *e;
// 将环境ID转换为环境结构体指针,检查当前进程是否有权限
if ((r = envid2env(envid, &e, 1)) < 0) {
return r; // 如果转换失败或没有权限,返回错误码
}
// 设置陷阱帧的标志寄存器,确保中断是开启的
tf->tf_eflags = FL_IF;
// 清除IO权限位,普通进程不应该有IO权限
tf->tf_eflags &= ~FL_IOPL_MASK;
// 设置代码段寄存器,使用用户模式的代码段选择子和RPL
tf->tf_cs = GD_UT | 3;
// 将提供的陷阱帧复制到环境的陷阱帧中
e->env_tf = *tf;
return 0; // 设置成功,返回0
}
- 函数作用:
sys_env_set_trapframe
用于初始化或修改一个进程的寄存器状态。这通常用于在进程创建后,设置其初始状态,或者在进程运行前修改其状态。 - 输入参数:
envid_t envid
:要设置陷阱帧的进程的环境ID。struct Trapframe *tf
:指向陷阱帧结构的指针,该结构包含了要设置的寄存器状态。
在实际系统中,这个函数需要非常谨慎地实现,因为它涉及到进程的执行状态,不正确的设置可能会导致安全问题或系统不稳定。此外,函数还需要检查传入的陷阱帧指针是否指向有效的用户空间地址,以及调用进程是否有权限修改目标进程的状态。
Challenge! Implement Unix-style
exec
.
Challenge! Implement
mmap
-style memory-mapped files and modifyspawn
to map pages directly from the ELF image when possible.
Sharing library state across fork and spawn
UNIX文件描述符是一个泛化的概念,它也包括管道、控制台I/O等。在JOS中,每种设备类型都有一个对应的 struct Dev
,其中包含了为该设备类型实现读写等操作的函数指针。lib/fd.c
在这之上实现了类UNIX的文件描述符接口。每个 struct Fd
表示它的设备类型,而 lib/fd.c
中的大多数函数简单地将操作分派到相应 struct Dev
中的函数。(JOS 中文件描述符的实现方式,它通过将操作分派到不同设备的专门函数来处理各种类型的 I/O 设备,从而模仿了 UNIX 系统中文件描述符的泛化概念。这种设计使得系统能够以统一的方式处理不同类型的 I/O 操作,同时为每种设备类型提供专门的操作实现。)
lib/fd.c
还维护着每个应用环境地址空间中的文件描述符表区域,从 FDTABLE
开始。这个区域为每个最多可同时打开的 MAXFD
(当前为32)文件描述符保留了一页(4KB)的地址空间。在任何给定时间点,特定的文件描述符表页面是否被映射取决于相应的文件描述符是否正在使用。每个文件描述符还有一个可选的“数据页”,位于从 FILEDATA
开始的区域,设备可以选择使用这个数据页。
-
如何在进程地址空间管理文件描述符
这段话描述了 JOS(一个教学用操作系统)中如何在每个应用环境(即进程)的地址空间中管理文件描述符。具体来说,它说明了文件描述符表和文件描述符数据页的实现和用途。以下是详细解释:
-
文件描述符表区域:
-
在每个应用环境的地址空间中,
lib/fd.c
维护着一个特定的区域,从一个地址FDTABLE
开始,用于存储文件描述符表。 -
这个区域为每个可能同时打开的文件描述符预留了一定的空间。在 JOS 中,最大可同时打开的文件描述符数量
MAXFD
设为 32,这意味着最多可以有 32 个活跃的文件描述符。 -
为了存储这些文件描述符,每个文件描述符分配了一页(通常是 4KB)的地址空间。
是的,在 JOS 这样的操作系统中,文件描述符表和每个文件描述符所分配的一页内存通常是在用户进程的地址空间内进行分配的。这种设计允许每个进程有自己的文件描述符表,从而独立管理自己的文件资源。下面是一些详细说明:
- 用户进程的地址空间: 每个用户进程在操作系统中都有自己的独立地址空间。这个地址空间包括代码、数据、堆、栈和其他用于进程运行的结构,如文件描述符表。
- 文件描述符表的分配: 在用户进程的地址空间中,操作系统(通过其库函数,如 JOS 的
lib/fd.c
)会维护一个特定的区域从FDTABLE
开始,用于存储文件描述符表。这个表为每个可同时打开的文件描述符预留空间。 - 文件描述符的内存分配: 对于每个文件描述符,操作系统在用户进程的地址空间中分配一页内存。这个内存用于存储文件描述符的相关信息,如文件的当前读写位置、状态标志、指向文件内容的指针等。
- 进程独立性: 由于文件描述符表和文件描述符的内存都在用户进程的地址空间内分配,每个进程可以独立地管理其文件资源。这意味着不同进程之间的文件描述符是隔离的,一个进程不能直接访问或干扰另一个进程的文件描述符。
- 内存保护: 操作系统通过内存保护机制确保进程只能访问自己的地址空间,这包括其文件描述符表和分配给文件描述符的页面。这样可以避免进程间的不当访问和潜在的安全问题。
总的来说,在用户进程的地址空间中分配文件描述符表和文件描述符的内存页是操作系统中常见的做法,它有助于实现进程间的隔离和安全性,同时提供灵活的文件管理机制。
-
-
文件描述符的映射:
- 特定的文件描述符表页面是否被映射到地址空间中取决于相应的文件描述符是否正在使用。这意味着如果某个文件描述符当前未被任何进程使用,其对应的内存页可能不会被映射到地址空间中。
- 这种设计可以有效地节省内存,因为只有那些实际被使用的文件描述符会占用地址空间。
-
文件描述符的数据页:
- 每个文件描述符还可以有一个“数据页”,这是一个可选的内存页,用于存储与文件描述符相关的数据。
- 这些数据页位于从
FILEDATA
开始的区域内。设备可以选择使用这些数据页来存储额外的信息,例如设备特定的状态信息或缓冲数据。 - 这样的设计允许每个文件描述符除了基本的描述符信息之外,还能有额外的空间来存储与其相关的数据。
总结来说,这段话描述了 JOS 中文件描述符表和数据页的管理方式。每个应用环境的地址空间中都保留有一个特定区域用于存储活跃的文件描述符,而且每个文件描述符可以关联一个用于存储额外数据的数据页。这种设计既节约了内存,又提供了足够的灵活性来满足不同文件描述符的需求。
-
我们希望能够在 fork
和 spawn
之间共享文件描述符状态,但是文件描述符状态保存在用户空间内存中。目前,在 fork
时,内存会被标记为写时复制(copy-on-write),所以状态会被复制而非共享。(这意味着环境将无法在它们自己没有打开的文件中进行寻找,且管道在 fork
之后无法工作。)在 spawn
时,内存则会被遗弃,根本不会被复制。(实际上,生成的环境开始时没有打开的文件描述符。)
我们将改变 fork
的行为,让它知道某些内存区域是被“库操作系统”使用的,应该始终被共享。我们将不会在某处硬编码一个区域列表,而是设置页表条目中的一个未使用的位(就像我们在 fork
中所做的 PTE_COW 位一样)。
我们在 inc/lib.h
中定义了一个新的 PTE_SHARE 位。这个位是英特尔和AMD手册中标记为“供软件使用”的三个PTE位之一。我们将建立一个约定,如果一个页表条目设置了这个位,那么在 fork
和 spawn
中,PTE 应该直接从父级复制到子级。注意,这与标记为写时复制不同:正如第一段所描述的,我们希望确保更新能够共享到页面。
Exercise 8.
在
lib/fork.c
中更改duppage
以遵循新的约定。如果页表条目设置了PTE_SHARE
位,只需直接复制映射。(您应该使用PTE_SYSCALL
而不是0xfff
来屏蔽页表条目中的相关位。0xfff
也会获取已访问位和脏位。)同样,在
lib/spawn.c
中实现copy_shared_pages
。它应该遍历当前进程中的所有页表条目(就像fork
执行的那样),将任何设置了PTE_SHARE
位的页面映射复制到子进程中。
这段代码中的 duppage
函数是在实现进程复制(如 fork
操作)时使用的,其目的是为了复制父进程的内存页到子进程。它根据不同的页属性来决定如何复制这些页。以下是函数的详细中文注释和解释:
static int
duppage(envid_t envid, unsigned pn)
{
int r;
// LAB 4: Your code here.
// 计算页的虚拟地址
void *addr = (void*) (pn * PGSIZE);
if (uvpt[pn] & PTE_SHARE) {
// 如果页表项标记为 PTE_SHARE,直接复制映射关系到子进程
sys_page_map(0, addr, envid, addr, PTE_SYSCALL); // 并且两个进程都有对应的权限
} else if ((uvpt[pn] & PTE_W) || (uvpt[pn] & PTE_COW)) {
// 如果页是可写的或标记为写时复制(Copy-On-Write, COW),将其映射到子进程
// 同时将父进程和子进程的页都设置为写时复制
if ((r = sys_page_map(0, addr, envid, addr, PTE_COW|PTE_U|PTE_P)) < 0)
panic("sys_page_map:%e", r);
if ((r = sys_page_map(0, addr, 0, addr, PTE_COW|PTE_U|PTE_P)) < 0)
panic("sys_page_map:%e", r);
} else {
// 对于只读页,只需将映射关系复制到子进程
sys_page_map(0, addr, envid, addr, PTE_U|PTE_P);
}
return 0; // 返回0表示成功
}
- 函数作用:
duppage
用于复制父进程的一个内存页到子进程,同时根据页的属性来决定如何设置权限。它处理以下三种情况:- 如果页被标记为共享(
PTE_SHARE
),则页映射关系被直接复制,且父子进程都将保留原有权限。 - 如果页是可写的或已被标记为写时复制,它将页映射为写时复制到子进程,同时也将父进程的映射更新为写时复制。
- 对于只读页,映射关系简单地复制到子进程,不涉及写时复制。
- 如果页被标记为共享(
- 输入参数:
envid_t envid
:子进程的环境ID。unsigned pn
:要复制的内存页的页号(page number),这是一个基于页大小(通常是4KB)的索引。
函数中使用的 sys_page_map
系统调用用于设置页映射,panic
函数用于在出现错误时中止程序并打印错误信息。这个实现利用了JOS操作系统提供的系统调用接口,使得用户空间的代码能够控制内存页的复制和权限设置。
这段代码定义了 copy_shared_pages
函数,它的目的是在实现进程创建(如 spawn
操作)时,将父进程中标记为共享(PTE_SHARE
)的所有内存页复制到子进程。以下是函数的详细中文注释和解释:
static int
copy_shared_pages(envid_t child)
{
// LAB 5: Your code here.
uintptr_t addr;
// 遍历用户空间的所有地址,直到 UTOP
for (addr = 0; addr < UTOP; addr += PGSIZE) {
// 检查页目录项和页表项是否存在,并且检查该页是否被标记为用户空间和共享
if ((uvpd[PDX(addr)] & PTE_P) && (uvpt[PGNUM(addr)] & PTE_P) &&
(uvpt[PGNUM(addr)] & PTE_U) && (uvpt[PGNUM(addr)] & PTE_SHARE)) {
// 如果满足条件,则将该页映射到子进程
// 使用 PTE_SYSCALL 来保留页表项中的权限位
sys_page_map(0, (void*)addr, child, (void*)addr, (uvpt[PGNUM(addr)] & PTE_SYSCALL));
}
}
return 0; // 返回0表示成功
}
- 函数作用:
copy_shared_pages
用于在进程创建时复制父进程中标记为共享的所有内存页到子进程。这包括遍历父进程的用户空间地址,检查每个页表项是否被标记为共享,并将这些共享页映射到子进程。 - 输入参数:
envid_t child
:子进程的环境ID。
函数中使用的 sys_page_map
系统调用用于设置页映射。该函数确保只有标记为共享的页被复制,这对于实现进程间的内存共享是非常重要的。此外,它通过使用 PTE_SYSCALL
位掩码来确保复制页表项时保留了正确的权限位。这种实现方式允许在父子进程间共享特定的内存页,而不是简单地复制整个地址空间,从而提高了效率并减少了内存占用。
The keyboard interface
为了使shell工作,我们需要一种方法来向其输入内容。QEMU已经可以显示我们写到CGA显示器和串行端口的输出,但到目前为止,我们只在内核监视器中处理输入。在QEMU中,输入到图形窗口的内容会作为键盘输入传递给JOS,而输入到控制台的内容会作为字符出现在串行端口上。kern/console.c
已经包含了自实验1以来内核监视器使用的键盘和串行驱动程序,但现在你需要将这些驱动程序连接到系统的其余部分。
Exercise 9.
In your kern/trap.c, call
kbd_intr
to handle trapIRQ_OFFSET+IRQ_KBD
andserial_intr
to handle trapIRQ_OFFSET+IRQ_SERIAL
.
在/kern/console.c/cons_getc()
中的代码,实现了在 monitor 模式下(禁止中断)可以正常获取用户输入。
// poll for any pending input characters,
// so that this function works even when interrupts are disabled
// (e.g., when called from the kernel monitor).
serial_intr();
kbd_intr();
在 trap.c 中加入中断处理函数。
case (IRQ_OFFSET + IRQ_KBD):
lapic_eoi();
kbd_intr();
break;
case (IRQ_OFFSET + IRQ_SERIAL):
lapic_eoi();
serial_intr();
break;
我们已经在 lib/console.c
中为您实现了控制台输入/输出文件类型。kbd_intr
和 serial_intr
函数将填充一个缓冲区,其中包含最近读取的输入,而控制台文件类型则会排空这个缓冲区(控制台文件类型默认用于标准输入/输出,除非用户重定向它们)。
The Shell
运行make run-icode,将会执行user/icode,user/icode又会执行inti,然后会spawn sh。然后就能运行如下指令:
echo hello world | cat
cat lorem |cat
cat lorem |num
cat lorem |num |num |num |num |num
lsfd
Exercise 10.
The shell doesn't support I/O redirection. It would be nice to run sh <script instead of having to type in all the commands in the script by hand, as you did above. Add I/O redirection for < to
user/sh.c
.Test your implementation by typing sh <script into your shell
Run make run-testshell to test your shell. testshell simply feeds the above commands (also found in fs/testshell.sh) into the shell and then checks that the output matches fs/testshell.key.
实现 I/O 重定向。第一反映就是解析<
后的文件,通过打开文件获得文件描述符,再将此文件描述符传入关联到标准输入 0(使用dup
实现),最后关闭之前获得的描述符。
if ( (fd = open(t, O_RDONLY) )< 0 ) {
fprintf(2,"file %s is no exist\\n", t);
exit();
}
if (fd != 0) {
dup(fd, 0);
close(fd);
}
// LAB 5: Your code here.
// panic("< redirection not implemented");
break;