〇、前言
本文是 xv6 book 第五章的翻译,以下将开始翻译。
一、(翻译)第五章 中断和设备驱动
驱动程序是操作系统中管理特定设备的代码:它配置设备硬件,指示设备执行操作,处理产生的中断,并与可能正在等待来自设备的输入输出的进程进行交互。驱动程序代码可能会比较棘手,因为驱动程序与其管理的设备并发执行。此外,驱动程序必须理解设备的硬件接口,这可能是复杂的并且文档记录不足。
通常,需要操作系统关注的设备可以配置为生成中断,而中断是一种陷阱。内核陷阱处理代码会识别设备引发中断的情况,并调用驱动程序的中断处理程序;在xv6中,这种分发发生在devintr
(kernel/trap.c
:177)中。
许多设备驱动程序在两个上下文中执行代码:一个是在进程的内核线程中运行的上半部分,另一个是在中断时执行的下半部分。上半部分通过诸如读取和写入之类的系统调用进行调用,这些调用希望设备执行输入输出操作。该代码可能会要求硬件开始一个操作(例如,要求磁盘读取一个块),然后等待操作完成。最终,设备完成操作并引发中断。驱动程序的中断处理程序作为下半部分,确定完成了哪个操作,如果合适的话唤醒等待的进程,并告诉硬件开始进行任何等待的下一个操作。
5.1 控制台输入
控制台驱动程序(console.c
)是驱动程序结构的简单示例。控制台驱动程序接受人类通过连接到 RISC-V 的 UART 串口硬件键入的字符。控制台驱动程序一次积累一行输入,处理特殊输入字符,例如退格和控制-U
。用户进程(例如 shell)使用读取系统调用从控制台获取输入行。当您在 QEMU 中输入 xv6 时,您的击键通过 QEMU 模拟的 UART 硬件传递到 xv6。
驱动程序通信的 UART 硬件是由 QEMU 模拟的 16550 芯片[11]。在真实计算机上,16550 将管理与终端或其他计算机连接的 RS232 串行链路。在运行 QEMU 时,它连接到您的键盘和显示器。
对软件而言,UART 硬件显现为一组内存映射的控制寄存器。也就是说,RISC-V 硬件连接到 UART 设备的一些物理地址,因此加载和存储与设备硬件交互而不是 RAM。UART 的内存映射地址从 0x10000000
开始,或称为 UART0(kernel/memlayout.h
:21)。有一些 UART 控制寄存器,每个都是字节宽度。它们相对于 UART0 的偏移量在(kernel/uart.c
:22)中定义。例如,LSR 寄存器包含指示输入字符是否等待软件读取的位。这些字符(如果有)可从 RHR 寄存器读取。每次读取一个字符时,UART 硬件会从等待字符的内部 FIFO 中删除它,并在 FIFO 为空时清除 LSR 中的“就绪”位。UART 发送硬件在很大程度上独立于接收硬件;如果软件将一个字节写入 THR,UART 将传输该字节。
xv6 的主要调用 consoleinit
(kernel/console.c
:184)来初始化 UART 硬件。此代码配置 UART,在 UART 接收每个字节输入时生成接收中断,每次 UART 完成发送一个字节的输出时生成传输完成中断(kernel/uart.c
:53)。
xv6 shell 通过由 init.c
打开的文件描述符从控制台读取(user/init.c
:19)。对读取系统调用的调用通过内核传递到 consoleread
(kernel/console.c
:82)。consoleread
等待输入到达(通过中断)并在 cons.buf
中进行缓冲,将输入复制到用户空间,并在整个行到达后返回给用户进程。如果用户尚未输入完整的行,则任何正在进行读取的进程将在 sleep 调用中等待(kernel/console.c
:98)(第 7 章解释了 sleep 的详细信息)。
当用户键入字符时,UART 硬件要求 RISC-V 引发中断,这会激活 xv6 的陷阱处理程序。陷阱处理程序调用 devintr
(kernel/trap.c
:177),检查 RISC-V scause 寄存器以发现中断来自外部设备。然后,它询问一个名为 PLIC 的硬件单元以告知它是哪个设备发生了中断(kernel/trap.c
:186)。如果是 UART,devintr
调用 uartintr
。
uartintr
(kernel/uart.c
:180)从 UART 硬件读取任何等待的输入字符,并将它们传递给 consoleintr
(kernel/console.c
:138);它不等待字符,因为未来的输入会引发新的中断。consoleintr
的任务是在 cons.buf
中积累输入字符,直到整行到达。consoleintr
对退格和其他一些字符进行特殊处理。当换行符到达时,consoleintr
唤醒正在等待的 consoleread
(如果有的话)。
一旦唤醒,consoleread
将在 cons.buf
中观察到一个完整的行,将其复制到用户空间,并通过系统调用机制返回到用户空间。
5.2 代码:控制台输出
与控制台相关的文件描述符上的写系统调用最终会到达 uartputc
(kernel/uart.c
:87)。设备驱动程序维护一个输出缓冲区(uart_tx_buf),因此写入进程无需等待 UART 完成发送;相反,uartputc 将每个字符附加到缓冲区,调用 uartstart 启动设备传输(如果尚未启动),然后返回。uartputc 等待的唯一情况是如果缓冲区已满。
每当 UART 完成发送一个字节时,它都会生成一个中断。uartintr 调用 uartstart,后者检查设备是否真的已经完成发送,并将下一个缓冲的输出字符传递给设备。因此,如果进程向控制台写入多个字节,通常第一个字节将由 uartputc 调用 uartstart 发送,而其余缓冲的字节将由 uartintr 中的 uartstart 调用发送,这些调用是在传输完成中断到达时进行的。
要注意的一般模式是通过缓冲和中断将设备活动与进程活动解耦。即使没有进程等待读取输入,控制台驱动程序也可以处理输入;随后的读取将看到输入。同样,进程可以发送输出而无需等待设备。这种解耦可以通过允许进程与设备 I/O 并发执行来提高性能,并且在设备速度较慢(例如 UART)或需要立即处理(例如回显键入的字符)时尤其重要。这个想法有时被称为 I/O 并发。
5.3 驱动程序中的并发性
你可能已经注意到了 consoleread 和 consoleintr 中的 acquire 调用。这些调用获取了一个锁,用于保护控制台驱动程序的数据结构免受并发访问。这里存在三个并发性危险:不同 CPU 上的两个进程可能同时调用 consoleread;硬件可能要求一个 CPU 在该 CPU 已经在 consoleread 中执行时传递一个控制台(实际上是 UART)中断;硬件可能在不同的 CPU 上执行 consoleread 时传递一个控制台中断。第 6 章探讨了锁在这些情况下的作用。
在驱动程序中,并发性需要特别小心的另一种情况是,一个进程可能正在等待来自设备的输入,但是标志输入到达的中断可能在不同的进程(或根本没有进程)运行时到达。因此,中断处理程序不允许考虑它们中断的进程或代码。例如,中断处理程序不能安全地使用当前进程的页表调用 copyout。中断处理程序通常只做相对较少的工作(例如,只是将输入数据复制到缓冲区),然后唤醒顶半部代码继续处理。
5.4 定时器中断
xv6 使用定时器中断来维护其时钟,并使其能够在计算密集型进程之间进行切换;在 usertrap 和 kerneltrap 中的 yield 调用导致了这种切换。定时器中断来自连接到每个 RISC-V CPU 的时钟硬件。xv6 编程配置这些时钟硬件以定期中断每个 CPU。
RISC-V 要求定时器中断在机器模式下处理,而不是在监管者模式下处理。RISC-V 机器模式在没有分页且具有单独一组控制寄存器的情况下执行,因此无法在机器模式下运行普通的 xv6 内核代码。因此,xv6 完全独立地处理定时器中断,与上面介绍的陷阱机制完全不同。
在 main
函数之前的 start.c
中以机器模式执行的代码设置了接收定时器中断的相关内容(kernel/start.c
:57)。其中的一部分工作是编程 CLINT 硬件(核本地中断器)在一定延迟后生成中断。另一部分是设置一个类似于 trapframe 的临时区域,以帮助定时器中断处理程序保存寄存器和 CLINT 寄存器的地址。最后,start 将 mtvec 设置为 timervec 并启用定时器中断。
定时器中断可能发生在用户或内核代码执行时的任何时刻;内核无法在关键操作期间禁用定时器中断。因此,定时器中断处理程序必须以一种不会干扰被中断的内核代码的方式执行其工作。基本策略是让处理程序请求 RISC-V 发出“软件中断”,然后立即返回。RISC-V 使用普通的陷阱机制将软件中断传递给内核,并允许内核禁用它们。可以在 devintr 中看到由定时器中断生成的软件中断的处理代码(kernel/trap.c
:204)。
机器模式定时器中断向量为 timervec(kernel/kernelvec.S
:93)。它在 start 准备的临时区域中保存了一些寄存器,告诉 CLINT 下一次定时器中断的生成时间,请求 RISC-V 发出软件中断,恢复寄存器并返回。在定时器中断处理程序中没有 C 代码。
5.5 现实情况
xv6允许在内核执行时以及执行用户程序时发生设备和定时器中断。即使在内核执行时,定时器中断也会从定时器中断处理程序强制进行线程切换(调用 yield)。在内核线程之间公平地对CPU进行时间切片是有用的,如果内核线程有时会花费大量时间进行计算,而不返回到用户空间。然而,需要内核代码注意它可能会被挂起(由于定时器中断)并稍后在不同的CPU上恢复是 xv6 中一些复杂性的源头。
如果设备和定时器中断仅在执行用户代码时发生,内核可能会变得更简单一些。
在充分支持Typical计算机上的所有设备方面,工作量很大,因为有许多设备,这些设备有许多功能,并且设备与驱动程序之间的协议可能复杂且文档不足。在许多操作系统中,驱动程序占据的代码比核心内核还要多。
UART 驱动程序通过读取 UART 控制寄存器逐字节检索数据;这种模式称为程序化I/O,因为软件正在驱动数据移动。程序化I/O很简单,但在高数据传输速率下使用会过于缓慢。需要以高速传输大量数据的设备通常使用直接内存访问(DMA)。DMA设备硬件直接将传入的数据写入RAM,并从RAM读取传出的数据。现代磁盘和网络设备使用DMA。
DMA设备的驱动程序将在RAM中准备数据,然后使用对控制寄存器的单次写操作来告诉设备处理准备好的数据。
当设备需要在不可预测的时间内引起关注,而且不会太频繁时,中断是合理的。但中断有很高的CPU开销。因此,高速设备(例如网络和磁盘控制器)使用一些减少中断需求的技巧。一个技巧是为一整批传入或传出的请求引发单个中断。另一个技巧是驱动程序完全禁用中断,并定期检查设备是否需要关注。这种技术称为轮询。如果设备操作非常快,则轮询是有意义的,但如果设备大部分时间处于空闲状态,则会浪费CPU时间。一些驱动程序根据当前设备负载动态切换轮询和中断。
UART驱动程序首先将传入数据复制到内核中的缓冲区,然后再复制到用户空间。这在低数据传输速率下是有意义的,但对于产生或消耗数据非常快的设备,这种双重复制可能会显著降低性能。一些操作系统能够直接在用户空间缓冲区和设备硬件之间移动数据,通常使用DMA。