[MIT 6.1810]Xv6 Chapter 5

Interrupts and device drivers

驱动程序在操作系统中起着管理硬件设备的重要作用。它负责配置设备硬件、启动操作、处理中断,并与可能在等待设备输入/输出(I/O)的进程进行协调。驱动程序代码比较复杂,因为它需要与它管理的设备并发执行。此外,驱动程序必须理解设备的硬件接口,而这些接口往往比较复杂,甚至文档不全。

需要操作系统关注的设备通常可以配置为生成中断,中断是一种陷阱(trap)。内核的陷阱处理代码识别出设备触发了中断,并调用驱动程序的中断处理程序。在 xv6 操作系统中,这个调度过程发生在 devintr(kernel/trap.c:185)中。

许多设备驱动程序在两种上下文中执行代码:上半部分在进程的内核线程中运行,而下半部分在中断发生时执行。上半部分通常通过系统调用(如 readwrite)被调用,要求设备执行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
2
3
4
5
6
7
8
9
10
11
12
13
void
consoleinit(void)
{
initlock(&cons.lock, "cons");

uartinit();

// connect read and write system calls
// to consoleread and consolewrite.
devsw[CONSOLE].read = consoleread;
devsw[CONSOLE].write = consolewrite;
}

xv6的shell通过init.c打开的文件描述符(user/init.c:19)从控制台读取数据。对read系统调用的请求通过内核到达consoleread(kernel/console.c:80)。consoleread等待输入通过中断到达并缓存在cons.buf中,将输入复制到用户空间,并在整行到达后返回给用户进程。如果用户没有输入完整的一行,任何正在读取的进程会在sleep调用中等待(kernel/console.c:96)。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
//
// user read()s from the console go here.
// copy (up to) a whole input line to dst.
// user_dist indicates whether dst is a user
// or kernel address.
//
int
consoleread(int user_dst, uint64 dst, int n)
{
uint target;
int c;
char cbuf;

target = n;
acquire(&cons.lock);
while(n > 0){
// wait until interrupt handler has put some
// input into cons.buffer.
while(cons.r == cons.w){
if(killed(myproc())){
release(&cons.lock);
return -1;
}
sleep(&cons.r, &cons.lock);
}

c = cons.buf[cons.r++ % INPUT_BUF_SIZE];

if(c == C('D')){ // end-of-file
if(n < target){
// Save ^D for next time, to make sure
// caller gets a 0-byte result.
cons.r--;
}
break;
}

// copy the input byte to the user-space buffer.
cbuf = c;
if(either_copyout(user_dst, dst, &cbuf, 1) == -1)
break;

dst++;
--n;

if(c == '\n'){
// a whole line has arrived, return to
// the user-level read().
break;
}
}
release(&cons.lock);

return target - n;
}

当用户输入一个字符时,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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
//
// the console input interrupt handler.
// uartintr() calls this for input character.
// do erase/kill processing, append to cons.buf,
// wake up consoleread() if a whole line has arrived.
//
void
consoleintr(int c)
{
acquire(&cons.lock);

switch(c){
case C('P'): // Print process list.
procdump();
break;
case C('U'): // Kill line.
while(cons.e != cons.w &&
cons.buf[(cons.e-1) % INPUT_BUF_SIZE] != '\n'){
cons.e--;
consputc(BACKSPACE);
}
break;
case C('H'): // Backspace
case '\x7f': // Delete key
if(cons.e != cons.w){
cons.e--;
consputc(BACKSPACE);
}
break;
default:
if(c != 0 && cons.e-cons.r < INPUT_BUF_SIZE){
c = (c == '\r') ? '\n' : c;

// echo back to the user.
consputc(c);

// store for consumption by consoleread().
cons.buf[cons.e++ % INPUT_BUF_SIZE] = c;

if(c == '\n' || c == C('D') || cons.e-cons.r == INPUT_BUF_SIZE){
// wake up consoleread() if a whole line (or end-of-file)
// has arrived.
cons.w = cons.e;
wakeup(&cons.r);
}
}
break;
}

release(&cons.lock);
}

// handle a uart interrupt, raised because input has
// arrived, or the uart is ready for more output, or
// both. called from devintr().
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);
}

一旦被唤醒,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

你可能已经注意到在consolereadconsoleintr中调用了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 个时间单位大约等于十分之一秒。

定时器中断像其他设备中断一样,通过usertrapkerneltrapdevintr到达。定时器中断到来时,scause寄存器的低位被设置为5;trap.c中的devintr检测到这种情况并调用clockintr(kernel/trap.c:164)。该函数递增ticks变量,从而使内核能够跟踪时间的流逝。为避免在多CPU情况下时间流逝加速,递增操作仅发生在一个CPU上。clockintr唤醒任何在sleep系统调用中等待的进程,并通过写入stimecmp来安排下一个定时器中断。

devintr对于定时器中断返回2,以指示kerneltrapusertrap调用yield,从而使 CPU 可以在可运行的进程之间进行多路复用。

定时器中断在内核代码中可能会导致上下文切换,这就是为什么usertrap的早期代码在启用中断之前要谨慎保存状态(如sepc)。这些上下文切换意味着内核代码必须以能够在没有警告的情况下从一个 CPU 转移到另一个 CPU 的方式编写。


[MIT 6.1810]Xv6 Chapter 5
https://erlsrnby04.github.io/2024/09/27/MIT-6-1810-Xv6-Chapter-5/
作者
ErlsrnBy04
发布于
2024年9月27日
许可协议