操作系统之文件系统

27 文件系统
文件系统:和项目运行生命周期无关的地方,可以永久保存,并且空间大。

文件系统的功能规划
第一点,文件系统要有严格的组织形式,使得文件能够以块为单位进行存储。
第二点,文件系统中也要有索引区,用来方便查找一个文件分成的多个块都存放在了什么位置。
第三点,如果文件系统中有的文件是热点文件,近期经常被读取和写入,文件系统应该有缓存层。
第四点,文件应该用文件夹的形式组织起来,方便管理和查询。
第五点,Linux内核要在自己的内存里面维护一套数据结构,来保存哪些文件被哪些进程打开和使用。

文件系统相关命令行
0 查看硬盘分区情况,
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,是没有格式化的。

1 格式化
也即将一块盘使用命令组织成一定格式的文件系统的过程。咱们买个硬盘或者 U 盘,经常说要先格式化,才能放文件,说的就是这个。
命令:mkfs.ext3 或者 mkfs.ext4
如:
mkfs.ext4 /dev/vdc

也可以将硬盘格式化为多个分区,具体如下:
#启动一个交互式程序
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.

2 挂载
格式化后的硬盘,需要挂在到某个目录下面,才能作为普通的文件系统进行访问。
如:
mount /dev/vdc1 /根目录/用户A目录/目录1
注意:执行上面指令后,“/根目录/用户A目录/目录1”这个目录下面原来的文件就看不到了
卸载:
umount /根目录/用户A目录/目录1

3 区分文件类型
Linux 里面一切都是文件,那从哪里看出是什么文件呢?要从 ls -l 的结果的第一位标识位看出来。
- 表示普通文件;
d 表示文件夹;
c 表示字符设备文件,这在设备那一节讲解;
b 表示块设备文件,这也在设备那一节讲解;
s 表示套接字 socket 文件,这在网络那一节讲解;
l 表示符号链接,也即软链接,就是通过名字指向另外一个文件,例如下面的代码,instance 这个文件就是指向了 /var/lib/cloud/instances 这个文件
# ls -l
lrwxrwxrwx 1 root root   61 Dec 14 19:53 instance -> /var/lib/cloud/instances


文件系统相关系统调用
如:fd=open("./test", O_RDWR|O_CREAT|O_TRUNC)
当使用系统调用 open 打开一个文件时,操作系统会创建一些数据结构来表示这个被打开的文件。
在进程中,我们会为这个打开的文件分配一个文件描述符 fd(File Descriptor)。
- write 写数据, 参数 fd, 数据位置, 写入字节数; 返回成功写入字节数
- lseek 重新定位读写位置, 参数 fd, 位置, SEEK_SET
- read 读数据, 参数 fd, 存放位置, 读取字节数; 返回成功读取字节数
- close 关闭文件
- stat/lstat 通过文件名获取文件信息; fstat 通过 fd 获取文件信息
- opendir 打开一个目录, 生成一个目录流 DIR
- readdir 读取目录流的一个条目, 自动指向下一个条目
- closedir 关闭目录流

28 硬盘文件系统
硬盘:
硬盘分成多层;每一层里分多个磁道,每个磁道分多个扇区,每个扇区是 512 个字节。

inode与块存储
硬盘分成相同大小的单元,我们称为块(Block)。一块的大小是扇区大小的整数倍,默认是 4K。
分块之后,存放一个文件,就不用给他分配一块连续的空间了。我们可以分散成一个个小块进行存放。这样就灵活得多,也比较容易添加、删除和插入数据。

一方面,块会导致文件的数据存放得太散,不易寻找;另一方面,文件还有名字、权限等元数据;所以引入inode来存放。
每个文件都会对应一个 inode;一个文件夹就是一个文件,也对应一个 inode。

inode中的信息:
1)元数据相关:文件的读写权限i_mode,属于哪个用户i_uid,哪个组i_gid,大小是多少i_size_io,占用多少个块i_blocks_io。ls命令的结果也是来自这里。
2)时间相关:i_atime是access time,是最近一次访问文件的时间;i_ctime是change time,是最近一次更改inode的时间;i_mtime 是modify time,是最近一次更改文件的时间。
3)存储文件的块信息。EXT4_N_BLOCKS,定义如下:
#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个块,按照ext3的方式会非常散;
Exents则可以用于存放连续的块,也就是说,我们可以把 128M 放在一个 Extents 里面。这样的话,对大文件的读写性能提高了,文件碎片也减少了。
exents的结构如下图:

树上的每个节点包含两个数据结构:
1)ext4_extent_header, 可以用来描述某个节点。
2)多项ext4_extent或ext4_extend_idx,如果是叶子节点,这一项会直接指向硬盘上的连续块的地址,我们称为数据节点 ext4_extent;如果是分支节点,这一项会指向下一层的分支节点或者叶子节点,我们称为索引节点 ext4_extent_idx。


inode的位图和快位图
硬盘上肯定有一系列的inode和一系列的块排列起来。
所以,如果我要保存一个数据块,或者要保存一个inode,我应该放在硬盘上的哪个位置呢?
所以在文件系统里面,我们专门弄了一个块来保存 inode 的位图。
在这 4k 里面,每一位对应一个 inode。如果是1,表示这个 inode已经被用了;如果是 0,则表示没被用。
同样,我们也弄了一个块保存block的位图。

inode位图是如何起作用的呢?
系统调用open在空文件夹下创建文件: 
do_sys_open-> do_filp_open->path_openat->do_last->lookup_open ,涉及到文件夹的操作,如创建文件夹:调用 dir_node→i_op_create(ext4_create) 创建文件夹 inode
ext4_create→...→__ext4_new_inode 读取 inode 位图, 找到下一个空闲 inode,并创建文件的inode
- 同样用块位图找空闲块

文件系统的格式
采用“一个块的位图+一系列的块”,外加“一个块的inode的位图+一系列的inode的结构”,最多能够表示 128M。
大文件肯定大于128M,所以需要使用多个这样的结构,称为块组。
多个块组组成一个列表,称为块组描述符表。
还需要有一个数据结构,对整个文件系统的情况进行描述,这个就是超级块。记录整个文件系统一共有多少 inode,s_inodes_count;一共有多少块,s_blocks_count_lo,每个块组有多少 inode,s_inodes_per_group,每个块组有多少块,s_blocks_per_group 等。
如果是一个启动盘,我们需要预留一块区域作为引导区。

所以整个文件系统格式如图:

超级块和块组描述符表都是全局信息; 默认超级块和块组描述符表在每个块组都有备份; 这样会浪费空间。
可以采用 Meta Block Groups 特性, 避免块组表浪费空间, 或限制文件系统的大小,具体方案为:
将块组分成多个组(元块组) 块组描述符表只保存当前元块组中块组的信息, 并在元块组内备份自己的块组描述符表.
如:一共有 256 个块组,原来是一个整的块组描述符表,里面有 256 项,要备份就全备份,现在分成 4 个元块组,每个元块组里面的块组描述符表就只有 64 项了,这就小多了,而且四个元块组自己备份自己的。


- 目录存储格式
    - 目录也是文件, 也有 inode, inode 指向一个块, 块中保存各个文件信息, ext4_dir_entry 包括文件名和 inode, 默认按列表存
    - 第一项 "." 当前目录; 第二项 ".." 上一级目录
    - 可添加索引, 加快文件查找
        - 需要改变目录块格式, 加入索引树: 用索引项 dx_entry 保存文件名哈希和块的映射, 若该块不是索引, 则里面保存 ext4_dir_enry 列表, 逐项查找
- 软连接/硬链接的存储
    - 链接即文件的别名: ln -s 创建软链接; ln 创建硬链接
    - 硬链接与原始文件共用一个 inode, 但不能跨文件系统
    - 软链接是一个文件, 有自己的 inode, 该文件内容指向另一个文件, 可跨文件系统
 

29 虚拟文件系统
进程要想往文件系统里面读写数据,需要很多层的组件一起合作,具体如下:
在应用层,进程在进行文件读写操作时,可通过系统调用如 sys_open、sys_read、sys_write 等。
在内核,每个进程都需要为打开的文件,维护一定的数据结构。
在内核,整个系统打开的文件,也需要维护一定的数据结构。

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

然后就是对接的是真正的文件系统,例如我们上节讲的 ext4 文件系统。
为了读写 ext4 文件系统,要通过块设备 I/O 层,也即 BIO 层。这是文件系统层和块设备驱动的接口。
为了加快块设备的读写效率,我们还有一个缓存层。
最下层是块设备驱动程序。

其结构如下图:

 


重点关注几个系统调用:
mount 系统调用用于挂载文件系统;
open 系统调用用于打开或者创建文件,创建要在 flags 中设置 O_CREAT,对于读写要设置 flags 为 O_RDWR;
read 系统调用用于读取文件内容;
write 系统调用用于写入文件内容。

挂载文件系统详解
挂载前先确认是不是支持某种类型的文件系统:
通过 register_filesystem 进行注册,传入的参数是 ext4_fs_type。如果一种文件系统的类型曾经在内核注册过,这就说明允许你挂载并且使用这个文件系统。

mount 系统调用:
调用链为:do_mount->do_new_mount->vfs_kern_mount,
vfs_kern_mount 先是创建 struct mount 结构,即:

struct mount {
  struct hlist_node mnt_hash;
  struct mount *mnt_parent;//装载点所在的父文件系统
  struct dentry *mnt_mountpoint;// 装载点在父文件系统中的 dentry,struct dentry 表示目录,并和目录的 inode 关联
  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;  // 当前文件系统根目录的 dentry,
  struct super_block *mnt_sb;  //  是指向超级块的指针。
  int mnt_flags;
} __randomize_layout;


再接下来调用mount_fs挂载文件系统:
调用ext4_mount,从文件系统里面读取超级块。在文件系统的实现中,每个在硬盘上的结构,在内存中也对应相同格式的结构。当所有的数据结构都读到内存里面,内核就可以通过操作这些数据结构,来操作文件系统了

理解各个数据结构在这里的关系,非常重要。举一个例子,来解析经过 mount 之后,刚刚那些数据结构之间的关系:
假设根文件系统下面有一个目录 home,有另外一个文件系统 A 挂载在这个目录 home 下面。在文件系统 A 的根目录下面有另外一个文件夹 hello。由于文件系统 A 已经挂载到了目录 home 下面,所以我们就有了目录 /home/hello

打开文件open
在进程里面通过 open 系统调用打开文件,最终调用到内核的系统调用实现 sys_open。具体过程如下:
1)通过 get_unused_fd_flags 得到一个没有用的文件描述符
说明:每一个进程的 task_struct 中,有一个指针 files,类型是 files_struct。
files_struct里面最重要的是一个文件描述符列表,每打开一个文件,就会在这个列表中分配一项,如下:
struct files_struct {
......
  struct file __rcu * fd_array[NR_OPEN_DEFAULT];
};

文件描述符列表的每一项都是一个指向 struct file 的指针,也就是说,每打开一个文件,都会有一个 struct file 对应。

do_sys_open中调用 do_filp_open,就是创建这个 struct file 结构,然后 fd_install(fd, f) 是将文件描述符和这个结构关联起来。

do_file_open解析:
1 首先初始化了 struct nameidata 这个结构,其中有一个关键的成员变量,struct path如下:
struct path {
  struct vfsmount *mnt; // 和文件系统的挂载有关
  struct dentry *dentry;//除了上面说的用于标识目录之外,也表示文件名,还会建立文件名及其 inode 之间的关联
} __randomize_layout;
2 调用path_openat,主要工作为:
1)get_empty_filp 生成一个 struct file 结构;
2)path_init 初始化 nameidata,准备开始节点路径查找;
3)link_path_walk 对于路径名逐层进行节点路径查找。以文件“/root/hello/data”为例,解析后nameidata 的 dentry为/root/hello,nameidata 的 filename 为data
4)do_last 获取文件对应的 inode 对象,并且初始化 file 对象。

总结:

对于虚拟文件系统的解析就到这里了,有关文件的数据结构层次多,而且很复杂。
如图:

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

对于每一个打开的文件,都有一个 dentry 对应,虽然叫作 directory entry,但是不仅仅表示文件夹,也表示文件。它最重要的作用就是指向这个文件对应的 inode。

如果说 file 结构是一个文件打开以后才创建的,dentry 是放在一个 dentry cache 里面的,文件关闭了,他依然存在,因而他可以更长期地维护内存中的文件的表示和硬盘上文件的表示之间的关系。
inode 结构就表示硬盘上的 inode,包括块设备号等。
几乎每一种结构都有自己对应的 operation 结构,里面都是一些方法,因而当后面遇到对于某种结构进行处理的时候,如果不容易找到相应的处理函数,就先找这个 operation 结构,就清楚了。

30 文件缓存
系统函数read
vfs_read->__vfs_read,调用相应文件系统的 file_operations 里面的 read 操作

系统函数write
vfs_write->__vfs_write,调用相应文件系统 file_operations 里的 write 操作

ext4 文件系统层
内核对ext4文件系统定义了一个ext4_file_operations,对应的读写函数是:
ext4_file_read_iter 会调用 generic_file_read_iter,
ext4_file_write_iter 会调用 __generic_file_write_iter。

Linux 为了改进性能,有时候会选择不直接操作硬盘,而是读写都在内存中,然后批量读取或者写入硬盘。

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

第二种类型是直接 IO,就是应用程序直接访问磁盘数据,而不经过内核缓冲区,从而减少了在内核缓存和用户程序之间数据复制。
读的逻辑 generic_file_read_iter 里面,发现设置了 IOCB_DIRECT,则会调用 address_space 的 direct_IO 的函数,将数据直接读取硬盘。direct_IO 最终会调用到 __blockdev_direct_IO->do_blockdev_direct_IO,这就跨过了缓存层,到了通用块层,最终到了文件系统的设备驱动层。

对于缓存来讲,也需要文件和内存页进行关联,这就要用到 address_space(主要用于在内存映射的时候将文件和内存页产生关联)。address_space 的相关操作定义在 struct address_space_operations 结构中。


带缓存的写入操作
带缓存写入的函数是generic_perform_write,函数里,是一个 while 循环。我们需要找出这次写入影响的所有的页,然后依次写入。对于每一个循环,主要做四件事情:
第一步:对于每一页,先调用 address_space 的 write_begin 做一些准备;
ext4格式调用ext4_write_begin,首先调用了ext4_journal_start,有三种模式:
新引入了日志模式:比非日志文件系统多了一个 Journal 区域。文件在 ext4 中分两部分存储,一部分是文件的元数据,另一部分是数据。在将数据写入文件系统前,必须等待元数据和数据的日志已经落盘才能发挥作用。这样性能比较差,但是最安全。
另一种模式是 order 模式。这个模式不记录数据的日志,只记录元数据的日志,但是在写元数据的日志前,必须先确保数据已经落盘。这个折中,是默认模式。
还有一种模式是 writeback,不记录数据的日志,仅记录元数据的日志,并且不保证数据比元数据先落盘。这个性能最好,但是最不安全。

ext4_write_begin还调用了grab_cache_page_write_begin,得到应该写入的缓存页。
每一个打开的文件都有一个 struct file 结构,每个 struct file 结构都有一个 struct address_space 用于关联文件和内存,就是在这个结构里面,有一棵树,用于保存所有与这个文件相关的的缓存页。且可以根据文件中的偏移量找出相应的页面,如果找不到则会从文件读取并创建一个缓存页。


第二步:调用 iov_iter_copy_from_user_atomic,将写入的内容从用户态拷贝到内核态的页中;

第三步:调用 address_space 的 write_end 完成写操作;
会调用 ext4_journal_stop 完成日志的写入,会调用 block_write_end->__block_commit_write->mark_buffer_dirty,将修改过的缓存标记为脏页。到这里并未真正写入硬盘,仅仅做了标记。

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


带缓存的读操作
对应的是函数 generic_file_buffered_read。
需要先找到 page cache 里面是否有缓存页。如果没有找到,不但读取这一页,还要进行预读,这需要在 page_cache_sync_readahead 函数中实现。预读完了以后,再试一把查找缓存页,应该能找到了。

如果第一次找缓存页就找到了,我们还是要判断,是不是应该继续预读;如果需要,就调用 page_cache_async_readahead 发起一个异步预读。

最后,copy_page_to_iter 会将内容从内核缓存页拷贝到用户内存空间。


总结:
在系统调用层我们需要仔细学习 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 读写的流程不一样。对于读,从块设备读取到缓存中,然后从缓存中拷贝到用户态。对于写,从用户态拷贝到缓存,设置缓存页为脏,然后启动一个线程写入块设备。

如图所示:

查询和清除文件系统缓存命令是什么?
查看文件缓存:通过free命令中的buff/cache一栏的信息即可看到文件缓存的用量。
清除缓存:sync; echo 1 > /proc/sys/vm/drop_caches
 

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值