大家好,这里是物联网心球。
VFS(Virtual File System,虚拟文件系统)是Linux系统中一个非常重要的概念,是Linux设计思想的核心。不管你是学习Linux编程还是研究Linux内核源码,都必须对VFS有深入的理解。
本文我们好好聊聊VFS,揭开VFS的神秘面纱。
1.VFS是什么?
如图1所示,VFS是Linux内核中的一个关键抽象层,它的主要作用包括:
图1 VFS是什么
目前Linux可识别并挂载的文件系统类型已超过40多种,随着内核版本的迭代,文件系统的数量还在增加。文件系统按功能可以划分为以下几大类,见表1:
表1 Linux文件系统分类
每种文件系统都有一套独立的文件访问接口。如果没有VFS,用户程序必须熟悉每种文件系统的文件访问接口,才能够很好地使用文件系统,这样显然是非常不合理的。VFS的出现,将文件系统的文件访问工作交给内核,用户程序只需要学习少量的系统调用(约为10多种)就能够轻松地访问各种文件系统。
VFS的核心设计思想是多态(面相对象编程的三大特性之一),用户程序调用相同的文件访问接口能够实现不同的功能。这种设计方式可以屏蔽底层的细节,用户程序不再需要关注底层实现细节,只需要学会调用系统调用即可。另外,这种设计方式非常便于增加新文件系统,新文件系统添加至内核不会对原有的文件系统有任何影响(文件系统之间不存在耦合关系)。
2.VFS的四大核心对象
VFS通过四大核心对象来实现它的核心功能,这四大核心对象分别是:
超级块 (super_block):表示一个已挂载的文件系统实例。Linux挂载文件系统时,会在内存创建一个超级块,用于管理文件系统。
索引节点 (inode):表示文件系统中的一个文件或目录,用于访问文件系统中的文件。
目录项 (dentry):表示文件路径中的一个分量,是Linux文件树的树节点,将文件路径名与对应的inode关联起来。
文件对象 (file):表示进程中一个已打开文件的实例,同一个文件可以被多个进程同时打开,每打开一次文件都要创建一个文件对象。
我们以ext4文件系统为例,来看一下VFS的四大核心对象的作用,如图2所示。
图2 VFS四大核心对象
dentry是Linux文件树的树节点,对应的结构为struct dentry。每个文件和目录都会对应一个dentry,dentry之间通过父子关系形成一个树形结构(类似于家族树)。
如果把Linux文件树比作一个地图,每个dentry都是一个小的站点,文件路径就是导航路线,文件路径用于指导内核快速定位到目标文件对应的dentry。
为什么需要先找到dentry呢?
因为dentry的d_inode和i_sb成员分别指向索引节点(inode)和超级块(super_block)。内核需要通过inode和super_block这两个对象来访问文件系统。
inode在Linux系统中用来唯一标识一个文件,对应的结构为struct inode。inode存储了文件的元数据以及提供了文件的操作接口,但是它并不包含的文件实际数据,因为inode属于抽象层,不能够和任何具体实现有耦合关系。每个inode都有一个唯一编号(inode号),用于在文件系统中索引文件。
super_block是文件系统的一个实例,对应的结构为struct super_block,它包含了文件系统的元数据信息(如:文件系统类型、容量信息、结构参数、位置信息等),内核需要借助这些元数据信息才能访问文件系统。
细心的小伙伴应该发现,图2中并没有文件对象(file),file对象后续会详细介绍。
3.目录项(dentry)
structdentry {structdentry *d_parent;/* 指向父目录dentry */structqstrd_name;/* 文件名 */structinode *d_inode;/* 指向与该目录项关联的inode */structsuper_block *d_sb;/* 指向该dentry所属文件系统的超级块 */structlist_headd_child;/* 将当前dentry插入到其父目录dentry的d_subdirs链表中 */structlist_headd_subdirs;/* 链表头,指向该目录下的所有子dentry */ ......};
dentry的结构并不复杂,dentry不包含文件的实际数据,只包含文件名、指向inode和super_block的指针以及与其他dentry的关联关系。d_name成员为文件名,d_inode成员指向inode(表示具体的文件),d_sb指向super_block(表示具体的文件系统)。 子dentry(文件或目录)的d_parent指向父dentry,表示二者为父子关系。多个子dentry可以指向相同的父dentry(对应一个目录下可以创建多个文件和子目录),dentry的父子关系如图3所示。图3 dentry父子关系
父dentry通过d_subdirs链表来维护所有的子dentry,子dentry通过d_child链表节点插入父dentry的d_subdirs链表。dentry通过d_parent、d_child、d_subdirs三个成员和其他dentry构成树形结构,从而形成Linux文件树。4.索引节点(inode)
索引节点(inode)的定义如下:
structinode {umode_t i_mode; /* 文件类型和访问权限 */kuid_t i_uid; /* 文件所有者用户ID */kgid_t i_gid; /* 文件所有者组ID */conststructinode_operations *i_op;/* inode操作函数表 */structsuper_block *i_sb;/* 指向所属超级块 */structaddress_space *i_mapping;/* 文件页缓存 */unsignedlong i_ino; /* inode号,在同一个文件系统内,通过i_ino字段唯一标识一个文件 */union {constunsignedint i_nlink; /* 硬链接数 */ };loff_t i_size; /* 文件大小(字节)*/structtimespec64i_atime;/* 最后访问时间 */structtimespec64i_mtime;/* 最后修改时间 */structtimespec64 __i_ctime;/* 状态变更时间 */blkcnt_t i_blocks; /* 文件占用的块数量 */union {conststructfile_operations *i_fop;/* 文件操作函数表 */ };structaddress_spacei_data;/* 页缓存实体,i_mmaping指向i_data */void *i_private; /* 私有指针 */ ......};
inode包含基本的文件元数据,但不包含文件实际数据(因为inode同样属于抽象层,不能和具体实现有耦合关系)。ext4文件系统中文件的实际数据存储在ext4_inode_info结构中,ext4_inode_info表示一个具体的ext4文件系统文件,ext4_inode_info内部包含一个inode,二者之间为组合关系。当然不同的文件系统这部分的具体实现存在差异,需要具体情况具体分析。
inode有两个重要的成员i_op(inode操作函数表)和i_fop(文件操作函数表)。
i_op主要负责处理与文件或目录本身相关的元数据操作和结构性操作,如:创建和创建文件、删除文件、查找文件、修改权限等。
i_fop的类型为struct file_operations结构,其定义如下:
structfile_operations {loff_t (*llseek)(......); /* 修改文件当前读写位置,实现文件定位 */ssize_t (*read)(......); /* 读取文件数据到用户空间 */ssize_t (*write)(......); /* 将用户空间数据写入文件 */__poll_t (*poll)(......); /* 支持I/O多路复用机制(select/poll/epoll) */int (*mmap)(......); /* 将设备内存映射到用户空间 */int (*open)(......); /* 打开文件时初始化资源 */int (*release) (......); /* 关闭文件时,释放资源 */int (*flush)(......); /* 关闭前刷新待处理的文件数据 */int (*fasync)(......); /* 设置/清除设备的异步通知机制 */int (*fsync)(......); /* 将文件数据同步写入磁盘 */ssize_t (*splice_write)(......); /* splice 零拷贝:管道→文件 */ssize_t (*splice_read)(......); /* splice 零拷贝:文件→管道 */ ......};
i_fop函数表中定义的函数接口,大家应该都不陌生。这些函数是Linux编程中经常会用到的函数,我们平时调用的文件操作相关的函数接口都是通过i_fop来实现的。
VFS之所以能够为用户程序提供统一的文件访问接口,都是因为i_fop默默地完成了这些文件访问接口的具体实现,如图4所示。
图4 VFS实现接口统一
当用户程序执行文件操作时,内核通过文件路径查找文件树找到目标文件对应的dentry,再从dentry获取inode,再通过inode的i_fop找到实际文件系统的文件操作函数表,最后调用函数表中对应的函数。
每种文件系统类型的文件操作函数表都不一样(图4中只列举了一些常用的文件操作函数表)。文件创建的过程中,内核根据文件系统类型动态的给i_fop赋值, 从而实现“接口统一、具体实现不一样”的动态多态特性。
5.文件对象(file)
文件对象(file)的定义如下:
structfile {fmode_t f_mode; /* 文件打开模式(读/写/执行)*/loff_t f_pos; /* 当前文件偏移量(读写位置)*/unsignedint f_flags; /* 打开标志(如O_NONBLOCK、O_SYNC)*/structfown_structf_owner;/* 文件属主信息,用于信号通知 */structpathf_path;/* 文件路径信息(dentry + mount)*/structinode *f_inode;/* 指向inode的指针,文件元数据 */conststructfile_operations *f_op;/* 文件操作函数表(read/write等)*/structaddress_space *f_mapping;/* 文件地址空间,用于mmap */ ......} ;
文件对象(file)用于记录已打开文件的状态信息,一个文件可以被多个进程多次打开,每次打开一个文件都需要创建一个新的file。
图5 进程打开文件过程
Linux进程打开文件过程如图5所示。
用户程序调用open函数打开一个文件时,内核会根据open函数传入的文件路径查找文件树,找到目标文件对应的dentry,再通过dentry找到inode。如果文件不存在(文件树中没有对应的dentry和inode),open函数可以创建文件并生成dentry和inode。
获取到inode后,内核会创建一个新的file,同时将inode的关键信息复制给file,如:
file的f_op将指向inode的i_fop,file将直接使用inode的文件操作函数表。
file的f_mapping成员将指向inode的f_mmaping,通过file可以访问inode的页缓存。
同时file的f_inode将指向inode,file和inode进行绑定。
内核最后会申请一个未使用的fd(文件描述符),将fd和file进行映射,并将这种映射关系记录在进程的已打开文件表。后续的文件操作,只需要通过fd查找进程已打开文件表获取file,就能通过file访问文件了,从而避免每次文件访问都需要索引Linux文件树。
6.超级块(super block)
超级块(super_block)的定义如下:
structsuper_block {dev_t s_dev; /* 设备号 */unsignedchar s_blocksize_bits; /* 文件系统数据块大小的位数 */unsignedlong s_blocksize; /* 文件系统数据块大小(字节为单位)*/loff_t s_maxbytes; /* 该文件系统支持的最大文件长度 */structfile_system_type *s_type;/* 文件系统类型 */conststructsuper_operations *s_op;/* 超级块操作函数表 */unsignedlong s_magic; /* 魔数,标识文件系统类型(如 Ext4 为 0xEF53)*/structdentry *s_root;/* 超级块(文件系统)根目录 */structblock_device *s_bdev;/* 指向块设备 */void *s_fs_info; /* 文件系统私有数据指针 */structlist_heads_inodes;/* 指向文件系统中所有 inode 的链表头 */ ......};
super_block是文件系统的控制中心,用于管理整个文件系统。
主要作用:
存储文件系统的全局信息和运行状态,减少磁盘I/O。
提供文件系统操作方法(s_op文件系统函数表)。
以s_root为根目录,建立和维护文件系统内部文件树。
通过s_fs_info可以访问实际文件系统超级块。
总结:
我的新书《图解Linux网络编程》发布了,如果你想系统性地学习Linux网络编程,从底层原理到上层应用彻底通关Linux网络编程,欢迎入手我的新书。