Lec9 Device Drivers

设备驱动,以控制台为例。
Published

2024-03-23

Modified

2024-10-23

1 课前准备

Read Chapter 5 and kernel/kernelvec.S, kernel/plic.c, kernel/console.c, kernel/uart.c, kernel/printf.c .

xv6 在 kernel/trap.c 中的 devintr 处理设备中断,调用驱动的 trap handler。

许多设备驱动在两个上下文中执行代码:上半部分在进程的内核线程中运行,下半部分在中断时执行。上半部分是通过系统调用(例如 readwrite)来调用的,这些系统调用希望设备执行 I/O。该代码可能会要求硬件开始一项操作(例如,要求磁盘读取一个块);然后代码等待操作完成。最终设备完成操作并引发中断。驱动程序的中断处理程序充当下半部分,找出已完成的操作,在适当的情况下唤醒等待进程,并告诉硬件开始处理任何等待的下一个操作。

1.1 Code:控制台输入

控制台驱动通过 RISC-V 附属的 UART (Universal Asynchronous Receiver/Transmitter) 串口硬件接收人输入的字符。UART 硬件是由 QEMU 模拟的 16550 芯片。在真实的计算机上,16550 将管理连接到终端或其他计算机的 RS232 串行链路。运行 QEMU 时,它会连接到你的键盘和显示器。UART 硬件在软件层面为一组 memory-mapped 的控制寄存器。因此它是 RISC-V 物理地址的一部分,只不过是与设备而非 RAM 交互。在 kernel/uart.c 中有这些寄存器具体的介绍。比如,LSR 包含指示输入字符是否等待被软件读入的位,这些字符(如果有的话)可从 RHR 寄存器中读取。每次读取时,UART 会把它从内部的 FIFO 等待队列中删除。当队列为空时,清除 LSR 中的那一位。UART 的发送硬件独立于接收硬件,如果软件向 THR 写入字节,UART 发送那个字节。

xv6 的 main 调用了 consoleinit 初始化 UART 硬件。它配置了 UART 在每接收一个字节的输入时产生一个接收中断,在每完成发送一个字节的输出时产生一个发送完成中断。

shell 从 console1 通过 init.c 打开的文件描述符读取。对 read 的调用通过内核到达 consolereadconsoleread 等待输入通过中断到达并缓存在 cons.buf 中,拷贝到用户空间,并在一整行都到达时返回到用户进程。当用户未输入一个整行时,读取进程会在 sleep 中等待。

1 关于 shell 和 console 的区别,参见 What is the difference between shell, console, and terminal?

当用户键入一个字符时,UART 通知 RISC-V 引发一个中断,激活 xv6 的 trap handler。trap handler 调用 devintr,它通过查询 scause 寄存器来判断中断来自外设。之后它询问 PLIC (platform-level interrupt controller) 是哪个设备的中断。如果是 UART,则调用 uartintr

uartintr 读取任何从 UART 的等待输入的字符并交给 consoleintr 解析;consoleintr 将输入字符收集在 cons.buf 中直到一整行到达。此时将 consoleread 唤醒,它来执行如前所述的事务。

1.2 Code:控制台输出

对与 console 相连的文件描述符的 write 系统调用最终会到达 uartputc。设备驱动维护一个输出缓冲区 (uart_tx_buf),以便写入进程不必等待 UART 完成发送;相反,uartputc 将每个字符附加到缓冲区,调用 uartstart 启动设备传输(如果尚未传输),然后返回。 uartputc 等待的唯一情况是缓冲区已满。

每次 UART 完成发送一个字节时,都会生成一个中断。 uartintr 调用 uartstart,它检查设备是否确实已完成发送,并将下一个缓冲的输出字符交给设备。因此,如果进程将多个字节写入 console,通常第一个字节将通过 uartputcuartstart 的调用发送,其余缓冲字节将在传输完成中断到达时通过 uartintruartstart 调用发送。

I/O 并发:通过缓冲和中断将设备活动与进程活动解耦。即使没有进程正在等待读取输入,控制台驱动程序也可以处理输入;随后的读取将看到输入。同样,进程可以发送输出而无需等待设备。

1.3 计时器中断

xv6 使用定时器中断来维护其时钟并使其能够在计算密集型进程之间切换; usertrapkerneltrap 中的 yield 调用会导致这种切换。定时器中断来自连接到每个 RISC-V CPU 的时钟硬件。xv6 对该时钟硬件进行编程,以定期中断每个 CPU。

RISC-V 要求定时器中断在机器模式下进行,而不是在管理模式下进行。 RISC-V 机器模式执行时没有分页,并具有一组单独的控制寄存器,因此在机器模式下运行普通 xv6 内核代码是不切实际的。因此,xv6 完全独立于上面列出的 trap 机制来处理定时器中断。

1.4 Real World

xv6 允许在内核中执行以及执行用户程序时发生设备和定时器中断。定时器中断强制从定时器中断处理程序进行线程切换(调用yield),即使在内核中执行时也是如此。如果内核线程有时花费大量时间进行计算而不返回用户空间,那么在内核线程之间公平地对 CPU 进行时间切片的能力非常有用。然而,内核代码需要注意它可能会被挂起(由于定时器中断)并稍后在不同的 CPU 上恢复,这是 xv6 中一些复杂性的根源。如果设备和定时器中断仅在执行用户代码时发生,则内核可以变得更简单。

UART 驱动程序通过读取 UART 控制寄存器一次检索一个字节的数据;这种模式称为 programmed I/O,因为软件驱动数据移动。programmed I/O 很简单,但速度太慢,无法在高数据速率下使用。需要高速移动大量数据的设备通常使用直接内存访问 (DMA)。 DMA 设备硬件直接将传入数据写入 RAM,并从 RAM 读取传出数据。现代磁盘和网络设备使用 DMA。 DMA 设备的驱动程序将在 RAM 中准备数据,然后使用对控制寄存器的单次写入来告诉设备处理准备好的数据。

当设备在不可预测的时间(但不是太频繁)需要关注时,中断就有意义。但中断的 CPU 开销很高。因此,高速设备(例如网络和磁盘控制器)使用减少中断需求的技巧。一个技巧是为整批传入或传出请求引发一个中断。另一个技巧是驱动程序完全禁用中断,并定期检查设备以查看是否需要关注。这种技术称为轮询 (polling)。如果设备执行操作速度非常快,则轮询是有意义的,但如果设备大部分时间处于空闲状态,则轮询会浪费 CPU 时间。一些驱动程序通过当前设备负载状态在轮询和中断之间动态切换。

2 Lecture

总结一下控制台输入输出的过程。

输入时,引发中断,devintr 处理中断,判断出时 UART 后交给 uartintr,它 get 到字符后交给 consoleintr,它将字符收集在 buffer 中,之后唤醒 consoleread,将数据拷贝到进程里。输出时,通过 write 到达 uartputc,加入缓冲区后通过 uartstart 发送,发送成功后引发中断,交给 uartintr 处理。

总之,很复杂,需要多看代码。