Chapter 5 Interrupts and device drivers
驱动 driver 是内核用来管理具体硬件的程序,其提供的功能有:配置设备硬件,通知硬件执行相关操作,处理中断,与使用设备的进程交互。驱动必须完全了解硬件接口。
大部分的驱动将执行代码分为两个部分,其一是在进程的内核执行,其二是在中断的时候执行。前一部分通常是通过系统调用(如 read 和 write来要求进行设备 I/O),然后代码等待设备响应并执行这一要求。当设备执行完毕后,它启动一个中断,唤醒等待中的进程,并查找是否有等待中的需要设备执行的任务。
Code: Console input
终端 console 就是硬件驱动的一个典型例子。这一设备通过 RISC-V 上 UART 串行接口接收用户输入的字符。这一驱动每次收集一行的输入字符,并对特殊字符(如 backspace, Ctrl+U)作出相应的反应。用户进程(如 shell)通过 read 这一系统调用获取从 console 来的输入。
从软件的视角来看,UART 硬件是以内存映射的方式为软件提供若干控制寄存器。RISC-V 将某些内存地址与 UART 设备链接起来,那么在这些地址上加载和存储就能操作硬件设备。具体地,内存映射设备地址在 RISC-V 中从地址 UART0=0x10000000 开始,具体的偏移定义在 uart.c 中,每个寄存器占据一个 byte 的位置。
在 xv6 的 main 中调用 consoleinit 来注册 UART 硬件,配置一个当 UART 接收到字符时触发的接收中断,和当 UART 结束传输字符的传输完成中断。
xv6 的 shell 通过 init.c 中打开的文件描述符来读取 console 中的内容,对 read 的系统调用最终会转移到 consoleread 函数中,这一函数通过中断等待输入,然后将输入内容通过 buffer 复制到 user space,并且当一整行都输入完后返回到用户程序。若用户并没有完成输入一整行,正在等待的进程都会在 sleep 系统调用中等待。
当用户输入一个字符时,UART 硬件通知 RISC-V 启动一个中断,激活 xv6 的中断处理程序,该程序会发现中断是由硬件发起的(通过寄存器),然后找到对应硬件,并通知硬件驱动作出相应反应。
Code: Console output
对于终端的输出与输入其实是类似的。驱动也会设置一个 buffer uart_tx_buf来缓冲输出内容,这样执行写操作的进程就不需要等待 UART 完成传输。
每次 UART 完成传输一个 byte 时都会引发一次中断。这一中断首先会检查设备是否真的已经传输完毕了,然后将 buffer 中下一个需要传输的 byte 交给设备。这样一来,如果进程一次性要求输出多个字符,第一个字符是由 uartputc 调用的 uartstart 输出的,后面的则是由 uartintr 调用的 uartstart 输出的。
上面提到的两个例子均使用了将设备和进程通过 buffer 和中断解耦的模式。这一模式能显著提升性能,因为允许进程与设备 I/O 同时执行,尤其是设备 I/O 与进程执行速率存在显著差异的情况。
Timer interrupts
时钟中断与其它硬件中断有少许不同之处,它是由时钟硬件引发的。RISC-V 要求时钟中断必须在 machine mode 而非 supervisor mode 中执行,故 xv6 处理时钟中断的方法与之前提到的所有 trap 都不同。
时钟中断可能在任意时刻发生,且内核不能使用任何方式屏蔽这一中断的发生。时钟中断的处理函数必须保证不会影响到被打断的内核程序。关于其的核心思想是,要求内核引发一个“软中断”然后迅速返回,然后内核就可以使用处理常规 trap 的方式来处理它了。
Real world
xv6 允许设备和时钟中断在用户态和内核态均可以被触发,如果仅允许在用户态触发,相关编程会更加简便。但是这同时意味着对内核的编写要更加谨慎,否则会破坏 CPU 对每个运行进程的公平性。
设备是多种多样的,每一种设备可能都有自己独有的特性,所以想要在一个机器上支持很多设备的所有功能是困难的,或许需要比内核更多的代码工作。
xv6 中通过 UART 每次读写一个字符,这一方式被称为 programmed I/O,因为是由软件来主导数据的移动。programmed I/O 是简单的,但是对于数据压力更大的程序就很慢了。现代设备使用 DMA(direct memory access) 来传输大量数据,这一技术能让硬件将数据直写到内存,只需要少量与进程的通信来协调。
中断会消耗大量时间,xv6 中每单个字符的读写都会引发一次中断,这样效率不高。一个技巧是,将多个读/写打包操作,这样就可以显著减少中断次数;另一个技巧是完全屏蔽硬件中断,而是采用轮询的方式,周期性检查设备是否需要操作,但这一方法在设备长时间为空的时候会浪费大量 CPU 资源。一些设备能够根据负载情况动态地在这两种模式下切换。
UART 驱动在处理数据的时候会进行两次复制,一次是从 buffer 到 kernel,一次是从 kernel 到用户空间。两次复制会显著降低性能,现代一些操作系统能在用户空间和设备 buffer 之间直接移动数据,减少复制次数,这通常需要基于 DMA 等技术支持。