
   \chapter{Interrupts and device drivers}   
    \label{CH:INTERRUPT}     


    \indextext{Driver}    是操作系统中管理特定设备的代码：它配置设备硬件，告诉设备执行操作，处理产生的中断，并与可能正在等待设备 I/O 的进程交互。驱动程序代码可能很棘手，因为驱动程序与其管理的设备同时执行。此外，驱动程序必须了解设备的硬件接口，该接口可能很复杂且记录很少。  

需要操作系统关注的设备通常可以配置为生成中断，这是一种陷阱。内核陷阱处理代码识别设备何时引发中断并调用驱动程序的中断处理程序；在 xv6 中，此调度发生在  {    \tt    devintr   }     \lineref{kernel/trap.c:/^devintr/}   中。  

许多设备驱动程序在两个上下文中执行代码：在进程的内核线程中运行的    \indextext{tophalf}    和在中断时执行的    \indextext{bottomhalf}   。上半部分通过系统调用（例如  {    \tt    read   }  和  {    \tt    write   } ）来调用，这些系统调用希望设备执行 I/O。该代码可能会要求硬件启动一个操作（例如，要求磁盘读取一个块）；然后代码等待操作完成。最终设备完成操作并引发中断。驱动程序的中断处理程序充当下半部分，确定哪些操作已完成，如果合适，则唤醒等待进程，并告诉硬件开始处理任何等待的下一个操作。  

   \section{代码：控制台输入  }     

控制台驱动程序    \fileref{kernel/console.c}    是驱动程序结构的简单说明。控制台驱动程序通过连接到 RISC-V 的    \indextext{UART}    串行端口硬件接受人类输入的字符。控制台驱动程序一次累积一行输入，处理特殊输入字符，例如退格键和 control-u。用户进程（例如 shell）使用  {    \tt    read   }  系统调用从控制台获取输入行。当您在 QEMU 中向 xv6 键入输入时，您的击键将通过 QEMU 的模拟 UART 硬件传送到 xv6。  

驱动程序与之通信的 UART 硬件是由 QEMU 模拟的 16550chip~    \cite{ns16550a}   。在真实的计算机上，16550 将管理连接到终端或其他计算机的 RS232 串行链路。运行 QEMU 时，它会连接到您的键盘和显示器。  

UART 硬件对于软件来说表现为一组    \indextext{memory-mapped}    控制寄存器。也就是说，RISC-V 硬件有一些物理地址连接到 UART 设备，以便加载和存储与设备硬件而不是 RAM 进行交互。 UART 的内存映射地址从 0x10000000 或  {    \tt    UART0   }  开始
    \lineref{kernel/memlayout.h:/UART0.0x/}    。有几个 UART 控制寄存器，每个寄存器的宽度为一个字节。它们与  {    \tt    UART0   }  的偏移量定义在
    \lineref{kernel/uart.c:/define.RHR/}    。例如，
  {    \tt    LSR   }  寄存器包含指示输入字符是否正在等待软件读取的位。这些字符（如果有）可以从
  {    \tt    RHR   }  寄存器。每次读取一个字符时，UART 硬件都会将其从等待字符的内部 FIFO 中删除，并在 FIFO 为空时清除  {    \tt    LSR   }  中的“就绪”位。 UART 发送硬件很大程度上独立于接收硬件；如果软件向  {    \tt    THR   }  写入一个字节，则 UART 会传输该字节。  

Xv6 的  {    \tt    main   }  调用  {    \tt    consoleinit   } 
\lineref{kernel/console.c:/^consoleinit/}    初始化 UART 硬件。此代码将 UART 配置为在 UART 接收到每个输入字节时生成一个接收中断，并在每次 UART 完成发送一个输出字节    \lineref{kernel/uart.c:/^uartinit/}    时生成一个    \indextext{transmit complete}    中断。  

xv6 shell 通过  {    \tt    init.c   }     \lineref{user/init.c:/open..console/}    打开的文件描述符从控制台读取。对  {    \tt    read   }  系统调用的调用通过内核到达  {    \tt    consoleread   }     \lineref{kernel/console.c:/^consoleread/}    。  {    \tt    consoleread   }  等待输入到达（通过中断）并在  {    \tt    cons.buf   }  中进行缓冲，将输入复制到用户空间，然后（在整行到达后）返回到用户进程。如果用户尚未输入整行，则任何读取进程都将在
  {    \tt    sleep   }  调用
  \lineref{kernel/console.c:/sleep..cons/}    （第~\ref{CH:SCHED}    解释了  {    \tt    sleep   }  的详细信息）。  

当用户键入字符时，UART 硬件会要求 RISC-V 引发中断，从而激活 xv6 的陷阱处理程序。陷阱处理程序调用  {    \tt    devintr   } 
\lineref{kernel/trap.c:/^devintr/}    ，它查看 RISC-V  {    \tt   scause   }  寄存器以发现中断来自外部设备。然后它询问一个称为 PLIC 的硬件单元
    \cite{riscv:priv}    告诉它哪个设备中断了
    \lineref{kernel/trap.c:/plic.claim/}   。如果是 UART，则  {    \tt    devintr   }  调用  {    \tt    UARTINTR   }  。  

 {    \tt    UARTINTR   } 
 \lineref{kernel/uart.c:/^uartintr/}    从 UART 硬件读取任何等待的输入字符并将它们交给  {    \tt    consoleintr   } 
 \lineref{kernel/console.c:/^consoleintr/}  ;它不等待字符，因为将来的输入将引发新的中断。  {    \tt    consoleintr   }  的工作是将输入字符累积在
  {    \tt    cons.buf   }  直到整行到达。
  {    \tt    consoleintr   }  特别对待退格键和其他一些字符。当换行符到达时， {    \tt    consoleintr   }  醒来等待  {    \tt    consoleread   } （如果有）。  

一旦唤醒，  {    \tt    consoleread   }  将观察  {    \tt    cons.buf   }  中的整行，将其复制到用户空间，然后返回（通过系统调用机制）到用户空间。  

   \section{代码：控制台输出  }     

对连接到控制台的文件描述符的  {    \tt    write   }  系统调用最终到达
  {    \tt    UARTPUC   } 
    \lineref{kernel/uart.c}{87}    。设备驱动程序维护一个输出缓冲区（  {    \tt    uart\_tx\_buf   }  ），以便写入过程不必等待 UART 完成发送；相反， {    \tt    UARTPUC   }  将每个字符附加到缓冲区，调用  {    \tt    uartstart   }  启动设备传输（如果尚未准备好），然后返回。  {    \tt    UARTPUC   }  等待的唯一情况是缓冲区已满。  

每次 UART 完成发送一个字节时，都会生成一个中断。
  {    \tt    UARTINTR   }  调用  {    \tt    uartstart   }  ，它检查设备是否确实已完成发送，并将下一个缓冲输出字符交给设备。因此，如果进程将多个字节写入控制台，通常第一个字节将由  {    \tt    UARTPUC   }  调用  {    \tt    uartstart   }  发送，其余缓冲字节将在传输完成中断到达时由  {    \tt    uartstart   }  调用从  {    \tt    UARTINTR   }  发送。  

需要注意的一般模式是通过缓冲和中断将设备活动与进程活动解耦。即使没有进程正在等待读取输入，控制台驱动程序也可以处理输入；随后的读取将看到输入。同样，进程可以发送输出而无需等待设备。这种解耦可以通过允许进程与设备 I/O 并发执行来提高性能，并且当设备速度较慢（如 UART）或需要立即关注（如回显键入的字符）时尤其重要。这个想法有时被称为    \indextext{I/O
  concurrency}    。  

   \section{驱动程序中的并发性  }     

您可能已经注意到  {    \tt    consoleread   }  和  {    \tt    consoleintr   }  中对  {    \tt    acquire   }  的调用。这些调用获取锁，以保护控制台驱动程序的数据结构免遭并发访问。这里存在三个并发危险：不同 CPU 上的两个进程可能会同时调用  {    \tt    consoleread   } ；硬件可能会要求 CPU 提供控制台（实际上是 UART）中断，而该 CPU 已经在内部执行
  {    \tt    consoleread   }  ；并且当  {    \tt    consoleread   }  执行时，硬件可能会在不同的 CPU 上传递控制台中断。这些危险可能会导致竞争或僵局。第~\ref{CH:LOCK}    章探讨了这些问题以及锁如何解决这些问题。  

驱动程序中需要注意并发性的另一种方式是，一个进程可能正在等待来自设备的输入，但是当另一个进程（或根本没有进程）正在运行时，输入的中断信号到达可能会到达。因此，中断处理程序不允许考虑它们中断的进程或代码。例如，中断处理程序无法使用当前进程的页表安全地调用  {    \tt    copyout   } 。中断处理程序通常执行相对较少的工作（例如，仅将输入数据复制到缓冲区），并唤醒上半部分代码来完成其余的工作。  

   \section{定时器中断  }     

Xv6 使用定时器中断来维护其时钟并使其能够在计算密集型进程之间进行切换；  {    \tt    usertrap   }  和  {    \tt    kerneltrap   }  中的  {    \tt    yield   }  调用会导致此切换。定时器中断来自连接到每个 RISC-V CPU 的时钟硬件。 Xv6 对该时钟硬件进行编程以定期中断每个 CPU。  

RISC-V 要求定时器中断在机器模式下进行，而不是在管理程序模式下进行。 RISC-V 机器模式执行时无需分页，并具有一组单独的控制寄存器，因此在机器模式下运行普通 xv6 内核代码是不切实际的。因此，xv6 handlertimer 中断完全独立于上面的陷阱机制布局。  

在  {    \tt    start.c   }  中、在  {    \tt    main   }  之前以机器模式执行的代码设置为接收定时器中断
\lineref{kernel/start.c:/^timerinit/}    。部分工作是对 CLIINT 硬件（核心本地中断器）进行编程，使其在一定延迟后生成中断。另一部分是设置一个临时区域，类似于陷阱帧，以帮助定时器中断处理程序保存寄存器和CLINT寄存器的地址。最后， {    \tt    start   }  将  {    \tt    mtvec   }  设置为  {    \tt    timervec   }  并启用定时器中断。  

定时器中断可以在用户或内核代码执行时的任何时刻发生；内核无法在关键操作期间禁用定时器中断。因此，定时器中断处理程序必须以保证不会干扰中断的内核代码的方式完成其工作。基本策略是处理程序要求 RISC-V 引发“软件中断”并立即返回。 RISC-V通过普通的陷阱机制将软件中断传递给内核，并允许内核禁用它们。处理定时器中断生成的软件中断的代码可以在  {    \tt    devintr   }     \lineref{kernel/trap.c}{205}    中看到。  

机器模式定时器中断处理程序是  {    \tt    timervec   } 
\lineref{kernel/kernelvec.S:/^timervec/}    。它在 {    \tt    start   } 准备的临时区域中保存一些寄存器，告诉CLINT何时生成下一个定时器中断，要求RISC-V引发软件中断，恢复寄存器，然后返回。定时器中断处理程序中没有 C 代码。  

   \section{真实世界  }     

Xv6 允许在内核中执行时以及执行用户程序时发生设备和定时器中断。定时器中断强制从定时器中断处理程序进行线程切换（调用  {    \tt    yield   }  ），即使在内核中执行时也是如此。如果内核线程有时花费大量时间进行计算而不返回用户空间，那么在内核线程之间公平地对 CPU 进行时间切片的能力非常有用。然而，内核代码需要注意它可能会被挂起（由于计时器中断）并稍后在不同的 CPU 上恢复，这是 xv6 中某些复杂性的根源（请参阅~部分~\ref{s:lockinter}    ）。如果设备和定时器中断仅在执行用户代码时发生，则内核可以变得更简单。  

支持典型计算机上的所有设备是一项艰巨的工作，因为设备数量很多，设备具有很多功能，并且设备和驱动程序之间的协议可能很复杂且文档记录很少。在许多操作系统中，驱动程序所占的代码比核心内核还要多。  

UART 驱动程序通过读取 UART 控制寄存器一次检索一个字节的数据；该模式称为    \indextext{programmed I/O}    ，因为软件正在驱动数据移动。编程 I/O 很简单，但在高数据速率下使用速度太慢。需要高速移动大量数据的设备通常使用    \indextext{direct memory access (DMA)}    。 DMA 设备硬件直接将传入数据写入 RAM，并从 RAM 读取传出数据。现代磁盘和网络设备使用 DMA。 DMA 设备的驱动程序将在 RAM 中准备数据，然后使用对控制寄存器的单次写入来告诉设备处理准备好的数据。  

当设备在不可预测的时间需要关注且频率不高时，中断就有意义。但中断的CPU开销很高。因此，高速设备（例如网络和磁盘控制器）使用减少中断需求的技巧。一个技巧是为整批传入或传出请求引发单个中断。驱动程序的另一个技巧是完全禁用中断，并定期检查设备以查看是否需要关注。该技术称为    \indextext{polling}    。如果设备执行操作非常快，则轮询是有意义的，但如果设备大部分时间处于空闲状态，则轮询会浪费 CPU 时间。某些驱动程序根据当前设备负载在轮询和中断之间动态切换。  

UART 驱动程序首先将传入数据复制到内核中的缓冲区，然后复制到用户空间。这在低数据速率下是有意义的，但这样的双副本会显着降低快速生成或消耗数据的设备的性能。一些操作系统能够直接在用户空间缓冲区和设备硬件之间移动数据，通常使用 DMA。  

正如章节~\ref{CH:UNIX}    中提到的，控制台对应用程序来说是一个常规文件，应用程序使用    \lstinline{read}    和    \lstinline{write}    系统调用读取输入和写入输出。应用程序可能想要控制无法通过标准文件系统调用表达的设备方面（例如，在控制台驱动程序中启用/禁用行缓冲）。 Unix 操作系统支持针对此类情况的    \lstinline{ioctl}    系统调用。  

计算机的某些用途要求系统必须在充足的时间内做出响应。例如，在安全关键系统中，错过最后期限可能会导致灾难。 Xv6 不适合硬实时设置。硬实时操作系统倾向于与应用程序链接的库，以便进行分析以确定最坏情况的响应时间。 xv6 也不适合软实时应用程序，偶尔错过最后期限是可以接受的，因为 xv6 的调度程序过于简单，并且它的内核代码路径会长时间禁用中断。  

   \section{练习  }     

   \begin{enumerate}


   \item   修改  {    \tt    uart.c   }  以完全不使用中断。您可能还需要修改  {    \tt    console.c   } 。   \item   添加以太网卡驱动程序。  \end{enumerate}     


