在次重申下,Linux 的设计理念是:一切都是文件!
也就是在 Linux 中,一切设备皆是以文件的形式进行操作,如网络套接字、硬件设备等。这一切都是通过一个中间层实现的,被称为 VFS (Virtual File System) 。
Virtual File System, VFS
总体上说 Linux 的文件系统主要可分为三大块:一是上层的文件系统的系统调用,也就是提供的统一文件系统 API;二是虚拟文件系统 VFS;三是挂载到 VFS 中的各实际文件系统,例如 ext4 。
按照类型文件系统可以分为三类:A) 磁盘文件系统,最常见,用来将数据保存在物理存储上,如 ext4、FAT、NTFS;B) 虚拟文件系统,如 procfs、sysfs;C) 网络文件系统,NFS 。
为了支持多种文件系统,Linux 内核在用户进程 (C标准库) 和文件系统之前实现了一个抽象层,虚拟文件系统,也就是 VFS 。
VFS 除了为所有文件系统的实现提供一个通用接口外,还提供了一些文件相关数据结构的磁盘高速缓存。例如最近最常使用的目录项对象被放在所谓目录项高速缓存(dentry cache)的磁盘高速缓存中,从而加速从文件路径名到最后一个路径分量的索引节点的转换过程。
数据结构
对于文件,主要包括了两部分信息:A) 存储的数据本身;B) 该文件的组织和管理的信息。
后者就是 Linux 中维护的一些元数据 (metadata),主要结构包括了 superblock、inode、dentry 和 file,用来支持如指示存储位置、历史数据、资源查找、文件纪录等功能。
在内存中, 每个文件都有一个 dentry(目录项) 和 inode (索引节点) 结构,dentry 记录着文件名,上级目录等信息,正是它形成了我们所看到的树状结构;而有关该文件的组织和管理的信息主要存放 inode 里面,它记录着文件在存储介质上的位置与分布。
另外,dentry->d_inode
指向相应的 inode 结构,dentry 与 inode 是多对一的关系,因为有可能一个文件有好几个文件名,如硬链接。
dentry 和 inode 的关系
在 Linux 进程中,是通过目录项 (dentry) 和索引节点 (inode) 描述文件的,而所谓 “文件” 就是按一定的格式存储在介质上的信息,所以一个文件其实包含了两方面的信息,一是存储的数据本身,二是有关该文件的组织和管理的信息。
在内存中, 每个文件都有一个 dentry 和 inode 结构,前者记录着文件名、上级目录等信息,所有的 dentry 用 d_parent 和 d_child连 接起来,就形成了我们熟悉的树状结构; 而有关该文件的组织和管理的信息主要存放 inode 里面,它记录着文件在存储介质上的位置与分布。
同时 dentry->d_inode 指向相应的 inode 结构,由于硬链接导致一个文件可能有好几个文件名,所以 dentry 与 inode 是多对一的关系。
inode 代表的是物理意义上的文件,通过 inode 可以得到一个数组,这个数组记录了文件内容的位置,如该文件位于硬盘的第 3、8、10 块,那么这个数组的内容就是 3、8、10。在同一个文件系统中可以通过索引节点号 inode->i_ino 计算出在介质上的位置,对于硬盘来说,可直接计算出对应的 inode 属于哪个块 (block),从而找到相应的 inode 结构。
另外,对于某一种特定的文件系统而言,如 ext4,在内存中用 ext4_inode_info 结构体描述,包含了一个 inode 容器。就磁盘文件而言,dentry 和 inode 的信息保存在磁盘上,对于像 ext4 这样的磁盘文件来说,存储介质中的目录项和索引节点载体通过 ext4_inode、ext4_dir_entry_2 标示。
文件系统
磁盘上的文件内容通过文件系统组织一系列的文件,通常这些文件保存在磁盘的不同分区上,当然不同的分区可能包含不同的文件系统类型,如 ext2、ext3、fat16、ntfs 等。
可以通过 cat /proc/filesystems
查看已经注册的文件系统。
对于每个文件系统,同时会有代码,或者是模块,来告诉我们如何操作这些文件。因此,在使用具体的文件之前,需要告诉内核该文件系统的相关信息,主要包括 A) 文件系统名称;B) 知道如何挂载;C) 如何查找文件的路径;D) 如何查找文件的内容。
数据结构
file system
其中 struct file_system_type
包括了文件系统的主要参数,如下之列出了主要部分。
struct file_system_type {
const char *name; // 文件系统的名称,如EXT4、FAT16、NTFS
int fs_flags; // 对应文件系统的类型,在该变量下定义了一些符号的宏
struct dentry *(*mount) (struct file_system_type *, // 代替早期的get_sb(),用户挂载此文件系统时使用的回调函数
int, const char *, void *);
void (*kill_sb) (struct super_block *); // 删除内存中的super block,在卸载文件系统时使用
struct module *owner; // 指向实现这个文件系统的模块,通常为THIS_MODULE宏
struct file_system_type *next; // 指向文件系统类型链表的下一个文件系统类型
struct hlist_head fs_supers; // 该文件系统类型的超级块结构,都串连在这个表头下
};
另外,存在一个全局变量 file_systems
,用于保存所有已经注册的文件系统。
static struct file_system_type *file_systems;
static DEFINE_RWLOCK(file_systems_lock);
文件系统信息在内核中会通过单向链表保存,其中 file_systems
全局变量作为链头,同时对应一个 file_systems_lock
锁,当需要读写时需要先加锁,如上,可以通过 cat /proc/filesystems
查看已经注册的文件系统。
operation
与文件相关的操作包括了三部分:
- SuperBlock 包含了文件系统的元数据信息;
- Inode 与文件相关的信息;
- File 保存的文件主体。
针对这三部分同时也对应了三类的操作 API 接口函数。
struct super_operations {
struct inode *(*alloc_inode)(struct super_block *);
void (*destroy_inode)(struct inode *);
int (*sync_fs)(struct super_block *sb, int wait);
int (*statfs) (struct dentry *, struct kstatfs *);
// ... ...
};
struct inode_operations {
struct dentry * (*lookup) (struct inode *,struct dentry *, unsigned int);
int (*create) (struct inode *,struct dentry *, umode_t, bool);
int (*link) (struct dentry *,struct inode *,struct dentry *);
int (*mkdir) (struct inode *,struct dentry *,umode_t);
// ... ...
};
struct file_operations {
loff_t (*llseek) (struct file *, loff_t, int);
ssize_t (*read) (struct file *, char __user *, size_t, loff_t *);
ssize_t (*write) (struct file *, const char __user *, size_t, loff_t *);
int (*open) (struct inode *, struct file *);
// ... ...
};
文件系统注册
这是最简单的,也就是直接添加到全局列表中。
在上面的结构体中包含了文件系统的名称,以及如何产生一个保存在内存中的 super_block
结构体。主要,通过 register_filesystem()
向 VFS 注册,对于编译到内核中的文件系统则是在内核初始化的时候注册,当然也可以在模块初始化的时候注册。
文件系统注册通过 int register_filesystem(struct file_system_type * fs)
来完成,该函数唯一的操作是将相应的结构体添加到 file_systems
链表中。
在 struct file_system_type
中通过链表与 file_systems
链头相连,而 register_filesystem()
也就是将对应的类型添加到链表中。
然后在通过 mount 命令挂载时,会指定相应的文件系统,然后通过对应的 mount()
函数从文件系统 (extN 是从硬盘上) 读取 super_block
并初始化。
新建 inode 实际通过 new_inode()
完成,而该函数最终会调用 super_block->s_op->alloc_inode()
完成。
Mount 挂载
挂载就是将一个文件系统添加到一个目录上,对应的文件系统通常为磁盘文件系统,如 EXTN、NTFS、XFS、FAT16 等;当然也包括虚拟文件,如 proc、sysfs 等。
挂载时通常包括 A) 一个设备,可以是磁盘、软盘、CDROM 、U盘等;B) 一个对应的目录挂载点;C) 指定相应的文件系统。
一个挂载命令 mount 通常如下,包括了几个重要参数 fstype、devname、mountpoint、options,可以通过 man 8 mount
查看,对于函数的原型可以通过 man 2 mount
查看。
# mount -t ext4 /dev/sda1 /mnt -o ....
对于上述的命令也可以通过如下的程序执行。
$ man 2 mount
... ...
int mount(const char *source, const char *target,
const char *filesystemtype, unsigned long mountflags,
const void *data);
... ...
$ cat mount_test.c
#include <stdio.h>
#include <sys/mount.h>
int main(int argc, char *argv[]) {
if (mount("/dev/sda1", "/mnt", "ext4", 0, NULL)) {
perror("mount failed");
}
return 0;
}
$ gcc -Wall -o mount_test mount_test.c # 编译
$ ./mount_test # 执行命令挂载
$ findmnt /dev/sda1 # 查看执行结果
对于 mount()
函数,source 是要挂载的设备名,target 是要挂载到哪,filesystemtype 就是文件系统类型名,而剩余的两个参数 flags 和 data 对应于传入的参数。
其中 flags 相应宏定义在 include/uapi/linux/fs.h
中,如 MS_RDONLY
、MS_NOATIME
等,这些 flags 会在 VFS 层被解析使用。而 data 则是每个文件系统各自支持的挂载选项,可以通过 strace 查看最终调用 mount()
接口是调用的命令。
$ strace mount /dev/loop0 /mnt/foobar -o noquota,nodev
... ...
mount("/dev/loop0", "/mnt/foobar", "xfs", MS_MGC_VAL|MS_NODEV, "noquota") = 0
... ...
其中 nodev 被解释为 flag,noquota 被当作了 mount data。
挂载过程
在内核中,struct mount
代表着一个 mount 实例,每次挂载都会新建一个该结构体,其中 struct vfsmount mnt
成员是它最核心的部分,过去所有的成员都保存在 vfsmount 结构体中,后来只保留了核心部分在 vfsmount ,这样使得 vfsmount 的内容更加精简,在很多情况下只需要传递 vfsmount。
// fs/mount.h
struct mount {
struct hlist_node mnt_hash;
struct mount *mnt_parent;
struct vfsmount mnt;
... ...
};
// include/linux/mount.h
struct vfsmount {
struct dentry *mnt_root; // 该文件系统的根目录
struct super_block *mnt_sb; // 指向superblock
int mnt_flags;
};
// include/linux/path.h
struct path {
struct vfsmount *mnt;
struct dentry *dentry;
};
在全局文件系统树上要想确定一个位置不能由 dentry 唯一确定,因为有了挂载关系,一切都变的复杂了,比如一个文件系统可以挂装载到不同的挂载点。所以文件系统树的一个位置要由 (mount, dentry)
二元组,或者说 (vfsmount, dentry)
来确定,在内核中通过 struct path
表示。
下面查看 sys_mount()
的执行过程,实际上,mount 操作的过程就是新建一个 struct mount
,然后将此结构和挂载点关联。之后,目录查找时就能沿着 mount 挂载点一级级向下查找文件。
sys_mount()
|-copy_mount_string() # 从用户空间复制文件系统类型名称、设备名称
|-copy_mount_options() # 获取data数据
|-do_mount() # 通过该函数调用已传入内核的参数
|-user_path() # 把挂载点解析成path内核结构,也就是路径解析过程
| |-user_path_at()
| |-filename_lookup()
|- ... ... # 解析flags确定mount的操作类型,如bind、remount、newmount
|-do_remount() # 重新挂载等操作,下面以挂载新节点为例
|-do_new_mount()
|-get_fs_type() # 通过文件系统名称,找到对应文件系统的类型
|-vfs_kern_mount() # 通过该函数调用具体文件系统的处理函数,构建一个vfsmnt结构
| |-alloc_vfsmnt() # 分配新的struct mount结构体,并初始化其中的一部分,
| | # 构造一个root dentry,包含特定文件系统的super block信息
| |-mount_fs() # 调用具体文件系统的mount回调函数
| | |-type->mount() # 调用特定文件系统的回调函数
| |- ... ... # 完成最后struct mount的初始化
|
|-do_add_mount() # 将得到的mnt结构添加到全局文件系统
|-lock_mount() # 找到最新的挂载点,并加锁
|-real_mount() # 挂载到对应的mount点,对挂载进行检查
|-graft_tree() # 将newmount添加到全局文件系统中
在调用 vfs_kern_mount()
时,只有文件系统类型、挂载标记、设备名和挂载选项信息为参数,并没有 mountpoint 参数,其时这里只是用 type 中的 mount 回调函数读取设备的 superblock 信息,填充 mnt 结构,然后把 flag 和 data 解析后填充到 mnt 结构中。
就是说,通过 vfs_kern_mount()
会调用具体文件系统的 mount()
函数,生成 struct mount
,最后通过 do_add_mount()
添加到全局的文件系统中。
系统调用
对于文件的常见操作如下:
#include <fcntl.h>
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h
int main(void)
{
int fd, ret;
fd = open("syscall.txt", O_RDWR | O_CREAT, 0644); // man 2 open
if(fd == -1) {
printf("Error try to open 'syscall.txt'");
exit(EXIT_FAILURE);
}
ret = write(fd, "just for test\n", 14);
if(ret == -1) {
printf("Error try to write 'syscall.txt'");
close(fd);
exit(EXIT_FAILURE);
}
close(fd);
return 0;
}
在 Linux 内核中,每个打开的文件均由一个文件描述符 struct file
表示,而返回给用户的是在 fd_array[]
中的位置索引。
也即,该描述符在特定进程的数组中充当位置索引,这也意味着包括 open()
数组是 task_struct -> files -> fd_arry
,该数组的元素包含了 file 结构,其中包括每个打开文件的所有必要信息。
sys_open
对于 open() 系统调用的的主要执行操作在 do_sys_open() 中,下面介绍其主要操作。
do_sys_open() 的操作大致为:A) 找到一个本进程没有使用的文件描述符 fd;B) 分配一个全新的 struct file 结构体;C) 根据传人的 pathname 查找或建立对应的 dentry;D) 建立 fd 到这个 struct file 结构体的联系。
sys_open()
|-force_o_largefile() # 是否要强制使用大文件
|-do_sys_open() # 实际工作,返回索引
|-build_open_flags() # 检测传入的flag合法性,并转换为内核的格式struct open_flags
|-getname() # 1. 将用户空间的路径名复制到内核空间,此时申请了内核内存
| |-getname_flags() # 主要的处理函数
|-get_unused_fd_flags() # 2. 查找一个未使用的文件描述符
|-do_filp_open() # 3. 完成路径搜索并打开文件
| |-path_openat() # 实际返回struct file结构体
| |-get_empty_filep()
| |-path_init()
| |-link_path_walk()
| |-do_last() # 打开文件的最后一步
| |-may_open() # 进行权限检查
| |-vfs_open()
| | |-do_dentry_open() ### 真正的针对文件系统的操作
| |-open_check_o_direct() # 通过f->f_flags & O_DIRECT判断
|-fsnotify_open() # 4. 通过fsnotify机制唤醒文件系统中的监控进程
|-fd_install() # 5. 为该文件描述符安装文件,设置current->files->fd[fd]=file
|-putname() # 6. 释放之前申请的内核内存
在开始,sys_open() 会检测是否要强制支持大文件,在 64 位系统上 flags 会自动加上 O_LARGEFILE ,对于 32 位系统,文件最大受索引节点中表示文件大小的 32-bit 的 i_size 的影响,只能访问 2^32 字节,即 4GB ,而实际高位一般不用,所以通常只有 2G 。
加上 O_LAGEFILE 之后启用索引节点的 i_dir_acl 字段也可以一起表示文件的大小了,这样位数就变成了 64 位,也就是单文件最大为 2^64=16TB 。
do_last() 是文件打开操作的最后一步,其中很大一部分是针对 flag 的判断操作,关于 flags 的含义可以参考 man 2 open,该函数最终会调用 vfs_open() 函数。
int vfs_open(const struct path *path, struct file *filp,
const struct cred *cred)
{
struct inode *inode = path->dentry->d_inode;
if (inode->i_op->dentry_open)
return inode->i_op->dentry_open(path->dentry, filp, cred);
else {
filp->f_path = *path;
return do_dentry_open(filp, NULL, cred);
}
}
struct inode {
... ...
const struct inode_operations *i_op;
... ...
};
对于 ext4,可以通过 cd fs/ext4 && grep -rne ‘^const.*ext4.*inode_operations’ 查看,而 dentry_open() 指针并不存在,实际上会调用 do_dentry_open() 函数。
在 do_dentry_open() 中,会分配一个全新的 struct file 结构体,打开时会根据路径判断所属的文件系统,并执行具体类型的 open() 操作。
static int do_dentry_open(struct file *f,
int (*open)(struct inode *, struct file *),
const struct cred *cred)
{
... ...
path_get(&f->f_path);
inode = f->f_inode = f->f_path.dentry->d_inode;
f->f_mapping = inode->i_mapping;
... ...
f->f_op = fops_get(inode->i_fop); // 后续的操作都使用该行指定的操作
if (unlikely(WARN_ON(!f->f_op))) {
error = -ENODEV;
goto cleanup_all;
}
error = security_file_open(f, cred);
if (error)
goto cleanup_all;
error = break_lease(inode, f->f_flags);
if (error)
goto cleanup_all;
if (!open)
open = f->f_op->open;
if (open) {
error = open(inode, f);
if (error)
goto cleanup_all;
... ...
}
struct dentry {
... ...
struct inode *d_inode; /* Where the name belongs to - NULL is negative */
... ...
};
struct path {
struct vfsmount *mnt;
struct dentry *dentry;
};
struct file {
... ...
struct path f_path;
const struct file_operations *f_op;
... ...
};
对于后续的 read()、write() 操作,实际调用的是 struct file_operations 指定的接口,如上述的 open() 函数接口,也就是 inode->i_fop 指定的值。
另外,可以参考 Linux 系统调用 open 七日游 相当不错的介绍文件系统打开的过程。
sys_read
sys_read() 会根据用户空间传入的文件描述符 fd 取出对应的 struct file 结构体,获取 struct file 结构体的当前偏移量指针,从文件读取内容,存放到用户空间内存区,如果读取成功,唤醒相关等待进程,更新文件的当前指针,如果需要则释放对 file 结构的引用。
sys_read()
|-fdget_pos() # 1. 通过fd获取struct file结构
|-file_pos_read() # 2. 获取当前的偏移量,也即file->f_pos
|-vfs_read() # 3. 调用VFS接口
|-file->f_op->read() # 3.1 如果指针存在
|-do_sync_read() # 如果是异步读
|-wait_on_sync_kiocb() # 等待数据传输完成
通过 fdget_pos() 返回当前进程下标为 fd (文件描述符) 的 struct file 结构体,其中 current 为当前进程 task_struct,实际返回 current -> files_struct -> fdtable -> file[fd] 这个 struct file 结构体。
在此之前,以及通过 sys_open() 把进程的这个 fd 对应的文件从硬盘上读取或创建好了,所以这里可以之前从数组里面读取。注意,该函数同时会将 file->file_opeeration 设置为 inode->file_operation 。
ssize_t vfs_read(struct file *file, char __user *buf, size_t count, loff_t *pos)
{
... ...
ret = rw_verify_area(READ, file, pos, count);
if (ret >= 0) {
count = ret;
if (file->f_op->read)
ret = file->f_op->read(file, buf, count, pos);
else if (file->f_op->aio_read)
ret = do_sync_read(file, buf, count, pos);
else
ret = new_sync_read(file, buf, count, pos);
if (ret > 0) {
fsnotify_access(file);
add_rchar(current, ret);
}
inc_syscr(current);
}
return ret;
}
struct file {
... ...
const struct file_operations *f_op;
... ...
};
struct file_operations {
... ...
ssize_t (*read) (struct file *, char __user *, size_t, loff_t *);
... ...
};
获取偏移量之后会调用 vfs_read(),该函数会把内核读取的文件内容存入 buf 指向的内存地址。如果是同步读,则会直接调用 file->f_op 对应的 read(),也就是直接调用 inode 对应的文件操作。
如果是异步,也即 f_op 中存在 aio_read() 函数,则会调用 do_sync_read() 。该函数首先会将 buf 和 len 保存,以供后续向用户空间传递数据。