0%

xv6-2020-中断与驱动

这部分并没有对应的lab,但是个人觉得理解它还是非常有必要的。我个人认为重在理解这个过程,因为我自己应该是不会去接触驱动的。

驱动

驱动是操作系统内核中用来管理硬件的代码。因为硬件千千万,操作系统不可能为管理所有的硬件写代码;同时操作系统也不可能包含所有的驱动程序。所以目前的做法是,硬件的厂商提供驱动程序,然后用户购买了对应的硬件之后,去对应的网址下载驱动程序进行安装。

内核的中断处理程序能够知道发生了中断,并且调用驱动中对应的处理程序。这一部分是发生在devintr中,具体的代码逻辑就是判断一下这次中断发生的原因,根据原因分成几类不同的进行处理。其中有一类是监视者模式的外部中断,那么就获取对应的irq,根据irq来进行不同的处理逻辑。

很多驱动执行的代码分成两部分,顶部是在进程的内核线程中,底部则是在中断的时候执行的。也即顶部其实是被系统调用使用的,顶部能够控制设备,向设备发送诸如读写操作的指令;而当设备完成对应的指令之后,就会发生一个中断;这个时候就需要之前提到的下半部分的驱动来进行中断的处理。

Console input

console.c本身就是一个简单的driver。UART串行端口硬件连接到RISC-V上面,console的驱动就从这个UART中获取人类打印的字符。

类似shell的这种进程,就通过系统调用read从console中获取字符。当然在使用qemu进行模拟的时候,你敲击的按键其实是通过UART这个硬件递交给xv6的。

所以我觉得流程应该是这样的: 你敲击键盘 -> qemu -> UART -> console -> xv6

从软件的角度来说,UART这个硬件可以抽象为一组寄存器。

在main函数中,有一个consoleinit用来初始化UART这个硬件。这函数本身调用了uartinit(),本质上就是往一些寄存器里面设置值。这些代码设置了,每当UART接收到一个字节的输入,会产生一个接收的中断;每次当发送了一个字节,就产生一个发送的中断。这些中断是之后处理的关键部分。

shell在初始化的使用open("console")来创建一个文件描述符,并且能够通过这个文件描述符从console获取内容。调用read系统调用通过内核进入consoleread,consoleread等待输入的到来,并且输入被缓冲到一个地方,然后把一行的内容复制到用户空间中,然后返回到用户空间。如果用户还没有输入完整的一行(没有带上回车符),那么所有调用read的进程会使用sleep。

所以每当用户敲击一个字符的时候,UART这个硬件就会向RISC-V发送一个interupt,然后触发中断处理函数,处理函数会调用devintr。在devintr中,会向PLIC去询问是哪个设备产生了中断,当发现是UART之后,就执行uartintr。uartintr从UART硬件获取多个输入字符,调用consoleintr。consoleintr会简单处理一下,比如退格符之类的处理,当换行符到达的时候,它会唤醒consoleread。consoleread就会把缓存中的所有的字符拷贝到用户空间,并且返回用户空间。

Console output

write系统调用最终调用的就是uartputc,驱动维护了一个输出的buffer,所以输出程序并不需要等待UART完成发送。uartputc会使用uartstart来开始数据的传送。每当UART完成一次字节的发送,就会触发一个中断,然后uartintr触发uartstart,确认设备是否真的完成了一次传输。

根据上述内容,如果传输一串字符,那么最典型的情况是,第一个字符是通过uartputc的uartstart,而接下来的所有字符则都通过uartintr的uartstart来传输。

实际过程

当你在键盘上输入一个字符的时候,键盘会发送一个interrupt到操作系统中,然后对应到xv6里,就开始执行trap.c中的代码了。也就是会开始从devintr开始执行,在这个函数中,会判断一下是哪个设备发生的中断,显然就是UART,然后就进入到uartintr()中:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
// handle a uart interrupt, raised because input has
// arrived, or the uart is ready for more output, or
// both. called from trap.c.
void
uartintr(void)
{
// read and process incoming characters.
while(1){
int c = uartgetc();
if(c == -1)
break;
consoleintr(c);
}

// send buffered characters.
acquire(&uart_tx_lock);
uartstart();
release(&uart_tx_lock);
}

其中的uartgetc()其实很简单,就是判断一下LSR寄存器是否是1,如果是1,则说明数据已经准备好,就从另外一个寄存器RHR中读取数据并返回;而如果没有准备好久直接返回-1。如果数据准备好了,那么就执行consoleintr(c),就是根据来的字符进行判断,比如是ctrl+p就需要特殊处理。

也可以发现这就是一个死循环。然后就可以调用uartstart了。

现实世界中

让我们以更加具体的一个过程来看一下,当我们在键盘上敲击一下一个按键的时候,操作系统发生了什么吧。

现实世界中每个硬件都会有自己的设备控制器,设备控制器有自己的寄存器,即状态命令数据寄存器,见名知意。

如果硬件是那种存储数据的块设备,比如硬盘这类,那么在硬件内部本身还有一块缓冲区,操作系统其实是把数据放到设备的缓冲区中,让设备自己判断什么时候需要真正写入到硬件部分。

CPU本身通过以下两种方式和硬件进行交互:

  • 端口 I/O,每个设备的控制寄存器被分配一个 I/O 端口,可以通过特殊的汇编指令操作这些寄存器,最常见的如in/out指令。在Linux中可以通过cat /proc/ioports查看,在windows可以通过设备管理器查看:

image-20210421104159129

  • 内存映射 I/O,把寄存器映射到内存空间,这样就可以像访问内存空间一样访问寄存器了。

然后当设备完成之后,就可以通过产生一个中断告知CPU来完成操作。但是CPU还需要把数据从缓冲区搬动到内存之中,这显然是大材小用了。所以在此基础之上有了DMA,它可以直接把数据搬动到内存中,然后再去通知CPU。这样CPU只需要在最开始的时候编程一下DMA,然后就可以把所有工作都委托给DMA做了,当数据最后到了指定位置的内存之后,CPU才去处理。

注意键盘鼠标不是块设备,它们是字符设备;所以键盘和鼠标本身并不和DMA打交道。

当你按下键盘的某个按键的时候,键盘的控制器会向CPU发生一个中断(可以理解成物理上发送了某个特定的电流),CPU会保存当前的运行的进程的状态,然后CPU跳转到特定的键盘中断处理程序中(这个在安装驱动的时候就注册好了),这个处理程序就会去某个特定地址的内存(这里假设是内存映射IO)读取特定的内容,然后放入到一个特殊的地方,这个地方就是显示器驱动程序注册的中断处理程序获取数据的地方。

最后是一组有趣的数据加深一下记忆:

  • 现代的CPU一秒钟可以执行109条指令。
  • 处理中断需要100-1000条指令,所以每秒钟可以大约有106到107次中断可以发生。但是这种计算方式是所有的CPU时间全部用来处理中断了,显然不现实;我们就大概让1%的时间来处理中断好了,在这种条件下,每秒大约105=十万次中断发生。