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 寄存器 → 屏幕
- 内核调用
printf,或者consoleintr为了回显,最终都会调用consputc(c)。 consputc(c)调用uartputc_sync(c)。uartputc_sync(c)是一个同步、阻塞的函数。它会进入一个 while 循环,不断地读取LSR寄存器,检查LSR_TX_IDLE位。- 直到
LSR_TX_IDLE为 1(表示硬件发送器空闲),循环才会退出。 - 它立刻将字符
c写入发送保持寄存器 (THR)。 - 一旦写入 THR,UART 硬件就会自动开始把它发送到屏幕上。函数返回。
这种方式简单直接,但效率低(CPU 在原地空转等待),适用于不能睡眠的中断上下文或内核关键代码。
B. 用户 write(异步方式)
流程图:
write() → consolewrite() → uartputc() → uart_tx_buf → uartstart() → THR → (中断) → uartintr() → uartstart() → THR ...
- 用户程序(比如
cat命令)调用write()系统调用。 - 最终调用到
consolewrite()。 consolewrite()循环读取用户缓冲区里的每一个字符c,然后调用uartputc(c)。uartputc(c)是异步的。它不会等待硬件,而是:- 将字符
c放入uart.c自己的软件发送缓冲区uart_tx_buf中。 - 调用
uartstart()尝试启动发送。
- 将字符
uartstart()检查硬件 THR 是否空闲:- 如果空闲:它就从
uart_tx_buf取出一个字符,写入 THR,然后继续尝试下一个,直到缓冲区为空或硬件变忙。 - 如果不空闲:它就立刻返回。字符暂时留在
uart_tx_buf中。
- 如果空闲:它就从
- 当硬件发送完 THR 中的字符后,会触发一个发送中断。
- 中断最终调用
uartintr()。uartintr的后半部分就是处理发送。它获取锁,然后再次调用uartstart()。 - 这次
uartstart运行时,硬件肯定是空闲的,于是它就能把uart_tx_buf中等待的字符取出来,写入 THR,从而“接力”发送。
[!Note] 这种方式利用了缓冲区和中断,CPU 不必等待,效率更高,是用户态 I/O 的标准做法。