[MIT 6.1810]Xv6 Chapter 5
Interrupts and device drivers
驱动程序在操作系统中起着管理硬件设备的重要作用。它负责配置设备硬件、启动操作、处理中断,并与可能在等待设备输入/输出(I/O)的进程进行协调。驱动程序代码比较复杂,因为它需要与它管理的设备并发执行。此外,驱动程序必须理解设备的硬件接口,而这些接口往往比较复杂,甚至文档不全。
需要操作系统关注的设备通常可以配置为生成中断,中断是一种陷阱(trap)。内核的陷阱处理代码识别出设备触发了中断,并调用驱动程序的中断处理程序。在 xv6 操作系统中,这个调度过程发生在 devintr
(kernel/trap.c:185)中。
许多设备驱动程序在两种上下文中执行代码:上半部分在进程的内核线程中运行,而下半部分在中断发生时执行。上半部分通常通过系统调用(如 read
和 write
)被调用,要求设备执行I/O操作。例如,它可能请求硬盘读取一个数据块,并等待操作完成。最终,设备完成操作并触发中断。驱动程序的中断处理程序作为下半部分运行,确定完成了什么操作,必要时唤醒等待的进程,并指示硬件继续处理任何等待的操作。
5.1 Code: Console input
控制台驱动程序(kernel/console.c)是驱动程序结构的简单示例。该驱动程序通过连接到RISC-V的UART串口硬件接收人类输入的字符。控制台驱动程序每次累积一行输入,处理特殊字符如退格键和Control-U。用户进程(如shell)使用read
系统调用从控制台获取输入。当你在QEMU中向xv6输入内容时,你的按键通过QEMU模拟的UART硬件传递到xv6。
驱动程序与UART硬件通信,UART硬件是由QEMU模拟的16550芯片。在真实计算机中,16550芯片管理连接到终端或其他计算机的RS232串行链接。而在QEMU中,它连接到你的键盘和显示器。
UART硬件通过内存映射的控制寄存器呈现给软件。即,RISC-V硬件将一些物理地址连接到UART设备,因此对这些地址的加载和存储操作将与设备硬件交互,而非与RAM交互。UART的内存映射地址从0x10000000
(即UART0
)开始(定义在kernel/memlayout.h:21
)。UART控制寄存器是宽度为一个字节的一组寄存器,其偏移量定义在kernel/uart.c:22
。例如,LSR寄存器包含指示是否有输入字符等待被软件读取的位。这些字符(如果有的话)可从RHR寄存器读取。每次读取一个字符后,UART硬件会从内部FIFO中删除该字符,并在FIFO为空时清除LSR中的“ready”位。UART的发送硬件与接收硬件基本独立;如果软件向THR写入一个字节,UART会发送该字节。
xv6的main
函数通过调用consoleinit
(kernel/console.c:182)初始化UART硬件。该代码配置UART,以便在每次接收到一个输入字节时生成接收中断,并且在每次完成发送一个输出字节时生成发送完成中断(kernel/uart.c:53)。
1 |
|
xv6的shell通过init.c
打开的文件描述符(user/init.c:19)从控制台读取数据。对read
系统调用的请求通过内核到达consoleread
(kernel/console.c:80)。consoleread
等待输入通过中断到达并缓存在cons.buf
中,将输入复制到用户空间,并在整行到达后返回给用户进程。如果用户没有输入完整的一行,任何正在读取的进程会在sleep
调用中等待(kernel/console.c:96)。
1 |
|
当用户输入一个字符时,UART硬件请求RISC-V触发中断,从而激活xv6的陷阱处理程序。陷阱处理程序调用devintr
(kernel/trap.c:185),该函数通过查看RISC-V的scause
寄存器发现中断来自外部设备。然后它请求名为PLIC的硬件单元(kernel/trap.c:193)告知中断的设备。如果中断来自UART,devintr
会调用uartintr
。
uartintr
(kernel/uart.c:177)从UART硬件读取任何等待的输入字符,并将其传递给consoleintr
(kernel/console.c:136);它不会等待字符,因为将来输入会触发新的中断。consoleintr
的任务是将输入字符累积到cons.buf
中,直到一整行到达。consoleintr
会特殊处理退格键和其他一些字符。当换行符到达时,consoleintr
会唤醒等待的consoleread
(如果有的话)。
1 |
|
一旦被唤醒,consoleread
会检测到cons.buf
中有一整行,将其复制到用户空间,并通过系统调用机制返回到用户空间。
5.2 Code: Console output
在控制台连接的文件描述符上进行的write
系统调用最终会到达uartputc
(kernel/uart.c:87)。设备驱动程序维护一个输出缓冲区(uart_tx_buf
),这样写入的进程不需要等待UART完成发送操作;相反,uartputc
会将每个字符添加到缓冲区中,并调用uartstart
来启动设备的传输(如果设备尚未传输),然后立即返回。唯一会导致uartputc
等待的情况是缓冲区已满。
每次UART完成发送一个字节时,它会生成一个中断。uartintr
会调用uartstart
,该函数检查设备是否确实完成了发送,并将下一个缓冲的输出字符交给设备。因此,如果一个进程向控制台写入多个字节,通常第一个字节会通过uartputc
调用uartstart
发送,剩余的缓冲字节则由uartstart
在UART发送完成中断到来时调用uartintr
发送。
需要注意的一个通用模式是通过缓冲和中断将设备活动与进程活动解耦。即使没有进程在等待读取,控制台驱动程序仍然可以处理输入;之后的读取操作将能够看到这些输入。同样,进程可以发送输出而不必等待设备的响应。这种解耦可以通过允许进程与设备I/O并发执行来提高性能,特别是在设备较慢(如UART)或需要立即处理(如回显输入字符)的情况下。这种思想有时被称为I/O并发。
5.3 Concurrency in drivers
你可能已经注意到在consoleread
和consoleintr
中调用了acquire
函数。这些调用用于获取锁,以保护控制台驱动程序的数据结构免受并发访问的影响。这里有三种并发的潜在危险:两个进程在不同的CPU上同时调用consoleread
;硬件可能请求CPU在该CPU已经在执行consoleread
时传递控制台(实际上是UART)的中断;硬件可能在不同的CPU上传递控制台中断,而此时consoleread
正在执行。第六章解释了如何使用锁来确保这些并发问题不会导致错误的结果。
并发对驱动程序的另一个影响是,某个进程可能正在等待设备输入,但输入到来的中断可能发生在另一个进程(甚至是没有进程运行的情况下)。因此,中断处理程序不允许考虑它所中断的进程或代码。例如,中断处理程序不能安全地调用copyout
,因为它依赖于当前进程的页表。中断处理程序通常执行的工作相对较少(例如,只是将输入数据复制到缓冲区中),然后唤醒上半部分代码来完成其余工作。
5.4 Timer interrupts
Xv6 使用定时器中断来维护当前时间的概念,并在计算密集型进程之间进行切换。定时器中断来自连接到每个 RISC-V CPU 的时钟硬件。Xv6 通过编程使每个 CPU 的时钟硬件定期中断 CPU。
start.c
中的代码(kernel/start.c:53)设置了一些控制位,允许监督模式访问定时器控制寄存器,然后请求第一次定时器中断。定时器控制寄存器包含一个硬件以稳定速率递增的计数,这表示当前时间。stimecmp
寄存器则包含 CPU 将触发定时器中断的时间;通过将stimecmp
设置为当前时间加上x
,可以安排在x
个时间单位后触发中断。对于 QEMU 的 RISC-V 模拟,1000000 个时间单位大约等于十分之一秒。
定时器中断像其他设备中断一样,通过usertrap
或kerneltrap
和devintr
到达。定时器中断到来时,scause
寄存器的低位被设置为5;trap.c
中的devintr
检测到这种情况并调用clockintr
(kernel/trap.c:164)。该函数递增ticks
变量,从而使内核能够跟踪时间的流逝。为避免在多CPU情况下时间流逝加速,递增操作仅发生在一个CPU上。clockintr
唤醒任何在sleep
系统调用中等待的进程,并通过写入stimecmp
来安排下一个定时器中断。
devintr
对于定时器中断返回2,以指示kerneltrap
或usertrap
调用yield
,从而使 CPU 可以在可运行的进程之间进行多路复用。
定时器中断在内核代码中可能会导致上下文切换,这就是为什么usertrap
的早期代码在启用中断之前要谨慎保存状态(如sepc
)。这些上下文切换意味着内核代码必须以能够在没有警告的情况下从一个 CPU 转移到另一个 CPU 的方式编写。