在进入持久性部分的主要学习之前,我们先介绍输入/输出(I/O)设备的概念,并且了解一下操作系统如何与它们进行交互。
I/O设备对于计算机系统很重要。设想一个程序没有任何输入(即输出是固定的),或者程序没有任何输出(那为什么要运行它)。显而易见,为了让计算机系统更有趣,输入和输出是都是必不可少的。因此,我们的关键问题是:
“如何将I/O集成到计算机系统中,其中的一般机制是什么?如何让它们更高效?”
系统架构
在开始讨论之前,我们先看一个典型的架构。其中,CPU通过内存总线(memory bus)或者互连电缆连接到内存。图像或者其它高性能I/O设备使用常规的I/O总线(I/O bus)连接到系统,在很多现代系统会使用PCI或它的衍生形式。最底部的是外围总线(peripheral bus),比如SCSI、SATA或者USB。它们连接的是速度比较慢的设备,包括键盘,鼠标及其它类似设备。
你可能会问:“为什么要用这种形式的分层架构?”
简单来说:因为物理布局及造价成本,越快的总线越短,且离CPU更近,所以高性能的内存总线没有足够的空间来连接太多的其它设备。其次,在工程上高性能的总线造价非常高。所以,系统的设计采用了这种分层的方式,这样才可以让要求高性能的设备(如显卡)离CPU更近一些,低性能的设备离CPU远一些。将磁盘及其它低速设备连接到外围总线还有很多好处,其中较为突出的好处就是我们可以在外围总线上连接大量的设备。
标准设备
我们现在来看一个标准的外接设备(不是真实存在的),通过它可以帮助我们更好地理解设备交互机制。下图中可以看到,这个设备分为对外的接口和内部的具体实现两部分。和软件一样,硬件设备也需要对外暴露一些接口,让系统软件来控制它操作。因此,所有的设备都有自己的特定接口以及典型的交互协议。
第二部分是它的内部结构(internal structure)。这里面是该设备的具体实现,一个非常简单的设备通常用一个或几个芯片来实现它们的功能。更复杂的设备会包含简单的CPU,一些通用内存,设备相关的特定芯片,来完成它们的工作。例如,现代RAID控制前通常包含成百上千行固件(firmware, 即硬件设备中的软件),以实现其功能。
RAID(Redundant Array of Independent Disks,独立磁盘冗余阵列)是一种数据存储技术,通过将多个硬盘组合在一起形成一个逻辑卷,从而提供更高的数据可靠性和性能。
RAID控制器是一种硬件设备或者软件程序,用于管理RAID阵列的创建、配置和维护。在硬件RAID阵列中,RAID控制器通常是一块专用的PCI-E扩展卡,安装在计算机的主板上,它通过内部的芯片、固件和软件实现RAID阵列的管理和控制。在软件RAID阵列中,RAID控制器则是一个运行在操作系统中的软件程序。
RAID控制器可以提供多种RAID级别的支持,例如RAID 0、RAID 1、RAID 5、RAID 6等。不同的RAID级别有不同的数据保护和性能特点,RAID控制器可以根据用户的需求和硬件条件来选择合适的RAID级别。
总之,RAID控制器是一种重要的硬件设备或软件程序,它可以帮助用户实现数据的冗余和高性能存储,保护用户的数据安全和可靠性。
标准协议
在刚刚的图中,我们可以看到,一个简化的设备接口包含3个寄存器:一个状态(status)寄存器,可以用来读取并查看设备的当前状态;一个命令(command)寄存器,用于通知设备执行某个具体任务;一个数据(data)寄存器,将数据传给设备或从设备接收数据。通过读写这些寄存器,操作系统可以控制设备的行为。
我们来描述操作系统与该设备的典型交互,从而让设备为它做一些事情,协议如下:
这个协议很简单
- 操作系统先轮询的检查设备的状态。这个被称为轮询(polling)设备。
- 操作系统将数据给到设备的数据寄存器。例如,想象这个设备是硬盘,需要多次写入操作,将一个磁盘块(如4KB)传递给设备。如果主CPU参与数据移动(就像这个数据协议一样),我们就称之为编程的I/O(programmend I/O, PIO)。
- 操作系统将命令写入到命令寄存器;这样设备就知道数据准备好了,它就可以开始执行命令。
- 随后,操作系统继续轮询设备的状态,以检查写入是否成功,因为状态码有可能会是失败。
这个简单协议的好处是足够简单并且有效。但是难免会有一些低效和不方便。我们注意到这个协议存在的第一个问题就是轮询的过程比较低效,在等待设备执行完成命令时浪费大量的CPU时间(CPU要等待设备执行完的状态),如果此时操作系统可以切换到其它已就绪的进程,就可以大大提高CPU的利用率。
如何减少轮询开销
操作系统检查设备的状态需要频繁轮询,如何避免频繁轮询,从而降低管理设备的CPU开销?
利用中断减少CPU开销
在多年前,工程师就发明了我们现在很常见的中断(interrupt),大概是60年代的时候就诞生了。出现中断之后,CPU不再需要一直轮询设备,而是向设备发出一个请求,然后对应的进程可以进入睡眠,CPU可以切换到其它任务去执行。当设备完成了自身操作之后,会抛出一个硬件中断,中断控制器会处理这个中断(如8259中断控制器)。随后会读取对应的中断向量表,执行对应的中断处理程序。中断处理程序是一小段操作系统代码,他会结束之前的请求(比如从设备读取到了数据或者错误码)并且唤醒等待 I/O 的进程继续执行。
因此,中断信号还未发生的时候允许计算与I/O重叠(overlap),这是提高CPU利用率的关键。下面的时间线展示了这一点:
其中,进程1在CPU上运行一段事件(对应CPU那一行上重复的1),然后发出一个读取数据的I/O请求给磁盘。如果没有中断,那么操作系统就会简单自旋,不断轮询设备状态,直到设备完成I/O(对应其中的p)。当设备完成请求的操作后,进程1又可以继续运行。
如果我们利用中断并允许重叠,操作系统就可以在等待磁盘操作时做其它事情:
在这个例子中,在磁盘处理进程1的请求时,操作系统在CPU上允许进程2。磁盘处理完成后,触发一个中断,然后操作系统唤醒进程1继续允许。这样,在这段时间,无论CPU还是磁盘都可以有效地利用。
但是要注意,中断并不是在所有场景都是最优解。假如有一个非常高性能的设备,它处理请求很快:通常CPU第一次轮询时就可以返回结果。此时如果使用中断,反而会使系统变慢:切换到其它进程,处理中断,再切换回之前的进程代价不小。
因此,如果设备非常快,那么使用轮询反而更好。如果设备比较慢,那么采用中断则更好。如果设备的速度未知,或者时快时慢,可以考虑混合(hybrid)策略,先尝试轮询一小段时间,如果设备没有完成操作,此时再使用中断。这种两阶段(two-phased)的办法可以实现两种方法的好处。
中断并非总是比较好
尽管中断可以做到计算与I/O的重叠,但这仅在慢速设备上有意义。否则,额外的中断处理和上下文切换的代价反而会超过其收益。
另外,如果短时间内出现大量的中断,可能会使得系统过载并且引发活锁。这种情况下,轮询的方式可以再操作系统自身的调度上提供更多的控制,反而更有效。
对于网络的场景来说,中断也是不太适用的。试想一下,网络端收到大量数据包,如果每一个包都发生一次中断,那么有可能会导致操作系统发生活锁(livelock),即不断处理中断而无法处理层的请求(频繁处理中断)。例如,假设一个Web服务器因为“点杠效应”而突然承受很重的负载(突然并发变高了)。这种情况下,偶尔使用轮询的方式可以更好地控制系统的行为,并允许Web服务器先服务一些用户请求,再回去检查网卡设备是否有更多的数据到达。具体使用中断或轮询的选择应该根据实际情况进行综合考虑,选择最优的处理方式。所以一般会轮询和中断结合使用。
还有一些场景适合使用轮询,如一个室内温度显示设备。则可以不断的轮询获取最新的温度信息。因为这个设备的事情足够简单,除此显示实时温度之外,没有其它工作需要做。
对于中断,还会有一些优化,就是合并(coalescing)。可以通过配置中断的延迟时间来实现。设备在准备抛出中断时一般会等待一会,在此期间,如果有其它中断也准备好了,这多个中断可以并成一次发出(中断依旧是多个不同的,只是发出的通知是一次)。从而降低处理器频繁处理中断的代价。当然,等待太长会增加请求的延迟,这是系统中常见的折中。参见Ahmad 等人的文章[A+11],有精彩的总结。
利用DMA进行更高效的数据传送
我们已经知道,如果使用编程的I/O将一大块数据传给设备,CPU又会因为琐碎的任务而变得负担更重,浪费了很多时间和算力,其实这时候用来运行其它线程会更好。下图可以说明问题
进程1在运行时需要往磁盘写入一些数据,所以它开始进行I/O操作,将数据从内存拷贝到磁盘(图中C的标记)。拷贝结束后,CPU才可以处理其它请求
如何减少PIO(程序I/O)的开销
使用PIO的方式,CPU的时间会浪费在向设备传输数据或从设备传出数据的过程中。如何才能分离这项工作,从而提高CPU的利用率?
解决方案就是使用直接内存访问(Direct Memory Access, DMA)。DMA引擎是系统中一个特殊设备,它可以协调完成内存和设备的数据传递,不需要CPU的参与。
为了让DMA完成工作,操作系统会通过编程告诉DMA数据所在的地址,数据的大小,以及目标设备的地址。在此之后,操作系统就可以处理其它请求了。当DMA的任务完成后,它会给操作系统抛出一个中断告诉操作系统数据传输以及完成。
DMA(直接内存访问)
通常会在内存中保留一部分空间用来缓冲数据。DMA是一种高效的数据传输方式,可以直接在设备和内存之间传输数据,而无需CPU的干预。为了避免DMA在内存中写入数据时发生覆盖,通常会为DMA保留一部分空间。
这样可以确保DMA在传输数据时不会影响其他内存区域的数据,从而提高系统的稳定性和可靠性。具体来说,DMA缓冲区的大小取决于系统的设计和需求,通常会根据设备的数据传输速率、内存容量和带宽等因素来确定。
那么在使用DMA之后,工作的时间线就会如下图所示:
从时间线可以看出,数据的拷贝工作都是DMA控制器完成的。因为CPU在此时是空闲的,所以操作系统可以让CPU做一些其它事情,比如调度进程2到CPU来执行。因此进程2在进程1再次运行之前可以使用更多的CPU。
举个形象一点的例子,DMA有点类似于外卖员
在没有外卖员的时候,餐厅老板(CPU)需要自己去把外卖送到顾客手中。有了外卖员之后。老板可以让外卖员来做这份事情,外卖员在送达之后只需要和老板说一声就OK了。
设备交互方法
我们现在已经了解了I/O的效率问题,现在我们可以来了解一下,操作系统究竟该如何与设备进行通信。关键问题如下
硬件如何与设备通信?是否需要一些明确的指令?或者其它的方式?
随着设备的不断发展,现在一共主要有两种方式来实现设备的交互。第一种方式相对比较老一点(在IBM主机中使用了多年),就是用明确的I/O指令。这些指令规定了操作系统将数据发送到设备寄存器的方法,从而允许构造上文提到的协议。
例如在x86上,in和out指令可以用来与设备进行交互。当需要发送数据给设备时,调用者指定一个存入了数据的特定寄存器及一个代表设备的特定端口。执行这个指令就可以实现期望的行为。
这些指令通常都是特权指令(privileged)。操作系统是唯一可以直接与设备交互的实体。假象一下,如果任意程序都可以直接读写磁盘;那就会完全混乱掉,因为任何用户程序都可以利用这个漏洞来取得计算机的全部控制权。
第二种方法是内存映射I/O(memory-mapped I/O)。通过这种方式,硬件将设备寄存器作为内存地址提供。当需要访问设备寄存器时,操作系统装载(读取)或者写入(写入)到该内存地址;然后硬件会装载/存入转移到设备上,而不是物理内存。
这两种方法没有一种具有极大的优势。内存映射I/O的好处是不需要引入新指令来实现设备交互,但两种方法今天都还在使用。
纳入操作系统:设备驱动程序
最后一个问题:每个设备都有非常具体的接口,如果将它们纳入到操作系统,而我们希望操作系统尽可能通用。例如文件系统,我们希望开发一个文件系统可以工作在SCSI硬盘,IDE硬盘,USB钥匙串设备等设备之上,并且希望这个文件系统不那么清楚对这些不用设备发出的读写请求的全部细节。因此,我们的关键问题是:
如何实现一个设备无关的操作系统?
如果保持操作系统的大部分与设备无关,从而对操作系统的主要子系统隐藏设备的细节?
我们知道,所有计算机科学的问题都可以通过添加一个中间层来实现。这也是使用抽象(abstraction)来实现的。对于我们来说,我们可以在操作系统和设备中间添加一层来解决。这一层会实现与设备进行交互的具体内容。我们把这部分软件称为设备驱动程序(device driver),所有设备交互的细节都封装在其中。
我们来看一下Linux文件系统栈,理解抽象技术如何应用于操作系统的设计和实现。下图粗略的展示了Liunx软件的组织方式。可以看出,文件系统(当然也包括其之上的应用程序)完全不清楚它使用的是何类型的磁盘。应用程序只需要简单地向通用块设备层发送读写请求即可,块设备层会将这些请求路由给对应的设备驱动,然后设备驱动来完成真正的底层操作。尽管比较简单,但下图中可以看出,对于具体与磁盘交互的细节是对操作系统进行了隐藏的。
注意,这些封装也有不足的地方。例如,如果有一个设备提供了很多特殊功能,但是为了兼容大多数操作系统它不得不提供一个通用的接口,这就使得自身的特殊功能无法使用。这种情况在使用SCSI设备的Linux中就发生了。SCSI设备提供非常丰富的错误报告,但其它块(如ATA/IDE)只提供非常简单的报错处理,这样上层的所有软件只能在出错时收到一个通用的EIO(一般IO错误)错误码,SCSI可能提供的所有附加信息都不能报告给文件系统。
有趣的是,因为所有需要插入系统的设备都需要安装对应的驱动程序,所以久而久之,驱动程序的代码在整个内核代码的占比越来越大。查看Linux内核代码会发现,超过70%的代码都是各种驱动程序。在Windows中,这样的比例同样很高。因此,如果有人和你说操作系统包含上百万行代码,实际的意思是包含上百万行驱动程序代码。当然,任何安装操作系统的驱动程序,大部分默认都不是激活状态(只有一小部分设备是在系统刚开启时就需要连接)。更令人沮丧的是,因为驱动程序的开发者大部分都是“业余的”(不是全职内核开发者),所以他们更容易写出缺陷,因此是内核崩溃的主要贡献者。
案例研究:简单的IDE磁盘驱动程序
为了更深入的理解设备驱动,我们快速来看一个真实的设备-IDE磁盘驱动程序。我们总结了协议,我们也会看看xv6源码中一个简单的,能工作的IDE驱动程序实现。
IDE硬盘暴露给操作系统的接口比较简单,包含4种类型的寄存器,即控制、命令块、状态和错误。在x86上,利用I/O指令in和out向特定的I/O地址(如下面的0x3F6)读取或者写入时,可以访问这些寄存器,如下是IDE的接口:其中LBA是逻辑块地址(Logical Block Address, LBA)
下面是与设备交互的简单协议,假设它已经初始化了
- 等待驱动就绪。读取状态寄存器
((int r = inb(0x1f7)) & IDE_BSY) || !(r & IDE_DRDY)
,(0X1F7),直到驱动READY而非忙碌。
- 向命令寄存器写入参数。写入扇区数,待访问扇区对应的逻辑块地址(LBA),并将驱动编号(mater=0x00,slave=0x10,因为IDE允许接入两个硬盘)写入命令寄存器(0x1f2 - 0x1F6)
- 开启I/O。发送读写命令到命令寄存器。向命令寄存器(0X1F7)中写入READ-WRITE命令。
- 数据传送(针对写请求):一直等待驱动状态为READY和DRQ(驱动请求数据),向数据端口写入数据。
- 中断处理。在最简单的情况下,每个扇区的数据穿偶是那个结束后都会触发一次中断处理程序。较复杂的方式支持批处理,全部数据传送结束后才会触发一次中断数据。
- 错误处理。在每次操作之后读取状态寄存器。如果ERROR位被置位,可以读取错误寄存器来获取详细信息。
该协议的大部分可以在xv6的IDE驱动程序中看到,它通过4个主要函数来实现。
第一个是ide_rw(),它会将一个请求加入队列(如果前面还有请求未处理完成),或者直接将请求发送到磁盘(通过ide_start_request())。不论哪种情况,调用进程进入睡眠状态,等待请求处理完成。
第二个是ide_start_request(),他会将请求发送到磁盘(在写请求时,可能是发送数据)。此时x86的in或out指令会被调用,以读取或写入设备寄存器。
在请求发起之前,会用到第三个函数 ide_wait_ready(),来确保驱动处于就绪状态。
最后当发生中断时,ide_intr()会被调用。他会从设备中读取数据(如果是读请求),并且在结束后唤醒等待的进程,如果此时在队列中还有别的未处理的请求,则调用ide_start_request()接着处理下一个I/O请求。
历史记录
在结束之前,我们简述一下这些基本思想的由来。如果你想了解更多内容,可以阅读
Smotherman 的出色总结[S08]。
中断的思想很古老,存在于最早的机器之中。例如,20 世纪 50 年代的 UNIVAC 上就有某种形式的中断向量,虽然无法确定具体是哪一年出现的[S08]。遗憾的是,即使现在还是计算机诞生的初期,我们就开始丢失了起缘的历史记录。
关于什么机器第一个使用 DMA 技术也有争论。Knuth 和一些人认为是 DYSEAC(一种“移动”计算机,当时意味着可以用拖车运输它),而另外一些人则认为是 IBM SAGE[S08]。无论如何,在 20 世纪 50 年代中期,就有系统的 I/O 设备可以直接和内存交互,并在完成后中断 CPU。
这段历史比较难追溯,因为相关发明都与真实的、有时不太出名的机器联系在一起。例如,有些人认为 Lincoln Labs TX-2 是第一个拥有向量中断的机器[S08],但这无法确定。
因为这些技术思想相对明显(自然)(在等待缓慢的 I/O 操作时让 CPU 去做其他事情,这种想法不需要爱因斯坦式的飞跃),也许我们关注“谁第一”是误入歧途。肯定明确的是:在人们构建早期的机器系统时,I/O 支持是必需的。中断、DMA 及相关思想都是在快速 CPU 和慢速设备之间权衡的结果。如果你处于那个时代,可能也会有同样的想法。(他说,如果我在那个年代,我也会有同样的想法)