这部分并没有对应的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 | // handle a uart interrupt, raised because input has |
其中的uartgetc()其实很简单,就是判断一下LSR寄存器是否是1,如果是1,则说明数据已经准备好,就从另外一个寄存器RHR中读取数据并返回;而如果没有准备好久直接返回-1。如果数据准备好了,那么就执行consoleintr(c),就是根据来的字符进行判断,比如是ctrl+p就需要特殊处理。
也可以发现这就是一个死循环。然后就可以调用uartstart了。
现实世界中
让我们以更加具体的一个过程来看一下,当我们在键盘上敲击一下一个按键的时候,操作系统发生了什么吧。
现实世界中每个硬件都会有自己的设备控制器,设备控制器有自己的寄存器,即状态、命令和数据寄存器,见名知意。
如果硬件是那种存储数据的块设备,比如硬盘这类,那么在硬件内部本身还有一块缓冲区,操作系统其实是把数据放到设备的缓冲区中,让设备自己判断什么时候需要真正写入到硬件部分。
CPU本身通过以下两种方式和硬件进行交互:
- 端口 I/O,每个设备的控制寄存器被分配一个 I/O 端口,可以通过特殊的汇编指令操作这些寄存器,最常见的如
in/out指令。在Linux中可以通过cat /proc/ioports查看,在windows可以通过设备管理器查看:

- 内存映射 I/O,把寄存器映射到内存空间,这样就可以像访问内存空间一样访问寄存器了。
然后当设备完成之后,就可以通过产生一个中断告知CPU来完成操作。但是CPU还需要把数据从缓冲区搬动到内存之中,这显然是大材小用了。所以在此基础之上有了DMA,它可以直接把数据搬动到内存中,然后再去通知CPU。这样CPU只需要在最开始的时候编程一下DMA,然后就可以把所有工作都委托给DMA做了,当数据最后到了指定位置的内存之后,CPU才去处理。
注意键盘鼠标不是块设备,它们是字符设备;所以键盘和鼠标本身并不和DMA打交道。
当你按下键盘的某个按键的时候,键盘的控制器会向CPU发生一个中断(可以理解成物理上发送了某个特定的电流),CPU会保存当前的运行的进程的状态,然后CPU跳转到特定的键盘中断处理程序中(这个在安装驱动的时候就注册好了),这个处理程序就会去某个特定地址的内存(这里假设是内存映射IO)读取特定的内容,然后放入到一个特殊的地方,这个地方就是显示器驱动程序注册的中断处理程序获取数据的地方。
最后是一组有趣的数据加深一下记忆:
- 现代的CPU一秒钟可以执行109条指令。
- 处理中断需要100-1000条指令,所以每秒钟可以大约有106到107次中断可以发生。但是这种计算方式是所有的CPU时间全部用来处理中断了,显然不现实;我们就大概让1%的时间来处理中断好了,在这种条件下,每秒大约105=十万次中断发生。