Xv6 book: Interrupts and device drivers
Xv6 book 的第五章节
Interrupts and device drivers
驱动是操作系统中管理特定设备的代码,它配置设备硬件,告诉设备执行操作,处理产生的中断,并与可能正在等待来自设备的 I/O 的进程交互。驱动程序代码可能很棘手,因为驱动程序与其管理的设备同时执行。此外,驱动程序必须了解设备的硬件接口,该接口可能很复杂且文档信息很少。
需要操作系统关注的设备通常可以配置为生成中断,这是一种 trap。 内核 trap 处理代码识别设备何时引发中断并调用驱动程序的中断处理程序; 在 xv6 中,此调度发生在 devintr()
(kernel/trap.c)中。
许多设备驱动程序在两个 context 中执行代码:上半部分在进程的内核线程中运行,下半部分在中断时执行。上半部分通过系统调用(例如希望设备执行 I/O 的读写)。 该代码可能会要求硬件开始一项操作(例如,要求磁盘读取一个块);然后代码等待操作完成。最终设备完成操作并引发中断。 驱动程序的中断处理程序充当下半部分,确定哪些操作已完成,在适当的情况下唤醒等待进程,并告诉硬件开始处理任何等待的下一个操作。
Code: Console input
console 驱动程序(kernel/console.c)是驱动程序结构的简单说明。 console driver 通过连接到 RISC-V 的 UART 串行端口硬件接受人类输入的字符。console driver 一次累积一行输入,处理特殊输入字符,例如退格键和 Control-u。用户进程(例如 shell)使用 read()
系统调用从 console 获取输入行。当你在 QEMU 中向 xv6 输入时,你的击键将通过 QEMU的 模拟 UART 硬件传送到 xv6。
驱动程序与之通信的 UART 硬件是由 QEMU 模拟的 16550 芯片。在真实的计算机上,16550 将管理连接到终端或其他计算机的 RS232 串行链路。运行 QEMU 时,它会连接到您的键盘和显示器。
UART 硬件对于软件来说就像一组内存映射的控制寄存器。也就是说,RISC-V 硬件有一些物理地址连接到 UART 设备,以便加载和存储与设备硬件而不是RAM进行交互。UART 的内存映射地址从 0x10000000
或 UART0
(kernel/memlayout.h) 开始。有几个 UART 控制寄存器,每个寄存器都有一个字节的宽度。它们与 UART0
的偏移量在 kernel/uart.c 中定义。例如,LSR 寄存器包含指示输入字符是否正在等待软件读取的bits。这些字符(如果有)可从 RHR 寄存器中读取。每次读取一个字符时,UART 硬件都会将其从等待字符的内部 FIFO 中删除,并在FIFO为空时清除 LSR 中的 “ready” bit。UART发 送硬件很大程度上独立于接收硬件; 如果软件向 THR 写入一个字节,则 UART 会传输该字节。
Xv6 的 main()
调用 consoleinit
(kernel/console.c)来初始化 UART 硬件。此代码将 UART 配置为在 UART 接收每个输入字节时生成接收中断,并在每次 UART 完成发送一个输出字节时生成发送完成中断 (kernel/uart.c)。
Xv6 shell 通过 user/init.c 打开的文件描述符从 console 读取。对 read
系统调用的调用通过内核到达 consoleread()
(kernel/console.c)。consoleread()
等待输入到达(通过中断)并缓冲在 cons.buf
中,将输入复制到用户空间,然后(在整行到达后)返回到用户进程。 如果用户还没有输入完整的行,任何读取进程都将在 sleep 调用中等待 (kernel/console.c)。
当用户键入字符时,UART 硬件会要求 RISC-V 引发中断,然后就到了 xv6 的 trap 处理程序。trap 处理程序调用 devintr()
(kernel/trap.c),它查看 RISC-V scause
寄存器以发现中断来自外部设备。然后它要求称为 PLIC 的硬件单元告诉它哪个设备中断了。如果是 UART,则 devintr()
调用 uartintr()
。
uartintr()
(kernel/uart.c)从 UART 硬件读取任何等待的输入字符并将它们交给 consoleintr()
(kernel/console.c);它不等待字符,因为将来的输入将引发新的中断。consoleintr()
的工作是在 cons.buf
中累积输入字符,直到整行到达。consoleintr()
特别对待退格键和其他一些字符。当换行符到达时,consoleintr()
会唤醒一个正在等待的 consoleread()
(如果有的话)。
一旦被唤醒,consoleread()
将遍历 cons.buf
中的一整行,将其复制到用户空间,然后返回(通过系统调用机制)到用户空间。
Code: Console output
对连接到 console 的文件描述符的 write()
系统调用最终会调用 uartputc()
(kernel/uart.c)。设备驱动程序维护一个输出缓冲区(uart_tx_buf
),以便写入进程不必等待 UART 完成发送; 相反,uartputc()
将每个字符附加到缓冲区,调用 uartstart()
启动设备传输(如果尚未传输,然后返回。uartputc()
需要等待的唯一情况是缓冲区已满。
每次 UART 完成发送一个字节时,都会生成一个中断。uartintr()
调用 uartstart()
,它检查设备是否确实已完成发送,并将下一个缓冲的输出字符交给设备。 因此,如果进程将多个字节写入 console,通常第一个字节将通过 uartputc()
对 uartstart()
的调用发送,其余缓冲字节将在传输完成中断到达时通过 uartintr()
的 uartstart()
调用发送。
Concurrency in drivers
您可能已经注意到 consoleread()
和 consoleintr()
中对 acquire()
的调用。这些调用获取锁,以保护 console 驱动程序的数据结构免受并发访问。这里存在三个并发带来的问题:
- 不同 CPU 上的两个进程可能同时调用
consoleread()
- 当 CPU 已经在
consoleread()
内执行时,硬件可能会要求 CPU 传递 console(实际上是 UART)中断 - 当
consoleread()
执行时,硬件可能会在不同的 CPU 上传递 console 中断。 这些危险可能会导致竞争或僵局。
驱动程序中需要注意并发的另一种方式是,一个进程可能正在等待来自设备的输入,但是当另一个进程(或根本没有进程)正在运行时,输入的中断信号到达可能会到达。因此,中断处理程序不允许在导致它们中断的进程或代码做一些考量。例如,中断处理程序无法安全地使用当前进程的页表调用 copyout()
。中断处理程序通常执行相对较少的工作(例如,仅将输入数据复制到缓冲区),并唤醒上半部分代码来完成其余的工作。
Timer interrupts
Xv6 使用时钟中断来维护其时钟并使其能够在计算密集型进程之间切换;usertrap()
和 kerneltrap()
中的 yield()
调用会导致这种切换。时钟中断来自连接到每个 RISC-V CPU 的时钟硬件。Xv6 对该时钟硬件进行编程,以定期中断每个CPU。
RISC-V 要求时钟中断在 machine 模式下进行,而不是在 supervisor 模式下进行。RISC-V machine 模式执行时无需分页,并具有一组单独的控制寄存器,因此在 machine 模式下运行普通 xv6 内核代码是不切实际的。 因此,xv6 完全独立于上面列出的 trap 机制来处理时钟中断。
在 main()
之前的 start.c 中以 machine 模式执行的代码设置为接收时钟中断 (kernel/start.c)。 部分工作是对 CLIINT 硬件(core-local interruptor)进行编程,使其在一定延迟后生成中断。另一部分是设置一个临时区域,类似于 trapframe,以帮助时钟中断处理程序保存寄存器和CLINT
寄存器的地址。最后,start()
将 mtvec
设置为 timervec
并启用时钟中断。
时钟中断可以在用户或内核代码执行时的任何时刻发生;内核无法在关键操作期间禁用定时钟中断。 因此,时钟中断处理程序必须以保证不会干扰中断的内核代码的方式完成其工作。处理程序的基本策略是要求 RISC-V 引发 “software interrupt” 并立即返回。 RISC-V 通过普通的 trap 机制将软件中断传递给内核,并允许内核禁用它们。 处理时钟中断生成的软件中断的代码可以在 devintr()
(kernel/trap.c) 中看到。
machine 模式时钟中断处理程序 timervec
(kernel/kernelvec.S)。它在启动准备的暂存区域中保存一些寄存器,告诉 CLINT 何时生成下一个定时钟中断,要求 RISC-V 引发软件中断,恢复寄存器并返回。时钟中断处理程序中没有 C 代码。
Real world
Xv6 允许在内核中执行以及执行用户程序时发生设备和时钟中断。 时钟中断强制从时钟中断处理程序进行线程切换(调用 yield()
),即使在内核中执行时也是如此。如果内核线程有时花费大量时间进行计算而不返回用户空间,那么在内核线程之间公平地对CPU进行时间切片的能力非常有用。然而,内核代码需要注意它可能会被挂起(由于时钟中断)并稍后在不同的CPU上恢复,这是 xv6 中一些复杂性的根源。如果设备和时钟中断仅在执行用户代码时发生,则内核可以变得更简单。
支持典型计算机上的所有设备是一项艰巨的工作,因为有很多设备,这些设备有很多功能,并且设备和驱动程序之间的协议可能很复杂并且文档质量良莠不齐。在许多操作系统中,驱动程序所占的代码比核心内核还要多。
UART 驱动程序通过读取 UART 控制寄存器一次检索一个字节的数据; 这种模式称为 programmed I/O,因为软件驱动着数据的移动。programmed I/O 很简单,但速度太慢,无法在高数据速率下使用。需要高速移动大量数据的设备通常使用直接内存访问 (DMA)。DMA 设备硬件直接将传入数据写入 RAM,并从 RAM 读取传出数据。现代硬盘和网络设备使用 DMA。DMA 设备的驱动程序将在RAM中准备数据,然后使用对控制寄存器的单次写入来告诉设备处理准备好的数据。
当设备在不可预测的时间(但不是太频繁)需要关注时,中断就有意义。但中断的 CPU 开销很高。 因此,高速设备(例如网络和磁盘控制器)使用减少中断需求的技巧。一个技巧是为整批传入或传出请求引发一个中断。另一个技巧是驱动程序完全禁用中断,并定期检查设备以查看是否需要关注。这种技术称为轮询。如果设备执行操作速度非常快,则轮询是有意义的,但如果设备大部分时间处于空闲状态,则轮询会浪费 CPU 时间。某些驱动程序根据当前设备负载在轮询和中断之间动态切换。
UART 驱动程序首先将传入数据复制到内核中的缓冲区,然后复制到用户空间。这在低数据速率下是有意义的,但这样的双副本会显着降低快速生成或消耗数据的设备的性能。一些操作系统能够直接在用户空间缓冲区和设备硬件之间移动数据,通常使用 DMA。
正如第 1 章中提到的,console 对应用程序来说就像一个常规文件,应用程序使用 read()
和 write()
系统调用来进行读写。应用程序可能想要控制无法通过标准文件系统调用表达的设备方面(例如,在 console 驱动程序中启用/禁用行缓冲)。Unix 操作系统支持这种情况下的 ioctl
系统调用。
计算机的某些用途要求系统必须在有限的时间内做出响应。例如,在安全关键系统中,错过最后期限可能会导致灾难。Xv6 不适合 hard real-time,那样的操作系统往往是实现成与应用程序链接的库,其方式允许分析以确定最坏情况的响应时间。xv6 也不适合 soft real-time 应用程序,因为偶尔错过截止日期是可以接受的,而 xv6 的调度程序过于简单,并且存在长时间禁用中断的内核代码执行流。