MIT6828 学习笔记 014 (book::c5)

MIT6828 学习笔记 014 (book::c5)

RayAlto OP

中断和设备驱动

驱动指操作系统里管理某个设备的代码:配置设备硬件、使设备执行操作、处理产生的中断、与等待这个设备 I/O 的进程通信。因为驱动与其管理的设备并发执行,所以驱动代码可能很复杂,除此之外驱动还必须了解设备硬件接口,可能有的设备硬件接口非常复杂或者缺少文档。

需要操作系统介入的设备通常被配置产生中断( trap 的一种),内核的 trap 处理代码在检测到设备发起的中断后会调用驱动的中断处理代码,在 Xv6 中,这个分配发生在 devintr (kernel/trap.c:178) 。

许多设备驱动在两个上下文中执行代码:在进程的内核线程中运行的 top half 、在中断时运行的 bottom half , top half 在请求设备进行 I/O 的系统调用(如 readwrite )里被调用,这个代码可能会要求硬件开始执行一个操作(如要求磁盘读取一个块),然后这个代码等待这个操作完成,最后设备完成这个操作后发起一个中断,驱动的中断处理代码(作为 bottom half )分辨出完成了的操作,唤醒对应的进程并命令硬件开始完成其他等待完成的操作。

1. 代码:控制台输入

Xv6 的控制台驱动在 kernel/console.c ,通过连接到 RISC-V 的 UART 串口设备接收人类输入的字符,每次处理一行输入,处理比如 BackspaceCtrl-U 之类的特殊输入字符。像 Shell 之类的用户进程使用 read 系统调用来以行的形式获取控制台输入,当在 QEMU 中对 Xv6 进行输入时,按键通过 QEMU 模拟的 UART 硬件传递给 Xv6 。

驱动进行通信的 UART 硬件是 QEMU 模拟的 16550 芯片,在真实的计算机下, 16550 芯片会管理一个连接到中断会其他计算机的 RS232 串行链路,在 QEMU 中, 16550 “连接”键盘和显示器。 UART 硬件以一套映射在内存里的控制寄存器的形式暴露给软件,也就是说有一些物理地址被 RISC-V 硬件用来连接 UART 设备,对这些物理地址的存取实际上干涉了设备硬件而不是 RAM 。 UART 被映射到从 0x10000000 开始的一段地址 (UART0, kernel/memlayout.h:21) ,里面有一些 UART 控制寄存器,每个寄存器宽为一个 byte ,偏移量定义在 kernel/uart.c:22 ,比如 LSR 寄存器包含了指示输入字符是否在等待被软件读取的 bit 。当有一些输入时可以从 RHR 寄存器里读取这些字符,每读取一个字符, UART 硬件会把它从内部的一个存放待读取字符的 FIFO 中删掉,并在 FIFO 被清空时清除 LSR 寄存器中的 “ready” bit 。 UART 传输硬件几乎独立于接收硬件:如果软件往 THR 寄存器里写入了一个 byte , UART 就传输这个 byte 。

Xv6 的 main 调用 (kernel/main.c:14) consoleinit (kernel/console.c:182) 来初始化 UART 硬件:使 UART 在每次接收一个 byte 的输入时产生一个接收中断,在每次完成一个 byte 的输出时产生一个传输完成中断。 Xv6 Shell 通过 init.c 打开的 (user/init.c:19) 文件描述符来从控制台读取输入,对 read 系统调用的调用最终进入内核的 consoleread (kernel/console.c:80) , colsoleread 通过中断等待输入并缓存到 cons.buf ,把输入复制进用户空间后(读取了一整行)返回到用户进程,当用户输入的还不是完整的一行的情况下,读取进程会通过 sleep 等待用户完成输入。

UART 在接收到一个字符输入后会请求 RISC-V 发起一个中断,来激活 Xv6 的 trap handler ,这个 handler 会调用 devintr (kernel/trap.c:178) , devintr 会查看 RISC-V 的 scause 寄存器来查找来自外部设备的中断,然后要求一个称为 PLIC 的硬件单元来告诉它发生中断的具体设备,如果是 UART , devintr 调用 uartintr (kernel/uart.c:176) 来读取 UART 硬件中等待输入的字符并传递给 consoleintr (kernel/console.c:136) ,因为后面的输入会发起一个新的中断,所以 uartintr 不会 wait ,它的工作只是把输入的字符放进 cons.buf ,直到一整行的输入完成。 consoleintrBackspace 和一些其他字符的处理方式是特化的:当输入了一个 newline , consoleintr 唤醒一个等待状态的 consoleread ,唤醒后 consoleread 会在 cons.buf 看到一整行的输入,把它复制进用户空间后返回到用户空间(通过系统调用机制)。

2. 代码:控制台输出

对连接到控制台的 fd 的 write 系统调用最终会进入到 uartputc (kernel/uart.c:87) ,设备驱动维护一个输出缓冲区 (uart_tx_buf, kernel/uart.c:44) ,所以调用 write 的进程不需要等待 UART 完成发送操作, uartputc 会把这些字符放进 uart_tx_buf 并调用 uartstart 来开始设备传输后返回,只有 uart_tx_buf 满了的情况下 uartputc 才会 wait 。 UART 每次发送完一个 byte 都会产生一个中断, uartintr 调用 uartstart 来检查设备是否真的完成了发送,并把下一个缓冲的处处字符交给设备,所以进程往控制台写了多个 byte 时,基本上第一个 byte 会被 uartputc 调用的 uartstart 发走,剩下的缓存了的 byte 会被来自作为传输完成的中断 uartintruartstart 调用发走。

这里一个重要的设计是缓存和中断,这样可以把设备活动和进程活动解耦:即使没有进程在等待读取输入,控制台驱动也能处理输入;对控制台进行输出进程也不需要等待输出的完成。这种解耦通过允许进程并发执行设备 I/O 来提高性能,在设备较慢(如 UART )或需要即时注意(如输入回显)的情况下尤为重要,这种设计有时称作 I/O 并发

3. 驱动中的并发

consolereadconsoleintr 里都调用了 aquire ,这个调用获取了一个锁,用于在并行读写的情况下保护控制台驱动的数据结构。并发读写有三个风险:

  1. 两个在不同 CPU 的进程可能同时调用 consoleread
  2. 硬件可能在 CPU 正在 consoleread 里执行时请求 CPU 发送一个控制台中断
  3. 执行 consoleread 时硬件可能给不同的 CPU 发送控制台中断

这些风险可能会造成资源竞争或死锁,驱动的并发需要关注的另一个点是一个进程可能在等待一个设备的输入,但输入的中断信号可能在别的进程(或根本没有运行的进程)正在运行时出现,所以 interrupt handler 不应该考虑自己打断的进程或代码,比如 interrupt handler 不能保证对当前进程分页表的 copyout 调用是安全的。

4. 计时器中断

Xv6 使用计时器中断来维护自己的锁并允许自己在计算密集型进程间切换, usertrapkerneltrap 里的 yield 调用就会引起这种切换。计时器中断来自连接到 RISC-V 的每个 CPU 的时钟硬件。 Xv6 给这个时钟硬件编程为阶段性地中断每个 CPU 。 RISC-V 需要计时器中断发生在 machine mode 下,而不是 supervisor mode , RISC-V machine mode 下没有分页,且有一套独立的控制寄存器,所以在 machine mode 下运行普通的 Xv6 内核代码是不现实的,这导致了 Xv6 对计时器中断的处理与之前介绍的 trap 机制完全分离。

start.c 的代码运行在 machine mode ,在 main 之前配置自己接收计时器中断 (timerinit, kernel/start.c:63) 这段代码的一部分工作是对 CLINT (Core-Local INTerruptor) 硬件进行编程,使它在一定的延迟后产生中断,另一部分是配置一个 scratch 区域(类似于 trapframe )被计时器中断 handler 用来保存寄存器和 CLINT 寄存器的地址,最后, start (kernel/start.c:21) 把 mtvec 设置为 timervec 并启用计时器中断。

计时器中断可能在用户或内核代码执行的任意时刻发生,即使内核在执行危险操作时也无法阻止计时器中断,因此计时器中断的 handler 必须在保证自己不会扰乱被中断的内核代码的前提下完成自己的工作,实现这个要求的基本策略是 handler 要求 RISC-V 发起软件中断后立即返回, RISC-V 把软件中断按照一般 trap 的机制传给内核,并允许内核禁止这些中断,这种处理计时器中断生成的软件中断的代码在 devintr (kernel/trap.c:205) 。

machine mode 的计时器中断的 handler 是 timervec (kernel/kernelvec.S:95) ,它把一些寄存器保存到 start 准备好的 scratch 区域、告诉 CLINT 产生下一个计时器中断的时间、要求 RISC-V 发起一个软件中断、恢复寄存器,最后返回。计时器中断 handler 里没有 C 代码。

5. 联系现实

Xv6 允许在内核或用户程序中运行时发生设备或计时器中断,计时器中断会引起一次线程交换(即使正在执行内核),这样可以做到很公平的分时,但也需要内核代码时刻考虑自己可能被中断。如果限制设备或计时器中断只能在执行用户代码时发生的话会在一定程度上使内核的代码变得简单。

给典型计算机的所有设备提供支持是一项巨大的工作,因为世界上有很多设备,每个设备有很多特性,设备与设备间的协议可能十分复杂且缺少文档,许多操作系统里驱动的代码反而比内核的代码多得多。

UART 驱动通过读取控制寄存器每次读取一个 byte 的数据,这种模式称为可编程输入输出 (programmed I/O) 因为驱动数据移动的是软件。可编程 I/O 简单,但在高数据流量的情况下很慢,需要高速移动很多数据的设备通常使用直接内存访问 (DMA, Direct Memory Access) , DMA 设备硬件直接把输入数据写进 RAM ,从 RAM 读取输出数据。现代的磁盘和网络设备使用 DMA 。 DMA 设备的驱动可以在 RAM 中准备数据,然后对一个控制寄存器进行一次写入来告诉设备处理这些数据。

当设备不知所措需要注意时中断起了大作用,但高频率的中断会占用很高的 CPU 资源,所以网络和磁盘控制器之类的高速设备使用一些技巧来减少引起中断的次数。一种技巧是一次中断带上一堆出/入请求;另一种技巧是驱动完全禁用中断,阶段性检查设备是否需要注意,这种技术叫做轮询 (polling) ,如果设备执行操作非常快的话 轮询 非常有用,但如果这个设备大部分时间都是空闲的,则会浪费一些 CPU 资源,所以一些驱动根据设备的负载在轮询和中断模式中动态切换。

UART 驱动先把输入数据放进内核的缓冲区,然后传进用户空间,这在数据流量低的情况下非常合理,但这样两次传递对于生成/消耗数据频率含高的设备来说是巨大的性能损失,一些操作系统可以直接把数据在用户空间的缓冲区和设备硬件之间移动(通常通过 DMA )。

第一章的时候说过控制台对于应用程序来说只是一个正常的文件,程序通过 readwrite 系统调用进行读写。而一些应用程序可能需要对设备进行一些标准文件系统调用无法实现的设置(如启用/禁用控制台驱动的行缓冲), Unix 操作系统对于这些需求提供了 ioctl 系统调用。