深入理解磁盘I/O

[TOC]

I/O的源起

所谓“I/O”,就是“Input/Output”(输入/输出)的意思。从广义上讲,任何输入输出都可以叫做I/O。

例如,声卡可以输入输出声音,键盘控制器可以输入输出字符,网卡可以输入输出数据包,硬盘可以输入输出数据块,这些都是I/O。声卡、键盘、网卡、硬盘,属于“外设”(Peripheral),所以它们的I/O是属于外设的I/O。但“外设I/O”,往往是现代的使用者对I/O的理解,这是一种偏于直观感受的理解,也是范围较窄的理解。

在做存储的人的眼中,I/O往往指的是磁盘I/O,也就是对硬盘的读写叫做I/O。当然这是一种比“外设I/O”更加狭义的理解,实际上I/O的范畴应该更加广泛。

I/O的起源,应该从CPU开始。在计算机刚刚发展起来的时候,外设并没有那么丰富,许多现在常见的外设那时根本就不存在。一个计算机系统,除了CPU和MEMORY,其他的部件并不多。那时候说到“I/O”,指的往往是“CPU管脚上的输入输出”。

时至今日,这一点从未改变:所有的I/O,都源起于CPU管脚上的信号。只不过,由于现在的外设种类丰富,又加入了许多软件、硬件中间层,所以使得I/O的“路径”变得很长。很长的I/O路径,使得这一行为变得相对复杂难懂。

由于计算机产业的专业化趋势,从业者只需要理解其中一段的I/O旅程,所以很多人选择了“狭义”地理解I/O。其实这一点无可厚非,因为对于工作而言,专精于一点即可。但对于任何事情都喜欢追本溯源的人,就必须从I/O的源起开始,理解I/O路径上的所有东西。这种探索的精神也是成为一个“全栈”工程师的必要条件。

“原始I/O” 和寻址

I/O的源起是从CPU开始,这一点一直到现在都未改变。CPU与外界的所有交互,都叫做I/O。I/O的本质,就是把外界的数据输入给CPU,或者让CPU向外界输出数据。

最初的I/O是非常直接的:信号从CPU的某个输出管脚发出,然后直接进入外设;信号也可以从外设发出,然后直接进入CPU的某个输入管脚。所谓“信号”,其实就是指的管脚上的高低电平,以及上升沿和下降沿,这些电信号就是CPU与外面通信的基础。

原始I/O

CPU由电压驱动,人为地将不同的电压定义为“高电平”或“低电平”(实际这也并非完全人为定义,而是跟逻辑门的驱动电压有关:足以使得逻辑门打开的电压叫做高电平,低于逻辑门要求的电压叫做低电平)。例如3.5V~5V为高电平,接近0V为低电平。

CPU和外设都可以检测到这些电信号的高低电平,通过人为地赋予它们含义,使得电信号成为一种有意义的编码:例如高电平代表1,低电平代表0,那么一连串的高低电平按顺序组合起来,就形成了由0和1组成的一串二进制数。

CPU可以通过编程来控制管脚上的电平高低,这种能力是I/O的基础。通过机器指令(Instructions),控制CPU内部寄存器的值为0或1,可以相应地使得对应这个寄存器的管脚上呈现高电平或者低电平。“计算机程序”,也就是通过不断修改内部寄存器的值,来输出一串0和1。这其实就是输出的本质。

相应地,CPU还可以检查其管脚的电平高低,如果某个输入管脚上呈现高电平或低电平,那么CPU内部对应的寄存器也会相应地被赋值为1或0。然后程序通过指令就可以不停检查寄存器的值,获取到一连串的0和1的编码。这就是输入的本质。

CPU内部有寄存器,每个寄存器都有名字:例如8086的AX, BX, CX, DX, BX, BP, SI, DI, SS, SP 等等。这些内部寄存器可以通过指令来直接访问,例如:“MOV AX, 800H” 是一条汇编指令,编译为机器指令之后就可以给AX寄存器赋值。

除了可以直接访问的内部寄存器,在CPU外部还有内存和外设。其中内存是不可或缺的部件,因为光靠CPU的寄存器是无法满足大量的计算需求的,因此计算机使用内存扩展了CPU的存储空间。

CPU从一开始设计,就将内存考虑进去,有些指令是可以直接访问内存的。例如8086计算机:“MOV AX, [0]”是一条汇编指令,编译为机器指令之后就可以将内存中某个位置的数据复制到CPU内部的AX寄存器,这就是最基本的一次I/O了。

这一次I/O只需要一条指令,看上去很简单,但是从物理上需要20个CPU管脚参与(8086),CPU和内存条都需要在几个机器周期内检查这20条管脚(或者说地址/数据总线)上的电平信号。以获取一个20位、16位的二进制编码(8086的例子,地址20位,数据16位)。

访问内存就是CPU最熟悉,也最自然的与外界打交道的方法,也是最自然的输入输出的方法。因为CPU从一开始就设计了专门的指令来访问内存,CPU执行的下一条指令也只能从内存中读取,内存从一开始就是体系架构的一部分。所以,只要理解了CPU访问内存的方法,其他类型的I/O就都容易理解了,因为它们都是从这里开始的。

内存I/O

为了方便说明,后面默认都以8086为例,其他架构的机器都有差不多的过程。我们要通过8086访问内存的过程,来说明I/O的本质。虽然后来经过演变,加入了许多中间层,I/O路径变得很长很复杂,但不管经过了多少层的路径,I/O的本质并没有发生变化。而后续会看到,这些“中间层”的设计,都会尽量“适配”内存I/O的模式。

8086 CPU访问内存的过程大概如下:

  1. 内存部件中包含了多个字节,每个字节(内存单元)都进行了编码,也就是说每个内存单元都有一个地址
  2. CPU和内存都连接到一个“地址总线”和一个“数据总线上”(还有同步所需的控制总线,这里无需提及)
  3. CPU先通过20个“地址管脚”发送一个“地址”(一个20位的二进制信号),从地址总线(20位)传输到内存部件
  4. 内存部件通过地址,找到对应单元的内容(数据),通过“数据总线”(16位)传输给CPU
  5. CPU通过16个“数据管脚”(8086的数据与地址管脚其实是公用其中16个管脚)检测到数据(一个16位的二进制信号)

CPU访问内存的模式,是自然的,是内置的,高效的。其中的关键点,是“地址”。

CPU通过内存地址,找到对应的数据,这个过程叫做“寻址”。而对于CPU I/O而言,“寻址”却不仅仅局限于对内存的访问。实际上,在地址总线上,不仅仅挂了内存,还有其他部件,比如BIOS,比如显卡,声卡一类的外设。CPU与这些外设交互,都是通过寻址。

内存和外设,内部都有各自的存储单元。内存本身就是存储器,显卡有显存,声卡也有缓存,CPU通过访问这些存储单元,就控制了这些设备。

所以总结下来,CPU与外界交互,其实都是通过访问外部设备的存储单元;而访问存储单元,都是通过“寻址”。

统一编址和寻址

CPU通过内存地址去访问内存中的数据。对于其他外设,CPU也采用了类似的方法去访问它们的内部存储器,以达到控制其行为的目的。例如显卡,CPU往显卡中的显存写数据,其实就是在控制显卡输出和屏幕的显示。

早期的8086有20条地址线,就可以有220=1M种组合,也就是说可以代表1百万个地址。这就是8086的“寻址空间”,最大可以到1M个字节。内存的空间是有限的,在早期往往实际的内存是不足1MB的。所以只有一部分的地址,被用来寻址内存,而剩余的地址则被用来寻址一些外设。

8086 CPU通过总线将许多外设连接起来,每个外设都有自己的ROM或RAM或者两者都有。CPU把所有外设的存储器进行统一的编址(映射),把他们各自零散的存储器空间组织到一个连续的地址空间里,看上去就像在一个大逻辑存储器里一样。

image.png

0~9FFFFH的640KB空间:主RAM地址空间,由内存条提供。

A0000H~BFFFFH的128KB空间:显存地址空间,由显卡提供。

其中B8000H~BFFFFH,是文本模式下的显存地址空间
C0000H~FFFFFH的256KB空间:各个ROM的地址空间,主板和各卡提供。

其中F0000H~FFFFFH为BIOS空间

这种编址的方式,使得访问外设就和访问内存一样,都通过统一的方法即可访问。例如:

  • MOV指令访问地址0X9FFFF0,其实是在访问内存
  • CPU将0x9FFFF0通过地址总线发送出去
  • 地址总线上的所有设备都收到这个地址,并判断自己的地址范围
  • 只有内存发现这个地址属于自己的地址范围,所以内存部件知道这属于它的责任范围,所以只有内存会对0x9FFFF0这个地址请求进行响应
  • 扩展开来,CPU还是执行MOV指令,但如果访问地址0xA0000,实际是在访问显卡中的显存。这样CPU就控制显卡在显示器上显示内容。

所以“寻址”就是最基本的I/O模式。8086将所有外设和内存都统一编址,然后通过地址寻址,对它们一视同仁,使用同样的方法去操作它们。CPU与外界交互,归根结底都是往某个地址发送信号。了解了这种最基础的I/O模式,就了解了I/O的源起,这对复杂I/O的理解,特别是软件中间层的理解有非常大的帮助。实际上可以看到,所有的中间层都极力在配合在这种最基础的I/O模式。

外设 I/O

如果CPU可以将系统中所有设备的每个字节都进行统一的编址,那么这是最理想的情况。因为这样可以让CPU用寻址的方式访问所有的设备。

可惜,现实却并非如此美好,CPU的寻址空间看上去很大,其实还是远远不满足数据存储的需要。就以8086为例,它有20条地址线,可以代表 220 个存储单元,总共1MB的寻址空间。但早在8086时代,一张5寸软盘的容量就已经可以达到1.2MB,一张3.5寸高密度软盘的容量可以达到1.44MB。所以,将软盘的每个字节都编址到8086 1MB的寻址空间内是不可能的。

后来的80386,有32位地址总线,寻址空间达到4GB。4GB的寻址空间看上去非常大,但同时数据量的增加和存储设备的发展也非常迅速。386时代的硬盘和光盘的容量也越来越大,几个GB的硬盘开始流行,因此仍然不可能将所有的字节都进行编码。

所以当大容量存储设备例如硬盘、软盘被接入系统后,对它们的访问就无法再用编址寻址的方法,而必须考虑其他的手段来访问这些设备了。

请注意,这是一种遗憾,因为无法在CPU的地址空间里容纳所有的存储单元,不得不使用其他手段去访问大容量存储器。最理想的I/O模式,仍然是通过地址去读写数据,就像访问内存一样去访问所有的设备。后续的软件层例如操作系统,都是想通过一些手段极力回归这种简洁的I/O模式。

统一编址被打破:外设的读写通过“端口”

CPU与计算机的外围设备通信,存在两个问题:其中一个是之前提及的无法统一编址的问题,第二个问题是工作速度不匹配的问题。CPU和内存,都是工作在很高的时钟频率下,而很多外设却工作在很低的频率。CPU和外设的通信,还要经过一个频率转换和适配的过程。

为了解决这两个问题,当外设接入计算机系统时,它们并不是直接接入到系统总线上,而是接入到一个I/O接口上。

I/O 设备接入计算机时,需要通过 I/O 接口进行适配。 I/O 接口的作用主要有两个:

  1. 将外设接入系统的总线
  2. 将总线的速度和外设的速度同步起来

I/O 接口可以是一个电路板,也可能是一块小芯片,这取决于它有多复杂。无论如何,它是一个典型的变换器,或者说是一个翻译器。在一边,它按处理器的信号规程工作,负责把处理器的信号转换成外围设备能接受的另一种信号;在另一边,它也做同样的工作,把外围设备的信号变换成处理器可以接受的形式。

计算机有许多外设,每种外设就得有对应的 I/O 接口。它们和 CPU 之间需要通信。为了协调各种外设和 CPU 的通信,在 外设和 CPU 之间设立了一个叫做 I/O Controller Hub 的芯片,也就是 ICH,所谓的南桥芯片。

image.png

如图所示,处理器通过局部总线连接到 ICH 内部的处理接口电路。在 ICH 内部,又通过总线与各个 I/O 接口相连。

在 ICH 内部,集成了一些常规的外围设备接口,如 USB、PATA(IDE)、SATA、老式总线接口(LPC)、时钟等,这些东西对计算机来说必不可少,故直接集成在 ICH 内。

当处理器想同某个设备说话时,ICH 会接到通知。然后,它负责提供相应的传输通道和其他辅助支持,并命令所有其他无关设备闭嘴。同样,当某个设备要跟处理器说话,情况也是一样。

CPU 和外设之间的通信,通过 I/O 接口电路里的寄存器来进行,这些寄存器叫做 I/O 端口(I/O Port)。

CPU 对外设发命令,读状态, 读数据,写数据,实际就是读写这些端口.

端口在不同的计算机系统中有着不同的实现方式。在一些计算机系统中,端口号是映射到内存地址空间的。比如, 0x00000~0xE0000 是真实的物理内存地址,而 0xE0001~0xFFFFF 是从很多I/O接口那里映射过来的,当访问这部分地址时, 实际上是在访问 I/O 接口.

而在另一些计算机系统中,端口是独立编址的,不和内存发生关系。在这种计算机中,处理器的地址线既连接内存,也连接每一个 I/O 接口。但是,处理器还有一个特殊的引脚 M/IO#,在这里,“#”表示低电平有效。也就是说,当处理器访问内存时,它会让 M/IO# 引脚呈高电平,这里,和内存相关的电路就会打开;相反,如果处理器访问 I/O 端口,那么 M/IO#引脚呈低电平,内存电路被禁止。与此同时,处理器发出的地址和 M/IO#信号一起用于打开个某个 I/O 接口,如果该 I/O 接口分配的端口号与处理器地址相吻合的话。

Intel 处理器,早期是独立编址的,现在既有内存映射的,也有独立编址的。 对于独立编址的端口,所有端口都是统一编号的,比如 0x0001、0x0002、0x0003、...。每个 I/O 接口电路都分配了若干个端口,比如 I/O 接口 A 有 3 个端口,端口号分别是 0x0021~0x0023;I/O 接口 B 需要 5 个端口,端口号分别是 0x0303~0x0307。

在 Intel 的系统中,只允许 65536(十进制数)个端口存在,端口号从 0 到65535(0x0000~0xffff)。因为是独立编址,所以端口的访问不能使用类似于 mov 这样的指令,取而代之的是 in 和 out 指令。

在这里,外设的I/O模式与CPU最熟悉的寻址式I/O模式就发生了分叉。这使得CPU不得不通过另一套指令来访问外设,同时还需要增加特殊的芯片(ICH)来控制外设。而且CPU还必须知道如何与ICH打交道(通过in和out指令)。

CPU通过in和out指令来操作某个端口,内存访问通过CPU寻址,这两种都是I/O,却采用了不同的方法。大容量存储设备打破了统一寻址的I/O模式。

in和out指令

通过in和out读写外设,虽然与直接编址的方法相比显得杂乱一些,但用起来其实也不是那么糟糕:

in指令的格式如下:

in al, dx 或者 in ax, dx

还有两种形式,但是不常用,因为它们是双字节指令,在I/O操作中,当然尽量用上面两种简短的单字节指令

in al, 端口号(立即数) 或者 in ax, 端口号(立即数)

例如: in ax, 0xf0

out指令和in指令是类似的,也是这几种形式,只不过操作数的位置相反。

可以看出in和out指令,都是将数据从某个端口读出到CPU的某个内部寄存器或反之。in和out这是属于比较早期的指令了,现代的计算机一般都不会用CPU直接执行指令的方法来做外设的I/O,而是会通过DMA部件将数据从外设“搬运到”内存中,然后CPU才去读取内存中的数据。这样可以节省CPU的时钟周期,用于别的更需要CPU参与的计算。

但是可以肯定的是不管是现代计算机还是古老的8086,CPU都不能直接从某个内存地址去读取外设的数据,或用内存某个地址去写数据到外设。

以8086架构为例,比如对硬盘的读写,只可以通过in和out指令,不能通过mov指令。因为mov指令是寻址的,in和out指令就是明确针对外设的。

在ICH芯片中,集成了2个SATA接口,即一个主SATA接口一个副SATA接口。每个SATA接口被分配了 8 个端口,主SATA接口的端口号从 0x1f0 到 0x1f7,副SATA接口的端口号从0x170到0x177。这些端口号的分配,是PC的标准内容之一,每个端口都有不同的用途。程序读写硬盘,实际就是通过in和out指令,对这些端口进行输入输出。

以第一个SATA控制器为例,各个端口的用处:

image.png

那么程序编写者在需要读写硬盘的时候,就必须知道上表中的约定,然后用in和out指令来编写程序,读写硬盘。硬盘的读写不是以字节为单位,而是以“扇区”为单位的。这是由硬件特性决定的,磁头一次读取一个扇区更为方便。硬盘读写的基本代码大致如下:

read_hard_disk_0:                        ;从硬盘读取一个逻辑扇区
                                        ;输入:DI:SI=起始逻辑扇区号
                                        ;      DS:BX=目标缓冲区地址
        push ax
        push bx
        push cx
        push dx

        mov dx,0x1f2
        mov al,1
        out dx,al                       ;读取的扇区数

        inc dx                          ;0x1f3
        mov ax,si
        out dx,al                       ;LBA地址7~0

        inc dx                          ;0x1f4
        mov al,ah
        out dx,al                       ;LBA地址15~8

        inc dx                          ;0x1f5
        mov ax,di
        out dx,al                       ;LBA地址23~16

        inc dx                          ;0x1f6
        mov al,0xe0                     ;LBA28模式,主盘
        or al,ah                        ;LBA地址27~24
        out dx,al

        inc dx                          ;0x1f7
        mov al,0x20                     ;读命令
        out dx,al

 .waits:
        in al,dx
        and al,0x88
        cmp al,0x08
        jnz .waits                      ;不忙,且硬盘已准备好数据传输 

        mov cx,256                      ;总共要读取的字数
        mov dx,0x1f0
 .readw:
        in ax,dx
        mov [bx],ax
        add bx,2
        loop .readw

        pop dx
        pop cx
        pop bx
        pop ax

        ret

这样每次读取两个字节,经过CPU的AX寄存器中转,经过256次循环,硬盘中某个扇区的内容(512字节),就被读取到了内存中。

其实,上面这个程序就是最底层的磁盘操作程序。对它稍加改动,可以实现一个磁盘驱动程序。其他的程序如果要读取硬盘,可以直接调用它,给它传入所需的参数例如扇区数,读写模式,目标位置等等,就可以读写硬盘了。这样做的好处是某些重复代码可以不用每次都写到程序里。

无论如何,CPU除了in和out之外的其他指令都只能与寄存器或者寻址空间内的设备打交道。所以外设的数据必须经过寄存器或内存中转,才能被CPU用来计算。从前面的例子也清楚地表明了这一点,硬盘上所有数据,都只能先从端口读入到CPU的寄存器才能被CPU使用。

外设的I/O模式与内存I/O模式不统一,使得CPU的I/O出现了两套指令。这种不统一,并不是最优美的,但这在底层实现上却是无法避免的。因为无论CPU的寻址空间有多大,存储器的容量始终会超越它。另外,通过端口的方式来操作外设,使得外设的扩展性大大增加。

CPU不需要关心外设的细节,只需要通过“端口”这个相对稳定的接口去操作所有的外设,使得外设在接口层面得到了统一。只要能接入“端口”的设备,都可以用in和out指令去操作,所以新的外设只要符合“接口”的规范,都可以被in和out指令读写。这样,就可以几乎无限制地增加外设的种类。例如SATA接口本来是为硬盘设计的,但我们也可以设计一个打印机插入SATA接口,只要这个打印机可以响应in和out指令就行。接口稳定,实现可以不同,这可以说是硬件上的“面向对象”。

内存映射I/O的构想

虽然用端口的方式操作外设具有很大的扩展性,但是它毕竟带入了另一套I/O的方法。并且这套方法与前面所描述的最自然的寻址式I/O模式并不兼容。

那么有没有什么办法可以让CPU用内存寻址式的方法与外设通信呢?答案当然是有,但是这并不完全是通过硬件本身实现的,而是通过在硬件之上又加入了一层软件来实现的。这就是“内存映射I/O”的思路。

本文不打算将现实中的实现细节一一呈现,所有的讨论都是对思路的梳理。所以对于I/O路径上的参与者(软硬件都有),都只会进行大约的描述。主要目的,是要解释现代计算机的I/O路径的由来,以及每个参与者出现的意义以及它们扮演的角色。

理解了这些思路,对后续深入理解每个I/O参与者(例如文件系统,设备驱动,虚拟内存等等)都有很大的益处。

访问内存一样访问外设?

在前面我们说过了,有一种思路,就是将外设的访问与内存的访问方式统一起来,那就是“内存映射I/O”。采用内存映射I/O之后,CPU可以像访问内存一样访问外设。

在“内存映射I/O”的机制下,一条简单的访问内存的指令例如:MOV AX, [800H],可能实际是在读写硬盘。

再说直接一点的话:一条本来用于访问内存的mov指令,可能会触发in或out指令去读写外设。

那么这个效果是如何实现的呢?单靠硬件,虽然做不到这一点,但硬件提供了一些机制,使得“内存映射I/O”成为可能。特别是当80386保护模式和分页机制出现之后,使得地址和内存的管理更加灵活和强大。软件正是利用了硬件的“中断”、“地址转换”以及“分页”机制,在CPU寻址和外设I/O之间插入了一个软件“中间层”。这个中间层,作为一个桥梁,连接了普通的CPU寻址以及外设I/O,使它们统一起来。

“All problems in computer science can be solved by another level of indirection.”

“除了层数太多的问题,所有计算机科学中的问题都可以通过增加一个中间层来得以解决。”

“中间层”,是解决许多问题的思路,不仅限于计算机问题。

这个中间层,就是后来出现在操作系统里的虚拟内存管理子系统(VM,Virtual Memory)。

虚拟内存管理,是基于硬件的分页机制。这个分页机制,使得指令中的地址可以随意被映射。

  • 也就是说,在分页机制下,指令中的地址,不一定是实际的物理地址;
  • 出现在指令中的地址叫做“线性地址”,例如:MOV AX, [800H],其中800H是一个线性地址,它不一定对应着物理内存里DS:800H这个单元;
  • “线性地址”要经过转换,才对应真正的物理地址;
  • 这种转换是通过一套位于内存里的“表格”,叫做“页目录”和“页表”;
  • 页目录和页表由软件维护;
  • 线性地址通过页表被“映射”到了真正的物理地址;
  • 映射关系可能存在,也可能不存在,具体要看页表中有没有对应该地址的项目;
  • 当CPU试图访问某个不存在映射关系的地址(线性地址),会导致异常中断(page fault);
  • 中断会触发中断处理程序被调用,于是软件就有机会为线性地址DS:800H建立一个映射;
  • 中断处理完成后,CPU会自动重新执行之前那条mov指令,这时由于线性地址已经有了映射关系,所以800H这个线性地址是有实际的物理内存与之对应的,这样就可以成功读取到物理内存中的内容了。

这就是分页机制的大概工作原理。虚拟内存管理,就是通过这个机制,来介入CPU的寻址过程,使得CPU可以像访问内存一样读取外设。这个过程就叫做“内存映射I/O”。

通过内存映射来实现I/O,大概有如下的步骤:

  1. 操作系统先将地址空间内的一些地址段与某个外设进行绑定(外设就叫做“后端设备”)
  2. 当CPU第一次试图用mov一类指令访问这个经过绑定的地址段时,指令的原意本来是想读取这个地址所指定的内存单元中的内容
  3. 但硬件会认为这个地址非法,因为此时这个线性地址还没有映射关系,没有与物理内存联系起来
  4. 所以硬件产生一个异常中断(page fault),从而通知操作系统的中断处理程序
  5. 中断处理程序是由操作系统提供,它知道这一段地址是代表的另一个外设中的数据
  6. 所以操作系统先分配出一块物理内存来,将其作为缓存
  7. 然后操作系统调用这个后端设备的设备驱动程序,驱动程序通过类似in指令,将外设的数据先搬运到这块缓存中
  8. 随后为这块物理缓存编址,将其地址指定为mov指令所指定的那个线性地址,并在页表中建立映射关系,这样就将物理内存和线性地址联系起来
  9. 接下来操作系统从中断处理返回,CPU会重新执行触发中断那条mov指令,这次因为线性地址与物理内存已经发生了关联,所以就可以读取到内存中的数据
  10. 而此时内存中的数据,实际是刚刚从外设读取到内存中的数据
  11. 这样,CPU执行了一条mov指令,以为读取的是内存中的数据,实际是从外设读取的数据(当然,最终的确是从内存读取的数据)

这个过程就像你去商店买东西,第一次去老板说缺货,让你回家等,他会去进货。等你第二次去商店时,货已经到了,你就可以直接买到。

虚拟内存管理子系统在内存和外设之间搭建了一条时空隧道,使得内存被映射到外设上,访问内存就等于访问外设。这就是虚拟内存(VM)子系统的功能,如果要把VM管理的实质总结出一句话来概括,那我的总结就是:VM子系统把线性地址和外设映射起来,把物理内存当做Cache,使得读写外设就跟访问内存一样直接。

访问内存一样访问文件-“内存映射文件”

虚拟内存管理的后端设备可以有许多种,可以是磁盘设备,可以是其他外设,还可以是文件系统。实际上,文件系统出现的时间是早于VM子系统的。文件系统是为了帮助人们管理磁盘上的数据,到后来文件系统变得越来越重要,最终形成了UNIX系统“一切皆文件”的局面。

在文件系统出现之前,访问磁盘就只能通过CHS(柱面、磁头、扇区号)来指定要访问的位置。但文件系统出现后,将磁盘上的数据用文件目录树的结构组织起来,大大方便了数据的检索和管理。

为了支持多种文件系统,操作系统增加了“VFS”中间层,VFS也是在VM子系统之前出现的。

前面说过,I/O的本质,就是将外设中的数据输入到CPU,或者从CPU输出数据到外设。文件也可以看做一种特殊的“外设”,因为文件里包含着数据。将文件中的数据读入到CPU,也是一种I/O。

在VM子系统出现之前,对文件的访问是通过“open”,“read”,“write”等系统调用,而文件内容则是先被读入到一个叫做“buffer cache”的缓冲区中进行缓存。VM子系统出现后,文件系统也相应地发生了一些变化。

  • VM系统将内存I/O和外设I/O统一了起来;
  • 而文件系统也增加了相应的接口函数例如“GET_PAGE”,“PUT_PAGE”等,使得文件也可以像外设一样作为VM的后端设备;
  • 这使得CPU可以用访问内存一样的方法去访问文件
  • 这种文件I/O的方式叫做“内存映射文件”
  • 用户层面通过调用“mmap”,来将某一段内存地址与文件关联起来

实际上,在UNIX系统中,大多数的情况都是用文件作为后端设备,因为在UNIX里“一切皆文件”。

所以,通过内存映射技术(VM管理子系统和文件系统),操作系统等于是扩展了CPU的寻址范围,将必须通过in和out指令来操作的外设也用CPU寻址的方式来实现了I/O。

现如今,几乎所有的I/O都是通过内存映射来完成的,哪怕是传统的文件系统的“read”和“write”系统调用,也被纳入了虚拟内存管理的范畴。

I/O的简单过程

在明白了内存映射I/O的基本原理的情况下,我们最后来看看现在操作系统中一次I/O的历程。这里主要是展现大约的流程,而非细节。但过程中不免要涉及到一些操作系统和进程、文件系统、VM子系统相关的术语。

要知道,I/O不仅仅是发生在程序的执行过程中,程序本身从磁盘加载到内存也是需要I/O的。CPU只能执行内存中的指令,不能执行磁盘上的指令。所以程序在被执行之前,必须先加载到内存。

操作系统,就是第一个被加载到内存的程序。操作系统是一个特殊的程序,只有在操作系统加载之后,才会有VM子系统,才会有页目录页表,才会有文件系统和进程。所以我们现在讨论的I/O,是在有操作系统的情况下的I/O。

程序如何被加载-内存映射I/O

在有操作系统的情况下,程序是以文件的形式保存在磁盘中的,执行时是以“进程”的方式被操作系统管理起来的。要执行程序,就必须先由操作系统将它从磁盘中调入内存,然后交给CPU执行。

在执行程序之前,操作系统会为程序准备一个“执行环境”,其中包括安排进程的线性地址空间。VM子系统将进程的用户空间分成多个段,每个段都包含一个或多个物理页,每个段都关联到一个后端存储设备(backing store)。这些段,在VM的术语中叫做“内存对象”。所谓的关联,其实也是一种映射,比如将一个文本段映射到一个可执行文件。

CPU所执行的指令,总是在CS:IP所指向的内存单元中。所以在VM管理之下,操作系统无需将整个可执行文件都读入内存,而只需要将某个线性地址与磁盘上的可执行文件进行映射,然后用jmp指令跳转到那个线性地址(相当于给CS:IP赋值)。当CPU尝试访问CS:IP所指向的指令时,由于此时映射关系还没有建立起来,所以会产生page fault。于是就触发了磁盘读操作,由于后端设备是一个文件,所以由文件系统将可执行文件的对应内容复制到内存中,这样就完成了一次I/O。随后CPU重新执行jmp指令,此时目标地址中的内容就是可执行文件中的指令了。于是程序得以加载和执行。

程序加载的过程就是典型的内存映射I/O。

[图片上传失败...(image-641a11-1597586979899)]

程序读写文件

程序被操作系统调入内存并开始执行后,它本身可能也会读写文件或者进行别的I/O。

  • 如果程序调用了“mmap”来读写文件,那么它是用标准的“内存映射文件”的方法在做文件I/O。
  • 如果程序使用“read”,“write”方法,那么这个文件I/O仍然受VM子系统的约束,只是不像mmap一样是通过内存映射而已。

之所以在有了内存映射机制后仍然保留传统的“read”,“write”方法,是因为虽然内存映射文件有种种好处,但由于历史原因它不能完全替代传统的 read,write 系统调用。其中的最大问题是文件系统元数据的一致性得不到保证。通过内存访问来读写文件,其指令就跟随机访问内存是一样的(不经过文件系统?),属于普通的程序指令(例如 MOV指令),而 read,write 系统调用,则可以通过锁定 inode 进行底层的序列化,保证操作的原子性。

所以,现代的操作系统中,一个I/O经历了用户程序,系统调用,文件系统,虚拟内存,设备驱动,最终才从外设到达内存,被CPU使用。

不管哪种I/O,其本质都是“将数据传输给CPU,或者从CPU传输数据”,而实现I/O本质的最直接方法,就是让CPU能够对所有设备寻址,也就是内存映射I/O。

你可能感兴趣的:(深入理解磁盘I/O)