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 , 用于缓存路径



















