前言 本文为原创,可能会存在一些知识点或理解上的问题,欢迎切磋和交流 ^_^
1、块设备访问方式
Linux块设备访问方式有两种:
1、dd if=/dev/sda1
2、mount -o loop /dev/sda1 /mnt
第一种方式,直接dd方式访问裸设备,底层直接和vfs层接口打交道, 直接调用fs/block_dev.c里的函数操作集,而第二种方式则是某种文件系统调用f_ops函数操作集帮助找到a.txt文件在磁盘的哪个磁道、哪个柱面、哪个扇区。
2、一睹庐山真面目--物理结构
2.1.1 结构概观
ext2文件系统是一种基于块的文件系统,它将磁盘划分为若干块,每个块的长度都相同,按块管理元数据和文件内容。那么首先给出的一个概念叫做“块组”(block group)。我们将数据最终存储到磁盘上,一个TB级的磁盘相对于一个只有十几KB的文件很大,该如何存?如何管理这个文件的元数据和数据?既然是管理,是不是就要划分,“良田万亩,划分到户”,所以,磁盘划分为一个一个的块组进行管理,ext2文件系统的块组长什么样呢,如下图所示。
图1 ext2文件系统的块组
此处对启动块不进行分析,启动扇区是硬盘上的一个区域,当系统加电启动时,其内容由bios自动装载并执行。该区域不填充文件系统相关的数据,也并非每个文件系统都有该区域。要说的重点是块组,文件系统上的元数据和文件的具体数据都存储在块组上。通过以上图可以看到,每个块组其实包含了大量冗余的信息,这就会产生严重的浪费。但是为什么ext2文件系统允许这样的浪费呢?有如下两个原因:
◆ 如果系统崩溃,第一个块组超级块损坏掉,那么有关文件系统结构和内容的数据都会丢帧,结果非常的严重。但是如果有“备胎”,专业术语叫做冗余副本,那么该信息就可以恢复;
◆ 通过上图可以看到,ext2这种文件系统,文件的元数据和数据是在一起的,这样的设计思想,对于HDD来说,减少了磁头寻道和旋转,大大提高了文件系统IO性能。
在实际场景中,超级块并不是在每个块组中都复制,内核通常只使用超级块的第一个副本,而超级块的数据缓存在内存中,也使得内核不必重复的从磁盘进行读取,因此,在设计之初,上述两个考虑因素也做出修改。此处不再进行详细分析。
下面我们说一下块组中每个结构具体的作用。
◆ 超级块
它存放的是文件系统自身元数据的核心结构。比如当前文件系统状态、上一次装载时间戳,文件系统类型魔术字,以及空闲和已使用块的数目等。
◆ 组描述符
描述的是文件系统各个块组的状态,比如块组中空闲块和inode的数目
◆ 数据块位图
数据bitmap和inode bitmap相类似,它记录的是数据块中哪些块是空闲的,哪些块已经被用掉了,用掉了就将对应的bit位置1,比如有一个文件16K,每个数据块大小是4k,则数据块中要用掉4个数据块,假设是第100、101、102、103块,则数据块bit map中则将第100、101、102、103个块的bit位置成1。
◆ inode位图
即inode bitmap,用一个bit位来表示一个块是否是空闲的。比如在linux中,每个文件唯一的信息是inode,如何表示inode=10的文件是否有效或无效,则在inode bitmap中将inode=10的bit位置1,说明inode=10有效。下次比如再touch file11时,ext2文件系统先索引这个inode bitmap,发现inode=10的bit位已置1,则将inode=11的bit位置1,那么新创建的文件inode就是11。
◆ inode表
inode table中则是大小相等的等分块,每个块大小都是128字节,每个块记录的是文件系统中各个文件和目录的所有元数据。这里的inode table和前面的inode bitmap一一对应,比如inode=10的块被占用,说明有一个文件inode=10,那么此处inode table中inode=10的块也被占用。它里面的内容是什么呢?因为它记录的是数据的数据,比如文件的各种日期,创建大小,以及它指向数据块中具体存放数据的地址。
◆ 数据块
实际存放数据的内容。
举个例子,我在ext2文件系统中touch一个文件时,通过上述几个结构,大致的流程如下图所示
图2 touch一个文件时块组中各结构作用
◆ 当执行touch 1.txt,先到inode bitmap中进行索引,发现inode=1的bit位是0,则将inode=1置1,表示这个文件inode号为1;
◆ 然后在inode table中将inode=1的块进行占用,填充相应的元数据信息,获取文件大小,分配指向实际数据块的指针;
◆ 再到数据块bitmap中进行索引,比如文件大小是16k,每个块大小是4k,则需要在数据块中分配4个数据块用于存放文件大小,发现第100、101、102、103个数据块是空闲状态,则将这四个数据块bit位置1;
◆ 在数据块中,由inode表指针指向相应的四个数据块,用于存放文件实际数据;
举个例子,比如查找/mnt目录下d目录下的g.txt文件,其过程如下
图3 查找/mnt目录下d目录下文件过程
◆ 目录也是一种文件,首先找到mnt目录的inode号,比如inode=10,此时再到inode table中找到inode=10的块,获取其指向到数据块中的内容,因为目录里存放的数据是文件名与相应文件名inode的对应关系,所以可以找到目录d与之对应的inode号是多少,比如inode=17;
◆ 此时针对inode=17,再到inode表中查找inode=17的块,获取其指向在数据块中的内容,因为目录d里存放的内容也是文件名与之对应inode号,查找到g文件inode=1000;
◆ 此时再到inode table中查找inode=1000的块,获取其指向在数据块中的内容,继而找到g文件里的内容;
2.1.2 间接
ext2文件系统用于存储一个文件的过程大致如上所示,这样一种映射关系叫做“直接映射”(direct blocks),较简单。但是稍加思考,发现有一个问题,inode table中能够存放的块号的数目,限制了文件的大小。因为inode table大小是有限的,这就意味着无法存的下大文件。
通过增加inode table中块号的数目也无法解决该问题,我们稍加计算一下就可以知道。比如数据块的大小是4k,存储一个700M的文件,所需要数据块个数是179200个,那么每个数据块都需要一个4字节的指针来唯一指向,那么就需要700K空间来存储这些块号信息,所以这显然行不通。
所以就有了间接的思想。
对于间接的思想,谈一下我的理解吧,为什么要用间接,瓶颈就在inode table块大小限制了无法存放描述大文件的数据块数目。如果能够一次性存放下来,其实就不需要再间接这么麻烦了。那么既然一次性存放不了,那么采用间接这一思想,是不是可以先指向存放数据块的数据块数目和地址,然后通过数据块的数据块,再依次指向实际存放数据的数据块,此为二次间接。当然,还有三次间接。这样,就可以存放住大文件所需要的数据块。
原理如下图所示。
图4 二次间接
3、数据结构、结构体
linux文件系统对象之间的关系可以概括为文件系统类型、超级块、inode、dentry和vfsmount之间的关系。文件系统类型的作用是构造某一种文件系统类型的实例,或者称作超级块实例。超级块反映的是某种文件系统整体的元数据信息。inode反映的是某种文件系统对象的元数据信息。dentry反映的是文件系统对象在文件系统树中的位置。下面就一一说一下这几个数据结构体平时我用到或者知道较多的几个成员。其他暂未涉及的成员暂且不说了。
注意:如下结构体均是linux-4.14内核源码
3.1 file_system_type: 文件系统类型
struct file_system_type {
const char *name; //文件系统类型名称
int fs_flags; //文件系统类型标志
#define FS_REQUIRES_DEV 1
#define FS_BINARY_MOUNTDATA 2
#define FS_HAS_SUBTYPE 4
#define FS_USERNS_MOUNT 8 /* Can be mounted by userns root */
#define FS_RENAME_DOES_D_MOVE 32768 /* FS will handle d_move() during rename() internally. */
struct dentry *(*mount) (struct file_system_type *, int,
const char *, void *); //转载文件系统时调用
void (*kill_sb) (struct super_block *); //文件系统被卸载时调用
struct module *owner; //指向实现该文件系统模块的指针
struct file_system_type * next; //指向文件系统类型链表的下一个元素
struct hlist_head fs_supers; //该文件系统类型的所有超级块实例链表的表头
struct lock_class_key s_lock_key;
struct lock_class_key s_umount_key;
struct lock_class_key s_vfs_rename_key;
struct lock_class_key s_writers_key[SB_FREEZE_LEVELS];
struct lock_class_key i_lock_key;
struct lock_class_key i_mutex_key;
struct lock_class_key i_mutex_dir_key;
};
图5 file_system_type结构体
Linux支持很多种文件系统,每个文件系统都会有一个文件系统类型结构体,不管是编译进内核,还是动态加载,首先文件系统类型都要通过register_filesystem接口向vfs层进行注册。所有注册过的文件系统类型对象都链接到一个单链表中,这个单链表的成员就用file_system_type结构体中next指针串起来。如下图所示。
图6 文件系统类型单链表
单链表表头是一个指针变量file_systems,定义在fs/filesystems.c文件中,后面每注册一个文件系统类型,就通过next指针进行指向,如何在linux系统中进行查看呢?可以通过cat /proc/filesystems命令进行查看,当前实验机注册的文件系统类型如下图所示。
图7 获取当前实验机文件系统类型-1
动态加载simplefs.ko后,继续查看文件系统类型,如下图所示。
图8 获取当前实验机文件系统类型-2
文件系统类型注册的主要目的是向vfs层提供get_sb和kill_sb回调接口。所以每个具体文件系统在注册时都要初始化这两个函数。每个文件系统类型下可以串多个超级块实例,这是通过结构体中fs_supers成员来实现的。fs_supers是一个list_head结构体,就是一个抽象链表,可以把它当做一个钩子,将不同类型文件系统的超级块成员钩起来,串成一个双向链表。如下图所示。
图9 fs_supers成员结构关系
三句话概念file_system_type结构体的作用就是:
- 告诉内核,我是谁;
- 告诉内核,我该如何被装载;
- 告诉内核,我该如何被卸载;
3.2 Super Block超级块
针对超级块结构体的成员注释,参考了《存储技术原理分析-基于2.6.x内核源码》进行分析和整理的,如下图所示。
struct super_block {
struct list_head s_list; /* Keep this first */ //作为一个链表头,将所有超级块对象串到这个链表中来
dev_t s_dev; /* search index; _not_ kdev_t */ //存储超级块信息的块设备
unsigned char s_blocksize_bits; //文件系统块长度的位数
unsigned long s_blocksize; //文件系统的块长度,单位为字节
loff_t s_maxbytes; /* Max file size */ //文件的最大长度
struct file_system_type *s_type; //指回到文件系统类型的指针
const struct super_operations *s_op; //超级块函数操作集
const struct dquot_operations *dq_op; //磁盘配额函数操作集
const struct quotactl_ops *s_qcop; //磁盘配额管理函数操作集
const struct export_operations *s_export_op;
unsigned long s_flags; //文件系统装载标识
unsigned long s_iflags; /* internal SB_I_* flags */
unsigned long s_magic; //每个超级块都有一个唯一的魔术字标记
struct dentry *s_root; //指向文件系统根目录的dentry对象
struct rw_semaphore s_umount; //文件系统卸载时用到的读写信号量
int s_count; //超级块的引用计数,表示该超级块是否能被释放
atomic_t s_active; //活动引用计数,表示被mount多少次
#ifdef CONFIG_SECURITY
void *s_security;
#endif
const struct xattr_handler **s_xattr; //是一个指向结构的指针,该结构包含了一些用于处理扩展属性的函数指针
const struct fscrypt_operations *s_cop;
struct hlist_bl_head s_anon; /* anonymous dentries for (nfs) exporting */
struct list_head s_mounts; /* list of mounts; _not_ for fs use */
struct block_device *s_bdev; //表示文件系统数据所在的块设备,但它是一个指向内存中block_device结构的指针
struct backing_dev_info *s_bdi;
struct mtd_info *s_mtd;
struct hlist_node s_instances; //链入到所所属文件系统类型的链表中
unsigned int s_quota_types; /* Bitmask of supported quota types */
struct quota_info s_dquot; /* Diskquota specific options */ //磁盘限额相关选项
struct sb_writers s_writers;
char s_id[32]; /* Informational name */
uuid_t s_uuid; /* UUID */
void *s_fs_info; /* Filesystem private info */ //指向具体文件系统超级块信息的指针
unsigned int s_max_links;
fmode_t s_mode; //对于磁盘文件系统,记录装载模式(只读、读/写)
/* Granularity of c/m/atime in ns.
Cannot be worse than a second */
u32 s_time_gran; //文件系统时间戳(访问/修改时间等)粒度,单位为ns
/*
* The next field is for VFS *only*. No filesystems have any business
* even looking at it. You had been warned.
*/
struct mutex s_vfs_rename_mutex; /* Kludge */
/*
* Filesystem subtype. If non-empty the filesystem type field
* in /proc/mounts will be "type.subtype"
*/
char *s_subtype;
const struct dentry_operations *s_d_op; /* default d_op for dentries */
/*
* Saved pool identifier for cleancache (-1 means none)
*/
int cleancache_poolid;
struct shrinker s_shrink; /* per-sb shrinker handle */
/* Number of inodes with nlink == 0 but still referenced */
atomic_long_t s_remove_count;
/* Being remounted read-only */
int s_readonly_remount;
/* AIO completions deferred from interrupt context */
struct workqueue_struct *s_dio_done_wq;
struct hlist_head s_pins;
/*
* Owning user namespace and default context in which to
* interpret filesystem uids, gids, quotas, device nodes,
* xattrs and security labels.
*/
struct user_namespace *s_user_ns;
/*
* Keep the lru lists last in the structure so they always sit on their
* own individual cachelines.
*/
struct list_lru s_dentry_lru ____cacheline_aligned_in_smp;
struct list_lru s_inode_lru ____cacheline_aligned_in_smp;
struct rcu_head rcu;
struct work_struct destroy_work;
struct mutex s_sync_lock; /* sync serialisation lock */
/*
* Indicates how deep in a filesystem stack this SB is
*/
int s_stack_depth;
/* s_inode_list_lock protects s_inodes */
spinlock_t s_inode_list_lock ____cacheline_aligned_in_smp;
struct list_head s_inodes; /* all inodes */ //文件系统所有inode链表的表头
spinlock_t s_inode_wblist_lock;
struct list_head s_inodes_wb; /* writeback inodes */
} __randomize_layout;
图10 超级块结构体相关成员注释信息
针对super block结构体,目前工作和学习过程中,暂未深入了解和探究一二,通过分析《存储技术原理分析-基于2.6.x内核源码》相关内容,将其中的一些重点搬到此文章中。
● 超级块对象存在于两个链表数据结构中,每一个超级块都会通过s_list这个“钩子”被链入到超级块循环双向链表中;另外,每一个超级块对象也会通过s_instances这个“钩子”链入到它所属的文件系统类型结构体的链表中;
● s_inodes为文件系统所有inode链表的表头,通过这个表头,可以把所有该文件系统上的inode连接起来;
在sb结构体中还包含其他很重要的成员,比如s_type,s_op,s_count等等,这里不再一一详细展开,填鸭式的方式不一定能够消化得了这些成员,后面用到了会继续更新补充文档。
3.3 inode vfs索引节点
《存储技术基础》上讲inode结构体时第一句话就说了,inode包含了文件系统各种对象(文件,目录,块设备文件等)的元数据。对于ext类型文件系统,inode结构存在于磁盘,内存以及vfs中,就是说,inode是一个实体,是实实在在存在的,回想一下上面讲到的物理结构,磁盘划分的每一个块组概念中,的确有inode块,当系统上电,文件系统被挂载时,首先将磁盘中的inode读取到内存中,当将该具体文件系统挂载到linux全局文件系统树上时,建立vfsmnt、inode、dentry和sb之间的关联,此时vfs inode就会和该具体inode建立了一种映射关联,便于上层通过vfs层来操作该文件系统。对inode在整个文件系统层的理解,可以见如下图所示,该图也是在存储技术基础图的基础上理解的。
图11 inode在文件系统层的三种形式
inode结构体成员关系如下图所示。
struct inode {
umode_t i_mode; //访问权限控制
unsigned short i_opflags; //文件系统操作标识 猜的?
kuid_t i_uid; //使用者id
kgid_t i_gid; //所在组id
unsigned int i_flags; //文件系统标识
#ifdef CONFIG_FS_POSIX_ACL
struct posix_acl *i_acl;
struct posix_acl *i_default_acl;
#endif
const struct inode_operations *i_op; //inode操作函数集
struct super_block *i_sb; //指向对应的超级块
struct address_space *i_mapping; //用于描述页高速缓存页面
#ifdef CONFIG_SECURITY
void *i_security;
#endif
/* Stat data, not accessed from path walking */
unsigned long i_ino; //索引节点号,且每个节点号唯一
/*
* Filesystems may only read i_nlink directly. They shall use the
* following functions for modification:
*
* (set|clear|inc|drop)_nlink
* inode_(inc|dec)_link_count
*/
union {
const unsigned int i_nlink; //硬链接计数
unsigned int __i_nlink;
};
dev_t i_rdev; //设备文件,表示与哪个设备进行通信,只是一个数字
loff_t i_size; //以字节为单位的文件大小
struct timespec i_atime; //文件最后访问时间
struct timespec i_mtime; //文件最后修改时间
struct timespec i_ctime; //文件最后改变时间
spinlock_t i_lock; /* i_blocks, i_bytes, maybe i_size */
unsigned short i_bytes; //文件中最后一个块的字节数
unsigned int i_blkbits;
enum rw_hint i_write_hint;
blkcnt_t i_blocks; //文件所占块数
#ifdef __NEED_I_SIZE_ORDERED
seqcount_t i_size_seqcount;
#endif
/* Misc */
unsigned long i_state; //索引节点的状态标识
struct rw_semaphore i_rwsem;
unsigned long dirtied_when; /* jiffies of first dirtying */
unsigned long dirtied_time_when;
struct hlist_node i_hash; //散列表,用于快速查找inode
struct list_head i_io_list; /* backing dev IO list */
#ifdef CONFIG_CGROUP_WRITEBACK
struct bdi_writeback *i_wb; /* the associated cgroup wb */
/* foreign inode detection, see wbc_detach_inode() */
int i_wb_frn_winner;
u16 i_wb_frn_avg_time;
u16 i_wb_frn_history;
#endif
struct list_head i_lru; /* inode LRU list */
struct list_head i_sb_list; //通过该链表节点挂到sb的双向循环链表头
struct list_head i_wb_list; /* backing dev writeback list */
union {
struct hlist_head i_dentry;
struct rcu_head i_rcu;
};
u64 i_version; //文件版本号
atomic_t i_count; //inode使用计数
atomic_t i_dio_count;
atomic_t i_writecount; //记录有多个进程以某一种模式打开该文件
#ifdef CONFIG_IMA
atomic_t i_readcount; /* struct files open RO */
#endif
const struct file_operations *i_fop; /* former ->i_op->default_file_ops */
struct file_lock_context *i_flctx;
struct address_space i_data; //表示被该inode读写的页面
struct list_head i_devices;
union {
struct pipe_inode_info *i_pipe; //包含了用于实现管道的inode的信息
struct block_device *i_bdev; //指向块设备文件指针,如果文件是块设备文件时使用
struct cdev *i_cdev; //指向字符设备文件指针,如果文件是字符设备时使用
char *i_link;
unsigned i_dir_seq;
};
__u32 i_generation;
#ifdef CONFIG_FSNOTIFY
__u32 i_fsnotify_mask; /* all events this inode cares about */
struct fsnotify_mark_connector __rcu *i_fsnotify_marks;
#endif
#if IS_ENABLED(CONFIG_FS_ENCRYPTION)
struct fscrypt_info *i_crypt_info;
#endif
void *i_private; /* fs or device private pointer */ //fs私有指针
} __randomize_layout;
图12 inode结构体
针对inode结构体,关注的几点如下
● 前面讲super block结构体时,有一个成员s_instances,它是将该文件系统所有的inode都串到该文件系统超级块双向循环链表中来,与之对应的inode中的成员就是i_sb_list;
● struct address_space *i_mapping 这个成员很重要,因为它涉及页高速缓存,该结构体描述的一个高速缓存页面,其中有一个结构体成员const struct address_space_operations *a_ops,表示的页高速缓存操作函数集,对文件进行读写访问走page cache层调用的就是这个结构体里的接口,后面讲页高速缓存层会详细讲到。
3.4 dentry vfs目录项
首先强调一点的是dentry(目录项)和文件系统中的目录不是同一个概念。dentry描述的是文件系统中所有的文件、目录、块设备文件、链接等在内核中所在文件系统树的位置。
dentry结构体也分为三种形式,当打开一个文件对象时,先是从磁盘读取dentry,用于构造vfs dentry和内存dentry。这一点其实和inode是一样的,换句话说,dentry也不是一个逻辑上的概念,而是一个实体。
dentry结构体如下图所示。
struct dentry {
/* RCU lookup touched fields */
unsigned int d_flags; /* protected by d_lock */ //目录项缓存标识
seqcount_t d_seq; /* per dentry seqlock */
struct hlist_bl_node d_hash; /* lookup hash list */ //内核使用dentry_hashtable对dentry进行管理,dentry_hashtable是由list_head组成的链表,一个dentry创建后,通过d_hash进入对应hash值的链表中
struct dentry *d_parent; /* parent directory */ //父目录的目录项
struct qstr d_name;
struct inode *d_inode; /* Where the name belongs to - NULL is //与该目录项相关联的inode
* negative */
unsigned char d_iname[DNAME_INLINE_LEN]; /* small names */
/* Ref lookup also touches following */
struct lockref d_lockref; /* per-dentry lock and refcount */
const struct dentry_operations *d_op;
struct super_block *d_sb; /* The root of the dentry tree */
unsigned long d_time; /* used by d_revalidate */
void *d_fsdata; /* fs-specific data */
union {
struct list_head d_lru; /* LRU list */ //最近未使用的目录项的链表
wait_queue_head_t *d_wait; /* in-lookup ones only */
};
struct list_head d_child; /* child of parent list */ //目录项通过该成员进入到父目录的d_subdirs中
struct list_head d_subdirs; /* our children */ //本目录中所有
/*
* d_alias and d_rcu can share memory
*/
union {
struct hlist_node d_alias; /* inode alias list */
struct hlist_bl_node d_in_lookup_hash; /* only for in-lookup ones */
struct rcu_head d_rcu;
} d_u;
} __randomize_layout;
图13 dentry结构体
3.5 vfsmount 文件系统装载
针对该结构体,存储技术基础一书中对该结构体的描述感觉略有偏差,上面说到的几个结构体inode、dentry和sb描述的是一个具体的文件系统,如果该具体文件系统只在内核中访问,那么调用相应操作接口即可实现,但是,用户态进程要向访问该文件系统,是无法通过内核操作api进行访问的,于是才有了vfsmount结构体,它的作用是将该具体的文件系统挂到linux全局文件系统树上,这样,用户态进程就可以看到这棵树上对应的具体文件系统,进而可以通过封装的系统调用或命令来操作。所以可以这样说,vfsmount结构体的本质是将具体文件系统和全局文件系统树建立起一种映射关系,供用户访问。
vfsmount结构体是一个纯内存的逻辑概念,相对比而言,sb、dentry和inode三个是实体概念,也存在于磁盘上。
装载涉及到父子关系,这里完全照搬存储技术基础一书的原话,如果一个具体的文件系统被装载到另一个文件系统实例的装载点上,前一个vfsmount被称作子vfsmount,后一个被称作父vfsmount。关系如下图所示。
图14 父子vfsmount关系
在4.14内核源码中,vfsmount结构体明显做了优化,拆分为vfsmount和struct mount两个结构体,vfsmount结构体成员见如下图所示。
struct vfsmount {
struct dentry *mnt_root; /* root of the mounted tree */
struct super_block *mnt_sb; /* pointer to superblock */
int mnt_flags;
} __randomize_layout;
图15 vfsmount结构体
struct mount结构体成员见下图所示。
struct mount {
struct hlist_node mnt_hash; //作为一个“钩子”,链入到全局已装载文件系统哈希表
struct mount *mnt_parent; //指向父vfsmount指针
struct dentry *mnt_mountpoint; //指向被装载到的装载点dentry的指针
struct vfsmount mnt;
union {
struct rcu_head mnt_rcu;
struct llist_node mnt_llist;
};
#ifdef CONFIG_SMP
struct mnt_pcp __percpu *mnt_pcp;
#else
int mnt_count; //文件系统引用计数
int mnt_writers;
#endif
struct list_head mnt_mounts; /* list of children, anchored here */ //装载到该文件系统目录上的所有子文件系统的链表头
struct list_head mnt_child; /* and going through their mnt_child */ //挂到被装载到父文件系统上mnt_counts链表上的一个链接件
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;
struct list_head mnt_expire; /* link in fs-specific expiry list */
struct list_head mnt_share; /* circular list of shared mounts */
struct list_head mnt_slave_list;/* list of slave mounts */
struct list_head mnt_slave; /* slave list entry */
struct mount *mnt_master; /* slave is on master->mnt_slave_list */
struct mnt_namespace *mnt_ns; /* containing namespace */
struct mountpoint *mnt_mp; /* where is it mounted */
struct hlist_node mnt_mp_list; /* list mounts with the same mountpoint */
struct list_head mnt_umounting; /* list entry for umount propagation */
#ifdef CONFIG_FSNOTIFY
struct fsnotify_mark_connector __rcu *mnt_fsnotify_marks;
__u32 mnt_fsnotify_mask;
#endif
int mnt_id; /* mount identifier */
int mnt_group_id; /* peer group identifier */
int mnt_expiry_mark; /* true if marked for expiry */ //如果为1,表示文件系统已经过期
struct hlist_head mnt_pins;
struct fs_pin mnt_umount;
struct dentry *mnt_ex_mountpoint;
} __randomize_layout;
图16 struct mount结构体
4、文件系统的创建/装载/卸载
4.1 文件系统的创建
先问两个问题吧
如何创建一个文件系统?
格式化文件系统的原理又是怎样的呢?
如果能够很好的回答这两个问题,说明对磁盘文件系统的创建从原理上已经比较了解和熟悉,对于这两个问题,我也仅仅是知道表面的一些东西,其实很深入的,贴近具体文件系统没有看过。一般格式化文件系统很简单,无外乎如下图所示的两行命令
图17 dd image文件
先dd一个内存文件400KB,其中bs=4KB,一共100个
再执行命令如下图所示。
图18 执行mkfs命令格式化image文件
其中linux系统中使用的mkfs命令更加的复杂,mkfs命令本身并不会执行具体的格式化磁盘的逻辑,而是通过参数来调用具体的文件系统处理流程进行格式化。其中mkfs命令的源码包可以通过命令rpm -qf /usr/sbin/mkfs来获取,在此就不再详细研究了,我只拿分析过的simplefs文件系统项目来进行简单的分析,所谓“麻雀虽小,五脏俱全”。
● 执行dd命令时,此时就已经规划好了文件块大小和个数;
● 因为linux系统里一切皆文件,磁盘也不例外。照样可以使用文件操作函数write、open等系统调用进读写访问,通过这些操作,可以充分理解linux一切皆文件。 所以格式化磁盘文件系统的本质就是在每一个块中填充超级块,inode块等结构体信息。在simplefs文件系统中只填充了一次sb、inode。
● 格式化后的文件image可以通过dumpe2fs命令查看相应内容,因为simplefs格式化命令mkfs-simplefs逻辑很简单,导致磁盘内容不能完全被dumpe2fs命令所识别,故执行结果如下图所示。
图19 dumpe2fs命令查看image文件内容
4.2 文件系统的装载
4.2.1 从数据结构上进行分析装载的过程
当内存中构建了某个具体文件系统的sb、inode、dentry结构以及互相关联后,内核态就可以访问该文件系统了,而且该文件系统只能被内核态认识。此时用户态看不到该文件系统,要想用户态能够看到该文件系统,需要将该具体的文件系统装载到全局文件系统树上,这里就用到了vfsmount结构体,vfsmount结构体反应的是具体文件系统挂载全局文件系统某个目录的对应关系。
当执行装载操作时,结构体之间的关联关系如下图所示,其中,vfsmount结构体只存在于内存中,其他三个结构体不仅存在于内存中,也存在于磁盘中。首先,当格式化一个文件系统时,磁盘中已经建立有sb、inode、dentry,装载过程就是在内存中创建vfsmount结构体,并让vfsmount结构体和sb、inode和dentry三个结构体之间建立关联的过程。
4.2.2 通过ftrace工具分析一次mount调用流程
比如这里用ext4文件系统进行演示,执行mount image /mnt命令函数调用关系如下图所示。(其中,文件系统格式为ext4)
图20 ftrace分析mount命令调用关系
其中代码流程图如下图所示。
存在的问题
- block_device结构体是干嘛的
- User_namespace结构体是干嘛的
- struct mountpoint *mp结构体是干嘛用的
4.3 文件系统的卸载
本地linux系统内核版本为:5.0.0-2.el7.elrepo.x86_64,通过ftrace工作目录执行查看可跟踪函数,未找到do_umount,无法进行实验分析,此小结函数流程采用之前的一张函数调用流程进行分析,如下所示。
5、文件系统I/O操作
其实在一开始学习文件系统时,就浅显的以为文件系统最重要的部分莫过于文件系统io操作,比如inode_operations、file_operaionts等,因为上层访问文件系统,最终都是要调底层这些接口来实现,但是,不管是工作中,还是学习过程中,发现文件系统io操作固然重要,但是重点还是要理解文件系统的几个数据结构相互之间的关系和内容,如何通过这几个结构体穿针引线,构建整个文件系统,如果把ops比作整个大楼的龙骨,那么结构体和数据结构则是组成这些龙骨的关键部件。要深知这些部件是如何一个一个链接起来,才能更好的理解龙骨的实现。另外这里将文件系统所有的op放在一起,其中包含有struct inode_operation、struct file_operation、struct dir_operation,inode_operations是对文件inode进行操作的函数集,file_operations是对文件进行操作的函数集,dir_operations是对目录进行操作的函数集。
这一部分是通过开源项目simplefs文件系统来编写inode ops,加深对i_ops 函数操作的理解,我的小伙伴看了说,别扯这么多没用的,直接上干货。。哈哈,代码会在后面持续输出,稍等稍等。这里还是先说一下流程和设计思路。
5.1 创建文件
关于在文件系统中创建一个文件,从数据结构角度看,是创建了一个inode,则要做的事情是如何在super block、inode以及dentry等几个结构体中管理这个inode。从这个流程来看,一共有如下几个步骤
(1)首先获取vfs层sb结构体,通过vsb找到simplefs文件系统sb结构体,获取inodes_count计数,便于后面计算inode结构体里的ino编号,代码流程如下
(2) 在vfs sb结构体里创建一个inode对象,文件系统元素的创建,不管是inode、dentry、sb、还是vfsmount,都会填充该元素内容,即让表示该对象的几个结构体互相“认识”。相应代码如下:
其中注意,inode的i_ino编号是通过sb的inode_count计算获取的,这里inode是vfs层,simple inode的i_ino也是这个值。
(3)创建sinode,同样道理,既然创建了对象,就要对该对象进行填充,顺便将sinode和inode两个结构体关联起来,代码如下
(4) 通过传入mode文件类型,根据是目录还是普通文件,再对sinode和inode进行填充ops函数操作集,如果是目录,则填充dir_operations,如果是普通文件,则填充file_operations和i_mapping->aops,特此说明,因为普通文件读写有直读和sync方式读写,即走缓存层,所以这里多了一套函数操作集。
(5) 在ssb结构体里申请一个数据块,用于存储文件真正的数据内容,这个数据块就用sb->free_blocks来进行操作,怎么知道是哪个数据块要被用了呢,由传入参数sfs_inode->data_block_number来决定。操作步骤是所有ssb上空闲块都被置位1,比如此时申请一个空闲块用于存储文件数据,则将这个空闲块置0。
(6) 将sinode填充到ssb中去,这里用到了sb_bread函数,这个函数作用获取缓冲区头信息,用于将磁盘中sb内容读取到内存中来,然后将bh的data数据内容强制转数据类型为sinode,这里就用到了ssb->inode_count计数,用于将该inode信息填充到第inode_count个sinode信息内存中;再sb->inode_count++;
(7) 在该inode文件所在的父目录中添加一个条目信息,即inode到文件名称的映射关系;这里也用到sb_bread(),用于将磁盘sb目录内容读取到缓存区sb中,再将缓存区bh->data数据强转为simplefs_dir_record结构体,下面就是要往这个结构体里填充内容,首先是填到哪里,填什么内容,sinode里有一个计数dir_children_count,如果这是一个目录文件,这个计数表示我这个目录有多少个文件记录条目,所以当创建文件时,要在这个目录偏移sinode->dir_children_count个字节地址处填写内容,内容就是s_dir_record结构体,一个是文件名称,一个是inode,这个ino=sinode->inode_ino=inode->inode_ino。
存在问题
- sb_bread函数是干嘛的
5.2 删除文件
在simple文件系统中删除文件,这里即delete inode,也是inode_operations函数操作集中一个op,该过程是上面create inode过程的逆过程。
5.3 创建硬链接
从结构体上看,对于一个文件的硬链接,其inode和dentry是一对多关系,所以在数据结构上看,inode不变,只需要在该硬链接文件所在父目录的条目内容中添加一条文件名对应该inode的信息即可,这是实现原理。
simplefs文件系统支持硬链接设计分析步骤如下:
(1) 首先需在struct simplefs inode结构体里添加link_counter成员用于硬链接数目计数,如下图所示
struct simplefs_inode {
mode_t mode; //文件类型和访问权限
uint64_t inode_no; //inode编号
uint64_t data_block_number; //数据块编号
uint64_t link_counter; //simplefs文件系统支持硬链接计数 modify 2019-05-19
union {
uint64_t file_size; //文件长度(以字节为单位)
uint64_t dir_children_count;//unused currently
};
};
(2) 在simplefs_inode_ops inode操作集里新增hardlink操作函数,该函数有两部分组成
在父目录inode对应数据块中添加一个条目信息;
该文件inode对应的link_counter计数加1;
这里要注意,对于文件的操作,比如计数,条目信息最后都是要写入磁盘的,即块设备中,所以要用到sb_bread()函数进行映射,这样,我们下次再读取该sb时,才能在块设备中读到之前的内容,比如这里计数变化,如果只是简单sinode->link_counter++,这次是写在内存了,当卸载文件系统,下次再mount,因为数据没有落盘,所以不会从磁盘中读取内容。
(3)删除文件操作操作时,添加硬链接逻辑,判断link_counter计数是否为1,大于1时,只删除条目信息,只对link_counter减1即可,为1时再释放inode内存和data块内存区域,通过编码实现创建硬链接需求
存在问题
删除硬链接,会删除原文件,再删除一遍硬链接文件才会成功
分析:问题应该是删除一个文件在父目录inode条目信息流程时出错
经过分析确认是删除条目信息这里逻辑不对,当前在父目录inode数据块中删除一个硬链接文件流程
输入dentry-->转换为inode-->sinode-->到父目录inode数据块区通过inode删除该映射关系,所以这样会出现输入不同dentry,最后找到的是同一个inode,这样就会存在明明删除了文件硬链接文件aa,但是删除的却是原文件bb。
解决思路
在硬链接文件所在父目录inode数据块区删除条目函数里增加对文件名称的判断即可。
5.4 创建软链接
软链接和硬链接的原理图大致如下
设计步骤
(1) 创建软链接文件和普通文件的流程是一样的,需要在inode->operations结构体里注册回调函数,如下
该函数流程和对普通文件创建inode流程相一致,首先,在sb中申请inode,对于simplefs文件系统申请一个sfs_inode,并通过i_private指针建立连接关系;然后在数据块区申请一个数据块,创建普通文件这里数据块里不需要添加任何内容,因为此时还没有写入任何数据,但是软链接文件已经写好了,数据块区存放的是原文件的文件路径,所以这里需要进行填充;最后在软链接文件所在父目录inode数据块区添加一个条目信息即可。
(2) 创建了软链接文件,还要注册软链接文件操作函数,如下所示
图21 软链接文件操作函数
其中read_link函数接口如下
其作用是当执行ls命令查看该文件系统挂载目录下文件属性时,vfs层读取所有文件时,根据文件属性,调具体文件注册的回调函数,比如文件是软链接,则会调用readlink回调函数,再到具体文件系统,该函数实现从dentry指向的链接路径读取buflen字节大小的内容到buffer缓冲区中,用于显示该软链接文件相关属性。
follow_link函数接口如下
其作用是当执行cat file_sym软链接文件时,由dentry指向的inode获取该软链接文件inode指向的数据块内容,从数据块区获取内容,即指向的链接文件的路径,并将该路径保存到nameidata结构体中。之前在写软链接需求时,以为readlink和followlink是一起使用,通过调试发现这两个回调接口是分开使用,一个是ls,一个是cat获取文件数据内容。
5.5 直接读写
对于simplefs文件系统,直接读写(即direct方式)是直接提供好了的,就是不过缓存,每一次io读写数据都是内存和磁盘直接打交道,读的时候是从磁盘读上来,写的时候是内存直接写到磁盘里去。
对于读流程,从磁盘里读数据用到函数sb_bread(),该函数的作用是从磁盘读取内容到缓存头,伪代码如下
struct buffer_head *bh;
char *buffer = NULL;
bh = sb_bread(sb, inode->data_block_number);
BUG_ON(!bh);
Buffer = (char *)bh->b_data;
copy_to_user(source, buffer, len);
mark_buffer_dirty(bh);
Brelse(bh);
直写的大致流程和直读流程相差不大,但是写需要考虑到偏移,所以直接写流程伪代码如下
struct buffer_head *bh;
char *buffer;
bh = sb_bread(sb, inode->data_block_number);
BUG_ON(!bh);
buffer = (char *)bh->b_data;
buffer += *offset;
copy_from_user(buffer, buf, len);
mark_buffer_dirty(bh);
Sync_dirty_buffer(bh);
brelse(bh);
5.5 高速页缓存page cache读写
缓存读写的目的就是提高磁盘io性能,这里不再赘述。磁盘IO的本质是数据从内存到磁盘的一种映射关系。如果直接读写的源和目的分别是内存和磁盘,那么缓存读写的源和目的分别是内存和page cache页,只要到page cache缓存页,上层io就认为我完成这一次io下发的使命了,我可以返回了,下面该干啥干啥,和我没半毛钱关系了。
而page cache页是一个逻辑上概念,通过查找资料和看书,Linux内核使用定义在linux/fs.h中的结构体address_space描述高速页缓存中的页面。如下图所示。
图22 描述page cache页address_space结构体
address_space结构体会与内核里的一个实例相关联。通常情况下,会与inode进行关联。address_space结构体里a_ops域指向地址空间对象中的操作函数集,所以对文件inode进行高速页缓存io读写操作是通过inode->i_mapping结构体里a_ops进行处理,如下图所示。
图23 inode通过i_mapping和address_space建立关系
设计步骤
- 参考2.6.32-504内核代码编写struct address_sapce_operations结构体函数操作集;
- 在simplefs_create_fs_object()里添加如果是针对普通文件类型,则给inode->i_mapping->a_ops进行赋值;
- 在simplefs_read里通过inode->f_flags进行判断,如果是O_DIRECT,则走之前流程,否则走高速页缓存,先调用find_get_page获取一个page,如果找不到,则调用page_cache_alloc_cold()申请一个page,并将该page放到LRU链表中,调用readpage()从页缓存进行读取即可;读出到page后,需要将page内数据通过kmap输出到一个局部内核变量,利用copy_to_user上传给用户态buffer;
- simplefs_write和simplefs_read流程大致相同,根据文件类型进行判断走direct流程还是pace cache流程,如果是direct流程,则走之前逻辑即可,如果走page cache,则调用grab_cache_page_write_begin获取一个page,通过kmap将page映射到本地局部变量,调用copy_from_user将数据拷贝到局部变量,即映射给page 最后标脏,释放page即可。
6、块I/O子系统
前面的章节内容讲的都是文件系统层的内容,但文件系统层下面就是块I/O子系统层,这一层从linux I/O架构上又可以分为三层,通用块层、io调度层和块设备驱动层,因为纵观整个io栈,不能不说块io子系统是极其重要的,所以在此也简单的总结一下自己浅薄的见识,以下内容首先借鉴宋宝华老师《文件读写(BIO)波澜壮阔的一生》进行消化和分析,在此基础上进行添油加醋,以形成自己的格局观和认识。
6.1 文件读写I/O栈--第一次read系统调用整个流程
以下就按照宋老师的例子,用户态程序重复执行两次read系统调用,来看两次读操作io栈在内核中的调用关系。Demo程序如下图所示:
图24 io读写demo程序
我们假设该程序是要读取ext4文件系统的一个文件,根据宋老师讲解内容,其实我们大多数人都能够或多或少看明白,但我在分析和学习过程中,认为有两个点是关键的
● bio和request的三进三出
● 如何分析一次read系统调用整个I/O调用栈
宋老师《文件读写波澜壮阔的一生》看了有多遍,但是每次对于bio数据结构是在哪一层生成,什么时候放进request请求链表里,内核对上层IO进行合并和优化的三个点分别对应代码的哪里,这些问题思考起来,从大的IO栈上进行概览,往往很糊涂,云里雾里;再一个就是通过什么工具能够快速掌握一次read系统调用代码调用流程呢?所谓“工欲善其事必先利其器”,有什么好的工具可以用的吗?
所以当时脑子里就有这两个问题,带着这两个问题,自己做实验,结合文章自己推敲,拿demo程序中第一次read系统调用为例,通过ftrace工具,先给出了一张函数调用流程图,宏观上先把握好主干脉络,抓住整个层次。
整个函数调用流程如下图所示。
图25 read系统调用I/O调用栈
6.2 文件读写I/O栈--pace cache预读
这里还是以demo程序为例,执行read系统调用时,默认情况下读写IO流会走page cache,我们假设要读取的文件并没有在内存中,此时执行read就要真正到磁盘里去拿数据,read读取4096个字节,即一个page页。那么这段故事该怎么发生呢?可以用如下对白演示。
应用程序:hello,内核前辈,我的主人(客户端)想读取file文件4096个字节,可以发送给我吗?
内核前辈:可以啊,木有问题,但是4096个字节会不会太少啦,哈哈哈
应用程序:...(心想:我能有啥办法,我的主人就那么抠,唉...)
内核前辈:这样吧,小伙子,我返回给你0-16K的数据(即4个page页),省的你回去了,你的主人责怪你呢~~
应用程序:哇,简直泪崩啊,好感动,多谢内核前辈慷慨!
所以第一次read读操作时,内核会多读,提前读,把用户不想读的也给读了,因为内核设计的逻辑是,假设用户读操作场景中,读完还想继续读,那干脆我多给你一些数据好了,反正我cpu闲着也是闲着,你内存空着也是浪费了,不如咱们干点实事。那么这种设计思想会大大提高读效率。
第一个read系统调用执行代码调用流如下:
read-->sys_read-->vfs_read-->page_cache_sync_readahead()
第一次read执行前,page cache命令情况见下图所示。
图26 第一次read前page cache命令情况
第一次read执行后,page cache命令情况见下图所示。
图27 第一次read后page cache命令情况
第一次read执行后,内核会预读0-16K数据到内存,并在第2页设置标记readahead,如果继续read,则会异步读更多数据到内存中。所以第二次read执行前,page cache命令情况见下图所示。
图28 第二次read前page cache命令情况
第二次read执行后,page cache命令情况见下图所示。
图29 第二次read后page cache命令情况
第二个read系统调用执行代码调用流如下:
read-->sys_read-->vfs_read-->page_cache_async_readahead()
6.3 文件读写I/O栈--内存到磁盘的转换
当执行read系统调用读取文件内容时,此时读取磁盘上的0-16K数据到内存的page cache页中,但是page cache页和磁盘块具体位置如何对应起来呢?
其中这一层映射关系也恰恰说明了底层IO的本质,即:I/O就是内存到磁盘的映射。
其中用到的一个数据结构体就是bio,它的作用就是把要具体访问的磁盘上具体的位置和内存中的页映射起来,其内容如下图
/*
* main unit of I/O for the block layer and lower layers (ie drivers and
* stacking drivers)
*/
struct bio {
struct bio *bi_next; /* request queue link */
struct gendisk *bi_disk;
u8 bi_partno;
blk_status_t bi_status;
unsigned int bi_opf; /* bottom bits req flags,
* top bits REQ_OP. Use
* accessors.
*/
unsigned short bi_flags; /* status, etc and bvec pool number */
unsigned short bi_ioprio;
unsigned short bi_write_hint;
struct bvec_iter bi_iter;
/* Number of segments in this BIO after
* physical address coalescing is performed.
*/
unsigned int bi_phys_segments;
/*
* To keep track of the max segment size, we account for the
* sizes of the first and last mergeable segments in this bio.
*/
unsigned int bi_seg_front_size;
unsigned int bi_seg_back_size;
atomic_t __bi_remaining;
bio_end_io_t *bi_end_io;
void *bi_private;
#ifdef CONFIG_BLK_CGROUP
/*
* Optional ioc and css associated with this bio. Put on bio
* release. Read comment on top of bio_associate_current().
*/
struct io_context *bi_ioc;
struct cgroup_subsys_state *bi_css;
#ifdef CONFIG_BLK_DEV_THROTTLING_LOW
void *bi_cg_private;
struct blk_issue_stat bi_issue_stat;
#endif
#endif
union {
#if defined(CONFIG_BLK_DEV_INTEGRITY)
struct bio_integrity_payload *bi_integrity; /* data integrity */
#endif
};
unsigned short bi_vcnt; /* how many bio_vec's */
/*
* Everything starting with bi_max_vecs will be preserved by bio_reset()
*/
unsigned short bi_max_vecs; /* max bvl_vecs we can hold */
atomic_t __bi_cnt; /* pin count */
struct bio_vec *bi_io_vec; /* the actual vec list */
struct bio_set *bi_pool;
/*
* We can inline a number of vecs at the end of the bio, to avoid
* double allocations for a small number of bio_vecs. This member
* MUST obviously be kept at the very end of the bio.
*/
struct bio_vec bi_inline_vecs[0];
};
图30 struct bio结构体内容
其中,bio结构体里有一个成员struct bio_vec,每个bio会记录一个或多个页,所以bio结构体里用到bio_vec结构体来描述每一个对应的page页。
还是以0-16K数据为例,分析一下bio和page页之间的对应关系,有如下几种假设情况
情况1 读取的0-16K数据在底层磁盘上是连续的
图31 连续磁盘与page映射
情况2 读取的0-16K数据在底层磁盘上是完全不连续的
图32 不连续磁盘与page映射
情况3 读取的0-16K数据在底层磁盘上是部分连续的
图33 部分连续磁盘与page映射
以上完成bio数据结构与page页的映射关系这一处理逻辑的工作,是由ext4_readpages函数完成的。可以参见上面的图。
6.4 文件读写I/O栈--上层io请求(bio和request)的三进三出
思考一个问题,比如上层读写文件系统n个块,那么到最终落盘是否也是n个磁盘io呢?
对于这个问题,我目前还没有实际上机操作过,但是通过分析多篇文章讲述的块io子系统io栈,也即宋宝华老师写的bio和request的三进三出内容,略知一二,可以得出结论,最终落盘磁盘IO个数未必等于n。如果磁盘上bio相邻,则等于n;如果磁盘上每个bio不相邻,则会小于n。
通过第一张图,已经可以清晰地看到io请求的三次进出,分别是page cache层的plug list蓄流、I/O调度层的电梯排序以及最终的块设备驱动层下发执行。
其中本地蓄流的函数调用流程如下:
read_pages-->blk_start_plug
-->submit_bio
-->blk_finish_plug
其中做的事情如下:
● 构建多个bio;
● 另外一个非常重要的工作是make_request_fn,这是一个函数指针,在初始化过程中就已经赋值了,注册回调函数是blk_queue_bio,它完成的工作就是创建request,并将bio合并进到本地进程描述符task_struct对象plug成员request list链表上,如果bio在磁盘位置上相邻,则下一个bio同样会合并进该request list,即蓄流链表;如果bio不相邻,则重新创建一个request,将下一个bio合并进新生成的request list中。
假设file文件0-16K数据块在磁盘上的位置如下
图34 0-16KB数据在磁盘具体块位置
因为从0k计算,这四个数据块不连续,所以说要分配4个bio,但是26 27两个数据块相邻,所以可以合并进入同一个request请求,所以最后本地进程plug list上的request请求如下表
request0 | bio0 | 10 |
request1 | bio1 | 17 |
request2 | bio2 bio3 | 26-27 |
进程本地的plug request list中的请求,会通过调用io调度层的add_req_fn回调函数,被加入到电梯队列。
进入到电梯队列的request请求,不会直接发送给具体的块设备驱动层,而是进一步的合并,优化这些队列的request请求,然后通过调用io调度层的dispatch_req_fn回调函数,具体块设备驱动层request_fn函数会获取一个个的request请求,并传送给相应的块设备进行执行命令。