5.3.3 与设备无关的I/O软件

虽然I/O软件中有一些是设备特定的,但是其他部分I/O软件是与设备无关的。设备驱动程序和与设备无关的软件之间的确切界限依赖于具体系统(和设备),因为对于一些本来应按照与设备无关方式实现的功能,出于效率和其他原因,实际上是由驱动程序来实现的。图5-13所示的功能典型地由与设备无关的软件实现。

阅读 ‧ 电子书库
图 5-13 与设备无关的I/O软件的功能

与设备无关的软件的基本功能是执行对所有设备公共的I/O功能,并且向用户层软件提供一个统一的接口。下面我们将详细介绍上述问题。

1.设备驱动程序的统一接口

广告:个人专属 VPN,独立 IP,无限流量,多机房切换,还可以屏蔽广告和恶意软件,每月最低仅 5 美元

操作系统的一个主要问题是如何使所有I/O设备和驱动程序看起来或多或少是相同的。如果磁盘、打印机、键盘等接口方式都不相同,那么每次在一个新设备出现时,都必须为新设备修改操作系统。必须为每个新设备修改操作系统决不是一个好主意。

设备驱动程序与操作系统其余部分之间的接口是这一问题的一个方面。图5-14a所示为这样一种情形:每个设备驱动程序有不同的与操作系统的接口。这意味着,可供系统调用的驱动程序函数随驱动程序的不同而不同。这可能还意味着,驱动程序所需要的内核函数也是随驱动程序的不同而不同的。综合起来看,这意味着为每个新的驱动程序提供接口都需要大量全新的编程工作。

阅读 ‧ 电子书库
图 5-14 a)没有标准的驱动程序接口;b)具有标准的驱动程序接口

相反,图5-14b所示为一种不同的设计,在这种设计中所有驱动程序具有相同的接口。这样一来,倘若符合驱动程序接口,那么添加一个新的驱动程序就变得容易多了。这还意味着驱动程序的编写人员知道驱动程序的接口应该是什么样子的。实际上,虽然并非所有的设备都是绝对一样的,但是通常只存在少数设备类型,而它们的确大体上是相同的。

这种设计的工作方式如下。对于每一种设备类型,例如磁盘或打印机,操作系统定义一组驱动程序必须支持的函数。对于磁盘而言,这些函数自然地包含读和写,除此之外还包含开启和关闭电源、格式化以及其他与磁盘有关的事情。驱动程序通常包含一张表格,这张表格具有针对这些函数指向驱动程序自身的指针。当驱动程序装载时,操作系统记录下这张函数指针表的地址,所以当操作系统需要调用一个函数时,它可以通过这张表格发出间接调用。这张函数指针表定义了驱动程序与操作系统其余部分之间的接口。给定类型(磁盘、打印机等)的所有设备都必须服从这一要求。

如何给I/O设备命名是统一接口问题的另一个方面。与设备无关的软件要负责把符号化的设备名映射到适当的驱动程序上。例如,在UNIX系统中,像/dev/disk0这样的设备名惟一确定了一个特殊文件的i节点,这个i节点包含了主设备号(major device number),主设备号用于定位相应的驱动程序。i节点还包含次设备号(minor device number),次设备号作为参数传递给驱动程序,用来确定要读或写的具体单元。所有设备都具有主设备号和次设备号,并且所有驱动程序都是通过使用主设备号来选择驱动程序而得到访问。

与设备命名密切相关的是设备保护。系统如何防止无权访问设备的用户访问设备呢?在UNIX和Windows中,设备是作为命名对象出现在文件系统中的,这意味着针对文件的常规的保护规则也适用于I/O设备。系统管理员可以为每一个设备设置适当的访问权限。

2.缓冲

无论对于块设备还是对于字符设备,由于种种原因,缓冲也是一个重要的问题。作为例子,我们考虑一个想要从调制解调器读入数据的进程。让用户进程执行read系统调用并阻塞自己以等待字符的到来,这是对到来的字符进行处理的一种可能的策略。每个字符的到来都将引起中断,中断服务过程负责将字符递交给用户进程并且将其解除阻塞。用户进程把字符放到某个地方之后可以对另一个字符执行读操作并且再次阻塞。这一模型如图5-15a所示。

阅读 ‧ 电子书库
图 5-15 a)无缓冲的输入;b)用户空间中的缓冲;c)内核空间中的缓冲接着复制到用户空间;d)内核空间中的双缓冲

这种处理方式的问题在于:对于每个到来的字符,都必须启动用户进程。对于短暂的数据流量让一个进程运行许多次效率会很低,所以这不是一个良好的设计。

图5-15b所示为一种改进措施。此处,用户进程在用户空间中提供了一个包含n个字符的缓冲区,并且执行读入n个字符的读操作。中断服务过程负责将到来的字符放入该缓冲区中直到缓冲区填满,然后唤醒用户进程。这一方案比前一种方案的效率要高很多,但是它也有一个缺点:当一个字符到来时,如果缓冲区被分页而调出了内存会出现什么问题呢?解决方法是将缓冲区锁定在内存中,但是如果许多进程都在内存中锁定页面,那么可用页面池就会收缩并且系统性能将下降。

另一种方法是在内核空间中创建一个缓冲区并且让中断处理程序将字符放到这个缓冲区中,如图5-15c所示。当该缓冲区被填满的时候,将包含用户缓冲区的页面调入内存(如果需要的话),并且在一次操作中将内核缓冲区的内容复制到用户缓冲区中。这一方法的效率要高很多。

然而,即使这种方案也面临一个问题:正当包含用户缓冲区的页面从磁盘调入内存的时候有新的字符到来,这样会发生什么事情?因为缓冲区已满,所以没有地方放置这些新来的字符。一种解决问题的方法是使用第二个内核缓冲区。第一个缓冲区填满之后,在它被清空之前,使用第二个缓冲区,如图5-15d所示。当第二个缓冲区填满时,就可以将它复制给用户(假设用户已经请求它)。当第二个缓冲区正在复制到用户空间的时候,第一个缓冲区可以用来接收新的字符。以这样的方法,两个缓冲区轮流使用:当一个缓冲区正在被复制到用户空间的时候,另一个缓冲区正在收集新的输入。像这样的缓冲模式称为双缓冲(double buffering)。

广泛使用的另一种形式的缓冲是循环缓冲区(circular buffer)。它由一个内存区域和两个指针组成。一个指针指向下一个空闲的字,新的数据可以放置到此处。另一个指针指向缓冲区中数据的第一个字,该字尚未被取走。在许多情况下,当添加新的数据时(例如刚刚从网络到来),硬件将推进第一个指针,而操作系统在取走并处理数据时推进第二个指针。两个指针都是环绕的,当它们到达顶部时将回到底部。

缓冲对于输出也是十分重要的。例如,对于没有缓冲区的调制解调器,我们考虑采用图5-15b的模型输出是如何实现的。用户进程执行write系统调用以输出n个字符。系统在此刻有两种选择。它可以将用户阻塞直到写完所有字符,但是这样做在低速的电话线上可能花费非常长的时间。它也可以立即将用户释放并且在进行I/O的同时让用户做某些其他计算,但是这会导致一个更为糟糕的问题:用户进程怎样知道输出已经完成并且可以重用缓冲区?系统可以生成一个信号或软件中断,但是这样的编程方式是十分困难的并且被证明是竞争条件。对于内核来说更好的解决方法是将数据复制到一个内核缓冲区中,与图5-15c相类似(但是是另一个方向),并且立刻将调用者解除阻塞。现在实际的I/O什么时候完成都没有关系了,用户一旦被解除阻塞立刻就可以自由地重用缓冲区。

缓冲是一种广泛采用的技术,但是它也有不利的方面。如果数据被缓冲太多次,性能就会降低。例如,考虑图5-16中的网络。其中,一个用户执行了一个系统调用向网络写数据。内核将数据包复制到一个内核缓冲区中,从而立即使用户进程得以继续进行(第1步)。在此刻,用户程序可以重用缓冲区。

阅读 ‧ 电子书库
图 5-16 可能涉及多次复制一个数据包的网络

当驱动程序被调用时,它将数据包复制到控制器上以供输出(第2步)。它不是将数据包从内核内存直接输出到网线上,其原因是一旦开始一个数据包的传输,它就必须以均匀的速度继续下去,驱动程序不能保证它能够以均匀的速度访问内存,因为DMA通道与其他I/O设备可能正在窃取许多周期。不能及时获得一个字将毁坏数据包,而通过在控制器内部对数据包进行缓冲就可以避免这一问题。

当数据包复制到控制器的内部缓冲区中之后,它就会被复制到网络上(第3步)。数据位被发送之后立刻就会到达接收器,所以在最后一位刚刚送出之后,该位就到达了接收器,在这里数据包在控制器中被缓冲。接下来,数据包复制到接收器的内核缓冲区中(第4步)。最后,它被复制到接收进程的缓冲区中(第5步)。然后接收器通常会发回一个应答。当发送者得到应答时,它就可以自由地发送下一个数据包。然而,应该清楚的是,所有这些复制操作都会在很大程度上降低传输速率,因为所有这些步骤必须有序地发生。

3.错误报告

错误在I/O上下文中比在其他上下文中要常见得多。当错误发生时,操作系统必须尽最大努力对它们进行处理。许多错误是设备特定的并且必须由适当的驱动程序来处理,但是错误处理的框架是设备无关的。

一种类型的I/O错误是编程错误,这些错误发生在一个进程请求某些不可能的事情时,例如写一个输入设备(键盘、扫描仪、鼠标等)或者读一个输出设备(打印机、绘图仪等)。其他的错误包括提供了一个无效的缓冲区地址或者其他参数,以及指定了一个无效的设备(例如,当系统只有两块磁盘时指定了磁盘3),如此等等。在这些错误上采取的行动是直截了当的:只是将一个错误代码报告返回给调用者。

另一种类型的错误是实际的I/O错误,例如,试图写一个已经被破坏的磁盘块,或者试图读一个已经关机的便携式摄像机。在这些情形中,应该由驱动程序决定做什么。如果驱动程序不知道做什么,它应该将问题向上传递,返回给与设备无关的软件。

软件要做的事情取决于环境和错误的本质。如果是一个简单的读错误并且存在一个交互式的用户可利用,那么它就可以显示一个对话框来询问用户做什么。选项可能包括重试一定的次数,忽略错误,或者杀死调用进程。如果没有用户可利用,惟一的实际选择或许就是以一个错误代码让系统调用失败。

然而,某些错误不能以这样的方式来处理。例如,关键的数据结构(如根目录或空闲块列表)可能已经被破坏,在这种情况下,系统也许只好显示一条错误消息并且终止。

4.分配与释放专用设备

某些设备,例如CD-ROM刻录机,在任意给定的时刻只能由一个进程使用。这就要求操作系统对设备使用的请求进行检查,并且根据被请求的设备是否可用来接受或者拒绝这些请求。处理这些请求的一种简单方法是要求进程在代表设备的特殊文件上直接执行open操作。如果设备是不可用的,那么open就会失败。于是就关闭这样的一个专用设备,然后将其释放。

一种代替的方法是对于请求和释放专用设备要有特殊的机制。试图得到不可用的设备可以将调用者阻塞,而不是让其失败。阻塞的进程被放入一个队列。迟早被请求的设备会变得可用,这时就可以让队列中的第一个进程得到该设备并且继续执行。

5.与设备无关的块大小

不同的磁盘可能具有不同的扇区大小。应该由与设备无关的软件来隐藏这一事实并且向高层提供一个统一的块大小,例如,将若干个扇区当作一个逻辑块。这样,高层软件就只需处理抽象的设备,这些抽象设备全都使用相同的逻辑块大小,与物理扇区的大小无关。类似地,某些字符设备(如调制解调器)一次一个字节地交付它们的数据,而其他的设备(如网络接口)则以较大的单位交付它们的数据。这些差异也可以被隐藏起来。