0%

Linux VFS

文件系统是操作系统中负责管理持久数据的子系统,其基本数据单位是文件。 在 Linux 中 everything is a file,除了你写的文档、下载的音乐、运行的程序,还包括网络连接的 socket、挂载的设备、管道等等都属于文件。所有不同类型的文件都交由文件系统来管理。对文件的组织形式不同,也就对应着不同类型的文件系统。为了屏蔽不同文件系统的差异和操作细节,接入不同类型的文件系统,Linux 通过 VFS 将 openreadwrite 这样的系统调用抽象出来,为用户程序提供了文件和文件系统的统一接口,不再需要考虑具体的文件系统和实际的存储介质。本文将介绍 Linux VFS 的设计与实现,参考 Linux 内核实现版本为 v5.4。

Linux 支持的各种不同类型的文件系统,根据存储位置的不同,可以分为三类:

  • 硬盘的文件系统:比如 xfs,ext4 等
  • 内存的文件系统,比如 /proc,/sys,读写这类文件,实际上是读写内核中相关的数据结构
  • 网络的文件系统,比如 NFS,SMB 等

文件系统的基本单位 Block

磁盘物理结构

如前所述,文件一般是存储在硬盘中的,常见的有机械硬盘和固态硬盘,下图是机械硬盘的常见结构:

  • 机械硬盘中有很多层盘片 Cylinder
  • 每层盘片有多个刺刀 Track
  • 每个磁道分为多个扇区 Sector,每个扇区是 512 字节

不同硬盘的物理结构可能会有区别,于是 OS 在基本的硬件结构上又抽象出 块 block 的概念。一块的大小是扇区大小的整数倍,默认是 4K。在格式化一个硬盘的时候,这个值是可以设定的。

1
2
3
4
5
$ echo "0123456789" > a.txt 
$ ls -alh a.txt
-rw-r--r-- 1 root root 11 Oct 2 15:23 a.txt
$ du -sh a.txt
4.0K a.txt

因此,从 OS 角度看,硬盘的存储空间被划分为许多个块,这样我们存储一个文件,就不用分配一个连续的空间了。我们可以分散成很多个一个个 block 来存放。

Block 组织方式

我们知道,文件系统的基本单位是 block。数据在磁盘上的存放方式,可以分为:

  • 连续空间存放:文件存放在磁盘「连续的」物理空间中
  • 非连续空间存放
    • 链表方式存放
    • 索引方式存放

连续空间存放

连续空间存放方式

  • 读写效率高:数据紧密相连,一次磁盘寻道就可以读出所有数据
  • 文件头需要指定 起始块的位置长度
  • 缺点:
    • 存在磁盘空间碎片
    • 文件长度不易扩展

磁盘碎片

链表方式存放

链表的方式存放是离散的,不用连续的,于是就可以消除磁盘碎片,可大大提高磁盘空间的利用率,同时文件的长度可以动态扩展。根据实现的方式的不同,链表可分为「隐式链表」和「显式链接」两种形式。

隐式链表

文件要以「隐式链表」的方式存放的话,实现的方式是文件头要包含「第一块」和「最后一块」的位置,并且每个数据块里面留出一个指针空间,用来存放下一个数据块的位置,这样一个数据块连着一个数据块,从链头开是就可以顺着指针找到所有的数据块,所以存放的方式可以是不连续的。

隐式链表

隐式链表存放方式的缺点在于

  • 无法直接访问数据块,只能通过指针顺序访问文件
  • 数据块指针消耗了一定的存储空间
  • 分配的稳定性较差,系统在运行过程中由于软件或者硬件错误导致链表中的指针丢失或损坏,会导致文件数据的丢失。
显式链接

如果取出每个磁盘块的指针,把它放在内存的一个表中,就可以解决上述隐式链表的两个不足。那么,这种实现方式是「显式链接」,它指把用于链接文件各数据块的指针,显式地存放在内存的一张链接表中,该表在整个磁盘仅设置一张,每个表项中存放链接指针,指向下一个数据块号

对于显式链接的工作方式,我们举个例子,文件 A 依次使用了磁盘块 4、7、2、10 和 12 ,文件 B 依次使用了磁盘块 6、3、11 和 14 。利用下图中的表,可以从第 4 块开始,顺着链走到最后,找到文件 A 的全部磁盘块。同样,从第 6 块开始,顺着链走到最后,也能够找出文件 B 的全部磁盘块。最后,这两个链都以一个不属于有效磁盘编号的特殊标记(如 -1 )结束。内存中的这样一个表格称为文件分配表(File Allocation Table,FAT)

显式链接

由于查找记录的过程是在内存中进行的,因而不仅显著地提高了检索速度,而且大大减少了访问磁盘的次数。但也正是整个表都存放在内存中的关系,它的主要的缺点是不适用于大磁盘

比如,对于 200GB 的磁盘和 1KB 大小的块,这张表需要有 2 亿项,每一项对应于这 2 亿个磁盘块中的一个块,每项如果需要 4 个字节,那这张表要占用 800MB 内存,很显然 FAT 方案对于大磁盘而言不太合适。

索引方式存放

链表的方式解决了连续分配的磁盘碎片和文件动态扩展的问题,但是不能有效支持直接访问(FAT除外),索引的方式可以解决这个问题。

索引的实现是

  • 为每个文件创建一个「索引数据块」,里面存放的是指向文件数据块的指针列表,说白了就像书的目录一样,要找哪个章节的内容,看目录查就可以。
  • 文件头需要包含指向「索引数据块」的指针,这样就可以通过文件头知道索引数据块的位置,再通过索引数据块里的索引信息找到对应的数据块。

创建文件时,索引块的所有指针都设为空。当首次写入第 i 块时,先从空闲空间中取得一个块,再将其地址写到索引块的第 i 个条目。

索引的方式

索引的方式优点在于:

  • 文件的创建、增大、缩小很方便;
  • 不会有碎片的问题;
  • 支持顺序读写和随机读写;

由于索引数据也是存放在磁盘块的:

  • 如果文件很小,明明只需一块就可以存放的下,但还是需要额外分配一块来存放索引数据,所以缺陷之一就是存储索引带来的开销
  • 如果文件很大,大到一个索引数据块放不下索引信息,这时又要如何处理大文件的存放呢?我们可以通过组合的方式,来处理大文件的存放。

先来看看链表 + 索引的组合,这种组合称为「链式索引块」,它的实现方式是在索引数据块留出一个存放下一个索引数据块的指针,于是当一个索引数据块的索引信息用完了,就可以通过指针的方式,找到下一个索引数据块的信息。那这种方式也会出现前面提到的链表方式的问题,万一某个指针损坏了,后面的数据也就会无法读取了。

链式索引块

还有另外一种组合方式是索引 + 索引的方式,这种组合称为「多级索引块」,实现方式是通过一个索引块来存放多个索引数据块,一层套一层索引,像极了俄罗斯套娃是吧。

多级索引块

Ext2 方案

那 Ext2 文件系统是组合了前面的文件存放方式的优点,如下图:

它是根据文件的大小,存放的方式会有所变化:

  • 如果存放文件所需的数据块小于 12 块,则采用直接查找的方式;
  • 如果存放文件所需的数据块超过 12 块,则采用一级间接索引方式;
  • 如果前面两种方式都不够存放大文件,则采用二级间接索引方式;
  • 如果二级间接索引也不够存放大文件,这采用三级间接索引方式;

那么,文件头(Inode)就需要包含 15 个指针:

  • 12 个指向数据块的指针;
  • 第 13 个指向索引块的指针;
  • 第 14 个指向二级索引块的指针;
  • 第 15 个指向三级索引块的指针;

这里的 inode 不是 VFS 的inode,而是磁盘上面存放的 inode,叫磁盘的索引节点。以 ext2 为例:磁盘索引节点的inode有一个 i_block 字段,有15个元素:每一个元素指向一个数据块

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
struct ext2_inode {
__le16 i_mode; /* File mode */
__le16 i_uid; /* Low 16 bits of Owner Uid */
__le32 i_size; /* Size in bytes */
__le32 i_atime; /* Access time */
__le32 i_ctime; /* Creation 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; /* Blocks count */
__le32 i_flags; /* File flags */

/**
* 指向数据块的指针。
* 前12个块是直接数据块。
* 第13块是一级间接块号。
* 14->二级间接块。
* 15->三级间接块。
*/
__le32 i_block[EXT2_N_BLOCKS];/* Pointers to blocks */

/* ... */
};

所以,这种方式能很灵活地支持小文件和大文件的存放:

  • 对于小文件使用直接查找的方式可减少索引数据块的开销;
  • 对于大文件则以多级索引的方式来支持,所以大文件在访问数据块时需要大量查询;

这个方案就用在了 Linux Ext 2/3 文件系统里,虽然解决大文件的存储,但是对于大文件的访问,需要大量的查询,效率比较低。为了解决这个问题,Ext 4 做了一定的改变,具体怎么解决的,本文就不展开了。

空闲空间管理

前面说到的文件的存储是针对已经被占用的数据块组织和管理,接下来的问题是,如果我要保存一个数据块,我应该放在硬盘上的哪个位置呢?难道需要将所有的块扫描一遍,找个空的地方随便放吗?

那这种方式效率就太低了,所以针对磁盘的空闲空间也是要引入管理的机制,接下来介绍几种常见的方法:

  • 空闲表法
  • 空闲链表法
  • 位图法

空闲表法

空闲表法就是为所有空闲空间建立一张表,表内容包括

  • 空闲区的第一个块号
  • 该空闲区的块个数

注意,这个方式是连续分配的。如下图:

空闲表法

当请求分配磁盘空间时,系统依次扫描空闲表里的内容,直到找到一个合适的空闲区域为止。当用户撤销一个文件时,系统回收文件空间。这时,也需顺序扫描空闲表,寻找一个空闲表条目并将释放空间的第一个物理块号及它占用的块数填到这个条目中。

这种方法仅当有少量的空闲区时才有较好的效果。因为,如果存储空间中有着大量的小的空闲区,则空闲表变得很大,这样查询效率会很低。另外,这种分配技术适用于建立连续文件。

空闲链表法

我们也可以使用「链表」的方式来管理空闲空间,每一个空闲块里有一个指针指向下一个空闲块,这样也能很方便的找到空闲块并管理起来。如下图:

空闲链表法

当创建文件需要一块或几块时,就从链头上依次取下一块或几块。反之,当回收空间时,把这些空闲块依次接到链头上。

这种技术只要在内存中保存一个指针,令它指向第一个空闲块。其特点是简单,但不能随机访问,工作效率低,因为每当在链上增加或移动空闲块时需要做很多 I/O 操作,同时数据块的指针消耗了一定的存储空间。

空闲表法和空闲链表法都不适合用于大型文件系统,因为这会使空闲表或空闲链表太大。

位图法

位图是利用二进制的一位来表示磁盘中一个 Block 的使用情况,磁盘上所有的 Block 都有一个二进制位与之对应。

当值为 0 时,表示对应的 Block 空闲,值为 1 时,表示对应的 Block 已分配。它形式如下:

1
1111110011111110001110110111111100111 ...

在 Linux 文件系统就采用了位图的方式来管理空闲空间,不仅用于数据空闲块的管理,还用于 inode 空闲块的管理,因为 inode 也是存储在磁盘的,自然也要有对其管理。

磁盘的组织形式

Disk Layout

前面提到 Linux 是用位图的方式管理空闲空间,用户在创建一个新文件时,Linux 内核会通过 inode 的位图找到空闲可用的 inode,并进行分配。要存储数据时,会通过块的位图找到空闲的块,并分配,但仔细计算一下还是有问题的。

数据块的位图是放在磁盘块里的,假设是放在一个块里,一个块 4K,每位表示一个数据块,共可以表示 4 * 1024 * 8 = 2^15 个空闲块,由于 1 个数据块是 4K 大小,那么最大可以表示的空间为 2^15 * 4 * 1024 = 2^27 个 byte,也就是 128M。

也就是说按照上面的结构,如果采用「一个块的位图 + 一系列的块」,外加「一个块的 inode 的位图 + 一系列的 inode 的结构」能表示的最大空间也就 128M,这太少了,现在很多文件都比这个大。

在 Linux 文件系统,把这个结构称为一个块组,那么有 N 多的块组,就能够表示 N 大的文件。

下图给出了 Linux Ext2 整个文件系统的结构和块组的内容,文件系统都由大量块组组成,在硬盘上相继排布:

最前面的第一个块是引导块,在系统启动时用于启用引导,接着后面就是一个一个连续的块组了,块组的内容如下:

  • super block: 保存在全局的 super block结构中, 描述了整个文件系统在磁盘中的信息
  • block group descriptor: 包含文件系统中各个块组的状态,比如块组中空闲块和 inode 的数目等,每个块组都包含了文件系统中「所有块组的组描述符信息」。
  • data block bitmap:来表示这些 block 是否占用,它在改变文件大小、创建、删除等操作时,都会改变
  • inode bitmap:文件系统使用该 bitmap 来标识,inode table 里面的 inode 是否使用
  • inode table : 存放 inode 表,每一个 inode 项代表一个文件,里面存放文件的元信息
  • data blocks: 存放文件数据的区域

你可以会发现每个块组里有很多重复的信息,比如超级块和块组描述符表,这两个都是全局信息,而且非常的重要,这么做是有两个原因:

  • 如果系统崩溃破坏了超级块或块组描述符,有关文件系统结构和内容的所有信息都会丢失。如果有冗余的副本,该信息是可能恢复的。
  • 通过使文件和管理数据尽可能接近,减少了磁头寻道和旋转,这可以提高文件系统的性能。

不过,Ext2 的后续版本采用了稀疏技术。该做法是,超级块和块组描述符表不再存储到文件系统的每个块组中,而是只写入到块组 0、块组 1 和其他 ID 可以表示为 3、 5、7 的幂的块组中。

Super Block 结构

每个文件系统有其各自的属性,它通过 Super Block 来描述文件系统在磁盘中的控制信息,包括文件系统的状态、类型、大小、区块数、索引节点数等。每次一个实际的文件系统被安装时, 内核会从磁盘的特定位置读取一些控制信息来填充内存中的超级块对象。

1
2
3
4
5
6
7
8
9
10
11
12
13
// include/linux/fs.h
struct super_block {
struct list_head s_list; // 指向链表的指针
dev_t s_dev; // 设备标识符
unsigned long s_blocksize; // 以字节为单位的块大小
loff_t s_maxbytes; // 文件大小上限
struct file_system_type *s_type; // 文件系统类型
const struct super_operations *s_op; // SuperBlock 操作函数,write_inode、put_inode 等
const struct dquot_operations *dq_op; // 磁盘限额函数
struct dentry *s_root; // 根目录

/* ... */
}

SuperBlock 的重要成员有

  • 链表s_list,包含所有修改过的 Inode,使用该链表很容易区分出来哪个文件被修改过,并配合内核线程将数据写回磁盘
  • 操作函数 s_op,定义了针对其 Inode 的所有操作方法,例如标记、释放索引节点等一系列操作
  • s_type,记录它所属的文件系统类型
  • s_root,本文件系统的根目录

Super Block Operations

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
// include/linux/fs.h
struct super_operations {
struct inode *(*alloc_inode)(struct super_block *sb);
void (*destroy_inode)(struct inode *);
void (*free_inode)(struct inode *);

void (*dirty_inode) (struct inode *, int flags);
int (*write_inode) (struct inode *, struct writeback_control *wbc);
int (*drop_inode) (struct inode *);
void (*evict_inode) (struct inode *);
void (*put_super) (struct super_block *);
int (*sync_fs)(struct super_block *sb, int wait);
int (*freeze_super) (struct super_block *);
int (*freeze_fs) (struct super_block *);
int (*thaw_super) (struct super_block *);
int (*unfreeze_fs) (struct super_block *);
int (*statfs) (struct dentry *, struct kstatfs *);
int (*remount_fs) (struct super_block *, int *, char *);
void (*umount_begin) (struct super_block *);

int (*show_options)(struct seq_file *, struct dentry *);
int (*show_devname)(struct seq_file *, struct dentry *);
int (*show_path)(struct seq_file *, struct dentry *);
int (*show_stats)(struct seq_file *, struct dentry *);

int (*bdev_try_to_free_page)(struct super_block*, struct page*, gfp_t);
long (*nr_cached_objects)(struct super_block *,
struct shrink_control *);
long (*free_cached_objects)(struct super_block *,
struct shrink_control *);
};

文件的具体组成

Inode

如果一个文件的数据被分到很多个 block 中,那么我们应该如何去寻找这些 block 呢?这需要维护一个索引区域,记录着这个文件分成多少个块,每个块在哪里的信息。另外,文件还包括 meta data,比如文件名和权限等。在内核中有 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
// include/linux/fs.h
struct inode {
umode_t i_mode; // 文件权限及类型
kuid_t i_uid; // user id
kgid_t i_gid; // group id

const struct inode_operations *i_op; // inode 操作函数,如 create,mkdir,lookup,rename 等
struct super_block *i_sb; // 所属的 SuperBlock

unsigned long i_ino; // 索引节点号,标志磁盘位置

loff_t i_size; // 文件大小
struct timespec i_atime; // 文件最后访问时间
struct timespec i_mtime; // 文件最后修改时间
struct timespec i_ctime; // 文件元数据最后修改时间(包括文件名称)

unsigned short i_bytes;
u8 i_blkbits;
u8 i_write_hint;
blkcnt_t i_blocks;

const struct file_operations *i_fop; // 文件操作函数,open、write 等
void *i_private; // 文件系统的私有数据

/* ... */
}

Inode Operations

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
// include/linux/fs.h
struct inode_operations {
struct dentry * (*lookup) (struct inode *,struct dentry *, unsigned int);
const char * (*get_link) (struct dentry *, struct inode *, struct delayed_call *);
int (*permission) (struct inode *, int);
struct posix_acl * (*get_acl)(struct inode *, int);

int (*readlink) (struct dentry *, char __user *,int);

int (*create) (struct inode *,struct dentry *, umode_t, bool);
int (*link) (struct dentry *,struct inode *,struct dentry *);
int (*unlink) (struct inode *,struct dentry *);
int (*symlink) (struct inode *,struct dentry *,const char *);
int (*mkdir) (struct inode *,struct dentry *,umode_t);
int (*rmdir) (struct inode *,struct dentry *);
int (*mknod) (struct inode *,struct dentry *,umode_t,dev_t);
int (*rename) (struct inode *, struct dentry *,
struct inode *, struct dentry *, unsigned int);
int (*setattr) (struct dentry *, struct iattr *);
int (*getattr) (const struct path *, struct kstat *, u32, unsigned int);
ssize_t (*listxattr) (struct dentry *, char *, size_t);
int (*fiemap)(struct inode *, struct fiemap_extent_info *, u64 start,
u64 len);
int (*update_time)(struct inode *, struct timespec64 *, int);
int (*atomic_open)(struct inode *, struct dentry *,
struct file *, unsigned open_flag,
umode_t create_mode);
int (*tmpfile) (struct inode *, struct dentry *, umode_t);
int (*set_acl)(struct inode *, struct posix_acl *, int);
} ____cacheline_aligned;

ICache

INode 存储的数据存放在磁盘上,由具体的文件系统进行组织,当需要访问一个 INode 时,会由文件系统从磁盘上加载相应的数据并构造 INode。一个 INode 可能被多个 DEntry 所关联,即相当于为某一文件创建了多个文件路径(通常是为文件建立硬链接)。

硬盘里的 inode diagram 里的数据结构,在内存中会通过slab分配器,组织成 xxx_inode_cache,统计在 meminfo 的可回收的内存中。 inode表也会记录每一个 inode 在硬盘中摆放的位置。这里所说的索引节点高速缓存也就是我们常说的 icache.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// fs/ext2/super.c
static int __init init_inodecache(void)
{
ext2_inode_cachep = kmem_cache_create_usercopy("ext2_inode_cache",
sizeof(struct ext2_inode_info), 0,
(SLAB_RECLAIM_ACCOUNT|SLAB_MEM_SPREAD|
SLAB_ACCOUNT),
offsetof(struct ext2_inode_info, i_data),
sizeof_field(struct ext2_inode_info, i_data),
init_once);
if (ext2_inode_cachep == NULL)
return -ENOMEM;
return 0;
}

Directory Entry

文件路径中,每一部分都被称为目录项。/home/jie/vfs.c中,目录 /、home、 jie和文件 vfs.c对应了一个目录项。

注意区分目录项和目录,目录在文件系统里面也是一个文件,但是它是一个特殊的文件,文件的内容是一个inode号和名字的映射表格

image-20201205172146722

目录在硬盘里是一个特殊的文件。目录在硬盘中也对应一个inode,记录文件的名字和inode号。查找一个文件时(/home/vfs.c),根据文件系统的根据根目录和根inode,找到根目录所在硬盘的位置,将根目录的文件读出来,再去做字符串匹配,能够找到 home这个字符串, 于是就再去读home这个目录对应的inode的文件内容,再做字符串匹配,然后就找到了vfs.c 。最后发现vfs.c是一个常规文件,返回他的inode即可。(上一小节在讲解inode的时候说过,通过inode就可以表示整个文件的元信息,数据内容等)

目录文件在磁盘中存放的内容:

image-20201205172655276

DEntry 用来保存文件路径和 INode 之间的映射,从而支持在文件系统中移动。DEntry 由 VFS 维护,所有文件系统共享,不和具体的进程关联。dentry对象从根目录 / 开始,每个dentry对象都会持有自己的子目录和文件,这样就形成了文件树。举例来说,如果要访问 /home/houmin/a.txt 文件并对他操作,系统会解析文件路径,首先从/ 根目录的dentry对象开始访问,然后找到 home/ 目录,其次是 houmin/,最后找到 a.txtdentry结构体,该结构体里面d_inode字段就对应着该文件。

下面是 DEntry 的数据结构

1
2
3
4
5
6
7
8
9
10
11
12
// include/linux/dcache.h
struct dentry {
struct dentry *d_parent; // 父目录
struct qstr d_name; // 文件名称
struct inode *d_inode; // 关联的 inode
struct list_head d_child; // 父目录中的子目录和文件
struct list_head d_subdirs; // 当前目录中的子目录和文件

unsigned char d_iname[DNAME_INLINE_LEN]; /* small names */
const struct dentry_operations *d_op;
/* ... */
}

每一个dentry对象都持有一个对应的inode对象,表示 Linux 中一个具体的目录项或文件。INode 包含管理文件系统中的对象所需的所有元数据,以及可以在该文件对象上执行的操作。

DEntry Operations

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
// include/linux/dcache.h
struct dentry_operations {
int (*d_revalidate)(struct dentry *, unsigned int);
int (*d_weak_revalidate)(struct dentry *, unsigned int);
int (*d_hash)(const struct dentry *, struct qstr *);
int (*d_compare)(const struct dentry *,
unsigned int, const char *, const struct qstr *);
int (*d_delete)(const struct dentry *);
int (*d_init)(struct dentry *);
void (*d_release)(struct dentry *);
void (*d_prune)(struct dentry *);
void (*d_iput)(struct dentry *, struct inode *);
char *(*d_dname)(struct dentry *, char *, int);
struct vfsmount *(*d_automount)(struct path *);
int (*d_manage)(const struct path *, bool);
struct dentry *(*d_real)(struct dentry *, const struct inode *);
} ____cacheline_aligned;

DCache

虚拟文件系统维护了一个 DEntry Cache 缓存,用来保存最近使用的 DEntry,加速查询操作。DEntry 对象都放在名为 dentry_cache 的 slab 分配器高速缓存中。

当调用open()函数打开一个文件时,内核会第一时间根据文件路径到 DEntry Cache 里面寻找相应的 DEntry,找到了就直接构造一个file对象并返回。如果该文件不在缓存中,那么 VFS 会根据找到的最近目录一级一级地向下加载,直到找到相应的文件。期间 VFS 会缓存所有被加载生成的dentry

管理目录项高速缓存的数据结构有两个:

  • 一个是处于正在使用、未使用或负状态的目录项对象的集合。这用的是双向链表。
  • 一个叫 dentry_hashtable 的散列表,从中能够快速获取与给定的文件名和目录名对应的目录项对象。

    如下是 初始化目录项对象的代码

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
// fs/dcache.c

static struct kmem_cache *dentry_cache __read_mostly;

static void __init dcache_init(unsigned long mempages)
{
int loop;

/*
* A constructor could be added for stable state like the lists,
* but it is probably not worth it because of the cache nature
* of the dcache.
*/
dentry_cache = kmem_cache_create("dentry_cache",
sizeof(struct dentry),
0,
SLAB_RECLAIM_ACCOUNT|SLAB_PANIC,
NULL, NULL);

set_shrinker(DEFAULT_SEEKS, shrink_dcache_memory);

/* Hash may have been set up in dcache_init_early */
if (!hashdist)
return;

dentry_hashtable =alloc_large_system_hash("Dentry cache",
sizeof(struct hlist_head),
dhash_entries,
13,
0,
&d_hash_shift,
&d_hash_mask,
0);

for (loop = 0; loop < (1 << d_hash_shift); loop++)
INIT_HLIST_HEAD(&dentry_hashtable[loop]);
}

在内核中,并不丢弃与未用目录项相关的索引节点,这是由于目录项高速缓存仍在使用它们。

File

文件对象表示进程已打开的文件。用户看到最多的就是它,包含文件对象的使用计数、用户的UID和GID等。它存放了打开文件与进程之间进行交互的信息,这类信息仅当进程访问文件期间存在与内核内存中。

每个进程都持有一个 files_struct 指针,存放的是指该进程打开的 file 结构体的指针:

1
2
3
4
5
6
7
8
9
10
// include/linux/sched.h
struct task_struct {
/* ... */

/* Filesystem information: */
struct fs_struct *fs;

/* Open file information: */
struct files_struct *files;
}

这个 files_struct 有一个 fd_array,里面的每个元素是一个 file 对象,记录了打开的文件:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
// include/linux/fdtable.h
struct files_struct {
/*
* read mostly part
*/
atomic_t count;
bool resize_in_progress;
wait_queue_head_t resize_wait;

struct fdtable __rcu *fdt;
struct fdtable fdtab;
/*
* written part on a separate cache line in SMP
*/
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];
};

file内核中的数据结构,表示一个被进程打开的文件,和进程相关联。当应用程序调用open()函数的时候,VFS 就会创建相应的file对象。它会保存打开文件的状态,例如文件权限、路径、偏移量等等。Linux文件系统会为每个文件都分配两个数据结构,目录项(DEntry, Directory Entry)和索引节点(INode, Index Node)。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
// include/linux/fs.h
struct file {
struct path f_path;
struct inode *f_inode; /* cached value */
const struct file_operations *f_op;
unsigned int f_flags;
fmode_t f_mode;
loff_t f_pos;
struct fown_struct f_owner;

/* ... */
}

// include/linux/path.h
struct path {
struct vfsmount *mnt;
struct dentry *dentry;
}

从上面的代码可以看出,文件的路径实际上是一个指向 DEntry 结构体的指针,VFS 通过 DEntry 索引到文件的位置。

除了文件偏移量f_pos是进程私有的数据外,其他的数据都来自于 INode 和 DEntry,和所有进程共享。不同进程的file对象可以指向同一个 Dentry 和 Inode,从而实现文件的共享。


文件系统是对一个存储设备上的数据和元数据进行组织的机制,由于定义如此宽泛,各个文件系统的实现也大不相同,其中常见的文件系统有 ext4、NFS、/proc 等。Linux 采用为分层的体系结构,将用户接口层、文件系统实现和存储设备的驱动程序分隔开,进而兼容不同的文件系统。

虚拟文件系统(Virtual File System, VFS)是 Linux 内核中的软件层,它在内核中提供了一组标准的、抽象的文件操作,允许不同的文件系统实现共存,并向用户空间程序提供统一的文件系统接口。下面这张图展示了 Linux 虚拟文件系统的整体结构:

vfs-architecture@2x

上图修改自:《Linux 文件系统剖析》图 1. Linux 文件系统组件的体系结构

从上图可以看出,用户空间的应用程序直接、或是通过编程语言提供的库函数间接调用内核提供的 System Call 接口(如open()write()等)执行文件操作。System Call 接口再将应用程序的参数传递给虚拟文件系统进行处理。

每个文件系统都为 VFS 实现了一组通用接口,具体的文件系统根据自己对磁盘上数据的组织方式操作相应的数据。当应用程序操作某个文件时,VFS 会根据文件路径找到相应的挂载点,得到具体的文件系统信息,然后调用该文件系统的对应操作函数。

VFS 提供了两个针对文件系统对象的缓存 INode Cache 和 DEntry Cache,它们缓存最近使用过的文件系统对象,用来加快对 INode 和 DEntry 的访问。Linux 内核还提供了 Buffer Cache 缓冲区,用来缓存文件系统和相关块设备之间的请求,减少访问物理设备的次数,加快访问速度。Buffer Cache 以 LRU 列表的形式管理缓冲区。

VFS 的好处是实现了应用程序的文件操作与具体的文件系统的解耦,使得编程更加容易:

  • 应用层程序只要使用 VFS 对外提供的read()write()等接口就可以执行文件操作,不需要关心底层文件系统的实现细节;
  • 文件系统只需要实现 VFS 接口就可以兼容 Linux,方便移植与维护;
  • 无需关注具体的实现细节,就实现跨文件系统的文件操作。

了解 Linux 文件系统的整体结构后,下面主要分析 Linux VFS 的技术原理。

符号链接和硬链接

符号链接

符号链接是linux中是真实存在的实体文件,文件内容指向其他文件。符号链接和文件是不同的inode。

如下图所示:cbw_file和my_file指向不同的inode. 但是cbw_file的文件内容指向了my_file的inode

img

符号链接特性:

  1. 针对目录的软链接,用rm -fr 删除不了目录里的内容
  2. 针对目录的软链接,”cd ..”进的是软链接所在目录的上级目录
  3. 可以对文件执行unlink或rm,但是不能对目录执行unlink

硬链接

硬链接在硬盘中是同一个inode存在,在目录文件中多了一个目录和该inode对应。

image-20201205172655276

硬链接特性

  1. 硬链接不能跨本地文件系统
  2. 硬链接不能针对目录

文件缓存

关于文件系统的相关的缓存,前面我们讲到了icache以及dcache. 其实还有一个文件内容的缓存叫page cache. 那么他是存放在哪里的呢? 我们下面简单来看一下。当打开一个文件后,内核中会为struct fille建立如下的映射关系:图中描述了struct file、inode、dentry、address_space之间的关系:

我们通过file结构体按照图中箭头指向,可以一步一步找到page cache. 或者是发起IO操作。

file_page_cache

上图中的radix tree里面的内容就是文件的page cache. 其中索引值是需要访问的数据在文件里面的偏移量, 关于radix tree更详细的信息,请参考:: 页高速缓存

其中i_fop(struct file_operations)和a_ops(struct address_space_operations)的关系是, i_fop是hook到虚拟文件系统中的,a_ops完成page cache的访问,包括page cache不存在的时候,发起IO请求等操作。

代码只是列举一下,不感兴趣的直接忽略掉。

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
57
58
59
60
61
62
63
struct address_space_operations {
/**
* 写操作(从页写到所有者的磁盘映象)
*/
int (*writepage)(struct page *page, struct writeback_control *wbc);
/**
* 读操作(从所有者的磁盘映象读到页)
*/
int (*readpage)(struct file *, struct page *);
/**
* 如果对所有者页进行的操作已准备好,则立刻开始I/O数据的传输
*/
int (*sync_page)(struct page *);

/* Write back some dirty pages from this mapping. */
/**
* 把指定数量的所有者脏页写回磁盘
*/
int (*writepages)(struct address_space *, struct writeback_control *);

/* Set a page dirty */
/**
* 把所有者的页设置为脏页
*/
int (*set_page_dirty)(struct page *page);

/**
* 从磁盘中读所有者页的链表
*/
int (*readpages)(struct file *filp, struct address_space *mapping,
struct list_head *pages, unsigned nr_pages);

/*
* ext3 requires that a successful prepare_write() call be followed
* by a commit_write() call - they must be balanced
*/
/**
* 为写操作做准备(由磁盘文件系统使用)
*/
int (*prepare_write)(struct file *, struct page *, unsigned, unsigned);
/**
* 完成写操作(由磁盘文件系统使用)
*/
int (*commit_write)(struct file *, struct page *, unsigned, unsigned);
/* Unfortunately this kludge is needed for FIBMAP. Don't use it */
/**
* 从文件块索引中获取逻辑块号
*/
sector_t (*bmap)(struct address_space *, sector_t);
/**
* 使所有者的页无效(截断文件时用)
*/
int (*invalidatepage) (struct page *, unsigned long);
/**
* 由日志文件系统使用,以准备释放页
*/
int (*releasepage) (struct page *, int);
/**
* 所有者页的直接I/O传输(绕过页高速缓存)
*/
ssize_t (*direct_IO)(int, struct kiocb *, const struct iovec *iov,
loff_t offset, unsigned long nr_segs);
};

处于ext2文件系统下的文件对a_ops的赋值为:

1
2
3
4
5
6
7
8
9
10
11
struct address_space_operations ext2_nobh_aops = {
.readpage = ext2_readpage,
.readpages = ext2_readpages,
.writepage = ext2_writepage,
.sync_page = block_sync_page,
.prepare_write = ext2_nobh_prepare_write,
.commit_write = nobh_commit_write,
.bmap = ext2_bmap,
.direct_IO = ext2_direct_IO,
.writepages = ext2_writepages,
};

读取文件的时候有如下流程:伪代码如下:

1
2
3
4
5
f_ops.read
if O_DIRECT
a_ops.direct_IO();
else
do_generic_file_read;//如果page cache读到数据,直接返回。否则使用a_ops->readpage发起io请求

这里我们第一次见到了IO , 实际上IO就是从a_ops->readpage或者a_ops->wirtepage函数触发的。 关于IO流程,请参考其他文章。后面我会梳理一下IO流程, 这里不再过多的阐述。

文件系统

关于文件系统的三个易混淆的概念:

  • 创建:以某种方式格式化磁盘的过程就是在其之上建立一个文件系统的过程。创建文件系统时,会在磁盘的特定位置写入关于该文件系统的控制信息。
  • 注册:向内核报到,声明自己能被内核支持。一般在编译内核的时侯注册;也可以加载模块的方式手动注册。注册过程实际上是将表示各实际文件系统的数据结构 struct file_system_type 实例化。
  • 挂载:也就是我们熟悉的mount操作,将文件系统加入到Linux的根文件系统的目录树结构上;这样文件系统才能被访问。

文件系统创建

以某种方式格式化磁盘的过程就是在其之上建立一个文件系统的过程。创建文件系统时,会在磁盘的特定位置写入关于该文件系统的控制信息。用指定文件系统格式话磁盘分区后。磁盘上被格式化的分区如下图所示:

下图是组描述符的缓存示意图:

文件系统的注册

文件系统注册就是文件系统向内核报到,声明该文件系统能被内核支持,一般是在内核的初始化阶段完成或者在文件系统内核模块初始化函数中完成注册。如下是 ext2 文件系统初始化时候的注册:

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
// fs/ext2/super.c
static struct file_system_type ext2_fs_type = {
.owner = THIS_MODULE,
.name = "ext2",
.mount = ext2_mount,
.kill_sb = kill_block_super,
.fs_flags = FS_REQUIRES_DEV,
};
MODULE_ALIAS_FS("ext2");

static int __init init_ext2_fs(void)
{
int err;

err = init_inodecache();
if (err)
return err;
err = register_filesystem(&ext2_fs_type);
if (err)
goto out;
return 0;
out:
destroy_inodecache();
return err;
}

注册文件系统就是将该文件系统挂载到file_systems链表中,以供后续使用。如下图所示:

register_ext2

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
// fs/filesystems.c

static struct file_system_type *file_systems; // 文件系统全局链表

static struct file_system_type **find_filesystem(const char *name, unsigned len)
{
struct file_system_type **p;
for (p = &file_systems; *p; p = &(*p)->next)
if (strncmp((*p)->name, name, len) == 0 &&
!(*p)->name[len])
break;
return p;
}

int register_filesystem(struct file_system_type * fs)
{
int res = 0;
struct file_system_type ** p;

if (fs->parameters && !fs_validate_description(fs->parameters))
return -EINVAL;

BUG_ON(strchr(fs->name, '.'));
if (fs->next)
return -EBUSY;
write_lock(&file_systems_lock);
p = find_filesystem(fs->name, strlen(fs->name));
if (*p)
res = -EBUSY;
else
*p = fs;
write_unlock(&file_systems_lock);
return res;
}

文件系统的挂载

文件系统加入到Linux的根文件系统的目录树结构上,这样文件系统上面的文件才能被访问。在内核中描述文件系统的文件系统描述符为如下代码块的结构体所示:

1
2
3
4
5
6
// include/linux/mount.h
struct vfsmount {
struct dentry *mnt_root; /* root of the mounted tree */
struct super_block *mnt_sb; /* pointer to superblock */
int mnt_flags;
};

一个vfs_mount可以理解为一个文件系统的实例。它有挂载点、有挂载点的dentry项等、同一个文件系统可以有多个vfs_mount实例。也就是同一个文件系统可以被安装多次。例如:/home/test1 、/home/jie/test2、这两个目录可以mount同一种文件系统,假设均为ext2,实际上就是有了ext2文件系统的两个实例。对于每个安装操作(mount), 内存里面都需要保存安装点、安装标记、以及已安装文件系统与其他文件系统之间的关系(是否挂在其他文件系统下等)。

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
// fs/namespace.c
void __init mnt_init(void)
{
int err;

mnt_cache = kmem_cache_create("mnt_cache", sizeof(struct mount),
0, SLAB_HWCACHE_ALIGN | SLAB_PANIC, NULL);

mount_hashtable = alloc_large_system_hash("Mount-cache",
sizeof(struct hlist_head),
mhash_entries, 19,
HASH_ZERO,
&m_hash_shift, &m_hash_mask, 0, 0);
mountpoint_hashtable = alloc_large_system_hash("Mountpoint-cache",
sizeof(struct hlist_head),
mphash_entries, 19,
HASH_ZERO,
&mp_hash_shift, &mp_hash_mask, 0, 0);

if (!mount_hashtable || !mountpoint_hashtable)
panic("Failed to allocate mount hash table\n");

kernfs_init();

err = sysfs_init();
if (err)
printk(KERN_WARNING "%s: sysfs_init error: %d\n",
__func__, err);
fs_kobj = kobject_create_and_add("fs", NULL);
if (!fs_kobj)
printk(KERN_WARNING "%s: kobj create error\n", __func__);
shmem_init();
init_rootfs();
init_mount_tree();
}

mount的过程就是把 设备的文件系统入到 vfs 框架

  1. 首先,要mount一个新的设备,需要创建一个新的 super block。 这通过要mount的文件系统的 file_system_type, 调用其 get_sb方法来创建一个新的 super block。
  2. 需要创建一个新的vfsmount ,对于任何一个 mount 的文件系统,都要有一个 vfsmount, 创建这个 vfsmount, 并设置好 vfsmount 中的各个成员
  3. 将创建好的 vfsmount 加入到系统中,对于新的vfsmount:
    1. 其mount_point为目录 “my” 的dentry,
    2. 其 mnt_root 是设备 sdb1上的根目录的 dentry
    3. 其父 vfsmount 就是原文件系统中的那个 vfsmount

syscall

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
// fs/namespace.c
SYSCALL_DEFINE5(mount, char __user *, dev_name, char __user *, dir_name,
char __user *, type, unsigned long, flags, void __user *, data)
{
return ksys_mount(dev_name, dir_name, type, flags, data);
}

int ksys_mount(const char __user *dev_name, const char __user *dir_name,
const char __user *type, unsigned long flags, void __user *data)
{
int ret;
char *kernel_type;
char *kernel_dev;
void *options;

kernel_type = copy_mount_string(type);
kernel_dev = copy_mount_string(dev_name);
options = copy_mount_options(data);

ret = do_mount(kernel_dev, dir_name, kernel_type, flags, options);

return ret;
}

do_mount

do_mount()函数是mount操作过程中的核心函数,在该函数中,通过mount的目录字符串找到对应的dentry目录项,然后通过do_new_mount()函数完成具体的mount操作。

do_new_mount

do_new_mount() 函数主要分成两大部分:

  • 建立 vfsmount 对象和 superblock 对象,必要时从设备上获取文件系统元数据
  • 将 vfsmount 对象加入到mount树和 Hash Table中,并且将原来的 dentry 对象无效掉
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
// fs/namespace.c
static int do_new_mount(struct path *path, const char *fstype, int sb_flags,
int mnt_flags, const char *name, void *data)
{
struct file_system_type *type;
struct fs_context *fc;
const char *subtype = NULL;
int err = 0;


type = get_fs_type(fstype);

fc = fs_context_for_mount(type, sb_flags);
put_filesystem(type);

err = do_new_mount_fc(fc, path, mnt_flags);

put_fs_context(fc);
return err;
}

static int do_new_mount_fc(struct fs_context *fc, struct path *mountpoint,
unsigned int mnt_flags)
{
struct vfsmount *mnt;
struct super_block *sb = fc->root->d_sb;
int error;

up_write(&sb->s_umount);

mnt = vfs_create_mount(fc);

error = do_add_mount(real_mount(mnt), mountpoint, mnt_flags);

return error;
}

vfs_create_mount

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
// fs/namespace.c
struct vfsmount *vfs_create_mount(struct fs_context *fc)
{
struct mount *mnt;

if (!fc->root)
return ERR_PTR(-EINVAL);

mnt = alloc_vfsmnt(fc->source ?: "none");
if (!mnt)
return ERR_PTR(-ENOMEM);

if (fc->sb_flags & SB_KERNMOUNT)
mnt->mnt.mnt_flags = MNT_INTERNAL;

atomic_inc(&fc->root->d_sb->s_active);
mnt->mnt.mnt_sb = fc->root->d_sb;
mnt->mnt.mnt_root = dget(fc->root);
mnt->mnt_mountpoint = mnt->mnt.mnt_root;
mnt->mnt_parent = mnt;

lock_mount_hash();
list_add_tail(&mnt->mnt_instance, &mnt->mnt.mnt_sb->s_mounts);
unlock_mount_hash();
return &mnt->mnt;
}

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

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 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;
  • mnt_root 是当前文件系统根目录的 dentry
  • mnt_sb 是指向超级块的指针。接下来,我们来看调用 mount_fs 挂载文件系统。

文件操作

路径名查找

如下图所示,当你在硬盘查找 /usr/bin/emacs文件时:

  • 从根的inode和dentry,根据/的inode表,找到/ 目录文件所在的硬盘中的位置
  • 读硬盘/目录文件的内容,发现 usr 对应inode 2, bin 对应inode 3, share 对应inode4。
  • 再去查inode表,inode 2所在硬盘的位置,即/usr 目录文件所在硬盘的位置。
  • 读出内容包括 var 对应 inode 10, bin 对应inode 11, opt对应inode 12。
  • 于是又去找inode 11 所在的磁盘位置,emacs 对应的inode是119.
  • 我们现在就找到了119这个inode,它就对应了 /usr/bin/emacs这个文件
  • 这个过程会查找很多inode和 dentry,这些都会通过 icache 和dcache缓存。

img

前面我们讲了很多文件系统的创建、注册、挂载,那么和我们的路径查找有什么关系呢,关系大着呢。每当我们搜索到目录项对象的时候,如果 dentry->d_mounted 大于1的话,说明该目录项被文件系统挂载,那么需要调用lookup_mnt()查找vfs_mount. 并切换文件系统,这样就会找到新的文件系统的根dentry项以及对应的inode项。如果没有切换文件系统的,那么是无法继续解析下去的。试想一下,挂载点下面文件都是存放在ext4文件系统下的,文件也是按ext4文件系统去组织的。这个时候你还是按上一个目录的文件系统,比如exfat去解析后面的文件,肯定是解析不了的。

路径名查找完成后,将找到的文件的dentry以及inode对应的f_ops赋值给struct file结构体, 系统调用返回文件描述符给应用成,这个时候我们就可以操作通过fd(文件描述符)号先找到struct file, 顺藤摸瓜,可以找到对应文件的inode。

读写文件过程

进程通过task_struct中的一个域 files_struct files来了解它当前所打开的文件对象;而我们通常所说的文件 描述符其实是进程打开的文件对象数组的索引值。文件对象通过域f_dentry找到它对应的dentry对象,再由dentry对象的域d_inode找 到它对应的索引结点,这样就建立了文件对象与实际的物理文件的关联。最后,还有一点很重要的是, 文件对象所对应的文件操作函数列表是通过索引结点的域i_fop得到的。如下图所示:

图片示例_进程与超级块,文件,索引结点,目录项的关系

如下图所示:通过文件描述符fd我们可以找到具体文件的inode的文件描述符,通过注册的回调函数a_ops以及page cache. 我们就可以实际的读写文件的内容了。file_page_cache

特殊文件系统操作

套接字以及pipe、netlink 等文件在磁盘中没有内容,他们的 inode 只有VFS中的inode。下面我们以套接字为例,说明 linux是如何处理这种特殊的文件系统的。

  1. 在内核初始化时完成
  2. 内核初始化过程(/init/main.c):kernel_init()-> do_basic_setup()-> do_initcalls()-> do_one_initcall()
  3. socket文件系统注册过程(/net/socket.c):core_initcall(sock_init)

core_initcall 宏定义如下:

1
2
3
4
5
6
7
8
// include/linux/init.h
#define core_initcall(fn) __define_initcall(fn, 1)
#define __define_initcall(fn, id) ___define_initcall(fn, id, .initcall##id)

#define ___define_initcall(fn, id, __sec) \
static initcall_t __initcall_##fn##id __used \
__attribute__((__section__(#__sec ".init"))) = fn;
#endif

宏定义 __define_initcall(level,fn, id) 对于内核的初始化很重要,他指示编译器在编译的时候,将一系列初始化函数的起始地址值按照一定的顺序放在一个section中。

在内核初始化阶段,do_initcalls() 将按顺序从该 section 中以函数指针的形式取出这些函数的起始地址,来依次完成相应的初始化。由于内核某些部分的初始化需要依赖于其他某些部分的初始化的完成,因此这个顺序排列常常很重要。该宏的作用分三部分:

  • 申明一个函数指针 initcall_t ,即int *(void))变量 __initcall_fn_id
  • 将该函数指针初始化为fn;
  • 编译的时候需要把这个函数指针变量放置到名称为 “.initcall”level”.init”的section中;

根据上面的解释,core_initcall(sock_init) 的作用就是让编译器在编译时声明函数指针变量 __initcall_sock_init1,让其指向 sock_init,并放到名为.initcall1.init的section中;

sockfs 注册

可以看到 sockfs 也有对应的 file_system_type

1
2
3
4
5
static struct file_system_type sock_fs_type = {
.name = "sockfs",
.init_fs_context = sockfs_init_fs_context,
.kill_sb = kill_anon_super,
};

会在内核初始化的时候初始化 sockfs,在这个函数中,将socket注册为一个伪文件系统,并安装相应的mount点:

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
// net/socket.c

core_initcall(sock_init); /* early initcall */

static int __init sock_init(void)
{
int err;

init_inodecache();

err = register_filesystem(&sock_fs_type);
if (err)
goto out_fs;
sock_mnt = kern_mount(&sock_fs_type);
if (IS_ERR(sock_mnt)) {
err = PTR_ERR(sock_mnt);
goto out_mount;
}

out:
return err;

out_mount:
unregister_filesystem(&sock_fs_type);
out_fs:
goto out;
}
  • init_inodecache():创建一块用于socket相关的inode的调整缓存;后面创建inode、释放inode会使用到;
  • register_filesystem():将 socket 文件系统注册到内核中;
    • 在内核中,所有的文件系统保存在全局变量file_systems中,如下:
1
static struct file_system_type *file_systems;

​ 不同的文件系统类型通过结构体的next字段形成一个单向链表;这样,注册文件系统实质是将新的结构体插入到单向链表中的过程;

  • kern_mount():在内核中安装文件系统,并建立安装点;

在安装的过程中,会初始化该安装点的超级块,此时会将该超级块的操作函数指针记录下来;如:

1
2
3
4
5
6
7
static struct dentry *sockfs_mount(struct file_system_type *fs_type,
int flags, const char *dev_name, void *data)
{
return mount_pseudo_xattr(fs_type, "socket:", &sockfs_ops,
sockfs_xattr_handlers,
&sockfs_dentry_operations, SOCKFS_MAGIC);
}

其中sockfs_ops结构变量如下:

1
2
3
4
5
static const struct super_operations sockfs_ops = {
.alloc_inode = sock_alloc_inode,
.destroy_inode = sock_destroy_inode,
.statfs = simple_statfs,
};

该操作函数结构体定义了如何分配 inode,如何销毁 inode 等;

套接字的打开 socket

socket

1
2
// net/socket.c
SYSCALL_DEFINE3(socket, int, family, int, type, int, protocol)

sock_create

sock_create(family, type, protocol, &sock);

  1. 协议栈相关的创建 ,其实就是一个create 函数,inet协议栈就是inet_create,如果是netlink就是netlink的creat函数
  2. 根据protocol注册不同的回调函数,那么就可以使用 udp、tcp 了,并分配函数接收的收包和发包函数

sock_map_fd

sock_map_fd(sock, flags & (O_CLOEXEC | O_NONBLOCK));

读写套接字

内核建立起了虚拟文件系统和套接字的映射关系后,那么直接调用write、read等函数就可以操作和读写套接字。

对于套接字f_op被初始化为socket_file_ops

1
file->f_op = SOCK_INODE(sock)->i_fop = &socket_file_ops;

socket_file_ops为如下定义:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
static struct file_operations socket_file_ops = {
.owner = THIS_MODULE,
.llseek = no_llseek,
.aio_read = sock_aio_read,
.aio_write = sock_aio_write,
.poll = sock_poll,
.unlocked_ioctl = sock_ioctl,
.mmap = sock_mmap,
.open = sock_no_open, /* special open code to disallow open via /proc */
.release = sock_close,
.fasync = sock_fasync,
.readv = sock_readv,
.writev = sock_writev,
.sendpage = sock_sendpage
};

其中sock_aio_readsock_aio_writesock_readvsock_writev等函数会调用到具体协议的读写函数、比如udp就是调用到udp 的函数、tcp就调用到tcp的函数,这一切的映射关系是根据套接字建立的时候根据传入的family、type、protocol这几次参数决定的。

References