MIT6.S081 ---- Preparation: Read chapter 5
Chapter 5 Interrupts and device drivers
理解当设备产生中断时:
CPU 会发生什么?
如何从设备读、写信息?中断相比系统调用的新特点
- 中断是异步的(asynchronous)。当硬件产生一个中断时,中断处理程序开始运行,中断处理程序可能和 CPU 上正在运行的进程没有关系,系统调用在进入内核后,在当前进程的上下文中运行,而且就是当前进程调用(通过 ecall 指令)的。
- 并行(Concurrency): CPU 和 设备是并行运行的。
- 编程(Program):如网卡和 UART 必须被编程配置,查看设备手册。
驱动是操作系统管理特定设备的代码,负责:
- 配置硬件设备
- 通知设备执行操作
- 处理设备产生的中断
- 与等待设备 I/O 的进程进行交互
驱动代码可能很复杂,因为一个驱动和它管理的设备并行执行。而且,驱动必须理解设备的硬件接口,这些接口很难,且缺乏文档。
需要操作系统关注处理(attention)的设备通常能被配置产生中断,中断是一种 trap 。内核 trap handling 代码识别一个设备何时产生一个中断,并且调用驱动的中断处理程序;在 xv6 中, 识别-分发调用 在 devintr
(kernel/trap.c) 中。
许多设备驱动代码在两个上下文(contexts)中执行代码:上半部分(\(top\ half\))在进程的 kernel thread 中执行,下半部分(\(bottom\ half\))在中断期间执行:
上半部分通过调用 write 和 read 等系统调用请求设备执行 I/O:
- 代码请求硬件开启一个操作(如:请求硬盘读一个块)。
- 然后代码等待 I/O 操作的完成(由下半部分处理)。
- 最终设备完成操作并发出一个中断。
下半部分执行驱动的中断处理程序:
- 找出已完成的操作(设备完成操作后会发出中断)。
- 在适当时机唤醒等待的进程(之后由上半部分处理)。
- 通知硬件开启下一个操作的执行。
Code: Console input
console 驱动程序(kernel/console.c)是驱动程序结构的简单示范。
console 驱动程序通过连接到 RISC-V 的 UART 串行端口硬件接受用户输入的字符。console 驱动程序一次积累一行输入,处理如 backspace 和 control-u 等特殊字符。
用户进程,如 shell,使用 read 系统调用从 console 获取输入行。当向 QEMU 中的 xv6 输入时,按键内容通过 QEMU 模拟的 UART 硬件传给 xv6。
和驱动程序交互的 UART 硬件是 QEMU 模拟的 \(16550\) 芯片。在真正的计算机上,\(16550\) 管理连接到终端或其他计算机的 \(RS232\) 串行链路。当 QEMU 运行时,它会连接到你的键盘或显示器。
对于软件来说,UART 是一组内存映射(memory-mapped)的控制寄存器。RISC-V 硬件将一些物理地址对应到 UART 设备,对这些地址的 L/S 指令是与设备硬件交互而不是与 RAM 交互。 UART 的内存映射地址在 \(0x10000000\) , UART0
(kernel/memlayout.h)。有一些 UART 控制寄存器,每个寄存器宽一字节,他们相对 UART0
的偏移定义在(kernel/uart.c:22)。 如:$LSR
寄存器含有指示是否有正在等待被软件读取的输入字符的标志位。如果有字符,则可以从 $RHR
寄存器读取。每次读取一个,UART 硬件会从等待字符的内部 FIFO 队列删除它,当 FIFO 为空时,clear $LSR
中的 "ready" 位。UART 传输硬件很大程度上独立于接收硬件;如果软件向 $THR
写一个字节,则 UART 传输那个字节。
xv6 的 main
调用 consoleinit
(kernel/console.c) 初始化 UART 硬件。配置 UART :当 UART 接收一个输入字节时,产生一个接收中断(receive interrupt);当 UART 发送一个输出字节时,产生一个传输完成中断(transmit complete interrupt)(kernel/uart.c:uartinit())。
xv6 shell 通过 user/init.c:19
打开的文件描述符从 console 读取数据。调用 read 系统调用到达内核的 consoleread
(kernel/console.c:80)。consoleread
等待输入到来(通过中断),并在 cons.buf
中缓冲,将输入拷贝到用户空间,并且(输入一整行后)返回用户进程。如果用户没有输入完一整行,任何读进程都将等待在 sleep 调用(kernel/console.c:96)。
当用户键入一个字符时:
-
UART 硬件会请求 RISC-V 发出一个中断,激活 xv6 的 trap handler
-
trap handler 调用
devintr
(kernel/trap.c:177),查看 RISC-V 的$scause
寄存器发现中断来自外设 -
然后 trap handler 请求一个 PLIC 硬件单元,PLIC 告知是哪个设备中断
-
如果是 UART,
devintr()
调用uartintr
-
uartintr
(kernel/uart.c:180) 从 UART 硬件读取等待的输入字符。 -
将读取的字符串逐个交给
consoleintr
(kernel/console.c:136)处理。不需要等待字符,因为未来的输入将产生一个新的中断。 -
consoleintr
的工作是在cons.buf
中积累输入字符直到一整行输入到达。 -
consoleintr
特殊处理 backspace 和一些特殊字符。 -
当新行到达时,
consoleintr
唤醒等待的consoleread
。 -
一旦唤醒,
consoleread
将cons.buf
中的完整的一行复制到用户空间。 -
然后(通过系统调用机制)返回用户空间。
Code: Console output
一个 write 系统调用通过连接 console 的文件描述符最终到 uartputc
(kernel/uart.c:87)。设备驱动维护了一个输出缓冲(uart_tx_buf
),所以写进程没有必要等待 UART 完成发送;相反,uartputc
将字符加到缓冲,调用 uartstart
开启设备传输(如果还没有开启),并返回。uartputc
等待的唯一情况是缓冲区满。
UART 每发送完一个字节,产生一个中断。uartintr
调用 uartstart
,检查设备是否已经完成发送,将下一个缓冲的输出字符交给设备。因此,如果一个进程向 console 写入多个字节,通常第一个字节通过 uartputc
调用 uartstart
发送,当传输完成中断(transmit complete interrupt)到达时,剩余的缓冲字节将通过 uartintr
调用 uartstart
发送。
需要注意的一般模式是通过缓冲和中断将设备活动和进程活动分离。即使没有进程正在等待读取输入, console 驱动也能处理输入;随后的 read 也能看到输入。同样的,进程无需等待设备也能发送输出。这种解耦能通过进程和 I/O 设备并行执行提高性能,特别在设备运行缓慢(如 UART)或者需要立即注意处理时尤为重要(如回显键入的字符)。这种思想被称作 I/O 并行(\(I/O\ concurrency\))。
Concurrency in drivers
在 consoleread
和 consoleintr
中需要调用 acquire
。这个调用获取一个锁,保护 console 驱动的数据结构并发访问。
这里有三个并发危险:
- 不同 CPUs 上的两个进程可能会同时调用
consoleread
; - 当 CPU 正在执行
consoleread
的代码时,硬件可能要求 CPU 发送一个 console(实际是UART) 中断; - 当
consoleread
正在执行时,硬件可能在不同的 CPU 上发出一个 console 中断。
这些危险可能导致条件竞争(race conditions)或者死锁(deadlocks)。第6章谈论这些问题和锁如何解决这些问题。
在驱动程序中并发需要关注的另一种情况是:一个进程可能正在等待来自设备的输入,但当另一个进程(或根本没有进程)正在运行时,输入的中断信号可能到达。因此,中断处理程序不能考虑被它们中断的进程或代码。例如,一个中断处理程序不可能用并发进程的页表安全的调用 copyout
。中断处理程序通常做相对少的工作(如,只将输入数据复制到缓冲区),唤醒上半部分代码做剩余的工作。
Timer interrupts
xv6 使用定时器中断维护它的时钟,能够用它在计算受限进程间进行切换;usertrap
和 kerneltrap
中的 yield
完成这个切换。定时器中断来自于每个 RISC-V CPU 连接的时钟硬件。xv6 对时钟硬件进行编程,周期中断每个 CPU 。
RISC-V 要求定时器中断在 machine-mode 下进行,而不是 supervisor-mode 下。RISC-V machine-mode 没有分页,有一组独立的控制寄存器,因此在 machine-mode 下运行一般的 xv6 内核代码是不实际的。因此 xv6 完全独立于 trap 机制单独处理定时器中断。
main()
之前执行的 start.c 代码运行在 machine-mode,设置接收定时器中断(kernel/start.c:62)。一部分工作是编程 CLINT 硬件(core-local interruptor)在特定的延迟后产生一个中断。另一部分工作是设置 scratch 区域,类似 trapframe,帮助定时器中断处理程序保存寄存器和 CLINT 寄存器的地址。最终,start
设置 mtvec
为 timervec
, 并开启定时器中断。
定时器中断能发生在任何时间点,无论正在执行用户代码还是内核代码;内核无法在关键操作期间关闭定时器中断。因此定时器中断处理程序必须保证它的行为不会破坏被中断的内核代码。定时器中断处理程序的基本策略是要求 RISC-V 发出“软中断”(software interrupt)并立即返回。RISC-V 用普通的 trap 机制向内核发送软中断,并允许内核关中断。处理定时器中断产生的软中断的代码在 devintr
(kernel/trap.c:204)。
machine-mode 定时器中断处理程序是 timervec
(kernel/kernelvec.S)。它在 start
准备的 scratch 区域保存保存一些寄存器,告诉 CLINT 什么时候产生下一个定时器中断,请求 RISC-V 产生一个软中断,恢复寄存器并返回。在定时器中断处理程序中没有 C 代码。
Real world
当运行内核代码或用户程序时, xv6 允许外设和定时器中断。尽管执行内核代码,定时器中断会导致一次线程切换(调用 yield
)。如果 kernel threads 有时花费大量时间运行计算而不返回用户空间,那么在 kernel threads 之间公平的对 CPU 进行时间分片的能力是有用的。
然而,内核代码需要注意的是,它可能会被挂起(suspended,由于定时器中断),然后在其他 CPU 上恢复运行,这是 xv6 一些复杂性的缘由(6.6节详解)。如果只在运行用户代码时,定时器和外设产生中断,那么内核会变得简单。
在一台特定的计算机上支持所有的外设是一项非常庞大的工作,因为有很多外设,这些外设有很多功能特性,并且外设和驱动之间的协议可能很复杂且缺少文档。在许多操作系统中,驱动程序代码量大于核心 kernel。
UART 驱动通过读取 UART 控制寄存器一次接收一个字节的数据;因为是软件驱动数据传输,所以这种模式称为编程 I/O(\(programmed\ I/O\))。编程 I/O 简单,但是速度太慢,无法用于高数据速率。
需要高数据速率传输大量数据的设备通常使用 DMA(\(direct\ memory\ access\))。DMA 设备硬件直接将输入数据写到 RAM,从 RAM 中读取输出数据。现代硬盘和网络设备使用 DMA。DMA 设备的驱动程序在 RAM 中准备好数据,然后只用一次写入控制寄存器,通知设备处理准备的数据。
当外设在不可预测的时间需要关注处理,并且不太频繁,则适合中断。
但是中断有很高的 CPU 开销。因此高速设备,如网络和硬盘控制器,使用技巧减少中断的需要:
- 一个技巧是为一整批输入或输出请求发出一个中断。
- 另一技巧是驱动程序完全关中断,周期检查设备是否需要处理。这个技巧称为轮询( \(polling\) )。如果外设执行操作非常快,轮询很有意义,但如果外设常处于 ilde 状态则很浪费 CPU。一些驱动根据当前设备负载在中断和轮询中动态切换。
UART 驱动程序首先将输入数据复制到内核中的缓冲区,然后再复制到用户空间。这适合于低数据速率传输,但是对于快速生成或消耗数据的设备来说,两次复制会显著降低性能。一些操作系统能使用 DMA 直接在用户空间缓冲区和设备硬件之间传输数据。
如 Chapter 1 所述,console 对于应用程序来说作为一个常规文件,应用程序使用 read
、 write
系统调用读取输入、写输出。应用程序可能想控制不可能通过标准的文件系统调用表示的设备层(如:在 console 驱动中关闭/开启行缓冲)。Unix 操作系统为这种情况提供了 ioctl
系统调用。
计算机的一些使用要求操作系统必须在规定时间做出响应。例如,在 safety-critical 操作系统中,错过一个 deadline 会造成灾难。 xv6 不适用于硬实时配置。硬实时操作系统通常用与应用程序链接的库,分析确定最坏情况下的响应时间。 xv6 也不适用与软实时应用,偶尔错过一个 deadline 是可以接受的,因为 xv6 的调度非常简单,它有一段内核代码长时间关中断。