Linux 文件 IO 管理(第三讲:文件系统)
- 进程为什么默认要打开文件描述符为 0,1 和 2 的文件呢?
- 文件系统
- 物理磁盘
- 简单认识
- 存储结构
- 对磁盘存储进行逻辑抽象
- 分组 —— 文件系统
- Block Bitmap
- inode Table
- inode Bitmap
- GDT(Group Descriptor Table)
- Super Block
- 格式化
- 文件系统细节
- inode number
- datablocks[N] 数组
- 编号唾手可得?
- 理解文件的增删查改
- 逆向路径解析和如何找到文件自己所在分区
进程为什么默认要打开文件描述符为 0,1 和 2 的文件呢?
我们写的程序,本质上都是 对数据进行处理(计算,存储等等),既如此就肯定有三个问题:
- 数据从哪里来
- 数据去哪里
- 用户要不要看到这个过程
程序变成进程之后数据并不全是硬编码而成的,也有比如 scanf
, cin
的数据等等,所以是为了更好地让进程获取数据,动态地让用户看到进程的结果,毕竟这些现象都是和人来往的
归结到底,还是人有这个需求罢了,是历史的原因
那 文件描述符 为 2
的 标准错误文件 为什么也要被打开呢?
其实 标准错误文件 对应的文件也是 显示器文件,咱可以来验证一下:
int main()
{fprintf(stdout, "fprintf hello stdout\n");fprintf(stderr, "fprintf hello stderr\n");return 1;
}
咱们分别往 标准输出和错误 打印数据,但结果就是两条打印结果都在显示器上
所以在刚开始,文件描述符为 1
和 2
的两个下标其实指向同一个文件罢了;如果你 重定向,那也只是重定向 文件描述符 为 1
的下标,所以 标准错误 仍然会往显示器打印,如下:
[exercise@localhost redirection]$ ./Test > log.txt
fprintf hello stderr
[exercise@localhost redirection]$ cat log.txt
fprintf hello stdout
[exercise@localhost redirection]$
那为什么还要有 2
呢?
程序运行输出的消息无非就是 正确 和 错误 两类
而正常 Debug
的时候,会将 正确 的调试信息往 1
里打印,错误 的调试信息往 2
里打印,未来我们只需要做一次 重定向 就可以 将正确和错误的调试信息分开:
./Test 1>ok.log 2>err.log
正确调试信息 都在 ok.log
文件里,错误调试信息 都在 err.log
文件里
要是想把两种信息写在别的文件里,可以这样:
./Test 1>all.log 2>&1
如此就都在 all.log
文件里了
文件系统
之前谈到的都是被打开的文件,但是磁盘上的大量文件里,被打开的只是少量文件,还有大量没有被打开的啊!
没有被打开的文件是在 磁盘 内存放,所以这种文件也被叫做 磁盘文件
可是你要打开某个文件都是要先找到这个文件,也就是在大容量磁盘里寻找此文件,所以必须要有 文件路径 + 文件名 才能在偌大的磁盘空间里找到此文件
而 没有被打开的文件 无非就是要放在磁盘中存放,还是那句话,存放的意义就是有朝一日可以更好的取走,所以在本质上就是在 研究文件如何存取的问题
物理磁盘
简单认识
计算机只认识二进制是公认的,而 0 ,1 是被规定出来的,其表示形式可能大不相同,可能使用高低电平表示,也有可能使用磁极表示,所以在物理上会有不同的表现
磁盘拆开就发现里面会有圆形反光的结构,叫做 盘片 ,盘片可读可写可擦除 ,一片盘片两面都可以存数据
接续拆,会发现不止一个盘片,而是一摞盘片,是由很多盘片组合而成的结构,盘片越多,容量越大
而每一个盘面都会有一个磁头(一面一个磁头),磁头通常是用于在特定的盘面当中来回寻址
磁盘的本质是一个机械设备 ,一般磁盘在加电工作的时候,盘片会在类似马达的带动下高速旋转,而磁头会进行左右摆动,由于速度极快,所以磁头和盘片不能紧挨着,不然会造成两个硬件不可逆的损伤,所以 磁头其实是悬浮在盘面上的
存储结构
怎么存就怎么取,这是很正常的想法,所以必须要了解磁盘的内部结构
我们知道磁盘内部有一摞 盘片,每一个 盘片 的正反两面都可以写数据,而每一面都会配备一个 磁头,所以,要想精准找到想要的数据,就要确定数据存放在哪个 磁头 下
每一个 盘面 又被划分为一圈圈同心圆,这一圈圈就是 磁道,如果从一摞盘片的角度看,相同半径的磁道会构成 柱面,而一旦找到了数据在哪个 磁头 下,就可以确定数据在那一圈 磁道(柱面) 上
盘面 上一圈圈 磁道 并非磁盘的 最小读写单位,因为人们又将盘面均等的过圆心分开,那么一圈圈磁道就被分为好多 扇区,这 扇区 才是磁盘最小的 读写单位,也就是说不论读取修改与否,都是要将一整个 扇区 的内容送进内存
那么现在要想确定数据的位置,只需要知道 磁头(Header),柱面(Cylinder),扇区(Sector) 的编号即可(CHS定址法)
对磁盘存储进行逻辑抽象
其实无论是内存还是外存,我们对其抽象均为 线性结构 ,磁盘内盘面虽然是一圈圈磁道,但拉直依然是直线结构,所以 磁盘整体的抽象结果就是线性的
想象你现在把一圈圈磁道拉直了,变成了直线,那现在唯一可以度量这条直线的就只剩下扇区了,而每一个扇区(sector)的大小均固定,那是不是就可以抽象成为 数组 啊,基本单位就是 扇区 sector disk_array[N]
,而数组会有自己的 下标,那么 在无形之中就相当于为每一个扇区完成编址
那怎么 将这个数组里的扇区下标转换为CHS 地址 呢?其实每一个盘片里的空间都是一样大的,扇区大小数量也一样,非常均等,所以可以通过计算找出 CHS 参数:
假设一块磁盘里,每一个盘面共有 N
个扇区,M
个磁道,那么每个磁道里就有 N / M
个扇区,而 index
是任意扇区在 sector disk_array[N]
里的索引下标
首先要明白,上面的数组是抽象出来的,而真正磁盘的每一个盘面都是从 0 开始标号的,并不是接着上一个盘面编号,不然还抽象什么呀
// num 表示一个磁道有几个扇区
num = N / M;
// 计算位于哪一个盘面,即磁头编号
Header = index / N;
// 计算出所在盘面后,使用 temp 存储 index 编号处于该盘面的下标
temp = index % 1000;
// 利用 temp 直接计算出磁道编号
Cylinder = temp / num;
// 同样 temp 取模得到扇区编号
Sector = temp % num;
这时 index
地址完美映射成为 CHS 地址
上面的工作其实是 磁盘内部直接完成的(比较简单),所以 OS 使用的一直都是抽象出来的虚拟磁盘地址
所以目前为止,文件 = 数组内很多个 sector 的下标内容构成 ,只需要记录下该文件所占的扇区下标,将其送往磁盘,再经过映射,即可完美定位磁盘文件位置
上面的问题是解决了,可是磁盘的一个扇区大小为 512 字节(现在可能是 4KB),OS 觉得太小;如果系统只是需要小小的 4KB 的数据,那 IO 端口就得完成来回 8 次拷贝,效率问题凸显!!!
一般而言,虽然磁盘被访问的 基本单位是 0.5 KB ,但 OS 未来和磁盘交互的时候,基本单位是 4 KB(后续博文会说明为什么是 4KB),也就是一次性要拿 8 个 sector ,如此提高 IO 效率问题
那么 OS 就不以 0.5 KB 进行访问,而是 4 KB,这 4 KB 是连续的 8 个扇区 sector ,被称之为 块
既然 OS 不愿意以扇区为基本单位进行抽象,那就 用块来抽象,那么此时 8 个扇区组成一个基本单位为块,使用下标为连续的块进行编址,那么目前为止 文件 = 数组内很多个 块 的下标内容构成
那现在还怎么使用下标来定位磁盘位置啊?很简单啊,现在的一个块是 8 个扇区,那么 下标值乘以 8 就是这个块开头扇区的原来编号,至此于 OS 而言,未来读取数据可以以块为单位
很显然,块的大小既然是固定的,那么现在只需要知道磁盘的总容量,就可以确定抽象后的每一块磁盘地址,有多少块,每个块的块号,如何转移到对应的多个 CHS 地址之类的全都知道
而块的编号叫做 LBA (Logical Block Address)逻辑块地址 ,也就是 LBA block[N]
,以一个数字来描述磁盘空间的地址,以数组的形式组织起来,妥妥的 先描述,再组织,此后 对磁盘的管理就转变为对数组的管理
如果磁盘空间太大不好管理,那么分区就浮出水面,只要把其中的小分区管理好,其他分区使用一样的方法就能实现管理了啊,分区如何实现呢?只需要记住所有分区的开始和结束的 LBA ,如此分区完成
那么现在 文件就是由很多个 LBA 块组成
分组 —— 文件系统
很显然,分完区还是很大,都是以 100GB 为单位的,所以还需要进行分组,分完组一个组的大小 可能 为 10GB ,相同的问题,只要管理好这 10GB 的小组就能管理好分区,进而就能管理好整个磁盘
而上面的一整套思想被称为 分治思想
我们之前就就说 文件 = 内容 + 属性 ,所以文件在磁盘存储,本质上是存储文件的内容数据 + 文件的属性数据,而 Linux 文件系统特定:文件内容和属性分开存储 ,要想理解这些,就得先理解分组后的一个小组,被称为 磁盘级文件系统 的东西:
一个 Block group
就是一个分组,也就是 磁盘文件系统 (Linux ext2文件系统):
Block Group
:ext2
文件系统会根据分区的大小将其划分为数个Block Group
;每个Block Group
都有着相同的结构组成- 超级块(
Super Block
):存放文件系统本身的结构信息,Super Block
的信息被破坏,可以说整个文件系统结构就被破坏了;记录的信息主要有:block
和inode
的总量- 未使用的
block
和inode
的数量 - 一个
block
和inode
的大小 - 最近一次挂载的时间
- 最近一次写入数据的时间
- 最近一次检验磁盘的时间
- 等其他文件系统的相关信息
GDT
(Group Descriptor Table
):块组描述符,描述块组属性信息- 块位图(
Block Bitmap
):Block Bitmap
中记录着Data Block
中哪个数据块已经被占用,哪个数据块没有被占用 inode
位图(inode Bitmap
):每个bit
表示一个inode
是否空闲可用i
节点表:存放文件属性 ,如:文件大小,所有者,最近修改时间等- 数据区
Data Block
:存放文件内容,是整个分组系统里占据空间最大的区域(九成以上) ,里面都是基本单位大小为 4 KB 的数据块,只存储文件的内容 ,每个块都有其块号
注意 文件加载到内存就是以块为单位分批加载,没有即使文件的最后一个块没有占完,而这一整个块也都是此文件的,只是内容不是此文件的内容而已
Block Bitmap
这是 块位图 ,理想情况下,Data Block
里有多少个块 Block Bitmap
就会申请多少个比特位,也就是说 Data Block
里的每一个数据块都对应 Block Bitmap
里的一个 bit
位,如此在 Data Block
里,数据块的占用状态就能被表示出来
注意 bit
位的位置也和数据块一一对应,不能有差错,未来想要为新文件分配空间,可以直接扫描 Block Bitmap
位图,查出 bit
位为 0 的数据块分配即可
inode Table
这玩意就是所谓的 i
节点表,这里面宏观上,其实也全都是数据块,但里面保存的是 所有文件的所有属性
Linux 中文件的属性是大小固定的集合体,就是将所有可以准确描述文件的属性集合在一起成为一个结构体,那也就是说一个文件的内容可以不一样大,但 它们的属性结构体一定是一样大的,这是每个文件都要有的,只是不同的文件属性值不同罢了 ,此乃 先描述
那么在内核里就一定存在 struct inode
结构体,可以描述任意一个文件,里面包含文件的所有属性 ,在里面存在一个非常重要的字段 int inode_number;
为 inode
编号,因为在 struct inode
结构体内部是没有文件名属性的,但是在内核层面,每一个文件都要有 inode number
,我们通过 inode
号去标识一个文件
在 Linux 里可以通过 ls -li
指令查看磁盘文件的 inode number
现在我也知道了一个文件的 inode
号,但是我要怎么找到文件内容的位置呢?在结构体里还会存在属性 int datablocks[N]
用于标识该文件占据的所有块号
在 Linux 系统里, struct inode
结构体 大小一般固定为 128 Byte
,而现在一个块的大小为 4KB ,那么一个块就能存储 32 个 inode
,每一个文件都有一个 inode
那我怎么知道 inode
的使用情况呢?
inode Bitmap
当然啦,和 Block Bitmap
相同的作用:
比特位的位置 表示第几个 inode
(inode number
);比特位的内容,表示该 inode
是否被占用
有几个 inode
,inode Bitmap
里就有几个比特位
GDT(Group Descriptor Table)
块组描述符,顾名思义:描述块组信息
该结构体主要描述当前分组的基本情况(相当具体),如:块大小,共有多少个 inode
,共有多少个 Data Blocks
,有多少个块没有被使用,有多少个 inode
被使用了等等,都会被记录在此结构体中
说白了就是个管理字段,用来管理整个块组的使用情况
Super Block
超级块 :存放文件系统本身的结构信息,这是存放一个分区的基本信息,上面的简介里也提到存放的相关内容
既然是一整个分区的基本内容,那为啥放在 0 号分组里?不应该单独于所有分组进行存放吗?
这 并不是每一个分组都有 Super Block,一般会根据实际的文件系统,可能存在于 2 ~ 3 个分组里,但是即便有好几个分组里都有,但大家的 Super Block 内容都是一样的 !
为什么要这么干呢?纯纯浪费空间啊?都是一样的内容有什么好存的呢?
既然是存放一整个分区的使用情况,说明极为重要啊!磁盘是个机械设备,靠磁头和盘片的物理旋转来定位物理空间,而 Super Block
也并不大,可能就是其中的几个扇区,如果因为一些特殊原因把 Super Block
内容刮花,导致数据失真,那后果就是这个分区都会挂掉,这个影响是巨大的!!!
所以虽然没必让整个分区的所有组都有 Super Block
,但咱还是需要选中几个幸运分组来多保存几份 Super Block
,这是出于安全考量,让文件系统更具有健壮性
格式化
现在我们知道了,一块磁盘仅仅分区是不够的,还需要分组;分完组也是不够,还需要在分组后的所有分组里写入上述 Block Group
结构内容来管理数据
那么日后我们在用磁盘的时候,是基于这么一套文件系统之上来新建删除修改等等
而在每一个分区内进行分组,然后写入文件系统的管理数据,这个叫做 格式化!!!
所以啊,格式化的本质: 在磁盘中写入文件系统
文件系统细节
inode number
寻找任何文件只能通过 inode
编号,所以这个必须知道
inode
编号是以分区为单位整体分配的,而不是分组;一个分区内部的任意文件的 inode
编号都不能重复,但两个分区可能会出现重复,所以 inode
不能跨分区访问!!!
在 inode
被分配的时候,是按照区域进行划分的:在 Super Block
里会记录当前整个分区的 inode
编号范围;在 GDT
里会记录当前整个分组的 inode
编号范围
当拿到一个文件的 inode
编号后,就可以对照分区和分组的范围,确定文件所属的分组位置,找到所属分组后,对照 inode bitmap
合法与否,合法可直接定位 i 节点表找到该文件
当然 Data Block
里的块号也是如此!是基于分区为单位来整体分配的!而且当文件被分配到某个组后,会优先分配该组 Data Block
里的块, 除非此文件非常大,要不然不会跨组存储
datablocks[N] 数组
其实里面就是指向该文件所使用的数据块
这是将文件属性和文件内容存储位置相关联的属性,而这 N 一般为 15
如果是 15 ,那只能映射到 15 个 data block
数据块,那一个文件最大 15 * 4KB = 60 KB 吗?肯定说不通
其实这 15 个空间里面,前 12 个是 直接映射 的,也就是可以直接指向文件存储的数据块编号;后 2 个则是 间接映射 的,可以通过这 2 个来寻找 2 个数据块单元,但这数据块单元里全都是此文件的存储地址,从而完成扩容;而最后一个存的就厉害了,它指向一个数据块单元,但是这个数据块单元里又指向其他的数据块单元,其他的数据块单元才指向真正的文件存储单元
这就很大了,可以直接映射也可以间接映射(3级甚至是4级)
那如果文件容量大于分组空间?当然是可以的,因为只要文件愿意,依然可以让 datablocks[N]
数组指向其他的分组,所以是 支持跨组访问 的!但 非常不建议这么做,因为文件较大,可能不是存在相邻的组中导致同一个文件零碎存储,跨度大,磁盘寻址时间较长,导致效率过低
编号唾手可得?
我们使用文件可是使用的文件名,但 OS 找文件却是用的 inode number
,很反直觉啊!而且 inode
文件属性里还不包括文件名!怎么回事?
首先用户在电脑里所处位置一定是目录,那目录是文件吗?肯定是!那就有它自己的属性和内容,所以目录也会有自己的 inode
,和普通文件有着相同的属性字段,只是属性的值不一样罢了
属性可以理解,那目录的内容呢?放什么?目录的内容其实放的是:目录名和 inode number
的关系映射
所以每次打开查看一个目录的内容时,都是通过文件名和其编号的映射关系,才找到文件的 inode number
而 /
目录是 系统规定 的,是一定可以找到的,所以每次找文件,都是会对文件路径进行逆向路径解析,但这操作是 OS 自己做的,只是 Linux 会为用户缓存常用的路径,不至于每次都要逆向到 /
目录
所以现在就可以解释:
- 在同一个目录下为什么不能创建同名文件
- 目录的
r
权限(查看),本质上是是否允许我们 读取 目录的内容(文件名和inode
号的映射关系) - 目录的
w
权限(新建删除),本质上是是否允许我们向目录进行 修改写入 (文件名和inode
号的映射关系)
理解文件的增删查改
新建文件:在特定的分区中申请一个 inode
,Super Block
也会记录下最近的 inode
编号的分配,确定好分组的编号后再进入此分组中查找 inode bitmap
,寻找为 0 的 bit
位,计算出 inode
编号,并在 inode table
的对应位置填写属性;再去查找 Block Bitmap
寻找空间分配给该文件,并将数据块的地址和属性进行映射;最后将文件内容进行保存,并将 inode
编号返回和文件名进行映射完成新建
查找 就不谈了,有文件名和 inode
编号就很简单; 修改 也是,只是分为修改属性或内容罢了
删除文件:需要在 inode Bitmap
里找到要删的文件的位置,由 1 置为 0 ;再找到 inode
属性里的 datablocks[N]
数组,将对应在 Block Bitmap
的数据块地址由 1 置为 0 ,此时就完成了
所以啊如果一个文件被误删了,只要还没有被覆盖,是可以恢复出来的!
逆向路径解析和如何找到文件自己所在分区
在云服务器上,一般都只有一个盘,查看:
ls /dev/vda
v 代表虚拟,而 /dev/vda1
则是虚拟出来的一个分区,在 Linux 上要访问一个分区是要将一个分区进行挂载的
挂载 意思是说:将磁盘分区和文件系统的一个目录进行关联,未来我们进入一个分区其实是进入指定的一个目录
指令 df -h
:
上图红框就是将 /dev/vda1
和 /
目录挂载
挂载有什么作用呢?就相当于将此分区和目录进行绑定,然后进入该目录,就是在该分区进行文件操作
而不管怎么样,任何文件在被访问之前,一定有目录,只要有目录,对比目录的字符串前缀来确定自己究竟在哪个分区
所以,目录本身除了可以定位文件,还能确定分区
那么找到一个文件就简单了,现在进程提供一个文件的路径,那么路径的末尾就是文件名,需要根据文件名寻找它自己的 inode number
,如何找?需要再上一级目录的文件内容里寻找嘛,那如何获取上一级目录的文件内容?路径里由上级目录名对吧?然后获取它的 inode
编号才能读取需要的文件 inode
编号对吧?那这个目录的 inode
编号又怎么获取?
显然是不是要一直 逆向解析路径,然后一路回退至 /
,此时再返回回来找文件名和 inode
编号的映射即可
其实每一个文件的寻找过程都是这样的,只是会将常用路径进行缓存,所以会效率会比较高
怎么缓存路径?是不是要用数据结构来描述,再将其以树状结构组织起来?没错,这个数据结构在 Linux 里叫做 struct dentry
, 用于缓存路径