Linux文件系统原理笔记

操作系统 专栏收录该内容
10 篇文章 4 订阅

一、文件系统

1. 在文件系统中,每个文件都有一个名字,文件名就是一个普通的文本。当然文件名会经常冲突,不同用户取相同名字的情况还是会经常出现的。要想把很多的文件有序地组织起来,就需要让它们成为目录或者文件夹。这样,一个文件夹里可以包含文件夹,也可以包含文件,这样就形成了一种树形结构,可以将不同的用户放在不同的用户目录下,就可以一定程度上避免了命名的冲突问题。如下图所示:

不同用户的文件放在不同的目录下,虽然很多文件都叫“文件1”,只要在不同的目录下,就不会有问题。当然,Linux内核要在自己的内存里面维护一套数据结构,来保存哪些文件被哪些进程打开和使用

2. 当一个Linux系统插入了一块没有格式化的硬盘的时候,可以通过命令fdisk -l,查看格式化和没有格式化的分区,如下所示:

# fdisk -l


Disk /dev/vda: 21.5 GB, 21474836480 bytes, 41943040 sectors
Units = sectors of 1 * 512 = 512 bytes
Sector size (logical/physical): 512 bytes / 512 bytes
I/O size (minimum/optimal): 512 bytes / 512 bytes
Disk label type: dos
Disk identifier: 0x000a4c75


   Device Boot      Start         End      Blocks   Id  System
/dev/vda1   *        2048    41943006    20970479+  83  Linux


Disk /dev/vdc: 107.4 GB, 107374182400 bytes, 209715200 sectors
Units = sectors of 1 * 512 = 512 bytes
Sector size (logical/physical): 512 bytes / 512 bytes
I/O size (minimum/optimal): 512 bytes / 512 bytes

从上面的命令的输出结果可以看出,vda这块盘大小21.5G,是格式化了的,有一个分区/dev/vda1。vdc这块盘大小107.4G,是没有格式化的。可以通过命令mkfs.ext4进行格式化,如下所示:

mkfs.ext4 /dev/vdc

执行完这个命令后,vdc会建立一个分区,格式化为ext4文件系统。至于这个格式是如何组织的,后面会提到。当然,也可以选择不将整块盘格式化为一个分区,而是格式化为多个分区。下面的这个命令行可以启动一个交互式程序:

fdisk /dev/vdc

在这个交互式程序中,可以输入p来打印当前分了几个区。如果没有分过,那这个列表应该是空的。接下来,可以输入n新建一个分区。它会让选择创建主分区primary,还是扩展分区extended,一般都会选择主分区p。接下来,它会让输入分区号,如果原来没有分过区,应该从1开始,或直接回车使用默认值也行。接下来,可以一路选择默认值,直到让指定这个分区的大小,通过+sizeM或者+sizeK的方式,默认值是整块盘都用上,可以输入+5620M分配一个5G的分区。这个时候再输入p,就能看到新创建的分区了,最后输入w,将对分区的修改写入硬盘。

分区结束之后,可能会出现vdc1, vdc2等多个分区,这个时候可以mkfs.ext3 /dev/vdc1将第一个分区格式化为ext3,通过mkfs.ext4 /dev/vdc2将第二个分区格式化为ext4。格式化后的硬盘,需要挂在到某个目录下面,才能作为普通的文件系统进行访问,如下所示:

mount /dev/vdc1 /根目录/用户A目录/目录1

上面这个命令就是将这个文件系统挂在到“/ 根目录 / 用户 A 目录 / 目录 1”这个目录下面。一旦挂载过去,“/ 根目录 / 用户 A 目录 / 目录 1”这个目录下面原来的文件1和文件2就都看不到了,换成了vdc1这个硬盘里面的文件系统的根目录。有挂载就有卸载,卸载使用umount命令,如下所示:

umount /根目录/用户A目录/目录1

Linux里面一切都是文件,那从哪里看出是什么文件呢?要从ls -l的结果的第一位标识位看出来:

(1)-表示普通文件;

(2)d表示文件夹;

(3)c表示字符设备文件,后面会提到;

(4)b表示块设备文件,后面会提到;

(5)s表示套接字socket文件,在网络部分会提到;

(6)l表示符号链接,也即软链接,就是通过名字指向另外一个文件,例如下面的命令结果,instance这个文件就是指向了/var/lib/cloud/instances这个文件:

# ls -l
lrwxrwxrwx 1 root root   61 Dec 14 19:53 instance -> /var/lib/cloud/instances

3. 如何使用系统调用操作文件?先来看一个完整的例子,如下所示:

#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <fcntl.h>


int main(int argc, char *argv[])
{


  int fd = -1;
  int ret = 1;
  int buffer = 1024;
  int num = 0;


  if((fd=open("./test", O_RDWR|O_CREAT|O_TRUNC))==-1)
  {
    printf("Open Error\n");
    exit(1);
  }


  ret = write(fd, &buffer, sizeof(int));
  if( ret < 0)
  {
    printf("write Error\n");
    exit(1);
  }
  printf("write %d byte(s)\n",ret);


  lseek(fd, 0L, SEEK_SET);
  ret= read(fd, &num, sizeof(int));
  if(ret==-1)
  {
    printf("read Error\n");
    exit(1);
  }
  printf("read %d byte(s),the number is %d\n", ret, num);


  close(fd);


  return 0;
}

当使用系统调用open打开一个文件时,操作系统会创建一些数据结构来表示这个被打开的文件。为了能够找到这些数据结构,在进程中,代码会为这个打开的文件分配一个文件描述符fd(File Descriptor)。文件描述符就是用来区分一个进程打开的多个文件的。它的作用域就是当前进程,出了当前进程这个文件描述符就没有意义了。open返回的fd必须记录好,对这个文件的所有操作都要靠这个fd,包括最后关闭文件

在Open函数中,有一些参数:O_CREAT表示当文件不存在,创建一个新文件;O_RDWR表示以读写方式打开;O_TRUNC表示打开文件后,将文件的长度截断为0。接下来,write用于写入数据。第一个参数就是文件描述符,第二个参数表示要写入的数据存放位置,第三个参数表示希望写入的字节数,返回值表示成功写入到文件的字节数。

lseek用于重新定位读写的位置,第一个参数是文件描述符,第二个参数是希望重新定位的位置,第三个参数是 SEEK_SET,表示起始位置为文件头,第二个参数和第三个参数合起来表示将读写位置设置为从文件头开始0的位置,也即从头开始读写。read用于读取数据,第一个参数是文件描述符,第二个参数是读取来的数据存到指向的空间,第三个参数是希望读取的字节数,返回值表示成功读取的字节数。最终,close将关闭一个文件。

对命令行来说,通过ls可以得到文件的属性,使用代码怎么办呢?有下面三个函数,可以返回与打开的文件描述符相关的文件状态信息。这个信息将会写到类型为struct stat的buf结构中,如下所示:

int stat(const char *pathname, struct stat *statbuf);
int fstat(int fd, struct stat *statbuf);
int lstat(const char *pathname, struct stat *statbuf);


struct stat {
  dev_t     st_dev;         /* ID of device containing file */
  ino_t     st_ino;         /* Inode number */
  mode_t    st_mode;        /* File type and mode */
  nlink_t   st_nlink;       /* Number of hard links */
  uid_t     st_uid;         /* User ID of owner */
  gid_t     st_gid;         /* Group ID of owner */
  dev_t     st_rdev;        /* Device ID (if special file) */
  off_t     st_size;        /* Total size, in bytes */
  blksize_t st_blksize;     /* Block size for filesystem I/O */
  blkcnt_t  st_blocks;      /* Number of 512B blocks allocated */
  struct timespec st_atim;  /* Time of last access */
  struct timespec st_mtim;  /* Time of last modification */
  struct timespec st_ctim;  /* Time of last status change */
};

函数stat和lstat返回的是通过文件名查到的状态信息。这两个方法区别在于,stat没有处理符号链接(软链接)的能力。如果一个文件是符号链接,stat会直接返回它所指向的文件的属性,而lstat返回的就是这个符号链接的内容,fstat则是通过文件描述符获取文件对应的属性。接下来看,如何使用系统调用列出一个文件夹下面的文件以及文件的属性:

#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <fcntl.h>
#include <sys/types.h>
#include <sys/stat.h>
#include <dirent.h>


int main(int argc, char *argv[])
{
  struct stat sb;
  DIR *dirp;
  struct dirent *direntp;
  char filename[128];
  if ((dirp = opendir("/root")) == NULL) {
    printf("Open Directory Error%s\n");
    exit(1);
  }
  while ((direntp = readdir(dirp)) != NULL){
    sprintf(filename, "/root/%s", direntp->d_name);
    if (lstat(filename, &sb) == -1)
    {
      printf("lstat Error%s\n");
      exit(1);
    }


    printf("name : %s, mode : %d, size : %d, user id : %d\n", direntp->d_name, sb.st_mode, sb.st_size, sb.st_uid);


  }
  closedir(dirp);


  return 0
}

opendir函数打开一个目录名所对应的DIR目录流,并返回指向DIR目录流的指针,流定位在DIR目录流的第一个条目。readdir函数从DIR目录流中读取一个项目,返回的是一个指针,指向dirent结构体,且流自动指向下一个目录条目。如果已经到流的最后一个条目,则返回NULL。closedir()关闭参数dir所指的目录流。

4. 总结一下对于文件系统的主要功能,如下所示:

(1)在文件系统上,需要维护文件的严格的格式,要通过mkfs.ext4命令来格式化为严格的格式。

(2)每一个硬盘上保存的文件都要有一个索引,来维护这个文件上的数据块都保存在哪里。

(3)文件通过文件夹组织起来,可以方便用户使用。

(4)为了能够更快读取文件,内存里会分配一块空间作为缓存,让一些数据块放在缓存里面。

(5)在内核中,要有一整套的数据结构来表示打开的文件。

(6)在用户态,每个打开的文件都有一个文件描述符,可以通过各种文件相关的系统调用,操作这个文件描述符。

二、硬盘文件系统

5. 将文件系统的模式搬到硬盘上来看一看,如下所示:

机械硬盘就是上面这幅图左边的样子,中间圆的部分是磁盘的盘片,右边的图是抽象出来的图。每一层里分多个磁道,每个磁道分多个扇区,每个扇区是 512 个字节,文件系统就是安装在这样的硬盘之上。就像图书馆的书架都要分成大小相同的格子,硬盘也是一样的。硬盘分成相同大小的单元称为(Block)。一块的大小是扇区大小的整数倍,默认是4K,在格式化的时候,这个值是可以设定的。

一大块硬盘被分成了一个个小的块,用来存放文件的数据部分。这样如果想存放一个文件,就不用给它分配一块连续的空间了,可以分散成一个个小块进行存放。这样就灵活得多,也比较容易添加、删除和插入数据。但是这也带来一个新的问题,那就是文件的数据存放得太散,找起来就比较困难,是不是可以像图书馆那样,也设立一个索引区域,用来维护“某个文件分成几块、每一块在哪里“等等这些基本信息?另外,文件还有元数据部分,例如名字、权限等,这就需要一个结构inode来存放

inode的“i”是index的意思,就是“索引”。既然如此,每个文件都会对应一个inode;一个文件夹就是一个文件,也对应一个inode。至于inode里面有哪些信息,在内核中就有定义。可以看下面这个数据结构:

struct ext4_inode {
  __le16  i_mode;    /* File mode */
  __le16  i_uid;    /* Low 16 bits of Owner Uid */
  __le32  i_size_lo;  /* Size in bytes */
  __le32  i_atime;  /* Access time */
  __le32  i_ctime;  /* Inode Change time */
  __le32  i_mtime;  /* Modification time */
  __le32  i_dtime;  /* Deletion Time */
  __le16  i_gid;    /* Low 16 bits of Group Id */
  __le16  i_links_count;  /* Links count */
  __le32  i_blocks_lo;  /* Blocks count */
  __le32  i_flags;  /* File flags */
......
  __le32  i_block[EXT4_N_BLOCKS];/* Pointers to blocks */
  __le32  i_generation;  /* File version (for NFS) */
  __le32  i_file_acl_lo;  /* File ACL */
  __le32  i_size_high;
......
};

可以看出,inode里面有文件的读写权限i_mode,属于哪个用户i_uid,哪个组i_gid,大小是多少i_size_lo,占用多少个块i_blocks_lo。ls命令行列出来的权限、用户、大小这些信息,就是从这里面取出来的

另外,这里面还有几个与文件相关的时间。i_atime是access time,是最近一次访问文件的时间;i_ctime是change time,是最近一次更改inode的时间;i_mtime是modify time,是最近一次更改文件的时间。这里需要注意区分几个地方。首先访问了,不代表修改了,也可能只是打开看看,就会改变access time。其次,修改inode,有可能修改的是用户和权限,没有修改数据部分,就会改变change time。只有数据也修改了,才改变modify time。

6. 刚才说的“某个文件分成几块、每一块在哪里”,这些在inode中应该保存在i_block里面。具体如何保存呢?EXT4_N_BLOCKS有如下的定义,计算下来一共有15项:

#define  EXT4_NDIR_BLOCKS    12
#define  EXT4_IND_BLOCK      EXT4_NDIR_BLOCKS
#define  EXT4_DIND_BLOCK      (EXT4_IND_BLOCK + 1)
#define  EXT4_TIND_BLOCK      (EXT4_DIND_BLOCK + 1)
#define  EXT4_N_BLOCKS      (EXT4_TIND_BLOCK + 1)

在ext2和ext3中,其中前12项直接保存了块的位置,也就是说可以通过i_block[0-11],直接得到保存文件内容的块,如下所示:

但是如果一个文件比较大,12块放不下。当用到i_block[12]的时候,就不能直接放数据块的位置了,要不然i_block很快就会用完了。需要想个办法。可以让i_block[12]指向一个块,这个块里面不放数据块,而是放数据块的位置,这个块称为间接块。也就是说,在i_block[12]里面放间接块的位置,通过i_block[12]找到间接块后,间接块里面放数据块的位置,通过间接块可以找到数据块。如果文件再大一些,i_block[13]会指向一个块,可以用二次间接块。二次间接块里面存放了间接块的位置,间接块里面存放了数据块的位置,数据块里面存放的是真正的数据。如果文件再大一些,i_block[14]会指向三次间接块,就像俄罗斯套娃,一层一层打开才能拿到最中心的数据块。

这里面有一个非常显著的问题,对于大文件来讲要多次读取硬盘才能找到相应的块,这样访问速度就会比较慢。为了解决这个问题,ext4引入了一个新的概念,叫作Extents,比方说一个文件大小为128M,如果使用4k大小的块进行存储,需要32k个块,如果按照ext2或者ext3那样散着放数量太大了,但是Extents可以用于存放连续的块,也就是说可以把128M放在一个Extents里面。这样对大文件的读写性能提高了,文件碎片也减少了。Exents如何来存储呢?它其实会保存成一棵树,如下所示:

树有一个个的节点,有叶子节点也有分支节点。每个节点都有一个头,ext4_extent_header可以用来描述某个节点,如下所示:

struct ext4_extent_header {
  __le16  eh_magic;  /* probably will support different formats */
  __le16  eh_entries;  /* number of valid entries */
  __le16  eh_max;    /* capacity of store in entries */
  __le16  eh_depth;  /* has tree real underlying blocks? */
  __le32  eh_generation;  /* generation of the tree */
};

eh_entries表示这个节点里面有多少项。这里的项分两种,如果是叶子节点,这一项会直接指向硬盘上连续块的地址,称为数据节点ext4_extent;如果是分支节点,这一项会指向下一层的分支节点或者叶子节点,称为索引节点ext4_extent_idx,这两种类型的项的大小都是12个byte。两种项的实现如下所示:

/*
 * This is the extent on-disk structure.
 * It's used at the bottom of the tree.
 */
struct ext4_extent {
  __le32  ee_block;  /* first logical block extent covers */
  __le16  ee_len;    /* number of blocks covered by extent */
  __le16  ee_start_hi;  /* high 16 bits of physical block */
  __le32  ee_start_lo;  /* low 32 bits of physical block */
};
/*
 * This is index on-disk structure.
 * It's used at all the levels except the bottom.
 */
struct ext4_extent_idx {
  __le32  ei_block;  /* index covers logical blocks from 'block' */
  __le32  ei_leaf_lo;  /* pointer to the physical block of the next *
         * level. leaf or next index could be there */
  __le16  ei_leaf_hi;  /* high 16 bits of physical block */
  __u16  ei_unused;
};

如果文件不大,inode里面的i_block 中可以放得下一个ext4_extent_header和4项ext4_extent。如果是这种情况,eh_depth为0,即inode里面就是叶子节点,树高度为 0。如果文件比较大4个extent放不下,就要分裂成为一棵树,eh_depth>0的节点就是索引节点,其中根节点深度最大,放在inode中,最底层eh_depth=0的是叶子节点。

除了根节点,其他的节点都保存在一个块4k里面,4k扣除ext4_extent_header的12个byte,剩下的能够放340项,每个extent最大能表示128MB的数据,340个extent会使能表示的文件达到42.5GB。这已经非常大了,如果再大可以增加树的深度。

7. 因此,硬盘上有一系列的inode和一系列的块排列起来。接下来的问题是,如果要保存一个数据块,或者要保存一个inode,应该放在硬盘上的哪个位置呢?难道需要将所有的inode列表和块列表扫描一遍,找个空的地方随便放吗?当然这样效率太低了。所以在文件系统里面,Linux专门弄了一个块来保存inode的位图。在这4k里面,每一位对应一个inode。如果是1,表示这个inode已经被用了;如果是0,则表示没被用,同样也有一个块保存block的位图

接下来看位图究竟是如何在Linux操作系统里面起作用的。之前提过,如果创建一个新文件会调用open函数,并且参数会有O_CREAT。这表示当文件找不到的时候,就需要创建一个。open是一个系统调用,在内核里面会调用sys_open,定义如下:

SYSCALL_DEFINE3(open, const char __user *, filename, int, flags, umode_t, mode)
{
  if (force_o_largefile())
    flags |= O_LARGEFILE;


  return do_sys_open(AT_FDCWD, filename, flags, mode);
}

这里还是重点看对于inode的操作。其实open一个文件很复杂,后面会详细分析整个过程。来看接下来的调用链:do_sys_open-> do_filp_open->path_openat->do_last->lookup_open,这个调用链的逻辑是,要打开一个文件,先要根据路径找到文件夹。如果发现文件夹下面没有这个文件,同时又设置了O_CREAT,就说明要在这个文件夹下面创建一个文件,那就需要一个新的inode,如下所示:

static int lookup_open(struct nameidata *nd, struct path *path,
      struct file *file,
      const struct open_flags *op,
      bool got_write, int *opened)
{
......
  if (!dentry->d_inode && (open_flag & O_CREAT)) {
......
    error = dir_inode->i_op->create(dir_inode, dentry, mode,
            open_flag & O_EXCL);
......
  }
......
}

上面代码中想要创建新的inode,就要调用dir_inode,也就是文件夹的inode的create函数。它的具体定义是这样的:

const struct inode_operations ext4_dir_inode_operations = {
  .create    = ext4_create,
  .lookup    = ext4_lookup,
  .link    = ext4_link,
  .unlink    = ext4_unlink,
  .symlink  = ext4_symlink,
  .mkdir    = ext4_mkdir,
  .rmdir    = ext4_rmdir,
  .mknod    = ext4_mknod,
  .tmpfile  = ext4_tmpfile,
  .rename    = ext4_rename2,
  .setattr  = ext4_setattr,
  .getattr  = ext4_getattr,
  .listxattr  = ext4_listxattr,
  .get_acl  = ext4_get_acl,
  .set_acl  = ext4_set_acl,
  .fiemap         = ext4_fiemap,
};

这里面定义了,如果文件夹inode要做一些操作,每个操作对应应该调用哪些函数。这里create操作调用的是ext4_create。接下来的调用链是这样的:ext4_create->ext4_new_inode_start_handle->__ext4_new_inode。在__ext4_new_inode函数中,会创建新的inode,如下所示:

struct inode *__ext4_new_inode(handle_t *handle, struct inode *dir,
             umode_t mode, const struct qstr *qstr,
             __u32 goal, uid_t *owner, __u32 i_flags,
             int handle_type, unsigned int line_no,
             int nblocks)
{
......
inode_bitmap_bh = ext4_read_inode_bitmap(sb, group);
......
ino = ext4_find_next_zero_bit((unsigned long *)
                inode_bitmap_bh->b_data,
                EXT4_INODES_PER_GROUP(sb), ino);
......
}

这里面一个重要的逻辑就是,从文件系统里面读取inode位图(ext4_read_inode_bitmap),然后找到下一个为0的inode(ext4_find_next_zero_bit),就是空闲的inode。对于block位图,在写入文件的时候也会有这个过程。

8. 看起来现在应该能够顺利地通过inode位图和block位图创建文件了。如果仔细计算一下,其实是有问题的,数据块的位图是放在一个块里面的,共4k,每位表示一个数据块,共可以表示4∗1024∗8=2^15个数据块。如果每个数据块也是按默认的4K,最大可以表示空间为 2^15∗4∗1024=227个byte,也就是128M。

也就是说按照上面的格式,如果采用“一个块的位图 + 一系列的块”,外加“一个块的inode的位图 + 一系列的inode的结构”,最多能够表示128M。这样太小了,现在很多文件都比这个大。可以改成先把这个结构称为一个块组。有N多的块组,就能够合起来表示N大的文件

对于块组,也需要一个数据结构来表示为ext4_group_desc。这里面对于一个块组里的inode位图bg_inode_bitmap_lo、块位图bg_block_bitmap_lo、inode列表bg_inode_table_lo,都有相应的成员变量。这样一个个块组,就基本构成了整个文件系统的结构。因为块组有多个,块组描述符也同样组成一个列表,称为块组描述符表

当然,还需要有一个数据结构,对整个文件系统的情况进行描述,这个就是超级块ext4_super_block。这里面有整个文件系统一共有多少inode即s_inodes_count;一共有多少块即s_blocks_count_lo,每个块组有多少inode即s_inodes_per_group,每个块组有多少块即s_blocks_per_group 等。这些都是这个类的全局信息。对于整个文件系统,如果是一个启动盘,需要预留一块区域作为引导区,所以第一个块组的前面要留1K,用于启动引导区。最终,整个文件系统格式就是下面这个样子:

这里需要重点说一下,超级块和块组描述符表都是全局信息,而且这些数据很重要。如果这些数据丢失了,整个文件系统都打不开了,这比一个文件的一个块损坏更严重。所以,这两部分都需要备份,但是采取不同的策略。默认情况下,超级块和块组描述符表都有副本保存在每一个块组里面。如果开启了sparse_super特性,超级块和块组描述符表的副本只会保存在块组索引为0、3、5、7的整数幂里。除了块组0中存在一个超级块外,在块组1(3^0=1)的第一个块中存在一个副本;在块组3(3^1=3)、块组5(5^1=5)、块组7(7^1=7)、块组9(3^2=9)、块组25(5^2=25)、块组27(3^3=27)的第一个block处也存在一个副本。

对于超级块来讲,由于超级块不是很大,所以就算备份多了也没有太多问题。但是对于块组描述符表来讲,如果每个块组里面都保存一份完整的块组描述符表,一方面很浪费空间;另一个方面,由于一个块组最大128M,而块组描述符表里面有多少项,这限制了有多少个块组,128M * 块组的总数目就是整个文件系统的大小,这样就被限制住了。

改进的思路就是引入Meta Block Groups特性。首先,块组描述符表不会保存所有块组的描述符了,而是将块组分成多个组,称为元块组(Meta Block Group)。每个元块组里面的块组描述符表仅仅包括属于自己的块组,一个元块组包含64个块组,这样一个元块组中的块组描述符表最多64。假设一共有256个块组,原来是一个整的块组描述符表,里面有256项,要备份就全备份,现在分成4个元块组,每个元块组里面的块组描述符表就只有64项了,这就小多了,而且四个元块组自己备份自己的,如下所示:

根据图中,每一个元块组包含64个块组,块组描述符表也是64项,备份三份,在元块组的第一个,第二个和最后一个块组的开始处。这样化整为零,就可以发挥出ext4的48位块寻址的优势了,在超级块ext4_super_block的定义中,可以看到块寻址分为高位和地位,均为32位,其中有用的是48位,2^48个块是1EB,空间足够用了,如下所示:

struct ext4_super_block {
......
  __le32  s_blocks_count_lo;  /* Blocks count */
  __le32  s_r_blocks_count_lo;  /* Reserved blocks count */
  __le32  s_free_blocks_count_lo;  /* Free blocks count */
......
  __le32  s_blocks_count_hi;  /* Blocks count */
  __le32  s_r_blocks_count_hi;  /* Reserved blocks count */
  __le32  s_free_blocks_count_hi;  /* Free blocks count */
......
}

9. 其实目录本身也是个文件,也有inode。inode里面也是指向一些块,和普通文件不同的是,普通文件的块里面保存的是文件数据,而目录文件的块里面保存的是目录里面一项一项的文件信息,这些信息称为ext4_dir_entry。从代码来看有两个版本,对成员来讲几乎没有差别,只不过第二个版本ext4_dir_entry_2是将一个16位的name_len,变成了一个8位的name_len和8位的file_type,如下所示:

struct ext4_dir_entry {
  __le32  inode;      /* Inode number */
  __le16  rec_len;    /* Directory entry length */
  __le16  name_len;    /* Name length */
  char  name[EXT4_NAME_LEN];  /* File name */
};
struct ext4_dir_entry_2 {
  __le32  inode;      /* Inode number */
  __le16  rec_len;    /* Directory entry length */
  __u8  name_len;    /* Name length */
  __u8  file_type;
  char  name[EXT4_NAME_LEN];  /* File name */
};

在目录文件的块中,最简单的保存格式是列表,就是一项一项地将ext4_dir_entry_2列在哪里。每一项都会保存这个目录的下一级的文件的文件名和对应的inode,通过这个inode就能找到真正的文件。第一项是“.”表示当前目录,第二项是“..”表示上一级目录,接下来就是一项一项的文件名和inode。有时候,如果一个目录下面的文件太多时,想在这个目录下找一个文件,按照列表一个个去找太慢了,于是就添加了索引的模式。如果在inode中设置EXT4_INDEX_FL标志,则目录文件的块的组织形式将发生变化,变成了下面定义的这个样子:

struct dx_root
{
  struct fake_dirent dot;
  char dot_name[4];
  struct fake_dirent dotdot;
  char dotdot_name[4];
  struct dx_root_info
  {
    __le32 reserved_zero;
    u8 hash_version;
    u8 info_length; /* 8 */
    u8 indirect_levels;
    u8 unused_flags;
  }
  info;
  struct dx_entry  entries[0];
};

当然,首先出现的还是差不多的,第一项是“.”表示当前目录;第二项是“..”表示上一级目录,接下来就开始发生改变了,是一个dx_root_info的结构,其中最重要的成员变量是indirect_levels,表示间接索引的层数。接下来看索引项dx_entry,这个其实就是文件名的哈希值和数据块的一个映射关系,如下所示:

struct dx_entry
{
  __le32 hash;
  __le32 block;
};

如果要查找一个目录下面的文件名,可以通过名称取哈希。如果哈希能够匹配上,就说明这个文件的信息在相应的块里面。然后打开这个块,如果里面不再是索引,而是索引树的叶子节点的话,那里面还是ext4_dir_entry_2的列表,只要一项一项找文件名就行。通过索引树可以将一个目录下面的N多文件分散到很多的块里面,可以很快地进行查找,这两个索引项如下图所示:

10. 还有一种特殊的文件格式,硬链接(Hard Link)和软链接(Symbolic Link)。所谓的链接(Link),可以认为是文件的别名,通过下面的命令可以创建:

ln [参数][源文件或目录][目标文件或目录]

ln -s创建的是软链接,不带-s创建的是硬链接。它们的区别和保存方式如下所示:

硬链接与原始文件共用一个inode,但是inode是不跨文件系统的,每个文件系统都有自己的inode列表,因而硬链接是没有办法跨文件系统的。而软链接相当于重新创建了一个文件,这个文件也有独立的inode,只不过打开这个文件看里面内容的时候,该内容指向另外的一个文件。这就很灵活,可以跨文件系统,甚至目标文件被删除了,链接文件还是在的,只不过指向的文件找不到了而已。

11. 总结一下inode和数据块在文件系统上的关联关系,如下图所示:

为了表示图中上半部分的那个树形结构,在文件系统上的布局就像图的下半部分一样,无论是文件夹还是文件,都有一个 inode,inode里面会指向数据块,对于文件夹的数据块,里面是一个表或者树,是下一层的文件名和inode的对应关系,文件的数据块里面存放的才是真正的数据

三、虚拟文件系统

12. 上面硬盘上的文件系统格式都搭建好了,现在还需要文件管理模块,知晓文件的操作和去向。进程要想往文件系统里面读写数据,需要很多层的组件一起合作,如下图所示:

(1)在应用层,进程在进行文件读写操作时,可通过系统调用如sys_open、sys_read、sys_write等。

(2)在内核,每个进程都需要为打开的文件,维护一定的数据结构;整个系统打开的文件,也需要维护一定的数据结构。

(3)Linux可以支持多达数十种不同的文件系统,它们的实现各不相同,因此Linux内核向用户空间提供了虚拟文件系统这个统一的接口,来对文件系统进行操作。它提供了常见的文件系统对象模型,例如inode、directory entry、mount等,以及操作这些对象的方法,例如inode operations、directory operations、file operations等。

(4)然后对接的是真正的文件系统,例如ext4文件系统。

(5)为了读写ext4文件系统,要通过块设备I/O层,也即BIO层。这是文件系统层和块设备驱动的接口。

(6)为了加快块设备的读写效率,还有一个缓存层。

(7)最下层是块设备驱动程序。

解析系统调用是了解内核架构最有力的钥匙,这里只要重点关注这几个最重要的系统调用就可以了:

(1)mount系统调用用于挂载文件系统;

(2)open系统调用用于打开或者创建文件,创建要在flags中设置O_CREAT,对于读写要设置flags为O_RDWR;

(3)read系统调用用于读取文件内容;write系统调用用于写入文件内容。

13. 想要操作文件系统,第一件事情就是挂载文件系统内核是不是支持某种类型的文件系统,需要进行注册才能知道。例如ext4文件系统,就需要通过register_filesystem进行注册,传入的参数是ext4_fs_type,表示注册的是ext4类型的文件系统。这里面最重要的一个成员变量就是ext4_mount,后面还会用到这个变量,如下所示:

register_filesystem(&ext4_fs_type);


static struct file_system_type ext4_fs_type = {
  .owner    = THIS_MODULE,
  .name    = "ext4",
  .mount    = ext4_mount,
  .kill_sb  = kill_block_super,
  .fs_flags  = FS_REQUIRES_DEV,
};

如果一种文件系统的类型曾经在内核注册过,这就说明允许挂载并且使用这个文件系统。从第一个mount系统调用开始解析,它的定义如下:

SYSCALL_DEFINE5(mount, char __user *, dev_name, char __user *, dir_name, char __user *, type, unsigned long, flags, void __user *, data)
{
......
  ret = do_mount(kernel_dev, dir_name, kernel_type, flags, options);
......
}

接下来的调用链为:do_mount->do_new_mount->vfs_kern_mount,vfs_kern_mount的实现如下所示:

struct vfsmount *
vfs_kern_mount(struct file_system_type *type, int flags, const char *name, void *data)
{
......
  mnt = alloc_vfsmnt(name);
......
  root = mount_fs(type, flags, name, data);
......
  mnt->mnt.mnt_root = root;
  mnt->mnt.mnt_sb = root->d_sb;
  mnt->mnt_mountpoint = mnt->mnt.mnt_root;
  mnt->mnt_parent = mnt;
  list_add_tail(&mnt->mnt_instance, &root->d_sb->s_mounts);
  return &mnt->mnt;
}

vfs_kern_mount先是创建struct mount结构,每个挂载的文件系统都对应于这样一个结构,如下所示:

struct mount {
  struct hlist_node mnt_hash;
  struct mount *mnt_parent;
  struct dentry *mnt_mountpoint;
  struct vfsmount mnt;
  union {
    struct rcu_head mnt_rcu;
    struct llist_node mnt_llist;
  };
  struct list_head mnt_mounts;  /* list of children, anchored here */
  struct list_head mnt_child;  /* and going through their mnt_child */
  struct list_head mnt_instance;  /* mount instance on sb->s_mounts */
  const char *mnt_devname;  /* Name of device e.g. /dev/dsk/hda1 */
  struct list_head mnt_list;
......
} __randomize_layout;


struct vfsmount {
  struct dentry *mnt_root;  /* root of the mounted tree */
  struct super_block *mnt_sb;  /* pointer to superblock */
  int mnt_flags;
} __randomize_layout;

其中,mnt_parent是装载点所在的父文件系统,mnt_mountpoint是装载点在父文件系统中的dentry;struct dentry表示目录,并和目录的inode关联;mnt_root是当前文件系统根目录的dentry,mnt_sb是指向超级块的指针。接下来看调用mount_fs挂载文件系统,如下所示:

struct dentry *
mount_fs(struct file_system_type *type, int flags, const char *name, void *data)
{
  struct dentry *root;
  struct super_block *sb;
......
  root = type->mount(type, flags, name, data);
......
  sb = root->d_sb;
......
}

这里调用的是ext4_fs_type的mount函数,也就是上面提到的ext4_mount,从文件系统里面读取超级块。在文件系统的实现中,每个在硬盘上的结构,在内存中也对应相同格式的结构。当所有的数据结构都读到内存里面,内核就可以通过操作这些数据结构,来操作文件系统了。这里举一个例子,来解析经过mount之后,刚刚那些数据结构之间的关系。

假设根文件系统下面有一个目录home,有另外一个文件系统A挂载在这个目录home下面。在文件系统A的根目录下面有另外一个文件夹hello。由于文件系统A已经挂载到了目录home下面,所以就有了目录/home/hello,然后有另外一个文件系统B挂在在/home/hello下面。在文件系统B的根目录下面有另外一个文件夹world,在world下面有个文件夹data。由于文件系统B已经挂载到了/home/hello下面,所以就有了目录/home/hello/world/data。为了维护这些关系,操作系统创建了这一系列数据结构,如下所示:

文件系统是树形关系。如果所有的文件夹都是几代单传,就变成了一条线。注意看图中的三条斜线。第一条线是最左边的向左斜的dentry斜线,每一个文件和文件夹都有dentry,用于和inode关联。第二条线是最右面的向右斜的mount斜线,因为这个例子涉及两次文件系统的挂载,再加上启动的时候挂载的根文件系统,一共三个mount。第三条线是中间向右斜的file斜线,每个打开的文件都有一个file结构,它里面有两个变量,一个指向相应的mount,一个指向相应的dentry。

从最上面往下看。根目录/对应一个dentry,根目录是在根文件系统上的,根文件系统是系统启动的时候挂载的,因此有一个mount结构。这个mount结构的mount point指针和mount root指针都是指向根目录的dentry。根目录对应的file的两个指针,一个指向根目录的dentry,一个指向根目录的挂载结构mount。

再来看第二层。下一层目录home对应了两个dentry,而且它们的parent都指向第一层的dentry,这是为什么呢?是因为文件系统A挂载到了这个目录下,使得这个目录有两个用处,一方面home是根文件系统的一个挂载点;另一方面home是文件系统A的根目录。

因为还有一次挂载,因而又有了一个mount结构。这个mount结构的mount point指针指向作为挂载点的那个dentry。mount root指针指向作为根目录的那个dentry,同时parent指针指向第一层的mount结构。home对应的file的两个指针,一个指向文件系统 A 根目录的dentry,一个指向文件系统A的挂载结构mount。

再来看第三层。目录hello又挂载了一个文件系统B,所以第三层的结构和第二层几乎一样。

接下来是第四层。目录world就是一个普通的目录。只要它的dentry的parent指针指向上一层就可以了。来看world对应的file结构,由于挂载点不变,还是指向第三层的mount结构。

接下来是第五层。对于文件data,是一个普通的文件,它的dentry的parent指向第四层的dentry。对于data对应的file结构,由于挂载点不变,还是指向第三层的mount结构。

14. 接下来,从分析Open系统调用说起。在进程里面通过open系统调用打开文件,最终对调用到内核的系统调用实现sys_open。现在接着分析这个过程,如下所示:

SYSCALL_DEFINE3(open, const char __user *, filename, int, flags, umode_t, mode)
{
......
  return do_sys_open(AT_FDCWD, filename, flags, mode);
}


long do_sys_open(int dfd, const char __user *filename, int flags, umode_t mode)
{
......
  fd = get_unused_fd_flags(flags);
  if (fd >= 0) {
    struct file *f = do_filp_open(dfd, tmp, &op);
    if (IS_ERR(f)) {
      put_unused_fd(fd);
      fd = PTR_ERR(f);
    } else {
      fsnotify_open(f);
      fd_install(fd, f);
    }
  }
  putname(tmp);
  return fd;
}

要打开一个文件,首先要通过get_unused_fd_flags得到一个没有用的文件描述符。如何获取这个文件描述符呢?在每一个进程的task_struct中,有一个指针files,类型是files_struct,如下所示:

struct files_struct    *files;

files_struct里面最重要的成员变量,是一个文件描述符列表,每打开一个文件,就会在这个列表中分配一项,下标就是文件描述符,如下所示:

struct files_struct {
......
  struct file __rcu * fd_array[NR_OPEN_DEFAULT];
};

对于任何一个进程,默认情况下文件描述符0表示stdin标准输入,文件描述符1表示stdout标准输出,文件描述符2表示stderr标准错误输出。另外再打开的文件,都会从这个列表中找一个空闲位置分配给它。文件描述符列表的每一项都是一个指向struct file的指针,也就是说每打开一个文件,都会有一个struct file对应。do_sys_open中调用do_filp_open,就是创建这个struct file结构,然后fd_install(fd, f)是将文件描述符和这个结构关联起来,如下所示:

struct file *do_filp_open(int dfd, struct filename *pathname,
    const struct open_flags *op)
{
......
  set_nameidata(&nd, dfd, pathname);
  filp = path_openat(&nd, op, flags | LOOKUP_RCU);
......
  restore_nameidata();
  return filp;
}

do_filp_open里面首先初始化了struct nameidata这个结构。文件都是一串的路径名称需要逐个解析,这个结构就是解析和查找路径的时候做辅助作用。在struct nameidata里面有一个关键的成员变量struct path,如下所示:

struct path {
  struct vfsmount *mnt;
  struct dentry *dentry;
} __randomize_layout;

其中,struct vfsmount和文件系统的挂载有关。另一个struct dentry除了上面说的用于标识目录之外,还可以表示文件名,还会建立文件名及其inode之间的关联。接下来就调用path_openat,主要做了以下几件事情:

static struct file *path_openat(struct nameidata *nd,
      const struct open_flags *op, unsigned flags)
{
......
  file = get_empty_filp();
......
  s = path_init(nd, flags);
......
  while (!(error = link_path_walk(s, nd)) &&
    (error = do_last(nd, file, op, &opened)) > 0) {
......
  }
  terminate_walk(nd);
......
  return file;
}

(1)get_empty_filp生成一个struct file结构;

(2)path_init初始化nameidata,准备开始节点路径查找;

(3)link_path_walk对于路径名逐层进行节点路径查找,这里有一个大的循环用“/”分隔逐层处理;

(4)do_last获取文件对应的inode对象,并且初始化file对象

例如文件“/root/hello/world/data”,link_path_walk会解析前面的路径部分“/root/hello/world”,解析完毕的时候nameidata的dentry为路径名的最后一部分的父目录“/root/hello/world”,而nameidata的filename为路径名的最后一部分“data”。

15. 最后一部分的解析和处理是交给do_last的,如下所示:

static int do_last(struct nameidata *nd,
       struct file *file, const struct open_flags *op,
       int *opened)
{
......
  error = lookup_fast(nd, &path, &inode, &seq);
......
    error = lookup_open(nd, &path, file, op, got_write, opened);
......
  error = vfs_open(&nd->path, file, current_cred());
......
}

在这里面,需要先查找文件路径最后一部分对应的dentry。Linux为了提高目录项对象的处理效率,设计与实现了目录项高速缓存dentry cache,简称dcache,它主要由两个数据结构组成:

(1)哈希表dentry_hashtable:dcache中的所有dentry对象都通过d_hash指针链到相应的dentry哈希链表中;

(2)未使用的dentry对象链表s_dentry_lru:dentry对象通过其d_lru指针链入LRU链表中。如下图所示:

上面这两个列表之间会产生复杂的关系:

(1)引用为0:一个在散列表中的dentry变成没有人引用了,就会被加到LRU表中去;

(2)再次被引用:一个在LRU表中的dentry再次被引用了,则从LRU表中移除;

(3)分配:当dentry在散列表中没有找到,则从Slub分配器中分配一个;

(4)过期归还:当LRU表中最长时间没有使用的dentry应该释放回Slub分配器;

(5)文件删除:文件被删除了,相应的dentry应该释放回Slub分配器;

(6)结构复用:当需要分配一个 dentry,但是无法分配新的,就从LRU表中取出一个来复用。

所以,do_last()在查找dentry的时候,当然先从dcache缓存中查找,调用的是上方代码中的lookup_fast。如果缓存中没有找到,就需要真的到文件系统里面去找了,lookup_open会创建一个新的dentry,并且调用上一级目录inode 的inode_operations的lookup函数,对于ext4来讲调用的是ext4_lookup,会到文件系统里面去找inode,最终找到后将新生成的dentry付给path变量,如下所示:

static int lookup_open(struct nameidata *nd, struct path *path,
      struct file *file,
      const struct open_flags *op,
      bool got_write, int *opened)
{
    ......
    dentry = d_alloc_parallel(dir, &nd->last, &wq);
    ......
    struct dentry *res = dir_inode->i_op->lookup(dir_inode, dentry,
                   nd->flags);
    ......
    path->dentry = dentry;
  path->mnt = nd->path.mnt;
}



const struct inode_operations ext4_dir_inode_operations = {
  .create    = ext4_create,
  .lookup    = ext4_lookup,
...

do_last()的最后一步是调用vfs_open真正打开文件,如下所示:

int vfs_open(const struct path *path, struct file *file,
       const struct cred *cred)
{
  struct dentry *dentry = d_real(path->dentry, NULL, file->f_flags, 0);
......
  file->f_path = *path;
  return do_dentry_open(file, d_backing_inode(dentry), NULL, cred);
}


static int do_dentry_open(struct file *f,
        struct inode *inode,
        int (*open)(struct inode *, struct file *),
        const struct cred *cred)
{
......
  f->f_mode = OPEN_FMODE(f->f_flags) | FMODE_LSEEK |
        FMODE_PREAD | FMODE_PWRITE;
  path_get(&f->f_path);
  f->f_inode = inode;
  f->f_mapping = inode->i_mapping;
......
  f->f_op = fops_get(inode->i_fop);
......
  open = f->f_op->open;
......
  error = open(inode, f);
......
  f->f_flags &= ~(O_CREAT | O_EXCL | O_NOCTTY | O_TRUNC);
  file_ra_state_init(&f->f_ra, f->f_mapping->host->i_mapping);
  return 0;
......
}


const struct file_operations ext4_file_operations = {
......
  .open    = ext4_file_open,
......
};

vfs_open里面最重要做的一件事情是,调用f_op->open,也就是调用ext4_file_open。另外一件重要的事情是将打开文件的所有信息,填写到struct file这个结构里面,如下所示:

struct file {
  union {
    struct llist_node  fu_llist;
    struct rcu_head   fu_rcuhead;
  } f_u;
  struct path    f_path;
  struct inode    *f_inode;  /* cached value */
  const struct file_operations  *f_op;
  spinlock_t    f_lock;
  enum rw_hint    f_write_hint;
  atomic_long_t    f_count;
  unsigned int     f_flags;
  fmode_t      f_mode;
  struct mutex    f_pos_lock;
  loff_t      f_pos;
  struct fown_struct  f_owner;
  const struct cred  *f_cred;
......
  struct address_space  *f_mapping;
  errseq_t    f_wb_err;
}

16. 可以看出,有关文件的数据结构层次多,而且很复杂,就得到了下面这张图,这张图十分重要(记),因为后面的字符设备、块设备、管道、进程间通信、网络等等,全部都要用到这里面的知识:

对于每一个进程,打开的文件都有一个文件描述符,在files_struct里面会有文件描述符数组。每一个文件描述符是这个数组的下标,里面的内容指向一个file结构,表示打开的文件。这个结构里面有这个文件对应的inode,最重要的是这个文件对应的操作file_operation。如果操作这个文件,就看这个file_operation里面的定义了。

对于每一个打开的文件,都有一个dentry对应,虽然叫作directory entry,但是不仅仅表示文件夹,也表示文件,它最重要的作用就是指向这个文件对应的inode。如果说file结构是一个文件打开以后才创建的,dentry是放在一个dentry cache里面的,文件关闭了它依然存在,因而它可以更长期的维护内存中文件的表示和硬盘上文件的表示之间的关系。

inode结构就表示硬盘上的inode,包括块设备号等。几乎每一种结构都有自己对应的operation结构,里面都是一些方法,因而当后面遇到对于某种结构进行处理时,如果不容易找到相应的处理函数,就先找这个operation结构,就清楚了。

四、文件缓存

17. 上面讲了文件系统的挂载和文件的打开,并通过打开文件的过程,构建了一个文件管理的整套数据结构体系。其实到这里还没有对文件进行读写,还属于对于元数据的操作。文件系统的读写,其实就是调用系统函数read和write。下面的代码就是read和write的系统调用,在内核里面的定义:

SYSCALL_DEFINE3(read, unsigned int, fd, char __user *, buf, size_t, count)
{
  struct fd f = fdget_pos(fd);
......
  loff_t pos = file_pos_read(f.file);
  ret = vfs_read(f.file, buf, count, &pos);
......
}


SYSCALL_DEFINE3(write, unsigned int, fd, const char __user *, buf,
    size_t, count)
{
  struct fd f = fdget_pos(fd);
......
  loff_t pos = file_pos_read(f.file);
    ret = vfs_write(f.file, buf, count, &pos);
......
}

对于read来讲里面调用vfs_read->__vfs_read。对于write来讲里面调用vfs_write->__vfs_write。下面是__vfs_read和__vfs_write的代码,读和写的逻辑是很相似的:

ssize_t __vfs_read(struct file *file, char __user *buf, size_t count,
       loff_t *pos)
{
  if (file->f_op->read)
    return file->f_op->read(file, buf, count, pos);
  else if (file->f_op->read_iter)
    return new_sync_read(file, buf, count, pos);
  else
    return -EINVAL;
}


ssize_t __vfs_write(struct file *file, const char __user *p, size_t count,
        loff_t *pos)
{
  if (file->f_op->write)
    return file->f_op->write(file, p, count, pos);
  else if (file->f_op->write_iter)
    return new_sync_write(file, p, count, pos);
  else
    return -EINVAL;
}

每一个打开的文件,都有一个struct file结构。这里面有一个struct file_operations f_op,用于定义对这个文件做的操作。__vfs_read会调用相应文件系统的file_operations里面的read操作,__vfs_write会调用相应文件系统file_operations里的write操作。

18. 对于ext4文件系统来讲,内核定义了一个ext4_file_operations,如下所示:

const struct file_operations ext4_file_operations = {
......
  .read_iter  = ext4_file_read_iter,
  .write_iter  = ext4_file_write_iter,
......
}

由于ext4没有定义read和write函数,于是会调用ext4_file_read_iter和ext4_file_write_iter。ext4_file_read_iter会调用generic_file_read_iter,ext4_file_write_iter会调用__generic_file_write_iter,如下所示:

ssize_t
generic_file_read_iter(struct kiocb *iocb, struct iov_iter *iter)
{
......
    if (iocb->ki_flags & IOCB_DIRECT) {
......
        struct address_space *mapping = file->f_mapping;
......
        retval = mapping->a_ops->direct_IO(iocb, iter);
    }
......
    retval = generic_file_buffered_read(iocb, iter, retval);
}


ssize_t __generic_file_write_iter(struct kiocb *iocb, struct iov_iter *from)
{
......
    if (iocb->ki_flags & IOCB_DIRECT) {
......
        written = generic_file_direct_write(iocb, from);
......
    } else {
......
    written = generic_perform_write(file, from, iocb->ki_pos);
......
    }
}

generic_file_read_iter和__generic_file_write_iter有相似的逻辑,就是要区分是否用缓存。缓存其实就是内存中的一块空间,因为内存比硬盘快的多,Linux为了改进性能,有时候会选择不直接操作硬盘,而是将读写都在内存中,然后批量读取或者写入硬盘。一旦能够命中内存,读写效率就会大幅度提高。

因此,根据是否使用内存做缓存,可以把文件的I/O操作分为两种类型:

(1)缓存I/O。大多数文件系统的默认I/O操作都是缓存I/O。对于读操作来讲,操作系统会先检查,内核的缓冲区有没有需要的数据。如果已经缓存了,那就直接从缓存中返回;否则从磁盘中读取,然后缓存在操作系统的缓存中。对于写操作来讲,操作系统会先将数据从用户空间复制到内核空间的缓存中。这时对用户程序来说,写操作就已经完成。至于什么时候再写到磁盘中由操作系统决定,除非显式地调用了sync同步命令。

(2)直接IO,就是应用程序直接访问磁盘数据,而不经过内核缓冲区,从而减少了在内核缓存和用户程序之间数据复制。

如果在读的逻辑generic_file_read_iter里面,发现设置了IOCB_DIRECT,则会调用address_space的direct_IO的函数,将数据直接读取硬盘。在mmap映射文件到内存的时候提到过address_space,它主要用于在内存映射的时候将文件和内存页产生关联。同样对缓存来讲,也需要文件和内存页进行关联,这就要用到address_space。address_space 的相关操作定义在struct address_space_operations结构中,对于ext4文件系统来说, address_space的操作定义在ext4_aops,direct_IO对应的函数是ext4_direct_IO,如下所示:

static const struct address_space_operations ext4_aops = {
......
  .direct_IO    = ext4_direct_IO,
......
};

如果在写的逻辑__generic_file_write_iter里面,发现设置了IOCB_DIRECT,则调用generic_file_direct_write,里面同样会调用address_space的direct_IO的函数,将数据直接写入硬盘。ext4_direct_IO最终会调用到__blockdev_direct_IO->do_blockdev_direct_IO,这就跨过了缓存层,直接到了文件系统的通用块层,主要用io合并之类操作,然后才是设备驱动层。由于文件系统是块设备,所以这个调用的是blockdev相关的函数,有关块设备驱动程序的原理以后详细讲,这里就讲到文件系统到块设备的分界线部分。do_blockdev_direct_IO的实现如下所示:

/*
 * This is a library function for use by filesystem drivers.
 */
static inline ssize_t
do_blockdev_direct_IO(struct kiocb *iocb, struct inode *inode,
          struct block_device *bdev, struct iov_iter *iter,
          get_block_t get_block, dio_iodone_t end_io,
          dio_submit_t submit_io, int flags)
{......}

19. 接下来,重点看带缓存的部分如何进行读写。带缓存写入的函数generic_perform_write的实现如下所示:

ssize_t generic_perform_write(struct file *file,
        struct iov_iter *i, loff_t pos)
{
  struct address_space *mapping = file->f_mapping;
  const struct address_space_operations *a_ops = mapping->a_ops;
  do {
    struct page *page;
    unsigned long offset;  /* Offset into pagecache page */
    unsigned long bytes;  /* Bytes to write to page */
    status = a_ops->write_begin(file, mapping, pos, bytes, flags,
            &page, &fsdata);
    copied = iov_iter_copy_from_user_atomic(page, i, offset, bytes);
    flush_dcache_page(page);
    status = a_ops->write_end(file, mapping, pos, bytes, copied,
            page, fsdata);
    pos += copied;
    written += copied;


    balance_dirty_pages_ratelimited(mapping);
  } while (iov_iter_count(i));
}

这个函数里是一个while循环,需要找出这次写入影响的所有的页,然后依次写入。对于每一个循环,主要做四件事情:

(1)对于每一页,先调用address_space的write_begin做一些准备;

(2)调用iov_iter_copy_from_user_atomic,将写入的内容从用户态拷贝到内核态的页中;

(3)调用address_space的write_end完成写操作;

(4)调用balance_dirty_pages_ratelimited,看脏页是否太多,需要写回硬盘。所谓脏页,就是写入到缓存,但是还没有写入到硬盘的页面

20. 依次来看这四个步骤,如下所示:

static const struct address_space_operations ext4_aops = {
......
  .write_begin    = ext4_write_begin,
  .write_end    = ext4_write_end,
......
}

第一步,对于ext4来讲,调用的是ext4_write_begin。ext4是一种日志文件系统,是为了防止突然断电时的数据丢失,引入了日志(Journal)模式。日志文件系统比非日志文件系统多了一个Journal区域。文件在ext4中分两部分存储,一部分是文件的元数据,另一部分是数据。元数据和数据的两个操作日志Journal也是分开管理的。可以在挂载ext4的时候,选择Journal模式,这种模式在将数据写入文件系统前,必须等待元数据和数据的日志已经落盘才能发挥作用。这样性能比较差,但是最安全

另一种模式是order模式。这个模式不记录数据的日志,只记录元数据的日志,但是在写元数据的日志前,必须先确保数据已经落盘。这个折中,是默认模式。

还有一种模式是writeback,不记录数据的日志,仅记录元数据的日志,并且不保证数据比元数据先落盘。这个性能最好,但是最不安全。

在ext4_write_begin,能看到对于ext4_journal_start的调用,就是在做日志相关的工作。在ext4_write_begin中,还做了另外一件重要的事情,就是调用grab_cache_page_write_begin,来得到应该写入的缓存页,如下所示:

struct page *grab_cache_page_write_begin(struct address_space *mapping,
          pgoff_t index, unsigned flags)
{
  struct page *page;
  int fgp_flags = FGP_LOCK|FGP_WRITE|FGP_CREAT;
  page = pagecache_get_page(mapping, index, fgp_flags,
      mapping_gfp_mask(mapping));
  if (page)
    wait_for_stable_page(page);
  return page;
}

在内核中,缓存以页为单位放在内存里面,那如何知道,一个文件的哪些数据已经被放到缓存中了呢?每一个打开的文件都有一个struct file结构,每个struct file结构都有一个struct address_space用于关联文件和内存,就是在这个结构里面,有一棵树用于保存所有与这个文件相关的的缓存页。查找的时候,往往需要根据文件中的偏移量找出相应的页面,而基数树radix tree这种数据结构能够快速根据一个长整型查找到其相应的对象,因而这里缓存页就放在radix基数树里面。address_space的实现如下所示:

struct address_space {
  struct inode    *host;    /* owner: inode, block_device */
  struct radix_tree_root  page_tree;  /* radix tree of all pages */
  spinlock_t    tree_lock;  /* and lock protecting it */
......
}

再上面的pagecache_get_page就是根据pgoff_t index这个长整型,在这棵树里面查找缓存页,如果找不到就会创建一个缓存页。

第二步,调用iov_iter_copy_from_user_atomic。先将分配好的页面调用kmap_atomic映射到内核里面的一个虚拟地址,然后将用户态的数据拷贝到内核态的页面的虚拟地址中,调用kunmap_atomic把内核里面的映射删除,如下所示:

size_t iov_iter_copy_from_user_atomic(struct page *page,
    struct iov_iter *i, unsigned long offset, size_t bytes)
{
  char *kaddr = kmap_atomic(page), *p = kaddr + offset;
  iterate_all_kinds(i, bytes, v,
    copyin((p += v.iov_len) - v.iov_len, v.iov_base, v.iov_len),
    memcpy_from_page((p += v.bv_len) - v.bv_len, v.bv_page,
         v.bv_offset, v.bv_len),
    memcpy((p += v.iov_len) - v.iov_len, v.iov_base, v.iov_len)
  )
  kunmap_atomic(kaddr);
  return bytes;
}

第三步,调用ext4_write_end完成写入。这里面会调用ext4_journal_stop完成日志的写入,会调用block_write_end->__block_commit_write->mark_buffer_dirty,将修改过的缓存标记为脏页。可以看出,其实所谓的完成写入,并没有真正写入硬盘,仅仅是写入缓存后标记为脏页。但是这里有一个问题,数据很危险,一旦宕机就没有了,所以需要一种机制,将写入的页面真正写到硬盘中,称为回写(Write Back)。

第四步,调用balance_dirty_pages_ratelimited,是回写脏页的一个很好的时机,如下所示:

/**
 * balance_dirty_pages_ratelimited - balance dirty memory state
 * @mapping: address_space which was dirtied
 *
 * Processes which are dirtying memory should call in here once for each page
 * which was newly dirtied.  The function will periodically check the system's
 * dirty state and will initiate writeback if needed.
  */
void balance_dirty_pages_ratelimited(struct address_space *mapping)
{
  struct inode *inode = mapping->host;
  struct backing_dev_info *bdi = inode_to_bdi(inode);
  struct bdi_writeback *wb = NULL;
  int ratelimit;
......
  if (unlikely(current->nr_dirtied >= ratelimit))
    balance_dirty_pages(mapping, wb, current->nr_dirtied);
......
}

在balance_dirty_pages_ratelimited里面,发现脏页的数目超过了规定的数目,就调用balance_dirty_pages->wb_start_background_writeback,启动一个后台线程开始回写,如下所示:

void wb_start_background_writeback(struct bdi_writeback *wb)
{
  /*
   * We just wake up the flusher thread. It will perform background
   * writeback as soon as there is no other work to do.
   */
  wb_wakeup(wb);
}


static void wb_wakeup(struct bdi_writeback *wb)
{
  spin_lock_bh(&wb->work_lock);
  if (test_bit(WB_registered, &wb->state))
    mod_delayed_work(bdi_wq, &wb->dwork, 0);
  spin_unlock_bh(&wb->work_lock);
}


  (_tflags) | TIMER_IRQSAFE);    \
  } while (0)


/* bdi_wq serves all asynchronous writeback tasks */
struct workqueue_struct *bdi_wq;


/**
 * mod_delayed_work - modify delay of or queue a delayed work
 * @wq: workqueue to use
 * @dwork: work to queue
 * @delay: number of jiffies to wait before queueing
 *
 * mod_delayed_work_on() on local CPU.
 */
static inline bool mod_delayed_work(struct workqueue_struct *wq,
            struct delayed_work *dwork,
            unsigned long delay)
{....

通过上面的代码,可以看出bdi_wq是一个全局变量,所有回写的任务都挂在这个队列上。mod_delayed_work函数负责将一个回写任务bdi_writeback挂在这个队列上。bdi_writeback有个成员变量struct delayed_work dwork,bdi_writeback就是以delayed_work的身份挂到队列上的,并且把delay设置为0,意思就是一刻不等,马上执行。

那具体这个任务由谁来执行呢?这里bdi的意思是backing device info,用于描述后端存储相关的信息。每个块设备都会有这样一个结构,并且在初始化块设备的时候,调用bdi_init初始化这个结构,在初始化bdi的时候,也会调用wb_init初始化bdi_writeback,如下所示:

static int wb_init(struct bdi_writeback *wb, struct backing_dev_info *bdi,
       int blkcg_id, gfp_t gfp)
{
  wb->bdi = bdi;
  wb->last_old_flush = jiffies;
  INIT_LIST_HEAD(&wb->b_dirty);
  INIT_LIST_HEAD(&wb->b_io);
  INIT_LIST_HEAD(&wb->b_more_io);
  INIT_LIST_HEAD(&wb->b_dirty_time);
  wb->bw_time_stamp = jiffies;
  wb->balanced_dirty_ratelimit = INIT_BW;
  wb->dirty_ratelimit = INIT_BW;
  wb->write_bandwidth = INIT_BW;
  wb->avg_write_bandwidth = INIT_BW;
  spin_lock_init(&wb->work_lock);
  INIT_LIST_HEAD(&wb->work_list);
  INIT_DELAYED_WORK(&wb->dwork, wb_workfn);
  wb->dirty_sleep = jiffies;
......
}


#define __INIT_DELAYED_WORK(_work, _func, _tflags)      \
  do {                \
    INIT_WORK(&(_work)->work, (_func));      \
    __setup_timer(&(_work)->timer, delayed_work_timer_fn,  \
            (unsigned long)(_work),      \

这里面最重要的是INIT_DELAYED_WORK,其实就是初始化一个timer即定时器,到时候就执行wb_workfn这个函数。接下来的调用链为:wb_workfn->wb_do_writeback->wb_writeback->writeback_sb_inodes->__writeback_single_inode->do_writepages,写入页面到硬盘。在调用write的最后,当发现缓存的数据太多时,会触发回写,这仅仅是回写的一种场景。另外还有几种场景也会触发回写:

(1)用户主动调用sync,将缓存刷到硬盘上去,最终会调用wakeup_flusher_threads同步脏页;

(2)当内存十分紧张,以至于无法分配页面的时候,会调用free_more_memory,最终会调用wakeup_flusher_threads释放脏页;

(3)脏页已经更新了较长时间,时间上超过了timer,需要及时回写,保持内存和磁盘上数据一致性。

20. 接下来看带缓存的读,对应的是函数generic_file_buffered_read,如下所示:

static ssize_t generic_file_buffered_read(struct kiocb *iocb,
    struct iov_iter *iter, ssize_t written)
{
  struct file *filp = iocb->ki_filp;
  struct address_space *mapping = filp->f_mapping;
  struct inode *inode = mapping->host;
  for (;;) {
    struct page *page;
    pgoff_t end_index;
    loff_t isize;
    page = find_get_page(mapping, index);
    if (!page) {
      if (iocb->ki_flags & IOCB_NOWAIT)
        goto would_block;
      page_cache_sync_readahead(mapping,
          ra, filp,
          index, last_index - index);
      page = find_get_page(mapping, index);
      if (unlikely(page == NULL))
        goto no_cached_page;
    }
    if (PageReadahead(page)) {
      page_cache_async_readahead(mapping,
          ra, filp, page,
          index, last_index - index);
    }
    /*
     * Ok, we have the page, and it's up-to-date, so
     * now we can copy it to user space...
     */
    ret = copy_page_to_iter(page, offset, nr, iter);
    }
}

读取比写入总体而言简单一些,主要涉及预读的问题。在generic_file_buffered_read函数中,需要先找到page cache里面是否有缓存页。如果没有找到,不但读取这一页,还要进行预读,这需要在page_cache_sync_readahead函数中实现。预读完了以后,再试一把查找缓存页,这时应该就能找到了。如果第一次找缓存页就找到了,还是要判断是不是应该继续预读;如果需要,就调用page_cache_async_readahead发起一个异步预读。最后,copy_page_to_iter会将内容从内核缓存页拷贝到用户内存空间

21. 读写的过程还是很复杂的,这里有一张调用图,可以看到调用过程:

在系统调用层需要仔细学习read和write。在VFS层调用的是vfs_read和vfs_write并且调用file_operation。在ext4层调用的是ext4_file_read_iter和ext4_file_write_iter。接下来就是分叉,需要知道缓存I/O和直接I/O。直接I/O读写的流程是一样的,调用ext4_direct_IO,再往下就调用块设备层了。缓存I/O读写的流程不一样,对于读,从块设备读取到内核缓存中,然后从缓存中拷贝到用户态。对于写,从用户态拷贝到缓存,设置缓存页为脏,然后启动一个线程写入块设备

  • 1
    点赞
  • 0
    评论
  • 9
    收藏
  • 打赏
    打赏
  • 扫一扫,分享海报

参与评论 您还未登录,请先 登录 后发表或查看评论
©️2022 CSDN 皮肤主题:像素格子 设计师:CSDN官方博客 返回首页

打赏作者

书忆江南

莫怕真理无穷,进一寸有一寸欢喜

¥2 ¥4 ¥6 ¥10 ¥20
输入1-500的整数
余额支付 (余额:-- )
扫码支付
扫码支付:¥2
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、C币套餐、付费专栏及课程。

余额充值