探秘Linux VFS:从磁盘到用户的文件之旅
本篇文章试图通过“大部分理论+少部分代码”的形式为读者展开linux中VFS(虚拟文件系统)的神秘面纱。从磁盘眼中的文件,到操作系统眼中的文件,再到用户眼中的文件,这篇文章都会给予较为完整自洽的解释。虽然没有硬核的源码分析,但是相信读完这篇文章,读者会感觉不虚此行
目录
磁盘中的文件
磁盘的物理结构

如上图,磁盘由多个盘片组成,每个盘片的正反两个盘面都是可以存储数据的,并且每个盘面都对应一个磁头(用于数据读写)。需要注意的是,这些磁头并不是独立移动的,所有的磁头在传动臂的带动下一起移动,是一个整体。
单个盘面可以分为多圈磁道(传动臂左右摆动起始就是在定位一个磁道),每个磁道又可以分成多个多个扇区,而扇区是磁盘读写的最小单位。所有盘面的相同半径的磁道可以组成一个抽象的曲面,称作柱面。
检验一下你是否理解了上述概念吧:磁盘的容量 = 柱面(磁道)数 * 每个磁道的扇区数 * 每个扇区的字节数 *盘面(磁头)数
磁盘寻址方式
基本的磁盘寻址是CHS寻址法:先确定哪一个柱面(cylinder),再确定哪一个磁头(head),再确定哪一个扇区(sector),这样就可以确定一个具体的扇区进行读写。然而,如果OS内使用给出C、H、S的值的方式读写磁盘,那么兼容性是很不好的,因为我们知道,外部存储设备不止是磁盘,还有硬盘,未来还会有新的存储设备,而这些存储设备不见得都有柱面、磁头、扇区,强行写死的话就要不断修改OS的代码。
所以实际上,OS永远只使用LBA(Logical Block Addressing)寻址法:就是把整个存储设备上所有可读写的扇区看做一个线性数组,从 0 开始统一编号。OS只需要给出想要读/写的块号即可,而具体逻辑块怎么映射到存储设备的一个存储单元,由存储设备的驱动自己实现。
接下来我们以磁盘为例,讲一个存储设备如何实现LBA到具体存储单元的映射:

- 将每一个盘面上的每个磁道看做一维数组,其中每一个元素代表本磁道上的一个扇区。

- 一个柱面上有多个磁道,就可以将整个柱面看做一个二维数组。而这个二维数组也可以展开成一个大的一维数组,因此整个柱面的扇区可以看做一个大的一维数组。

- 整个磁盘由多个柱面组成,那么就可以将磁盘看做多张二维数组表,也就是三维数组。同理,也可以转换成一维数组。
自此,整个磁盘就被抽象为了一个一维数组,而OS就把磁盘当做线性数组访问,OS只需要给出想访问的下标,磁盘自动把下标转换成CHS地址并访问相应位置,如果换了别的种类的外部存储设备,磁盘依旧使用下标访问,只要让这个存储设备有相应的转换功能即可。这方便了OS的使用以及管理。
LBA和CHS如何互相转换:

如上所述,像访问一维数组一样访问整个磁盘。不过,一个扇区大小一般是512B,对于OS来说,以扇区为单位和磁盘进行IO交互有些太少了(我们都知道,IO交互是比较耗费时间的),根据局部性原理,可以一次性多读一些数据。因此文件系统一般把一个块(多个扇区)作为OS一般把磁盘抽象出来的一维数组每8个扇区划分成一个“块”,这个块的大小是8*512B,即4KB,每次IO操作都是以块为单位。
如下图所示,OS以块为基本单位进行IO操作,并把块号转换成若干个LBA下标交给磁盘,由磁盘进行扇区号和CHS地址的转换。所以OS访问磁盘总共经过两次转换。

OS访问磁盘的基本单位
如上所述,OS像访问一维数组一样访问整个磁盘。不过,一个扇区大小一般是512B,对于OS来说,以扇区为单位和磁盘进行IO交互有些太少了(我们都知道,IO交互是比较耗费时间的),根据局部性原理,可以一次性多读一些数据,因此OS一般把磁盘抽象出来的一维数组每8个扇区划分成一个“块”,这个块的大小是8*512B,即4KB,每次IO操作都是以块为单位。
如下图所示,OS以块为基本单位进行IO操作,并把块号转换成若干个扇区号交给磁盘,由磁盘进行扇区号和CHS地址的转换,总共两次转换。

磁盘上的文件系统
磁盘上不仅需要保存文件本身,还需要保存文件的组织信息,否则你就不知道如何访问文件
首先我们要知道,并不是一整个磁盘只有一个分区,我们可以选择性的把磁盘被分成几个分区,每个分区都是完全独立,互不影响的,他们甚至可以安装不同的文件系统进行文件组织(我们平时说的C盘,D盘其实大概率只是同一块磁盘的不同分区罢了):

接下来我将把EXT4文件系统作为例子来介绍一个文件系统如何组织文件。
文件系统组成
EXT4文件系统把一个分区按组为单位划分,每个分组内又包含分为若干个不同用途的部分。一旦文件系统被安装,整个分区的所有结构就固定了,包括每个分组的大小,分组内的结构划分,分组的存储块以及属性块个数,等等,总之,一切都变成了固定的。类似于管理一个国家,不同的分区代表不同的国家,不同的组代表一个国家中不同的省,这样划分开方便管理:

SuperBlock(超级块)
- 数量:超级块是文件系统的实体,每个分区都有超级块,存放在每个分区内固定的某一个或某几个分组的开头(SuperBlock肯能存储多份,原因是可以在文件系统受损后利用拷贝来恢复文件系统而不至于使系统直接崩溃)。
- 内容:里面存储的是该分区使用的文件系统的信息,以及整个分区的使用情况,分组情况等。
- 功能:存放文件系统。
BootBlock(引导块)
- 数量:每个磁盘只有一个引导块,一般存放在磁盘的前几个扇区(也就是根分区的最前面的位置)
- 内容:它里面存放的是引导程序(不讨论)以及分区表(整个磁盘分为哪几个分区,每个分区的起止位置是什么云云)等开机信息。
- 功能:大体来说,在第一次使用磁盘的时候,他会引导你分区,安装文件系统等初始化操作。之后,他就是记录分区等信息,帮助OS初始化管理磁盘的数据结构。
GDT(Group Descriptor Table)
- 数量:每个组都有,存放在每个组内相对固定的位置。
- 内容:里面存储的是这个组的整体管理信息,比方说该组多少块被使用,还剩下的容量,以及该分组的大小等信息。
- 功能:存放一个分组的管理信息。
InodeTable
- 数量:每个组都有,存放在每个组内相对固定的位置。
- 内容:里面存储的是该组所有文件的struct inode(也就是每个文件的属性部分,在linux中文件内容和属性是分开存储的)。
- 功能:存放组中文件的属性。
DateBlock
- 数量:每个组都有,存放在每个组内相对固定的位置。
- 内容:这是一个分组中占比最大的部分,这部分包含的是用来存储文件内容的存储块。
- 功能:存储数据。
BlockBitmap
- 数量:每个组都有,存放在每个组内相对固定的位置。
- 内容:里面存储的是一个位图,每一位都作为相对位置代表DataBlock中的一个特定位置的存储块(例如第0个bit代表的是DateBlock中的第一个存储块)。
- 功能:表示本组中的块的使用情况(使用了就把相应位置置1,反之置0)。
InodeBitmap
- 数量:每个组都有,存放在每个组内相对固定的位置。
- 内容:里面存储的是一个位图,每一位都作为相对位置代表InodeTable中的一个特定位置的属性块(例如第0个bit代表的是InodeTable中的第一个属性块,即struct inode)。
- 功能:表示本组中的InodeTable中struct inode的使用情况(使用了就把相应位置置1,反之置0)。
需要注意的是,OS以块为基本单位进行IO,所以一个DataBlock不是一个扇区,而一般是4KB,而若干个inode结构体因为单个体积太小所以挤在一起,不是一个inode一个块
属性块(struct inode)的内部结构如下:

注:
- inode号(i节点编号)用来唯一的标识一个文件。块号用来唯一标识一个存储块。
- 12个直接指针指向的是存放数据的DataBlock,而间接块索引表指针指向的DataBlock不存放数据只存放其他DataBlock的块号,以此来动态地增大一个inode所能管理的DataBlock的个数。
细节问题
看到这儿你可能已经明白整个文件系统是怎么回事了,不过这里我还要补充一些细节
inode号和块号如何是怎么得出的?
每个分组都设置不同的起始inode号和块号(保存在GDT中),inode号 = 本组起始inode号+inode在InodeTable中的偏移量,块号 = 本组起始块号+块在DataBlock中的偏移量。示例如下:
一个文件最大的大小是一个分组中所有存储块大小的和吗?
远远不是,由于起始号块号,使得整个分区中的所有存储块的块号都是不同的,这也就是说,如果一个分组中的文件想使用另一个分组的存储块非常简单,只需要读取另一个分组的BlockBitmap,找到空闲块并给相应bit位置1,然后把块号写入到文件的struct inode中维护映射即可。理论上说,一个文件最大可以达到整个分区那么大。
删除是将存储空间都置0吗?
不,本质上删除就是把Bitmap的相应位置置0罢了,至于存储块以及属性块的数据是不会动的,因此才有数据恢复这一说。
实际例子
创建一个文件的过程:
- 首先确定自己在那个分区,在该分区SuperBlock中的inode属性块是否用完。如果用完了,那就创建失败,否则,进入下一步。
- 遍历分区中所有组的GDT,查看哪个组的InodeTable不为全1,然后进入下一步
- 利用本组中的InodeBitmap中找一个bit位为0的位置,记作offset_1(也就是找到一个空闲的struct inode),并把该bit位置1(表示已经占用)。
- 在本组中的InodeBitmap中找到第offset_1个struct inode,并把想要创建的文件的属性写到这个属性块里面(文件的inode号 = 分组起始inode号+offset_1)。
- 利用本组中的BlockBitmap中找一个bit位为0的位置,记作offset_2,并把该bit位置1(表示已经占用),此时DataBlock中偏移为offset_2的块被使用,将起始块号+offset_2存入inode的,作为该文件关联的一个数据块块号。
- 在该DateBlock中写入文件数据。
- 更新管理结构(GDT,SuperBlock等),返回文件的inode号。
读取一个文件的过程:
- 首先确定自己所在的分区。并拿到文件的inode号
- 可以通过查看每个分组的起始inode号找到该inode号所属的组。
- 利用inode减去起始inode号得到offset_1。
- 在InodeTable中找到第offset_1个struct inode,即可获取文件的属性交给调用者,如果想获取文件内容,则读取struct inode中的存储块号。
- 存储块号减去起始块号得到offset_2。
- 读取DataBlock中第offset_2个存储块的内容并返回给调用者。
删除一个文件的过程:
- 首先确定自己所在的分区。并拿到文件的inode号
- 利用inode减去起始inode号得到offset_1。
- 在InodeTable中找到第offset_1个struct inode,读取struct inode中的存储块号。
- 将所有存储块号都减去分组起始块号,得到offset_2.1、offset_2.2、offset_2.3... ...
- 将本分组BlockBitmap中第offset_2.1、offset_2.2、offset_2.3... ...位置的bit位置0。
- 将本分组InodeBitmap中第offset_1位置的bit位置0
- 更新管理结构(GDT,SuperBlock等)。
后记
这些super_block,GDT等数据结构,会在启动后被加载到OS,OS通过他们对文件系统进行管理
目录文件和普通文件
在linux中,文件可以分为目录和普通文件,而目录文件存放的数据存放的就是文件名和inode号的映射关系,所以,当我们想要打开一个文件的时候,先要读取当前所在目录的数据,然后查找文件名所对应的inode号,进而通过inode号访问文件。可以想到,在我们创建文件的时候也会给其所在目录文件中写入新文件名和inode号映射关系。
开机的时候根目录是自动被加载到系统中的,依靠根目录的inode号就可以打开根目录,用户输入想访问的文件名就可得到文件的inode号然后打开... ...
VFS(OS中的文件)
VFS(Virtual File System,虚拟文件系统) 是 Linux 内核中的一个抽象软件层。它在上层为用户程序提供统一的文件操作接口,在下层则衔接各种具体的文件系统(如 ext4、XFS、NFS、proc 等),通过下面的讲解读者可以理解这一点
VFS相关数据结构
VFS 在内存中使用四个相互关联的关键结构体来刻画一个“文件系统”的全貌。它们是理解一切的基础。
超级块(struct super_block)
每个挂载的文件系统实例在内存中都由一个超级块表示,它保存文件系统的整体元数据和操作函数集,下面是关于struct super_block的部分属性介绍:
s_op:struct super_operations类型的指针,包含该文件系统级别的操作,如分配/销毁 inode同、获取统计信息等。
不同文件系统的inode类型是不同的,所以需要每个文件系统提供inode的创建方法。这些inode类型的第一个字段总是一样的,操作系统用这个字段统一看待和管理所有inode。s_root:指向该文件系统根目录的struct dentry。s_fs_info:这是一个void*类型的指针,指向具体文件系统的私有超级块数据(例如 ext4 的ext4_sb_info)
不同类型的文件系统总会有独特的属性s_type:指向struct file_system_type,代表文件系统类型。s_bdev:如果是块设备文件系统,指向对应的 block_device(块设备的实体),当我们读写磁盘数据的时候先要在super block找到要操作的目标,即:block_devices_id:设备名或标识字符串。s_instances:用于将同一类型的超级块链接在一起。(把同一种类型的文件系统超级块连接在一起,这是管理需求)s_inodes:该文件系统所有打开的 inode串联成的链表。
题外话—块设备
块设备就像快递站的货架。同样,货架上的柜子是固定的,你可以反复去同一个柜子拿不同的包裹,也可以今天把包裹放在1号柜,明天清空它再放别的。但是字符设备呢?它就像家里的水龙头。你无法在它上面“寻址”——你不能说“我要获取两次水管中第10厘米处的水分子”,结果当然不一样,因为水是流动的,打开水龙头,水流就源源不断地流出来(字符流),你只能按顺序接收,不能跳跃,也不能像快递柜那样,在同一个位置反复读写一个“固定块”。让我们回到技术定义:
- 块设备:提供或接受数据并存储在可寻址的块中,并且支持随机访问,数据一般是永久的。这意味着你可以直接跳到设备的任意一个块的位置进行读写,而不必按顺序从头读到尾。典型代表:硬盘(HDD)、固态硬盘(SSD)、U盘。文件系统(ext4, XFS等)正是为了高效管理这些“块”而生的。
- 字符设备:提供或接收连续的字符流,不支持随机访问,数据是顺序的、一次性的。典型代表:键盘、鼠标、串口、声卡。你从键盘读输入,是读一个流,不可能“跳到第5个字节去读”,他没必要给你提供这样的功能
索引节点(struct inode)
一个 inode 代表文件系统中的一个具体文件对象(文件、目录、设备节点、符号链接等),它包含了除文件名之外的所有文件元数据,向我们常用的文件属性就存在这里。
i_ino:inode 编号。i_sb:回指所属的超级块。i_op:指向struct inode_operations,提供对 inode 自身的操作(创建、删除、链接、修改属性等)。i_fop:指向该文件默认的文件操作函数集struct file_operations。对于块设备中的普通文件它是文件系统的读写实现;对于字符设备,它会在open时被替换为驱动实现。i_mapping:指向struct address_space,负责管理文件的页缓存。关于页缓存,点这里!i_rdev:设备号(用于设备类文件)。i_mode、i_uid、i_gid、i_size、i_atime等标准元数据。i_state:inode 状态标志(脏、正在回写等)。i_count:引用计数。
目录项(struct dentry)
dentry 代表文件系统上一个路径节点的实例对象,使用它构建一颗目录树(dentry cache),将整个文件层次关系展开在OS面前,是OS查找文件的依靠。每个dentry都含有该节点对应的文件的inode对象。
d_name:文件名,以struct qstr存储(包含字符串和哈希值,便于快速比较)。d_inode:指向关联的struct inode。如果文件不存在,则为NULL,这种 dentry 称为 negative dentry,用于加速后续失败查找的过程。d_parent:指向父 dentry。d_child:链入父 dentry 的d_subdirs链表。d_op:指向struct dentry_operations,提供 dentry 级别的操作(如验证缓存有效性、哈希计算、比较等),对于不同的文件系统可以有不同的操作。d_sb:指向所属超级块。d_flags:包含各类标志,其中DCACHE_MOUNTED表明该 dentry 是一个挂载点(挂载点是重点,后面会讲到)。
dentry 有若干种状态:used(有对应 inode 且被引用)、unused(有 inode 但未被使用,可回收)、negative(无对应 inode)。
文件对象(struct file)
struct file 代表一个进程打开的文件的上下文,它只是一个“视图”,不是文件本身。多个进程可以各自打开同一个文件并拥有不同的 file 对象(每个file对象有不同大小的偏移值表示进程对该文件读/写到哪儿)。
f_path:类型为struct path,包含- ,精确记录了文件在 VFS 全局树中的位置。
f_op:指向当前文件的操作函数集struct file_operations。这是实现设备多态的关键:普通文件的f_op直接取自inode->i_fop;字符设备在open时会被替换为驱动提供的file_operations。f_pos:当前文件偏移(读写位置)。f_flags:打开标志(O_RDONLY、O_NONBLOCK等)。f_mode:访问模式(FMODE_READ、FMODE_WRITE等)。f_mapping:通常直接指向inode->i_mapping,用于快速访问页缓存。f_count:引用计数。private_data:文件系统或驱动可使用的私有数据指针,这个指针是void*可以指向任意类型的对象。
五大操作接口集
上述每一类对象都对应了一套操作函数表,具体文件系统通过实现这些回调来参与 VFS 的工作。这些操作集也是实现VFS中多态的关键
| 对象 | 操作集 | 典型方法 |
|---|---|---|
super_block |
struct super_operations |
alloc_inode, destroy_inode, write_inode, sync_fs, statfs |
inode |
struct inode_operations |
create, lookup, link, unlink, mkdir, rmdir, rename, setattr, getattr, permission |
dentry |
struct dentry_operations |
d_revalidate, d_hash, d_compare, d_delete, d_release |
file |
struct file_operations |
open, release, read, write, llseek, read_iter, write_iter, mmap, poll, ioctl, flush, fsync |
address_space |
struct address_space_operations |
readpage, writepage, writepages, readahead, direct_IO, write_begin, write_end |
address_space 虽然不是四大主对象之一,但它是普通文件页缓存的核心。它由 inode 的 i_mapping 指向,其中的 host 指回 inode。address_space_operations可以存放着对页缓存的操作,比如读取一页缓存,或者回写脏页
分区和挂载
一个磁盘可以有多个分区,每个分区都有独立的文件系统,一个OS可以操作并管理多个文件系统。而linux中,将新的文件系统挂载在正在工作的文件系统的某一目录上进行使用(本质上就是挂载到dentry树上的某节点)。也就是说,分区与分区之间不是切换关系,而是你在某个分区然后进入这个分区的某个目录就相当于进入了新的分区(也就是使用新的文件系统)。接下来我将介绍挂载相关内容,以及挂载命名空间。
挂载数据结构与存储
每一次挂载在内核中都由一个 struct mount 表示,其中嵌入了对 VFS 通用代码可见的 struct vfsmount(即 mnt 字段)。一个 mount 实例记录的关键信息包括:
mnt_parent:指向挂载点所在文件系统的挂载实例。mnt.mnt_sb:指向被挂载文件系统的超级块。mnt_mountpoint:指向挂载点目录的 dentry(也就是被“贴”的那个树杈)。mnt.mnt_root:指向被挂载文件系统的根 dentry。mnt.mnt_flags:挂载选项(只读、noexec 等)。
那么这些 mount 结构体存放在哪里呢?它们全部保存在内核内存中,并通过两种方式组织起来:
-
全局挂载树:所有挂载实例通过
mnt_parent、mnt_child、mnt_mounts等链表指针构成一棵树,根节点是rootfs的挂载实例。这棵树用于遍历、查找父子关系以及卸载时的引用计数管理。 -
挂载哈希表(
mount_hashtable):以(挂载点 dentry,父挂载实例的 vfsmount)为键,快速查找挂在某个目录上的挂载实例。当路径遍历走到一个目录时,就是通过这个哈希表判断是否有文件系统挂在上面(不过通常使用dentry本身包含的d_flags标志为进行判断更快)。
挂载流程
当用户执行‘mount -t ext4 /dev/sda1 /mnt’时:
- VFS 根据文件系统名‘ext4’找到对应的
struct file_system_type。 - 调用其
mount方法。该方法负责解析参数、从‘/dev/sda1’指向的设备文件中读取超级块,在内存中分配并初始化super_block,设置好s_op,并构建根 inode 和根 dentry。 - 返回根 dentry 之后,VFS 创建一个新的
struct mount实例,将其内部的mnt_parent指针设为挂载点的父挂载实例,mnt_mountpoint设为挂载点目录的 dentry,mnt.mnt_root设为新文件系统的根 dentry。 - 将该新
mount插入全局挂载树和哈希表,并把挂载点 dentry 上d_flags的DECAHE_MOUNTED标志位置1
卸载时则相反:清除标志,从哈希表和挂载树中移除,释放超级块等资源。
挂载点穿越(路径解析)
路径解析是将用户态的路径字符串(如 /mnt/usb/file.txt)转换为内存中 VFS 对象(path)的过程,整个分析过程依赖于dentry树。
struct path —— 文件在 VFS 中的精确坐标
我们首先必须理解一个关键结构 struct path:
struct path {
struct vfsmount *mnt;
struct dentry *dentry;
};
它只有两个字段:
dentry:指向文件当前的目录项。mnt:指向当前路径节点所在的挂载实例的vfsmount。
为什么单靠 dentry 不够,不是说dentry是文件路径节点的实例对象吗? 因为同一个 inode 可能通过不同的挂载路径到达(例如把同一个分区挂载到不同的目录下)。不同的挂载实例可能拥有不同的读写权限、不同的可见性。vfsmount 不仅标识了“通过哪座桥(挂载点)过来的”,也携带了挂载选项(如只读)。因此,path 是 VFS 中定位文件的真正坐标。打开文件时,struct file 的 f_path 字段保存的就是这个坐标。
路径解析的起点
每个进程的 fs_struct 中记录了两个 path:
- 根目录(可由
chroot改变) - 当前工作目录(
pwd的输出)
解析绝对路径(以 / 开头)时,起始 path 设为进程的根目录;解析相对路径时,起始 path 设为当前工作目录。
逐级分量查找
解析过程在一个称为 nameidata 的上下文中进行,其核心是一个当前的 path。路径被按 / 分割成多个分量(component),对每个分量执行以下步骤:
1. dcache 快速查找:以当前父 dentry 和分量为键,在 dentry 的哈希表中查找子 dentry。若命中且缓存有效,直接获得子 dentry。
2. 磁盘查找与 negative dentry:若未命中,则分配一个新的 dentry,并调用父 inode 的 i_op->lookup() 方法。该方法由具体文件系统实现,负责从磁盘目录项中查找给定名称对应的 inode 号,读取 inode,并填充到新 dentry 中。如果查找发现名称不存在,则 dentry 的 d_inode 会被留为 NULL,成为 negative dentry。下次再查找同一名称时可以直接返回“不存在”,避免再次访问磁盘。
3. 挂载点穿越 —— 更新 path:如果在任一步骤中得到的 dentry 具有 DCACHE_MOUNTED 标志,就说明这个目录上挂载了另一个文件系统。此时需要执行挂载穿越:
- 通过全局挂载哈希表,以当前
path->dentry和path->mnt查找挂在它上面的struct mount实例。 - 然后替换
path的两个字段:path->dentry = mount->mnt.mnt_root; // 换成新文件系统的根 dentry path->mnt = &mount->mnt; // 换成新挂载实例的 vfsmount - 如果新的根 dentry 本身又是一个挂载点(挂载点叠罗汉),则循环执行,直到不再有新的挂载点。
- 穿越后,当前
path已经位于新文件系统的根目录,后续路径分量将在新的文件系统中继续解析。
4. 符号链接处理:如果当前分量解析后发现 dentry 是一个符号链接,则会获取其存储的目标路径,将其拼入剩余路径并递归解析。为防止死循环,内核限制符号链接的嵌套深度(通常为 40)。
5. 路径解析结束:所有分量处理完毕后,nameidata 中的 path 就指向了目标文件的精确位置。之后的 open 操作会基于这个 path 创建 struct file,并调用文件系统或驱动的 open 方法完成最后的准备工作。
挂载命名空间
每个进程都通过其 task_struct->nsproxy->mnt_ns 指向一个 struct mnt_namespace,它包含属于该命名空间的挂载树根,默认所有进程共享同一个初始命名空间。进程用clone或unshare创建新的挂载命名空间时,内核会拷贝一份当前的挂载树。之后在该命名空间内执行的挂载和卸载操作不会影响其他命名空间。这构成了 Linux 容器文件系统隔离的基石。
VFS中的一切皆文件
VFS 最强大的特性之一就是让设备、管道、套接字等对象都表现为文件,用户不用记得很多接口,像文件一样使用所有设备,内核的代码量也减少了不少。实现这一切的基础就是回调函数注册+统一的对象模型实现多态。
字符设备与块设备
- 通过
mknod创建的设备文件,其 inode 的类型标记为S_IFCHR或S_IFBLK,i_rdev存放主次设备号。 - 这类 inode 的特殊初始化函数
init_special_inode()会将i_fop设为默认的设备操作集(def_chr_fops或def_blk_fops)。 - 当打开设备文件时,默认的
open函数(如chrdev_open)根据设备号在内核的cdev_map中查找对应的设备驱动,并用驱动提供的file_operations替换file->f_op。此后所有读写操作都直接转发给驱动程序。 - 块设备的打开过程类似,而且会关联
block_device结构,并可能将file->f_mapping指向块设备的 address_space,用于缓冲块 I/O。
管道与 FIFO
- 管道由
pipefs特殊文件系统实现。pipe()系统调用创建一个 inode(内含pipe_inode_info),并返回两个文件描述符:一个的f_op为write_pipefifo_fops,另一个为read_pipefifo_fops。 - 读写操作直接操作管道缓冲区,完全不涉及磁盘。
套接字
- Socket 使用
sockfs虚拟文件系统。socket()调用分配 inode 和file,并将file->f_op设为socket_file_ops。 socket_file_ops中的read、write、poll等再调用网络栈提供的接口。
伪文件系统(proc, sysfs)
- 内核利用 VFS 框架,通过注册自定义的
inode_operations和file_operations,将运行时数据(进程信息、内核参数、设备模型等)以文件和目录的形式动态呈现给用户。
上面的实现看起来很难,其实并不麻烦:
- 所谓的虚拟文件系统/伪文件系统,就是在内核中创建一个super_block以及dentry树,只不过这个文件系统不会写到磁盘上罢了,然后将其挂载到根文件系统,就可以按照目录树的方式访问虚拟文件了。
- 字符设备要想使用文件访问,只需要把设备号填写到创建出来的inode中并注册对应回调函数(前提是要有驱动程序)即可文件式访问
- 一般来说,回调函数要注册到struct file(也就是用户视图)的f_op函数集中,这是用户访问文件最先找的系统调用
基于VFS的文件系统简略图
到目前为止,我们已经知道了,VFS+磁盘上的文件系统+伪文件系统,所以我在这里画了一张图,即是帮助读者理解各种数据结构的关系,也可以检验读者的阅读成果:

用户视角的文件
文件的访问
- 根据冯诺依曼体系,cpu不能直接对外设的数据进行访问,所以访问文件之前需要打开文件,即将文件从磁盘加载到内存中。
- 一个进程可以打开多个文件,而且OS中同一时间会有很多进程为了管理这些文件,采用先描述,再组织的方式,每打开一次文件就创建一个struct_file结构体表示文件视图,然后用链表把他们穿起来进行统一管理。
- 当我们访问文件时,其实是进程在访问,所以进程PCB中会保存打开过的文件的struct_file的标识符,通过标识符进程可以对文件进行读写等操作。
进程和文件的关系

父子进程的文件
子进程会继承父进程打开的文件,具体来说,子进程把父进程打开的所有struct_file都新创建一份(因为每个进程读写的位置都是不同的,所以一般不共享),但是子进程和父进程共享每个struct_fle的内核级缓冲区和inode属性节点。
除此之外,进程程序替换也不会关闭之前打开的文件。
进程默认打开的标准流
每个进程启动时都会自动打开三个流(文件),标准输出,标准输入,标准错误流(FILE是C语言库提供的封装了fd的结构体):

我们使用的printf,scanf函数默认从标准流里输入或者读取。
C库文件操作函数



我们使用这些函数对文件操作时,实际上是在用户层面对磁盘这种硬件做操作,而操作系统是软硬件的管理者,所以实际上这些函数必须封装操作系统提供的接口函数。
操作文件的系统接口


![]()

为什么语言要封装系统接口
- linux和windows或者其他系统的系统接口不同,封装后只需要在不同的系统中使用不同的库(不同的库中的函数实现不同,针对每个系统写一份函数实现)就可以让代码在不同系统下跑,提高代码可移植性。
- 语言级缓冲区提升性能
- 方便用户(格式化函数可以帮助我们进行编码转换)
文件内核级缓冲区
struct_file指向的inode节点中存在内核级缓冲区,这样可以减少与磁盘的交互次数,节约时间。
我们想要向文件写入数据,实际上是先把数据拷贝到inode节点中的内核级缓冲区,然后由操作系统决定什么时候把缓冲区的数据刷新到外设里。想要从文件读入数据时,首先检查缓冲区是否有数据,如果没有,就会从磁盘中拷贝数据到缓冲区,然后再拷贝到用户层。想要修改文件时,会首先把内容拷贝到缓冲区,交给用户层修改,再把修改后的内容写到磁盘中覆盖旧的内容。
fsync可以刷新内核级缓冲区:

语言级缓冲区
C,C++等高级语言会有语言级缓冲区(也就是用户自定义的缓冲区而非操作系统提供),缓冲区的存在减少了系统调用的次数,节省了时间。例如,FILE结构体中不仅封装了fd,而且还封装了缓冲区,这就是语言级缓冲区。
printf打印数据时,首先会把数据拷贝到stdout的缓冲区里,直到缓冲区写满或者遇到换行符才把数据刷新到stdout(对于普通文件是写满刷新,显示器文件是换行刷新)。
fflush函数可以刷新语言级缓冲区:

粗糙重定向
如果我们把文件描述符表1号位置对应的文件指针改成普通文件的地址,那么printf默认就会把数据打印到这个普通文件而不是显示器,因为操作文件只认fd,至于fd的内容是否改变,不在考虑范围内。这就是重定向。

如上,当我们关闭1号位置的文件时,实际上就关闭了显示器文件,这时我们再次打开一个文件,由于fd分配规则,新文件的fd就是1。而stdout没有销毁,且FILE结构体stdout里的fd仍然是1,且printf这些C语言封装的函数调用系统调用时默认使用的就是stdout,因此那么刷新缓冲区后,printf,sprintf打印的内容就跑到了log.txt。
如果不用fflush刷新用户级缓冲区:对于普通文件语言级缓冲区是写满后刷新,或者程序结束时刷新,我们此时没写满,只能是结束后刷新,但是程序结束之前我们关闭了文件,语言级缓冲区没法再通过系统往文件里刷新数据,会导致log.txt里没有数据。
重定向

dup2的原理就是把file_struct中oldfd位置的内容拷贝到newfd(如果newfd是1的话,就可以把默认输出位置改为oldfd指向的文件)
- 在重定向之后,会出现两个fd指向同一个文件的结果,但是他们并不互相影响,即使关闭了其中一个,也不会真正关闭文件,因为有引用计数统计指向这个文件的指针个数。
命令行中的重定向:
- shell命令行的重定向格式:命令 1>文件名 2>文件名......(1,2....代表要重定向的fd,如果不加数字默认就是1)
- 也可以不用文件名重定向,2>&1表示 fd[2] = fd[1],意思就是用下标为1的内容重定向下标为2的内容。
查询磁盘信息
linux下一切皆文件,可以通过文件来查询磁盘设备信息

关于内核级文件缓冲的保护机制
内核级文件缓冲区为了保证数据一致性,也会有互斥机制,但是他是按照页为单位进行加锁保护的,简单来说可以把缓冲区想象成一块一块的,每一块代表文件的不同区域,只有在访问同一区域的时候才有必要有互斥等保护一致性的机制。
- 当两个线程访问同一个文件的时候,它们的struct_file同一个,因此文件指针偏移量是相同的,每次他们读写内容会触发互斥机制,以此保护数据一致性。正是由于文件指针偏移量相同,两个线程同时写数据的话,会出现顺序问题(第一个线程写一点,第二个线程从当前位置开始也写一点,两个线程写的数据就会交错开来)。
- 当两个进程访问同一个文件的时候,它们的struct_file是不同的,因此文件指针偏移量是不一定相同的,此时如果偏移量相同就会互斥,如果不同就不会也没必要保护。但是也正是由于文件指针偏移量不同,如果同时在不同页写数据的话可能会造成数据覆盖的问题(后写的数据覆盖了前一个进程在这个页写下的数据)。
但是,只有管道文件才有同步机制,因为普通文件的缓冲区是可以动态扩展的,基本不会有“满”这一说,而管道文件的缓冲区是固定的环形缓冲区,会被打满而阻塞。
文件的权限
部分文件属性

文件类型介绍

修改文件权限(chmod命令)
权限修改指令:chmod(只有root和拥有者可以修改)
chmode有两种模式:
chmod [选项]权限 普通文件名

用法如:chmod u-w test.c,chmod a+x test.c(还可以 chmod u+x,g+x,o+x test.c)
chmod [三位数] 普通文件名
例如:644 == 110 100 100 == rw- r-- r-- ,因此chmod 644 text.c 就相当于 chmod u=rw,g=r,o=r text.c
需要注意的
- 上述是对于普通文件,而对于目录来说,r表示可以读出目录里有哪些文件,w表示可以删除或新增文件,x表示可以进入该目录。要仔细区分目录权限和普通文件权限,比如目录的权限是w,并不代表他可以在目录里面的文件里写东西(这看的是该文件的属性),而只代表他可以新建或删除文件。
- 如果同一个用户同时是拥有者和所属组,以拥有者为准,因为查验身份只查一次,默认是先查拥有者,然后所属者。
粘滞位

可以解决想共享资源却不想误删的情况
umask(权限掩码)
umask与创建文件或者目录有关:最终权限 = 起始权限 &(~权限掩码)
目录文件起始权限为111111111(rwxrwxrwx),普通文件起始权限为110110110(rw-rw-rw-)。
把权限掩码转化为二进制,然后进行上述运算,比如掩码是0002,二进制就是000000010,取反为111111101,然后按位与起始权限,得到111 111 101,110 110 100,这就是目录和文件的最终权限。
在命令行中,umask+四位数字,可以修改umask
文件的拥有者
权限修改指令:chown(修改拥有者),chgrp(修改所属组)
例如:sudo chown jiakun text.txt
注意:当修改拥有者或者所属组的时候,除了root,都需要暂时用sudo 短暂提升权限至管理者,即使你是拥有者
创建,删除,打开,读写文件
学习了上述内容,相信读者已经对linux系统中的文件有了一个较为深刻的轮廓,接下来我将模拟一下如何去创建、删除、打开、读写一个文件
创建一个文件的流程示例:
首先从PCB中过去当前工作目录的struct path结构体,从中得到dentry节点,通过dentry得到目录文件的inode(如果不是在本目录下创建新文件就需要进行路径解析先找到目录dentry,有缓存就不用从头解析哦),调用inode操作集中的‘create’操作,然后在本文件系统的super_block找找有没有空闲空间,如果有则遍历该文件系统中的GDT链表,找到一个有空闲空间的分组,然后在该分组的InodeBitmap中找到一个为0的bit位并置1,创建新的inode结构体,写入新文件属性,新文件的inode号就是GDT的起始inode号+偏移量。然后同样在该分组的BlockBitmap中找到一个为0的bit位并置1,然后把这个Block块的块号写入到新inode里面。最后创建一个dentry关联新inode。系统会在合适的时机把这些对磁盘的修改操作落盘持久化。这期间可能会调用文件系统的一些函数操作集等。
删除文件文件的流程:
路解析找到目录dentry,从而得到目录dentry关联的inode,调用目录indode函数集的‘delete(x)’,从inode的文件缓冲区里找到要删除的文件名对应的inode号(记为x),然后根据GDT的起始inode号确定这个文件在哪个GDT里,找到GDT后把GDT里面inodeBitmap相应位置0。
打开文件:
先路径解析查dentry树,如果没有就加载,如果不存在就把dentry指向的inode置成空表示该文件不存在。加载后创建struct file,更具inode里面的类型,也可能是根据i_rdev的值来注册struct file的f_op操作集。最后在PCB的files中找个位置指向新即可struct file
读写文件:
调用struct file中的f_op里的函数,因为这个函数集被注册成各种各样的,从而利用多态实现一切皆文件。如果是普通文件的话会根据struct file中记录读写偏移量的变量在inode中的文件缓冲区进行读写,并可能加载页缓存等
openEuler 是由开放原子开源基金会孵化的全场景开源操作系统项目,面向数字基础设施四大核心场景(服务器、云计算、边缘计算、嵌入式),全面支持 ARM、x86、RISC-V、loongArch、PowerPC、SW-64 等多样性计算架构
更多推荐

所有评论(0)