驱动,是操作系统中管理特定设备的代码:它注册设备硬件,告诉设备执行操作,处理结果中断,并与可能在等待设备I/O的进程通信。驱动代码可能有很多小花招,因为驱动与它管理的设备协同执行。此外,驱动必须理解设备的硬件接口,这些接口可能很繁琐而且缺乏文档。
需要操作系统关注的设备通常在操作系统中注册,以生成中断,中断是trap的一种。内核trap处理代码识别到设备产生中断后,调用驱动的中断handler;在xv6中,这个分发过程在devintr(kernel/tarp.c:177)中。
很多设备驱动在两种上下文中执行代码:上半段运行在进程的内核线程中,下半段在中断时刻执行。上半段被read,write之类的系统调用所调用,来让设备执行I/O操作。这些代码可能请求硬件开始一项操作(例如,请求硬盘读一个块);然后等待操作完成。最终设备完成了操作,触发中断。设备的中断handler,扮演下半段角色,指出是什么操作已经完成,然后唤醒相应的等待进程,接着告诉硬件开始为执行下一项等待中的操作。
concurrently
控制台驱动(console.c)是驱动结构的一个简单例子。控制台驱动通过RISC-V附带的UART串口硬件接收人类打印的字符。控制台驱动每次积累一行的输入,处理特殊字符例如backspace和ctrl-u。用户进程,例如shell,使用read系统调用,来抓取控制台的输入行。当你在QEMU中向xv6输入时,你的按键通过QEMU的模拟UART硬件传递到xv6。
跟驱动交互的UART硬件QEMU模拟的一块16550芯片。在真实计算机上,16550能管理连接到终端或其他计算机的RS232串口。当运行QEMU时,它连接到你的键盘和显示器上。
UART硬件对软件来说就是一组映射在内存上的控制寄存器。就是说,RISC-V硬件上的一些物理地址连接到UART设备,所以在这些地址上加载和保存是跟设备硬件交互,而不是RAM。UART内存映射地址从0x10000000开始,或UART0(kernel/memlayout.h:21)。有一些UART控制寄存器,每个都有1字节的宽度。它们到UART0的偏离被定义在(kernel/uart.c:22)。举例来说,LSR寄存器中包含的位,表示输入的字符是否正等待被软件读取。这些字符(如果有的话)可以被RHR寄存器读取。每读到一个,UART硬件就将它从内部的等待字符FIFO队列中删除,当FIFO队列被清空,硬件会清掉LSR中的“就绪”位。UART的传输硬件基本上与接收硬件独立;如果软件向THR写入一个字节,UART将发送它。
xv6的main函数调用consoleinit(kernel/console.c:184)来初始化UART硬件。这段代码注册UART,当UART每接收到一个字节的输入时,产生一个接收中断,UART每次发送完一个字节的输出时,产生一个发送完毕中断(kernel/uart.c:53)。
xv6 shell通过init.c(user/init.c:19)所打开的文件描述符从控制台读取内容。对read系统调用的调用通过kernel调到consoleread(kernel/console.c:82)。consoleread等待输入到来(从中断)并在cons.buf缓存,然后将输入草被盗用户空间,接着(在一整行都到达后)返回到用户进程中。如果用户还没有键入一整行,任何读取进程将在sleep调用(kernel/console.c:98)中等待(第7章详细解释了sleep的细节)。
当用户输入一个字节,UART硬件请求RISC-V产生一个中断,这将激活xv6的trap handler。trap handler调用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中看到一个完整的行,将它拷贝到用户空间,然后(通过系统调用机制)返回到用户空间。
keystrokes
连接到控制台的文件描述符执行write系统调用,最终会到达uartputc(kernel/uart.c:87)。设备驱动维护一个输出缓冲区(uart_tx_buf),这样写入进程就不需要等待UART完成发送;uartputc将每个字符添加到缓冲区,调用uartstart来启动设备发送(如果还没启动),然后返回。uartputc需要等待的唯一情况是缓冲区已满。
UART每完成一字节的发送都会产生一个中断。uartintr调用uartstart来检查设备是否完成了发送,然后把下一个缓冲中的输出字符传给设备。因此,如果一个进程向控制台写了很多字节,一般而言,第一个字节会被uartputs调用的uartstart所传递,剩余的缓存区字节将被传输完成中断中的uartintr调用的uartstart发送。
需要注意的一般模式是通过缓冲和中断将设备活动与进程活动解耦。即使在没有进程等待读取的时候,控制台驱动也可以执行输入;后续的读取将看到这个输入。相似地,进程不需要等待设备就能发送输出数据。通过允许进程与设备I/O协同运行,这种解耦能够提升性能。另外,这种解耦在低速设备(比方UART)且需要即时反馈时(例如显示输入的字符)非常重要。这种方式有时称为I/O concurrency。
concurrently
你可能已经注意到,在consoleread函数和consoleintr函数中会调用acquire。这些调用获取到一把锁,这把锁保护控制台驱动的数据结构不会被并行访问所干扰。这里有三种并发的危险:在不同CPU上执行的两个进程可能同时调用consoleread;当CPU已经执行在consoleread中时,硬件可能请求这个CPU传递一个控制台(实际是UART)中断;硬件也可能在CPU执行consoleread时,请求另一个CPU传递控制台中断。第6章探索了锁如何在这些场景中发挥作用。
另一种驱动中需要关注的可能产生问题的并行场景是,当一个进程可能正在等待设备输入,但输入中断信号到来时,另一个进程(或者没有进程)正在运行。因此中断handler无法考虑它们打断的进程或代码。举例来说,中断处理器不能使用当前进程的页表安全地调用copyout。中断处理器通常只做相对较少的工作(例如向缓冲区拷贝数据),然后唤醒上半部代码来做剩下的部分。
xv6使用计时器中断来维护时钟,也用来让xv6在计算密集型进程之间切换;yield调用usertrap与kerneltrap来引起这种切换。计时器中断来自RISC-V CPU附带的时钟硬件。xv6为这个时钟硬件编程,从而在每个CPU周期性地产生中断。
RISC-V需要计时器中断在机器模式下被处理,而不是管理者模式。RISC-V机器模式下的运行没有分页,并且有一组独立的控制寄存器,因而在机器模式下不能运行普通的xv6内核代码。所以,xv6对计时器中断的处理与之前所讲的trap机制完全不同。
start.c中执行在机器模式下的,main之前的代码代码,设置好接收计时器中断(kernel/start.c:57)。工作的一部分是编程CLINT硬件(核心局部中断器),来一段延迟后产生中断。另一部分是设置一个scratch区域,类似于trapframe,用来协助计时器中断处理器保存寄存器与CLINT寄存器的地址。最后,start将mtvec寄存器设置为timervec然后是能计时器中断。
计时器中断可以在用户代码或内核代码执行的任何时候发生,内核在关键操作时也不能关掉计时器中断。因此,计时器中断handler必须使用一种不会打扰被中断的内核代码的方式工作。基本策略是由handler请求RISC-V产生一个“软中断”然后直接返回。RISC-V使用普通的trap机制向内核传递软中断,而且允许内核关掉它们。处理计时器中断产生的软件中断的代码在devintr(kernel/trap.c:204)中。
机器模式计时器中断向量表是timervec(kernel/kernelvec.S:93)。它将一些寄存器保存在start准备好的scratch区域中,告诉CLINT产生下一个时钟中断的时间,请求RISC-V发起一个软中断,恢复寄存器 ,然后返回。在计时器中断handler中没有C代码。
compute-bound periodically scratch area analogous
不管是内核运行是还是用户程序运行时,xv6都允许设备和计时器中断。计时器中断在计时器中断handler中强制切换线程(调用yield),即使在内核运行时也一样。当内核线程有时花费大量时间进行计算而不返回用户空间时,CPU在内核线程之间公平划分时间片的能力很重要。然而,内核代码需要注意它可能被暂停(因为计时器中断)然后恢复在不同的CPU上,xv6因此增加了复杂度。如果设备和计时器中断只发生在用户代码执行期间,内核就能写的更加简单。
支持一台典型计算机上的所有设备是一项繁重的工作,因为有很多设备,这些设备有很多特性,而且设备和驱动程序之间的协议可能很复杂,文档也很差。在很多操作系统中,驱动代码的数量要多于核心内核。
UART驱动通过读取UART控制寄存器的方式,每次读取一字节数据;这种模式叫做程序化I/O,因为软件在驱动数据的移动。程序化I/O很简单,但在高数据速率下显得太慢。需要高速移动大量数据的设备通常使用直接内存访问(DMA,direct memory access)。DMA设备硬件直接将输入数据写入到RAM,并从RAM读取输出数据。现代硬盘和网络设备使用DMA。DMA设备驱动会在RAM中准备数据,然后向一个控制寄存器简单写入,来告诉设备处理准备好的数据。
当设备需要关注的次数不可预知而且不算太频繁时,中断是有效的。但是中断的CPU开销很高。因此网络和硬盘控制器之类的高速设备,使用一些花招来减少对中断的需求。一个小技巧是一整批输入或输出请求只产生一个中断。另一个技巧,是驱动完全禁止中断,然后周期性地查询,看设备是否需要关注。这个技术叫做轮询(polling)。在设备执行操作非常快的情况下,轮询是有效的。但如果设备大部分时间都是空闲的,那轮询就会浪费计算机时间。有些驱动根据当前设备的负载在轮询和中断之间切换。
UART设备首先将输入数据拷贝到一段内核缓冲区中,然后拷贝到用户空间。这在低数据速率下很有效,但对于生产或消费数据非常快的设备来说,这种二次拷贝会明显地降低性能。有些操作系统能能够直接在用户空间缓冲区与硬件设备之间直接移动数据,通常是使用DMA。
overhead batch