[4]开发一个Linux系统吧——VFS

虚拟文件系统

VFS主要支持三种文件类型:

  • 磁盘文件系统:比如ext4、ext3等,这种文件系统起到磁盘的作用。可以联想windows的NTFS系统。
  • 网络文件系统:NFS,可以允许从网络中读取文件
  • 特殊文件系统:比如proc用于保存进程,FIFO保存管道,这种文件不管理硬盘空间

VFS的目的是引出一个通用文件模型,本质上,Linux内核在访问文件系统都是使用函数指针进行操作,通常我们将文件系统看作是oop的。

普通文件模型中最基本的就是超级块对象,超级块就是系统的循环链表中的一个节点对象,它定义在linux/fs.h内:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
struct super_block {
// 系统头
struct list_head s_list;
dev_t s_dev;
// 系统块的头节点
unsigned char s_blocksize_bits;
// 系统块的大小
unsigned long s_blocksize;
// 最大文件限制
loff_t s_maxbytes;
// 文件系统类型
struct file_system_type *s_type;
unsigned long s_flags;
unsigned long s_iflags;
unsigned long s_magic;
// 根节点
struct dentry *s_root;
struct rw_semaphore s_umount;
// 系统的总数
int s_count;
// 活跃时间
atomic_t s_active;
// NFS的根节点
struct hlist_bl_head s_roots;
// NFS列表长度
struct list_head s_mounts;
// 块驱动器
struct block_device *s_bdev;

// 私有信息
void *s_fs_info;
// 时间限制
time64_t s_time_min;
time64_t s_time_max;

// 名字
char s_id[32];
// 编号
uuid_t s_uuid;

// 表示偏移量
atomic_long_t s_remove_count;

// 用户空间
struct user_namespace *s_user_ns;

// 文件系统锁(比如在多系统中使用)
struct mutex s_sync_lock;
int s_stack_depth;
spinlock_t s_inode_list_lock ____cacheline_aligned_in_smp;
struct list_head s_inodes;
} __randomize_layout;

第二个就是索引节点对象,依然保存在fs.h内,主要用于保存文件的属性信息,例如文件大小或者文件标识符等:

inode有两种,一种是VFS的inode,一种是具体文件系统的inode。前者在内存中,后者在磁盘中。所以每次其实是将磁盘中的inode调进填充内存中的inode,这样才是算使用了磁盘文件inode,同时inode号是唯一的,表示不同的文件。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
struct inode {
// inode编号(唯一的)
umode_t i_mode;
unsigned short i_opflags;
// 索引节点的虚拟id和物理id
kuid_t i_uid;
kgid_t i_gid;
unsigned int i_flags;

// 操作类型
const struct inode_operations *i_op;
struct super_block *i_sb;
struct address_space *i_mapping;

union {
const unsigned int i_nlink;
unsigned int __i_nlink;
};
// 设备编号
dev_t i_rdev;
// 索引大小
loff_t i_size;
// 文件锁
spinlock_t i_lock;
// 块设备大小
u8 i_blkbits;

// 文件的信息
atomic64_t i_version;
atomic64_t i_sequence;
// 链接数
atomic_t i_count;
atomic_t i_dio_count;
atomic_t i_writecount;

// 保存的方法
union {
const struct file_operations *i_fop;
void (*free_inode)(struct inode *);
};
// 设备的锁
struct file_lock_context *i_flctx;
// 设备地址
struct address_space i_data;
// 设备的头节点
struct list_head i_devices;
// 管道信息
union {
struct pipe_inode_info *i_pipe;
struct cdev *i_cdev;
char *i_link;
unsigned i_dir_seq;
};
// 系统管理用的指针
void *i_private;
} __randomize_layout;

第三个是目录结构,负责描述文件的逻辑属性,VFS将它当作一个文件看待,他也是路径的组成部分之一,它只存在于内存中,存在的意义是提升文件索引的能力,最主要的就是文件夹也属于目录结构,这些目录结构构成了一颗庞大的树。目录结构对应的结构体是dentry,他定义在linux/dcache.h内

目录也是inode,也有对应的编号

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
struct dentry {
// 文件夹标识
unsigned int d_flags;
seqcount_spinlock_t d_seq;
// 散列值
struct hlist_bl_node d_hash;
// 父目录
struct dentry *d_parent;
struct qstr d_name;
// 对应的inode对象
struct inode *d_inode;
// 文件夹的名字
unsigned char d_iname[DNAME_INLINE_LEN];

// 文件的权限锁
struct lockref d_lockref;
// 目录操作
const struct dentry_operations *d_op;
// 根节点,也就是超级块对象(它保存了除它以外所有VFS的对象)
struct super_block *d_sb;
// 有效时间
unsigned long d_time;
// 私有数据
void *d_fsdata;

union {
// 没有被使用的目录
struct list_head d_lru;
// 只读文件夹
wait_queue_head_t *d_wait;
};
// 子文件
struct list_head d_child;
// 子文件夹
struct list_head d_subdirs;
// 只读信息
union {
struct hlist_node d_alias;
struct hlist_bl_node d_in_lookup_hash;
struct rcu_head d_rcu;
} d_u;
} __randomize_layout;

最后就是最基础的文件对象,因为一个文件可以被多个进程打开,所以文件对象不唯一,但是inode唯一。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
struct file {
// 文件列表
union {
struct llist_node f_llist;
struct rcu_head f_rcuhead;
unsigned int f_iocb_flags;
};
// 文件路径
struct path f_path;
// 唯一的inode标识
struct inode *f_inode;
// 文件操作
const struct file_operations *f_op;

// 文件锁
spinlock_t f_lock;
// 引用计数器
atomic_long_t f_count;
// 文件的标志
unsigned int f_flags;
// 文件模式(读或写)
fmode_t f_mode;
// 偏移量锁,避免多进程读取紊乱
struct mutex f_pos_lock;
// 关于头的偏移量
loff_t f_pos;
// 保存进程ID
struct fown_struct f_owner;
const struct cred *f_cred;
struct file_ra_state f_ra;

u64 f_version;
void *private_data;

struct address_space *f_mapping;
errseq_t f_wb_err;
errseq_t f_sb_err; /* for syncfs */
} __randomize_layout
__attribute__((aligned(4)));// 使用4字节对齐,也可以为两字节,因为一个文件一定是1的有限倍数且仅为1的倍数

我们发现,每一个结构体都有相似的属性比如f_flags、f_model等,这些是文件的控制信息,如果多进程读取文件可以保护文件内容。

从结构上看我们从大到小依次介绍了一遍,从结构上看,File是最基本的对象,这些对象有对应的dentry进行分类管理,而这些文件都有inode,它们被超级块(super_block)管理。

所以我们知道了在linux系统中,真正管理对象的是inode而不是文件属性,文件属性只是供人使用的内容。我们使用指令ls -li就可以获取当前目录下所有文件的信息,其中第一列对应的就是inode编号,其余的就是file属性包括读写属性,所有用户、文件大小、创建日期和文件名。

1
2
3
4
5
6
7
8
minloha@minloha:~$ ls -li
total 20
135106 drwxrwxrwx 2 root root 4096 Dec 23 00:44 boot
135389 drwxrwxrwx 2 root root 4096 Dec 23 15:17 c
123030 drwxrwxrwx 12 minloha minloha 4096 Dec 22 21:57 glib-2.45.2
810 drwxrwxrwx 26 root root 4096 Dec 23 00:22 linux
122595 drwxrwxrwx 4 minloha minloha 4096 Dec 22 21:53 pkg-config-0.29.2
minloha@minloha:~$

因为文件的inode是唯一的,这样我们就明白了ln 进行的文件链接的具体链接的是什么了:

硬链接就是生成一个指向原文件lnode的指针,操作指针的同时也操作了原文件,相当于是文件的别名。不过inode是对应的超级块进行分配的,一旦跨文件系统,inode不唯一时,硬链接就无法链接了。如果这个时候删除了源文件,硬链接是不会消失的,因为inode链接数仍然大于1,VFS会将他识别为一个有效的文件。

软链接就是一个保存了目标文件的路径和文件名的逻辑信息文件,是会被重新赋予inode编号的,所以源文件删除后,软链接文件就失去了目标,自然无法发挥作用。

Linux最先启动的文件系统就是根目录,因为很多内核代码保存在根目录内,这样的话也方便其余文件能够挂在到根目录上,形成完整的文件结构。

关于根文件系统的挂载阶段有三部分:

  • 第一部分:挂在rootfs,提供“/”路径
  • 加载initrd,续接VFS树
  • 执行init,完成初始化,将文件系统的根从rootfs切换到磁盘文件系统

在init/main.c内描述了启动的过程:

2

据图分析,在高速缓存阶段已经初始化了磁盘、系统、根、文件树文件系统,按照这个次序就可以建立一个初始化目录的哈希表,内核可以设置最大的打开数,同时大大提高了查找效率。同时因为构造的目录保存在缓存中,在之后的使用会更快捷。

mnt_init()和sysfs_init()负责初始化结构,而init_rootfs负责挂载根目录,同时注册根文件系统。

完成了目录初始化之后,为了提高效率,内核制定了两种数据结构:

  • 正在使用和未使用的目录项
  • 包含了可以快速获取文件名于目录名对应的散列表

进程也有自己的工作目录,也包含在VFS内,每个进程都使用fs_struct结构保存,他定义在linux/fs_struct.h下:

1
2
3
4
5
6
7
8
9
10
11
12
struct fs_struct {
// 使用数
int users;
// 进程锁
spinlock_t lock;
seqcount_spinlock_t seq;
int umask;
// 返回码
int in_exec;
// 记录工作路径、根目录、打开的文件.
struct path root, pwd;
} __randomize_layout;

这就是进程描述符的fs指向的结构。files_struct是为了记录进程打开的文件而使用的结构体,他定义在linux/fdtable.h下

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
struct files_struct {
// 被读数
atomic_t count;
bool resize_in_progress;
wait_queue_head_t resize_wait;

struct fdtable __rcu *fdt;
struct fdtable fdtab;

// 在缓存上写入的部分
spinlock_t file_lock ____cacheline_aligned_in_smp;
unsigned int next_fd;
unsigned long close_on_exec_init[1];
unsigned long open_fds_init[1];
unsigned long full_fds_bits_init[1];
// 缓存上保存的文件对象数
struct file __rcu * fd_array[NR_OPEN_DEFAULT];
};

我们发现缓存保存的fd_array使用大小为NR_OPEN_DEFAULT,它的大小区间是32~64,所以多出来的文件需要有新的存储空间,内核会根据情况对fd_array进行扩容。而进行扩容管理的结构体定义在linux/fdtable.h内:

1
2
3
4
5
6
7
8
9
10
struct fdtable {
// fd_array大小
unsigned int max_fds;
// 指向fd_array的指针
struct file __rcu **fd;
unsigned long *close_on_exec;
unsigned long *open_fds;
unsigned long *full_fds_bits;
struct rcu_head rcu;
};

这里有一些特殊的文件系统举例,VFS需要对所有的文件系统类型进行跟踪,所以每个文件系统都需要进行注册,注册使用file_system_type对象表示。

3

图片摘自:https://zhuanlan.zhihu.com/p/482045070

所有文件系统都插入为file_systems的元素

file_systems_lock 读/写自旋锁保护整个链表免受同时访问。

file_system_type 的一些字段:

fs_supers,表示给定类型的已安装文件系统所对应的超级块链表的头。

链表元素的向后和向前链接存放在超级块对象的 s_instances 字段。

get_sb,指向依赖于文件系统类型的函数,该函数分配一个新的超级块对象并初始化它。

kill_sb,指向删除超级块的函数。

fs_flags,存放几个标志。

在系统初始化器间,register_filesystem() 注册编译时指定的每个文件系统:

该函数把相应的 file_system_type 对象插入到文件系统类型的链表。

当文件系统的模块被装入时,也要调用 register_filesystem()。

当该模块被卸载时,对应的文件系统也可以被注销。

get_fs_type() 扫描已注册的文件系统链表以查找文件系统类型的 name 字段,并返回指向相应的 file_system_type 对象的指针。


我们还记得之前所说的一切内容皆文件吧,下面看看进程在VFS内的体现。内核通过命名空间抽象资源,同时分离为各个不同的容器使得彼此之间相互隔离,但同时耶提供了一些可以交互的接口。

创建一个这样的命名空间有两种方式:

  • 使用fork或clone创建子进程时可控制是否共用命名空间
  • 可以分离某部分变成新的命名空间

在linux/nsproxy.h内定义了子进程命名空间的指针,每个进程都有一个这样的命名空间。

1
2
3
4
5
6
7
8
9
10
11
12
13
struct nsproxy {
// 进程计数
atomic_t count;
// 各种类型的命名空间
struct uts_namespace *uts_ns;
struct ipc_namespace *ipc_ns;
struct mnt_namespace *mnt_ns;
struct pid_namespace *pid_ns_for_children;
struct net *net_ns;
struct time_namespace *time_ns;
struct time_namespace *time_ns_for_children;
struct cgroup_namespace *cgroup_ns;
};

下面详细描述一下这些命名空间的具体含义,请看变量名:

  • uts:隔离用户
  • ipc:应用于进程通讯,当于PID空间组合起来的时候,同一个IPC空间的进程可以与彼此通信
  • mnt:每个进程都包含一个这样的命名空间,它提供了一个文件层次,如果父进程不设定,那么子进程可能会影响到所有这样的进程。
  • pid:进程号管理,他是进程的唯一标志,且仅父空间看到子空间的pid号
  • net:为进程提供了网络协议,包括Socket套接字等等,它负责提供网络环境
  • time: 时钟空间,主要负责处理中断时间时或者进程调度时进行切换
  • cgroup:Linux内核的控制空间,可以控制和管理子系统

卸载文件系统主要按照下列步骤

  • 查找文件系统的挂在路径,保存查询结果
  • 如果目标没有在命名空间内,则跳到最后一步
  • 如果没有权限删除文件系统,则跳到最后一步
  • 条件都满足,则:
    • 找到文件系统的超级块,停止文件系统的所有工作
    • 挂在mutex锁,保护命名空间
    • 释放文件系统内的各种对象。
    • 释放自旋锁,释放命名空间
  • 减少计数值和文件描述符的值,返回结束值

这部分的结束介绍一下文件锁,类似与多线程,文件锁也有互斥锁和自旋锁,不过名字不一样,这里进行一下联想记忆:

  • 写入锁(互斥锁):只能由一个进程调用文件
  • 强制性锁(自旋锁):进程读取后自动加锁,防止读取错误
  • 建议性锁(使用时为互斥锁):如果出现多个疑似进程调用文件时,自动保护

RAM到ROM的缓存

我们知道了SRAM到DRAM的缓存是cache,他是一种分级管理的缓存塔,主要由CPU进行控制,而如果需要将硬盘上保存的内容读取到内存上,Linux内核就使用了一种页高速缓存的方法,它的核心结构体是address_space,它嵌入在页的索引节点中,同时可以组建多个这样的页为一个表,进行方便的管理。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
struct address_space {
// 块设备拥有的节点
struct inode *host;
// 包含页面的基数树
struct radix_tree_root page_tree;
// 保护基数树的自旋锁
rwlock_t tree_lock;
// mappings的共享映射数
unsigned int i_mmap_writable;
// 优先搜索树的树根
struct prio_tree_root i_mmap;
// 非线性映射的链表头
struct list_head i_mmap_nonlinear;
// 保护i_mmap的自旋锁
spinlock_t i_mmap_lock;
// 将文件截断的记数
unsigned int truncate_count;
// 页总数
unsigned long nrpages;
// 回写的起始偏移
pgoff_t writeback_index;
// 操作函数表
struct address_space_operations *a_ops;
// gfp_mask掩码与错误标识
unsigned long flags;
// 预读信息
struct backing_dev_info *backing_dev_info;
// 私有address_space锁
spinlock_t private_lock;
// 私有address_space链表
struct list_head private_list;
// 缓冲区
struct address_space *assoc_mapping;
} __attribute__((aligned(sizeof(long))));

为了方便查找,每个address_space对象都有一棵搜索树,它包含指向所有者的页描述符的指针。当查找所需要的页时,内核把页索引转换为基数树中的路径,并快速找到页描述符所在的位置。如果找到,内核可从基数树获得页描述符,并很快确定所找的页是否为脏页,以及其数据的 I/O 传送是否正值进行。

address_space有这么多方法:

  • writepage 写操作,从页写到所有者的磁盘映像

  • readpage 读操作,从所有者的磁盘映像读到页

  • sync_page 如果对所有者页进行的操作已准备好,则立刻开始I/O数据的传输

  • writepages 把指定数量的所有者脏页写回磁盘。

  • set_page_dirty 把所有者的页设置为脏页

  • readpages 从磁盘中读所有者页的链表

  • prepare_write 为写操作做准备(由磁盘文件系统使用)

  • commit_write 完成写操作(由磁盘文件系统使用)

  • bmap 从文件块索引中获取逻辑块号

  • invalidatepage 是所有者的页无效(截断文件时使用)

  • releasepage 由日志文件系统使用以准备释放页

  • direct_IO 所有者页的直接I/O传输 (绕过了页高速缓存 page cache)

基数树可以有64个指针指向radix_tree_node节点,它定义在linux/radix-tree.h下

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
struct radix_tree_node {
// 当前节点的高度
unsigned int height;
// 节点数目
unsigned int count;
union {
// 父节点
struct radix_tree_node *parent;
// 释放节点
struct rcu_head rcu_head;
};
// 包含64个指针的数组
void __rcu *slots[RADIX_TREE_MAP_SIZE];
// 标记数组
unsigned long tags[RADIX_TREE_MAX_TAGS][RADIX_TREE_TAG_LONGS];
};
// 生成根节点
struct radix_tree_root {
// 树的深度
unsigned int height;
// 请求内存使用的标志
gfp_t gfp_mask;
// 指向rtnode
struct radix_tree_node __rcu *rnode;
}

当数据从ROM读取之后都会保存在这样的基数树内,这样的缓冲区也叫page cache,其中缓冲区的顶部就是buffer head,页块缓冲与都是页,buffer head包含了块设备的设备信息、编号、位置、偏移量等。

从宏观的角度看缓冲区是这样的。 从硬盘读取到的内容就会保存在这些缓冲区页内,不过随着写入量的增加就会产生一些脏页。

当缓冲区写满后,页面就变成了脏页,这是就需要将数据重新写入到硬盘内,如果不进行保存,脏页太多就会倒是内存不够用。这里有脏页的处理办法。

  • sync():允许进程把所有脏缓冲区刷新到磁盘。
  • fsync():允许进程把属于特定打开文件的所有块刷新到磁盘。
  • fdatasync():与 fsync() 相似,但不刷新文件的索引节点块。

内核时允许对缓冲区进行IO操作的,所以缓冲区建立好后就会发起bio操作,就是同步操作,负责从硬盘中同步读或写操作,然后将内容保存到缓存中。这样内核就可以更快速的获取到数据。


[4]开发一个Linux系统吧——VFS
https://blog.minloha.cn/posts/190913f7f9203b2023010935.html
作者
Minloha
发布于
2023年1月9日
更新于
2023年12月21日
许可协议