单机数据持久-I/O 设备
2023-1-15
| 2023-8-2
0  |  阅读时长 0 分钟
type
status
date
slug
summary
tags
category
icon
password
Property
 
 

系统架构

一个典型计算机系统的架构,CPU通过某种内存总线(memory bus)或互连电缆连接到系统内存。图像或者其他高性能I/O设备通过常规的I/O总线(I/O bus)连接到系统,在许多现代系统中会是PCI或它的衍生形式。更下面是外围总线(peripheral bus),比如SCSI、SATA或者USB。它们将最慢的设备连接到系统,包括磁盘、鼠标及其他类似设备。
notion image
为什么要用这样的分层架构?原因在于物理布局及造价成本。越快的总线越短,因此高性能的内存总线没有足够的空间连接太多设备。另外,在工程上高性能总线的造价非常高。所以,系统的设计采用了这种分层的方式,这样可以让要求高性能的设备离CPU更近一些,低性能的设备离CPU远一些。将磁盘和其他低速设备连到外围总线的好处很多,最重要的就是可以在外围总线上连接大量的设备。
 

标准设备

notion image
一个标准设备(不是真实存在的),包含两个重要组件备。
  • 第一部分是向系统其他部分展现的硬件接口,让系统软件来控制它的操作。因此,所有设备都有自己的特定接口以及典型的交互协议。
  • 第二部分是它的内部结构。这部分包含设备展示给系统的抽象接口相关的特定实现,非常简单的设备通常用一个或几个芯片来实现它们的功能。更复杂的设备会包含简单的CPU、一些通用内存、设备相关的特定芯片,来完成它们的工作。
 

标准协议

一个简化的设备接口包含3个寄存器:
  • 一个状态(status)寄存器,可以读取并查看设备的当前状态
  • 一个命令(command)寄存器,用于通知设备执行某个具体任务
  • 一个数据(data)寄存器,将数据传给设备或从设备接收数据
通过读写这些寄存器,操作系统可以控制设备的行为。操作系统与该设备的典型交互协议如下:
该协议包含4步:
  1. 操作系统通过反复读取状态寄存器,等待设备进入可以接收命令的就绪状态,称之为轮询(polling)设备
  1. 操作系统下发数据到数据寄存器。例如,如果这是一个磁盘,需要多次写入操作,将一个磁盘块(比如4KB)传递给设备。如果主CPU 参与数据移动,就称之为编程的I/O(programmed I/O,PIO)。
  1. 操作系统将命令写入命令寄存器;这样设备就知道数据已经准备好了,它应该开始执行命令。
  1. 操作系统再次通过不断轮询设备,等待并判断设备是否执行完成命令。
这个协议的好处是足够简单并且有效,但是难免会有一些低效和不方便。轮询会导致在等待设备执行完成时浪费大量CPU时间,如果此时操作系统可以切换执行下一个就绪进程,就可以大大提高CPU的利用率。
操作系统检查设备状态时如何避免频繁轮询,从而降低管理设备的CPU 开销?
 

利用中断减少CPU开销

多年前,工程师们发明了中断(interrupt)来减少CPU开销。有了中断,CPU 不再需要不断轮询设备,而是向设备发出一个请求,然后就可以让对应进程睡眠,切换执行其他任务。当设备完成了自身操作,会抛出一个硬件中断,引发CPU跳转执行操作系统预先定义好的中断服务例程(Interrupt Service Routine,ISR),或更为简单的中断处理程序(interrupthandler)。中断处理程序是一小段操作系统代码,它会结束之前的请求(比如从设备读取到了数据或者错误码)并且唤醒等待I/O的进程继续执行。
因此,中断允许计算与I/O 重叠(overlap),这是提高CPU 利用率的关键。
notion image
进程1 在CPU 上运行一段时间,然后发出一个读取数据的I/O 请求给磁盘。如果没有中断,那么操作系统就会简单自旋,不断轮询设备状态,直到设备完成I/O 操作。当设备完成请求的操作后,进程1 又可以继续运行。 如果我们利用中断并允许重叠,操作系统就可以在等待磁盘操作时做其他事情:
notion image
在磁盘处理进程1的请求时,操作系统在CPU 上运行进程2。磁盘处理完成后,触发一个中断,然后操作系统唤醒进程1 继续运行。这样,在这段时间,无论CPU 还是磁盘都可以有效地利用。
 
使用中断并非总是最佳方案。假如有一个非常高性能的设备,它处理请求很快:通常在CPU第一次轮询时就可以返回结果。此时如果使用中断,反而会使系统变慢:切换到其他进程,处理中断,再切换回之前的进程会有一定的代价。因此,如果设备非常快,那么最好的办法反而是轮询。如果设备比较慢,那么采用中断更好。如果设备的速度未知,或者时快时慢,可以考虑使用混合(hybrid)策略,先尝试轮询一小段时间,如果设备没有完成操作,此时再使用中断
 
另一个最好不要使用中断的场景是网络。网络端收到大量数据包,如果每一个包都发生一次中断,那么有可能导致操作系统发生活锁,即不断处理中断而无法处理用户层的请求。
还有一个基于中断的优化就是合并(coalescing)。设备在抛出中断之前往往会等待一小段时间,在此期间,其他请求可能很快完成,因此多次中断可以合并为一次中断抛出,从而降低处理中断的代价。当然,等待太长会增加请求的延迟,这是系统中常见的折中。
 
 

利用DMA进行更高效的数据传送

如果使用编程的I/O 将一大块数据传给设备,CPU 又会因为琐碎的任务而变得负载很重,浪费了时间和算力
notion image
进程1 在运行过程中需要向磁盘写一些数据,所以它开始进行I/O 操作,将数据从内存拷贝到磁盘(c过程)。拷贝结束后,磁盘上的I/O 操作开始执行,此时CPU 才可以处理其他请求。
解决方案就是使用DMA(Direct Memory Access)。DMA引擎是系统中的一个特殊设备,它可以协调完成内存和设备间的数据传递,不需要CPU介入。
DMA工作过程:为了能够将数据传送给设备,操作系统会通过程序告诉DMA引擎数据在内存的位置,要拷贝的大小以及要拷贝到哪个设备。在此之后,操作系统就可以处理其他请求了。当DMA的任务完成后,DMA控制器会抛出一个中断来告诉操作系统已经完成数据传输。
notion image
 
 

与设备交互的方法

硬件如何如与设备通信?是否需要一些明确的指令?或者其他的方式?
随着技术的不断发展,目前主要有两种方式来实现与设备的交互。
  • 第一种办法相对老一些,就是用明确的I/O指令。这些指令规定了操作系统将数据发送到特定设备寄存器的方法,从而允许构造上文提到的协议。例如在x86上,in和out指令可以用来与设备进行交互。当需要发送数据给设备时,调用者指定一个存入数据的特定寄存器及一个代表设备的特定端口。执行这个指令就可以实现期望的行为。
  • 第二种方法是内存映射I/O(memory-mapped I/O)。通过这种方式,硬件将设备寄存器作为内存地址提供。当需要访问设备寄存器时,操作系统装载或者存入到该内存地址;然后硬件会将装载/存入转移到设备上,而不是物理内存。
两种方法都没有相对明显的优势。内存映射I/O的好处是不需要引入新指令来实现设备交互,但两种方法今天都在使用。
 
 

纳入操作系统:设备驱动程序

每个设备都有非常具体的接口,那么如何将它们纳入操作系统,并且让操作系统尽可能通用呢?
这个问题可以通过抽象技术来解决。在最底层,操作系统的一部分软件清楚地知道设备如何工作,这部分软件称为设备驱动程序(device driver),所有设备交互的细节都封装在其中。
notion image
上图粗略地展示了Linux软件的组织方式。可以看出,文件系统(当然也包括在其之上的应用程序)完全不清楚它使用的是什么类型的磁盘。它只需要简单地向通用块设备层发送读写请求即可,块设备层会将这些请求路由给对应的设备驱动,然后设备驱动来完成真正的底层操作
这种封装也有不足的地方。例如,如果有一个设备可以提供很多特殊的功能,但为了兼容大多数操作系统它不得不提供一个通用的接口,这样就使得自身的特殊功能无法使用。这种情况在使用SCSI 设备的Linux 中就发生了。SCSI 设备提供非常丰富的报告错误信息,但其他的块设备(比如ATA/IDE)只提供非常简单的报错处理,这样上层的所有软件只能在出错时收到一个通用的EIO 错误码(一般IO 错误),SCSI 可能提供的所有附加信息都不能报告给文件系统。
因为所有需要插入系统的设备都需要安装对应的驱动程序,所以久而久之,驱动程序的代码在整个内核代码中的占比越来越大。查看Linux 内核代码会发现,超过70%的代码都是各种驱动程序。在Windows 系统中,这样的比例同样很高。因此,如果有人跟你说操作系统包含上百万行代码,实际的意思是包含上百万行驱动程序代码。当然,任何安装进操作系统的驱动程序,大部分默认都不是激活状态(只有一小部分设备是在系统刚开启时就需要连接)。更加令人沮丧的是,因为驱动程序的开发者大部分是“业余的”(不是全职内核开发者),所以他们更容易写出缺陷,因此是内核崩溃的主要贡献者。
 
 

案例:简单的IDE 磁盘驱动程序

IDE 硬盘暴露给操作系统的接口比较简单,包含4 种类型的寄存器,即控制、命令块、状态和错误。在x86 上,利用I/O 指令in 和out 向特定的I/O 地址(如下面的0x3F6)读取或写入时,可以访问这些寄存器。
下面是与设备交互的简单协议,假设它已经初始化了:
  • 等待驱动就绪。读取状态寄存器(0x1F7)直到驱动READY 而非忙碌。
  • 向命令寄存器写入参数。写入扇区数,待访问扇区对应的逻辑块地址(LBA),并将驱动编号(master=0x00,slave=0x10,因为IDE 允许接入两个硬盘)写入命令寄存器(0x1F2-0x1F6)。
  • 开启I/O。发送读写命令到命令寄存器。向命令寄存器(0x1F7)中写入READ-WRITE命令。
  • 数据传送(针对写请求):等待直到驱动状态为READY 和DRQ(驱动请求数据),向数据端口写入数据。
  • 中断处理。在最简单的情况下,每个扇区的数据传送结束后都会触发一次中断处理程序。较复杂的方式支持批处理,全部数据传送结束后才会触发一次中断处理。
  • 错误处理。在每次操作之后读取状态寄存器。如果ERROR 位被置位,可以读取错误寄存器来获取详细信息。
 
xv6 的IDE 硬盘驱动程序(简化的):
它(在初始化后)通过4 个主要函数来实现
  1. 第一个是ide_rw(),它会将一个请求加入队列(如果前面还有请求未处理完成),或者直接将请求发送到磁盘(通过ide_start_request())。不论哪种情况,调用进程进入睡眠状态,等待请求处理完成。
  1. 第二个是ide_start_request(),它会将请求发送到磁盘(在写请求时,可能是发送数据)。此时x86 的in 或out 指令会被调用,以读取或写入设备寄存器。
  1. 在发起请求之前,开始请求函数会使用第三个函数ide_wait_ready(),来确保驱动处于就绪状态。
  1. 最后,当发生中断时,ide_intr()会被调用。它会从设备中读取数据(如果是读请求),并且在结束后唤醒等待的进程,如果此时在队列中还有别的未处理的请求,则调用ide_start_request()接着处理下一个I/O 请求。
 
  • 计算机基础
  • 操作系统
  • 并发-基于事件的并发单机数据持久-磁盘驱动器
    目录