概述
在 Linux 环境下有过一些经历的同学可能都会遇到一个问题,这个问题就是往机器上插入 U盘 或者其他外接设备的时候,居然经常没有反应,上网查找之后别人会教你怎么用几条命令然后你就可以像操作正常文件一样操作这些设备了(当然,现在很多流行的桌面Linux环境例如 Ubuntu/Centos 都支持自动挂载了)。
但是,可能我们也曾想过为什么 Linux 要这么麻烦,很多时候我遇到这种问题都是想毕竟 Linux 不是普通的 OS 啊,需要这么麻烦也很正常([/手动尴尬]),但是,就是时不时对某些东西的抽风执念,我突然对这个原理上心了,所以想了解一下为什么要这么麻烦,或者说为什么需要这样。当然,在本文中,你得不到为什么要这么麻烦的答案,我猜想无非就是开发对应 OS 的人不想做这个 Feature 而已,实质上可以做到的,就像提过的 Ubuntu 一样;但是,你可以在本文中了解到挂载 U盘 之类的操作是怎么个原理。
Linux 的文件系统抽象模型
在 Linux 中,为了适应不同的格式的文件系统(ext4/nfs),也就是说可以支持同时使用不同文件系统的文件,做了一层抽象,也就是所谓的 VFS(Virtual File System),整个 Linux 的文件层级可以这么抽象得概括一下:
图 1:VFS 架构示意图 |
VFS 对上层应用程序和进程隐藏了底层复杂的各种文件系统细节,也就是进程不需要知道文件系统是存放在本地的硬盘或者是 U盘 还是一个网络的文件系统,同时,VFS 对下层提供了一系列抽象的接口,从而使得上层应用可以使用同一的接口从而访问不同来源的设备。需要注意的是,因为 VFS 是一个抽象的概念,所以很多里面的元素都是和具体的文件系统名称相同,在本文中,如果没有将具体的文件系统,那么所说的名词(例如 iNode/Superblock)都是描述 VFS,而不是具体的文件系统。
在 VFS 中,有四个重要的数据结构,他们分别是:
- Superblock:文件系统高级元数据的集合,包含了文件系统的各种数据,存放于磁盘中
- Dentry:文件系统的层次结构管理,在运行时动态创建,仅存在于内存中
- I-node:文件系统的基本单元,可以对应一个文件、一个目录或者一个链接,存放于磁盘中
- File:Linux 中每一个打开的文件都对应一个 File 对象,定义了 Linux 中常见的文件操作
所以当我们在进程中打开一些文件的时候,内存中的对象模型应该是这样的:
图 2:内存中的对象模型 |
- 第一层蓝色的就是我们打开的 File 对象,这个会被放到我们 进程表 中,此时是和进程联系在一起了
- 第二层是目录结构/vfsmount 结构,这一层是一个树的层级,前面说了是便于我们查找真正的 i-node
- 第三层就是真正的 i-node 对象了,这里就代表着打开的是这个文件,但是这个文件所在的文件系统啥的这里是不知道的
- 第四层是最后的 superblock 啦,这里我们就可以确定真实访问的文件所在的文件系统的真实类型(ext4/ext3…)
具体的文件系统举例:ext2
所谓的具体文件系统就是在我们平时使用过程中,当新装一个 OS 或者新加了一块存储,一般情况下都是不能直接挂载使用的,因为你的磁盘没有设置文件系统,即使挂载上去了,我们的 OS 也是不能识别的。那么,特殊情况是什么呢,那就是你想象这么一种情况,你有两台配置什么的一模一样的机器 A 和 B,一台在家里,一台在其他场所,你想在使用两台机器的时候都使用同一个环境(OS/Soft等等),那么你在任意一台机器上安装配置好环境,然后在两条机器上都通用这个安装了你需要环境的磁盘,这样,你就经常性的在两台机器中往返携带磁盘,而且机器 A 上工作完好的磁盘直接拔下来装到机器 B 中也是正常工作的,并不需要你在机器 B 上又重新设置一下文件系统,虽然你可能觉得这个例子有点傻,但是,这个在云环境确实很方便的实现。
ok,话不多说,来看看 Linux 中一个经典的 ext2 文件系统。在 ext2 文件系统中,这个文件系统所管理的磁盘(可以不是一个完整的磁盘,例如磁盘大小为 1T,这里可以只给 40G)被划分为均等大小的 block,而 block 的大小是可变的,通俗点说 /info/liuliqiang/da
和 /info/liuliqiang/db
都是使用的 ext2 的文件系统,但是 da 的 block 大小是 1024 byte,而 db 的 block 大小却是 4096 byte。
在 ext2 文件系统中,磁盘位置中最开始的 1024 bytes 是 superblock,也就说 superblock 的大小是 1024 bytes。有一个属性需要我们注意,那就是 magic,ext2 文件系统和 ext3 文件系统的 magic 都是 0xEF53,这说明了 ext2 和 ext3 的兼容性很好!,还有一个属性叫 block_size,它用于表示这个 ext2 文件系统中 block 的大小,前面说了,这是可变的。
ext2 的文件系统被均分为了 block,那么我们要怎么存储文件呢?接着往下看,在 ext2 文件系统中,不同数量的 block 被聚成了所谓的 block group,每个 block group 都会对应一个 block group description,这些 group description 会被放在一起,位置紧跟在 superblock 后面。
每个 block group 都包含inode table 和 block bitmap,通过这个 inode table,我们就可以得到一个个的 inode 了,然后 inode 里面存储了 block 的指针信息,这样我们就得到了真实的磁盘数据。这些 inode table 和 block bitmap 等信息都是放在 block group 的开头,一块接着一块,就组成了所谓的 ext2 文件系统。
图 3:EXT2 文件系统 |
VFS 和 ext2 的结合
看完抽象的虚拟文件系统和真实的文件系统,那么我们是时候结合两者进行统一得看一看了。
当我们系统启动加载的时候,会构建一个 Dentry 的目录树,这个目录树和具体的 OS 目录树是两码事,默认创建出来的时候的目录树只有一个真实的 rootfs 文件系统,然后目录树中的其他目录都是为以后挂载其他真实的文件系统提供的挂载点。例如我们的 ext2 文件系统就是挂载到其中的某个目录中,这样就挂载上了。
然后是打开文件,打开文件之后,我们建立了这个文件的 FD,FD 会对应到 Dentry 和 VFS i-node,通过 Dentry 我们就可以找到对应的文件系统(因为它是挂载在 Dentry 中的),然后通过 VFS i-node 我们就可以获取到文件的具体内容,这样就完成了整个 VFS 到 ext2 真实文件系统的转换。
在Linux系统中,每个进程都有一个进程描述符(process descriptor),它是一个 task_struct
结构体,用于存储进程的所有信息。这个结构体中有一个 files_struct
类型的成员 files
,用于存储进程打开的所有文件描述符。
每个文件描述符在内核中都对应一个 file
结构体,这个结构体包含了文件的所有信息,如文件的位置、访问模式、文件操作的函数指针等。file
结构体的指针被存储在 files_struct
结构体的 fdtable
成员中,这是一个动态数组,数组的索引就是文件描述符的值。
当进程打开一个文件时,内核会创建一个新的 file
结构体,并将其指针存储在 fdtable
数组的第一个空闲位置,这个位置的索引就是新的文件描述符的值。当进程关闭一个文件时,内核会清除对应的 file
结构体,并将 fdtable
数组中对应的位置设为空。
为了保护进程的文件描述符不被其他进程访问,每个进程的 files_struct
结构体都是独立的,即使是父子进程也不共享。
父子进程是否共享 fd
答案是 Yes,但是不完全共享文件描述符的状态。
在 Linux 中,当一个进程通过 fork
系统调用创建一个新的进程(通常称为子进程)时,子进程会继承父进程的许多属性,包括打开的文件描述符(fd)。这意味着父进程和子进程会共享同一套文件描述符。如果父进程在 fork
之后关闭一个文件描述符,这并不会影响子进程对该文件描述符的使用,反之亦然。
然而,尽管父进程和子进程共享文件描述符,但它们并不共享文件描述符的状态。例如,如果父进程读取了一个文件并移动了文件指针,这并不会影响子进程中文件指针的位置。
此外,当一个进程通过 exec
系列函数加载并运行一个新的程序时,新程序默认会继承原进程的打开的文件描述符。但是,你可以在调用 exec
之前设置文件描述符为 close-on-exec
,这样新程序就不会继承这些文件描述符。
总的来说,父进程和子进程在 fork
时会共享文件描述符,但是它们的文件描述符状态是独立的,而且你可以通过设置 close-on-exec
来控制 exec
时是否继承文件描述符。
文件描述符
在Linux内核中,file
结构体是用来表示一个打开的文件的。它包含了许多信息,包括但不限于:
f_path
:一个path
结构体,包含了一个指向dentry
(目录项)的指针和一个指向vfsmount
( 虚拟文件系统挂载点)的指针。dentry
结构体包含了文件名和一个指向inode
的指针,inode
结构体包含了文件的元数据,如文件类型、大小、权限、所有者、时间戳等。vfsmount
结构体包含了文件系统的信息,如文件系统类型、挂载选项等。
f_op
:一个file_operations
结构体的指针,包含了一组函数指针,这些函数用于实现文件操作,如读、写、映射、轮询等。f_mode
:文件的访问模式,如读、写、追加等。f_pos
:文件的当前位置,即下一次读或写的位置。f_flags
:文件的状态标志,如非阻塞、同步、关闭执行等。f_count
:文件的引用计数,表示有多少个文件描述符引用了这个文件。
当进程进行文件读写操作时,如 read
或 write
系统调用,内核会首先根据文件描述符找到对应的 file
结构体,然后调用 f_op
中的相应函数。这些函数会根据 f_path
找到文件的数据,根据 f_pos
确定操作的位置,根据f_mode
和f_flags
确定操作的方式,然后进行读写操作。
总结
本文从概括性的角度描述了 Linux 下,文件操作的抽象与具体的结合,但是,对于 Linux 的 IO 来说,这还只是冰山一角,而我再后续的文章中也将结合自身的思考和认识,更多得挖掘一些细节。最后,感谢下面 Reference 的这些文章的帮助,让我了解得更清楚一些。