第六章:中断与设备驱动程序
驱动程序(driver) 是操作系统中用于管理特定设备的代码:它配置设备硬件,指示设备执行操作,处理由此产生的中断(interrupts),并与使用该设备的进程进行交互。驱动程序代码往往比较棘手,因为驱动程序与设备并发执行,而且通常还会与使用该设备的进程并发执行。此外,驱动程序必须理解设备的硬件接口,而这些接口可能十分复杂且文档不完善。
需要操作系统关注的设备通常可以被配置为生成中断,而中断是一种陷阱(trap)。内核的陷阱处理代码能够识别设备是否触发了中断,并调用驱动程序的中断处理程序;在 xv6 中,这一分发过程发生在 devintr 中。
许多设备驱动程序会在两种上下文中执行代码:上半部(top half) 在进程的内核线程中运行,而 下半部(bottom half) 在中断发生时执行。上半部通常通过诸如 read 和 write 等系统调用被调用,这些调用希望设备执行 I/O 操作。该代码可能会请求硬件启动某个操作(例如,要求磁盘读取一个块),然后等待操作完成。最终,设备完成操作并触发中断。作为下半部的驱动程序中断处理程序会判断哪个操作已经完成,在合适的情况下唤醒等待的进程,并在有后续任务时通知硬件开始处理下一个操作。
代码:控制台输入
控制台驱动程序(console driver)(6950)是驱动程序结构的一个简单示例。控制台驱动通过连接到 RISC-V 的 UART 串口硬件,接收人类输入的字符。控制台驱动一次累积一行输入,并处理诸如 退格(backspace) 和 control-u 等特殊输入字符。用户进程(例如 shell)使用 read 系统调用从控制台获取输入行。当你在 QEMU 中向 xv6 键入输入时,你的按键是通过 QEMU 模拟的 UART 硬件传递给 xv6 的。
驱动程序所交互的 UART 硬件是一个由 QEMU 模拟的 16550 芯片。在真实计算机上,16550 会管理一条 RS232 串行链路,用于连接终端或其他计算机。在运行 QEMU 时,它连接到你的键盘和显示器。
UART 硬件在软件看来是一组内存映射的控制寄存器。也就是说,有一些物理地址被连接到 UART 设备,对这些地址的加载和存储会与设备硬件交互,而不是与 RAM 交互。UART 的内存映射地址从 0x10000000 开始,也称为 UART0(0220)。UART 有少量控制寄存器,每个寄存器的宽度为一个字节。它们相对于 UART0 的偏移量定义在(7221)中。
例如,LSR 寄存器包含一些位,用来指示是否有等待被驱动程序读取的输入字符。如果存在字符,这些字符可以从 RHR 寄存器中读取。每读取一个字符,UART 硬件就会将其从内部的等待字符 FIFO 中删除;当 FIFO 为空时,会清除 LSR 中的“就绪”位。发送数据时,驱动程序向 THR 寄存器写入一个字节,这会使 UART 将该字节附加到一个 FIFO 中,随后通过 RS232 串行链路发送。UART 的发送和接收硬件在很大程度上是彼此独立的。
xv6 的 main 调用 consoleinit(7154)来初始化 UART 硬件。该代码将 UART 配置为:在每次接收到一个输入字节时生成接收中断,并在每次完成发送一个输出字节时生成发送完成中断(7251)。
xv6 的 shell 通过 init.c(7768)打开的文件描述符从控制台读取数据。对 read 系统调用的调用会在内核中一路传递到 consoleread(7040)。consoleread 等待输入到达(通过中断)并被缓存在 cons.buf 中,然后将输入复制到用户空间,并在接收到完整一行之后返回给用户进程。如果用户尚未输入完整的一行,任何尝试读取的进程都会在 sleep 调用(7056)中等待(第 9 章将解释 sleep 的细节)。
当用户键入一个字符时,UART 硬件会请求 RISC-V 触发一次中断,从而激活 xv6 的陷阱处理程序。陷阱处理程序调用 devintr(3506),后者查看 RISC-V 的 scause 寄存器,以确定该中断来自外部设备。随后,它询问一个称为 PLIC 的硬件单元来确定是哪个设备触发了中断(3514)。如果是 UART,devintr 就会调用 uartintr。
uartintr(7354)从 UART 硬件中读取所有等待的输入字符,并将它们交给 consoleintr(7107);它不会等待字符的到来,因为未来的输入会触发新的中断。consoleintr 的任务是将输入字符累积到 cons.buf 中,直到接收到完整的一行。consoleintr 会对退格以及少数其他字符进行特殊处理。当收到换行符时,consoleintr 会唤醒正在等待的 consoleread(如果存在)。
一旦被唤醒,consoleread 就会观察到 cons.buf 中已经存在完整的一行,将其复制到用户空间,并通过系统调用机制返回到用户空间。
代码:控制台输出
对连接到控制台的文件描述符执行 write 系统调用,最终会到达 uartputc(7309)。设备驱动维护了一个输出缓冲区(uart_tx_buf),这样写入的进程就不必等待 UART 完成发送;相反,uartputc 会将每个字符追加到缓冲区中,调用 uartstart 来启动设备发送(如果尚未在发送),然后返回。uartputc 唯一需要等待的情况是缓冲区已经满了。
每当 UART 完成发送一个字节时,它就会生成一次中断。uartintr 会调用 uartstart,后者检查设备是否确实已经完成发送,并将下一个已缓冲的输出字符交给设备。因此,如果一个进程向控制台写入多个字节,通常第一个字节会由 uartputc 对 uartstart 的调用发送,而其余缓冲的字节则会在发送完成中断到达时,由 uartintr 中对 uartstart 的调用发送。
这里需要注意的一种通用模式是:通过缓冲和中断将设备活动与进程活动解耦。即使没有进程在等待读取,控制台驱动也可以处理输入;随后发生的 read 调用仍然可以看到这些输入。类似地,进程可以发送输出而不必等待设备完成操作。这种解耦可以通过允许进程与设备 I/O 并发执行来提升性能,尤其是在设备较慢(如 UART)或需要立即响应(如回显键入字符)时尤为重要。这种思想有时被称为 I/O 并发(I/O concurrency)。
驱动程序中的并发
你可能已经注意到在 consoleread 和 consoleintr 中调用了 acquire。这些调用会获取一个锁(lock),用于保护控制台驱动的数据结构免受并发访问的影响。这里存在三种并发危险:可能有两个运行在不同 CPU 上的进程同时调用 consoleread;当某个 CPU 已经在 consoleread 中执行时,硬件可能会请求该 CPU 处理一次控制台(实际上是 UART)中断;以及当 consoleread 正在执行时,硬件可能会在另一个 CPU 上投递控制台中断。第 7 章将解释如何使用锁来确保这些危险不会导致错误结果。
并发在驱动程序中需要谨慎处理的另一种情况是:某个进程可能正在等待来自设备的输入,但表示输入到达的中断可能会在另一个进程(甚至在没有任何进程运行)时到达。因此,中断处理程序不允许去考虑它中断了哪个进程或哪段代码。例如,中断处理程序不能安全地使用当前进程的页表来调用 copyout。中断处理程序通常只执行很少的工作(例如,仅将输入数据复制到一个缓冲区中),然后唤醒 上半部(top half) 代码来完成其余工作。
定时器中断
xv6 使用定时器中断来维护其对当前时间的认知,并在计算密集型进程之间进行切换。定时器中断来自连接到每个 RISC-V CPU 的时钟硬件。xv6 会为每个 CPU 编程,使其时钟硬件周期性地中断 CPU。
start.c(1102)中的代码设置了一些控制位,以允许 监督模式(supervisor mode) 访问定时器控制寄存器,然后请求第一次定时器中断。时间控制寄存器包含一个由硬件以稳定速率递增的计数值,用来表示当前时间。stimecmp 寄存器包含一个时间点,当时间到达该值时,CPU 就会触发一次定时器中断;将 stimecmp 设置为当前时间加上 x,就会在 x 个时间单位之后调度一次中断。对于 QEMU 的 RISC-V 模拟而言,1000000 个时间单位大约相当于十分之一秒。
与其他设备中断一样,定时器中断也会通过 usertrap 或 kerneltrap 以及 devintr 到达。当 scause 的低位被设置为 5 时,表示这是一次定时器中断;trap.c 中的 devintr 会检测到这种情况,并调用 clockintr(3482)。后者会递增 ticks,使内核能够跟踪时间的流逝。该递增操作只在一个 CPU 上发生,以避免在多 CPU 系统中时间流逝得更快。clockintr 会唤醒所有在 pause 系统调用中等待的进程,并通过写入 stimecmp 来调度下一次定时器中断。
对于定时器中断,devintr 会返回 2,以指示 kerneltrap 或 usertrap 应该调用 yield,从而使 CPU 能够在可运行的进程之间进行复用。内核代码可能会被定时器中断打断,并通过 yield 强制发生一次上下文切换,这也是 usertrap 中早期代码在启用中断之前小心保存诸如 sepc 等状态的原因之一。这些上下文切换还意味着,编写内核代码时必须意识到:代码可能会在没有任何预警的情况下从一个 CPU 迁移到另一个 CPU 上运行。
现实世界
xv6 和许多操作系统一样,允许在内核执行期间响应中断,甚至通过 yield 进行上下文切换。这样做的原因是在执行耗时较长、结构复杂的系统调用时,仍然能够保持较快的响应时间。然而,正如前文所述,在内核中允许中断会带来一定的复杂性;因此,也有少数操作系统只允许在执行用户代码时响应中断。
要完整地支持一台典型计算机上的所有设备是一项巨大的工作,因为设备种类繁多、功能复杂,而且设备与驱动程序之间的协议往往既复杂又缺乏良好文档。在许多操作系统中,设备驱动程序的代码量甚至超过了内核核心部分。
UART 驱动程序通过读取 UART 控制寄存器,一次一个字节地获取数据;这种模式称为程序控制 I/O(programmed I/O),因为数据移动是由软件驱动的。程序控制 I/O 很简单,但在高数据速率下速度过慢,难以胜任。需要以高速移动大量数据的设备通常使用直接内存访问(DMA)。DMA 设备硬件会直接将输入数据写入 RAM,并从 RAM 中读取输出数据。现代的磁盘和网络设备都使用 DMA。DMA 设备的驱动程序通常会先在 RAM 中准备好数据,然后通过一次对控制寄存器的写操作来通知设备处理这些已准备好的数据。
当设备在不可预测的时间、且不太频繁地需要关注时,中断是合理的选择。但中断会带来较高的 CPU 开销。因此,高速设备(如网络和磁盘控制器)通常会使用一些技巧来减少中断的需求。其中一种做法是:为一整批输入或输出请求只触发一次中断。另一种做法是驱动程序完全禁用中断,并周期性地检查设备是否需要处理,这种技术称为轮询(polling)。当设备以高频率执行操作时,轮询是合理的;但如果设备大多数时间处于空闲状态,轮询就会浪费 CPU 时间。一些驱动程序会根据当前设备负载,在轮询和中断之间动态切换。
UART 驱动程序会先将输入数据复制到内核中的缓冲区,然后再复制到用户空间。这在低数据速率下是合理的,但对于生成或消耗数据非常快的设备来说,这种双重拷贝会显著降低性能。一些操作系统能够在用户空间缓冲区和设备硬件之间直接移动数据,通常借助 DMA 来实现。
如第 1 章所述,控制台在应用程序看来就像一个普通文件,应用程序通过 read 和 write 系统调用来读取输入和写入输出。应用程序有时希望控制某些无法通过标准文件系统调用表达的设备特性(例如,启用或禁用控制台驱动中的行缓冲)。Unix 操作系统为此提供了 ioctl 系统调用。
某些计算机应用需要对外部事件作出 实时(real-time) 响应,即在有界的时间内保证完成响应。例如,在安全关键系统中,错过截止时间可能会导致灾难性后果。xv6 并不适合用于实时环境。其中一个原因是,xv6 的调度器在决定下一个运行的进程时,并不会考虑实时截止期限;此外,xv6 还存在较长的内核代码路径,在这些路径中中断被禁用,从而可能无法快速响应中断。一个实时操作系统不仅需要解决这些问题,还必须在结构上支持对最坏情况下响应时间的分析。