Loading...

内存与文件基础知识

  iwehdio的博客园:https://www.cnblogs.com/iwehdio/

学习自:

内存

虚拟内存

  • 单片机是没有操作系统的,所以每次写完代码,都需要借助工具把程序烧录进去,这样程序才能跑起来。

    • 单片机的 CPU 是直接操作内存的「物理地址」。
    • 在这种情况下,要想在内存中同时运行两个程序是不可能的。如果第一个程序在 2000 的位置写入一个新的值,将会擦掉第二个程序存放在相同位置上的所有内容,所以同时运行两个程序是根本行不通的。
  • 操作系统是如何解决这个问题呢?

    • 这里关键的问题是这两个程序都引用了绝对物理地址,而这正是最需要避免的。
    • 把进程所使用的地址「隔离」开来,即让操作系统为每个进程分配独立的一套「虚拟地址」,人人都有,互不干涉。
    • 前提每个进程都不能访问物理地址,至于虚拟地址最终怎么落到物理内存里,对进程来说是透明的,由操作系统负责。
    • 操作系统会提供一种机制,将不同进程的虚拟地址和不同内存的物理地址映射起来。
    • 如果程序要访问虚拟地址的时候,由操作系统转换成不同的物理地址,这样不同的进程运行的时候,写入的是不同的物理地址,这样就不会冲突了。
  • 两种地址的概念:

    • 我们程序所使用的内存地址叫做虚拟内存地址Virtual Memory Address
    • 实际存在硬件里面的空间地址叫物理内存地址Physical Memory Address)。
  • 操作系统引入了虚拟内存,进程持有的虚拟地址会通过 CPU 芯片中的内存管理单元(MMU)的映射关系,来转换变成物理地址,然后再通过物理地址访问内存:

    image-20210106191702508
  • 操作系统是如何管理虚拟地址与物理地址之间的关系?

    • 主要有两种方式,分别是内存分段和内存分页,分段是比较早提出的。

内存分段

  • 程序是由若干个逻辑分段组成的,如可由代码分段、数据分段、栈段、堆段组成。不同的段是有不同的属性的,所以就用分段(Segmentation)的形式把这些段分离出来。

  • 分段机制下,虚拟地址和物理地址是如何映射的?

    • 分段机制下的虚拟地址由两部分组成,段选择子段内偏移量

    image-20210106191958713

    • 段选择子就保存在段寄存器里面。段选择子里面最重要的是段号,用作段表的索引。段表里面保存的是这个段的基地址、段的界限和特权等级等。
    • 虚拟地址中的段内偏移量应该位于 0 和段界限之间,如果段内偏移量是合法的,就将段基地址加上段内偏移量得到物理内存地址。
  • 虚拟地址是通过段表与物理地址进行映射的。

    • 分段机制会把程序的虚拟地址分成 4 个段,每个段在段表中有一个项。
    • 在这一项找到段的基地址,再加上偏移量,于是就能找到物理内存中的地址。

    image-20210106192136705

  • 分段的办法很好,解决了程序本身不需要关心具体的物理内存地址的问题,但它也有一些不足之处:

    • 第一个就是内存碎片的问题。
    • 第二个就是内存交换的效率低的问题。
  • 内存碎片问题:

    • 内存碎片包括两个方面:
      • 外部内存碎片,也就是产生了多个不连续的小物理内存,导致新的程序无法被装载;
      • 内部内存碎片,程序所有的内存都被装载到了物理内存,但是这个程序有部分的内存可能并不是很常使用,这也会导致内存的浪费。

    image-20210106192437195

    • 解决外部内存碎片的问题就是内存交换。比如先将音乐程序从内存写到硬盘上,再从硬盘上读回,只不过紧跟在游戏内存的后边。
  • 分段为什么会导致内存交换效率低的?

    • 多进程的系统来说,用分段的方式,内存碎片是很容易产生的。
    • 但是硬盘的访问速度要比内存慢太多了,每一次内存交换,我们都需要把一大段连续的内存数据写到硬盘上。这个过程会产生性能瓶颈。

内存分页

  • 分段的好处就是能产生连续的内存空间,但是会出现内存碎片和内存交换的空间太大的问题。

  • 那么就要想出能少出现一些内存碎片的办法。另外,当需要进行内存交换的时候,让需要交换写入或者从磁盘装载的数据更少一点,这样就可以解决问题了。这个办法,也就是内存分页Paging)。

  • 分页是把整个虚拟和物理内存空间切成一段段固定尺寸的大小。这样一个连续并且尺寸固定的内存空间,我们叫Page)。在 Linux 下,每一页的大小为 4KB

    • 虚拟地址与物理地址之间通过页表来映射

    image-20210106193057684

    • 页表实际上存储在 CPU 的内存管理单元MMU) 中,于是 CPU 就可以直接通过 MMU,找出要实际要访问的物理内存地址。
    • 当进程访问的虚拟地址在页表中查不到时,系统会产生一个缺页异常,进入系统内核空间分配物理内存、更新进程页表,最后再返回用户空间,恢复进程的运行。
  • 分页是怎么解决分段的内存碎片、内存交换效率低的问题?

    • 由于内存空间都是预先划分好的,也就不会像分段会产生间隙非常小的内存,这正是分段会产生内存碎片的原因。
    • 而采用了分页,那么释放的内存都是以页为单位释放的,也就不会产生无法给进程使用的小内存。
    • 如果内存空间不够,操作系统会把其他正在运行的进程中的「最近没被使用」的内存页面给释放掉,也就是暂时写在硬盘上,称为换出Swap Out)。一旦需要的时候,再加载进来,称为换入Swap In)。
    • 一次性写入磁盘的也只有少数的一个页或者几个页,不会花太多时间,内存交换的效率就相对比较高。

    image-20210106193408589

    • 分页的方式使得我们在加载程序的时候,不再需要一次性都把程序加载到物理内存中。而是完全可以在进行虚拟内存和物理内存的页之间的映射之后,并不真的把页加载到物理内存里,而是只有在程序运行中,需要用到对应虚拟内存页里面的指令和数据时,再加载到物理内存里面去。
  • 分页机制下,虚拟地址和物理地址是如何映射的?

    • 在分页机制下,虚拟地址分为两部分,页号页内偏移。页号作为页表的索引,页表包含物理页每页所在物理内存的基地址,这个基地址与页内偏移的组合就形成了物理内存地址。

    image-20210106193729070

    • 对于一个内存地址转换,三个步骤:
      • 把虚拟内存地址,切分成页号和偏移量;
      • 根据页号,从页表里面,查询对应的物理页号;
      • 直接拿物理页号,加上前面的偏移量,就得到了物理内存地址。
  • 简单的分页有什么缺陷?

    • 有空间上的缺陷。因为操作系统是可以同时运行非常多的进程的,这就意味着页表会非常的庞大。
    • 需要采用的是一种叫作多级页表Multi-Level Page Table)的解决方案。
    • 这个 1024*1024 个「页表项」的单级页表再分页,将页表(一级页表)分为 1024 个页表(二级页表),每个表(二级页表)中包含 1024 个「页表项」,形成二级分页

    image-20210106194126480

    • 如果某个一级页表的页表项没有被用到,也就不需要创建这个页表项对应的二级页表了,即可以在需要时才创建二级页表。
    • 对于 64 位的系统,两级分页肯定不够了,就变成了四级目录,分别是:
      • 全局页目录项 PGD(Page Global Directory);
      • 上层页目录项 PUD(Page Upper Directory);
      • 中间页目录项 PMD(Page Middle Directory);
      • 页表项 PTE(Page Table Entry)。
  • 页表缓存TLB:

    • 多级页表虽然解决了空间上的问题,但是虚拟地址到物理地址的转换就多了几道转换的工序,这显然就降低了这俩地址转换的速度,也就是带来了时间上的开销。
    • 利用局部性原理,把最常访问的几个页表项存储到访问速度更快的硬件,。 CPU 芯片中,有一个专门存放程序最常访问的页表项的 Cache,这个 Cache 就是 TLB(Translation Lookaside Buffer) ,通常称为页表缓存、转址旁路缓存、快表等。

    image-20210106194558603

    • 在 CPU 芯片里面,封装了内存管理单元(Memory Management Unit)芯片,它用来完成地址转换和 TLB 的访问与交互。
    • 有了 TLB 后,那么 CPU 在寻址时,会先查 TLB,如果没找到,才会继续查常规的页表。
    • TLB 的命中率其实是很高的,因为程序最常访问的页就那么几个。

段页式内存管理

  • 内存分段和内存分页并不是对立的,它们是可以组合起来在同一个系统中使用的,那么组合起来后,通常称为段页式内存管理

    image-20210106194658773
  • 段页式内存管理实现的方式:

    • 先将程序划分为多个有逻辑意义的段,也就是前面提到的分段机制;
    • 接着再把每个段划分为多个页,也就是对分段划分出来的连续空间,再划分固定大小的页;
    • 这样,地址结构就由段号、段内页号和页内位移三部分组成。
    • 每一个程序一张段表,每个段又建立一张页表,段表中的地址是页表的起始地址,而页表中的地址则为某页的物理页号。

    image-20210106194824445

  • 段页式地址变换中要得到物理地址须经过三次内存访问:

    • 第一次访问段表,得到页表起始地址;
    • 第二次访问页表,得到物理页号;
    • 第三次将物理页号与页内位移组合,得到物理地址。
  • Linux 内存管理:

    • Linux 内存主要采用的是页式内存管理,但同时也不可避免地涉及了段机制。
    • Intel 处理器的发展历史,是先使用段机制,后来在段机制的基础上进行分页,即先分段,得到线性地址,后分页。
    • 但是事实上,Linux 内核所采取的办法是使段式映射的过程实际上不起什么作用。
    • Linux 系统中的每个段都是从 0 地址开始的整个 4GB 虚拟空间(32 位环境下),也就是所有的段的起始地址都是一样的。
    • 这意味着,Linux 系统中的代码,包括操作系统本身的代码和应用程序代码,所面对的地址空间都是线性地址空间(虚拟地址),这种做法相当于屏蔽了处理器中的逻辑地址概念,段只被用于访问控制和内存保护。
  • Linux 的虚拟地址空间是如何分布的?

    • 虚拟地址空间的内部又被分为内核空间和用户空间两部分,不同位数的系统,地址空间的范围也不同。

    image-20210106195337019

    • 内核空间与用户空间的区别:
      • 进程在用户态时,只能访问用户空间内存;
      • 只有进入内核态后,才可以访问内核空间的内存。
    • 虽然每个进程都各自有独立的虚拟内存,但是每个虚拟内存中的内核地址,其实关联的都是相同的物理内存。这样,进程切换到内核态后,就可以很方便地访问内核空间内存。
  • 用户空间的分段:

    image-20210106195540608

    从低到高分别是 7 种不同的内存段:

    • 程序文件段,包括二进制可执行代码;
    • 已初始化数据段,包括静态常量;
    • 未初始化数据段,包括未初始化的静态变量;
    • 堆段,包括动态分配的内存,从低地址开始向上增长;
    • 文件映射段,包括动态库、共享内存等,从低地址开始向上增长(跟硬件和内核版本有关);
    • 栈段,包括局部变量和函数调用的上下文等。栈的大小是固定的,一般是 8 MB。当然系统也提供了参数,可自定义大小。
    • 在这 7 个内存段中,堆和文件映射段的内存是动态分配的。比如说,使用 C 标准库的 malloc() 或者 mmap() ,就可以分别在堆和文件映射段动态分配内存。

内存页面置换算法

  • 缺页异常(缺页中断):

    • 当 CPU 访问的页面不在物理内存时,便会产生一个缺页中断,请求操作系统将所缺页调入到物理内存。它与一般中断的主要区别在于:

      • 缺页中断在指令执行「期间」产生和处理中断信号,而一般中断在一条指令执行「完成」后检查和处理中断信号。
      • 缺页中断返回到该指令的开始重新执行「该指令」,而一般中断返回回到该指令的「下一个指令」执行。
    • 缺页中断的处理流程:

      image-20210106212852100

      1. 在 CPU 里访问一条 Load M 指令,然后 CPU 会去找 M 所对应的页表项。
      2. 如果该页表项的状态位是「有效的」,那 CPU 就可以直接去访问物理内存了,如果状态位是「无效的」,则 CPU 则会发送缺页中断请求。
      3. 操作系统收到了缺页中断,则会执行缺页中断处理函数,先会查找该页面在磁盘中的页面的位置。
      4. 找到磁盘中对应的页面后,需要把该页面换入到物理内存中,但是在换入前,需要在物理内存中找空闲页,如果找到空闲页,就把页面换入到物理内存中。
      5. 页面从磁盘换入到物理内存完成后,则把页表项中的状态位修改为「有效的」。
      6. 最后,CPU 重新执行导致缺页异常的指令。
    • 第 4 步是能在物理内存找到空闲页的情况,那如果找不到呢?

      • 找不到空闲页的话,就说明此时内存已满了,这时候,就需要「页面置换算法」选择一个物理页,如果该物理页有被修改过(脏页),则把它换出到磁盘。
      • 然后把该被置换出去的页表项的状态改成「无效的」,最后把正在访问的页面装入到这个物理页中。
    • 页表项的字段:

      image-20210106213149852

      • 状态位:用于表示该页是否有效,也就是说是否在物理内存中,供程序访问时参考。
      • 访问字段:用于记录该页在一段时间被访问的次数,供页面置换算法选择出页面时参考。
      • 修改位:表示该页在调入内存后是否有被修改过,由于内存中的每一页都在磁盘上保留一份副本,因此,如果没有修改,在置换该页时就不需要将该页写回到磁盘上,以减少系统的开销;如果已经被修改,则将该页重写到磁盘上,以保证磁盘中所保留的始终是最新的副本。
      • 硬盘地址:用于指出该页在硬盘上的地址,通常是物理块号,供调入该页时使用。
    • 虚拟内存管理的整个流程:

    • 所以,页面置换算法的功能是,当出现缺页异常,需调入新页面而内存已满时,选择被置换的物理页面,也就是说选择一个物理页面换出到磁盘,然后把需要访问的页面换入到物理页。

    • 其算法目标则是,尽可能减少页面的换入换出的次数。

  • 最佳页面置换算法:

    • 最佳页面置换算法基本思路是,置换在「未来」最长时间不访问的页面。
    • 所以,该算法实现需要计算内存中每个逻辑页面的「下一次」访问时间,然后比较,选择未来最长时间不访问的页面。
    • 这很理想,但是实际系统中无法实现,因为程序访问页面时是动态的,我们是无法预知每个页面在「下一次」访问前的等待时间。
    • 所以,最佳页面置换算法作用是为了衡量你的算法的效率,你的算法效率越接近该算法的效率,那么说明你的算法是高效的。
  • 先进先出置换算法:

    • 既然我们无法预知页面在下一次访问前所需的等待时间,那我们可以选择在内存驻留时间很长的页面进行中置换。

    image-20210106214206089

  • 最近最久未使用置换算法:

    • 发生缺页时,选择最长时间没有被访问的页面进行置换,也就是说,该算法假设已经很久没有使用的页面很有可能在未来较长的一段时间内仍然不会被使用。
    • 这种算法近似最优置换算法,最优置换算法是通过「未来」的使用情况来推测要淘汰的页面,而 LRU 则是通过「历史」的使用情况来推测要淘汰的页面。
    • 虽然 LRU 在理论上是可以实现的,但代价很高。为了完全实现 LRU,需要在内存中维护一个所有页面的链表,最近最多使用的页面在表头,最近最少使用的页面在表尾。
    • 困难的是,在每次访问内存时都必须要更新「整个链表」。在链表中找到一个页面,删除它,然后把它移动到表头是一个非常费时的操作。
    • 所以,LRU 虽然看上去不错,但是由于开销比较大,实际应用中比较少使用。
  • 时钟页面置换算法:

    • 把所有的页面都保存在一个类似钟面的「环形链表」中,一个表针指向最老的页面。
    • 当发生缺页中断时,算法首先检查表针指向的页面:
      • 如果它的访问位位是 0 就淘汰该页面,并把新的页面插入这个位置,然后把表针前移一个位置;
      • 如果访问位是 1 就清除访问位,并把表针前移一个位置,重复这个过程直到找到了一个访问位为 0 的页面为止。

  • 最不常用算法:

    • 当发生缺页中断时,选择「访问次数」最少的那个页面,并将其淘汰。
    • 它的实现方式是,对每个页面设置一个「访问计数器」,每当一个页面被访问时,该页面的访问计数器就累加 1。在发生缺页中断时,淘汰计数器值最小的那个页面。
    • 要增加一个计数器来实现,这个硬件成本是比较高的,另外如果要对这个计数器查找哪个页面访问次数最小,查找链表本身,如果链表长度很大,是非常耗时的,效率不高。

文件

文件系统的基本组成

  • 文件系统是操作系统中负责管理持久数据的子系统,就是负责把用户的文件存到磁盘硬件中,因为即使计算机断电了,磁盘里的数据并不会丢失,所以可以持久化的保存文件。

  • 文件系统的基本数据单位是文件,它的目的是对磁盘上的文件进行组织管理,那组织的方式不同,就会形成不同的文件系统。

  • Linux 最经典的一句话是:「一切皆文件」,不仅普通的文件和目录,就连块设备、管道、socket 等,也都是统一交给文件系统管理的。

  • Linux 文件系统会为每个文件分配两个数据结构:索引节点(index node)和目录项(directory entry),它们主要用来记录文件的元信息和目录层次结构。

    • 索引节点,也就是 inode,用来记录文件的元信息,比如 inode 编号、文件大小、访问权限、创建时间、修改时间、数据在磁盘的位置等等。
    • 索引节点是文件的唯一标识,它们之间一一对应,也同样都会被存储在硬盘中,所以索引节点同样占用磁盘空间。
    • 目录项,也就是 dentry,用来记录文件的名字、索引节点指针以及与其他目录项的层级关联关系。
    • 多个目录项关联起来,就会形成目录结构,但它与索引节点不同的是,目录项是由内核维护的一个数据结构,不存放于磁盘,而是缓存在内存。
    • 由于索引节点唯一标识一个文件,而目录项记录着文件的名,所以目录项和索引节点的关系是多对一,也就是说,一个文件可以有多个别字。比如,硬链接的实现就是多个目录项中的索引节点指向同一个文件。
    • 注意,目录也是文件,也是用索引节点唯一标识,和普通文件不同的是,普通文件在磁盘里面保存的是文件数据,而目录文件在磁盘里面保存子目录或文件。
  • 目录项和目录是一个东西吗?

    • 不是一个东西,目录是个文件,持久化存储在磁盘,而目录项是内核一个数据结构,缓存在内存。
    • 如果查询目录频繁从磁盘读,效率会很低,所以内核会把已经读过的目录用目录项这个数据结构缓存在内存,下次再次读到相同的目录时,只需从内存读就可以。
    • 注意,目录项这个数据结构不只是表示目录,也是可以表示文件的。
  • 文件数据是如何存储在磁盘的?

    • 磁盘读写的最小单位是扇区,扇区的大小只有 512B 大小,很明显,如果每次读写都以这么小为单位,那这读写的效率会非常低。
    • 所以,文件系统把多个扇区组成了一个逻辑块,每次读写的最小单位就是逻辑块(数据块),Linux 中的逻辑块大小为 4KB,也就是一次性读写 8 个扇区,这将大大提高了磁盘的读写的效率。

    image-20210106200959456

    • 索引节点是存储在硬盘上的数据,那么为了加速文件的访问,通常会把索引节点加载到内存中。
  • 磁盘进行格式化的时候,会被分成三个存储区域,分别是超级块、索引节点区和数据块区。

    • 超级块,用来存储文件系统的详细信息,比如块个数、块大小、空闲块等等。
    • 索引节点区,用来存储索引节点;
    • 数据块区,用来存储文件或目录数据。
  • 不可能把超级块和索引节点区全部加载到内存,这样内存肯定撑不住,所以只有当需要使用的时候,才将其加载进内存,它们加载进内存的时机是不同的:

    • 超级块:当文件系统挂载时进入内存;
    • 索引节点区:当文件被访问时进入内存。

虚拟文件系统

  • 文件系统的种类众多,而操作系统希望对用户提供一个统一的接口,于是在用户层与文件系统层引入了中间层,这个中间层就称为虚拟文件系统(Virtual File System,VFS)。

  • VFS 定义了一组所有文件系统都支持的数据结构和标准接口,这样程序员不需要了解文件系统的工作原理,只需要了解 VFS 提供的统一接口即可。

  • 在 Linux 文件系统中,用户空间、系统调用、虚拟机文件系统、缓存、文件系统以及存储之间的关系:

    • Linux 支持的文件系统也不少,根据存储位置的不同,可以把文件系统分为三类:
      • 磁盘的文件系统,它是直接把数据存储在磁盘中,比如 Ext 2/3/4、XFS 等都是这类文件系统。
      • 内存的文件系统,这类文件系统的数据不是存储在硬盘的,而是占用内存空间,我们经常用到的 /proc/sys 文件系统都属于这一类,读写这类文件,实际上是读写内核中相关的数据数据。
      • 网络的文件系统,用来访问其他计算机主机数据的文件系统,比如 NFS、SMB 等等。
    • 文件系统首先要先挂载到某个目录才可以正常使用,比如 Linux 系统在启动时,会把文件系统挂载到根目录。

文件的使用

  • 从用户角度来看文件的话,要怎么使用文件?首先,我们得通过系统调用来打开一个文件。

    image-20210106201855192

    • 首先用 open 系统调用打开文件,open 的参数中包含文件的路径名和文件名。
    • 使用 write 写数据,其中 write 使用 open 所返回的文件描述符,并不使用文件名作为参数。
    • 使用完文件后,要用 close 系统调用关闭文件,避免资源的泄露。
  • 打开了一个文件后,操作系统会跟踪进程打开的所有文件,所谓的跟踪呢,就是操作系统为每个进程维护一个打开文件表,文件表里的每一项代表「文件描述符」,所以说文件描述符是打开的文件的标识。

  • 操作系统在打开文件表中维护着打开文件的状态和信息:

    • 文件指针:系统跟踪上次读写位置作为当前文件位置指针,这种指针对打开文件的某个进程来说是唯一的;
    • 文件打开计数器:文件关闭时,操作系统必须重用其打开文件表条目,否则表内空间不够用。因为多个进程可能打开同一个文件,所以系统在删除打开文件条目之前,必须等待最后一个进程关闭文件,该计数器跟踪打开和关闭的数量,当该计数为 0 时,系统关闭文件,删除该条目;
    • 文件磁盘位置:绝大多数文件操作都要求系统修改文件数据,该信息保存在内存中,以免每个操作都从磁盘中读取;
    • 访问权限:每个进程打开文件都需要有一个访问模式(创建、只读、读写、添加等),该信息保存在进程的打开文件表中,以便操作系统能允许或拒绝之后的 I/O 请求。
  • 在用户视角里,文件就是一个持久化的数据结构,但操作系统并不会关心你想存在磁盘上的任何的数据结构,操作系统的视角是如何把文件数据和磁盘块对应起来。

  • 所以,用户和操作系统对文件的读写操作是有差异的,用户习惯以字节的方式读写文件,而操作系统则是以数据块来读写文件,那屏蔽掉这种差异的工作就是文件系统了。

  • 读文件和写文件的过程:

    • 当用户进程从文件读取 1 个字节大小的数据时,文件系统则需要获取字节所在的数据块,再返回数据块对应的用户进程所需的数据部分。

    • 当用户进程把 1 个字节大小的数据写进文件时,文件系统则找到需要写入数据的数据块的位置,然后修改数据块中对应的部分,最后再把数据块写回磁盘。

    • 所以说,文件系统的基本操作单位是数据块

文件的存储

  • 文件的数据是要存储在硬盘上面的,数据在磁盘上的存放方式,有以下两种:

    • 连续空间存放方式

    • 非连续空间存放方式

    • 其中,非连续空间存放方式又可以分为「链表方式」和「索引方式」。

  • 连续空间存放方式:

    • 连续空间存放方式顾名思义,文件存放在磁盘「连续的」物理空间中。这种模式下,文件的数据都是紧密相连,读写效率很高,因为一次磁盘寻道就可以读出整个文件。
    • 文件头里需要指定「起始块的位置」和「长度」,有了这两个信息就可以很好的表示文件存放方式是一块连续的磁盘空间。注意,此处说的文件头,就类似于 Linux 的 inode。
    image-20210106202753144
    • 连续空间存放的方式虽然读写效率高,但是有「磁盘空间碎片」和「文件长度不易扩展」的缺陷。
  • 非连续空间存放方式(链表方式):

    • 链表的方式存放是离散的,不用连续的,于是就可以消除磁盘碎片,可大大提高磁盘空间的利用率,同时文件的长度可以动态扩展。根据实现的方式的不同,链表可分为「隐式链表」和「显式链接」两种形式。
    • 文件要以「隐式链表」的方式存放的话,实现的方式是文件头要包含「第一块」和「最后一块」的位置,并且每个数据块里面留出一个指针空间,用来存放下一个数据块的位置。
    • 如果取出每个磁盘块的指针,把它放在内存的一个表中,就可以解决上述隐式链表的两个不足。那么,这种实现方式是「显式链接」,它指把用于链接文件各数据块的指针,显式地存放在内存的一张链接表中,该表在整个磁盘仅设置一张,每个表项中存放链接指针,指向下一个数据块号。
    • 从文件的开始的表项开始,顺着链走到最后,能够找出文件的全部磁盘块。最后,链以一个不属于有效磁盘编号的特殊标记(如 -1 )结束。内存中的这样一个表格称为文件分配表(File Allocation Table,FAT)。
    image-20210106203426885
    • 由于查找记录的过程是在内存中进行的,因而不仅显著地提高了检索速度,而且大大减少了访问磁盘的次数。但也正是整个表都存放在内存中的关系,它的主要的缺点是不适用于大磁盘。
  • 非连续空间存放方式(索引方式):

    • 链表的方式解决了连续分配的磁盘碎片和文件动态扩展的问题,但是不能有效支持直接访问(FAT除外),索引的方式可以解决这个问题。
    • 索引的实现是为每个文件创建一个「索引数据块」,里面存放的是指向文件数据块的指针列表,就像书的目录一样,要找哪个章节的内容,看目录查就可以。
    • 另外,文件头需要包含指向「索引数据块」的指针,这样就可以通过文件头知道索引数据块的位置,再通过索引数据块里的索引信息找到对应的数据块。
    • 创建文件时,索引块的所有指针都设为空。当首次写入第 i 块时,先从空闲空间中取得一个块,再将其地址写到索引块的第 i 个条目。

    image-20210106203840236

    • 索引的方式优点在于:
      • 文件的创建、增大、缩小很方便;
      • 不会有碎片的问题;
      • 支持顺序读写和随机读写。
    • 由于索引数据也是存放在磁盘块的,如果文件很小,明明只需一块就可以存放的下,但还是需要额外分配一块来存放索引数据,所以缺陷之一就是存储索引带来的开销
  • 如果文件很大,大到一个索引数据块放不下索引信息,这时又要如何处理大文件的存放呢?

    • 可以通过组合的方式,来处理大文件的存放。
    • 链表 + 索引的组合,这种组合称为「链式索引块」,它的实现方式是在索引数据块留出一个存放下一个索引数据块的指针,于是当一个索引数据块的索引信息用完了,就可以通过指针的方式,找到下一个索引数据块的信息。
    • 这种方式也会出现前面提到的链表方式的问题,万一某个指针损坏了,后面的数据也就会无法读取了。

    image-20210106204101206

    • 索引 + 索引的方式,这种组合称为「多级索引块」,实现方式是通过一个索引块来存放多个索引数据块,一层套一层索引。

    image-20210106204130910

  • 文件实现方式比较?

    image-20210106204253098

    • 显式链表和索引方式的一个区别是,显式链表在内存,索引在磁盘。
  • Unix 文件系统是组合了前面的文件存放方式的优点:

    image-20210106204308598

    • 它是根据文件的大小,存放的方式会有所变化:
      • 如果存放文件所需的数据块小于 10 块,则采用直接查找的方式;
      • 如果存放文件所需的数据块超过 10 块,则采用一级间接索引方式;
      • 如果前面两种方式都不够存放大文件,则采用二级间接索引方式;
      • 如果二级间接索引也不够存放大文件,这采用三级间接索引方式。
    • 文件头(Inode)就需要包含 13 个指针:
      • 10 个指向数据块的指针;
      • 第 11 个指向索引块的指针;
      • 第 12 个指向二级索引块的指针;
      • 第 13 个指向三级索引块的指针;
    • 所以,这种方式能很灵活地支持小文件和大文件的存放:
      • 对于小文件使用直接查找的方式可减少索引数据块的开销;
      • 对于大文件则以多级索引的方式来支持,所以大文件在访问数据块时需要大量查询;
    • 这个方案就用在了 Linux Ext 2/3 文件系统里,虽然解决大文件的存储,但是对于大文件的访问,需要大量的查询,效率比较低。

空闲空间管理

  • 文件的存储是针对已经被占用的数据块组织和管理,接下来的问题是,如果我要保存一个数据块,我应该放在硬盘上的哪个位置呢?针对磁盘的空闲空间也是要引入管理的机制,几种常见的方法:

    • 空闲表法
    • 空闲链表法
    • 位图法
  • 空闲表法:

    • 空闲表法就是为所有空闲空间建立一张表,表内容包括空闲区的第一个块号和该空闲区的块个数,注意,这个方式是连续分配的。

    image-20210106204838902

    • 当请求分配磁盘空间时,系统依次扫描空闲表里的内容,直到找到一个合适的空闲区域为止。
    • 当用户撤销一个文件时,系统回收文件空间。这时,也需顺序扫描空闲表,寻找一个空闲表条目并将释放空间的第一个物理块号及它占用的块数填到这个条目中。
    • 这种方法仅当有少量的空闲区时才有较好的效果。因为,如果存储空间中有着大量的小的空闲区,则空闲表变得很大,这样查询效率会很低。另外,这种分配技术适用于建立连续文件。
  • 空闲链表法:

    • 也可以使用「链表」的方式来管理空闲空间,每一个空闲块里有一个指针指向下一个空闲块,这样也能很方便的找到空闲块并管理起来。

    image-20210106204949729

    • 当创建文件需要一块或几块时,就从链头上依次取下一块或几块。反之,当回收空间时,把这些空闲块依次接到链头上。
    • 这种技术只要在主存中保存一个指针,令它指向第一个空闲块。其特点是简单,但不能随机访问,工作效率低,因为每当在链上增加或移动空闲块时需要做很多 I/O 操作,同时数据块的指针消耗了一定的存储空间。
  • 空闲表法和空闲链表法都不适合用于大型文件系统,因为这会使空闲表或空闲链表太大。

  • 位图法:

    • 位图是利用二进制的一位来表示磁盘中一个盘块的使用情况,磁盘上所有的盘块都有一个二进制位与之对应。

    • 当值为 0 时,表示对应的盘块空闲,值为 1 时,表示对应的盘块已分配。它形式如下:

      1111110011111110001110110111111100111 ...
      
    • 在 Linux 文件系统就采用了位图的方式来管理空闲空间,不仅用于数据空闲块的管理,还用于 inode 空闲块的管理,因为 inode 也是存储在磁盘的,自然也要有对其管理。

文件系统的结构

  • Linux 是用位图的方式管理空闲空间,用户在创建一个新文件时,Linux 内核会通过 inode 的位图找到空闲可用的 inode,并进行分配。要存储数据时,会通过块的位图找到空闲的块,并分配。

  • 数据块的位图是放在磁盘块里的,假设是放在一个块里,一个块 4K,每位表示一个数据块,共可以表示 4 * 1024 * 8 = 2^15 个空闲块,由于 1 个数据块是 4K 大小,那么最大可以表示的空间为 2^15 * 4 * 1024 = 2^27 个 byte,也就是 128M。

  • 也就是说按照上面的结构,如果采用「一个块的位图 + 一系列的块」,外加「一个块的 inode 的位图 + 一系列的 inode 的结构」能表示的最大空间也就 128M,这太少了,现在很多文件都比这个大。

  • 在 Linux 文件系统,把这个结构称为一个块组,那么有 N 多的块组,就能够表示 N 大的文件。

  • Linux Ext2 整个文件系统的结构和块组的内容,文件系统都由大量块组组成,在硬盘上相继排布:

image-20210106205448617

  • 最前面的第一个块是引导块,在系统启动时用于启用引导。

  • 超级块,包含的是文件系统的重要信息,比如 inode 总个数、块总个数、每个块组的 inode 个数、每个块组的块个数等等。

  • 块组描述符,包含文件系统中各个块组的状态,比如块组中空闲块和 inode 的数目等,每个块组都包含了文件系统中「所有块组的组描述符信息」。

  • 数据位图和 inode 位图, 用于表示对应的数据块或 inode 是空闲的,还是被使用中。

  • inode 列表,包含了块组中所有的 inode,inode 用于保存文件系统中与各个文件和目录相关的所有元数据。

  • 数据块,包含文件的有用数据。

  • 可以会发现每个块组里有很多重复的信息,比如超级块和块组描述符表,这两个都是全局信息,非常的重要,这么做是有两个原因:

    • 如果系统崩溃破坏了超级块或块组描述符,有关文件系统结构和内容的所有信息都会丢失。如果有冗余的副本,该信息是可能恢复的。
    • 通过使文件和管理数据尽可能接近,减少了磁头寻道和旋转,这可以提高文件系统的性能。
    • 不过,Ext2 的后续版本采用了稀疏技术。该做法是,超级块和块组描述符表不再存储到文件系统的每个块组中,而是只写入到块组 0、块组 1 和其他 ID 可以表示为 3、 5、7 的幂的块组中。

目录的存储

  • 目录是一个特殊的文件。和普通文件不同的是,普通文件的块里面保存的是文件数据,而目录文件的块里面保存的是目录里面一项一项的文件信息。

  • 在目录文件的块中,最简单的保存格式就是列表,就是一项一项地将目录下的文件信息(如文件名、文件 inode、文件类型等)列在表里。

    • 列表中每一项就代表该目录下的文件的文件名和对应的 inode,通过这个 inode,就可以找到真正的文件。

    image-20210106210050770

    • 通常,第一项是「.」,表示当前目录,第二项是「..」,表示上一级目录,接下来就是一项一项的文件名和 inode。
    • 如果一个目录有超级多的文件,我们要想在这个目录下找文件,按照列表一项一项的找,效率就不高了。
    • 于是,保存目录的格式改成哈希表,对文件名进行哈希计算,把哈希值保存起来,如果我们要查找一个目录下面的文件名,可以通过名称取哈希。如果哈希能够匹配上,就说明这个文件的信息在相应的块里面。
    • Linux 系统的 ext 文件系统就是采用了哈希表,来保存目录的内容,这种方法的优点是查找非常迅速,插入和删除也较简单,不过需要一些预备措施来避免哈希冲突。
  • 目录查询是通过在磁盘上反复搜索完成,需要不断地进行 I/O 操作,开销较大。所以,为了减少 I/O 操作,把当前使用的文件目录缓存在内存,以后要使用该文件时只要在内存中操作,从而降低了磁盘操作次数,提高了文件系统的访问速度。

软链接和硬链接

  • 给某个文件取个别名,那么在 Linux 中可以通过硬链接(Hard Link) 和软链接(Symbolic Link) 的方式来实现,它们都是比较特殊的文件,但是实现方式也是不相同的。
  • 硬链接是多个目录项中的「索引节点」指向一个文件,也就是指向同一个 inode。
  • 但是 inode 是不可能跨越文件系统的,每个文件系统都有各自的 inode 数据结构和列表,所以硬链接是不可用于跨文件系统的。
  • 由于多个目录项都是指向一个 inode,那么只有删除文件的所有硬链接以及源文件时,系统才会彻底删除该文件。

image-20210106210441750

  • 软链接相当于重新创建一个文件,这个文件有独立的 inode,但是这个文件的内容是另外一个文件的路径。
  • 所以访问软链接的时候,实际上相当于访问到了另外一个文件,所以软链接是可以跨文件系统的,甚至目标文件被删除了,链接文件还是在的,只不过指向的文件找不到了而已。

image-20210106210538864

文件I/O

  • 文件的读写方式各有千秋,对于文件的 I/O 分类也非常多,常见的有

    • 缓冲与非缓冲 I/O
    • 直接与非直接 I/O
    • 阻塞与非阻塞 I/O VS 同步与异步 I/O
  • 缓存与非缓存I/O

    • 文件操作的标准库是可以实现数据的缓存,那么根据「是否利用标准库缓冲」,可以把文件 I/O 分为缓冲 I/O 和非缓冲 I/O:
      • 缓冲 I/O,利用的是标准库的缓存实现文件的加速访问,而标准库再通过系统调用访问文件。
      • 非缓冲 I/O,直接通过系统调用访问文件,不经过标准库缓存。
    • 这里所说的「缓冲」特指标准库内部实现的缓冲。比方说,很多程序遇到换行时才真正输出,而换行前的内容,其实就是被标准库暂时缓存了起来,这样做的目的是,减少系统调用的次数,毕竟系统调用是有 CPU 上下文切换的开销的。
  • 直接与非直接I/O

    • 磁盘 I/O 是非常慢的,所以 Linux 内核为了减少磁盘 I/O 次数,在系统调用后,会把用户数据拷贝到内核中缓存起来,这个内核缓存空间也就是「页缓存」,只有当缓存满足某些条件的时候,才发起磁盘 I/O 的请求。
    • 根据「 是否利用操作系统的缓存」,可以把文件 I/O 分为直接 I/O 与非直接 I/O:
      • 直接 I/O,不会发生内核缓存和用户程序之间数据复制,而是直接经过文件系统访问磁盘。
      • 非直接 I/O,读操作时,数据从内核缓存中拷贝给用户程序,写操作时,数据从用户程序拷贝给内核缓存,再由内核决定什么时候写入数据到磁盘。
    • 如果你在使用文件操作类的系统调用函数时,指定了 O_DIRECT 标志,则表示使用直接 I/O。如果没有设置过,默认使用的是非直接 I/O。
  • 如果用了非直接 I/O 进行写数据操作,内核什么情况下才会把缓存数据写入到磁盘?

    • 在调用 write 的最后,当发现内核缓存的数据太多的时候,内核会把数据写到磁盘上;
    • 用户主动调用 sync,内核缓存会刷到磁盘上;
    • 当内存十分紧张,无法再分配页面时,也会把内核缓存的数据刷到磁盘上;
    • 内核缓存的数据的缓存时间超过某个时间时,也会把数据刷到磁盘上。
  • 阻塞与非阻塞I/O

    • 阻塞 I/O:当用户程序执行 read ,线程会被阻塞,一直等到内核数据准备好,并把数据从内核缓冲区拷贝到应用程序的缓冲区中,当拷贝过程完成,read 才会返回。
    • 阻塞等待的是「内核数据准备好」和「数据从内核态拷贝到用户态」这两个过程。
    image-20210106211404316
    • 非阻塞 I/O:非阻塞的 read 请求在数据未准备好的情况下立即返回,可以继续往下执行,此时应用程序不断轮询内核,直到数据准备好,内核将数据拷贝到应用程序缓冲区,read 调用才可以获取到结果。
    image-20210106211502177
    • 这里最后一次 read 调用,获取数据的过程,是一个同步的过程,是需要等待的过程。这里的同步指的是内核态的数据拷贝到用户程序的缓存区这个过程。
    • 访问管道或 socket 时,如果设置了 O_NONBLOCK 标志,那么就表示使用的是非阻塞 I/O 的方式访问,而不做任何设置的话,默认是阻塞 I/O。
    • 应用程序每次轮询内核的 I/O 是否准备好,感觉有点傻,因为轮询的过程中,应用程序啥也做不了,只是在循环。
    • 为了解决这种傻乎乎的轮询方式,于是 I/O 多路复用技术就出来了,如 select、poll,它是通过 I/O 事件分发,当内核数据准备好时,再以事件通知应用程序进行操作。这个做法大大改善了应用进程对 CPU 的利用率,在没有被通知的情况下,应用进程可以使用 CPU 做其他的事情。
  • 使用 select I/O 多路复用过程。注意,read 获取数据的过程(数据从内核态拷贝到用户态的过程),也是一个同步的过程,需要等待:

    image-20210106211732710
  • 无论是阻塞 I/O、非阻塞 I/O,还是基于非阻塞 I/O 的多路复用都是同步调用。因为它们在 read 调用时,内核将数据从内核空间拷贝到应用程序空间,过程都是需要等待的,也就是说这个过程是同步的,如果内核实现的拷贝效率不高,read 调用就会在这个同步过程中等待比较长的时间。

  • 真正的异步 I/O 是「内核数据准备好」和「数据从内核态拷贝到用户态」这两个过程都不用等待。

    • 当我们发起 aio_read 之后,就立即返回,内核自动将数据从内核空间拷贝到应用程序空间,这个拷贝过程同样是异步的,内核自动完成的,和前面的同步操作不一样,应用程序并不需要主动发起拷贝动作。
    image-20210106211854417
  • 即,I/O 是分为两个过程的:

    1. 数据准备的过程
    2. 数据从内核空间拷贝到用户进程缓冲区的过程
  • 阻塞 I/O 会阻塞在「过程 1 」和「过程 2」,而非阻塞 I/O 和基于非阻塞 I/O 的多路复用只会阻塞在「过程 2」,所以这三个都可以认为是同步 I/O。

  • 异步 I/O 则不同,「过程 1 」和「过程 2 」都不会阻塞。

  • 阻塞/非阻塞,同步/异步都指的是从操作系统内核缓存中拷贝数据,都是非直接I/O。


iwehdio的博客园:https://www.cnblogs.com/iwehdio/
posted @ 2021-01-06 22:38  iwehdio  阅读(1034)  评论(0编辑  收藏  举报