整体架构

首先,理解这两个驱动程序之间的关系至关重要:

  1. UART 驱动 (kernel/uart.c) 这是最底层的驱动。它直接与 UART (Universal Asynchronous Receiver-Transmitter) 硬件芯片(在 QEMU 中是模拟的 NS16550A 芯片)打交道。它的职责是:
    • 初始化 UART 硬件。
    • 从硬件寄存器中读取接收到的字符。
    • 将要发送的字符写入硬件寄存器。
    • 处理来自 UART 的硬件中断。
  2. 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 这个内存地址。
  • 初始化步骤:
    1. 关闭中断 (IER): 在配置过程中,先关闭所有 UART 中断,防止意外发生。
    2. 设置波特率: 通过设置线路控制寄存器 LCR 的特殊位,进入波特率设置模式,然后写入分频值(这里设置为 38.4K Baud)。
    3. 设置线路格式: 退出波特率设置模式,并将 LCR 设置为 8 位数据位,无校验位,1 个停止位,这是最常见的串行通信格式。
    4. 启用 FIFO (FCR): 启用先进先出缓冲区,可以一次性收发多个字节,提高效率。
    5. 开启中断 (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:
    1. 它循环调用 uartgetc(),尝试从 UART 的接收 FIFO 中读取所有已经到达的字符。
    2. 对于每一个成功读到的字符 c,它并不直接处理,而是调用了更高层的 consoleintr(c) 函数。
    3. 这种设计将硬件相关的操作(从寄存器读数据)和软件相关的逻辑(如何解释这个数据)分离开来,是典型的分层设计。

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): 删除前一个字符。
  • 普通字符:
    1. 将字符存入 cons.buf 缓冲区。
    2. 调用 consputc(c) 将字符回显 (Echo) 到屏幕上,这样用户才能看到自己输入了什么。consputc 最终会调用 uartputc。
    3. 行结束判断: 如果用户输入了换行符 \n、EOF 符 C(‘D’) (Ctrl+D),或者缓冲区满了,就认为一行输入结束。
    4. cons.w = cons.e: 更新写指针 w,表示 [r, w) 范围内的数据是完整的、可以被进程读取的。
    5. 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 处理的几个核心思想:

  1. 分层抽象: console.c 无需关心 UART 芯片的具体型号和寄存器细节,它只需要调用 uart.c 提供的 uartputc 等接口即可。这使得代码更清晰,也更容易适配不同的硬件。

  2. 中断驱动 I/O: CPU 不需要一直轮询检查是否有输入。硬件会在需要时通过中断来通知 CPU,大大提高了系统效率。CPU 在等待 I/O 的时间里可以去执行其他进程。

  3. 生产者-消费者模型: consoleintr 是生产者,它由硬件中断驱动,不断地向缓冲区中放入字符。consoleread 是消费者,它在进程需要时从缓冲区取走字符。缓冲区 cons.buf 解耦了生产和消费的速度差异。

  4. 并发和同步: sleep 和 wakeup 机制,以及锁 (spinlock),确保了在多进程环境下,对共享资源(控制台缓冲区)的访问是安全的。