目录
3.1 文件描述符(File Descriptor, FD)
1 概述
浪潮信息KOS是浪潮信息基于Linux Kernel、OpenAnolis等开源技术自主研发的一款服务器操作系统,支持x86、ARM等主流架构处理器,性能和稳定性居于行业领先地位,具备成熟的 CentOS 迁移和替换能力,可满足云计算、大数据、分布式存储、人工智能、边缘计算等应用场景需求。详细介绍见官网链接https://www.ieisystem.com/kos/product-kos-xq.thtml?id=12126
2 VFS虚拟文件系统的设计哲学
对于用户来说,所有操作都是通过open、read、write、ioctl、close等接口操作的,确实很方便;但是对于linux,底层明明是不同的硬件设备,这些设备怎么才能统一被上述接口识别和适配了?识别和适配这层接口的功能就是虚拟文件系统,简称VFS,VFS(Virtual File System)是Linux内核中实现文件系统抽象的核心模块,其设计哲学深刻体现了UNIX“一切皆文件”的思想,同时通过分层抽象和统一接口实现了对异构文件系统的无缝兼容。以下是其核心设计理念与实现机制的综合分析:
2.1 “万物皆文件”的统一抽象
VFS的核心理念源于UNIX哲学,将设备、管道、套接字、进程信息等非传统文件对象抽象为文件形式。例如:
设备文件(如/dev/sda)通过VFS接口被读写,内核将其映射到对应的设备驱动程序,整体架构图如下:
网络套接字通过/proc/net等虚拟文件系统暴露状态信息,用户可通过文件操作接口访问。
进程信息通过/proc/<pid>目录以文件形式展示,如/proc/self/maps反映进程内存布局。
设计意义:统一的文件接口简化了用户态与内核的交互,开发者无需为不同对象学习多种API。
2.2 分层抽象模型
VFS通过分层设计实现接口统一性与底层多样性的平衡,分为三个层次:
用户接口层:提供open()、read()、write()等系统调用,用户程序通过文件描述符(File Descriptor)操作文件,当用户调用操作系统提供的文件系统API时会通过软中断的方式调用内核VFS实现的函数。如下表所示是部分文件API与内核VFS函数的对应关系:
虚拟文件系统层:定义通用文件模型(struct inode、struct dentry、struct file),并维护目录缓存(dcache)加速路径解析,VFS 定义了一组通用接口(如 struct file_operations),具体文件系统需实现这些接口:
具体文件系统层:各文件系统(如Ext4、XFS)实现VFS定义的接口(如inode_operations、file_operations),完成实际I/O操作。
示例:比如 Linux 写一个文件:
int ret = write(fd, buf, len);
调用了write()系统调用,它的过程简要如下:
首先,勾起 VFS 通用系统调用sys_write()处理。
接着,sys_write()根据fd找到所在的文件系统提供的写操作函数,比如op_write()。
最后,调用op_write()
2.3 多态与松耦合机制
VFS通过函数指针表实现多态,允许不同文件系统自定义行为:
超级块(super_block):存储文件系统元数据,并关联super_operations结构,定义挂载、同步等方法,super_operations的定义如下:
inode操作集(inode_operations):包含文件创建、删除、重命名等操作,由具体文件系统实现(如Ext4的ext4_create()),inode结构有两个指针(i_op和i_fop),指向实现了上述抽象的数组。一个数组与特定于inode的操作有关,另一个数组则提供了文件操作。file_operations用于操作文件中包含的数据,而inode_operations负责管理结构性的操作(例如删除一个文件)和文件相关的元数据(例如,属性)。所有inode操作都集中到以下结构中:
inode_operations的部分函数说明如下:
lookup | 根据文件系统对象的名称(表示为字符串)查找其inode实例 |
---|---|
link | 用于删除文件。但根据上文的描述,如果硬链接的引用计数器表明该inode仍然被多个文件使用,则不会执行删除操作 |
xattr | 函数建立、读取、删除文件的扩展属性,经典的UNIX模型不支持这些属性。例如,可使用这些属性实现访问控制表(access control list,简称ACL) |
truncate | 修改指定inode的长度。该函数只接受一个参数,即所处理的inode的数据结构。在调用该函数之前,必须将新的文件长度手工设置到inode结构的i_size成员 |
truncate_range | 用于截断一个范围内的块(即,在文件中穿孔),但该操作当前只有共享内存文件系统支持。follow_link根据符号链接查找目标文件的inode。因为符号链接可能是跨文件系统边界的,该例程的实现通常非常短,实际工作很快委托给一般的VFS例程完成 |
fallocate | 用于对文件预先分配空间,在一些情况下可以提高性能。但只有很新的文件系统(如Reiserfs或Ext4)才支持该操作 |
文件操作集(file_operations):实现read()、write()等具体I/O逻辑,例如块设备与字符设备的读写差异, 各个file实例都包含一个指向struct file_operations实例的指针,该结构保存了指向所有可能文件操作的函数指针。该结构定义如下:
仅当文件系统以模块形式装载并未编译到内核中时,才使用owner项。该项指向在内存中表示模块的数据结构。
read和write分别负责读写数据。这两个函数的参数包括文件描述符、缓冲区(放置读/写数据)和偏移量(指定在文件中读写数据的位置),另一个参数指定了需要读取和写入的字节数目。 |
---|
aio_read用于异步读取操作。 |
open打开一个文件,这相当于将一个file对象关联到一个inode。 |
file对象的使用计数器到达0时,调用release。换句话说,即该文件不再使用时。这使得底层实现能够释放不再需要的内存和缓存内容。 |
如果文件的内容映射到进程的虚拟地址空间中,访问文件就变得很容易。这通过mmap完成 |
readdir读取目录内容,因此只对目录对象适用。 |
ioctl用于与硬件设备通信,因而只能用于设备文件(不能用于其他对象,因为其他对象对应的file_operations中,ioctl为NULL指针)。在有必要向设备发送控制命令时,将使用该方法(write函数用于发送数据)。尽管该函数对所有外设的名称和调用语法都相同,但实际支持的命令与具体硬件相关。 |
poll用于poll和select系统调用,以便实现同步的I/O多路复用。这意味着什么?在进程等待来自文件对象的输入数据时,需要使用read函数。如果没有数据可用(在进程从外部接口读取数据时,可能有这样的情况),该调用将阻塞,直至数据可用。如果一直没有数据,read函数将永远阻塞,这将导致不可接受的情况出现。 |
优势:新增文件系统只需实现接口,无需修改内核其他模块,极大提升了扩展性。
2.4 高效路径解析与缓存优化
VFS通过目录项缓存(dentry cache)和inode缓存显著优化性能:
dentry缓存:将路径名(如/usr/bin)映射到内存中的dentry对象,避免重复解析磁盘目录结构,dentry_operations结构保存了一些指向各种特定于文件系统可以对dentry对象执行的操作的函数指针。该结构定义如下:
d_iput从一个不再使用的dentry对象中释放inode(在默认的情况下,将inode的使用计数器减1,计数器到达0后,将inode从各种链表中移除)。 |
---|
在最后一个引用已经移除(d_count到达0时)后,将调用d_delete。 |
在最后删除一个dentry对象之前,将调用d_release。d_release和d_delete的两个默认实现什么都不做。 |
d_hash计算散列值,该值用于将对象放置到dentry散列表中。 |
d_compare比较两个dentry对象的文件名。尽管VFS只执行简单的字符串比较,但文件系统可以替换默认实现,以适合自身的需求。 |
d_revalidate对网络文件系统特别重要。它检查内存中的各个dentry对象构成的结构是否仍然能够反映当前文件系统中的情况。因为网络文件系统并不直接关联到内核/VFS,所有信息都必须通过网络连接收集,可能由于文件系统在存储端的改变,致使某些dentry不再有效。该函数用于确保一致性。 |
inode缓存:存储文件元数据(权限、大小等),减少对底层文件系统的元数据查询,下图是其初始化的过程,通过参数ihash_entries可以看出其大小是动态的(其大小跟系统内存相关,系统内存阅读,inode缓存就越大)。
场景示例:多次访问同一文件时,dentry缓存直接命中,无需触发磁盘I/O。
2.5 挂载机制与命名空间隔离
VFS通过**挂载点(vfsmount)**实现多文件系统融合:
挂载过程:将文件系统关联到目录(如mount /dev/sda1 /mnt),原目录内容被覆盖,由新文件系统的根目录替代,挂载过程可参考下图:
命名空间隔离:容器技术利用VFS的挂载命名空间,使不同容器拥有独立的文件系统视图。
挂载命名空间是第一个添加到 Linux 的命名空间类型,出现在 2002 年的 Linux 2.4.19 中。
它们可隔离命名空间中的进程所看到的挂载点列表。换言之,每个挂载命名空间都有自己的挂载点列表,
这意味着不同命名空间中的进程可以看到并操作单个目录层次结构的不同视图。
当系统首次启动时,有一个单一的挂载命名空间,即所谓的“初始命名空间”。
带 CLONE_NEWNS 标志的 clone()(在新命名空间中创建新子进程)或 unshare()(将调用方移到新命名空间中)可创建新的挂载命名空间。
当新的装挂载名空间被创建时,它将接收 clone() 或 unshare() 的调用者的命名空间的挂载点列表的拷贝。
在 clone() 或 unshare() 之后,可以在每个命名空间中独立地添加和删除挂载点(通过 mount() 和 umount() )。
对挂载点列表的更改(默认情况下)仅对进程所在的挂载命名空间中的进程可见;这些更改在其他挂载命名空间中不可见。
挂载命名空间有多种用途。例如,可以提供文件系统的每个用户视图。
还有其它用途,可以为新的 PID 命名空间挂载 /proc 文件系统,而不会对其它进程造成副作用,还可通过 chroot() 将进程隔离到单个目录层次结构中。在某些用例中,挂载命名空间与绑定挂载一起使用。
简言之,挂载命名空间提供了一种机制,能够让不同进程「视角」下的文件系统呈现出不同的结构和内容,我们所熟知的 Docker 正是利用了这一技术来实现容器之间的隔离。
设计意义:支持动态扩展存储,同时为容器化提供基础设施。
2.6 设计哲学总结
VFS的设计体现了以下核心原则:
统一性:通过抽象层屏蔽异构文件系统的差异,用户仅需关注统一接口,大多数内核导出、用户程序使用的函数都可以通过VFS定义的文件接口访问。以下是使用文件作为其主要通信手段的一部分内核子系统:
、
要注意,上述的某些对象不一定联系到文件系统中的某个项。例如,管道是通过特殊的系统调用生成,然后由内核在VFS的数据结构中管理,管道并不对应于一个可以用通常的rm、ls等命令访问的真正的文件系统项。我们特别感兴趣的是访问块设备和字符设备的设备文件。这些是真正的文件,通常位于/dev目录。其内容是在进行读写操作时由相关的设备驱动程序动态生成的。
灵活性:支持本地、网络、虚拟文件系统混合使用(如NFS与procfs共存)。
高性能:通过缓存和高效数据结构(如哈希表加速inode查找)减少系统开销。
3 文件描述符与inode的关联关系
文件描述符(File Descriptor,简称 fd)是操作系统为用户空间程序提供的一个抽象概念,用于表示一个打开的文件或其他 I/O 资源(如管道、套接字等)。它是用户空间程序与内核之间进行文件操作的桥梁。
下面我会详细解释文件描述符的概念、作用以及它与 VFS、inode 的关系。
3.1 文件描述符(File Descriptor, FD)
Linux中,文件描述符(File descriptor,fd),是表示指向文件的引用的抽象化概念,在形式上是一个非负整数,当进程打开现有文件或创建新文件时,内核向进程返回一个文件描述符
下面open系统调用返回一个文件描述符给fd
常见的几个文件描述符:
STDIN,文件描述符:0;标准输入,默认从键盘读取信息;
STDOUT,文件描述符:1;标准输出,默认将输出结果输出至终端;
STDERR,文件描述符:2;标准错误,默认将输出结果输出至终端
示例:向标准输出中写入字符串
标准输出的文件描述符为1
编译运行:
文件描述符的作用
文件描述符的主要作用是:
为用户空间程序提供一个简单的句柄,用于访问文件或其他 I/O 资源。
隐藏底层文件系统的复杂性,用户空间程序只需通过文件描述符操作文件,而不需要关心文件的存储位置、文件系统类型等细节。
3.2 inode(索引节点)
inode是用来存储文件元数据的,stat命令可以输出一个文件的元信息
df -i 命令查看每个硬盘分区的inode总数和已经使用的数量
ll -i可以列出当前目录所有的包括inode号的文件信息
inode号是在第一列
inode结构体记录了很多关于文件的信息,比如文件长度,文件所在的设备,文件的物理位置,创建、修改和更新时间等等,特别的,它不包含文件名!目录下的所有文件名和目录名都存储在目录的数据块中,即如下图的目录块。对于常规文件,文件的数据存储在数据块中,一个文件通常占用一个inode,但往往要占用多个数据块,数据块是在分区进行文件系统格式化时所指定的“最小存储单位”,块的大小为扇区的2^n倍,一个扇区512B。
3.3 内核数据结构关联
进程级管理:文件描述符表
每个进程在Linux内核中都有一个task_struct结构体来维护进程相关的 信息,称为进程描述符(Process Descriptor),而在操作系统理论中称为进程控制块 (PCB,Process Control Block)。task_struct中有一个指针(struct files_struct *files; )指向files_struct结构体,称为文件描述符表,其中每个表项包含一个指向已打开的文件的指针,如下图所示。
用户程序不能直接访问内核中的文件描述符表,而只能使用文件描述符表的索引 (即0、1、2、3这些数字),这些索引就称为文件描述符(File Descriptor),用int 型变量保存。 当调用open 打开一个文件或创建一个新文件时,内核分配一个文件描述符并返回给用户程序,该文件描述符表项中的指针指向新打开的文件。当读写文件时,用户程序把文件描述符传给read 或write ,内核根据文件描述符找到相应的表项,再通过表项中的指针找到相应的文件。
已打开的文件在内核中用file 结构体表示,文件描述符表中的指针指向file 结构体。在file 结构体中维护File Status Flag(file 结构体的成员f_flags)和当前读写位置(file 结构体 的成员f_pos )。在下图中,进程1和进程2都打开同一文件,但是对应不同的file 结构体,因此可 以有不同的File Status Flag和读写位置。file 结构体中比较重要的成员还有f_count,表示引用计 数(Reference Count),如dup 、fork 等系统调用会导致多个文件描述符指向同一 个file 结构体,例如有fd1 和fd2 都引用同一个file 结构体,那么它的引用计数就是2, 当close(fd1) 时并不会释放file 结构体,而只是把引用计数减到1,如果再close(fd2) ,引用计数 就会减到0同时释放file 结构体,这才真的关闭了文件。 每个file 结构体都指向一个file_operations 结构体,这个结构体的成员都是函数指针,指向实现各种文件操作的内核函数。
内核级管理:file对象与inode
每个文件描述符都对应内核中的一个 文件对象(struct file)。文件对象是内核中表示一个打开文件的数据结构,它包含以下信息:
文件的当前位置(f_pos)。
文件的访问模式(如只读、只写、读写等)。
文件的操作函数(如 read、write、llseek 等)。
指向文件的 inode 的指针(f_inode)。
文件对象是 VFS 层的一部分,它通过 inode 与具体的文件系统交互。
文件描述符与 VFS、inode 的关系可以用以下流程图表示:
比如用户调用open()时,VFS通过路径解析找到目标文件的dentry和inode,创建struct file对象并绑定inode,最后将file指针存入进程的FD表
可以从下面vfs各结构体关系图来更好的理解它们之间的关联关系
3.4 关键操作流程分析
打开文件(open)
路径解析:VFS通过dentry缓存查找路径对应的dentry和inode。
权限检查:根据inode的i_mode验证进程权限。
创建file对象:绑定inode并初始化读写位置f_pos=0。
分配FD:将file对象指针存入进程FD表,返回FD编号
读取文件(read)
步骤:
用户调用read(fd, buf, size),内核通过FD找到对应的struct file。
通过file->f_inode获取文件的inode,确认可读权限。
调用file->f_op->read()(由具体文件系统实现,如Ext4的ext4_file_read)
文件描述符不直接操作inode,而是通过file对象间接关联,实现多进程独立维护读写偏移量
关闭文件(close)
步骤:
释放FD表中的条目。
减少struct file的引用计数,若归零则释放该对象。
若inode无其他引用,将其从缓存中移除
示例:
3.5 多进程共享与竞争
场景1 父子进程继承FD
不同进程的文件描述符是独立的:每个进程拥有独立的文件描述符表。当进程打开一个文件或创建一个套接字时,内核会为该进程分配一个文件描述符。这个文件描述符是该进程特有的,指向内核中的文件对象。
文件描述符的共享:虽然不同进程的文件描述符表是独立的,但可以通过一些机制让进程间共享文件描述符。
通过 fork() 继承:
fork()后子进程复制父进程的FD表,共享相同的struct file对象(引用计数增加)。父子进程的f_pos独立维护(除非使用O_APPEND标志)
示例:fork() 和文件描述符共享
假设有一个进程调用 fork(),并且该进程打开了一个文件:
在这个例子中,父进程和子进程共享相同的 fd,它们都指向同一个文件对象。然而,它们的文件描述符表是独立的。
场景2 独立进程打开同一文件
不同进程的FD指向不同的struct file对象,但绑定同一inode。文件数据通过inode的页缓存共享,但读写位置(f_pos)各自独立,结构关系如下图所示:
以下是一个简单的例子,演示了两个不同的进程分别使用open函数打开同一个文件:
在这个例子中,两个进程分别使用open函数打开同一个文件 example.txt。第一个进程以写入模式打开文件,写入一些内容,然后关闭文件。第二个进程以追加模式打开文件,写入一些内容,然后关闭文件。由于文件描述符是每个进程私有的,它们可以独立地访问和操作同一个文件,不会相互干扰。
4 文件操作的系统调用追踪(strace案例)
在Linux系统中,进程与硬件的交互并非直接进行,而是通过系统调用来实现。strace是一个强大的工具,它可以追踪进程执行时的系统调用以及接收到的信号,这对于诊断和调试程序非常有用。
4.1 strace简介与原理
strace用于跟踪程序执行时的系统调用和信号。在Linux中,用户态的进程需要通过系统调用来请求内核态的服务,比如文件操作、网络通信等。strace能够捕获这些调用的详细信息,包括调用的名称、参数和返回值,以及执行这些调用所消耗的时间。
简说系统调用与信号
系统调用
系统调用(System Call)是用户空间程序与操作系统内核空间交互的接口。由于安全和效率的考虑,用户空间的程序不能直接访问内核空间的资源,而是通过系统调用来请求内核提供服务。系统调用的类型非常多,涵盖了文件操作、进程控制、网络通信、信号处理等多个方面。
系统调用的实现原理
用户态到内核态的切换 | 当用户程序需要执行系统调用时,会从用户态切换到内核态。这种切换通常通过中断或异常机制实现 |
---|---|
系统调用表 | Linux内核维护一个系统调用表,包含了所有可用系统调用的入口点。当系统调用发生时,会根据调用号找到对应的内核函数执行 |
参数传递 | 系统调用的参数通过CPU的寄存器或栈传递给内核 |
执行系统调用 | 内核函数会执行实际的操作,如读写文件、创建进程等 |
返回用户态 | 系统调用完成后,会将结果返回给用户程序,并从内核态切换回用户态 |
系统调用的例子
信号
信号(Signal)是一种软件中断,用于通知进程发生了某些事件。信号可以在用户空间和内核空间之间传递信息,是进程间通信(IPC)的一种简单形式。
信号的实现原理
信号产生 | 当特定事件发生时(如用户按下Ctrl+C),内核会生成一个信号 |
---|---|
信号传递 | 内核将信号发送给目标进程 |
信号处理 | 进程可以定义信号处理函数来响应信号,执行特定的操作,如忽略信号、终止进程或执行自定义的清理代码 |
信号屏蔽 | 进程可以屏蔽(暂时忽略)某些信号,以避免在关键时刻被打扰 |
信号的例子
系统调用与信号的关联
系统调用和信号都是进程与内核交互的机制,但它们有不同的用途:
系统调用更多用于进程需要内核提供服务的场景,如资源管理、硬件访问等。
信号则用于进程间的通知和简单通信,以及处理某些紧急情况。
4.2 安装与基本使用
在大多数Linux发行版中,strace可以通过包管理器轻松安装,可直接使用以下命令安装:
yum install strace
基本使用如下:
strace <command>
这将输出<command>执行过程中的所有系统调用。
4.3 strace的输出解析
strace的输出每一行都代表一个系统调用,包括系统调用名、参数、返回值和错误码,格式通常为:
<syscall>(<arguments...>) = <return value>
示例:
open() 函数尝试打开文件,read() 从文件读取数据,而 close() 则关闭文件描述符。
4.4 strace的常用参数
strace提供了多种参数来定制跟踪的行为:
比如,跟踪特定进程
如果要跟踪一个已经运行的进程,可以使用-p参数指定进程ID:
strace -p <pid>
定位进程异常退出
通过跟踪进程的系统调用,可以观察到进程在异常退出前的最后行为:
strace -p <pid> -o output.txt
定位共享内存异常
共享内存操作相关的系统调用如shmget、shmat、shmctl等可以通过strace跟踪来排查问题:
strace -e trace=ipc <command>
网络相关调用排查
当涉及到网络问题时,可以专门跟踪网络相关的系统调用:
strace -e trace=network <command>
信号传递跟踪
可以跟踪程序接收到的信号:
strace -e signal=all <command>
系统调用统计
使用-c参数可以对系统调用进行统计分析:
strace -c <command> > output.txt
4.5 具体案例分析
追踪 linux 系统调用
追踪df命令
可以看到每一行都是一个系统调用,比如:
read(3, "\177ELF\2\1\1\3\0\0\0\0\0\0\0\0\3\0>\0\1\0\0\0\300\250\3\0\0\0\0\0"..., 832) = 832
其中:
read:系统调用名
(3, "\177ELF\2\1\1\3\0\0\0\0\0\0\0\0\3\0>\0\1\0\0\0\300\250\3\0\0\0\0\0"..., 832):系统调用的参数
832:系统调用的返回结果
只追踪特定的系统调用
上述 df -h 的 strace 结果是非常多的,从中比较难以找到我们真正关心的调用,通过 -e trace 参数,通过传入不同的参数值,就可以过滤出想要的结果了
过滤指定系统调用
通过传入系统调用的名称,就可以只查看对应的系统调用了
比如:strace -e trace=write df -h
除此之外,还可以传入:
strace -e trace=open,close df -h
strace -e trace=open,close,read,write df -h
strace -e trace=all df -h
针对进行管理的追踪
strace -q -e trace=process df -h
针对文件系统调用的追踪
strace -q -e trace=file df -h
打印指令指针
-i可以显示每一次系统调用的时候的指令指针
显示调用时间
-t参数可以显示调用时间。
strace -t df -h
显示系统调用的耗时
strace -T df -h
显示strace的debug信息
-d可以显示strace的debug信息
strace是一个功能强大的工具,可以帮助我们深入理解程序的行为,定位问题。通过合理使用strace的参数,可以有效地减少输出中的噪声,专注于相关的系统调用,可以追踪到文件操作的open()、read()、close()等的具体调用,从而帮助我们更好的排查、定位问题。
本文系统讲解了VFS虚拟文件系统及通过strace追踪系统其调用相关知识,本系列共4篇,链接如下: