动手写一个简单的文件系统.md

原帖地址:
https://zhangshurong.github.io/2018/03/25/%E5%8A%A8%E6%89%8B%E5%86%99%E4%B8%80%E4%B8%AA%E7%AE%80%E5%8D%95%E7%9A%84%E6%96%87%E4%BB%B6%E7%B3%BB%E7%BB%9F/
项目地址:https://github.com/ZhangShurong/HUST_OS_fs_experiment


title: 动手写一个简单的文件系统
date: 2018-03-25 16:53:50
categories: "Linux"
tags:

  • Linux
  • Kernel
  • FileSystem

总体设计

本文件系统的磁盘结构参考minix的文件系统实现。但是自举块(或称引导块)中没有数据。切不采用二级或者多级索引,其结构如下:

|Dummy Block|Super Block|IMap|BMap|Inode Table|Data blocks|

其中每个块的大小定义为4096bytes,每个Inode含有10个块所以单个文件最大为40KB,支持的最小磁盘大小为24K。以下详细阐述文件系统中所需要的三个基本数据结构。

1. 超级块结构:

struct HUST_fs_super_block {
    uint64_t version;
    uint64_t magic;
    uint64_t block_size;
    uint64_t inodes_count;
    uint64_t free_blocks;
    uint64_t blocks_count;
    uint64_t bmap_block;
    uint64_t imap_block;
    uint64_t inode_table_block;
    uint64_t data_block_number;
    char padding[4016];
};

超级块中的padding数组,是为了使超级块的大小为4096bytes,以简化后期的工作;“Magic”为1314522;indoe_count记录文件系统所支持的inode个数,这个值在格式化时就已经计算并写入超级块了。Bmap_block记录着bmap开始的数据块索引,imap_block,inode_table_block和data_block_number同理,记录索引是为了简化文件块的定位操作。

2. HUST_inode结构

struct HUST_inode {  
    mode_t mode; //sizeof(mode_t) is 4  
    uint64_t inode_no;  
    uint64_t blocks;  
    uint64_t block[HUST_N_BLOCKS];  
    union {  
        uint64_t file_size;  
        uint64_t dir_children_count;  
    };  
    int32_t i_uid;   
    int32_t i_gid;  
    int32_t i_nlink;  
    int64_t i_atime;  
    int64_t i_mtime;  
    int64_t i_ctime;  
    char padding[112];  
};  

HUST_inode对应着磁盘上的inode结构,在后文会描述它是如何转换为VFS中的inode的。在上述结构体中,mode代表该inode是文件还是目录,blocks代表该inode的大小(所占块的数目),i_uid和i_gid用于后面的多用户管理。Padding数组是为了让HUST_inode结构体能够被4096整除;宏HUST_N_BLOCK被定义为10,意味着每个文件(目录)最大的大小为10个块;block数组存储着每个块的索引,用于定位文件。

3. 文件系统的目录结构

struct HUST_dir_record {  
    char filename[HUST_FILENAME_MAX_LEN];  
    uint64_t inode_no;  
};

文件记录是为了储存目录项,其中HUST_FILENAME_MAX_LEN定义为256也就是说,文件名最大长度256。
编写文件系统除了设计文件系统的磁盘结构,定义文件系统支持的操作也是十分重要的,这个直接影响了文件系统的功能。本文件系统支持基本的文件的增删改查,多用户等功能,但是不支持文件的移动,软硬链接等操作。

mkfs的实现

要使用这个文件系统,必须首先创建一个符合磁盘布局的映像文件,所以我们需要实现一个格式化程序,这个程序按照惯例叫做mkfs。本节详细描述mkfs的实现。
mkfs的作用是将一个文件改写成对应于我们文件系统的结构,其主要功能点为写入超级块,写入imap,bmap,写入inode table,以及创建一个根目录和测试文件。
超级块包含了文件系统的基本信息,其信息在上文中有详细描述。写入超级块信息,需要计算整个磁盘的大小,然后计算imap,bmap以及inode table的大小,这样才能确定各个区域在磁盘中的位置。这些工作都是在init_disk这个函数中完成的。基本逻辑为读取需要格式化的文件大小,计算出整个磁盘中的块的个数,简单的将块的个数与inode的个数等同起来;然后通过块数以及inode个数计算imap和bmap的大小。其中bmap的大小如下(imap大小计算公式与bmap一致):
$$
bmapsize = blockcount/ HUST_BLOCKSIZE * 8
$$
关键代码如下:

static int init_disk(int fd, const char* path)  
{  
//获取基本信息  
//... ...  
    //计算bmap  
    bmap_size = super_block.blocks_count/(8*HUST_BLOCKSIZE);  
    super_block.bmap_block = RESERVE_BLOCKS;  
  
    if (super_block.blocks_count%(8*HUST_BLOCKSIZE) != 0) {  
        bmap_size += 1;  
    }  
    bmap = (uint8_t *)malloc(bmap_size*HUST_BLOCKSIZE);  
    memset(bmap,0,bmap_size*HUST_BLOCKSIZE);  
  
    //计算imap  
    imap_size = super_block.inodes_count/(8*HUST_BLOCKSIZE);  
    super_block.imap_block = super_block.bmap_block + bmap_size;  
 
    if(super_block.inodes_count%(8*HUST_BLOCKSIZE) != 0) {  
        imap_size += 1;  
    }  
    imap = (uint8_t *)malloc(imap_size*HUST_BLOCKSIZE);  
    memset(imap,0,imap_size*HUST_BLOCKSIZE);  
  
    //计算inode_table  
    inode_table_size = super_block.inodes_count/(HUST_BLOCKSIZE/HUST_INODE_SIZE);  
    super_block.inode_table_block = super_block.imap_block + imap_size;  
    super_block.data_block_number = RESERVE_BLOCKS + bmap_size + imap_size + inode_table_size;  
    super_block.free_blocks = super_block.blocks_count - super_block.data_block_number - 1;  

    //设置bmap以及imap  
//... ...  
}

其中,imap和bmap为uint8_t的全局数组。
计算完基本信息之后,我们需要将其写入文件并创建根目录和测试文件。文件创建的基本步骤如下:

  1. 检测(获取)磁盘(文件)大小,确认是否有足够的空间
  2. 找的空闲的inode和block,并标记imap和bmap。
  3. 生成相应的数据,并写入对应的块中。对于根目录来讲,写入的数据为三个目录项,目录项的内容为文件(目录)名以及对应的inode编号。第一个目录项为当前目录和对应的inode编号0,第二个目录项为上一级目录和对应的inode编号0,第三个目录项为欢迎文件,内容为文件名“file”和对应的inode编号1。
  4. 设置对应的inode信息,如是文件还是目录(mode信息),创建时间修改时间(i_ctime和i_mtime),用户id和组id信息(i_uid和i_gid)等。
  5. 更新超级块信息。

在我们的文件系统写完之前,我们可以新建一个文件来测试我们的mkfs是否能正常运行,通过16进制编辑器来查看是否功能正常。具体步骤如下:

  1. 运行下列命令创建文件。
dd bs=4096 count=100 if=/dev/zero of=image 
  1. 编译mkfs.c
 gcc mkfs.c -o mkfs 
  1. 格式化image文件
./mkfs ./image
  1. 通过hexdumo来查看文件的结构,结果如下图。通过检查,我们发现,image文件结构写入正确无误。

文件系统的实现

一个通常意义上的文件系统驱动可以单独被编译成模块动态加载,也可以被直接编译到内核中,为了调试的方便,本文中的文件系统采用动态加载的方式实现。实现一个文件系统必须遵照内核的一些“规则”,以下我将以递进的顺序阐述文件系统的实现过程。

一、 文件系统的加载与卸载

首先为了能够成功加载文件系统,文件系统需要提供文件系统的名字,超级块的加载和删除方法。这些东西反应在file_system,_type中。

struct file_system_type HUST_fs_type = {  
    .owner = THIS_MODULE,  
    .name = "HUST_fs",  
    .mount = HUST_fs_mount,  
    .kill_sb = HUST_fs_kill_superblock, /* unmount */  
};  

文件系统作为一种块设备驱动,自然也需要实现module_init以及mocule_exit。代码如下:

/* Called when the module is loaded. */  
int HUST_fs_init(void)  
{  
    int ret;  
    ret = register_filesystem(&HUST_fs_type);  
    if (ret == 0)  
        printk(KERN_INFO "Sucessfully registered HUST_fs\n");  
    else  
        printk(KERN_ERR "Failed to register HUST_fs. Error: [%d]\n", ret);  
    return ret;  
}  
  
/* Called when the module is unloaded. */  
void HUST_fs_exit(void)  

    int ret;  
    ret = unregister_filesystem(&HUST_fs_type);  
    if (ret == 0)  
        printk(KERN_INFO "Sucessfully unregistered HUST_fs\n");  
    else  
        printk(KERN_ERR "Failed to unregister HUST_fs. Error: [%d]\n", ret);  
}  
module_init(HUST_fs_init);  
module_exit(HUST_fs_exit);  
  
MODULE_LICENSE("MIT");  
MODULE_AUTHOR("cv");  

我们可以看到,设备驱动加载的时候,驱动向内核注册了文件系统,而驱动卸载的时候,文件系统的信息也被删除。文件系统加载时调用的函数为HUST_fs_mount,实际上,这个函数向内核注册了一个回调:

int HUST_fs_fill_super(struct super_block *sb, void *data, int silent)  

这个函数是用来与VFS交互从而生成VFS超级块的。在HUST fs中,超级块在磁盘的第二个4096字节上,即块号为1。这个函数执行时会从磁盘中读取信息,填充到VFS提供的超级块结构体中,下列为部分关键代码。

int HUST_fs_fill_super(struct super_block *sb, void *data, int silent)  {  
    struct buffer_head *bh;  
    bh = sb_bread(sb, 1);  
    struct HUST_fs_super_block *sb_disk;  
    sb_disk = (struct HUST_fs_super_block *)bh->b_data;  
    struct inode *root_inode;  
    if (sb_disk->block_size != 4096) {  
        printk(KERN_ERR "HUST_fs expects a blocksize of %d\n", 4096);  
        ret = -EFAULT;  
        goto release;  
   }  
   //fill vfs super block  
    sb->s_magic = sb_disk->magic;  
    sb->s_fs_info = sb_disk;  
    sb->s_maxbytes = HUST_BLOCKSIZE * HUST_N_BLOCKS; /* Max file size */  
    sb->s_op = &HUST_fs_super_ops;  
}  

从上述代码可以看出,我们用sb_read来读取磁盘上的内容,然后填充super_block结构体。值得注意的是,有关超级块的操作函数即superblock_operations也是在此处赋值的。由于super_block* sb在文件系统卸载之前是一直存在于内存中的,所以我们可以使用s_fs_info来存储原始的超级块信息,避免后期交互时 再次读取磁盘。
文件系统卸载的时候超级块信息需要被删除,所以HUST_fs_kill_superblock的作用时释放该超级块,通知VFS该挂载点已经卸载。
实现基本函数后,可以对文件系统进行挂载操作,挂载操作的脚本内容如下:

sudo umount ./test  
sudo rmmod HUST_fs  
dd bs=4096 count=100 if=/dev/zero of=image  
./mkfs image  
insmod HUST_fs.ko  
mount -o loop -t HUST_fs image ./test  
dmesg  

上述脚本,将项目下的test文件夹作为文件系统的挂载点,并在挂载之后答应出了内核调试目录。成功执行该脚本的截图如下:

我们可以看到test目录已经挂载成功而且内核调试信息显示文件系统挂载成功。

二、 ls命令的实现

加载文件系统之后第一个要实现的功能是读取文件系统中的数据,所以选择实现文件夹读取操作,这一操作在2.x内核中是.readdir函数指针,在最新版本中是,.iterate函数指针。这个指针在保存在file_operation中,如下所示。

const struct file_operations HUST_fs_dir_ops = {  
    .owner = THIS_MODULE,  
    .iterate = HUST_fs_iterate,  
}; 

HUST_fs_iterate函数主要功能逻辑是读取inode的块数据,并且将块数据中的inode和文件名通过dir_emit函数传输到VFS层。以根目录为例,根目录的包含三个数据项,分别是父目录,当前目录和欢迎文件,所以该函数会执行以下三个语句

//参数分别表示上下文,文件/目录名,文件/目录名长度,inode号,文件类型  
dir_emit(ctx, ".", 1,0, DT_DIR);  
dir_emit(ctx, "..", 2,0, DT_DIR);  
dir_emit(ctx, "file", 4,1, DT_REG);

完成该函数后,在填充根目录inode时将HUST_fs_dir_ops指针赋值,即可在挂在文件系统后执行ls命令。

如上图所示,我们成功看到了欢迎文件。但是此时我们不能对文件进行任何操作,因为还没有实现其他的接口。

三、 磁盘管理相关逻辑的实现

这个磁盘管理的内涵包括向磁盘写入和从磁盘取出读取inode,更新inode信息,维护imap,bmap,inode table等操作。为了使磁盘上的内容有序的组合起来,磁盘空间的管理十分的重要,后续的文件读写操作都与此相关。
写入和删除inode的操作存放在super_operations这个结构体中。

const struct super_operations HUST_fs_super_ops = {  
    .evict_inode = HUST_evict_inode,  
    .write_inode = HUST_write_inode,  
};

HUST_fs_super_ops需要在填充超级块时赋值到super_block的s_ops字段中。HUST_write_inode函数的功能是将内存中的inode保存在磁盘上。关键代码如下。

int HUST_write_inode(struct inode *inode, struct writeback_control *wbc)  
{  
    struct buffer_head * bh;  
    struct HUST_inode * raw_inode = NULL;  
    HUST_fs_get_inode(inode->i_sb, inode->i_ino, raw_inode);  
    if (!raw_inode)  
        return -EFAULT;  
    raw_inode->mode = inode->i_mode;  
    raw_inode->i_uid = fs_high2lowuid(i_uid_read(inode));  
    raw_inode->i_gid = fs_high2lowgid(i_gid_read(inode));  
    raw_inode->i_nlink = inode->i_nlink;  
    raw_inode->file_size = inode->i_size;      
    raw_inode->i_atime = (inode->i_atime.tv_sec);  
    raw_inode->i_mtime = (inode->i_mtime.tv_sec);  
    raw_inode->i_ctime = (inode->i_ctime.tv_sec);  
    mark_buffer_dirty(bh);  
    brelse(bh);  
    return 0;  
}  

可以看到,该函数的将vfs inode中的相关信息存储到HUST_inode结构体中,然后写入磁盘。这个是单独的写入磁盘操作,事实上,当我们申请inode时,imap也是需要检查刷新的,需要把相应位置标记为1。同理,evict_inode函数的作用时删除inode,删除成功后,我们需要刷新imap的值,把相应位置标记为0。
设置和写入map的操作都在map.c中,以下以imap为例。对于imap来讲,申请inode的时候需要检查第一个空闲的inode编号,当inode被释放的时候也要及时清零对应的imap。与此相关的函数如下。

//从磁盘中读取数据并存在imap数组中  
int get_imap(struct super_block* sb, uint8_t* imap, ssize_t imap_size);  
//在vaddr数组中找到第一个为0的bit,这个函数用于定位空inode或者block  
int HUST_find_first_zero_bit(const void *vaddr, unsigned size);  
//将imap的某一位置0或者1,并保存在磁盘上  
int set_and_save_imap(struct super_block* sb, uint64_t inode_num, uint8_t value);  
//定义的位操作宏如下  
#define setbit(number,x) number |= 1UL << x  
#define clearbit(number, x) number &= ~(1UL << x)  

由于本文件系统并不是为了实际使用,所以上述的操作都没有考虑性能以及准确性问题。事实上,能够加上校验或者冗余备份是最好的。

四、读写文件内容

为了能够快速看到文件系统在正常工作,所以接下来需要实现文件的读写操作。文件读写操作按照一般处理,应该是实现在struct file_operations这个结构体中的。事实上,最开始我是实现在这个结构体中的read_iter函数指针中的。但是比较有趣的一点是,如果我们实现了struct address_space_operations结构体中的函数,那么struct file_operations结构体中的函数则可以交由VFS实现。代码如下:

const struct file_operations HUST_fs_file_ops = {  
    .owner = THIS_MODULE,  
    .llseek = generic_file_llseek,  
    .mmap = generic_file_mmap,  
    .fsync = generic_file_fsync,  
    .read_iter = generic_file_read_iter,  
    .write_iter = generic_file_write_iter,  
};  
const struct   address_space_operations HUST_fs_aops = {  
    .readpage = HUST_fs_readpage,  
    .writepage = HUST_fs_writepage,  
    .write_begin = HUST_fs_write_begin,  
    .write_end = generic_write_end,  
};

上述的generic开头的函数是不需要我们手动实现的。上述的address_space_operations操作其实是实现了页高速缓存的一些操作。页高速缓存是linux内核实现的一种主要磁盘缓存,它主要用来减少对磁盘的IO操作,具体地讲,是通过把磁盘中的数据缓存到物理内存中,把对磁盘的访问变为对物理内存的访问。这些接口一旦实现,那么对文件的操作就可以转移到内存中,这就是为什么可以使用generic开头的这些函数来代替手写。
HUST_fs_readpage, HUST_fs_writepage以及HUST_fs_write_begin都被注册回调到同一个函数HUST_fs_get_block。HUST_fs_get_block主要返回内核请求长度的数据。至于读写操作,内核调用__bwrite函数最终调用块设备驱动执行。因为在我没有采用二级或者多级索引,故而HUST_fs_get_block函数逻辑比较简单,部分代码如下:

int HUST_fs_get_block(struct inode *inode, sector_t block,  
              struct buffer_head *bh, int create)  
{  
    struct super_block *sb = inode->i_sb;  
    if (block > HUST_N_BLOCKS) 
        return -ENOSPC;  
    struct HUST_inode H_inode;  
    if (-1 == HUST_fs_get_inode(sb, inode->i_ino, &H_inode))  
        return -EFAULT;  
    if (H_inode.blocks == 0)  
        if(alloc_block_for_inode(sb, &H_inode, 1)) 
            return -EFAULT;  
    map_bh(bh, sb, H_inode.block[block]);  
    return 0;  
}

如上所示,该函数判断传入的block的大小,并将磁盘内容映射到bh中。后续的读写操作将有VFS帮我们完成。

五、inode操作

Inode操作涉及文件(夹)的创建删除,将HUST_inode映射到VFS中的inode等操作。具体实现的函数如下。

const struct inode_operations HUST_fs_inode_ops = {  
    .lookup = HUST_fs_lookup,  
    .mkdir = HUST_fs_mkdir,  
    .create = HUST_fs_create,  
    .unlink = HUST_fs_unlink,  
}; 

HUST_fs_lookup是其中比较复杂的一个函数,它负责将一个目录下的inode信息交由VFS管理。首先,HUST_fs_lookup读取文件夹的内容,然后遍历文件夹下面的HUST_inode,找到我们想要的HUST_inode,根据不同的文件属性,申请vfs_inode;并对不同的vfs_inode设置不同的操作。假设vfs_inode对应的是一个文件,那么就设置vfs_inode->mapping->a_ops,如果vfs_inode对应的是文件夹,那么就设置vfs_inode->f_ops = &HUST_fs_dir_ops;最后将vfs_inode注册到VFS中。这部分的关键代码如下:

struct dentry *HUST_fs_lookup(struct inode *parent_inode,  
                  struct dentry *child_dentry, unsigned int flags)  
{  
    struct super_block *sb = parent_inode->i_sb;  
    struct HUST_inode H_inode;  
//省略代码  
    for (i = 0; i < H_inode.dir_children_count; i++) {  
        if (strncmp  
            (child_dentry->d_name.name, dtptr[i].filename,  
             HUST_FILENAME_MAX_LEN) == 0){  
            inode = iget_locked(sb, dtptr[i].inode_no);  
            if (inode->i_state & I_NEW) {  
                inode_init_owner(inode, parent_inode, 0);  
                struct HUST_inode H_child_inode;  
                if (-1 == HUST_fs_get_inode(sb, dtptr[i].inode_no, &H_child_inode))  
                    return ERR_PTR(-EFAULT);  
                HUST_fs_convert_inode(&H_child_inode, inode);  
                inode->i_op = &HUST_fs_inode_ops;  
                if (S_ISDIR(H_child_inode.mode)) {  
                    inode->i_fop = &HUST_fs_dir_ops;  
                } else if (S_ISREG(H_child_inode.mode)) {  
                    inode->i_fop = &HUST_fs_file_ops;;  
                    inode->i_mapping->a_ops = &HUST_fs_aops;  
                }  
                inode->i_mode = H_child_inode.mode;  
                inode->i_size = H_child_inode.file_size;  
                insert_inode_hash(inode);  
                unlock_new_inode(inode);  
            }  
        }  
    }  
//省略代码  
} 

只有在这里注册了相关函数,系统调用才能正常执行。不然就会出现不支持的操作这种报错信息。
.create与.mkdir都是对应了inode的创建,只是inode的属性不能而已。.create创建普通文件而.mkdir创建文件夹。所以这两个函数的功能被函数HUST_fs_create_obj所处理。这个函数接受新建文件(夹)的请求,检查磁盘的大小,检查是否有空余的indoe,并且分配inode号,然后更新imap信息,最后更新超级块信息。由于该函数逻辑简单但是代码量比较大,故而不在此展示其具体实现。
在完成上述工作之后,我们的文件系统基本已经完成了,这个系统采用线性(区别于minixi二级索引用树来管理)的方式管理磁盘空间,支持基本的增删改查文件操作,支持文件权限,支持多用户。

你可能感兴趣的:(动手写一个简单的文件系统.md)