这是我参与8月更文挑战的第3天,活动详情查看:8月更文挑战
现在目录和文件的抽象结构组成的文件系统已经是操作系统必备的功能。而文件存储离不开存储介质,让我们以机械硬盘为例探索文件系统的实现。
机械硬盘存储数据原理
计算机硬件的总线结构分为不同层级,磁盘的位置在最下层的外设IO设备。分层的图中,距离CPU越近速度越快,但对应的成本越高,外设IO总线成本低,用于外接IO设备,如磁盘、鼠标键盘等。
此时此刻我有一块机械硬盘,硬盘连接到外设IO总线后,操作系统要如何使用它呢?对于机械硬盘存储来说,存,存在机械硬盘哪个地方,读取,从机械硬盘哪个地方读取?解决这个问题,需要了解机械硬盘的物理结构以及读写数据的方式。
首先机械硬盘有几个结构:磁头、柱面、盘片、磁道、扇区。(就不贴图了,自行看机械硬盘拆机视频)
其材质是磁性材质,磁性有个特点,就是可以频繁修改正负极,磁头带电,与磁性材质触碰可以修改磁性材质正负极。基于这个特点,可以将正极视为0,负极视为1,以此存储二进制数据。比如我们需要存储一个十进制数字8,可以将磁头移动到第1一个柱面,第一个盘片,第一个扇区上第N个字节(可能这个扇区前N个字节长度已经被写入数据了,在磁盘中有些地方是存储了这些元信息可以直接读取,当数据删除只会调整被占用字节的长度大小,所以误删除文件可以进行恢复,然如果被覆盖写入了,就再也无法恢复)。十进制8的二进制为1000,存储到磁性材质中就是:-+++,读取时当然别忘了记录存储的数据长度,不然读取时不知道数据何时结束。
那么磁头又是如何移动的呢?比如如何让磁头移动到第1柱面第一个盘片第一个磁道的第一个扇区?此处只说下移动过程,不探讨如何控制磁头的移动,因为我感觉这部分又是另外一个领域了,需要设计电路。
接着说磁头是如何移动的,每个盘片的上下都有一个磁头,所以盘片选择不需要移动,只需要控制对应的盘片移动。然后磁头会根据指定的磁道进行移动。磁头一开始是静止不动,然后进行加速,在进行减速到达对应磁道,然后在等待盘片的旋转使目标扇区移动到磁头下方。所以机械硬盘的确定读写位置耗时有如下几部分:
- 寻道耗时,寻找对应磁道耗时,会进行加速减速过程
- 旋转耗时,等待盘片旋转到对应扇区
知道这两个耗时后,对于机械硬盘时3600转/s,7200转/s相信你也理解了,转速越快,对应的旋转耗时越短。
由于每次读写需要经过两个耗时,比如我在第一秒需要写入数据到磁盘第一个盘片第一磁道第一扇区,在第三秒也需要写入数据到到同个位置。正常来说两次写入需要两次定位耗时,一次读写进行一次定位耗时被称为随机读写。一种优化方式是将第一次写入和第二次写入合并在一起在进行写入,只需进行一次定位耗时,这种针对机械硬盘的优化被称为顺序读写。那么固态硬盘具备顺序读写的特性吗?它又是如何优化?
设备交互
当有了机械硬盘,并且与IO总线连接,那么操作系统又是如何与机械硬盘交互呢?其实类似于Java中使用第三方中间件提供的Jar包,包含了一系列的API接口,只需当一个掉包侠即可。硬盘的制造商也会提供一系列的API给到开发者进行调用。当然也有另外一种方式,内存映射(MMP)。区别会在虚拟化文章探讨。后面在进行交互都以API接口进行。
文件系统
目录和文件抽象的文件系统已经是所有操作系统都具备的功能。下面来探讨如何实现一个文件系统。
文件本质就是一些在机械硬盘上联系存储的二进制数据,文件系统仅关心如何读写文件不必关心文件的格式是图片还是文本。同时一些元信息也需要得到存储,如创建时间,文件大小等。
目录本质与文件没有区别,也可以看做一个文件,但目录存储的信息只能是包含一些文件。
以Linux中的文件系统为例,文件系统可以简化为如下几个功能呢个:
- 创建、修改、删除文件
- 创建、修改、删除目录
现有一块机械硬盘64kb,基于此实现简单文件系统。
文件存储实现
既然机械硬盘用于存储文件,那么64kb全用于存储文件,当写入一个文件后,第二个写入的文件紧紧跟在第一个文件后面是否可行?当然不行,有如下几个问题需要解决。
- 第一个问题是没有考虑文件如果要增加大小如何处理。
- 第二个是无法实现”显示所有文件列表”的需求,因为没有记录文件写入文件到磁盘的位置,以及文件的名称。
- 如果我要保存文件到磁盘,我需要找到磁盘哪些位置是空闲的。
对于第二个问题,需要有一个类似索引的东西来标明每个文件的名称以及存储在磁盘的哪个位置,大小是多少。这个类似索引的数据也被叫做文件的元数据,名词叫做inode。其数据结构为
- 文件名称
- 文件大小
- 文件存储在磁盘的起始地址
划分数据结构之后,我们的数据存储就不仅仅是用户数据了,还需要存储文件元信息inode。所以需要将64kb的空间分为两部分,一部分存储inode信息,另外一部分存用户数据。inode存储的位置称为inode表。并且每个inode都有一个唯一的inodeId,通过inodeId可以在inode表中很容易找到对应的indoe信息。
对于第一个问题,解决方法有很多,比如在inode中存储的磁盘起始地址指向的文件末尾指向下一块文件地址。另外一个方法是使用多个存储存储的磁盘地址进行表示。
对于第三个问题。由于将磁盘空间划分2部分,所以除了判断存储用户数据是否有空闲空间还需要判断inode是否有空闲空间。如果按照链表方式存储文件,那么可以用两个指针的方式存储空闲空间的起始地址。所以还需要对整个磁盘划分出来一部分存储空闲空间信息,这部分就叫它空闲表。
经过以上设计,最终我们的文件系统被划分为三部分,空闲表区、Inode区、用户数据区
- 空闲表区:存储Innode表以及用户数据区空闲的区域地址。
- Inode区:存储文件以及目录的Inode信息
- 用户数据区:存储用户的文件数据和目录数据
目录存储实现
目录其实相当于一个特殊的文件:存储的不是用户多样的数据,比如txt文本,或者是jpg图片。而目录存储数据的是子目录的名称以及inode编号。并且目录的inode信息中会额外有一个字段来标示其为目录类型。与文件区分开来。
文件读取
假设现在有一个/foo/1.txt文件,其大小为1kb,存储在这个简单的文件系统中,下面尝试读取一下。
- 从inode表中读取’/‘目录的inode信息,找到目录存储的地址信息
- 通过地址直接访问磁盘目录数据,找到下一个目录’foo’的inode号
- 通过’foo’的inode号找到’foo’目录的inode信息
- 通过’foo’inode信息中的数据地址信息,找到’1.txt’文件的inode号
- 访问文件,通过’1.txt’inode中存储的数据地址,访问1.txt的数据
文件写入
假设现在有一个/foo/1.txt文件,其大小为1kb,需要向该文件末尾写入数据’Hello disk!’。
- 从inode表中读取’/‘目录的inode信息,找到目录存储的地址信息
- 通过地址直接访问磁盘目录数据,找到下一个目录’foo’的inode号
- 通过’foo’的inode号找到’foo’目录的inode信息
- 通过’foo’inode信息中的数据地址信息,找到’1.txt’文件的inode号
- 访问文件,通过’1.txt’inode中存储的数据地址,并且根据当前文件长度计算出文件末尾的地址。
- 向空闲列表区查询空闲的用户数据区地址,比如找到地址0xAA地址处是空闲,则向文件末尾写入0xAA地址标示下一个存储数据的节点,然后将文件末尾处被覆盖的数据写入到新地址,然后写入’Hello disk!’,最后更新空闲列表(或者在查询时候指明写入数据的长度可以直接更新)。
通过以上理解,感觉echo >> 的追加写入也不再神秘。
并发读/写
当然在实际使用时候,一个文件不一定只是被一个进程读写。在并发读的情况没有意外发生。但当并发写入时,意外就来临了。所以通常会对写入进行加锁,加锁的思路也很简单可以在inode中存储一个字段用于标识当前文件是否被写入,如果被写入则不能再被访问了。当然加锁后需要解决锁带来的问题,比如死锁。
总结
通过常规思路思考了一下文件系统的实现,当然这才是开始,后续的工作就是对性能进行优化。在学习新技术时,我会去了解这个技术解决的核心问题是什么(说白了就就是去了解需求是什么),然后设计一个常规的方案出来(说白了就是第一步干啥第二部干啥)。常规方案通常是非常容易都能想出来的。然后下一步去思考常规方案的问题所在。比如性能、实现复杂度等问题。然后对这些问题逐个进行优化。然后在去看新技术是如何实现,与我“设想”的方案有何区别。取其精华,去其糟粕。
本文转载自: 掘金