Mit6.828 lab5: File system,Spawn and Shell

环境

deepin 20 64 位系统

说在前面

本次Lab我也觉得比较难,到最后也有部分测试点没有通过,在实现pipe上也有点问题。另外,我觉得Lab5这部分和xv6中对于Unix的一些知识点的实现稍微有些不同(至少我是这么认为的,这个lab中的很多知识点我选择了认真阅读xv6的代码来理解)。比如说pipe的实现,file descriptor的实现。相比来说xv6中和Linux中更加接近,虽然说xv6和Linux的差别还是有不少,不过肯定比我们直接阅读Linux内核好多了,也借此xv6管中窥豹来理解一些Unix的一些知识点。
在lab5的文件系统中,没有引入inode,然而inode又是Linux系统中非常关键的一个结构。所以,对于文件系统的理解,阅读xv6更加有帮助。

正文

File System Preliminaries

这里实现的文件系统比大多数真实的文件系统都要简单很多,包括xv6的文件系统,但是对于最基本的功能足够了:创建,读取,写入,删除,而且文件也是树状结构的。

我们要实现一个单用户的系统。我们的文件系统不支持权限,也不支持hard link和symbolic link,time stamps,以及special device files。

On-Disk File System Structure
大多数的UNIX文件系统将硬盘空间(disk space)分为两部分:inode 和 data 。UNIX 文件系统为每一个文件都分配了一个inode。一个文件的inode持有一个文件的重要信息,比如说该文件的数据在硬盘中的扇区位置。数据区(data regions)本划分为更大的区域(通常是8KB),在数据区域中文件系统用于存放数据以及目录的数据(在底层的文件系统当中,目录实际上也是文件)。目录项(directory entries)包含着文件名以及一个指向属于他的inode的指针(再说一次,目录也是文件,一个文件都有自己的inode)。因为我们的文件系统不支持hard link,为了简单起见:我们的文件系统没有使用inode,相反我们知识简单的将所有文件数据都直接存放到directory entry当中。(没理解这个意思。。)
文件和目录在逻辑上都是一大串数据块,而这些数据块散落在硬盘当中的各处的,就想进程的虚拟地址空间也是散落在实际的物理内存的各处。这里的意思就是:一个文件看起来是连续的,但是实际上文件数据是存储在各个扇区当中,是非连续的。这一段就像虚拟地址和物理地址,虚拟地址分配的时候往往都是连续的,但是这一大块虚拟地址对应的物理地址极有可能并不是连续的。文件系统将数据块的layout隐藏了起来,向外提供接口用于在文件中的任意偏移处读取以及写入数据。

Sector and Blocks
大多数的硬盘在执行读写的时候都不能以字节为单位,而是以扇区为单位的。在JOS中,每个扇区大小是512字节。文件系统实际上在分配以及使用硬盘空间的时候都是以块(block,而不是sector)为单位的。请注意这两个术语之间的区别:sector size是对于硬盘来说的,而block size是操作系统如何使用硬盘的角度来说的。一个文件的block size必须是一个sector size 的整数倍。

xv6中的block size是512字节,这与硬盘的扇区大小相一致。大多数的文件系统使用更大的块,因为现在的硬盘也越来越大了而且在以更大的粒度来处理文件具有更高的效率。
PS:
下面是一个xv6中写硬盘的函数例子,因为以操作系统的角度来讲,它往数据中写入数据都是以块来单位的。在写入函数中,我们要将块的大小转为需要写入的扇区数目。。一个写硬盘操作可以抽象为:检查状态是否就绪 -》 写入 -》 检查是否成功写入。如果粒度太小,一块连续的内存则需要多次调用写硬盘操作,则会多次执行这样的流程,性能有很大的损失。

Superblocks
文件系统要预留一定的很容易找到的(通常在硬盘的最开始或者最末尾的区域)硬盘区域来保存metadata,来描述整个文件系统的信息,比如说每个block size, disk size,文件系统是什么时候被挂载的,最后一次检查文件系统errors是什么时候等等信息。这些特殊的块叫做superblock。

我们的文件系统只有一个superblock,它占据硬盘的第一个block。它的结构定义在inc/fs.h中的struct Super结构当中。Block 0通常用于保留boot loaders 以及 分区表(partion table),所以文件系统通常不使用第一个block。很多真正的文件系统会维护着多个super block,在硬盘当中复制多次,这样一来如果有一个super block坏掉了,其他superblock(备份的)就可以使用了,可以防止文件系统无法使用。

File Meta-data
在我们的文件系统当中,一个文件的的meta data定义inc/fs.h中的struct File。一个文件的meta data包括文件名,文件的大小,类型(是目录还是普通文件,还是设备(这一点在lab5中没有对应的实现)),以及一个指向文件数据的指针。正如前面所提到的,我们没有使用inode, 所以文件数据直接存放在目录当中。不想一个真正的文件系统,简单起见,我们将会使用File这个结构(就是struct File)来表示硬盘和内存中的文件meta-data。
PS:因为在xv6当中,struct File表示一个打开的文件,而在硬盘中的文件数据,则是一个以inode的形式来持有的。在lab5中,无论是在内存中的文件还是在硬盘中的文件,我们都以struct File来表示文件。

struct File中的 f_direct数组表示用于存储一个文件的前10个block的数据,这部分叫做direct blocks。对于小文件来说(就是文件大小小于 10 *4096 = 40KB), 这就意味着存放数据的块号都直接存放在File内。对于大的文件来说,我们需要额外的地方来存放剩下的块号。对于任何大于40KB的文件来说,我们分配了一个额外的块,叫做indirect block,可以持有4096/4= 1024个块。(一个int占据4个字节,一个块4096kb,所以一个块总共可以持有1024个块号)。我们的文件系统于是乎最多可以持有1024+10=1034个块,也就是最大支持4MB的文件大小。为了支持更大尺寸的文件,真正的文件系统还会引入double 和 triple-indirect blocks。

注解:
我们在操作文件的时候,通常是以offset来操作的。几个熟悉的系统调用read(),wirte()都需要传入offset。offset这个意思给我们一种,好像一个文件在硬盘当中看起来是连续的样子。不过,在实际的硬盘当中,数据往往是分布在不同的扇区的。为了提高效率,我们对硬盘操作也不是以单个扇区(sector)为粒度,而是以块(block)为粒度。一个经典的文件结构(就是struct File)就是下图所示:

file structure

主要想表达的是,对于File结构中的f_direct[]数组中存放的是硬盘中的块号。

Directories versus Regular Files
一个File在我们的文件系统中既可以表示一个文件也可以表示一个目录(directory);这两种文件通过File结构中的type字段来区别。对于文件系统来说,普通文件和目录(directory)没什么区别,这两者都是文件。但是对于普通的文件来说,文件系统不会解释普通文件的内容,而对于目录来说,文件系统会将其数据解释为一大串的File结构。
在我们的文件系统当中,superblock(定义在inc/fs.h当中)包含着一个File结构,这个结构持有着文件系统的根目录信息。这个struct File的数据就是一大串子struct File用于记录根目录下的文件以及子目录的信息。任何位于根目录下的子目录又会又sub-subdirectories,以此类推。

The File System

本次lab的目的是为了实现整个文件系统,但是你只需要实现其中一些关键部分。尤其是,你需要实现从硬盘当中去Blocks到内存Blocks当中,还需要刷新blocks到硬盘当中,分配blocks;将文件的offset映射(mapping)到对应的disk blocks;然后实现read,write,以及open。因为你不需要实现整个文件系统,所以要弄清楚文件系统的各个函数的用处。

Disk Access
文件系统进程在我们的操作系统当中需要访问硬盘,但是目前在我们的内核当中实现访问硬盘的功能。不像传统的宏内核(monolithic)所采用的策略,它们将访问硬盘函数封装为为系统调用,在这里我们将硬盘驱动(IDE-driver)作为用户程序的一部分。我们还需要稍微修改一下内核代码,为了文件系统进程能够访问硬盘。

PS:上面这段话的意思就是说,传统的宏内核系统。操作硬盘的函数直接会封装被系统调用的内部(我没有看过Linux的内核代码,但是至少,在XV6是这么做的)。但是在在lab5中,我们将文件系统封装为一个进程。程序之间通过IPC(inter-process communication)来读取硬盘数据。感觉有点微内核那意思。

x86处理使用IOPL bits(IOPL位于EFLAGS寄存器当中)来决定一段代码是否可以执行IO指令,比如说IN和OUT指令。因为所有IDE disk的寄存器(registers)位于x86的IO space之间的而不是位于MMIO(比如说显存),给文件系统进程 IO 特权才能让文件系统进程来访问IDE disk的寄存器。实际上在EFLAGS寄存器中的IOPL bits为内核提供了"all-or-nothing"的方法来控制用户代码是否可以访问IO Space。在我们的例子当中,我们想让文件系统可以访问IO space,但是我们不想让其他用户程序有机会访问OP space。

回顾一下:
在这里想回顾一下TSS的作用。因为TSS和进程切换,以及I/O port permissions都有关。贴近主题,首先先说一下TSS在I/O permissions中的作用。
在x86体系当中,并不是所有的程序都可以执行执行IO指令,。比如说下列指令: IN, INS, OUT, OUTS, CLI (clear interrupt-enable flag), and STI (set interrupt-enable flag)。这些指令叫做 I/O sensitive instructions。 在CPU中,EFLAGS寄存器有有两个bits用于表示当前程序的IO权限。如下:

来自wikipedia

分别表示0,1,2,3(特权级别从高到低依次递减),恰好和程序本身的特权级差不多。
当一个程序的CPL <= IOPL(比如说CPL=1,IOPL=1),那么这个程序可以访问所有的IO 端口。如果 CPL > IOPL(比如说IOPL = 0 ,CPL =3),那么程序没有权限去访问所有的IO端口,那么处理器会从TSS中检查当前IO端口对应的bit在permission bit map来决定该进程是否有权限访问该接口。如果对应的位(bit)clear(意思就是说该位=1),那么有权限访问该IO端口,如果set,那么没有权限,会抛出GP(general-protecttion exception)。一个例子帮助说明问题,当CPL > IOPL的时候,比如说我们要访问0x29端口,那么就从bitmap中查看0x29bit是否为0,为0表示可以访问,为1表示不可以访问。
上面提到的是,TSS中有一个permission bit map。所以我们回顾一下TSS的结构。
TSS

这不是一个典型的TSS结构,如果需要的可以从Intel手册中查看。

The size of the I/O permission bit map and its location in the TSS are variable ------- intel 手册428页

从上图可以看到,bitmap是包含在TSS当中的(就如图中所画的那样)。 IO Map Base是表示IO permission bit map 相较于TSS的起点的offset。文档中说明如下:

Contains a 16-bit offset from the base of the TSS to the I/O permission bit map and interrupt redirection bitmap。------ 253 页 intel 手册

另外如果IO map base超过了或者等于TSS的大小。那么表示,如果当前程序的CPL > IOPL,那么它就无法访问任何IO端口。

If the I/O bit map base address is greater than or equal to the TSS segment limit, there is no I/O permission map, and all I/O instructions generate exceptions when the CPL is greater than the current IOPL. ------ 英特尔手册 429页

虽然这里和IO permission的关系不是非常大,但是还是说一下吧。好记忆不如烂笔头。我们要访问TSS也是通过segment descriptor的,下面是一个TSS的段描述符的示例:


TSS segment descriptor

在TSS的segment descriptor当中有一个limit字段(其他的也有),如果我们的IO map base超过了这个limit,那么就表示该TSS没有permission map了。如果我们不需要IO bitmap,那么limit就应该等于103。一个TSS的最小大小是104个字节。

TSS的另外一个作用是用于中断处理,因为在中断处理的时候,我们往往需要从低特权级的转移到高特权级的程序。比如说时钟中断发生后,用户程序被打断执行进入到了内核代码。此时会发生栈的切换,我们要从TSS中拿到内核栈的地址。因为我们内核肯定不能使用用户栈。下面是具体的流程:


栈切换

栈切换的图示

Exercise 1

init_i386函数通过传入ENV_TYPE_FS带进程创建函数。修改env.c中的env_create.修改env_create()的代码让文件系统进程可以有IO 访问权限,但是其他程序没有。

这个实现不是特别难。只要牢记前面的IOPL是如何与CPL一起来控制进程的IO权限。思路是只要让文件系统的IO权限设置为3。那么CPL 肯定都是 小于等于IOPL,这样文件系统进程一定可以访问IO端口。代码如下:

void
env_create(uint8_t *binary, enum EnvType type)
{
    // LAB 3: Your code here.
    //cprintf("env_create");
    struct Env *new_env;
    if(env_alloc(&new_env, 0)<0)
        panic("fail to create a env!\n");
    load_icode(new_env, binary);
    new_env->env_type = type;

    // If this is the file server (type == ENV_TYPE_FS) give it I/O privileges.
    // LAB 5: Your code here.
    if(type == ENV_TYPE_FS) {
        //if CPL <= IOPL, the processor allows all I/O opeations to proceed
        //because we let file system to execute IO instruction, thus IOPL of file system
        //must be 3, plz see intel manual volume 1 page 406
        new_env->env_tf.tf_eflags |= FL_IOPL_3;
    }
}

Question

我们是否需要保存IO特权级,以便于将来的恢复?

在lab5中,我们不需要在写额外的代码来实现这个。因为一个进程的IOPL都是保存在EFLAGS寄存器当中的。我们在切换进程的时候,会将前一个进程所属的EFLAGS寄存器压入到栈当中。在恢复一个进程的执行的时候,恢复属于他自己的eflags寄存器的值(看env_run()的代码)。

实验结果
运行make grade,通过fs i/o测试点,截图如下:

make grade运行截图

The Block Cache

在我们的文件系统当中,我们将会实现一个简单的 buffer cache。
我们的文件系统最多只能处理3GB的硬盘。我们为文件系统进程预留了3GB的虚拟内存空间。从 0x1000_0000(DISKMAP) 到 0XD000_0000(DISKMAP+DISKMAX),作为硬盘在内存当中的映射。比如说,硬盘中的block 0映射到了内存地址0x1000_0000处,disk block 1映射到了虚拟地址0x1000_1000处,以此类推。diskaddr函数用于将硬盘块号转为内存中的虚拟地址。
因为我们的文件系统由它自己的虚拟地址空间且与其他进程的地址空间像独立,文件系统唯一要做的事情就是实现文件访问,所以保留很大一部分的虚拟进程空空间是有理由的。
当然,如果将整个硬盘的数据都读取到硬盘当中会花费很多时间,所以我们采用demand paging的方式,我们只分配页在硬盘映射的区域,从对应的块读取数据的时候会时候会触发page fault来从硬盘读取数据。这样一来,我们可以佯装这个硬盘都在内存当中。
Exercise 2

实现fs/bc.c中的bc_pgfaultflush_block函数.bc_pgfault是page fault handler,就像在之前我们为copy-on-write建立page fault的时候那样,但是它(指的是bc_pgfault)作用是从硬盘当中读取对应的pages。当你在写代码的时候,注意两点(1)地址可能不是对块对齐的 (2)ide_read读取数据的时候是以扇区为单位的,而不是blocks.
flush_block函数在必要的时候将数据写入到硬盘当中。我们会使用到硬件的帮助来追踪一个disk block是否被修改过,自从它从硬盘当中读入以后。为了判断一个块是不否被写入过,我们只要看页是否有PTE_D这个标志位就行。在写入硬盘之后,flush_block应该调用系统调用sys_page_map()来清除TE_D。

这里代码较多,就不完全复制了。结合注释可以理解。代码如下:
bc.c/bc_pgfault():

    if((r = sys_page_alloc(0,alinged_addr,PTE_U | PTE_P | PTE_W)) < 0) {
        panic("bc_pgfault in fs/bc.c: failed to allocate for:%e",r);
    }
    /*
        在JOS中,我们使用的block size == PGSIZE,但是从硬盘当中读取的数据是以扇区(512byte为单位的)
        所以从硬盘中读入一个页,需要读取8个扇区.另外blockno是计算地址相较于DISKMAP有多少个block.
        要将其转为对应的扇区号.nsecs = blockno*8
        宏定义BLKSECTS = BLKSIZE/SECTSIZE = 8
    */
    if((r = ide_read(blockno*BLKSECTS,alinged_addr,BLKSECTS)) < 0) {
        panic("bc_pgfault in fs/bc.c: the disk is not ready to read data ");
    }

**bc.c/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);

    // LAB 5: Your code here.
    void *aliged_addr = ROUNDDOWN(addr,PGSIZE);
    if(!va_is_dirty(aliged_addr) || !va_is_mapped(aliged_addr)) {
        return ;
    }
    //将addr为起始地址的,将BLKSECTS(BLKSECTS=8)个字节的内容写到硬盘当中去
    if(ide_write(blockno*BLKSECTS,aliged_addr,BLKSECTS) < 0 ) {
        panic("flush_block in fs/bc.c: failed to write to disk");
    }
    r = sys_page_map(0,aliged_addr,0,aliged_addr,uvpt[PGNUM(aliged_addr)] & PTE_SYSCALL);
    if(r < 0) {
        panic("flush_block() in fs/bc.c: sys_page_map() failed %d",r);
    }

    // panic("flush_block not implemented");
}

The Block Bitmap

我们用bitmap来判断某个块是否可用。在lab5当中,1表示该block可用,0表示不可用,稍微有点诡异。在fs_init()函数设置好bitmap的指针后,我们将bitmap看作是一个bit构成的数组。block_is_free函数用于检查个硬盘中的块是否是free的。
Exercise 3

free_block()函数为模板来实现alloc_block(),在fs/fc.c中。alloc_block()要实现的功能是从bitmap中找到一个可用的block,返回这个块的块号。当我们申请到一个块的时候,应该马上将bitmap写回到硬盘当中去。
alloc_block()的代码如下:

int
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.
    for(int i = 1; i < super->s_nblocks; i++) {
        // 这里的nblock并不是硬盘扇区的个数,而是硬盘有多少个4KB的block
        if(block_is_free((uint32_t)i)) {
            //寻找第一个可用的block,并且在bitmap中将其标记为被使用
            bitmap[i/32] &= ~(1<<(i%32));
            //将bitmap写入到硬盘当中
            flush_block((void *)bitmap);
            return i;
        }
    }
    // panic("alloc_block not implemented");
    return -E_NO_DISK;
}

File Operation

我们在fs/fs.c提供了非常多的函数来帮助你解析和管理File结构。
Exercise 4

实现file _block_walk()和file_get_block()。file_block_walk()函数用于将文件offset映射到struct File中的direct 或者indrect的block,和页表中的pgdir_walk相似。file_get_block()用于返回实际的block,如果需要的话也可以重新分配一个。

最开始一直没有理解file_block_walk()的作用。首先需要牢记的一点就是:文件的块在硬盘当中并不是连续的,而我们在访问文件数据的时候,是以offset的(C语言中的read(),write()函数都是在这样做的)。offset首先先转为direct 或者indirect中的某个元素,该元素存放块号。这样说可能比较抽象,举个例子,比如说我们现在的块是4096kb,我们访问offset=10000的数据,那么就要10000/4096 = 2。然后去文件结构(struct File的direct成员变量)中找到第二个块的块号(direct是一个数组,这就是相当于访问数组的第二个元素,从中安拿到改块对应的扇区)。
上面的内容理解后,就可以说明file_block_walk()的作用了。下图帮助理解:

image.png

file_block_walk(2)的意思就是获得文件中第二个块的地址,注意,是第二个块的地址,而不是第二个块内容。

代码实现:
要找一个块的地址。我们首先要判断这个块是direct还是在indrect当中的。另外一个重要当然也是比较容易令人疑惑的是对于indirect的处理。比如说我们direct block有10个,通过offset 计算得到我们取得第11个块的地址,但是第11个块已经是indrect block中的第一个了。我们不能直接以11去indrect block中获得数据,要11-10=1才有用。剩下的结合代码上的注释可以理解。代码如下:

static int
file_block_walk(struct File *f, uint32_t filebno, uint32_t **ppdiskbno, bool alloc)
{   
       // LAB 5: Your code here.
       if (filebno < NDIRECT) {
           //判断第fileno个blocks是否在direct blocks当中
           if (ppdiskbno != NULL) {
               *ppdiskbno = &f->f_direct[filebno];
               return 0;
           } 
       }
       
       //如果filebno超过了 direct block以及indirect block的个数,那么返回-E_INVAL
       if (filebno >= NDIRECT+NINDIRECT) {
           return -E_INVAL;
       }

        filebno -= NDIRECT;
        // 如果当前文件没有indirect,且alloc==1,那么就创建一个块用于存放indirect
       if (f->f_indirect == 0) {
           if (alloc) {
               int blockno;
               if((blockno = alloc_block()) < 0) {
                   //分配失败,说明硬盘没有空间了
                   return -E_NO_DISK;
               }
               /*
                这里的情形当前这个文件没有indirect block的时候,为当前文件创建一个block
                用于存放indirect block的内容.接着讲里面的内容清0.块创建好后flush到硬盘当中去
               */
               f->f_indirect = blockno;
               memset(diskaddr(blockno),0,BLKSIZE);
               flush_block(diskaddr(blockno));
           } else {
               return 0;
           }
       }
       
        //返回indirect中存放的块的地址,注意需要减去NDIRECT
        *ppdiskbno = (uint32_t *) diskaddr(f->f_indirect)+filebno - NDIRECT;
       return 0;
    //    panic("file_block_walk  not implemented");
}

file_get_block():
该函数是获得块号的。

int
file_get_block(struct File *f, uint32_t filebno, char **blk)
{

    //本函数的目的是,给出filebno,寻找它对应的块号的虚拟地址
       // LAB 5: Your code here.
    int r;
    uint32_t *ppdiskbno;
    
    if(filebno >= NDIRECT+NINDIRECT) {
        return -E_INVAL;
    }

    //找到filebno对应的地址,这个地址里面存放的是某个块的块号
    if((r = file_block_walk(f,filebno,&ppdiskbno,1)) < 0) {
        return r;
    }
    //如果块号不存在,那么就新创建一个块,将块号放入到ppdiskbno中
    if(*ppdiskbno == 0) {
        int r;
        if ((r = alloc_block()) < 0) {
            return -E_NO_DISK;
        }
        *ppdiskbno = r;
        //将新申请的块赋值为0,并且写入到硬盘当中
        memset(diskaddr(r),0,BLKSIZE);
        flush_block(diskaddr(r));
    }
    //将块号对应的虚拟地址放到blk当中
    *blk = diskaddr(*ppdiskbno);
    return 0;
    //    panic("file_get_block not implemented");
}

The file system interface

现在我们已经有在文件系统进程中有了它所必须的函数,我们必须让其他希望使用文件系统的进程可以访问它。因为其他程序不能直接访问文件系统进程中的程序,我们将会通过RPC来文件系统,抽象一点,就是使用JOS的IPC机制。如下图所示:

I用户进程和文件系统进程的IPC过程

上图中虚线之下的就是一个用户进程经文件系统来读取数据的一个过程。一开始,read读取的对象是一个fiel descriptor然后将将这个读取数据请求发送到合适的device中去,在这个例子当中就是devfile_read()函数(还包含其他的device type,比如说pipes)。devfile_read为硬盘实现了read()函数。在lib/file.c中的其他以devfile_开头的函数实现了client这端的文件系统操作,然后调用fsipc()函数发发送IPC请求。fsipc()来来处理发送请求以及接受应答的细节(文件系统进程的返回值)。

文件系统的server code在fs/serv.c当中。serv.c中的serve()函数会一直循环,当它接收到一个IPC调用的时候,会将该请求发送到对应的处理函数去(handler function),然后经过IPC来发送返回值。在read例子当中,serve函数将会将请求分发到serve_read()seve_read()函数将会处理要发送过来的参数,然后调用file_read()(file_read函数定义在fs.c当中)函数来执行数据的读取。

回想一下JOS的IPC机制,它能够让一个进程发送一个32bit的数字,另外也可以选择分享一个页(共享内存,在接收者以及发送者进程中各有一片虚拟地址指向相同的物理页)。为了发送一个从client到server的请求,我们使用32bit的数字来表明request type(和系统调用的比较相似,我们以数字来表明当前这个请求是干嘛的,是read,write等等),将请求的参数放在union Fsipc中。在client这边,我们将参数放在fsipcbuf中,在server这边将参数放到fsreq当中。
PS
这里的主要意思就是,在client这边,我们将IPC所需要发送的参数放在fsipcbuf,我们在sys_ipc_try_send中都规定了要发送的页地址必须是页对齐的,所以在初始化的时候,fsipcbuf要做到页对齐。类似的,在server这端,我们将接收到的参数放到了fsreq地址处。

Exercise 5

实现serv.c中的serve_read()函数
serve_read()函数的大部份工作都由fs/fs.c完成了。查看serve_set_size里面的注释来获得一些idea来理解server 里面的函数是如何构建的。

Exervise 6

实现fs/serv.c中的serve_write函数,以及lib/file.c中的devfile_write()函数

代码实现
这一部分我认为是有点难度的,我花了不少时间去理解整个函数执行的流程。因为最开始没有完全理解lab中的描述。建议从user目录下(比如收user/ls.c程序)选择一个程序来理解这整个调用的过程。理解这其中用到的各个数据结构的作用。
serve_read()函数:
这个函数用于实际的读取数据,它主要完成的工作是调用file_read()来读取数据到缓冲区处(就如同我们使用C语言中的read()函数一样,调用read()函数的时候我们也需要传入缓冲区的地址)。参数中Fsipc是个参数的合集。里面有我们需要的各种参数。代码如下:

int
serve_read(envid_t envid, union Fsipc *ipc)
{   
    /*
        我们可能会好奇我们在哪里初始化了这些Fsipc中的各个参数
        一个文件读取的逻辑是,以ls程序为例子,ls中调用read()
        read()函数中有参数buf(缓冲区的地址),n(要读取的字节数)
        接着read()函数最后调用了dev_read函数,这里以读取硬盘文件为例子
        接着跳转到了file.c/devfile_read函数,在这里将接收到的buf,和n都
        赋值给了Fsipc,这样一来就就完成了参数初始化
    */

    struct Fsreq_read *req = &ipc->read;
    struct Fsret_read *ret = &ipc->readRet;
    struct OpenFile *o;
    int r;
    if (debug)
        cprintf("serve_read %08x %08x %08x\n", envid, req->req_fileid, req->req_n);

    // Lab 5: Your code here:
    //根据file_id来寻找对应的openfile
    if((r = openfile_lookup(envid,req->req_fileid,&o)) < 0) {
        return r;
    }

    //调用file_read来读取文件,file_read每一次读取文件都会修改offset
    //file_read的是读取了多少字节,读取到的内容放到了ret->ret_buf当中
    if((r = file_read(o->o_file,ret->ret_buf,req->req_n,o->o_fd->fd_offset)) < 0) {
        return r;
    }
    //修改当前 file descriptor对应的offset
    o->o_fd->fd_offset += r;
    return r;
}

serve_write()函数:
明白serve_read逻辑之后,serve_write()应该不难理解。我们只要从将缓冲区的数据写入即可。在每次写入之后,都需要使得offset递增。代码如下:

int
serve_write(envid_t envid, struct Fsreq_write *req)
{   
    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;
    if((r = openfile_lookup(envid,req->req_fileid,&o)) < 0) {
        return r;
    }
    
    //将输入从req->req_buf写入req->req_n字节的数据到文件o->o_file中,写入完毕修改o->o_fd->fd_offset
    if((r = file_write(o->o_file,req->req_buf,req->req_n,o->o_fd->fd_offset)) < 0) {
        return r;
    }
    o->o_fd->fd_offset += r;

    //返回读取了多少字节
    return r;
    // panic("serve_write not implemented");
}

devfile_wirte()函数:
参考一下devfile_read(),那么写这个devfile_write()就很容易了。我们在devfile_write()中要初始化好fscibuf,因为他将作为参数传给fsipc()函数。另外我们还要将数据从devfile_write()的参数中buf复制到fsipcbuf.wirte.buf处。代码如下:

static ssize_t
devfile_write(struct Fd *fd, const void *buf, size_t n)
{
    // Make an FSREQ_WRITE request to the file system server.  Be
    // careful: fsipcbuf.write.req_buf is only so large, but
    // remember that write is always allowed to write *fewer*
    // bytes than requested.
    // LAB 5: Your code here
    int r;
    //如果超过了buffer的大小,那么读取的数据也只能是buffer的最大值
    if (n > sizeof(fsipcbuf.write.req_buf)) {
        n = sizeof(fsipcbuf.write.req_buf);
    }
    fsipcbuf.write.req_fileid = fd->fd_file.id; //写入的目标文件
    //要写入的字节数
    fsipcbuf.write.req_n = n;
    
    //和读反过来的是,我们在写入的时候,现将数据从缓冲区(buf)当中复制到fsipcbuf.write.req_buf
    //然后在去调用serve_write()函数,该函数再去读取fsipcbuf.write.req_buf中的数据
    //serve_write()函数再将fsipcbuf.write.req_buf内的数据写入到文件当中去
    memmove(fsipcbuf.write.req_buf,buf,n);
    //fsipc返回的是成功写入的字节数,如果成功的话应该写入
    r = fsipc(FSREQ_WRITE,NULL);

    assert(r <= n);
    assert(r <= PGSIZE);
    //返回成功读取的字节数.
    return r;
    // panic("devfile_write not implemented");
}

Spawing Process

我们已经给出spawn(),它的作用是用于创建新的进程,从文件系统中加载文件镜像,然后开始执行新进程。父进程接下来与子进程独立执行。spawn()函数作用和UNIX系统中调用fork后马上在子进程中执行exec()差不多。
Exercise 7

spawn依赖于系统调用sys_env_set_trapframe来初始化新创建的进程的状态。实现sys_env_set_trapframekern/syscall.c(别忘了在syscall()中新加入一个case)

直接给代码吧,sys_env_set_trapframe()函数主要用于在创建子进程的时候设置状态。直接给代码吧,那个assert没有特别理解。

static int
sys_env_set_trapframe(envid_t envid, struct Trapframe *tf)
{
    // LAB 5: Your code here.
    // Remember to check whether the user has supplied us with a good
    // address!
    struct Env *env;
    int r; //根据ID获得进程
    if ((r = envid2env(envid,&env,1)) < 0) {
        return -E_BAD_ENV;
    }
    user_mem_assert(env,(const void*) tf, sizeof(struct Trapframe),PTE_U);
    env->env_tf = *tf;
    env->env_tf.tf_cs |= 0x3; 
    env->env_tf.tf_eflags &=  (~FL_IOPL_MASK);
    env->env_tf.tf_eflags |= FL_IF;
    return 0;
    // panic("sys_env_set_trapframe not implemented");
}

Sharing library state across fork and spawn

The UNIX file descriptor 包括了pipes,console I/O等等。在JOS,任何一种device都有一种与之对应的struct Dev,包含着该device的read/write的函数指针(function pointer)。lib/fd.c只中实现了UNIX-like file descriptor。每一个struct Fd表明了它的device type,lib/fd.c中的大部分函数只是简单的将操作分发的到合适的struct Dev

lib/fd.c为每一个进程都维护者一个file descriptor table,该table起始于地址FDTABLE。为每一个file descriptor都预留了4KB的内存,一个文进程最多可以持有32个文件描述符(也就是说,最多可以打开32个文件)。每一个文件描述符都有一个对应的可选的数据区域,从FILEDATA开始,这是留给device去使用的。

lab原文中下面的说明没有理解是什么意思。但是我从另外一个角度来说明,是参考xv6的代码。这里主要要做的是,将父进程中的文件描述符共享到子进程当中。因为xv6中的实现更加贴近于UNIX的实现,在xv6中实现共享文件描述符的很容易实现。在本lab当中,使用的共享page的方式共享父子进程当中的文件描述符。
所以,在我们打开一个文件的时候,要将该文件对应的文件描述符设置为是共享的,在serv.c/serv_open()函数中,在最后我们将文件描述符对应的页设置为PTE_SHARE。这样一来我们只需要在fork函数中判断页是否是PTE_SHARE就行。然后将对应的页表复制到子进程当中去。这样子进程和父进程中就完成了共享页面。

Exercise 8

修改lib/fork.c中的duppage()函数。如果一个页表项(page table entry)带有标志位PTE_SHARE,就直接复制页表。权限是PTE_SYSCALL。相类似的

这个不是很难。只要在lib/fork.c中的duppage()函数增加一个新的判断语句就行。然后将对应的页表复制过去。代码如下:

    if(uvpt[pn] & PTE_SHARE) {
        /*
            如果是PTE_share的,就将映射关系复制,并且权限为PTE_SYSCALL
        */
        result = sys_page_map(cur_proc,addr,envid,addr,PTE_SYSCALL);
        if (result < 0) {
            panic("duppage(): sys_page_map failed");
        }
    }

Exercise 9
现在要实现的是键盘中断。添加一个新的中断十分简单,只需要在trap.c中加入新的case就行。

        case (IRQ_OFFSET + IRQ_KBD):
            lapic_eoi();
            kbd_intr();
            break;

Exercise 10

在shell中实现IO重定位。
这部分的代码我没有仔细研究,是抄的别人的代码:

        case '>':   // Output redirection
            // Grab the filename from the argument list
            if (gettoken(0, &t) != 'w') {
                cprintf("syntax error: > not followed by word\n");
                exit();
            }
            if ((fd = open(t, O_WRONLY|O_CREAT|O_TRUNC)) < 0) {
                cprintf("open %s for write: %e", t, fd);
                exit();
            }
            if (fd != 1) {
                dup(fd, 1);
                close(fd);
            }
            break;

总结

我的代码没有通过所有的测试点,但是如果理解了xv6中的文件系统实现,我想不至于因为这几个测试点没有通过而学不到东西。本次lab中和xv6中的文件系统实现稍微有些不一样。比如说缓存的实现,inode的使用,目录的结构等等。所以我认为如果要更好的理解UNIX文件系统的一些概念。还是认真研读xv6的文件系统的代码。xv6的代码比较难懂,结合xv6 booK中关于文件系统的内容。管中窥豹,可以对文件系统增加一些了解。

你可能感兴趣的:(Mit6.828 lab5: File system,Spawn and Shell)