一、理解文件系统
1、前言
我们一直都在说打开的文件,磁盘中包含了上百万个文件,肯定不可能都是以打开的方式存在。其实文件包含打开的文件和普通的未打开的文件,下面重点谈谈未打开的文件。
我们知道打开的文件是通过操作系统被进程打开,一旦打开,操作系统就要维护多个文件,所以它是需要被操作系统管理的。也就是说这种方式,磁盘上和内存上都有这个文件,它们不是完全一样的,内存中的文件更强调的是属性和方法,磁盘中的文件更强调的是数据,它们是通过缓冲区关联的;而普通的未打开的文件在磁盘上,未被加载到内存中,它当然也要被管理;其中管理打开的文件和管理未打开的文件在操作系统中有一个功能模块叫做文件系统。
之前我们谈过进程 VS 程序,一个被打开的程序就是进程,只不过我们在解释进程时不是严格地把它当作文件来解释,需要明白的是进程是要被加载到内存的,程序就是一个磁盘文件,打开的文件是进程,而普通未打开的文件是程序。
- ls -l 除了看到文件名,还可以看到当前路径下文件的元数据,也就是文件的属性。
- 模式
- 硬链接数
- 文件所有者
- 组
- 大小
- 最后修改时间
- 文件名
ls -l 读取存储在磁盘上的文件信息,然后显示出来
如果不深入的话,这块也没什么价值,谁不知道文件在磁盘上,ls -l 读取当前路径下文件的属性,所以我们还要研究它的原理。
但是在此之前,我们需要先认识磁盘,关于磁盘这个话题,还会在数据库中再讲一次。
2、磁盘
- 内存 -- 掉电易失存储介质
- 磁盘 -- 永久性存储介质 -- SSD、U盘、flash卡、光盘、磁带
众所周知,磁盘分为机械硬盘(HDD)和固态硬盘(SSD),现在很多的电脑都是机械硬盘和固态硬盘组合使用,但服务器上大多都是机械硬盘,只有一些高效率的存储集群会用到固态硬盘。机械硬盘和固态硬盘在存储技术上肯定是不同的,而我们主要了解机械硬盘,因为它多用于服务器上,其次虽然固态硬盘要比机械硬盘快不少,但在 CPU 看来,两者都很慢,我们这里就了解最慢的。
如下图,虽然磁盘的盘面看起来很光滑,但是它上面有一些同心圆,这些同心圆用圆白线划分,每一圈叫做磁道,数据写在这些有颜色的区域上。实际上你并不是把一圈的空间都用完,所以这里还使用了一些直白线划分,被圆白线和直白线划分出来的区域叫做扇区。所以当盘片在旋转、磁头摆动就可以找到这个盘片的任何一个扇区进行读写。 实际磁头和盘面并不是接触的,它们之间的距离就像一架飞机在离平地 1 米的距离滑行,所以如果笔记本摔了或者是在开机状态下总被搬来搬去,磁头就容易与盘片接触,此时就有可能会刮花盘片,电脑就可能会发生蓝屏等。
盘面是有两面的,且两面都是同心圆,数据只能写在同心圆(磁道)上,根据配置不同,有些磁盘可能还有多组盘片,我们可以从上至下的分为不同的盘面,也叫做你是第几个盘面。
虽然在 C 语言中访问内存的基本单位是 1byte ,但是在操作系统的角度认为内存的基本单位一般是 4kb,在操作系统看来,内存就是一个数组,每一个元素是 4kb。
之前在谈进程地址空间时也说过它叫做页框,4kb 是页帧,所以操作系统申请内存时是按 4kb 为单位进行分配和加载的,语言层面上并不关心底层是怎么做的,比如你要 malloc 1byte,那么操作系统也不可能直接给你 4kb,有可能 C 语言本身就缓冲了一部分空间让你去使用,如果超出这一部分空间,操作系统再重新分配。
磁盘存储的基本单位是一个扇区,它是磁盘读取的最小单元,大部分磁盘的一个扇区是 512byte,但你会发现虽然这里好像越靠近圆心,扇区越小,其实它们都是 512byte,原因是越靠近圆心的虽然扇区越小,但是比特位也相对外圈更密集。内存和磁盘之间也是有交互的,它们之间的交互我们称为 output、input,也叫做 IO,一般内存和磁盘之间 IO 交互时,不是纯硬件级别的交互,而是要通过文件系统完成,也就是通过操作系统。
这里用户和内存之间交互的基本单元大小是 1byte,一般内存和磁盘之间交互时的基本单元大小是 4kb,所以文件系统在往磁盘读数据时,要读 8 个扇区,这就是数据由磁盘加载到内存的过程。
- 将数据存储到磁盘 --> 将数据存储到该数组
- 找到磁盘特定扇区的位置 --> 找到数组特定的位置
- 对磁盘的管理 --> 对该数组的管理
其中我们再选取 stat 中展示的一部分信息,我们将内存和磁盘之间交互时的基本单元大小 4kb 叫 Blocks,这里的 IO Block:4096 就是 8 × 512。
一般像这样的机械硬盘在物理上是圆状,操作系统很难去管理它,因为操作系统如果不对它进行抽象化处理,那么操作系统中的代码可能就是 read(盘面,磁道,扇区),操作系统需要知道这三个参数的话,那么一定要在操作系统读取磁盘的代码中以硬编码的形式写到操作系统中。
但是如果有一天,你给自己的电脑加了一块固态硬盘,你要对固态硬盘进行读操作,就不能再用以前的方法了,因为固态硬盘与机械硬盘的结构不一样,它没有盘面、磁道、扇区,所以操作系统中曾经设计好的代码就得修改。很显然,这样的设计导致它们之间出现了强耦合,这是很不合理的。
所以需要对磁盘抽象化处理,将圆状结构的磁盘空间抽象成线性结构的磁盘空间。这里举两个例子方便理解:
- 其实在 C 语言中我们见过的 int arr[3][4] 二维数组就是把线性的数据结构抽象成了好理解的有行有列的结构。
- 曾经风靡一时的磁带是把数据存储于那条黑色的带子上,可能是为了空间的原因,把带子卷起来形成一个圆状,所以磁带在物理上,既可以是圆状,也可以是线状。
同样的,也能把磁盘抽象成线性结构。把磁盘上的磁道抽象成线性形状,比如磁盘的所有磁道被我们抽象成了一条 500GB 的线性空间,我们可以把它看作一个很大的数组 —— 扇区 array[NUM],其中每一个元素是 512byte,操作系统要申请 4kb,那就给数组的 8 个元素。所以将磁盘抽象后,操作系统就摆脱盘面、磁道、扇区的束缚了,操作系统只关心你想访问的哪个下标,这里的地址我们称为逻辑区块地址(Logical Block Address, LBA),这里抽象出来的数组下标是和机械硬盘中盘面、磁道、扇区构成映射关系的,这里的映射关系是由对应的机械磁盘驱动维护的,操作系统想往 2 下标处写数据,最终 2 下标一定是能对应到具体磁盘中某个扇区上。如果要往固态硬盘中写数据,也是把它抽象成线性的数组,它也有自己的固态硬盘驱动维护数组下标和固态硬盘之间的映射关系。至此,通过抽象的方法,就完成了操作系统和磁盘之间的解耦。所以最终操作系统对磁盘的管理,转换成了对数组的管理。
为什么操作系统(文件系统)和磁盘不以 512byte 为单位呢?
太小了,可能会导致多次 IO,进而导致效率的降低。
如果操作系统使用和磁盘一样的大小,万一磁盘的基本大小发生改变,那 OS 的源代码要不要改呢?那岂不是将硬件和软件(OS)进行解耦。
二、inode
500G 的磁盘空间抽象成每个元素是 512byte 的数组,那样非常大,不易管理,所以操作系统还要对这 500G 的数组进行拆分,比如这里拆分成了 100G、100G、150G、150G,所以这里只要管理好了第一个 100G 的空间,然后把管理的方法复制到其它空间,其它的空间也能被管理好。
把拆分的过程叫做分区,这也就是我们的电脑上为什么会有 C 盘、D 盘、E 盘等。至此我们仅仅是对空间进行划分,要把空间管理好还需要写入相关的管理数据,比如把中国 960 万平方公里,划分了不同大小的省份,要管理好一个省,我们不考虑地质差异等因素,只要一个领导、一个团队,他们把一个省管理好了,那么他们的管理方法就可以复制到其它省。同样的,刚刚我们分区的工作只是把中国划分成不同的省份,接下来我们还要分配每个省的省长、省中每个市的市长、市中每个镇的镇长等,以此来管理一个省。
将这种分配的过程叫做格式化过程,所谓的格式化在计算机中就是写入文件系统,也就是说我们要把文件系统写入某个分区中,这个文件系统的核心包括数据 + 方法,数据就类似这个省有多少人口、粮食等,方法就类似这个省有生育政策、耕种政策等。同样文件系统包含的数据就是文件系统的类型等,方法就是操作各种文件的方法。
当然不同的分区可以使用不同的文件系统,Linux 下就使用五六种不同的文件系统,Linux 可以支持多种文件系统,包括 Ext2、Ext3、fs、usb-fs、sysfs、proc。这就好比各个省份需要因地制宜地分配不同的团队。我们谈的都是 Ext 系列的文件系统,也不谈其它的文件系统如何,我们就认为磁盘上不同分区的文件系统是一样的。
因为一个省也很大,为了更好的管理,还要分配市长、镇长等,同样的分区后的 100G 空间还要再划分,比如这里划分了 10 组 10G 的空间,然后把它看作成一个一个的块组(Block group),一个块组中又有多个 4kb 空间,而磁盘存储是有块基本单位的,文件系统认为一块是 4kb,我们只要把一个块组管好,整个文件系统内的块组就能管好,所以问题又转换为怎么把这 10G 的空间管好,所以接下来划分的才是文件系统写入的相关细节,也是我们要研究的,这个区域的信息大家都有,可能略有差异。
这里 Linux 文件系统以 Ext 系列的为话题,上图为磁盘文件系统图(内核内存映像肯定有所不同),磁盘是典型的块设备,硬盘分区被划分为一个个的 block。一个 block 的大小是由格式化的时候确定的,并且不可以更改。例如 mke2fs 的 -b 选项可以设定 block 大小为 1024、2048 或 4096 字节。而上图中启动块(Boot Block)的大小是确定的,因为不同的文件系统可能略有差异。一般一个磁盘的 0 号分区的 0 号块组上的第一扇区存储着一些启动信息。掌握以下信息能够让一个文件的信息可追溯,可管理。
- 超级块(Super Block):是文件系统的核心结构,用于存放文件系统本身的结构信息,描述文件系统的属性信息,记录的信息主要有:bolck 和 inode的总量,未使用的 block 和 inode 的数量,一个 block 和 inode 的大小,最近一次挂载的时间,最近一次写入数据的时间,最近一次检验磁盘的时间等其他文件系统的相关信息。一般计算机启动时,Super Block 会被加载到操作系统,其中每一块组好像都有一个 Super Block,但实际可能 10 个块组中只有两三个有 Super Block。如果 Super Block 的信息被破坏,可以说整文件系统结构就被破坏了。
- GDT,Group Descriptor Table:块组描述符。Super Block 描述的是整个块组相关的信息,这里描述的是一组的属性信息,每一个块组都必需要有一个 Group Descriptor Table。
-
块位图(Block Bitmap):Block Bitmap 中记录着 Data Block 中哪个数据块已经被占用,哪个数据块没有被占用。其中比特位为 1 表示该 block 被占用,否则表示可用。
-
Block Group:ext2 文件系统会根据分区的大小划分为数 个Block Group,而每个 Block Group 都有着相同的结构组成。
-
inode 位图(inode Bitmap):每个 bit 表示一个 inode 是否空闲可用,比特位和特定的 inode 是一一对应的。其中,inode bitmap 中比特为 1 表示该 indoe 被占用,否则表示可用。
-
i 节点表:存放文件属性,如文件大小、所有者、最近修改时间等。
-
数据区:存放文件内容。
我们说过文件 = 内容 + 属性。这里的内容和属性采用分离存储,属性放在 inode Table 中。一个组中可以放多少个 inode 是一定的,基本上一个文件或目录一个 inode,inode 是一个文件的所有属性集合,属性也是数据,也要占用空间。所以即便是一个空文件也要占用空间,这里的属性集合包含文件权限、大小等,但不包含文件名。
内容放在 Date blocks 中。比如这里的块组是 10G,那么 inode Table 占 1G,Date blocks 占了 8G, 一个 inode 是 512byte,粗略的算一下,1G 大概 42 亿多字节,除以 512 大概也有几千万,所以这样一个块组能保存几千万文件的 inode 信息。这里 inode Table 和 Data blocks 的划分可能会出现你用完了、我没用完,或者你没用完、我用完了的情况,这种情况并没有有效的方法解决。
Date blocks 相当于一个数据块集合,报错的都是特定文件的内容,它以 4k 为单位(Date blocks 可以理解成多个 4kb(扇区*8)大小的集合),对应的数据块属于哪些文件,是由 Data Blocks 和 inode Table 维护的。如下图,inode Table 是一个大小为 128字节的空间,包含了若干大小相同的块,这些块有不同的编号,对应就是对应文件的属性,Data blocks 也包含了若干大小相同的块,这些块也有不同的编号,对应就是文件的内容。此时新建文件或目录,就给文件申请 1 号 inode,并把文件的各种属性写入到 1 号 inode,1 号 inode 中包含了一个数组 block b[32],比如 1 号 inode 需要 2 个数据块,所以 [0] = 2,[1] = 3,所以 1 号 inode 就可以找到对应的数据块。换而言之,要在磁盘上查找一个文件,我们只需要知道这个文件的 inode 是多少,因为 inode 需要标识唯一性,每一个 inode 块都要有一个 inode 编号,至此,我们知道真正标识文件的不是文件名,而是文件的 inode 编号。既然 inode 大小是确定的,万一文件是 10 个 T,此时数据块就不够了,文件系统的处理策略是数据块不仅可以保存数据的内容,还可以保存其它数据块的编号,它类似于 b+ 树。换言之,对于保存较大的文件,可能就需要多级索引的形式。
找到文件:inode 编号 -> 分区特定的 BlockGroup -> inode -> 属性 -> 内容
目录是文件吗?
是。目录有自己的 inode 和自己的 data block,data block 存储的是文件名和对应的 inode 编号的映射关系,两者互为 key 值。
这里 ls - i 就可以查看文件或目录对应的 inode 了,可以看到这里的 inode 并不是严格连续申请的,它依然能看到文件名,是因为我们需要识别。
1、创建一个新文件的 4 个操作
(1)存储属性
内核先找到一个空闲的i节点(这里是263466)。内核把文件信息记录到其中。
(2)存储数据
该文件需要存储在三个磁盘块,内核找到了三个空闲块:300、500、800。将内核缓冲区的第一块数据复制到 300,下一块复制到 500,以此类推。
(3)记录分配情况
文件内容按顺序 300、500、800 存放。内核在 inode 上的磁盘分布区记录了上述块列表。
(4)添加文件名到目录
新的文件名 linux_1。linux 如何在当前的目录中记录这个文件?内核将入口(1052214,linux_1)添加到目录文件。文件名和 inode 之间的对应关系将文件名和文件的内容及属性连接起来。
如何知道文件的 inode 编号 && 如何理解目录?
我们知道定位一个文件是通过绝对路径或相对路径来定位的,但不管是绝对路径还是相对路径最终一定是要有一个目录。目录当然是一个文件,也有独立的 inode 和自己的数据块,目录中的 block 数组能找到对应的数据块,目录的数据块维护的是文件名和 inode 的映射关系。换而言之,在目录下创建文件时,除了给文件申请 inode、数据块以外,还要把文件名和申请创建成功之后文件的 inode 编号写到目录的数据块中。
所以就能理解为什么大多数操作系统下同一个目录中不允许存在同名文件。只要我们找到了目录就可以找到目录下的文件和对应的 inode,然后就可以通过 inode 读取文件的内容。所以 ls -l 时就可以读到文件的属性信息,它是在当前目录对应的 inode 下找到对应数据块中文件名和文件名映射的 inode,再去找对应文件的 inode,此时就看到文件的属性了。所以 echo “hello world” > file.txt 是先启动进程,这个进程当然知道自己所在的目录,那么它就可以拿着 file.txt 文件名找它对应的 inode,把数据追加到对应的数据块中。所以说 inode 不存储文件名,只是往目录的数据块中写入文件名和文件对应的 inode。
touch 一个空文件 file.txt,ext* 文件系统,做了什么工作?
它先在 inode Bitmap 申请没有被使用的比特位,然后把文件对应的属性信息 inode Table 里写,再把申请好文件的文件名和 inode 编号写入 Data blocks 建立映射关系。如果想 echo “hello world” > file.txt,现在是在当前目录下,file.txt 和 inode 就找到了,再通过 file.txt 文件的 inode 去对应的 Data blocks 中写入数据。所以就能理解为什么大多数操作系统下同一个目录中不允许存在同名文件。一方面是不方便人查看,另一方面是若一个目录下有同名文件了,那么 inode 去操作文件时操作的就不知道是哪个文件了。
Block Bitmap 和 inode Bitmap 是位图,就是用比特位 0 / 1 来表示的。Block Bitmap 用来标识数据块的使用情况,inode Bitmap 用来标识 inode 的使用情况,每个比特位都对应一个块。换而言之,当你新建文件时,它并不是遍历 inode 区域,因为这样做太慢了,它只需要在系统启动时将 Blok Bitmap 和 inode Bitmap 预加载到系统中。要新建文件就把 inode Bitmap 的比特位由 0 置为 1,文件需要多少数据块,就把 Block Bitmap 的比特位由 0 置为 1。所以我们可以通过位图快速的完成 inode 的申请和释放,同时也能确认当前磁盘的使用情况。但是位图依然还是需要去遍历哪些使用和未使用,以及做位操作等,所以这里通过 Group Descriptor Table 来管理。
如何理解删除文件?
计算机中删除一个文件并不是真正的删除,而是把那块空间标识为无效,就像拷贝一部电影到 U 盘需要 1 分钟,但是删除 U 盘上的电影只需要短暂的一两秒;亦或是盖一个房子需要 1 年,而销毁一个房子只需要在墙上写上拆 。而现在理解的是不用把 inode 属性清空,不用把 inode 对应的数据块清空,只需要把两个位图中对应的比特位由 1 到 0,再把所在的目录下中的对应的映射关系去掉,此时空间就是无效的,当下一次再新建文件时,就可以直接把无效的空间覆盖。
按上面这样说,删除后的文件当然可以恢复,Windows 下的回收站就是一个目录,当你删除时就是把文件移动到回收站目录下,然后把其它目录下数据块中的映射关系移动到回收站目录下的数据块中。Windows 下就算把回收站的内容删除也是能恢复的,Linux 下如果要恢复删除的文件是有一些恢复工具的,但有可能在恢复过程中创建各种临时文件,可能就会把想恢复的文件的信息覆盖掉。如果想自己恢复删除的文件,就需要更深入的了解文件系统原理。
三、软硬链接
硬链接是通过 inode 引用另外一个文件,软链接是通过名字引用另外一个文件。
1、软链接
ln -s testLink.txt soft.link 给 testLink.txt 建立软链接 soft.link(可任意取名)。testLink.txt 的 inode 是 1052193,soft.link 的 inode 是 1052198,也就是说软链接 soft.link 就是一个普通的正常文件,它有自己独立的 inode,soft.link 中的数据块中保存着它指向的文件 testLink.txt 的路径,就类似于 Winodws 下的快捷方式,比如桌面看到的软件保存的是其它的路径,在系统中可能你要运行的可执行程序在一个很深的目录下,就可以在较上层的目录中建立软链接。
2、硬链接
ln testLink1.txt hard.link 给 testLink.txt 建立硬链接 hard.link(可任意取名)。其中我们发现硬链接和它链接的文件的所有属性都是一样的,包括 inode,所以硬链接没有独立的 inode,而软链接有自己独立的 indoe,那么严格来说硬链接不是一个独立的文件,软链接是一个独立的链接。还发现链接后的 testLink1.txt 的有一个属性信息由 1 变成立 2,所以这里 ls -li 显示的这一列数据表示的不是软链接,而是硬链接。而当把 testLink1.txt 删除后,hard.link 就由 2 变成了 1,所以硬链接本质就是在 testLink1.txt 文件所在目录的数据块中重新创建一个映射关系,也就是给 testLink1.txt 的 inode 重新起了一个别名。
我们在删除文件时干了两件事情:
- 在目录中将对应的记录删除。
- 将硬连接数 -1;如果为 0,则将对应的磁盘释放。
3、硬链接的应用
为什么创建普通目录的硬链接是 2 ?而创建普通文件的硬链接是 1 ?
普通文件的硬链接是 1 是因为当前目录中只包含一组 file 和 file 的 inode.
而普通目录是 2 的原因是,因为除了当前目录下包含了 dir 和 dir 的 inode,还有 dir 目录下隐藏的 ".",这个点叫做当前路径,此时我们发现这个点的 inode 和 dir 的 inode 是一样的,所以 dir 的 inode 编号是 2。 这个点就是 dir 的别名,因为当前路径的使用频率很高,所以它是为了方便我们对当前路径的索引,如果没有这个别名,那就只能是 "dir/xxx/…",完全没有 "./xxx/…" 方便。
当我们再在 dir 下建立一个目录 other,此时 dir 的硬链接数就变成了 3,other 的硬链接数就是 2。other 的硬链接数是 2 是因为 other 和 inode + . 和 inode。
而 dir 之所以是 3,是因为要 "cd",所以 other 下还有一个点点,它是 dir 的别名。
所以硬链接最典型的应用场景就是方便进行路径转换。
4、文件的 ACM
- Access:文件最近被访问的时间
- Modify:文件内容最后被修改的时间
- Change:文件属性最后被修改的时间
touch file.txt 时会刷新文件的 ACM:
vim file.txt 写入内容后也会更新 file.txt 的 ACM(因为它对文件进行访问,内容修改,属性也修改的原因是写入了内容,文件的大小属性就变了)(不写入内容后也会刷新 file.txt 文件的 ACM,但不同版本下的 linux,结果可能也不同):
chmod u+x file.txt 后,更新的只有 Change:
cat file.txt 后并没有刷新 file.txt 的 Access,是因为 linux 2.6 之后,把访问的频率降低了,而是要真正有效访问或者访问多次后才会刷新,不同的版本策略不一样。那是因为文件在磁盘上,文件内容和属性的操作其实并不高频,比较高频的是文件的访问,而如果你经常 cat 文件,且每次都要刷新时间的话,效率就太低了:
之前说过 make 时,它会自动检测源文件是否作修改,若没有修改则不会再被重新编译。实际上只要 vim 了,即使没有修改,它依然还是会编译。其中 make 就是通过文件的修改时间来甄别文件是否是最新的,可执行程序的修改时间一定是晚于原文件的时间,当对原文件修改后,原文件的修改时间又比可执行程序的时间新了,所以就可以 make。那么在 make 时,若原文件的修改时间晚于可执行程序,那么就可以编译。