I/O 生命周期概览

  • 输入:当你在键盘上敲下一个字符,它如何被用户程序(如 Shell)读取到。
  • 输出:当用户程序(或内核)调用 write(或 printf),字符如何显示在屏幕上。

核心比喻:两个部门协同工作

uart.c - 物理收发部

  • 职责:直接和硬件 UART 芯片打交道。它不懂字符的意义(比如“退格”是什么),只负责把字节(byte)从硬件寄存器里读出来,或者把字节写到硬件寄存器里去。
  • 关心:硬件寄存器 (RHR, THR, LSR, IER)、中断、物理字节流。
  • 特点:非常底层,是“体力活”。

console.c - 编辑与客服部

  • 职责: 处理用户交互逻辑。它知道“退格键”意味着要删除前一个字符,知道“回车键”意味着一行输入结束。它维护一个“行缓冲区”来支持用户的编辑。
  • 关心:用户体验、行编辑、特殊控制字符(Ctrl-C, Ctrl-D 等)、唤醒等待输入的进程。
  • 特点:更上层,是“脑力活”。它使用“物理收发部”提供的服务来完成自己的工作。

1. 输入流程:从键盘敲击到 read()

假设你在 Shell 里,准备输入 ls 然后回车。我们追踪第一个字符 l 的旅程。

流程图:

键盘 → UART 芯片 → uartintr() → uartgetc() → consoleintr() → cons.buf → consoleread() → 用户程序

步骤分解

1. 硬件层面(物理世界 → UART 芯片)

  • 你按下 l 键。
  • 你的终端(或模拟器)将字符 l 的 ASCII 码(0x6C)通过串行线发送给计算机。
  • 计算机上的 UART 芯片接收到这个字节,并将它放入硬件的接收保持寄存器 (RHR)。
  • UART 芯片自动在线路状态寄存器 (LSR) 中设置 LSR_RX_READY 位,表示“有数据可读”。
  • 因为我们在 uartinit() 中通过 IER_RX_ENABLE 开启了接收中断,所以 UART 芯片立刻向 CPU 发出一个硬件中断。

2. 物理收发部介入(uart.c

  • CPU 响应中断,通过中断向量最终调用到 uartintr()
  • uartintr() 的第一部分是处理输入。它调用 uartgetc()
  • uartgetc() 读取 LSR 寄存器,确认 LSR_RX_READY 被设置。
  • 然后,它从 RHR 寄存器中读出字节 0x6C,并将其返回。
  • uartintr() 拿到了字符 l,但它不知道这是什么,于是它将这个原始字符交给了上级部门处理,调用 consoleintr('l')

3. 编辑与客服部处理(console.c

  • consoleintr('l') 被调用。这是整个输入处理的核心。
  • 它首先检查 l 是不是特殊字符(退格、Ctrl-U 等)。l 不是,于是进入 default 分支。
  • 回显 (Echo):为了让你看到自己输入了什么,它需要把 l 显示在屏幕上。它调用 consputc('l') 来完成这个任务(consputc 内部会调用 uartputc_sync,这个我们将在输出流程里详述)。
  • 行缓冲:它将字符 l 存入自己的软件输入缓冲区 cons.buf 中,并移动编辑指针 cons.e
  • 判断结束:它检查 l 是否是回车符 \n 或文件结束符 C('D')l 不是,所以这一行输入还没结束。它不会唤醒任何等待输入的进程。
  • consoleintr 返回。整个中断处理结束。

4. 一行结束与唤醒

  • 你继续输入 s 和回车 \n。当 consoleintr('\n') 被调用时,除了回显和存储 \n,它还会做一件关键的事:
    • 它发现输入的是行结束符,于是将写指针 cons.w 更新为当前的编辑指针 cons.e,表示“一个完整的行已经准备好了”。
    • 然后它调用 wakeup(&cons.r)

5. 用户程序获取数据

  • 你的 Shell 进程早就调用了 read() 系统调用,这个调用最终执行到 consoleread()
  • consoleread() 中,因为它发现输入缓冲区是空的 (cons.r == cons.w),所以它调用 sleep(&cons.r, ...) 进入了睡眠状态,等待被唤醒。
  • 现在,consoleintr 中的 wakeup 将这个睡眠的 Shell 进程唤醒。
  • consoleread() 从睡眠中醒来,再次检查发现 cons.r != cons.w,于是它从 cons.buf 中拷贝 l, s, \n 到 Shell 的用户空间缓冲区,然后返回。
  • read() 系统调用完成,Shell 拿到了你输入的命令 "ls\n"

2. 输出流程:从 printf 到屏幕显示

输出分为两种情况:内核直接输出(如 printf),和用户进程输出(如 write)。

A. 内核 printf / consoleintr 回显(同步方式)

流程图:

printf() → consputc() → uartputc_sync() → LSR & THR 寄存器 → 屏幕
  1. 内核调用 printf,或者 consoleintr 为了回显,最终都会调用 consputc(c)
  2. consputc(c) 调用 uartputc_sync(c)
  3. uartputc_sync(c) 是一个同步、阻塞的函数。它会进入一个 while 循环,不断地读取 LSR 寄存器,检查 LSR_TX_IDLE 位。
  4. 直到 LSR_TX_IDLE 为 1(表示硬件发送器空闲),循环才会退出。
  5. 它立刻将字符 c 写入发送保持寄存器 (THR)。
  6. 一旦写入 THR,UART 硬件就会自动开始把它发送到屏幕上。函数返回。

这种方式简单直接,但效率低(CPU 在原地空转等待),适用于不能睡眠的中断上下文或内核关键代码。

B. 用户 write(异步方式)

流程图:

write() → consolewrite() → uartputc() → uart_tx_buf → uartstart() → THR → (中断) → uartintr() → uartstart() → THR ...
  1. 用户程序(比如 cat 命令)调用 write() 系统调用。
  2. 最终调用到 consolewrite()
  3. consolewrite() 循环读取用户缓冲区里的每一个字符 c,然后调用 uartputc(c)
  4. uartputc(c) 是异步的。它不会等待硬件,而是:
    • 将字符 c 放入 uart.c 自己的软件发送缓冲区 uart_tx_buf 中。
    • 调用 uartstart() 尝试启动发送。
  5. uartstart() 检查硬件 THR 是否空闲:
    • 如果空闲:它就从 uart_tx_buf 取出一个字符,写入 THR,然后继续尝试下一个,直到缓冲区为空或硬件变忙。
    • 如果不空闲:它就立刻返回。字符暂时留在 uart_tx_buf 中。
  6. 当硬件发送完 THR 中的字符后,会触发一个发送中断。
  7. 中断最终调用 uartintr()uartintr 的后半部分就是处理发送。它获取锁,然后再次调用 uartstart()
  8. 这次 uartstart 运行时,硬件肯定是空闲的,于是它就能把 uart_tx_buf 中等待的字符取出来,写入 THR,从而“接力”发送。

[!Note] 这种方式利用了缓冲区和中断,CPU 不必等待,效率更高,是用户态 I/O 的标准做法。