← 返回首页
读书笔记

《Operating System:Three Easy Pieces》第三十六章 I/O设备

系统架构

先看一个典型系统的架构。

其中,CPU 通过某种内存总线(memory bus)或互连电缆连接到系统内存。图像或者其他高性能 I/O 设备通过常规的I/O 总线(I/O bus)连接到系统,在许多现代系统中会是 PCI 或它的衍生形式。最后,更下面是外围总线(peripheral bus),比如 SCSI、SATA 或者 USB。它们将最慢的设备连接到系统,包括磁盘、鼠标及其他类似设备。

为什么要用这样的分层架构?简单回答:因为物理布局造价成本

越快的总线越短,因此高性能的内存总线没有足够的空间连接太多设备。另外,在工程上高性能总线的造价非常高。所以,系统的设计采用了这种分层的方式,这样可以让要求高性能的设备(比如显卡)离 CPU 更近一些,低性能的设备离 CPU 远一些。将磁盘和其他低速设备连到外围总线的好处很多,其中较为突出的好处就是你可以在外围总线上连接大量的设备

标准设备

现在来看一个标准设备(不是真实存在的),通过它来帮助我们更好地理解设备交互的机制。

第一部分是向系统其他部分展现的硬件接口(interface)。同软件一样,硬件也需要一些接口,让系统软件来控制它的操作。因此,所有设备都有自己的特定接口以及典型交互的协议。

第二部分是它的内部结构(internal structure)。这部分包含设备相关的特定实现,负责具体实现设备展示给系统的抽象接口。非常简单的设备通常用一个或几个芯片来实现它们的功能。更复杂的设备会包含简单的 CPU、一些通用内存、设备相关的特定芯片,来完成它们的工作。例如,现代 RAID 控制器通常包含成百上千行固件(firmware,即硬件设备中的软件),以实现其功能。

标准协议

一个(简化的)设备接口包含 3 个寄存器:

我们现在来描述操作系统与该设备的典型交互,以便让设备为它做某事。协议如下:

c++
While (STATUS == BUSY) ; // wait until device is not busy Write data to DATA register // 写入数据寄存器 Write command to COMMAND register // 写入命令寄存器 (Doing so starts the device and executes the command) While (STATUS == BUSY) ; // wait until device is done with your request

轮询过程比较低效,在等待设备执行完成命令时浪费量 CPU 时间,如果此时操作系统可以切换执行下一个就绪进程,就可以大大提高 CPU 的利用率。

利用中断减少 CPU 开销

有了中断后,CPU 不再需要不断轮询设备,而是向设备发出一个请求,然后就可以让对应进程睡眠,切换执行其他任务。

当设备完成了自身操作,会抛出一个硬件中断,引发 CPU 跳转执行操作系统预先定义好的中断服务例程(Interrupt Service Routine,ISR),或更为简单的中断处理程序(interrupt handler)。

中断处理程序是一小段操作系统代码,它会结束之前的请求(比如从设备读取到了数据或者错误码)并且唤醒等待 I/O 的进程继续执行。

中断允许计算与 I/O 重叠(overlap),这是提高 CPU 利用率的关键。如果我们利用中断并允许重叠,操作系统就可以在等待磁盘操作时做其他事情:

在这个例子中,在磁盘处理进程 1 的请求时,操作系统在 CPU 上运行进程 2。磁盘处理完成后,触发一个中断,然后操作系统唤醒进程 1 继续运行。这样,在这段时间,无论CPU 还是磁盘都可以有效地利用。

使用中断并非总是最佳方案。

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

如果使用编程的 I/O 将一大块数据传给设备,CPU 又会因为琐碎的任务而变得负载很重,浪费了时间和算力,本来更好是用于运行其他进程。

进程 1 在运行过程中需要向磁盘写一些数据,所以它开始进行 I/O 操作,将数据从内存拷贝到磁盘(其中标示 c 的过程)。拷贝结束后,磁盘上的 I/O 操作开始执行,此时 CPU 才可以处理其他请求。

💡
关键问题:如何减少 PIO 的开销 使用 PIO 的方式,CPU 的时间会浪费在向设备传输数据或从设备传出数据的过程中。如何才能分离这项工作,从而提高 CPU 的利用率?

使用 DMA(Direct Memory Access)。DMA 引擎是系统中的一个特殊设备,它可以协调完成内存和设备间的数据传递,不需要 CPU 介入。 DMA 工作过程如下:为了能够将数据传送给设备,操作系统会通过编程告诉 DMA 引擎数据在内存的位置,要拷贝的大小以及要拷贝到哪个设备。在此之后,操作系统就可以处理其他请求了。当 DMA 的任务完成后,DMA 控制器会抛出一个中断来告诉操作系统自己已经完成数据传输。修改后的时间线如下:

从时间线中可以看到,数据的拷贝工作都是由 DMA 控制器来完成的。因为 CPU 在此时是空闲的,所以操作系统可以让它做一些其他事情,比如此处调度进程 2 到 CPU 来运行。因此进程 2 在进程 1 再次运行之前可以使用更多的 CPU。

设备交互的方法

随着技术的不断发展,主要有两种方式来实现与设备的交互:

两种方法没有一种具备极大的优势。内存映射 I/O 的好处是不需要引入新指令来实现设备交互,但两种方法今天都在使用。

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

每个设备都有非常具体的接口,如何将它们纳入操作系统,而我们希望操作系统尽可能通用。

例如文件系统,我们希望开发一个文件系统可以工作在SCSI 硬盘、IDE 硬盘、USB 钥匙串设备等设备之上,并且希望这个文件系统不那么清楚对这些不同设备发出读写请求的全部细节。

💡
关键问题:如何实现一个设备无关的操作系统 如何保持操作系统的大部分与设备无关,从而对操作系统的主要子系统隐藏设备交互的细节?

这个问题可以通过古老的抽象(abstraction)技术来解决。在最底层,操作系统的一部分软件清楚地知道设备如何工作,我们将这部分软件称为设备驱动程序(device driver),所有设备交互的细节都封装在其中。

这种封装也有不足的地方。例如,如果有一个设备可以提供很多特殊的功能,但为了兼容大多数操作系统它不得不提供一个通用的接口,这样就使得自身的特殊功能无法使用。

因为所有需要插入系统的设备都需要安装对应的驱动程序,所以久而久之,驱动程序的代码在整个内核代码中的占比越来越大。查看 Linux 内核代码会发现,超过 70%的代码都是各种驱动程序。在 Windows 系统中,这样的比例同样很高。

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

看 xv6 源码中一个简单的、能工作的 IDE 驱动程序实现。 IDE 硬盘暴露给操作系统的接口比较简单,包含 4 种类型的寄存器,即控制、命令块、状态和错误。在 x86 上,利用 I/O 指令 in 和 out 向特定的 I/O 地址(如下面的 0x3F6)读取或写入时,可以访问这些寄存器,如图 36.4 所示。

下面是与设备交互的简单协议,假设它已经初始化了,如图 36.5 所示。

xv6 的 IDE 硬盘驱动程序(简化的):

c++
// 在发起请求之前调用,确保驱动处于就绪状态。 static int ide_wait_ready() { while (((int r = inb(0x1f7)) & IDE_BSY) || !(r & IDE_DRDY)) ; // loop until drive isn't busy } // 将请求发送到磁盘(在写请求时,可能是发送数据)。 // 此时 x86 的 in 或 out 指令会被调用, // 以读取或写入设备寄存器。 static void ide_start_request(struct buf *b) { ide_wait_ready(); outb(0x3f6, 0); // generate interrupt outb(0x1f2, 1); // how many sectors? outb(0x1f3, b->sector & 0xff); // LBA goes here ... outb(0x1f4, (b->sector >> 8) & 0xff); // ... and here outb(0x1f5, (b->sector >> 16) & 0xff); // ... and here! outb(0x1f6, 0xe0 | ((b->dev&1)<<4) | ((b->sector>>24)&0x0f)); if(b->flags & B_DIRTY){ outb(0x1f7, IDE_CMD_WRITE); // this is a WRITE outsl(0x1f0, b->data, 512/4); // transfer data too! } else { outb(0x1f7, IDE_CMD_READ); // this is a READ (no data) } } // 将一个请求加入队列(如果前面还有请求未处理完成), // 或者直接将请求发送到磁盘(通过 ide_start_request()) void ide_rw(struct buf *b) { acquire(&ide_lock); // 找ide_queue链表第一个空元素,pp赋值为链表的头元素指针,相当于对链表的引用, // *pp表示元素,(*pp)->qnext表示下一个元素,pp=&(*pp)->qnext表示指针移向下个元素, // 循环条件是*pp,也就是元素不为空(不是元素指针pp不为空),由此实现遍历链表中的有效项 for (struct buf **pp = &ide_queue; *pp; pp=&(*pp)->qnext) ; // walk queue // 元素赋值为b(深拷贝) *pp = b; // add request to end // 当pp是当前头元素时成立 if (ide_queue == b) // if q is empty ide_start_request(b); // send req to disk // 是否不可用或是脏状态 while ((b->flags & (B_VALID|B_DIRTY)) != B_VALID) sleep(b, &ide_lock); // wait for completion release(&ide_lock); } // 当发生中断时调用,从设备读取数据(如果是读请求), 并且在结束后唤醒等待的进程, // 如果此时在队列中还有别的未处理的请求,则调用 ide_start_request() 接着处理下一个 I/O 请求。 void ide_intr() { struct buf *b; acquire(&ide_lock); if (!(b->flags & B_DIRTY) && ide_wait_ready() >= 0) insl(0x1f0, b->data, 512/4); // if READ: get data b->flags |= B_VALID; b->flags &= ˜B_DIRTY; wakeup(b); // wake waiting process if ((ide_queue = b->qnext) != 0) // start next request ide_start_request(ide_queue); // (if one exists) release(&ide_lock); }

小结

中断和 DMA,用于提高设备效率。

访问设备寄存器的两种方式,I/O 指令和 内存映射 I/O。

设备驱动程序的概念,展示了操作系统本身如何封装底层细节,从而更容易以设备无关的方式构建操作系统的其余部分。

参考

本文由 GJJ 创作,内容来源于 Notion 数据库,随时可在 Notion 中编辑更新。 本站由 DeepSeek-v4-flash 辅助构建,项目参考 NotionNext

← 返回首页
61
文章
6
标签
3
分类
962
运行天数