
操作系统(下)- 内存与持久化
1. 内存虚拟化
1.1 地址空间与转换
1.1.1 地址空间与虚拟内存
从内存来看,早期的机器并没有提供多少抽象给用户。基本上,机器的物理内存看起来 如图 13.1 所示。
操作系统曾经是一组函数(实际上是一个库),在内存中(在 本例中,从物理地址 0 开始),然后有一个正在运行的程序(进程),目前在物理内存中(在本例中,从物理地址 64KB 开始), 并使用剩余的内存。这里几乎没有抽象,用户对操作系统的要求也不多。
比如我们在做单片机开发时,其实就是往里面写一个“操作系统”,让单片机的CPU直接操作内存的物理地址。
后来,出现了多道程序系统与分时共享系统,出现了进程的概念。多道程序系统在一个进程等待 I/O 操作时切换其他进程给 CPU 运行,而分时操作系统使得”交互性“得到了更好的实现,每个用户的任务尽量都能够及时响应。这种系统中,各个进程都需要在内存中存储其各自的状态信息以供 CPU 来调度。
然而,这里有几个严重的问题:
程序员编写代码的时候,不知道这一块内存是否已经被分配了
一个进程可能会读取或修改另一个进程的内存数据,这样非常危险
为了解决这样的问题,我们引入了地址空间和虚拟内存。
我们假设每个进程占用16KB的内存,这16KB的内存就是这个进程的地址空间,是操作系统给运行的程序看到的一个物理内存抽象。它包含:
程序代码(指令):静态空间,大小不变。
栈:当前的函数调用信息、局部变量、传递参数和函数返回值。从代码之下(本例为 2KB)开始向下增长。
堆:管理动态分配的用户管理的内存。从底部(16KB)开始向上增长。
本例中地址空间只有 16KB,实际上它可以非常大。
对于运行的程序而言,它认为被加载到了地址0开始的内存中,并具有非常大的地址空间(实际上如 32位=2^32字节=4GB,本例为16KB,你就当 16KB 已经很大了)。而实际上由于操作系统与硬件配合的虚拟化内存机制,它其实被加载到了一个任意的合适的物理地址(本例为32KB~48KB)。
假如我们在操作系统上运行如下C语言代码,打印出的地址都是虚拟化内存的地址,而不是真实的物理地址。
#include <stdio.h>
#include <stdlib.h>
int main(int argc, char *argv[]) {
// 打印main函数的地址,即代码段的地址
printf("location of code : %p\n", (void *) main); // 0x1095afe50
// 分配1字节的堆内存,并打印其地址
printf("location of heap : %p\n", (void *) malloc(1)); // 0x1096008c0
// 定义一个整型变量x,并初始化为3
int x = 3;
// 打印变量x的地址,即栈的地址
printf("location of stack : %p\n", (void *) &x); // 0x7fff691aea64
// 返回变量x的值
return x;
}
虚拟化内存有三个目标:
透明假象:程序无法感知到内存被虚拟化的事实,所有工作由操作系统和硬件完成。
效率:操作系统应在时间和空间上尽可能高效,因此需要硬件支持与 TLB。
保护:操作系统应该保证进程之间的隔离,不能让进程之间相互影响。
1.1.2 地址转换机制
我们首先先进行如下假设:
用户的地址空间连续地放在物理内存中
地址空间小于物理内存的大小
每个地址空间的大小完全相同
然后我们从一个简单的例子入手:
void func(){
int x;
x = x + 3; // 注意这一行
}
针对这一行,它的汇编语句为:
x的地址已经存入了寄存器ebx
128: movl 0x0(%ebx), %eax ;把ebx中x地址的值加载到通用寄存器eax
132: addl $0x03, %eax ;对eax的内容加3
135: movl %eax, 0x0(%ebx) ;把eax的值写入ebx中的地址位置
现在看左侧的图,可以看到代码和数据都位于进程的地址空间:
地址128:3条指令序列
地址15KB:变量 x 的值,初始为3000
如果这 3 条指令执行,从进程角度来看,发生了以下几次内存访问:
从地址 128 获取指令;
执行指令(从地址 15KB 加载数据);
从地址 132 获取命令;
执行命令(没有内存访问);
从地址 135 获取指令;
执行指令(新值存入地址 15KB)。
那么重定位又是怎么实现的呢?是通过与两个寄存器结合的动态重定位来实现的,这两个寄存器被称为基地址寄存器和界限寄存器,暂时统称为内存管理单元(MMU)。
比如在上图中,基地址寄存器存入的数据是地址32KB。在执行指令 128: movl 0x0(%ebx), %eax
时:
程序计数器 PC 首先被设置为 128;
当硬件需要获取这条指令时,需要先将这个值加上基地址寄存器的 32KB(32768),得到实际的物理地址 32896,然后硬件从这个物理地址获取指令。
接下来,处理器开始执行该指令。这时,进程发起从虚拟地址 15KB 的加载,处理器同样将虚拟地址加上基址寄存器内容(32KB),得到最终的物理地址 47KB,从而获得需要的数据。
由于这种重定位是在运行时发生的,而且我们甚至可以在进程开始运行后改变其地址空间,这种技术一般被称为动态重定位。
在上面的例子中,界限寄存器被置为 16KB(即地址空间的大小)。如果进程需要访问超过这个界限或者为负数的虚拟地址,CPU 将触发异常,并安排操作系统的“越界”异常处理程序,最终可能被终止。 界限寄存器的用处在于,它确保了进程产生的所有地址都在进程的地址“界限”中。
此外,硬件应该提供一些特殊的指令,用于修改基址寄存器和界限寄存器,允许操作系统在切换进程时改变它们。这些指令是特权(privileged)指令,只有在内核模式下,才能修改这些寄存器。如果用户程序尝试修改基址或者界限寄存器时,CPU 也应该产生异常, 并调用“用户模式尝试执行特权指令”的异常处理程序。
在整个动态重定位的过程中,操作系统需要做的介入工作有:
进程创建时,操作系统检索空闲列表,为进程的地址空间找到内存空间。
进程终止时,操作系统回收其所有内存,将其内存放回到空闲列表,清除相关的数据结构,给其他进程和操作系统所用。
上下文切换时,由于每个CPU只有一个基地址寄存器与界限寄存器,但每个运行的程序其值均不同;因此操作系统在中止当前进程时,需要把这些内容保存到进程控制块 PCB 中,而恢复执行时也需要给这两个寄存器设置正确的值。
CPU 触发异常时,操作系统提供异常处理程序进行处理,如终止错误进程。
1.2 内存管理机制
1.2.1 分段
在 1.1.2 的假设中,有几个新问题需要考虑:
栈和堆之间,有一大块“空闲”空间。如果我们将整个地址空间放入物理内存,那么栈和堆之间的空间并没有被进程使用,却依然占用了实际的物理内存。
如果剩余物理内存无法提供连续区域来放置完整的地址空间,进程便无法运行。
由此我们引入了一个分段的概念。
我们在编写 C/C++ 代码时经常会因为非法内存访问而出现段错误(Segmentation Fault),没错,指的就是这个”段“。
这个想法很简单,在 MMU 中引入不止一个基址和界限寄存器对,而是给地址空间内的每个逻辑段(segment)一对。一个段只是 地址空间里的一个连续定长的区域,在典型的地址空间里有 3 个逻辑不同的段:代码、栈和堆。分段的机制使得操作系统能够将不同的段放到不同的物理内存区域,从而避免了虚 拟地址空间中的未使用部分占用物理内存。
来看一个地址转换的例子:
虚拟地址 100(代码段/前两位00/0KB开始):MMU 将基地址值加上偏移量 100 得到实际的物理地址 100 + 32KB = 32868,然后它会检查该地址是否在界限内(100 小于 2KB),发现是的,于是发起对物理地址 32868 的引用。
虚拟地址 4200(堆/前两位01/4KB开始):我们首先应该先减去堆的偏移量,即该地址指的是这个段中的哪个字节。因为堆从虚拟地址 4K(4096)开始,4200 的偏移量实际上是 4200 减去 4096,即 104,然后用这个偏移量(104)加上基址寄存器中的物理地址(34KB),得到真正的物理地址 34920。如果我们访问非法的地址(如 7KB)则操作系统抛出段错误异常。
虚拟地址 15KB(栈/前两位11/12KB开始):首先硬件需支持反向增长,而应该映射到物理地址 27KB。如何映射的呢?该虚拟地 址的二进制形式是:11 1100 0000 0000(十六进制 0x3C00)。硬件利用前两位(11)来指定 段,但然后我们要处理偏移量 3KB。为了得到正确的反向偏移量,我们必须从 3KB 中减去最大的段地址 4KB,即−1KB,再加上基址(28KB),就得到了正确的物理地址 27KB。用户可以进行界限检查,确保反向偏移量的绝对值小于段的大小。
分段还有一个附加的好处:代码共享。如果代码放在独立的段中,这样的段就可能被多个运行的程序共享。
不过,硬件是怎么知道前两位00就是代码段、前两位01就是堆段、前两位11就是栈段的呢?答案是……这是我们显式规定的。如虚拟地址 4200 的二进制形式:
从图中可以看到,前两位(01)告诉硬件我们引用哪个段。剩下的 12 位是段内偏移: 0000 0110 1000(即十六进制 0x068 或十进制 104)。因此,硬件就用前两位来决定使用哪个 段寄存器,然后用后 12 位作为段内偏移。偏移量与基址寄存器相加,硬件就得到了最终的 物理地址。请注意,偏移量也简化了对段边界的判断。我们只要检查偏移量是否小于界限, 大于界限的为非法地址。
操作系统在进程上下文切换时,需要保存并且恢复各个段寄存器内的内容。
然而这种策略有一个问题:管理物理内存的空闲空间。
现在,每个进程都有一些段,每个段的大小也可能不同。一般会遇到的问题是,物理内存很快充满了许多空闲空间的小洞,因而很难分配给新的段,或扩大已有的段。这种问题被称为外部碎片。
在这个例子中,一个进程需要分配一个 20KB 的段。当前有 24KB 空闲,但并不连续(是 3 个不相邻的块)。因此,操作系统无法满足这个 20KB 的请求。
该问题的一种解决方案是紧凑物理内存,重新安排原有的段。例如,操作 系统先终止运行的进程,将它们的数据复制到连续的内存区域中去,改变它们的段寄存器 中的值,指向新的物理地址,从而得到了足够大的连续空闲空间。这样做,操作系统能让 新的内存分配请求成功。但是,内存紧凑成本很高,因为拷贝段是内存密集型的,一般会占用大量的处理器时间。
还有一个问题是,分段还是不足以支持更一般化的稀疏地址空间。例如,如果有一个很大但是稀疏的堆,都在一个逻辑段中,整个堆仍然必须完整地加载到内存中。换言之,如果使用地址空间的方式不能很好地匹配底层分段的设计目标,分段就不能很好地工作。
1.2.2 分页:介绍
我们不妨换一种思路:将空间分割成固定长度的分片,即分页。
分页不是将一个进程的地址空间分割成几个不同长度的逻辑段(即代码、堆、段),而是分割成固定大小的单元,每个单元称为一页。相应地,我们把物理内存看成是定长槽块的阵列,叫作页帧(page frame)。每个这样的页帧包含一个虚拟内存页。
为了记录地址空间的每个虚拟页放在物理内存中的位置,操作系统通常为每个进程保存一个数据结构,称为页表(page table)。页表的主要作用是为地址空间的每个虚拟页面保存地址转换(address translation),从而让我们知道每个页在物理内存中的位置。
为了转换(translate)该过程生成的虚拟地址,我们必须首先将它分成两个组件:虚拟页面号(virtual page number,VPN)和页内的偏移量(offset)。
本例中,因为进程的虚拟地址空间是 64 字节,我们的虚拟地址总共需要 6 位(2^6 = 64);我们总共有 4 个页,所以需要有 2 位来表示页面号(2 ^ 2 = 4);而每页大小为 16 字节,所以我们需要 4 位(2^4 = 16)来表示偏移量。
如汇编命令 mov 21, %eax
,在获取指令后会执行:
“21”变成二进制形式,是“010101”,虚拟地址“21”在虚拟页“01”(VPN)的第 5 个(“0101”)字节处。
通过虚拟页号,我们现在可以检索页表(PTE),找到虚拟页 1 所在的物理页面。
页表中对应的物理帧号(PFN)是 7(二进制 111),通过用 PFN 替换 VPN 来转换此虚拟地址, 然后将载入发送给物理内存。偏移量保持不变,我们的最终物理地址PA 是 1110101(十进制 117)。
分页有很多优点:
不会导致外部碎片,因为分页按设计将内存划分为固定大小的单元
非常灵活,支持稀疏虚拟地址空间
但是也带来了一些新的问题:
有许多额外的内存访问来访问页表,致使机器运行速度变慢;
内存被页表塞满而不是有用的应用程序数据,会造成内存浪费。
我们将在以下(1.2.3-1.2.4)详细说明其问题背景以及解决方案。
1.2.3 分页:快速地址转换 TLB
一个进程在从虚拟地址21获取数据之前,系统必须:
知道当前进程的页表 PTE 的位置
从进程的页表中提取适当的页表项,执行 VPN 到 PFN 的转换,地址从21转到117
然后从物理地址117中加载数据
如果我们用上述分页的方法,对于每个内存引用(无论是取指令还是显式加载或存储),分页都需要我们执行一个额外的内存引用(上述的第一步),以便从页表中获取地址转换。工作量很大!
一种办法是,加一层“缓存”,把当前VPN的页表(含有VPN到PFN的映射)项存到“缓存”中,也就是第一步直接尝试从“缓存”中获取当前VPN的页表项,若“缓存”存在,则将 VPN 转换为 PFN 并得到物理地址;否则重新执行第一步,将页表项存进“缓存”中。
什么“缓存”能比内存要快呢?—— 答案就是寄存器。
我们把这个“缓存”就叫做 TLB (地址转换旁路缓冲存储器,translation-lookaside buffer)
由于的程序的空间和时间局部性,TLB的缓存命中率很高。
对于TLB未命中后的处理方式,有两种:
硬件处理 TLB(CISC指令集):硬件必须知道页表在内存中的确切位置,以及页表的确切格式。发生未命中时, 硬件会“遍历”页表,找到正确的页表项,取出想要的转换映射,用它更新 TLB,并重试该指令。
操作系统软件处理 TLB(RISC指令集):发生 TLB 未命中时,硬件系统会抛出一个异常,操作系统暂停当前的指令流,将特权级提升至内核模式,跳转至陷阱处理程序处理 TLB 未命中,查找页表中的转换映射,然后用特别的“特权”指令更新 TLB,并从陷阱返回。此时,硬件会重试该指令(导致 TLB 命中)。(系统在陷入内核时必须保存不同的程序计数器,以便硬件重试)
向 TLB 中插入新项时需要对旧项进行缓存替换,替换策略可以为LRU、随机策略等。
上下文切换的问题
很明显,TLB 的内容只对当前进程有效,所以进程上下文切换的时候一定会对 TLB 的内容大做文章。
当一个进程(P1)正在运行时,假设 TLB 缓存了对它有效的地址映射,即来自 P1 的页表。对这个例子,假设 P1 的 10 号虚拟页映射到了 100 号物理帧。 valid
为 1 代表有效, prot
位代表访问权限。
在这个例子中,假设还有一个进程(P2),操作系统不久后决定进行一次上下文切换,运行 P2。这里假定 P2 的 10 号虚拟页映射到 170 号物理帧。
一种简单的想法是上下文切换时直接清空(flush)TLB,即通过硬件或操作系统让 valid 位变为 0,但是,有一定开销:每次进程运行,当它访问数据和代码页时,都会触发 TLB 未命中。如果操作系统频繁地切换进程,这种开销会很高。
为了减少这种开销,一些系统增加了硬件支持,实现跨上下文切换的 TLB 共享。比如有的系统在 TLB 中添加了一个地址空间标识符(ASID)。可以把 ASID 看作是进程标识符 PID ,但通常比 PID 位数少(PID 一般 32 位, ASID 一般是 8 位)。这样,很清楚不同进程可以共享 TLB 了:只要 ASID 字段来区分原来无法区分的地址映射。
当然,硬件也需要知道当前是哪个进程正在运行,以便进行地址转换,因此操作系 统在上下文切换时,必须将某个特权寄存器设置为当前进程的 ASID。
1.2.4 分页:实现较小页表的数据结构
首先我们要注意一点:在单级页表(线性页表)中,页表的内存分配方式通常是一次性申请整个页表的内存,而不是每增加一个页表项就单独申请一块内存。页表项在内存中是连续存储的,这使得通过 VPN 快速查找页表项变得非常高效。CPU 可以直接通过 VPN 计算出页表项的地址,而不需要进行多次内存访问。
例如,想象一个典型的 32 位地址空间,带有 4KB 的页。
这个虚拟地址分成 20 位的 VPN 和 12 位的偏移量(回想一下,1KB 的页面大小需要 10 位,只需增加两位即可达到 4KB)。一个 20 位的 VPN 意味着,操作系统必须为每个进程管理 2^20 个地址转换(大约一百万,2^20 = 1M)。
假设每个页表格条目(PTE)需要 4 个字节,来保存物理地址转换和任何其他有用的东西, 每个页表就需要巨大的 4MB 内存!这非常大。现在想象一下有 100 个进程在运行:这意味着操作系统会需要 400MB 内存,只是为了所有这些地址转换!
怎么实现较小的页表呢?首先我们可能会想,既然页表大小和 VPN 位数有关,那么我们降低 VPN 位数,分配更大的偏移量,也就是更大的页就可以了。但是……页越来越大,又会出现没分页时出现的内存碎片问题,导致每页的浪费,问题又回到了起点。
我们通常由一些特殊的数据结构来实现较小的页表:
多级页表
多级页表通过将 VPN 分成多个部分,每一部分对应一个页表层次。例如,假设我们将 20 位的 VPN 分成两部分:
第一部分(例如 10 位)用于索引第一级页表(页目录)。
第二部分(例如 10 位)用于索引第二级页表(页表)。
这样,页表的结构就变成了一个两级结构(下图仅供示意):
第一级页表(页目录):包含 2^10 个条目,每个条目指向一个第二级页表。
第二级页表:每个第二级页表包含 2^10 个条目,每个条目指向一个物理页帧。
多级页表的主要优势在于它允许页表的某些部分不被加载到内存中,从而减少了内存占用。具体来说:
稀疏映射:在实际应用中,进程的地址空间通常是稀疏的,即很多虚拟页并没有被使用。在单级页表中,即使这些虚拟页没有被使用,它们的 PTE 仍然需要占用空间。而在多级页表中,如果某个第二级页表没有被使用,操作系统可以不分配该页表,从而节省内存。
按需加载:只有当访问某个虚拟地址时,操作系统才会加载对应的第二级页表。这意味着在大多数情况下,只有部分页表需要驻留在内存中。
假设我们使用两级页表:
第一级页表:包含 2^10 个条目,每个条目 4 字节,总共占用 2^10×4=4𝐾𝐵。
第二级页表:每个第二级页表包含 2^10 个条目,每个条目 4 字节,总共占用 2^10×4=4𝐾𝐵。
如果进程的地址空间是稀疏的,假设只有 1/4 的第二级页表被使用,那么每个进程的页表总大小为:
第一级页表:4KB
第二级页表:4𝐾𝐵×1/4=1𝐾𝐵
因此,每个进程的页表总大小为 4𝐾𝐵+1𝐾𝐵=5𝐾𝐵,远小于单级页表的 4MB。如果有 100 个进程,总内存占用为 5𝐾𝐵×100=500𝐾𝐵,远小于单级页表的 400MB。
反向页表(pxf专属考点)
反向页表的核心思想是:将页表的结构从虚拟地址到物理地址的映射,转变为物理地址到虚拟地址的映射。具体来说:
反向页表是为整个系统(而不是为每个进程)维护一个页表。
这个页表的每个条目对应一个物理页帧(PFN,Physical Frame Number),而不是虚拟页。
每个条目不仅包含物理页帧号,还包含该页帧对应的虚拟页号(VPN)和进程标识符(PID)。
反向页表的结构:
条目内容:每个条目包含以下信息:
物理页帧号(PFN)
虚拟页号(VPN)
进程标识符(PID)
其他控制信息(如有效位、保护位等)
条目数量:反向页表的条目数量等于系统中物理页帧的数量。
反向页表的工作方式:
当进程访问虚拟地址时,操作系统会根据虚拟页号(VPN)和进程标识符(PID)在反向页表中查找对应的物理页帧。
如果找到匹配的条目,则可以获取物理地址;如果找不到,则触发页错误(Page Fault)。
反向页表通过以下方式显著减小了页表的大小:
(1) 共享物理内存
反向页表是为整个系统维护的,而不是为每个进程单独维护的。
系统中所有进程共享同一个反向页表,因此不需要为每个进程分配独立的页表。
(2) 条目数量减少
传统页表的条目数量等于虚拟页的数量(例如 220 个)。
反向页表的条目数量等于物理页帧的数量(例如假设系统有 216 个物理页帧,则反向页表只有 216 个条目)。
物理页帧的数量通常远小于虚拟页的数量,因此反向页表的条目数量显著减少。
(3) 内存占用减少
假设每个反向页表条目占用 8 字节(包括 PFN、VPN、PID 等信息),系统有 216216 个物理页帧,则反向页表的大小为 216×8=512𝐾𝐵。
相比之下,传统页表的大小为 4MB(每个进程),如果有 100 个进程,总页表大小为 400MB。
反向页表的总大小仅为 512KB,显著减少了内存占用。
尽管反向页表显著减小了页表的大小,但它也带来了一些挑战:
(1) 查找效率低
在传统页表中,通过虚拟页号(VPN)可以直接计算出页表项的地址,查找非常高效。
在反向页表中,需要根据虚拟页号(VPN)和进程标识符(PID)在反向页表中进行查找,通常需要遍历整个反向页表,效率较低。
为了提高查找效率,通常会使用哈希表(Hash Table)来加速查找。
(2) 复杂性增加
反向页表的实现比传统页表复杂,尤其是在处理多进程和共享内存时。
需要额外的机制来管理进程标识符(PID)和虚拟页号(VPN)的映射。
1.3 页面置换 Swap
到目前为止,我们一直假设页表位于内核拥有的 物理内存中。即使我们有很多技巧来减小页表的大小,但是它仍然有可能是太大而无法一 次装入内存。因此,一些系统将这样的页表放入内核虚拟内存(kernel virtual memory),从而允许系统在内存压力较大时,将这些页表中的一部分交换(swap)到磁盘。
很容易想到,会有可能发生 CPU 访问的虚拟页面,通过页表项的存在位(状态位),发现该页面只在磁盘中而不在物理内存时的情况,此时称缺页中断,我们要从磁盘中读取页,通过I/O讲磁盘换入内存。
此处参考了小林Coding的内存页面置换算法。
1.3.1 缺页中断
当 CPU 访问的页面不在物理内存时,便会产生一个缺页中断,请求操作系统将所缺页调入到物理内存。和 TLB 相似的是,它与一般中断也有区别:
缺页中断在指令执行「期间」产生和处理中断信号,而一般中断在一条指令执行「完成」后检查和处理中断信号。
缺页中断返回到该指令的开始重新执行「该指令」,而一般中断返回回到该指令的「下一个指令」执行。
注意,缺页中断与TLB不同的是,TLB可以由硬件或软件操作系统来实现,而缺页中断的处理只能由操作系统来实现。
我们来看一下缺页中断的处理流程,如下图:
发生缺页中断:硬件在 页表 中查找 当前页面的 PTE,该 PTE 的存在位无效,则 CPU 发出缺页中断请求
操作系统进行I/O:PTE 存在位无效时,PTE 中的某些位会存储该页的硬盘地址,操作系统根据这个地址查找磁盘页面的位置,并讲页面换入(I/O)到物理内存的空闲页中。
操作系统更新页表:操作系统更新 PTE 将此页标记为存在,并更新其 PFN 字段以记录新物理页的内存位置。(当然,也可以直接把它放进 TLB 中)
重试指令。
1.3.2 页面置换算法(等后面背八股的时候再背)
https://xiaolincoding.com/os/5_schedule/schedule.html
其实它就是一种缓存淘汰策略,比如LRU,后面复习的时候可以自己写写玩
1.4 虚拟内存总结
以下是虚拟内存的管理整个流程,摘自小林Coding,后面背八股的时候捋一下这个表就OK。
2. 持久化:磁盘与文件系统
2.1 计算机系统的 I/O 设备
前置知识:计算机组成的I/O
2.1.1 架构
现在我们把计算机的组成变的复杂一些,总线变成分层架构:
最内层的内层总线:CPU 通过它连接到系统内存。
常规的 I/O 总线:图像或者其他高性能 I/O 设备连接到系统。
外围总线:将最慢的设备连接到系统,包括鼠标、本章要重点讨论的磁盘及其他类似设备。
为什么要用这样的分层架构?简单回答:因为物理布局及造价成本。越快的总线越短,因此高性能的内存总线没有足够的空间连接太多设备。另外,在工程上高性能总线的造价非常高。所以,系统的设计采用了这种分层的方式,这样可以让要求高性能的设备(比如显卡)离 CPU 更近一些,低性能的设备离 CPU 远一些。将磁盘和其他低速设备连到外围总线的好处很多,其中较为突出的好处就是你可以在外围总线上连接大量的设备。
一个设备的基本结构:
一个(简化的)设备接口包含 3 个寄存器:
一个状态寄存器, 可以读取并查看设备的当前状态;
一个命令寄存器,用于通知设备执行某个具体任务;
一个数据寄存器,将数据传给设备或从设备接收数据。
通过读写这些寄存器,操作系统可以控制设备的行为。我们现在来描述操作系统与该设备的典型交互,以便让设备为它做某事。协议包含 4 步:
轮询设备:操作系统通过反复读取状态寄存器,等待设备进入可以接收命令的就绪状态。
操作系统下发数据到数据寄存器:如果这是一个磁盘,需要多次写入操作,将一个磁盘块(比如 4KB)传递给设备。如果主 CPU 参与数据移动,我们就称之为编程的 I/O(PIO)。
操作系统将命令写入命令寄存器:设备就知道数据已经准备好了,它应该开始执行命令。
再次轮询设备:等待并判断设备是否执行完成命令(有可能得到一个指示成功或失败的错误码)。
如果设备非常快,那么最好的办法是轮询。如果设备比较慢,那么采用允许发生重叠的中断更好。
有允许发生重叠的中断,CPU 不再需要不断轮询设备,而是:
向设备发出一个请求,然后就可以让对应进程睡眠,切换执行其他任务。
当设备完成了自身操作,会抛出一个硬件中断;
引发 CPU 跳转执行操作系统预先定义好的中断处理程序,结束之前的请求(比如从设备读取到了数据或者错误码)
唤醒等待 I/O 的进程继续执行。
因此,中断允许计算与 I/O 重叠(overlap),这是提高 CPU 利用率的关键。
不过还会有一些问题,如下图:
使用 PIO 的方式,CPU 的时间会浪费在向设备传输数据或从设备传出数据的过程中。
如何才能分离这项工作,从而提高 CPU 的利用率?
答案很简单:直接内存访问 DMA。它可以协调完成内存和设备间的数据传递,不需要 CPU 介入。操作系统会通过编程告诉 DMA 引擎数据在内存的位置,要拷贝的大小以及要拷贝到哪个设备。在此之后,操作系统就可以 处理其他请求了。当 DMA 的任务完成后,DMA 控制器会抛出一个中断来告诉操作系统自 己已经完成数据传输。
2.1.2 通信机制
那么操作系统又是如何与设备进行通信的呢?
用明确的 I/O 指令。这些指令规定了操作系统将数据发 送到特定设备寄存器的方法,从而允许构造上文提到的协议。
用内存映射 I/O。通过这种方式,硬件将设备寄存器作为内存地址提供。当需要访问设备寄存器时,操作系统装载(读取)或者存入(写入) 到该内存地址;然后硬件会将装载/存入转移到设备上,而不是物理内存。
两种方法没有一种具备极大的优势。
2.1.3 驱动程序
最后,如何实现一个设备无关的操作系统呢?
在最底层,操作系统的一部分软件清楚地知道设备如何工作,我们将这部分软件称为设备驱动程序(device driver),所有设备交互的细节都封装在其中。
文件系统(当然也包括在其之上的应用程序)完全不清楚它使用的是什么类型的磁盘。它只需要简单地向通用块设备层发送读写请求即可,块设备层会将这些请求路由给对应的设备驱动,然后设备驱动来完成真正的底层操作。
我们将会在2.2节讲解设备驱动程序控制的设备(磁盘驱动器),在2.3节讲解文件系统UNIX API,2.4 节讲解文件系统实现。
2.2 磁盘驱动器
2.2.1 接口:扇区
接口以扇区为单位,每个扇区大小为 512Bytes ;
每个扇区都可以读取或写入。实际上,许多文件系统一次读取或写入多个扇区(如 4KB),但是每个扇区的写入保证原子性。
在具有 n 个扇区的磁盘上,扇区从 0 到 n−1 编号。因此,我们可以将磁盘视为一组扇区,0 到 n−1 是驱动器的地址空间。
通常可以假设访问连续块(即顺序读取或写入)是最快的访问模式。
2.2.2 磁道模型与 I/O 时间
每一个数字代表一个扇区,每一圈代表一个磁道(最内圈扇区最少,最外圈扇区最多),单个盘片围绕主轴旋转,磁头可以读写或写入扇区。
磁盘进行一次 I/O 即驱动器访问给定的扇区,需要以下过程:
寻道(寻道时间):磁盘臂可以通过旋转使得磁头选择不同的磁道,驱动器必须首先将磁盘臂移动到正确的磁道;
旋转(旋转延迟):磁盘必须等待期望的扇区旋转到磁头下;
传输(传输时间):数据从表面读取或写入表面。
因此总 I/O 时间为:
2.2.3 降低寻道时间:磁盘调度算法
给定一组 I/O 请求,磁盘调度程序检查请求并决定下一个要调度的请求;对于磁盘调度,我们可以很好地猜测“任务”(即磁盘请求)需要多长时间。通过估计请求的查找和可能的旋转延迟,磁盘 调度程序可以知道每个请求将花费多长时间,因此(贪婪地)选择先服务花费最少时间的 请求。因此,磁盘调度程序将尝试在其操作中遵循 SJF(最短任务优先)的原则。
FCFS算法(先来先服务)
注意:下面的队列数字代表的是磁道编号而不是扇区编号。
SSTF算法(最短寻道时间优先算法)
从当前磁头位置选择最短寻道时间的请求
基本上是一种最短作业优先调度,可能导致某些请求饥饿
SCAN调度:也叫电梯算法,磁头在磁盘上来回扫描
C-SCAN算法
SCAN调度的变种,主要提供更为均匀的等待时间
当磁头到头时,立即返回到磁盘开始端,返回时不处理请求
LOOK调度与C-LOOK调度
只移动到一个方向上最远的请求为止
以上算法容易出现“磁臂黏着”现象(一个或几个进程对某一磁道有较高的访问频率,垄断了整个磁盘设备,在高密度磁盘上容易出现)
N-Step-SCAN和FSCAN调度算法
N步SCAN算法是将磁盘请求队列分成若干个长度为N的子队列, 磁盘调度将按FCFS算法依次处理这些子队列。
而每处理一个队列时又是按SCAN算法,对一个队列处理完后,再处理其他队列。
当正在处理某子队列时,如果又出现新的磁盘I/O请求,便将新请求进程放入其他队列,这样就可避免出现粘着现象。
注:当N值取得很大时,会使N步扫描法的性能接近于SCAN算法的性能; 当N=1时, N步SCAN算法便蜕化为FCFS算法。
FSCAN调度算法
FSCAN算法实质上是N步SCAN算法的简化, 即FSCAN只将磁盘请求队列分成两个子队列。
一个是由当前所有请求磁盘I/O的进程形成的队列,磁盘调度按SCAN算法进行处理。
在扫描期间,将新出现的所有请求磁盘I/O的进程, 放入另一个等待处理的请求队列。这样,所有的新请求都将被推迟到下一次扫描时处理。
2.3 文件API:UNIX 系统调用
随着时间的推移,存储虚拟化形成了两个关键的抽象:
文件(file):一个线性字节数组,每个字节都可以读取或写入。每个文件都有 inode 号。操作系统不了解文件结构。
目录(directory):每个目录都有 inode 号,但是操作系统了解内容,包含一个
<用户可读名字,inode>
对的列表。目录中的每个条目 都指向文件或其他目录。通过将目录放入其他目录中,用户可以构建任意的目录树,在该目录树下存储所有文件和目录。
在 UNIX 系统中,你几乎可以想到的所有内容都是通过文件系统命名的。除了文件、设备、管道,甚至进程都可以在一个看似普通的旧文件系统中看到。文件系统提供了一种统一的方式来访问磁盘、U 盘、CD-ROM、许多其他设备上的 文件,事实上还有很多其他的东西,都位于单一目录树下。
在UNIX系统中,文件系统相关的系统调用是操作系统与文件系统交互的核心接口。以下是一些常见的文件系统相关系统调用及其功能:
所有 shell 命令如 cat、vim、mkdir、运行二进制等命令,都可以在前面加上 strace 查看其所调用的系统调用情况。
2.3.1 文件操作
open: 打开或创建文件。
int open(const char *pathname, int flags, mode_t mode);
pathname
: 文件路径。flags
: 打开方式(如O_RDONLY
,O_WRONLY
,O_RDWR
,O_CREAT
等)。mode
: 文件权限(仅在创建文件时使用)。返回:fd 文件描述符。一个进程可以同时打开多个文件, 因此描述符使操作系统能够知道某个特定的读取引用了哪个文件。
如下面是示例代码,用于在当前 工作目录中创建名为“foo”的文件。
#include <fcntl.h> // 包含 open() 函数的定义
#include <unistd.h> // 包含 close() 函数的定义
int main() {
// 调用 open() 函数创建文件 "foo"
// O_CREAT 如果文件不存在,则创建它。
// O_WRONLY 以只写方式打开文件。
// O_TRUNC 如果文件已存在,则将其截断为零长度。
// 0644: 文件的权限模式,表示文件所有者有读写权限,而其他用户只有读权限。
int fd = open("foo", O_CREAT | O_WRONLY | O_TRUNC, 0644);
printf("fd = %d\n", fd); // fd = 3
// 检查文件是否成功创建
if (fd == -1) {
// 如果出错,打印错误信息
perror("open");
return 1;
}
// 关闭文件描述符
close(fd);
return 0;
}
这里 fd
值为 3 的原因:在“3”之前,一个进程还打开了三个文件,这些分别由文件描述符 0、1 和 2 表示:
标准输入(进程可以读取以接收输入)
标准输出(进程 可以写入以便将信息显示到屏幕)
以及标准错误(进程可以写入错误消息)
close: 关闭文件描述符。
int close(int fd);
fd
: 文件描述符。
read: 从文件描述符读取数据。
ssize_t read(int fd, void *buf, size_t count);
fd
: 文件描述符。buf
: 存储读取数据的缓冲区。count
: 要读取的字节数。
write: 向文件描述符写入数据。
ssize_t write(int fd, const void *buf, size_t count);
fd
: 文件描述符。buf
: 要写入的数据。count
: 要写入的字节数。
假如我们追踪 cat foo
的系统调用即 strace cat foo
,我们可以看到:
open("foo", O_RDONLY|O_LARGEFILE) = 3
read(3, "hello\n", 4096) = 6
write(1, "hello\n", 6) = 6
hello
read(3, "", 4096) = 0
close(3) = 0
open 打开文件准备读取:O_RDONLY表示只读不写,O_LARGEFILE 表示64位偏移量;返回一个文件描述符,其值为 3。
read 重复读取文件中字节:描述符 “3” 使操作系统能够知道某个特定的读取引用了哪个文件;缓冲区显示了这时的读取结果 (“hello”);缓冲区大小 4KB;返回它读取的字节数 6(其中包括“hello”中的 5 个字母和一个行尾标记)。
write 对缓冲区字节写入输出:文件描述符 1 是标准输出文件,把单词“Hello”写到屏幕上。(也有可能调用 printf)
read 试图从文件中读取更多内容:但由于文件中没有剩余字节,read()返回 0, 程序知道这意味着它已经读取了整个文件。
close 文件被关闭:传入相应的文件描述符, 表明它已用完文件“foo”。
lseek: 移动文件指针。
off_t lseek(int fd, off_t offset, int whence);
fd
: 文件描述符。offset
: 偏移量。whence
: 基准位置(如SEEK_SET
,SEEK_CUR
,SEEK_END
)。
fsync:用于确保文件的所有修改(包括数据和元数据)都被写入底层存储设备,从而保证数据的持久性。
#include <unistd.h> int fsync(int fd);
fd
: 文件描述符,指向需要同步的文件。成功时返回
0
。失败时返回-1
,并设置errno
以指示错误原因。fsync
会将文件描述符fd
对应的文件的所有修改(包括文件数据和元数据,如大小、时间戳等)刷新到存储设备。它确保在系统调用返回时,数据已经写入物理存储设备,而不是仅仅缓存在内存中。在需要确保数据持久性的场景中使用,例如数据库系统、日志系统等。在关闭文件之前调用fsync
,可以防止系统崩溃或断电导致的数据丢失。
int fd = open("foo", O_CREAT | O_WRONLY | O_TRUNC); assert(fd > -1); int rc = write(fd, buffer, size); assert(rc == size); rc = fsync(fd); assert(rc == 0);
rename: 重命名或移动文件。
int rename(const char *oldpath, const char *newpath);
oldpath
: 原路径。newpath
: 新路径。
rename调用提供了一个有趣的保证:它(通常)是一个原子调用,不论系统是否崩溃。
// 一个文件编辑器所做的事情
int fd = open("foo.txt.tmp", O_WRONLY|O_CREAT|O_TRUNC);
write(fd, buffer, size); // write out new version of file
fsync(fd);
close(fd);
rename("foo.txt.tmp", "foo.txt");
2.3.2 文件元数据
可以通过 stat file
命令获取文件元数据,包括其大小(以字节为单位),其低级名称(即 inode 号),一些所有权信息以及有关何时文件被访问或修改的一些信息,等等。以下是相关的系统调用:
stat: 获取文件状态信息。
int stat(const char *pathname, struct stat *statbuf);
pathname
: 文件路径。statbuf
: 存储文件信息的结构体。
fstat: 通过文件描述符获取文件状态信息。
int fstat(int fd, struct stat *statbuf);
fd
: 文件描述符。statbuf
: 存储文件信息的结构体。
lstat: 类似于
stat
,但不会跟随符号链接。int lstat(const char *pathname, struct stat *statbuf);
2.3.3 目录操作
opendir: 打开目录。
DIR *opendir(const char *name);
name
: 目录路径。
readdir: 读取目录项。
struct dirent *readdir(DIR *dirp);
dirp
: 目录流指针。
closedir: 关闭目录。
int closedir(DIR *dirp);
dirp
: 目录流指针。
mkdir: 创建目录。
int mkdir(const char *pathname, mode_t mode);
pathname
: 目录路径。mode
: 目录权限。
rmdir: 删除空目录。
int rmdir(const char *pathname);
pathname
: 目录路径。
我们最常使用的一个命令 ls
,其所用的系统调用可以简化如下:
int main(int argc, char *argv[]) {
DIR *dp = opendir(".");
assert(dp != NULL);
struct dirent *d; // 将名称映射到 inode 号,以及少量其他细节
while ((d = readdir(dp)) != NULL) {
printf("%d %s\n", (int) d->d_ino, d->d_name);
}
closedir(dp);
return 0;
}
2.3.4 链接与重命名
link: 创建硬链接。
int link(const char *oldpath, const char *newpath);
oldpath
: 源文件路径。newpath
: 新链接路径。
link 只是在要创建链接的目录中创建了另一个名称,并将其指向原有文件的相同 inode 号(即低级别名称)。该文件不以任何方式复制。即对同一个 inode 号创建了新的引用。
prompt> echo hello > file
prompt> cat file
hello
prompt> ln file file2
prompt> cat file2
hello
注意:不能创建目录的硬链接(因为担心会在目录树中创 建一个环),也不能硬链接到其他磁盘分区中的文件(因为 inode 号在特定文件系统中是唯一的,而不是跨文件系统),
通过带-i 标志的 ls,它会打印出每个文件的 inode 编号(以及文件名)。
unlink: 删除文件链接。
int unlink(const char *pathname);
pathname
: 文件路径。
检查 inode 号中的引用计数(reference count)。该引用计数允许文件系统跟踪有多少不同的文件 名已链接到这个 inode。
调用 unlink()时,会删除人类可读的名称(正在删除的文件)与给定 inode 号之间的“链接”,并减少引用计数。
当引用计数达到零时,文件系统会释放 inode 和相关数据块,从而真正“删除”该文件。
可以使用 stat()来查看文件的引用计数。
symlink: 创建符号链接(symbolic link)。
int symlink(const char *target, const char *linkpath);
target
: 目标文件路径。linkpath
: 符号链接路径。
prompt> echo hello > file
prompt> ln -s file file2
prompt> cat file2
hello
不过符号链接实际上与硬链接完全不同。
符号链接本身实际上是一个不同类型的文件,有自己的 inode;
形成符号链接的方式中,即将链接指向文件的路径名作为链接文件的数据。因为我们链接到一个名为 file 的文件,所以我们的链接文件 file2 很小(4 个字节)。如果链接到更长的路径名(alongerfilename),链接文件会更大(15字节)。
创建符号链接的方式,有可能造成所谓的悬空引用。
2.3.5 创建与挂载文件系统
在 UNIX/Linux 系统中,文件系统的创建和挂载是将多个独立的文件系统整合到统一目录树中的关键步骤。以下是这一过程的简要介绍:
创建文件系统通常使用 mkfs
工具(“make file system”)。它的作用是在指定的设备(如磁盘分区)上创建一个空的文件系统。例如,在 /dev/sda1
分区上创建一个 ext3
文件系统,可以使用以下命令:
mkfs -t ext3 /dev/sda1
执行后,设备上会生成一个空文件系统,包含根目录和必要的元数据。
接下来,使用 mount
工具将文件系统挂载到目录树的某个位置(挂载点),使其内容可以被访问。例如,将 /dev/sda1
上的 ext3
文件系统挂载到 /home/users
,可以使用以下命令:
mount -t ext3 /dev/sda1 /home/users
挂载后,文件系统的内容可以通过挂载点访问。例如,文件系统的根目录对应 /home/users
,子目录和文件可以通过 /home/users/a
、/home/users/b
等路径访问。
挂载的意义在于将多个独立的文件系统整合到一棵统一的目录树中,简化了文件访问和命名。例如,挂载后,访问 /home/users/a/foo
实际上访问的是 /dev/sda1
文件系统中的文件。系统上的所有文件系统(如 ext3
、proc
、tmpfs
等)都通过挂载点整合到同一棵树中。
要查看当前系统中所有已挂载的文件系统及其挂载点,可以使用 mount
命令。示例输出如下:
/dev/sda1 on / type ext3 (rw)
proc on /proc type proc (rw)
tmpfs on /dev/shm type tmpfs (rw)
每一行表示一个挂载的文件系统,包括设备、挂载点、文件系统类型和挂载选项。
如果需要卸载已挂载的文件系统,可以使用 umount
工具。例如,卸载 /home/users
,可以使用以下命令:
umount /home/users
总结来说,mkfs
用于创建文件系统,初始化设备;mount
用于将文件系统挂载到目录树,使其内容可访问;umount
用于卸载文件系统,解除挂载。通过挂载,将多个文件系统整合到一棵树中,简化文件访问和管理。通过创建和挂载文件系统,操作系统能够灵活地管理存储设备,并为用户提供统一的文件访问接口。
2.4 文件系统实现
文件系统是操作系统中管理文件的重要组件,它负责文件的存储、检索和更新。本文将简要介绍文件系统实现中的几个关键部分,包括数据结构、磁盘布局、空间管理、文件操作、目录管理和缓存机制。
2.4.1 数据结构
文件系统使用多种数据结构来管理文件和存储空间:
超级块(Superblock):存储文件系统的元数据,如大小、块数量、空闲块信息等。
超级块是文件系统的重要组成部分,通常有备份以防止损坏。
在挂载文件系统时,操作系统将首先读取超级块,初始化各种参数,然后将该 卷添加到文件系统树中。
inode:存储文件的元数据(如权限、大小、时间戳)和数据块指针。每个文件都有一个唯一的inode号,通过它可以直接找到文件的数据块。
这里有个问题:inode 中有一个或多个直接指针(磁盘地址)。每个指针指向属于该文件的一个磁盘块。在如果这个文件很大,占用数据块很多的情况下,指针也会非常多。
解决办法是引入间接指针(如双重间接指针、三重间接指针),该指针指的是一个包含间接块指针的块,每个间接块都包含指向数据块的指针。将多级间接指针混合使用构成一个不平衡树,这种不平衡树被称为指向文件块的多级索引(multi-level index)方法。使用少量的直接指针(12 是一个典型的数字),inode 可以直接指向 48KB 的数据,需要一个 (或多个)间接块来处理较大的文件。
目录项(Directory Entry):记录文件名与inode的对应关系。目录本身也是一个文件,包含多个目录项。
数据块(Data Block):实际存储文件内容的地方。
2.4.2 磁盘布局
文件系统在磁盘上划分为多个区域:
引导块:存储系统启动代码。
超级块:存储文件系统的元数据。
inode表:存储所有inode。
数据块:存储文件数据。
2.4.3 空间管理
空间管理通过以下方式实现:
空闲块管理:使用位图或空闲链表来跟踪和分配空闲块。位图是一个位数组,每个位代表一个块的使用情况。
inode管理:类似地,使用位图或链表管理空闲inode。
2.4.4 文件操作
文件系统实现中,理解文件和目录在磁盘上的存储方式后,接下来需要了解读取或写入文件的操作过程。这个过程涉及多个步骤,包括路径解析、inode查找、数据块读写等。
读取文件操作
假设我们有一个文件 /foo/bar
,大小为4KB(即1个块),并且文件系统已经挂载,超级块在内存中。
open("/foo/bar", O_RDONLY) 调用:
路径解析:从根目录
/
开始,解析路径名。读取根目录的 inode:根目录的 inode 号通常是已知的(如 inode 2)。
读取根目录数据块:查找目录项
foo
,获取其 inode 号(假设为 44)。读取 foo 的 inode:查找目录项
bar
,获取其 inode 号。读取 bar 的 inode:进行权限检查,分配文件描述符。
read() 调用:
查找数据块:根据 bar 的 inode 指向的数据块位置读取数据。
更新 inode:更新 bar 的 inode 的最后访问时间。
更新文件偏移量:为下一次读取做准备。
写入文件操作
写入文件时,除了读取操作的步骤外,还需要进行块的分配和更新操作。
open() 调用:
与读取操作类似,进行路径解析和 inode 查找。
write() 调用:
块分配:如果需要新的数据块,读取数据位图,分配块,更新位图。
更新 inode:更新 bar 的 inode,记录新块的位置。
写入数据块:将数据写入分配的块。
close() 调用:
释放文件描述符:关闭文件,释放资源。
创建文件操作
创建文件 /foo/bar
并写入3个块的过程更为复杂,涉及更多的 I/O 操作。
创建文件:
分配 inode:读取 inode 位图,分配一个新的 inode,更新位图。
更新目录:在目录中添加新文件的目录项,可能需要分配新的目录块。
初始化 inode:写入新的 inode,设置文件属性。
写入数据:
分配数据块:为每个数据块重复分配块、更新位图和 inode 的过程。
写入数据:将数据写入分配的块。
2.4.5 目录管理
目录结构:目录实现为包含目录项的文件,每个目录项对应一个文件或子目录的inode。
路径解析:从根目录开始,逐级查找目录项,定位目标文件或目录。
2.4.6 缓存机制
块缓存:缓存常用数据块,减少磁盘访问。
inode缓存:缓存常用inode,加速元数据访问。缓存机制通常采用LRU(最近最少使用)策略来管理。
现代系统采用动态划分(dynamic partitioning)方法。具体来说,许多现代操作系统将虚拟内存页面和文件系统页面集成到统一页面缓存中(unified page cache)。 通过这种方式,可以在虚拟内存和文件系统之间更灵活地分配内存。
某些数据库系统通过调用 fsync(),使用绕过缓存的直接 I/O接口,或者使用原始磁盘(raw disk)接口并完全避免使用文件系统。
参考:
https://itanken.github.io/ostep-chinese/
小林 Coding