Skip to content

背景介绍

文件系统设计与实现

ucore 文件系统总体介绍

操作系统中负责管理和存储可长期保存数据的软件功能模块称为文件系统。在本次试验中,主要侧重文件系统的设计实现和对文件系统执行流程的分析与理解。

ucore的文件系统模型源于Havard的OS161的文件系统和Linux文件系统。但其实这二者都是源于传统的UNIX文件系统设计。UNIX提出了四个文件系统抽象概念:文件(file)、目录项(dentry)、索引节点(inode)和安装点(mount point)。

  • 文件:UNIX文件中的内容可理解为是一有序字节buffer,文件都有一个方便应用程序识别的文件名称(也称文件路径名)。典型的文件操作有读、写、创建和删除等。
  • 目录项:目录项不是目录(又称文件路径),而是目录的组成部分。在UNIX中目录被看作一种特定的文件,而目录项是文件路径中的一部分。如一个文件路径名是“/test/testfile”,则包含的目录项为:根目录“/”,目录“test”和文件“testfile”,这三个都是目录项。一般而言,目录项包含目录项的名字(文件名或目录名)和目录项的索引节点(见下面的描述)位置。
  • 索引节点:UNIX将文件的相关元数据信息(如访问控制权限、大小、拥有者、创建时间、数据内容等等信息)存储在一个单独的数据结构中,该结构被称为索引节点。
  • 安装点:在UNIX中,文件系统被安装在一个特定的文件路径位置,这个位置就是安装点。所有的已安装文件系统都作为根文件系统树中的叶子出现在系统中。

上述抽象概念形成了UNIX文件系统的逻辑数据结构,并需要通过一个具体文件系统的架构设计与实现把上述信息映射并储存到磁盘介质上,从而在具体文件系统的磁盘布局(即数据在磁盘上的物理组织)上具体体现出上述抽象概念。比如文件元数据信息存储在磁盘块中的索引节点上。当文件被载入内存时,内核需要使用磁盘块中的索引点来构造内存中的索引节点。

ucore模仿了UNIX的文件系统设计,ucore的文件系统架构主要由四部分组成:

  • 通用文件系统访问接口层:该层提供了一个从用户空间到文件系统的标准访问接口。这一层访问接口让应用程序能够通过一个简单的接口获得ucore内核的文件系统服务。
  • 文件系统抽象层:向上提供一个一致的接口给内核其他部分(文件系统相关的系统调用实现模块和其他内核功能模块)访问。向下提供一个同样的抽象函数指针列表和数据结构屏蔽不同文件系统的实现细节。
  • Simple FS文件系统层:一个基于索引方式的简单文件系统实例。向上通过各种具体函数实现以对应文件系统抽象层提出的抽象函数。向下访问外设接口
  • 外设接口层:向上提供device访问接口屏蔽不同硬件细节。向下实现访问各种具体设备驱动的接口,比如disk设备接口/串口设备接口。

对照上面的层次我们再大致介绍一下文件系统的访问处理过程,加深对文件系统的总体理解。假如应用程序操作文件(打开/创建/删除/读写),首先需要通过文件系统的通用文件系统访问接口层给用户空间提供的访问接口进入文件系统内部,接着由文件系统抽象层把访问请求转发给某一具体文件系统(比如SFS文件系统),具体文件系统(Simple FS文件系统层)把应用程序的访问请求转化为对磁盘上的block的处理请求,并通过外设接口层交给磁盘驱动例程来完成具体的磁盘操作。结合用户态写文件函数write的整个执行过程,我们可以比较清楚地看出ucore文件系统架构的层次和依赖关系。

ucore文件系统总体结构

从ucore操作系统不同的角度来看,ucore中的文件系统架构包含四类主要的数据结构, 它们分别是:

  • 超级块(SuperBlock),它主要从文件系统的全局角度描述特定文件系统的全局信息。它的作用范围是整个OS空间。
  • 索引节点(inode):它主要从文件系统的单个文件的角度它描述了文件的各种属性和数据所在位置。它的作用范围是整个OS空间。
  • 目录项(dentry):它主要从文件系统的文件路径的角度描述了文件路径中的一个特定的目录项(注:一系列目录项形成目录/文件路径)。它的作用范围是整个OS空间。对于SFS而言,inode(具体为struct sfs_disk_inode)对应于物理磁盘上的具体对象,dentry(具体为struct sfs_disk_entry)是一个内存实体,其中的ino成员指向对应的inode number,另外一个成员是file name(文件名).
  • 文件(file),它主要从进程的角度描述了一个进程在访问文件时需要了解的文件标识,文件读写的位置,文件引用情况等信息。它的作用范围是某一具体进程。

如果一个用户进程打开了一个文件,那么在ucore中涉及的相关数据结构(其中相关数据结构将在下面各个小节中展开叙述)和关系如下图所示:

ucore中文件相关关键数据结构及其关系

磁盘块设备

在LoongArch32版本的uCore中,其磁盘是通过一块内存进行的模拟,并无真实的磁盘控制器硬件。因此整个文件系统的磁盘数据其实运行在内存中。在编译uCore时,makefile会调用tools/mksfs在你的计算机上完成SFS文件系统的格式化以及文件为镜像,并链接到ucore-kernel-initrd这一个可执行文件中。

通用文件系统访问接口

文件和目录相关用户库函数

Lab4中部分用户库函数与文件系统有关,我们先讨论对单个文件进行操作的系统调用,然后讨论对目录和文件系统进行操作的系统调用。

在文件操作方面,最基本的相关函数是open、close、read、write。在读写一个文件之前,首先要用open系统调用将其打开。open的第一个参数指定文件的路径名,可使用绝对路径名;第二个参数指定打开的方式,可设置为O_RDONLY、O_WRONLY、O_RDWR,分别表示只读、只写、可读可写。在打开一个文件后,就可以使用它返回的文件描述符fd对文件进行相关操作。在使用完一个文件后,还要用close系统调用把它关闭,其参数就是文件描述符fd。这样它的文件描述符就可以空出来,给别的文件使用。

读写文件内容的系统调用是read和write。read系统调用有三个参数:一个指定所操作的文件描述符,一个指定读取数据的存放地址,最后一个指定读多少个字节。在C程序中调用该系统调用的方法如下:

count = read(filehandle, buffer, nbytes);

该系统调用会把实际读到的字节数返回给count变量。在正常情形下这个值与nbytes相等,但有时可能会小一些。例如,在读文件时碰上了文件结束符,从而提前结束此次读操作。

如果由于参数无效或磁盘访问错误等原因,使得此次系统调用无法完成,则count被置为-1。而write函数的参数与之完全相同。

对于目录而言,最常用的操作是跳转到某个目录,这里对应的用户库函数是chdir。然后就需要读目录的内容了,即列出目录中的文件或目录名,这在处理上与读文件类似,即需要通过opendir函数打开目录,通过readdir来获取目录中的文件信息,读完后还需通过closedir函数来关闭目录。由于在ucore中把目录看成是一个特殊的文件,所以opendir和closedir实际上就是调用与文件相关的open和close函数。只有readdir需要调用获取目录内容的特殊系统调用sys_getdirentry。而且这里没有写目录这一操作。在目录中增加内容其实就是在此目录中创建文件,需要用到创建文件的函数。

文件和目录访问相关系统调用

与文件相关的open、close、read、write用户库函数对应的是sys_open、sys_close、sys_read、sys_write四个系统调用接口。与目录相关的readdir用户库函数对应的是sys_getdirentry系统调用。这些系统调用函数接口将通过syscall函数来获得ucore的内核服务。当到了ucore内核后,在调用文件系统抽象层的file接口和dir接口。

文件系统抽象层 - VFS

文件系统抽象层是把不同文件系统的对外共性接口提取出来,形成一个函数指针数组,这样,通用文件系统访问接口层只需访问文件系统抽象层,而不需关心具体文件系统的实现细节和接口。

file & dir接口

file&dir接口层定义了进程在内核中直接访问的文件相关信息,这定义在file数据结构中,具体描述如下:

struct file {
    enum {
        FD_NONE, FD_INIT, FD_OPENED, FD_CLOSED,
    } status;                         //访问文件的执行状态
    bool readable;                    //文件是否可读
    bool writable;                    //文件是否可写
    int fd;                           //文件在filemap中的索引值
    off_t pos;                        //访问文件的当前位置
    struct inode *node;               //该文件对应的内存inode指针
    int open_count;                   //打开此文件的次数
};

而在kern/process/proc.h中的proc_struct结构中描述了进程访问文件的数据接口files_struct,其数据结构定义如下:

struct files_struct {
    struct inode *pwd;                //进程当前执行目录的内存inode指针
    struct file *fd_array;            //进程打开文件的数组
    atomic_t files_count;             //访问此文件的线程个数
    semaphore_t files_sem;            //确保对进程控制块中fs_struct的互斥访问
};

当创建一个进程后,该进程的files_struct将会被初始化或复制父进程的files_struct。当用户进程打开一个文件时,将从fd_array数组中取得一个空闲file项,然后会把此file的成员变量node指针指向一个代表此文件的inode的起始地址。

inode 接口

index node是位于内存的索引节点,它是VFS结构中的重要数据结构,因为它实际负责把不同文件系统的特定索引节点信息(甚至不能算是一个索引节点)统一封装起来,避免了进程直接访问具体文件系统。其定义如下:

struct inode {
    union {                                 //包含不同文件系统特定inode信息的union成员变量
        struct device __device_info;          //设备文件系统内存inode信息
        struct sfs_inode __sfs_inode_info;    //SFS文件系统内存inode信息
    } in_info;   
    enum {
        inode_type_device_info = 0x1234,
        inode_type_sfs_inode_info,
    } in_type;                          //此inode所属文件系统类型
    atomic_t ref_count;                 //此inode的引用计数
    atomic_t open_count;                //打开此inode对应文件的个数
    struct fs *in_fs;                   //抽象的文件系统,包含访问文件系统的函数指针
    const struct inode_ops *in_ops;     //抽象的inode操作,包含访问inode的函数指针     
};

在inode中,有一成员变量为in_ops,这是对此inode的操作函数指针列表,其数据结构定义如下:

struct inode_ops {
    unsigned long vop_magic;
    int (*vop_open)(struct inode *node, uint32_t open_flags);
    int (*vop_close)(struct inode *node);
    int (*vop_read)(struct inode *node, struct iobuf *iob);
    int (*vop_write)(struct inode *node, struct iobuf *iob);
    int (*vop_getdirentry)(struct inode *node, struct iobuf *iob);
    int (*vop_create)(struct inode *node, const char *name, bool excl, struct inode **node_store);
int (*vop_lookup)(struct inode *node, char *path, struct inode **node_store);
……
 };

参照上面对SFS中的索引节点操作函数的说明,可以看出inode_ops是对常规文件、目录、设备文件所有操作的一个抽象函数表示。对于某一具体的文件系统中的文件或目录,只需实现相关的函数,就可以被用户进程访问具体的文件了,且用户进程无需了解具体文件系统的实现细节。

Simple FS 文件系统

这里我们没有按照从上到下先讲文件系统抽象层,再讲具体的文件系统。这是由于如果能够理解Simple FS(简称SFS)文件系统,就可更好地分析文件系统抽象层的设计。即从具体走向抽象。ucore内核把所有文件都看作是字节流,任何内部逻辑结构都是专用的,由应用程序负责解释。但是ucore区分文件的物理结构。ucore目前支持如下几种类型的文件:

  • 常规文件:文件中包括的内容信息是由应用程序输入。SFS文件系统在普通文件上不强加任何内部结构,把其文件内容信息看作为字节。
  • 目录:包含一系列的entry,每个entry包含文件名和指向与之相关联的索引节点(index node)的指针。目录是按层次结构组织的。
  • 链接文件:实际上一个链接文件是一个已经存在的文件的另一个可选择的文件名。
  • 设备文件:不包含数据,但是提供了一个映射物理设备(如串口)到一个文件名的机制。可通过设备文件访问外围设备。
  • 管道:管道是进程间通讯的一个基础设施。管道缓存了其输入端所接受的数据,以便在管道输出端读的进程能一个先进先出的方式来接受数据。

在lab4中关注的主要是SFS支持的常规文件、目录和链接中的 hardlink 的设计实现。SFS文件系统中目录和常规文件具有共同的属性,而这些属性保存在索引节点中。SFS通过索引节点来管理目录和常规文件,索引节点包含操作系统所需要的关于某个文件的关键信息,比如文件的属性、访问许可权以及其它控制信息都保存在索引节点中。可以有多个文件名可指向一个索引节点。

文件系统的布局

文件系统通常保存在磁盘上。在本实验中,磁盘(即disk0)用于存放一个SFS文件系统(Simple Filesystem)。通常文件系统中,磁盘的使用是以扇区(Sector)为单位的,但是为了实现简便,SFS 中以 block (4K,与内存 page 大小相等)为基本单位。

SFS文件系统的布局如下图所示。

第0个块(4K)是超级块(superblock),它包含了关于文件系统的所有关键参数,当计算机被启动或文件系统被首次接触时,超级块的内容就会被装入内存。其定义如下:

struct sfs_super {
    uint32_t magic;                                  /* magic number, should be SFS_MAGIC */
    uint32_t blocks;                                 /* # of blocks in fs */
    uint32_t unused_blocks;                         /* # of unused blocks in fs */
    char info[SFS_MAX_INFO_LEN + 1];                /* infomation for sfs  */
};

可以看到,包含一个成员变量魔数magic,其值为0x2f8dbe2a,内核通过它来检查磁盘镜像是否是合法的 SFS img;成员变量blocks记录了SFS中所有block的数量,即 img 的大小;成员变量unused_block记录了SFS中还没有被使用的block的数量;成员变量info包含了字符串"simple file system"。

第1个块放了一个root-dir的inode,用来记录根目录的相关信息。有关inode还将在后续部分介绍。这里只要理解root-dir是SFS文件系统的根结点,通过这个root-dir的inode信息就可以定位并查找到根目录下的所有文件信息。

从第2个块开始,根据SFS中所有块的数量,用1个bit来表示一个块的占用和未被占用的情况。这个区域称为SFS的freemap区域,这将占用若干个块空间。为了更好地记录和管理freemap区域,专门提供了两个文件kern/fs/sfs/bitmap.[ch]来完成根据一个块号查找或设置对应的bit位的值。

最后在剩余的磁盘空间中,存放了所有其他目录和文件的inode信息和内容数据信息。需要注意的是虽然inode的大小小于一个块的大小(4096B),但为了实现简单,每个 inode 都占用一个完整的 block。

在sfs_fs.c文件中的sfs_do_mount函数中,完成了加载位于硬盘上的SFS文件系统的超级块superblock和freemap的工作。这样,在内存中就有了SFS文件系统的全局信息。

索引节点

在SFS文件系统中,需要记录文件内容的存储位置以及文件名与文件内容的对应关系。sfs_disk_inode记录了文件或目录的内容存储的索引信息,该数据结构在硬盘里储存,需要时读入内存。sfs_disk_entry表示一个目录中的一个文件或目录,包含该项所对应inode的位置和文件名,同样也在硬盘里储存,需要时读入内存。

磁盘索引节点

SFS中的磁盘索引节点代表了一个实际位于磁盘上的文件。首先我们看看在硬盘上的索引节点的内容:

struct sfs_disk_inode {
    uint32_t size;                              如果inode表示常规文件,则size是文件大小
    uint16_t type;                                  inode的文件类型
    uint16_t nlinks;                               此inode的硬链接数
    uint32_t blocks;                              此inode的数据块数的个数
    uint32_t direct[SFS_NDIRECT];                此inode的直接数据块索引值(有SFS_NDIRECT个)
    uint32_t indirect;                            此inode的一级间接数据块索引值
};

通过上表可以看出,如果inode表示的是文件,则成员变量direct[]直接指向了保存文件内容数据的数据块索引值。indirect间接指向了保存文件内容数据的数据块,indirect指向的是间接数据块(indirect block),此数据块实际存放的全部是数据块索引,这些数据块索引指向的数据块才被用来存放文件内容数据。

默认的,ucore 里 SFS_NDIRECT 是 12,即直接索引的数据页大小为 12 * 4k = 48k;当使用一级间接数据块索引时,ucore 支持最大的文件大小为 12 * 4k + 1024 * 4k = 48k + 4m。数据索引表内,0 表示一个无效的索引,inode 里 blocks 表示该文件或者目录占用的磁盘的 block 的个数。indiret 为 0 时,表示不使用一级索引块。(因为 block 0 用来保存 super block,它不可能被其他任何文件或目录使用,所以这么设计也是合理的)。

对于普通文件,索引值指向的 block 中保存的是文件中的数据。而对于目录,索引值指向的数据保存的是目录下所有的文件名以及对应的索引节点所在的索引块(磁盘块)所形成的数组。数据结构如下:

/* file entry (on disk) */
struct sfs_disk_entry {
    uint32_t ino;                                   索引节点所占数据块索引值
    char name[SFS_MAX_FNAME_LEN + 1];               文件名
};

操作系统中,每个文件系统下的 inode 都应该分配唯一的 inode 编号。SFS 下,为了实现的简便(偷懒),每个 inode 直接用他所在的磁盘 block 的编号作为 inode 编号。比如,root block 的 inode 编号为 1;每个 sfs_disk_entry 数据结构中,name 表示目录下文件或文件夹的名称,ino 表示磁盘 block 编号,通过读取该 block 的数据,能够得到相应的文件或文件夹的 inode。ino 为0时,表示一个无效的 entry。

此外,和 inode 相似,每个 sfs_dirent_entry 也占用一个 block。

内存中的索引节点

/* inode for sfs */
struct sfs_inode {
    struct sfs_disk_inode *din;                     /* on-disk inode */
    uint32_t ino;                                   /* inode number */
    uint32_t flags;                                 /* inode flags */
    bool dirty;                                     /* true if inode modified */
    int reclaim_count;                              /* kill inode if it hits zero */
    semaphore_t sem;                                /* semaphore for din */
    list_entry_t inode_link;                        /* entry for linked-list in sfs_fs */
    list_entry_t hash_link;                         /* entry for hash linked-list in sfs_fs */
};

可以看到SFS中的内存inode包含了SFS的硬盘inode信息,而且还增加了其他一些信息,这属于是便于进行是判断否改写、互斥操作、回收和快速地定位等作用。需要注意,一个内存inode是在打开一个文件后才创建的,如果关机则相关信息都会消失。而硬盘inode的内容是保存在硬盘中的,只是在进程需要时才被读入到内存中,用于访问文件或目录的具体内容数据

为了方便实现上面提到的多级数据的访问以及目录中 entry 的操作,对 inode SFS实现了一些辅助的函数:

  1. sfs_bmap_load_nolock:将对应 sfs_inode 的第 index 个索引指向的 block 的索引值取出存到相应的指针指向的单元(ino_store)。该函数只接受 index <= inode->blocks 的参数。当 index == inode->blocks 时,该函数理解为需要为 inode 增长一个 block。并标记 inode 为 dirty(所有对 inode 数据的修改都要做这样的操作,这样,当 inode 不再使用的时候,sfs 能够保证 inode 数据能够被写回到磁盘)。sfs_bmap_load_nolock 调用的 sfs_bmap_get_nolock 来完成相应的操作,阅读 sfs_bmap_get_nolock,了解他是如何工作的。(sfs_bmap_get_nolock 只由 sfs_bmap_load_nolock 调用)
  2. sfs_bmap_truncate_nolock:将多级数据索引表的最后一个 entry 释放掉。他可以认为是 sfs_bmap_load_nolock 中,index == inode->blocks 的逆操作。当一个文件或目录被删除时,sfs 会循环调用该函数直到 inode->blocks 减为 0,释放所有的数据页。函数通过 sfs_bmap_free_nolock 来实现,他应该是 sfs_bmap_get_nolock 的逆操作。和 sfs_bmap_get_nolock 一样,调用 sfs_bmap_free_nolock 也要格外小心。
  3. sfs_dirent_read_nolock:将目录的第 slot 个 entry 读取到指定的内存空间。他通过上面提到的函数来完成。
  4. sfs_dirent_search_nolock:是常用的查找函数。他在目录下查找 name,并且返回相应的搜索结果(文件或文件夹)的 inode 的编号(也是磁盘编号),和相应的 entry 在该目录的 index 编号以及目录下的数据页是否有空闲的 entry。(SFS 实现里文件的数据页是连续的,不存在任何空洞;而对于目录,数据页不是连续的,当某个 entry 删除的时候,SFS 通过设置 entry->ino 为0将该 entry 所在的 block 标记为 free,在需要添加新 entry 的时候,SFS 优先使用这些 free 的 entry,其次才会去在数据页尾追加新的 entry。

注意,这些后缀为 nolock 的函数,只能在已经获得相应 inode 的semaphore才能调用。

Inode的文件操作函数

static const struct inode_ops sfs_node_fileops = {
    .vop_magic                      = VOP_MAGIC,
    .vop_open                       = sfs_openfile,
    .vop_close                      = sfs_close,
    .vop_read                       = sfs_read,
    .vop_write                      = sfs_write,
    ……
};

上述sfs_openfile、sfs_close、sfs_read和sfs_write分别对应用户进程发出的open、close、read、write操作。其中sfs_openfile不用做什么事;sfs_close需要把对文件的修改内容写回到硬盘上,这样确保硬盘上的文件内容数据是最新的;sfs_read和sfs_write函数都调用了一个函数sfs_io,并最终通过访问硬盘驱动来完成对文件内容数据的读写。

Inode的目录操作函数

static const struct inode_ops sfs_node_dirops = {
    .vop_magic                      = VOP_MAGIC,
    .vop_open                       = sfs_opendir,
    .vop_close                      = sfs_close,
    .vop_getdirentry                = sfs_getdirentry,
    .vop_lookup                     = sfs_lookup,                           
    ……
};

对于目录操作而言,由于目录也是一种文件,所以sfs_opendir、sys_close对应户进程发出的open、close函数。相对于sfs_open,sfs_opendir只是完成一些open函数传递的参数判断,没做其他更多的事情。目录的close操作与文件的close操作完全一致。由于目录的内容数据与文件的内容数据不同,所以读出目录的内容数据的函数是sfs_getdirentry,其主要工作是获取目录下的文件inode信息。

设备层文件 IO 层

在本实验中,为了统一地访问设备,我们可以把一个设备看成一个文件,通过访问文件的接口来访问设备。目前实现了stdin设备文件文件、stdout设备文件、disk0设备。stdin设备就是串口,stdout设备就是CONSOLE(串口),而disk0设备是承载SFS文件系统的磁盘设备。下面我们逐一分析ucore是如何让用户把设备看成文件来访问。

关键数据结构

为了表示一个设备,需要有对应的数据结构,ucore为此定义了struct device,其描述如下:

struct device {
    size_t d_blocks;    //设备占用的数据块个数            
    size_t d_blocksize;  //数据块的大小
    int (*d_open)(struct device *dev, uint32_t open_flags);  //打开设备的函数指针
    int (*d_close)(struct device *dev); //关闭设备的函数指针
    int (*d_io)(struct device *dev, struct iobuf *iob, bool write); //读写设备的函数指针
    int (*d_ioctl)(struct device *dev, int op, void *data); //用ioctl方式控制设备的函数指针
};

这个数据结构能够支持对块设备(比如磁盘)、字符设备(比如串口)的表示,完成对设备的基本操作。ucore虚拟文件系统为了把这些设备链接在一起,还定义了一个设备链表,即双向链表vdev_list,这样通过访问此链表,可以找到ucore能够访问的所有设备文件。

但这个设备描述没有与文件系统以及表示一个文件的inode数据结构建立关系,为此,还需要另外一个数据结构把device和inode联通起来,这就是vfs_dev_t数据结构:

// device info entry in vdev_list 
typedef struct {
    const char *devname;
    struct inode *devnode;
    struct fs *fs;
    bool mountable;
    list_entry_t vdev_link;
} vfs_dev_t;

利用vfs_dev_t数据结构,就可以让文件系统通过一个链接vfs_dev_t结构的双向链表找到device对应的inode数据结构,一个inode节点的成员变量in_type的值是0x1234,则此 inode的成员变量in_info将成为一个device结构。这样inode就和一个设备建立了联系,这个inode就是一个设备文件。

stdout设备文件

初始化

既然stdout设备是设备文件系统的文件,自然有自己的inode结构。在系统初始化时,即只需如下处理过程

kern_init-->fs_init-->dev_init-->dev_init_stdout --> dev_create_inode
                 --> stdout_device_init
                 --> vfs_add_dev

在dev_init_stdout中完成了对stdout设备文件的初始化。即首先创建了一个inode,然后通过stdout_device_init完成对inode中的成员变量inode->__device_info进行初始:

这里的stdout设备文件实际上就是指的console外设(它其实是串口、并口和CGA的组合型外设)。这个设备文件是一个只写设备,如果读这个设备,就会出错。接下来我们看看stdout设备的相关处理过程。

初始化

stdout设备文件的初始化过程主要由stdout_device_init完成,其具体实现如下:

static void
stdout_device_init(struct device *dev) {
    dev->d_blocks = 0;
    dev->d_blocksize = 1;
    dev->d_open = stdout_open;
    dev->d_close = stdout_close;
    dev->d_io = stdout_io;
    dev->d_ioctl = stdout_ioctl;
}

可以看到,stdout_open函数完成设备文件打开工作,如果发现用户进程调用open函数的参数flags不是只写(O_WRONLY),则会报错。

访问操作实现

stdout_io函数完成设备的写操作工作,具体实现如下:

static int
stdout_io(struct device *dev, struct iobuf *iob, bool write) {
    if (write) {
        char *data = iob->io_base;
        for (; iob->io_resid != 0; iob->io_resid --) {
            cputchar(*data ++);
        }
        return 0;
    }
    return -E_INVAL;
}

可以看到,要写的数据放在iob->io_base所指的内存区域,一直写到iob->io_resid的值为0为止。每次写操作都是通过cputchar来完成的,此函数最终将通过console外设驱动来完成把数据输出到串口、并口和CGA显示器上过程。另外,也可以注意到,如果用户想执行读操作,则stdout_io函数直接返回错误值-E_INVAL。

stdin 设备文件

这里的stdin设备文件实际上就是指的串口。这个设备文件是一个只读设备,如果写这个设备,就会出错。接下来我们看看stdin设备的相关处理过程。

初始化

stdin设备文件的初始化过程主要由stdin_device_init完成了主要的初始化工作,具体实现如下:

static void
stdin_device_init(struct device *dev) {
    dev->d_blocks = 0;
    dev->d_blocksize = 1;
    dev->d_open = stdin_open;
    dev->d_close = stdin_close;
    dev->d_io = stdin_io;
    dev->d_ioctl = stdin_ioctl;

    p_rpos = p_wpos = 0;
    wait_queue_init(wait_queue);
}

相对于stdout的初始化过程,stdin的初始化相对复杂一些,多了一个stdin_buffer缓冲区,描述缓冲区读写位置的变量p_rpos、p_wpos以及用于等待缓冲区的等待队列wait_queue。在stdin_device_init函数的初始化中,也完成了对p_rpos、p_wpos和wait_queue的初始化。

访问操作实现

stdin_io函数负责完成设备的读操作工作,具体实现如下:

static int
stdin_io(struct device *dev, struct iobuf *iob, bool write) {
    if (!write) {
        int ret;
        if ((ret = dev_stdin_read(iob->io_base, iob->io_resid)) > 0) {
            iob->io_resid -= ret;
        }
        return ret;
    }
    return -E_INVAL;
}

可以看到,如果是写操作,则stdin_io函数直接报错返回。所以这也进一步说明了此设备文件是只读文件。如果此读操作,则此函数进一步调用dev_stdin_read函数完成对键盘设备的读入操作。dev_stdin_read函数的实现相对复杂一些,主要的流程如下:

static int
dev_stdin_read(char *buf, size_t len) {
    int ret = 0;
    bool intr_flag;
    local_intr_save(intr_flag);
    {
        for (; ret < len; ret ++, p_rpos ++) {
        try_again:
            if (p_rpos < p_wpos) {
                *buf ++ = stdin_buffer[p_rpos % stdin_BUFSIZE];
            }
            else {
                wait_t __wait, *wait = &__wait;
                wait_current_set(wait_queue, wait, WT_KBD);
                local_intr_restore(intr_flag);

                schedule();

                local_intr_save(intr_flag);
                wait_current_del(wait_queue, wait);
                if (wait->wakeup_flags == WT_KBD) {
                    goto try_again;
                }
                break;
            }
        }
    }
    local_intr_restore(intr_flag);
    return ret;
}

在上述函数中可以看出,如果p_rpos < p_wpos,则表示有键盘输入的新字符在stdin_buffer中,于是就从stdin_buffer中取出新字符放到iobuf指向的缓冲区中;如果p_rpos >=p_wpos,则表明没有新字符,这样调用read用户态库函数的用户进程就需要采用等待队列的睡眠操作进入睡眠状态,等待键盘输入字符的产生。

键盘输入字符后,如何唤醒等待键盘输入的用户进程呢?回顾lab1中的外设中断处理,可以了解到,当用户在QEMU控制台输入字符时,会产生串口中断,在trap_dispatch函数中,当识别出中断是串口中断时,会调用dev_stdin_write函数,来把字符写入到stdin_buffer中,且会通过等待队列的唤醒操作唤醒正在等待键盘输入的用户进程。

实验执行流程概述

与之前相比,实验四增加了文件系统,并因此实现了通过文件系统来加载可执行文件到内存中运行的功能,导致对进程管理相关的实现比较大的调整。我们来简单看看文件系统是如何初始化并能在ucore的管理下正常工作的。

首先看看kern_init函数,可以发现与之前相比增加了对fs_init函数的调用。fs_init函数就是文件系统初始化的总控函数,它进一步调用了虚拟文件系统初始化函数vfs_init,与文件相关的设备初始化函数dev_init和Simple FS文件系统的初始化函数sfs_init。这三个初始化函数联合在一起,协同完成了整个虚拟文件系统、SFS文件系统和文件系统对应的设备(串口、磁盘)的初始化工作。其函数调用关系图如下所示:

文件系统初始化调用关系图

参考上图,并结合源码分析,可大致了解到文件系统的整个初始化流程。vfs_init主要建立了一个device list双向链表vdev_list,为后续具体设备(串口、磁盘)以文件的形式呈现建立查找访问通道。dev_init函数通过进一步调用disk0/stdin/stdout_device_init完成对具体设备的初始化,把它们抽象成一个设备文件,并建立对应的inode数据结构,最后把它们链入到vdev_list中。这样通过虚拟文件系统就可以方便地以文件的形式访问这些设备了。sfs_init是完成对Simple FS的初始化工作,并把此实例文件系统挂在虚拟文件系统中,从而让ucore的其他部分能够通过访问虚拟文件系统的接口来进一步访问到SFS实例文件系统。

文件操作实现

打开文件

有了上述分析后,我们可以看看如果一个用户进程打开文件会做哪些事情?首先假定用户进程需要打开的文件已经存在在硬盘上。以user/sfs_filetest1.c为例,首先用户进程会调用在main函数中的如下语句:

int fd1 = safe_open("sfs\_filetest1", O_RDONLY);

从字面上可以看出,如果ucore能够正常查找到这个文件,就会返回一个代表文件的文件描述符fd1,这样在接下来的读写文件过程中,就直接用这样fd1来代表就可以了。那这个打开文件的过程是如何一步一步实现的呢?

通用文件访问接口层的处理流程

首先进入通用文件访问接口层的处理流程,即进一步调用如下用户态函数: open->sys_open->syscall,从而引起系统调用进入到内核态。到了内核态后,通过中断处理例程,会调用到sys_open内核函数,并进一步调用sysfile_open内核函数。到了这里,需要把位于用户空间的字符串"sfs_filetest1"拷贝到内核空间中的字符串path中,并进入到文件系统抽象层的处理流程完成进一步的打开文件操作中。

文件系统抽象层的处理流程

  1. 分配一个空闲的file数据结构变量file在文件系统抽象层的处理中,首先调用的是file_open函数,它要给这个即将打开的文件分配一个file数据结构的变量,这个变量其实是当前进程的打开文件数组current->fs_struct->filemap[]中的一个空闲元素(即还没用于一个打开的文件),而这个元素的索引值就是最终要返回到用户进程并赋值给变量fd1。到了这一步还仅仅是给当前用户进程分配了一个file数据结构的变量,还没有找到对应的文件索引节点。

为此需要进一步调用vfs_open函数来找到path指出的文件所对应的基于inode数据结构的VFS索引节点node。vfs_open函数需要完成两件事情:通过vfs_lookup找到path对应文件的inode;调用vop_open函数打开文件。

  1. 找到文件设备的根目录“/”的索引节点需要注意,这里的vfs_lookup函数是一个针对目录的操作函数,它会调用vop_lookup函数来找到SFS文件系统中的“/”目录下的“sfs_filetest1”文件。为此,vfs_lookup函数首先调用get_device函数,并进一步调用vfs_get_bootfs函数(其实调用了)来找到根目录“/”对应的inode。这个inode就是位于vfs.c中的inode变量bootfs_node。这个变量在init_main函数(位于kern/process/proc.c)执行时获得了赋值。
  2. 通过调用vop_lookup函数来查找到根目录“/”下对应文件sfs_filetest1的索引节点,,如果找到就返回此索引节点。
  3. 把file和node建立联系。完成第3步后,将返回到file_open函数中,通过执行语句“file->node=node;”,就把当前进程的current->fs_struct->filemap[fd](即file所指变量)的成员变量node指针指向了代表sfs_filetest1文件的索引节点inode。这时返回fd。经过重重回退,通过系统调用返回,用户态的syscall->sys_open->open->safe_open等用户函数的层层函数返回,最终把把fd赋值给fd1。自此完成了打开文件操作。但这里我们还没有分析第2和第3步是如何进一步调用SFS文件系统提供的函数找位于SFS文件系统上的sfs_filetest1文件所对应的sfs磁盘inode的过程。下面需要进一步对此进行分析。

SFS文件系统层的处理流程

这里需要分析文件系统抽象层中没有彻底分析的vop_lookup函数到底做了啥。下面我们来看看。在sfs_inode.c中的sfs_node_dirops变量定义了“.vop_lookup = sfs_lookup”,所以我们重点分析sfs_lookup的实现。注意:在lab4中,为简化代码,sfs_lookup函数中并没有实现能够对多级目录进行查找的控制逻辑(在ucore_plus中有实现)。

sfs_lookup有三个参数:node,path,node_store。其中node是根目录“/”所对应的inode节点;path是文件sfs_filetest1的绝对路径/sfs_filetest1,而node_store是经过查找获得的sfs_filetest1所对应的inode节点。

sfs_lookup函数以“/”为分割符,从左至右逐一分解path获得各个子目录和最终文件对应的inode节点。在本例中是调用sfs_lookup_once查找以根目录下的文件sfs_filetest1所对应的inode节点。当无法分解path后,就意味着找到了sfs_filetest1对应的inode节点,就可顺利返回了。

当然这里讲得还比较简单,sfs_lookup_once将调用sfs_dirent_search_nolock函数来查找与路径名匹配的目录项,如果找到目录项,则根据目录项中记录的inode所处的数据块索引值找到路径名对应的SFS磁盘inode,并读入SFS磁盘inode对的内容,创建SFS内存inode。

读文件

读文件其实就是读出目录中的目录项,首先假定文件在磁盘上且已经打开。用户进程有如下语句:

read(fd, data, len);

即读取fd对应文件,读取长度为len,存入data中。下面来分析一下读文件的实现。

通用文件访问接口层的处理流程

先进入通用文件访问接口层的处理流程,即进一步调用如下用户态函数:read->sys_read->syscall,从而引起系统调用进入到内核态。到了内核态以后,通过中断处理例程,会调用到sys_read内核函数,并进一步调用sysfile_read内核函数,进入到文件系统抽象层处理流程完成进一步读文件的操作。

文件系统抽象层的处理流程

\1) 检查错误,即检查读取长度是否为0和文件是否可读。

\2) 分配buffer空间,即调用kmalloc函数分配4096字节的buffer空间。

\3) 读文件过程

[1] 实际读文件

循环读取文件,每次读取buffer大小。每次循环中,先检查剩余部分大小,若其小于4096字节,则只读取剩余部分的大小。然后调用file_read函数(详细分析见后)将文件内容读取到buffer中,alen为实际大小。调用copy_to_user函数将读到的内容拷贝到用户的内存空间中,调整各变量以进行下一次循环读取,直至指定长度读取完成。最后函数调用层层返回至用户程序,用户程序收到了读到的文件内容。

[2] file_read函数

这个函数是读文件的核心函数。函数有4个参数,fd是文件描述符,base是缓存的基地址,len是要读取的长度,copied_store存放实际读取的长度。函数首先调用fd2file函数找到对应的file结构,并检查是否可读。调用filemap_acquire函数使打开这个文件的计数加1。调用vop_read函数将文件内容读到iob中(详细分析见后)。调整文件指针偏移量pos的值,使其向后移动实际读到的字节数iobuf_used(iob)。最后调用filemap_release函数使打开这个文件的计数减1,若打开计数为0,则释放file。

SFS文件系统层的处理流程

vop_read函数实际上是对sfs_read的包装。在sfs_inode.c中sfs_node_fileops变量定义了.vop_read = sfs_read,所以下面来分析sfs_read函数的实现。

sfs_read函数调用sfs_io函数。它有三个参数,node是对应文件的inode,iob是缓存,write表示是读还是写的布尔值(0表示读,1表示写),这里是0。函数先找到inode对应sfs和sin,然后调用sfs_io_nolock函数进行读取文件操作,最后调用iobuf_skip函数调整iobuf的指针。

在sfs_io_nolock函数中,先计算一些辅助变量,并处理一些特殊情况(比如越界),然后有sfs_buf_op = sfs_rbuf,sfs_block_op = sfs_rblock,设置读取的函数操作。接着进行实际操作,先处理起始的没有对齐到块的部分,再以块为单位循环处理中间的部分,最后处理末尾剩余的部分。每部分中都调用sfs_bmap_load_nolock函数得到blkno对应的inode编号,并调用sfs_rbuf或sfs_rblock函数读取数据(中间部分调用sfs_rblock,起始和末尾部分调用sfs_rbuf),调整相关变量。完成后如果offset + alen > din->fileinfo.size(写文件时会出现这种情况,读文件时不会出现这种情况,alen为实际读写的长度),则调整文件大小为offset + alen并设置dirty变量。

sfs_bmap_load_nolock函数将对应sfs_inode的第index个索引指向的block的索引值取出存到相应的指针指向的单元(ino_store)。它调用sfs_bmap_get_nolock来完成相应的操作。sfs_rbuf和sfs_rblock函数最终都调用sfs_rwblock_nolock函数完成操作,而sfs_rwblock_nolock函数调用dop_io->disk0_io->disk0_read_blks_nolock->ide_read_secs完成对磁盘的操作。


Last update: 2024年5月14日