我们每天在电脑前浏览网页、编辑文档或者观看视频,这些看似简单的操作,其实背后隐藏着一个复杂的系统在默默工作。 这个系统负责协调计算机与外部世界的数据交换,它就是我们今天要探讨的I/O系统。
计算机的两个主要工作就是I/O和计算。当我们说I/O时,指的不仅仅是输入输出设备,更包括了所有与外部世界的数据交换过程。 例如,当用户点击鼠标时,计算机需要接收这个输入信号;当屏幕显示图像时,计算机需要向显示器发送数据。
在很多情况下,I/O才是计算机系统的核心任务,而计算往往只是为了完成I/O而进行的辅助工作。 从系统设计的角度来看,I/O操作定义了系统的功能边界,而计算过程则是实现这些I/O目标的手段。

操作系统作为I/O子系统的核心管理组件,负责协调所有I/O操作和I/O设备的管理。从硬件接口的复杂性,到为应用程序提供的统一接口,操作系统都需要进行精心的架构设计和协调。
操作系统I/O子系统的设计面临着两个看似矛盾的趋势:一方面,软件和硬件接口正在不断标准化,这使得我们可以轻松地将新设备集成到现有系统中;另一方面,I/O设备的种类越来越多,有些新设备的功能与以往的设备截然不同,给操作系统设计者带来了巨大挑战。
为了应对这些挑战,操作系统采用了一种分层的架构设计。内核中有一个专门的I/O子系统,它将其他内核组件与I/O设备的复杂性隔离开来。 I/O设备的技术特点多种多样,有的设备速度极快,有的则相对缓慢。鼠标、硬盘、闪存驱动器、磁带机器人,这些设备的性能差异巨大。操作系统需要一套灵活的控制方法来管理这些性能各异的设备。
通过将I/O子系统与内核的其他部分分离,操作系统设计者可以专注于提供统一的设备访问接口。设备驱动程序作为硬件抽象层,它们内部针对特定设备进行了定制,但对外却提供标准化的接口。
计算机系统中的I/O设备种类繁多,从简单的键盘鼠标到复杂的硬盘、光驱、网络连接,甚至包括一些特殊的设备如飞机驾驶舱中的操纵杆和脚踏板。 这些设备虽然功能各异,但它们与计算机通信的基本原理却有着共同之处。
设备和计算机之间需要通过信号来传递信息。这些信号可以通过有线连接传输,也可以通过无线方式传输。 设备和计算机连接的物理接口称为端口,例如我们常见的串行端口,就是一种典型的I/O端口。
当多个设备需要共享通信路径时,它们会通过总线进行连接。总线是计算机系统中用于连接多个设备的数据传输通道,允许多个设备在同一物理路径上传输数据。现代计算机系统中,最常用的总线是PCIe总线。
总线在计算机架构中扮演着重要角色,它们在信号方法、速度、吞吐量和连接方式上各不相同。让我们通过一个典型的PC总线结构来理解这一点:
在这个结构中,PCIe总线负责连接处理器和高速设备,而扩展总线则连接像键盘、USB等较低速的设备。总线作为数据传输的通道,不同设备通过它们与计算机系统进行通信。
控制器是管理和协调设备操作的硬件组件。有些控制器结构简单,例如串行端口控制器可能只是一个简单的芯片;有些控制器则相当复杂,例如硬盘或光纤通道控制器,内部包含专门的处理器和内存,能够完成协议处理、数据缓存等多种任务。许多设备(如硬盘)自带控制器,负责设备内部的各种操作。

处理器和I/O控制器之间通过一组寄存器进行通信,处理器只需读写这些寄存器就能下达命令或获取数据。 常见的实现方式有两种:一种是用专门的I/O指令访问设备端口,另一种是将设备寄存器映射到内存地址,处理器像操作内存一样访问设备。 现在,大多数系统都采用内存映射I/O,因为更简单高效。
每个I/O设备通常有四类寄存器:状态寄存器(显示设备当前状态)、控制寄存器(主机写入命令或设置模式)、数据输入寄存器(主机读取设备输入),以及数据输出寄存器(主机写入要发送的数据)。
这些寄存器一般只有1到4字节大小。有些控制器还带有FIFO缓冲区,可以暂存多个字节,避免数据传输时丢失或堵塞,提高数据处理效率。
主机(例如CPU)和控制器之间虽然协议可能很复杂,但它们之间的同步通信原理相对简单。我们通过一个简化的例子来说明:假设主机和控制器之间使用两个状态位来进行同步通信。
控制器维护一个忙标志位(busy flag),用于表示当前是否正在处理操作。如果控制器处于忙碌状态,它将忙位设置为1;如果准备好接收新命令,则将忙位清零。 主机端维护一个命令就绪标志位(command ready flag),用于表示是否有新的命令需要执行。当主机有命令需要发送时,将此标志设置为1。
轮询机制的工作流程如下:主机持续检查控制器的忙位状态,直到其变为空闲状态。随后,主机在命令寄存器中设置写操作,并将要发送的数据写入数据输出寄存器。接着,主机将命令就绪标志设置为1,通知控制器可以开始处理。控制器检测到命令就绪标志被设置为1后,将忙位设置为1,表示开始处理操作。控制器读取命令和数据,然后执行相应的设备操作。操作完成后,控制器将命令就绪标志清零,同时将忙位也清零,表示重新进入空闲状态。
每传输一个字节的数据,这个流程就需要执行一遍。在第一步中,主机实际上在进行轮询操作——反复检查控制器是否处于空闲状态。
如果控制器和设备响应速度很快,这种方式可以正常工作。但如果控制器响应较慢,主机持续等待会浪费CPU资源,不如去执行其他任务。那么主机如何知道控制器何时空闲呢?有些设备还要求主机必须快速响应,否则数据可能会丢失。
在现代计算机系统中,单纯依靠轮询方式(CPU持续检查设备状态)会浪费大量CPU时间。因此,现代系统普遍采用中断机制,使CPU能够更高效地工作。
中断的基本原理是:每个CPU都有一条中断请求线(IRQ),设备控制器在需要CPU处理时(例如数据传输完成),会通过这条线发送中断信号。 CPU在执行完每条指令后,会检查是否有中断请求。如果检测到中断信号,CPU会暂停当前执行的任务,保存当前状态,然后跳转到专门的中断处理程序去处理该中断。 处理完成后,CPU恢复之前保存的状态,继续执行被中断的任务。
现代操作系统对中断的处理比这个基本流程要复杂得多,需要实现更多功能。系统需要能够在关键时刻(例如正在处理关键任务时)暂时屏蔽中断,等关键任务完成后再处理中断。系统需要能够快速准确地识别是哪一个设备发出的中断,而不是每次都轮询所有设备。系统需要支持多级中断优先级,即区分高优先级和低优先级中断。当有高优先级中断时,可以抢占正在处理的低优先级中断。除了设备中断,系统还需要处理一些特殊情况,例如程序异常(除零错误、内存越界等),这些称为陷阱(trap),也通过中断机制来处理。
这些功能主要通过CPU和专门的中断控制器硬件来实现。 大多数CPU有两条中断线:一条是不可屏蔽中断(NMI),用于处理必须立即处理的严重错误(例如内存故障),不能被屏蔽;另一条是可屏蔽中断,可以根据需要暂时关闭,例如正在执行不能被中断的关键代码段时。
当设备发起中断时,会携带一个中断号,这个编号用于标识中断类型。CPU维护一个中断向量表,其中存储着各种中断处理程序的地址。这样,CPU收到中断后,能够直接跳转到对应的处理程序,无需轮询所有设备。
实际上,设备数量往往比中断向量表的编号数量多。常见的解决方案是中断链:每个中断号对应一个处理程序列表,CPU收到中断后,依次调用列表中的处理程序,直到找到能够处理该中断的程序为止。
中断系统还实现了中断优先级的系统。这些优先级允许CPU推迟低优先级中断的处理,而不屏蔽所有中断,并使高优先级中断能够抢占低优先级中断的执行。
对于像磁盘这样需要大量数据传输的设备,如果让CPU逐字节地搬运数据,既效率低下又浪费CPU资源。 为了解决这个问题,计算机系统引入了直接内存访问(DMA)控制器,专门负责数据在内存和设备之间的传输。CPU只需要向DMA控制器指定源地址、目标地址和传输长度,剩余的数据传输工作都由DMA控制器完成,CPU可以继续执行其他任务。
DMA控制器和设备控制器之间通过专用信号线进行协调,数据传输完成后,DMA会通过中断通知CPU。虽然DMA工作时会短暂占用内存总线,使CPU暂时无法访问内存,但整体上大大提高了数据传输效率,显著减轻了CPU的负担。
DMA的安全考虑
值得注意的是,最直接的方法是将目标地址放在内核地址空间中。如果它在用户空间,用户可能会在传输期间修改该空间的内容,从而丢失一些数据。为了将DMA传输的数据提供给用户空间的进程访问,通常需要第二次复制操作,这次是从内核内存到用户内存。这会造成双重缓冲的低效。
现在,操作系统通常通过内存映射的方式,让设备和内存之间直接传输数据,提高了效率。同时,为了安全,普通进程不能直接操作硬件,只有操作系统或有特权的程序才能通过专门的接口访问底层设备,这样既保护了数据安全,也防止系统因误操作而崩溃。
I/O设备种类繁多,但操作系统通过设备驱动这一中间层,将各种硬件的差异都封装起来,向上只提供统一的接口。这样,应用程序不需要关心底层硬件的具体细节,例如使用什么型号的磁盘,可以直接使用标准的方式打开、读写文件。
设备驱动作为硬件抽象层,不同硬件只要有对应的驱动,操作系统就能识别和使用。虽然每种操作系统的驱动标准不同,硬件厂商通常会为主流系统(如Windows、Linux、macOS等)分别提供驱动,这样新设备就能方便地接入各种计算机系统,无需修改操作系统本身。 设备在多个维度上存在差异,包括数据传输模式(字符、块、顺序、随机、同步、异步、专用、共享)、I/O方向(只读、只写、读写)、设备速度(从每秒几个字节到千兆字节)、访问方法(终端、磁盘、调制解调器、CD-ROM、键盘、图形控制器等)以及延迟特性(寻道时间、传输率、操作间延迟)等,如下表所示:
对于应用程序访问,许多这些差异被操作系统隐藏,设备被分组到几个传统的类型。 由此产生的设备访问风格已被证明是有用和广泛适用的。虽然操作系统之间的确切系统调用可能不同,但设备类别是相当标准的。 主要访问约定包括块I/O、字符流I/O、内存映射文件访问和网络套接字。
操作系统也为一些附加设备提供特殊的系统调用,如时钟和定时器。有些操作系统为图形显示、视频和音频设备提供系统调用集。

大多数操作系统也提供一个转义机制(或后门接口),允许应用程序透明地将任意命令传递给设备驱动程序。 在UNIX系统中,这个系统调用是ioctl()(代表"I/O控制")。ioctl()系统调用允许应用程序访问设备驱动程序可以实现的任何功能,而无需为每个新功能发明新的系统调用。 ioctl()系统调用接受三个参数:一个设备标识符,将应用程序连接到由该驱动程序管理的硬件设备;一个整数,用于选择驱动程序中实现的命令之一;以及一个指向内存中任意数据结构的指针,允许应用程序和驱动程序传递任何必要的控制信息或数据。
在UNIX和Linux系统中,每个设备都有一个唯一的标识符,由主设备号和次设备号组成。主设备号用于标识设备类型,例如硬盘、打印机等; 次设备号则用于区分同一类型下的不同设备实例,例如系统中的第几个硬盘。操作系统通过这两个号码,将I/O请求准确地路由到对应的设备和驱动程序, 从而实现设备的识别和操作。
块设备(例如硬盘)可以将数据分成固定大小的块进行读写。操作系统为这些设备提供了统一的接口,例如read()(读)、write()(写),如果设备支持随机访问,还会有seek()(定位)这样的命令,允许程序跳转到特定的块进行操作。 大多数应用程序通常通过文件系统来访问这些块设备。这样,程序员只需要使用read、write、seek这些标准操作,就能满足大部分需求,无需关心底层硬件的具体差异。
有些特殊的程序,例如数据库系统,或者操作系统本身,有时需要直接、原始地访问块设备,将其视为一串连续的块来使用,这种方式称为原始I/O(raw I/O)。这样做的好处是可以自行控制数据的缓冲和锁定策略,避免与操作系统的缓存机制冲突,提高访问效率。 现在还有一种折中的做法,称为直接I/O(direct I/O),即在操作系统打开文件时,关闭缓冲和锁定机制,这样应用程序可以更直接地与设备交互。这种方式在UNIX系统中很常见。
内存映射文件访问是将磁盘上的文件直接映射到进程的虚拟地址空间中。这样,程序员无需使用read()和write()系统调用来读写文件,而是可以像操作内存中的普通数组一样,直接访问文件内容。
具体来说,操作系统会将文件的内容映射到一段虚拟内存地址上。程序获得这个地址后,访问这块内存实际上就是在访问文件。只有当程序真正访问这块内存时,操作系统才会通过页面错误机制将数据从磁盘读取到内存,或者将内存中的数据写回磁盘。这种方式利用了虚拟内存的按需分页机制,因此效率很高。
内存映射不仅简化了程序员的编程工作,操作系统本身也经常使用它。例如,加载可执行程序时,操作系统会将可执行文件映射到内存,然后直接跳转到入口地址开始执行。此外,操作系统管理磁盘上的交换空间(swap)时,也会使用内存映射机制。
由于网络I/O的性能和寻址特性与磁盘I/O显著不同,大多数操作系统为磁盘提供了与read()–write()–seek()接口不同的网络I/O接口。 一个在许多操作系统中可用的接口,包括UNIX和Windows,是网络套接字接口。套接字系统调用允许应用程序创建套接字,将本地套接字连接到远程地址(将此应用程序插入到另一个应用程序创建的套接字中),监听任何远程应用程序插入本地套接字,以及通过连接发送和接收数据包。
为了支持网络服务器的实现,套接字接口还提供了一个名为select()的函数,该函数管理一组套接字。select()的调用返回有关哪些套接字有等待接收的数据包以及哪些套接字有空间容纳要发送的数据包的信息。使用select()消除了网络I/O中本来需要的轮询和忙等待。 这些函数封装了网络的基本行为,大大促进了可以使用任何底层网络硬件和协议栈的分布式应用程序的创建。
除了套接字,操作系统还提供了很多其他进程间通信(IPC)和网络通信的方法。比如,Windows系统会为网卡和网络协议分别提供不同的接口。而在UNIX系统里,由于发展历史悠久,常见的通信方式有:半双工的管道(pipe)、全双工的FIFO(也叫命名管道)、全双工的STREAMS、消息队列,还有最常用的套接字(socket)等。这些机制让不同的进程或者网络上的程序可以方便地互相传递数据。
大多数计算机系统都配备硬件时钟和定时器,它们提供三个基本功能:提供当前时间、提供经过的时间,以及设置定时器在指定时间触发特定操作。
时钟和定时器是操作系统和各种需要计时的程序常用的功能,例如定时任务、进程调度等。常见的硬件定时器可以设置等待一段时间后发出中断,操作系统利用它来实现进程切换、定时刷新缓存、网络超时等功能。不过,不同操作系统的相关系统调用接口并不统一。
为了支持更多的定时需求,操作系统会使用软件模拟虚拟时钟,将所有定时请求进行排序,每次只使用硬件定时器处理最早到期的任务,等中断到来后再处理下一个任务。现代计算机还配备高精度定时器(例如HPET),能够更准确地计时和触发事件。
不过,定时器的精度受硬件限制,系统时钟还可能会逐渐漂移。为保证时间准确性,操作系统通常使用网络时间协议(NTP)定期校准时钟。另外,有些高频计数器虽然不产生中断,但可以用来精确测量时间间隔。
I/O操作有阻塞和非阻塞两种模式。在阻塞I/O模式下,程序发起I/O操作后会被挂起,等待数据准备好才恢复执行,这种模式编程简单,但程序会处于等待状态。非阻塞I/O则是操作系统立即返回,告知当前可以读取多少数据,程序可以继续执行其他任务,无需一直等待。
还有一种模式称为异步I/O,程序发起I/O请求后立即返回,等操作系统将数据准备好后通过回调、信号等方式通知程序。这样,程序可以在等待I/O完成的同时执行其他任务,提高系统效率。
现代操作系统经常在底层使用异步I/O,例如磁盘和网络操作。操作系统会先将请求缓存在内存中,在合适的时机统一处理,这样可以提升整体性能,但需要注意数据一致性和丢失风险。
注意,多个线程同时对同一文件执行I/O可能会收到不一致的数据,具体取决于内核如何实现其I/O。在这种情况下,线程可能需要使用锁定协议。
一些I/O请求需要立即执行,因此I/O系统调用通常有办法指示给定的请求或到特定设备的I/O应该同步执行。
有些操作系统还提供了矢量I/O功能,允许程序一次性对多个数据块进行读写操作。具体来说,程序可以将多个内存缓冲区(例如多个数组)打包成一个列表,然后使用一个系统调用(例如UNIX系统中的readv或writev)就能将这些数据一次性读取或写入。
为什么要使用矢量I/O?如果不使用矢量I/O,程序可能需要多次调用read或write,每次只处理一个缓冲区,这样会产生大量系统调用开销,还会频繁在用户态和内核态之间切换,效率较低。而使用矢量I/O,只需要一次系统调用,操作系统会按顺序处理所有缓冲区的数据。
另外,有些矢量I/O的实现还能保证原子性,也就是说,要么所有数据都一次性读写成功,要么都不执行,这样在多线程环境下可以避免数据被破坏。
现在我们已经了解了应用程序是如何通过这些标准接口和I/O设备打交道的,接下来我们要看看操作系统内核是怎么管理这些I/O操作的。
内核中包含专门负责I/O的子系统,它处理各种输入输出相关的工作。 这些工作包括:决定I/O请求的处理顺序(调度)、使用内存进行缓冲、加速数据访问(缓存)、打印任务排队(假脱机)、设备的占用管理和错误处理等。 这些功能都是在硬件和驱动程序的基础上实现的。I/O子系统还会保护自身,防止被有问题的程序或者恶意用户干扰。
I/O调度就是操作系统决定一组I/O请求的处理顺序,从而提升整体性能、减少等待时间,并让设备的使用更公平。 比如磁盘I/O,如果直接按应用程序请求的顺序处理,磁头可能来回移动很多次,效率很低。 操作系统会把请求排队,重新排序,让磁头移动距离最短,这样能大大加快读写速度。
除了效率,操作系统还会考虑公平性,避免某些程序一直得不到服务。有些重要或对延迟敏感的请求(比如虚拟内存)还会被优先处理。
在第上一部分中我们详细讨论了磁盘I/O的几种调度算法。这些算法如电梯算法(SCAN)和循环扫描(C-SCAN)都是为了优化磁盘臂的移动,减少寻道时间。
当内核支持异步I/O时,它必须能够同时跟踪许多I/O请求。为此,操作系统可能会将等待队列附加到设备状态表。 内核管理这个表,其中包含每个I/O设备的条目,如下表所示:
设备状态表中的每个条目都记录了设备的类型、地址以及当前状态(例如未使用、空闲或忙碌)。 如果设备正在处理请求,表中还会记录该请求的类型和相关参数。
I/O子系统提升系统效率主要通过两种方式:一是合理安排I/O操作的顺序(调度),二是使用缓冲区、缓存和假脱机等方法,将数据暂时存储在内存或其他存储空间中,减少等待时间和资源浪费。

缓冲区是一个内存区域,用于存储在两个设备之间或设备和应用程序之间传输的数据。缓冲主要有三个原因:
首先,缓冲用于解决数据传输速度不匹配的问题。有时候,数据的生产者和消费者速度差异很大,例如从网络下载文件时,网络传输速度可能很慢,但硬盘写入速度很快。为了不让硬盘一直等待网络,操作系统会使用缓冲区:先将网络接收到的数据暂时存储在内存中,等累积到一定量后再一次性写入硬盘。通常系统会使用两个缓冲区轮流工作(双缓冲),这样网络和硬盘可以并行工作,互不影响,效率更高。
其次,缓冲用于适应不同的数据块大小。有些设备一次传输的数据很大,有些很小。例如在网络上传输时,一个大文件会被拆分成多个小包发送,接收端收到后再重新组合。缓冲区作为一个中转站,帮助将这些小块数据重新组合成原始形式,或者将大块数据拆分成小块发送。
第三,缓冲用于保证数据的一致性(复制语义)。假设程序有一块数据要写入磁盘,调用了write(),但write()刚返回程序就修改了这块数据,这时磁盘上写入的到底是新数据还是旧数据?操作系统为了保证写入的就是调用write()时传入的数据,会先将数据复制到自己的缓冲区中,然后再写入磁盘。这样程序后续对数据的修改不会影响已经要写入磁盘的数据。
在操作系统中,应用程序和内核之间经常需要复制数据,虽然这样会有一定的性能损耗,但能保证数据安全和一致性。有些高级技术(例如虚拟内存映射、写时复制)还能让这个过程更高效。
缓存是一块高速内存区域,用来存放数据的副本。这样,系统下次需要使用这些数据时,就不需要从原始位置(例如磁盘)缓慢读取,直接从缓存中获取,速度会快很多。 例如,程序原本存储在磁盘上,操作系统会将其先读取到内存中(这也是一种缓存),CPU还有自己的缓存(一级、二级缓存),使数据传递更快。
那么缓冲和缓存有什么区别呢?缓冲区通常用来暂时存放数据的唯一副本,例如数据还没来得及写入磁盘前,先存放在缓冲区里。而缓存是把已经存在于别处的数据再复制一份,方便快速访问。
虽然缓冲和缓存的作用不同,但有时候一块内存区域可以同时作为缓冲和缓存使用。例如,操作系统会用内存来暂存磁盘上的数据,这样一方面保证数据安全(例如写文件时,先把数据复制到内存缓冲区),另一方面也能加快后续访问(如果又要读取同一块数据,直接从缓存中获取即可)。当程序请求读取文件时,操作系统会先检查内存中是否有这部分数据,如果有,就不需要再去磁盘读取。写入磁盘的数据也会先在内存中累积,等数据量达到一定阈值后再一起写入,这样效率更高。
假脱机(spooling)是操作系统管理独占设备访问的一种方法。例如,打印机一次只能打印一个任务,但可能有多个程序同时需要打印。
操作系统的解决方案是:每个程序要打印的内容,先被存储到各自的假脱机文件中(实际上是硬盘上的临时文件),等程序完成打印请求后,操作系统将这些文件按顺序发送到打印机。 这样,不同程序的打印内容不会混在一起,打印机也不会被多个程序争抢。假脱机系统有时由专门的后台程序(守护进程)管理,有时由内核中的线程管理。 无论由谁管理,操作系统都会提供一些基本操作,例如查看打印队列、删除打印任务、暂停打印等。
有些设备,例如磁带机、打印机,本质上不适合多个程序同时使用。假脱机是让多个程序轮流使用设备的方法之一。 另一种方法是让操作系统提供独占使用的功能:有些系统允许程序申请一个设备,使用完毕后再释放;有些系统规定同一时间只能有一个程序打开该设备。 Windows系统还提供了等待设备可用的系统调用,或者在打开文件时声明允许哪些线程访问。这样,程序之间可以自行协调,避免争抢设备导致死锁。
操作系统使用受保护内存技术,可以防止很多硬件或程序错误直接导致整个系统崩溃。例如,某个硬件出现小故障或者某个程序出错,系统不会立即完全崩溃。 设备和I/O操作出错的原因有很多种。常见的有两类:临时性故障和永久性故障。临时性故障包括网络暂时拥堵导致数据发送失败,或者磁盘偶尔读写失败。永久性故障包括磁盘控制器损坏等严重问题。
对于临时性问题,操作系统一般能自动处理。例如磁盘读取失败时,系统会自动重试;网络数据发送失败时,如果协议允许,会自动重发。 这样用户基本感觉不到问题。但如果遇到硬件真的损坏,例如磁盘控制器损坏,操作系统也无法完全恢复,只能报告错误。
通常,I/O相关的系统调用(例如读写文件)会返回操作是否成功。例如在UNIX系统中,如果出错,系统会通过errno变量返回具体的错误代码(大约有一百多种),例如参数错误、指针错误或者文件未打开等。
有些硬件还能提供非常详细的错误信息,但很多操作系统不会把这些细节直接告诉应用程序。例如,SCSI设备出错时,会分三级报告错误:感测键(sense key)大致说明错误类型,例如硬件故障、非法请求等;附加感测代码(additional sense code)具体说明是哪一类问题,例如命令参数错误、设备自检失败等;附加感测代码限定符(additional sense code qualifier)进一步说明具体哪个参数错了,或者哪个硬件部分自检未通过。
另外,很多SCSI设备还会在内部保存详细的错误日志,主机可以查询这些日志,但实际上很少有系统会主动查询这些日志。
错误处理和系统保护是紧密相关的。如果某个程序(无论是有意还是无意)试图直接对硬件发出非法的I/O操作指令,这可能会导致系统出现问题。 为了防止这种情况,操作系统实施了一套防护措施。
最重要的措施是:所有I/O操作都被规定为特权指令,只有操作系统本身能够直接操作硬件。 普通用户程序不能直接对硬件发出命令。如果用户程序需要进行I/O操作,例如读写文件、打印、网络访问等,必须通过系统调用来请求操作系统协助。 操作系统收到请求后,会检查请求是否合法,如果合法,就完成I/O操作,然后将结果返回给程序。
另外,像显卡、硬盘等设备的内存区域(例如内存映射的显存、I/O端口)也不能让用户程序随意访问,这些都需要依靠内存保护机制来守护。 这样可以防止程序错误地修改硬件导致系统崩溃。
不过,有些特殊情况,例如运行大型3D游戏或者进行视频编辑时,程序确实需要直接操作显卡的内存来提升性能。 遇到这种需求,操作系统会提供一种锁定机制:将一块显存专门分配给某个程序使用,其他程序暂时不能访问,这样既保证了性能,也保证了安全。
内核需要随时掌握各种I/O组件(例如文件、网络连接、设备等)的使用情况。它是通过在内存中维护一些数据结构来实现的, 例如我们之前提到过的打开文件表。这些数据结构记录着哪些进程在使用哪些资源,以及使用到哪个阶段。 除了文件,内核还会用类似的方式来跟踪网络连接、字符设备(例如串口)等各种I/O活动。
在UNIX系统中,文件系统可以让你访问很多不同的对象,例如普通文件、硬件设备,甚至是进程的内存空间。 虽然它们都能使用read()来读取,但背后的处理方式其实不同。例如,读取普通文件时,内核会先检查缓存中是否有数据,有就直接使用,没有才去磁盘读取;读取原始磁盘时,内核还需要确保读取的大小和位置正好对齐磁盘的扇区;而读取进程映像(例如内存快照)时,实际上只是从内存中拷贝数据。
为了让这些不同的操作看起来都像读取文件一样简单,UNIX采用了面向对象的设计。 每个打开的文件,内核都会给它分配一个文件对象,里面包含一个分派表,根据文件类型指向不同的处理函数。 这样,程序员使用同样的read(),内核会自动选择正确的方法。
有些操作系统(例如Windows)将这种面向对象的设计做得更彻底。它们使用消息传递的方式来处理I/O:每次I/O请求都被包装成一条消息,先送到内核的I/O管理器,再传给设备驱动,每一层都可以修改这条消息。 例如写数据时,消息中携带要写的内容;读数据时,消息中携带接收数据的缓冲区。这种做法虽然比直接操作数据结构稍慢,但让I/O系统的结构更清晰、扩展起来也更灵活。
随着数据中心规模越来越大,电力消耗和散热问题变得非常重要。操作系统可以通过监控负载,自动让不需要的服务器或硬件进入休眠甚至关机,从而节省电力和冷却成本。 比如,CPU核心在空闲时会被挂起,只有在需要时才唤醒。
在手机等移动设备上,电源管理更是重中之重。为了延长电池续航,操作系统会尽量让设备进入低功耗状态,例如关闭不用的硬件、让CPU进入深度睡眠。 Android系统就有电源崩溃功能,让手机在不用时几乎不耗电,但能随时被唤醒。
Android还通过组件级电源管理来精细控制每个硬件部件的电源开关。系统会追踪哪些部件正在被使用,未使用的就关闭,整个设备空闲时就进入深度休眠。 此外,应用可以使用唤醒锁临时阻止休眠,例如看视频或下载时,保证任务不中断。
在PC等通用计算机上,电源管理通常由ACPI等标准固件配合操作系统完成。ACPI能帮助操作系统发现和管理硬件设备的状态,实现自动休眠、唤醒和热插拔等功能。
现在我们已经了解了操作系统内核如何管理I/O,接下来我们将探索I/O请求如何从应用程序转换为硬件操作。
当我们使用文件名读取磁盘上的文件时,操作系统需要将文件名一步步转换成具体的硬件操作。 例如在MS-DOS中,C:代表主硬盘,冒号前的部分直接对应某个设备;而在UNIX系统中,设备也被当作文件,挂载到文件系统的某个目录下,操作系统通过设备号找到对应的驱动程序和硬件。
这种设计让操作系统可以灵活地管理各种设备。无论是硬盘、U盘还是打印机,系统都能通过查表的方式,将用户的I/O请求路由到合适的驱动程序。 这样一来,添加新设备时,只要有对应的驱动程序,无需修改内核就能支持。
现代操作系统还能动态加载驱动程序。系统启动时,系统会检测有哪些硬件,然后按需加载驱动。 即使后来插入新设备,系统也能自动识别并加载驱动,使设备立即可用。这一切都让I/O系统既灵活又易于扩展。
UNIX System V(以及很多后来的UNIX系统)有一个重要的机制,叫做STREAMS。它是一种模块化的I/O架构,可以将不同的驱动程序和处理模块像管道一样串联起来,让数据在它们之间流动。
在STREAMS中,流是用户进程和设备驱动之间的一条双向通道。 它主要分为三部分:最上面是流头(负责与用户进程交互),最下面是驱动程序端(直接控制硬件设备),中间可以插入0个或多个流模块(例如用于处理数据、过滤、转换等)。 每一层都有自己的读队列和写队列,数据通过这些队列逐层传递。
下面这张图就是STREAMS的结构示意图:
STREAMS是一种模块化的I/O机制,可以将不同的处理模块串联起来,让数据在它们之间流动。每个模块都有自己的队列,数据通过这些队列传递。为了防止队列溢出,STREAMS支持流控制:当下游队列没有空间时,上游就会暂时停止发送数据。
用户进程通过write()或putmsg()将数据写入流,数据会逐层传递到设备驱动;读数据时使用read()或getmsg(),数据则从设备经过各模块传回进程。只有进程和流头之间的通信可能会阻塞,其他模块之间一般是异步的。
STREAMS的优势是灵活、易扩展,不同设备和协议可以复用同样的模块。它还能支持消息边界和控制信息,比传统的字节流更强大。很多UNIX系统(如System V和Solaris)都使用STREAMS来实现网络和设备驱动。
I/O对系统性能影响很大。每次进行I/O操作,CPU不仅要运行设备驱动,还要频繁切换进程状态,这会让CPU和内存总线都很繁忙。 数据在设备、内核和应用程序之间多次复制,也会加重系统负担。
中断虽然让CPU能及时响应设备,但每次中断都要保存和恢复状态,开销不小。 如果I/O设备很慢,频繁中断会让CPU效率降低。网络I/O更复杂,例如远程登录时,每输入一个字符都要经过多次中断和上下文切换,进一步增加系统负担。
为减轻主CPU压力,有些系统会使用专门的前端处理器或I/O通道来处理大量I/O任务。这样可以让主CPU专注于计算,I/O通道负责数据传输,提高整体效率。 我们可以采用几个原则来改善I/O的效率。首先,尽量减少进程切换的次数,因为每次切换都要保存和恢复状态,会浪费时间。其次,减少数据在内存中来回复制的次数,让数据能直接从设备传到应用程序,或者反过来,减少中间环节。第三,尽量让每次传输的数据块大一些,或者使用更智能的控制器和轮询方式,这样可以减少设备中断CPU的次数,让CPU更专注于计算任务。第四,使用DMA(直接内存访问)等技术,让专门的硬件负责数据搬运,这样CPU就不需要亲自处理数据传输,可以腾出资源执行其他任务。第五,将一些常用的处理操作直接实现在硬件中,让设备自己能并发处理任务,不需要CPU来管理所有细节。最后,让CPU、内存、总线和I/O设备的速度尽量匹配,避免某一环节太慢成为系统瓶颈。
I/O设备的复杂程度差别很大。例如鼠标就很简单:用户移动鼠标或者点击按钮,这些动作会被转换成数字信号,通过鼠标驱动程序传给应用程序。 而像Windows下的磁盘设备驱动就复杂多了。它不仅要管理单个磁盘,还能实现RAID阵列(将多个硬盘组合起来,提高速度或安全性)。 这时候,驱动程序要把应用程序的读写请求拆分成多个磁盘操作,还要负责出错时的数据恢复和各种性能优化。
那么,I/O的各种功能到底应该放在哪里实现呢?是在硬件里、驱动程序里,还是应用程序里?实际上,这里面有一个逐步下沉的过程:一开始,很多新的I/O功能会先在应用程序中实现。这样做灵活,出错也不会影响整个系统。例如FUSE就允许我们在用户空间实现文件系统。缺点是效率不高,因为应用程序和内核之间来回切换,速度慢,还用不了内核里的一些高效机制。等到这些功能成熟、被广泛需要了,就会被移到内核中实现。这样可以大大提升性能,但开发难度也提高了,因为内核很复杂,出错可能导致系统崩溃,所以必须非常小心。如果对性能要求极高,最后还可以把功能实现在硬件中,例如专用的RAID控制器。这样速度最快,但灵活性最差,开发周期也很长,而且以后想改进就很困难了。例如硬件RAID控制器通常不允许操作系统精细控制每个读写操作的细节。
随着技术进步,I/O设备的速度也越来越快,像固态硬盘(非易失性内存设备)越来越普及,设备种类也越来越多。这些变化让操作系统的I/O子系统和相关算法面临更大压力,需要不断优化,才能充分发挥新硬件的性能。
I/O性能优化总是涉及权衡。例如,中断驱动I/O提供了更好的响应性,但增加了CPU开销。轮询可以减少中断,但可能浪费CPU周期等待慢速设备。现代系统通常结合这两种方法:对快速设备使用轮询,对慢速设备使用中断。
关于轮询机制,以下哪个描述是正确的?
关于中断机制,以下哪个描述是正确的?
关于DMA(直接内存访问),以下哪个描述是正确的?
关于块设备和字符设备,以下哪个描述是正确的?
关于内存映射文件访问,以下哪个描述是正确的?
关于缓冲,以下哪个描述是正确的?
关于假脱机(spooling),以下哪个描述是正确的?
关于I/O保护,以下哪个描述是正确的?
1. 磁盘I/O时间计算
假设一个磁盘系统,平均寻道时间为8毫秒,旋转速度为7200 RPM,传输速率为100 MB/s。请计算:
磁盘I/O时间计算:
已知条件:
1. 平均旋转延迟:
旋转延迟是指等待目标扇区旋转到磁头下方所需的时间。
平均旋转延迟 = 旋转半圈所需的时间
转速 = 7200 RPM = 7200转/分钟 = 120转/秒
每转时间 = 1 / 120 = 0.00833秒 = 8.33毫秒
平均旋转延迟 = 每转时间 / 2 = 8.33 / 2 = 4.17毫秒
2. 传输时间:
传输时间 = 数据大小 / 传输速率
传输时间 = (8 × 10³字节) / (100 × 10⁶字节/秒) = 8 × 10³ / 10⁸ = 8 × 10⁻⁵秒 = 0.08毫秒
3. 总访问时间:
总访问时间 = 寻道时间 + 旋转延迟 + 传输时间
总访问时间 = 8毫秒 + 4.17毫秒 + 0.08毫秒 = 12.25毫秒
4. DMA传输:
如果使用DMA传输,CPU只需要:
然后CPU就可以继续执行其他任务,不需要参与实际的数据传输过程。DMA控制器会直接控制数据在磁盘和内存之间的传输,传输完成后通过中断通知CPU。
分析: 在总访问时间中,寻道时间(8毫秒)和旋转延迟(4.17毫秒)占主导地位,传输时间(0.08毫秒)几乎可以忽略。使用DMA可以大大减轻CPU负担,让CPU在数据传输期间执行其他任务,提高系统整体效率。
2. 中断处理开销分析
假设一个系统每秒处理1000次中断,每次中断处理需要:
请计算:
中断处理开销分析:
已知条件:
1. 每次中断处理的总时间:
每次中断处理时间 = 保存时间 + 处理时间 + 恢复时间 = 2微秒 + 10微秒 + 2微秒 = 14微秒
每秒中断处理的总时间:
每秒总时间 = 每次处理时间 × 中断频率 = 14微秒 × 1000 = 14000微秒 = 14毫秒
2. CPU占用率:
CPU频率 = 2 GHz = 2 × 10⁹ Hz
每秒CPU总周期数 = 2 × 10⁹ 周期/秒
每秒中断处理时间 = 14毫秒 = 14 × 10⁻³秒
每秒中断处理占用CPU时间百分比 = (中断处理时间 / 1秒) × 100% = (14 × 10⁻³ / 1) × 100% = 1.4%
3. 中断频率增加到5000次/秒:
每秒总时间 = 14微秒 × 5000 = 70000微秒 = 70毫秒
CPU占用率 = (70 × 10⁻³ / 1) × 100% = 7%
分析: 可以看到,当中断频率从1000次/秒增加到5000次/秒时,CPU占用率从1.4%增加到7%,增加了5倍。这说明中断处理虽然比轮询更高效,但在高频率中断的情况下,仍然会消耗相当多的CPU资源。因此,对于高速设备,有时使用轮询或混合策略(轮询+中断)可能更高效。
3. 缓冲效率分析
假设一个网络下载场景:
请分析:
缓冲效率分析:
已知条件:
1. 不使用缓冲:
如果不使用缓冲,网络和磁盘必须同步工作。由于网络速度(10 MB/s)远慢于磁盘速度(100 MB/s),磁盘大部分时间处于等待状态。
系统效率 = min(网络速度, 磁盘速度) = min(10 MB/s, 100 MB/s) = 10 MB/s
磁盘利用率 = 10 / 100 = 10%(很低)
2. 使用单缓冲:
使用单缓冲时,数据先写入缓冲区,然后从缓冲区写入磁盘。
缓冲区填满时间 = 1 MB / 10 MB/s = 0.1秒
缓冲区写入磁盘时间 = 1 MB / 100 MB/s = 0.01秒
总时间 = 0.1秒 + 0.01秒 = 0.11秒
平均速度 = 1 MB / 0.11秒 ≈ 9.09 MB/s
问题: 在缓冲区写入磁盘期间(0.01秒),网络无法继续传输数据,因为缓冲区正在被使用。这会导致网络传输中断,效率降低。
3. 使用双缓冲:
使用双缓冲时,有两个缓冲区轮流工作:
这样,网络和磁盘可以并行工作,互不影响。
缓冲区填满时间 = 1 MB / 10 MB/s = 0.1秒
缓冲区写入磁盘时间 = 1 MB / 100 MB/s = 0.01秒
由于双缓冲允许并行工作,系统效率主要由较慢的网络速度决定:
系统效率 ≈ 10 MB/s(接近网络速度)
磁盘利用率 = 10 / 100 = 10%(但这是合理的,因为网络是瓶颈)
4. 三种方案比较:
结论: 双缓冲方案效率最高,因为它允许网络传输和磁盘写入完全并行进行,充分利用了两种设备的能力。虽然需要额外的内存空间,但带来的性能提升是值得的。
4. I/O调度算法比较
假设磁头当前位于磁道50,待处理的I/O请求按到达顺序为:10、90、30、70、100、20。请计算:
I/O调度算法比较:
已知条件:
1. FCFS算法:
FCFS算法按照请求到达的顺序依次处理,不进行任何优化。
处理顺序:50 → 10 → 90 → 30 → 70 → 100 → 20
磁头移动距离:
总移动距离 = 40 + 80 + 60 + 40 + 30 + 80 = 330个磁道
2. SSTF算法:
SSTF算法每次选择距离当前磁头位置最近的请求。
初始位置:50
处理过程:
处理顺序:50 → 30 → 20 → 10 → 70 → 90 → 100
磁头移动距离:
总移动距离 = 20 + 10 + 10 + 60 + 20 + 10 = 130个磁道
3. SCAN算法(先向外圈移动):
SCAN算法模拟电梯运行方式,磁头从当前位置开始,沿一个方向移动,沿途处理所有请求。
当前磁头在磁道50,先向外圈(磁道号增大方向)移动:
处理顺序:50 → 70 → 90 → 100 → 30 → 20 → 10
磁头移动距离:
总移动距离 = 20 + 20 + 10 + 70 + 10 + 10 = 140个磁道
4. 三种算法比较:
分析:
结论: 在实际系统中,SCAN算法或其变种(如C-SCAN、LOOK)通常是最佳选择,因为它们在性能和公平性之间取得了良好的平衡。