整体架构
首先,理解这两个驱动程序之间的关系至关重要:
- UART 驱动 (
kernel/uart.c) 这是最底层的驱动。它直接与 UART (Universal Asynchronous Receiver-Transmitter) 硬件芯片(在 QEMU 中是模拟的 NS16550A 芯片)打交道。它的职责是:- 初始化 UART 硬件。
- 从硬件寄存器中读取接收到的字符。
- 将要发送的字符写入硬件寄存器。
- 处理来自 UART 的硬件中断。
- Console 驱动 (
kernel/console.c) 这是建立在 UART 驱动之上的、更高一层的驱动。它为操作系统的其他部分(如 printf 和 shell)提供了一个更友好的接口。它的职责是:- 提供一个缓冲区,暂存用户输入。
- 实现行编辑功能(例如,处理退格键、Ctrl+U 等)。
- 处理来自多个进程的并发读写请求。
- 调用 UART 驱动来实现最终的硬件读写。
数据流向图
-
输入 (键盘 -> Shell): 键盘 -> QEMU -> UART 硬件 -> 硬件中断 -> uart.c (uartintr) -> console.c (consoleintr) -> Console 缓冲区 -> Shell 进程 (read 系统调用)
-
输出 (printf -> 屏幕): 进程 (printf) -> console.c (conswrite) -> uart.c (uartputc) -> UART 硬件 -> QEMU -> 屏幕
1. UART 驱动 (kernel/uart.c) - 与硬件直接交互
xv6 通过内存映射I/O (Memory-Mapped I/O) 来访问 UART 硬件。这意味着 UART 的控制寄存器被映射到了物理内存的特定地址(在 xv6 中是 0x10000000,定义在 kernel/memlayout.h 中为 UART0)。对这些内存地址的读写,实际上就是在操作 UART 硬件。
关键函数和代码解析
a. uartinit - 初始化
// kernel/uart.c
void
uartinit(void)
{
// disable interrupts.
WriteReg(IER, 0x00);
// special mode to set baud rate.
WriteReg(LCR, 0x80);
// LSB and MSB for baud rate of 38.4K.
WriteReg(0, 0x03);
WriteReg(1, 0x00);
// leave set-baud mode,
// and set word length to 8 bits, no parity.
WriteReg(LCR, 0x03);
// enable FIFO, clear data.
WriteReg(FCR, 0x07);
// enable transmit and receive interrupts.
WriteReg(IER, 0x03);
}
WriteReg(reg, v): 这是一个宏,用于向 UART 的寄存器写入数据。它本质上是一个指针解引用操作,将值 v 写入 UART0 + reg 这个内存地址。- 初始化步骤:
- 关闭中断 (
IER): 在配置过程中,先关闭所有 UART 中断,防止意外发生。 - 设置波特率: 通过设置线路控制寄存器 LCR 的特殊位,进入波特率设置模式,然后写入分频值(这里设置为 38.4K Baud)。
- 设置线路格式: 退出波特率设置模式,并将 LCR 设置为 8 位数据位,无校验位,1 个停止位,这是最常见的串行通信格式。
- 启用 FIFO (
FCR): 启用先进先出缓冲区,可以一次性收发多个字节,提高效率。 - 开启中断 (
IER): 配置完成后,开启接收和发送中断。这样,当 UART 收到数据或发送缓冲区为空时,就会向 CPU 发送一个中断信号。
- 关闭中断 (
b. uartputc(int c) - 发送一个字符 (阻塞式)
// kernel/uart.c
void
uartputc(int c)
{
// wait for Transmit Holding Empty bit to be set.
while((ReadReg(LSR) & (1 << 5)) == 0)
;
WriteReg(THR, c);
}
ReadReg(LSR) & (1 << 5): 这是在轮询 (Polling)。它不断地读取线路状态寄存器 LSR,检查第 5 位(Transmitter Holding Register Empty,发送保持寄存器为空)。while(...): 如果该位为 0,表示 UART 正忙,还不能接收新的数据。所以 while 循环会一直空转等待,直到 UART 准备好。这就是所谓的忙等待 (Busy-Waiting),效率较低,但在简单的场景下可以使用。WriteReg(THR, c): 一旦 UART 空闲,就将字符 c 写入 THR (发送保持寄存器),UART 硬件会自动将它发送出去。
c. uartintr() - UART 中断处理函数
这是 I/O 的核心。当 UART 硬件有事件发生时(比如收到了一个字符),CPU 会跳转到这里执行。
// kernel/uart.c
void
uartintr(void)
{
// read and process incoming characters.
while(1){
int c = uartgetc();
if(c == -1)
break;
consoleintr(c);
}
}
// kernel/uart.c
int
uartgetc(void)
{
if(ReadReg(LSR) & 0x01){
// input data is ready.
return ReadReg(RHR);
} else {
return -1;
}
}
uartgetc: 这是一个非阻塞的读取函数。它检查 LSR 的第 0 位(Data Ready),如果为 1,说明接收缓冲区有数据,就从 RHR (接收保持寄存器) 读取并返回;否则立即返回 -1。uartintr:- 它循环调用 uartgetc(),尝试从 UART 的接收 FIFO 中读取所有已经到达的字符。
- 对于每一个成功读到的字符 c,它并不直接处理,而是调用了更高层的 consoleintr(c) 函数。
- 这种设计将硬件相关的操作(从寄存器读数据)和软件相关的逻辑(如何解释这个数据)分离开来,是典型的分层设计。
2. Console 驱动 (kernel/console.c) - 提供缓冲和行编辑
Console 驱动不直接和硬件打交道,它利用 uart.c 提供的功能,并增加了一个重要的环形缓冲区 (Ring Buffer) 来处理输入。
// kernel/console.c
struct {
struct spinlock lock;
// input
#define INPUT_BUF 128
char buf[INPUT_BUF];
uint r; // Read index
uint w; // Write index
uint e; // Edit index
} cons;
- buf: 这就是输入缓冲区。
- r, w, e: 分别是读、写和编辑指针,用于管理这个环形缓冲区。
关键函数和代码解析
a. consoleintr(int c) - Console 的中断处理
这个函数由 uartintr 调用,是处理用户输入的“大脑”。
// kernel/console.c
void
consoleintr(int c)
{
switch(c){
// ...
case C('U'): // Kill line.
while(cons.e != cons.w &&
cons.buf[(cons.e-1) % INPUT_BUF] != '\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){
c = (c == '\r') ? '\n' : c;
cons.buf[cons.e++ % INPUT_BUF] = c;
consputc(c); // Echo back to screen
if(c == '\n' || c == C('D') || cons.e == cons.r+INPUT_BUF){
cons.w = cons.e;
wakeup(&cons.r); // Wake up sleeping process
}
}
break;
}
}
switch(c): 它根据接收到的字符 c 执行不同操作。- 行编辑:
- C(‘U’) (Ctrl+U): 删除整行。它通过移动编辑指针 e 并向屏幕输出退格符来实现。
- C(‘H’) (Backspace) / \x7f (Delete): 删除前一个字符。
- 普通字符:
- 将字符存入 cons.buf 缓冲区。
- 调用 consputc(c) 将字符回显 (Echo) 到屏幕上,这样用户才能看到自己输入了什么。consputc 最终会调用 uartputc。
- 行结束判断: 如果用户输入了换行符 \n、EOF 符 C(‘D’) (Ctrl+D),或者缓冲区满了,就认为一行输入结束。
- cons.w = cons.e: 更新写指针 w,表示
[r, w)范围内的数据是完整的、可以被进程读取的。 wakeup(&cons.r): 这是最关键的一步。它会唤醒正在等待控制台输入的进程(例如,sleep 在 consoleread 函数中的 shell 进程)。
b. consoleread(..., void *dst, int n) - 进程读取输入
当 shell 等进程需要从键盘读取数据时,最终会调用这个函数。
// kernel/console.c
int
consoleread(int user_dst, uint64 dst, int n)
{
// ...
acquire(&cons.lock);
while(n > 0){
// Wait for input until a complete line is typed.
while(cons.r == cons.w){
if(myproc()->killed){
release(&cons.lock);
return -1;
}
sleep(&cons.r, &cons.lock); // Go to sleep
}
int c = cons.buf[cons.r++ % INPUT_BUF];
if(c == C('D')){ // End of file
if(n < 1) break;
cons.r--;
break;
}
// Copy character to user space.
if(either_copyout(user_dst, dst, &c, 1) == -1)
break;
dst++;
--n;
if(c == '\n')
break;
}
release(&cons.lock);
// ...
}
while(cons.r == cons.w): 检查缓冲区是否为空(读指针追上了写指针)。sleep(&cons.r, &cons.lock): 如果缓冲区为空,说明用户还没有输入完整的一行。进程会调用 sleep,将自己置于休眠状态,并等待在 &cons.r 这个“频道”上。它会一直睡到 consoleintr 中调用 wakeup(&cons.r) 为止。- 数据拷贝: 当被唤醒后,consoleread 从 cons.buf 缓冲区中逐个拷贝字符到用户进程提供的内存地址 dst,直到拷贝完一行 (c == ‘\n’) 或达到指定字节数 n。
总结
xv6 的 Console 和 UART 驱动完美地展示了操作系统中 I/O 处理的几个核心思想:
-
分层抽象: console.c 无需关心 UART 芯片的具体型号和寄存器细节,它只需要调用 uart.c 提供的 uartputc 等接口即可。这使得代码更清晰,也更容易适配不同的硬件。
-
中断驱动 I/O: CPU 不需要一直轮询检查是否有输入。硬件会在需要时通过中断来通知 CPU,大大提高了系统效率。CPU 在等待 I/O 的时间里可以去执行其他进程。
-
生产者-消费者模型: consoleintr 是生产者,它由硬件中断驱动,不断地向缓冲区中放入字符。consoleread 是消费者,它在进程需要时从缓冲区取走字符。缓冲区 cons.buf 解耦了生产和消费的速度差异。
-
并发和同步: sleep 和 wakeup 机制,以及锁 (spinlock),确保了在多进程环境下,对共享资源(控制台缓冲区)的访问是安全的。