什么是驱动?
驱动是操作系统中用来管理一个特定设备的代码:
- 配置硬件设备;
- 告诉设备执行操作;
- 处理设备中断;
- 跟可能在等待来自设备的I/O的进程进行交互;
驱动代码需要一定的技巧,因为驱动是跟其管理的设备并发执行的。
驱动必须理解设备的硬件接口。
需要操作系统关注的设备通常被配置能生成中断。
当一个设备触发一个中断时,内核陷阱处理代码会识别出来,并调用设备的中断处理程序,比如在xv6中,这种分发机制发生在devintr
中。
许多驱动是在两种上下文中执行代码的:
- 上半部分在进程的内核线程里运行;
- 下半部分在中断时执行;
上半部分是通过诸如read和write等需要设备执行I/O的系统调用被调用的。这段代码可能要求硬件开始一个操作(比如请求磁盘读取块等),然后等着操作完成。最终,设备完成了操作,触发一个中断。
下半部分是设备中断处理程序
- 指出什么操作已完成;
- 如果合适的话,唤醒一个等待的进程;
- 告诉硬件开始处理任何下一个等待的操作;
5.1 代码:控制台输入
控制台驱动是对驱动结构的简单示例。
控制台驱动接收由人通过附加到RISC-V上的UART串行接口键入的字符。
控制台驱动一次累计一行输入,可处理特殊输入字符,比如backspace、control-u等。
诸如shell等用户进程,使用read系统调用来获取来自控制台的输入行。
当你键入输入给在QEMU中的xv6时,你的击键是通过QEMU模拟的UART硬件传递给xv6的。
驱动与之交互的UART硬件是一个由QEMU模拟的16550芯片。在实际的计算机上,16650芯片管理的是一个可连接到终端或者其他计算机的RS232串行链接。当运行QEMU时,16650芯片连接的是键盘和显示器。
UART硬件似乎是一组由内存映射控制寄存器组成的软件。即,RISC-V硬件将一些物理地址连接到UART硬件,使得加载和存储等指令直接跟UART设备交互,而不是RAM。
UART的内存映射地址UART0开始。有一撮UART控制寄存器,每个寄存器的大小是1字节。
从UART0开始的偏移量定义在uart.c中。
比如LSR寄存器包含的位用来标记是否有输入字符正等待着被软件读取。
RAH寄存器:存储可用的输入字符
THR寄存器
每当读取一个字符,UART硬件就会将该字符从等待字符的内部FIFO删除;当FIFO变空时,UART就会清除LSR中的ready标记位。
UART的发送硬件跟接收硬件是独立的。
如果软件向THR寄存器中写入一个字节,UART就发送这个字节。
xv6的main函数调用consoleinit来初始化UART硬件。这段代码配置UART,使得:当UART每接收一个字节的输入,就生成一个接收中断;每当UART每发送完一个字节的输出,就生成一个发送完成中断。
xv6的shell程序通过init.c打开的文件描述符来从终端读取数据。
对read系统调用的调用通过内核进入到consoleread。
consoleread等待着输入到达并缓冲在cons.buf中,拷贝输入到用户空间,返回给用户进程。
如果用户还没有键入一个完整的行,则所有的读取进程都将以sleep调用的形式等待。
当用户键入一个字符时,发生了哪些事?
- 当用户键入一个字符时,UART硬件请求RISC-V触发一个中断,中断会激活xv6的陷阱处理程序。
- 陷阱处理程序调用
devintr
。 -
devintr
查看RISC-V的scause寄存器,发现中断来自一个外部设备,然后请求PLIC硬件单元来告诉它是哪个设备发生了中断。如果是UART,则devintr
会调用uartintr
。 -
uartintr
从UART硬件中读取任何等待的输入字符,将这些字符交给consoleintr
。
uartintr不会等待字符,因为新的输入会触发新的中断。 -
consoleintr
在cons.buf
中累计输入字符直到一整行输入到达;consoleintr
会特殊处理backspace字符和其他一些字符;当有新行到达时,consoleintr
会唤醒一个等待的consoleread
。 - 一旦被唤醒,
consoleread
将观察到在cons.buf
中有一个完成的输入行,把数据从cons.buf
拷贝到用户空间,并通过系统调用机制返回到用户空间。
5.2 代码:控制台输出
对连接到控制台的文件描述符的write系统调用最终会到达uartputc。
设备驱动维护了一个输出缓冲区,使得写进程不必邓艾UART发成发送。
uartputc将每个字符添加到缓冲区,调用uartstart来开始设备发送,并返回。
uartputc等待的唯一情形就是缓冲区已经满了。
每当UART完成发送一个字节,则UART就生成一个中断。
uartintr调用uartstart:检查设备是否已完成发送,交给设备下一个缓冲的输出字符。因此,如果一个进程向控制台写入了多个字节,通常第一个字节是由uartputc调用uartstart发送的,剩余的字节是由uartintr调用uartstart作为发送完成中断达到来发送。
什么是I/O并发?
值得注意的模式:通过缓冲和中断将设备活动与进程活动解耦。即使没有进程在等待读,控制台驱动也能处理输入;下一次读将会看到输入。类似地,进程发送输出也不用等待设备。这种解耦能提升性能,通过允许进程跟设备I/O并发;当设备很慢或者需要立即关注时,就显得特备重要。这种思维就是I/O并发。
5.3 在驱动里的并发
注意到在consoleread和consoleintr中,都有调用acquire。
调用acquire会获得一把锁,用来保护控制台驱动的数据结构不受并发访问的影响。
在这里有4个并发危险:
- 在不同CPU上的两个进程同时调用consoleread;
- 当CPU已经在consoleread内部执行的同时,硬件可能请求该CPU发出一个控制台中断;
- 在consoleread执行的同时,硬件可能会在另一个不同的CPU上发出一个控制台中断;
- 有一个进程可能正在等待来自某个设备的输入,但是输入中断信号到达的时候可能是另一个不同的进程在运行;
不允许中断处理程序来考虑被中断的进程或者代码。比如即使有了当前进程的页表,中断处理程序也没法安全地调用copyout。通常,中断处理程序只做相对较少的工作(比如,仅拷贝输入数据到缓冲区等),然后唤醒上半部分代码来做剩余的事情。
5.4 计时器中断
xv6使用计时器中断来维护它的时钟,来实现在计算密集型的进程间切换的功能。
在usertrap和kerneltrap中调用yield会导致这类切换。计时器中断来自添加到每个RISC-V CPU上的时钟硬件。xv6对这个时钟硬件进行编程来周期性地中断每个CPU。
RISC-V要求计时器中断必须在机器模式下处理,而不是在内核模式下。因为RISC-V机器模式执行时不需要分页,有单独的控制寄存器集,所以在机器模式下运行普通代码是不切实际的。因此,xv6处理计时器中断是方式是完全不同于陷阱机制的。
如何设置计时器中断?
start.c里以机器模式运行的代码在main之前,用来设置接收计时器中断。一部分任务是对CLINT硬件编程来在指定延迟后生成一个中断。另一部分的任务是建立scratch区,来帮助计时器中断处理程序保存寄存器和CLINT寄存的地址。最后,start设置mevec为timervec,使得计时器中断生效。
如何处理计时器中断?
因为计时器中断可以在执行用户代码或者内核代码的任何一个点处发生,且于内核没有办法使计时器中断在关键操作期间失效,所以计时器中断处理程序必须以不干扰被中断内核代码的方式来处理计时器中断。
基本的策略是中断处理程序请求RISC-V触发一个软件中断,并立即返回。RISC-V将软件中断传递给使用普通陷阱机制的内核,并允许内核来使其失效。处理由计时器中断产生的软件中断的代码可参考devintr。
机器模式的计时器中断向量是timervec。它在有start准备好的scratch区里保存一些寄存器,告诉CLINT何时生成下一个中断,请求RISC-V生成一个软件中断,检索寄存器,并返回。注意:在计时器中断处理程序中没有C代码的。
5.5 真实世界
xv6允许:在内核中执行或者执行用户程序时,发生设备中断和计时器中断。
注意:即使是在内核中执行,计时器中断也会从计时器中断处理程序中强制进行线程切换(比如调用yield)。
如果内核线程有时花费许多时间在计算而没有返回用户空间,则在内核线程之间对CPU进行时间分片就变得很重要了。
由于计时器中断,内核代码可能会被挂起,之后在不同的CPU上恢复执行,这是xv6中复杂性的一个来源。
如果仅在执行用户代码时才会发生设备中断和计时器重案,则内核可以更简单点。
在一台典型的计算机上支持所有的设备是一项艰巨的任务,因为存在许多设备,每个设备都有许多特征,在设备和驱动之间的协议可能会很复杂且文档记录很差。在许多操作系统上,驱动占据的代码要大于核心内核的代码。
可编程I/O VS DMA
UART驱动通过读取UART控制寄存器来每次读取一个字节的数据。这种模式是可编程I/O,因为软件驱动着数据移动。可编程I/O虽然简单,但是太慢了以至于不能用于高数据传输速率。
需要高速移动数据的设备通常都使用DMA。DMA设备硬件直接将输入数据写入到RAM,直接从RAM读取输出数据。现代的磁盘和网络设备都使用DMA。
DMA驱动会在RAM中准备好数据,然后向控制寄存器中单独写入来告诉设备来处理准备好的数据。
中断 VS 轮训
当设备需要关注的时不可预测的,和次数不频繁时,中断就很有意义。但是,中断有很高的CPU开销。因此,诸如网络和磁盘控制器等高速设备会使用一些技巧来减少对中断的需要。一个技巧是对批量输入请求或者输出请求触发一次中断。
另一个技巧是完全使中断失效,周期性地检查设备来看是否需要关注。这种技术称作轮询。如果设备执行操作非常快,则轮询就有意义。但是如果设备大部分时间处于空间,则轮询会浪费CPU时间。有些驱动会根据当前设备的负载在中断和轮询之间动态切换。
UART驱动首先将输入数据拷贝到内核中的缓冲区,然后再从缓冲区拷贝到用户空间。如果数速率很低,则这样是有意义的。但是对那些消费或者产生数据非常快的设备来说,这样的两次拷贝会严重降低性能。一些操作系统能使用DMA在用户空间的缓冲区和设备硬件之间直接移动数据。