1. 中断与设备驱动 (Interrupts and Device Drivers)
在操作系统中,CPU 不能一直轮询(polling)设备(比如键盘)问“你有数据给我吗?”。这样做效率极低。取而代之,我们使用中断机制。
- 中断 (Interrupt):当中断发生时,设备会向 CPU 发送一个信号。CPU 会立即暂停当前正在执行的任何代码(无论是内核代码还是用户代码),保存现场,然后跳转到一段预先设置好的、专门处理该中断的中断服务程序 (Interrupt Service Routine, ISR) 去执行。处理完成后,再恢复现场,回到之前被暂停的地方继续执行。
- 设备驱动 (Device Driver):这就是一段内核代码,它懂得如何与特定的硬件设备通信。它负责初始化设备、从设备读取数据、向设备写入数据,以及最重要的——提供一个中断服务程序来响应设备的中断。
在 xv6 中,中断处理的核心逻辑位于 kernel/trap.c。所有类型的中断/异常(设备中断、系统调用、缺页等)都会先经过 kernel/kernelvec.S 中的汇编代码,然后跳转到 kernel/trap.c 中的 kerneltrap() 函数进行统一处理。
kerneltrap() 会检查中断原因。如果是设备中断,它会调用 plic_claim() 来询问是哪个设备触发了中断(在 RISC-V 架构中,PLIC 是平台级中断控制器)。然后,它会根据设备号调用相应的驱动程序注册的中断处理函数。
2. 控制台 I/O (Console Input and Output)
现在我们来看具体的例子:控制台(Console)。在 xv6 中,控制台的实现分为两层:
kernel/uart.c:这是底层的 UART (Universal Asynchronous Receiver-Transmitter) 驱动。它直接与 QEMU 模拟的 16550 UART 芯片(chip)通信。uartinit():初始化 UART 设备。uartputc():向 UART 发送一个字符(用于输出)。uartgetc():从 UART 读取一个字符(用于输入)。uartintr():这是 UART 的中断服务程序。当 UART 硬件接收到一个新字符时,它会触发一个中断。kerneltrap()最终会调用到这个uartintr()函数。
kernel/console.c:这是高层驱动,它在uart.c之上提供了一个更友好的接口,并实现了重要的行缓冲 (line buffering) 和行编辑 (line editing) 功能。consoleinit():初始化控制台,主要是初始化一个锁和一个缓冲区。consolewrite():上层(如 printf)通过这个函数写数据到控制台。它会调用uartputc()将字符一个个发送出去。consoleread():上层(如 shell)通过这个函数从控制台读取数据。consoleintr():这是uartintr()会调用的函数。它实现了控制台的核心输入逻辑。
输入流程 (Input Flow)
- 你在 QEMU 窗口中敲下一个键(比如 ‘l’)。
- QEMU 模拟的 UART 硬件接收到字符 ‘l’,并触发一个中断。
- CPU 跳转到
kerneltrap()。 kerneltrap()发现是 UART 中断,于是调用uartintr()。uartintr()从 UART 硬件的寄存器中读取字符 ‘l’,然后把它传递给consoleintr()。consoleintr()收到字符 ‘l’,将它存入自己的输入缓冲区cons.buf。它还会处理特殊字符,比如:- 如果你敲的是退格键,它会从缓冲区中删除一个字符。
- 如果你敲的是 Ctrl-U,它会清空整个行缓冲区。
- 如果你敲的是回车键 (
\n或\r),它会标记“一行已经输入完毕”,并唤醒任何正在consoleread()中等待输入的进程(比如 shell)。
- Shell 进程被唤醒,从
consoleread()返回,得到了你输入的完整命令行。
输出流程 (Output Flow)
输入和输出的流程有一个根本性的区别:
- 输入是异步的 (Asynchronous):内核不知道用户什么时候会敲键盘,所以它必须被动地通过中断来接收通知。
- 输出是同步的 (Synchronous):输出是由程序明确调用 printf 或 write 等函数来发起的。因此,它是一个主动的、从上到下的调用链。
下面是从用户程序调用 printf 到字符显示在屏幕上的完整流程:
Step 1: 用户空间 - printf() 函数
一个用户程序(比如 ls 或我们自己写的程序)调用 printf("hello\n");。
printf()函数在user/printf.c中实现。它会根据格式化字符串(这里没有格式化)准备好要输出的最终字符串"hello\n"。- 然后,它调用
write()系统调用,将这个字符串写入到文件描述符 1,也就是标准输出 (stdout)。
// user/printf.c
// ... simplified ...
putc(1, 'h'); // write('h' to fd 1)
putc(1, 'e'); // write('e' to fd 1)
// ...
Step 2: 系统调用入口 (System Call Trap)
write()的调用(在user/usys.pl生成的user/usys.S中)会执行ecall指令。ecall指令触发一个 “environment call from U-mode” 陷阱 (trap),CPU 从用户态切换到内核态。- CPU 根据
stvec寄存器的设置,跳转到uservec(在kernel/trampoline.S中),最终调用 C 函数usertrap()(在kernel/trap.c中)。
Step 3: 内核 - 系统调用分发
usertrap()检查scause寄存器,发现这是一个系统调用。- 它调用
syscall()函数(在kernel/syscall.c中)。 syscall()函数从a7寄存器中取出系统调用号(SYS_write),然后在一个函数指针数组syscalls[]中找到对应的处理函数sys_write并调用它。
Step 4: 内核 - 文件系统层
sys_write()函数(在kernel/sysfile.c中)被执行。- 它从寄存器中解析出参数:文件描述符(1)、要写入的数据的内存地址、以及数据长度。
- 它根据文件描述符 1,在当前进程的打开文件表中找到对应的
struct file。对于一个正常的 shell 进程,这个struct file指向的是控制台设备。 - 它调用
filewrite()函数。
Step 5: 内核 - 设备驱动层
filewrite()发现这个文件是一个设备(f->type == FD_DEVICE),于是它不会去写磁盘,而是调用该设备的写函数。- 对于控制台设备,这个写函数就是
consolewrite()(在kernel/console.c中)。
Step 6: consolewrite() 的行为
consolewrite()获取控制台锁acquire(&cons.lock)。- 它在一个循环里,将用户要打印的数据(比如
"hello\n")拷贝到内核的发送缓冲区uart_tx_buf中。它不会直接发送数据。 - 它更新缓冲区的写指针
uart_tx_w来记录有多少数据被放入了缓冲区。 - 拷贝完成后,它释放锁
release(&cons.lock)。 - 最关键的一步:它调用
uartstart()来“启动”或“唤醒”发送过程。 consolewrite函数至此返回。这意味着 write 系统调用可以非常快地完成,用户进程不必等待缓慢的I/O,可以继续执行或被调度去做别的事。CPU 被解放了。
Step 7: uartstart() 的角色
uartstart() (在 kernel/uart.c 中) 是连接同步和异步的桥梁。
- 它检查发送缓冲区中是否有数据待发送 (
uart_tx_w != uart_tx_r)。 - 它检查硬件发送器当前是否空闲 (通过读 LSR 寄存器)。
- 如果两者都满足,它会从
uart_tx_buf中取出第一个待发送的字符,写入 THR 硬件寄存器,然后将读指针uart_tx_r向前移动一位。 - 它只发送一个字符!这个字符的发送过程会由硬件接管。
Step 8: 发送中断 uartintr()
- 当 UART 硬件成功发送完
uartstart给它的那个字符后,THR 寄存器就空了。 - 因为在
uartinit()中我们开启了发送中断 (IER_TX_ENABLE),所以此时 UART 硬件会触发一个中断,告诉 CPU:“我空闲了,可以发送下一个字符了!” - CPU 响应中断,最终调用到
uartintr()。 uartintr()内部会调用uartstart()(或者直接包含其逻辑)。uartstart检查到缓冲区里还有更多字符待发送,于是它从uart_tx_buf中取出下一个字符,写入 THR。- 这个过程形成了一个闭环:
uartstart发送一个字符。- 硬件发送完毕,触发中断。
- 中断处理程序
uartintr被调用,它再次调用uartstart。 uartstart发送下一个字符。- …如此循环,直到
uart_tx_buf中的所有数据都被发送完毕。
当 uartstart 发现缓冲区已空 (uart_tx_w == uart_tx_r),它就什么也不做。整个发送过程在没有新的 write 系统调用的情况下就暂停了,完全由中断在“后台”驱动。
总结
真正的输出流:
write() 系统调用 -> consolewrite() (快速拷贝数据到 uart_tx_buf) -> uartstart() (发送第一个字符) -> 系统调用立即返回
(后台异步执行)
硬件发送完毕 -> (中断) -> uartintr() -> uartstart() (发送下一个字符) -> 硬件发送完毕 -> (中断) -> ... (直到缓冲区为空)
3. 驱动中的并发 (Concurrency in Drivers)
驱动程序必须处理并发,因为中断可能在任何时候发生。想象一下:
- 一个用户进程正在调用
consoleread()读取cons.buf缓冲区。 - 就在此时,一个键盘中断发生了,
consoleintr()被调用,它要去修改cons.buf缓冲区。
如果不对 cons.buf 的访问进行保护,就会导致数据竞争 (race condition) 和混乱。
xv6 使用自旋锁 (Spinlock) 来解决这个问题。在 kernel/console.c 中,你会看到一个锁:
// kernel/console.c
struct {
struct spinlock lock;
...
} cons;
所有访问 cons 结构体(尤其是 cons.buf)的函数,在操作前都必须先获取锁,操作完成后再释放锁。
consoleintr()在修改缓冲区前会调用acquire(&cons.lock)。consoleread()在读取缓冲区前也会调用acquire(&cons.lock)。consolewrite()同样如此。
这样就保证了在任何时刻,只有一个代码路径可以访问控制台的共享数据,避免了并发问题。
4. Shell, UART, Console 的关联
现在我们可以把所有东西串起来了:
- UART (
uart.c):是最底层的物理(模拟)层。它只管收发单个字符和触发中断。它不知道什么是“行”,也不知道什么是“退格”。 - Console (
console.c):是中间的逻辑层。它利用 UART 提供的能力,实现了一个带行编辑和缓冲区的、更高级的“控制台”设备。它使得用户可以像样地输入命令。在 xv6 中,它表现为一个设备文件。 - Shell (
user/sh.c):是最高层的用户应用程序。它是一个普通的程序,唯一的特殊之处在于它的标准输入 (stdin, fd=0) 和标准输出 (stdout, fd=1) 被连接到了控制台设备文件。- 当 Shell 需要读取用户命令时,它就执行
read(0, buf, size)系统调用。这个调用会进入内核,最终走到consoleread()。如果cons.buf中还没有一个完整的行,consoleread()会让 Shell 进程睡眠 (sleep),等待consoleintr()在收到回车时将它唤醒。 - 当 Shell 需要打印提示符
$或者命令的执行结果时,它就执行write(1, "$ ", 2)系统调用。这个调用会进入内核,最终走到consolewrite(),把字符一个个通过uartputc()发送到屏幕上。
- 当 Shell 需要读取用户命令时,它就执行
总结一下这个关联
用户输入 ls 并回车:
键盘 -> QEMU -> UART硬件 --(中断)--> uartintr() -> consoleintr() (字符被存入缓冲区) -> Shell在 read() 中等待 ->
(用户敲回车) -> consoleintr() 唤醒 Shell -> Shell 从 read() 返回,得到 "ls\n" -> Shell 解析命令并执行 ls 程序。
ls 程序输出结果:
ls 程序调用 printf() -> write() 系统调用 -> consolewrite() -> uartputc() -> UART硬件 -> QEMU -> 屏幕。
通过这种分层和中断驱动的机制,xv6 实现了一个高效、清晰且功能完善的命令行交互环境。
5. 涉及到的一些寄存器
UART 设备寄存器:
这些寄存器通过访问 UART0 (在 kernel/memlayout.h 中定义的物理地址 0x10000000) 加上一个偏移量来读写。kernel/uart.c 中的代码就是通过直接读写这些地址来和 UART 硬件打交道的。
THR(Transmitter Holding Register for output) - 偏移量 0, 只写- 作用:当内核想发送一个字符时,就把这个字符的 ASCII 码写入这个寄存器。
- 在 xv6 中的应用:uartputc() 函数会把要发送的字符写入 (volatile char*)UART0。
RHR(Receiver Holding Register for input) - 偏移量 0, 只读- 作用:当 UART 硬件从外部(比如键盘)接收到一个字符时,会把这个字符的 ASCII 码存放在这里(是UART操作,UART可以读写).
- 在 xv6 中的应用:uartgetc() 和 uartintr() 函数会从 (volatile char*)UART0 读取数据来获取接收到的字符。
LSR(Line Status Register) - 偏移量 5, 只读- 作用:提供 UART 当前的状态信息。
- 在 xv6 中的应用:
- uartputc() 在写入 THR 之前,会循环检查 LSR 的第 5 位 (LSR_TX_IDLE)。这一位为 1 表示发送器(transimit)是空闲的,可以接收新的字符。
- uartintr() 在读取 RHR 之前,会检查 LSR 的第 0 位 (LSR_DATA_READY)。这一位为 1 表示接收到了有效数据。
IER(Interrupt Enable Register) - 偏移量 1, 只写- 作用:控制 UART 在哪些事件发生时可以触发中断。
- 在 xv6 中的应用:uartinit() 函数会向 IER 写入 IER_RX_ENABLE。这等于告诉 UART:“当你接收到数据时(RHR 变满时),请立即触发一个中断。或者当THR由满变为空时,立即触发一个中断.” 这就是整个中断驱动机制的源头。