到目前为止,进程只能输出,没有办法接收用户输入。SYS_READ 形同虚设,键盘按了也没反应。

这一章实现 TTY——Unix 对终端设备的最初抽象,让进程能以行为单位从串口读取输入。


TTY 是什么

TTY 原本是 teletypewriter(电传打字机)的缩写。在 Unix 里,它泛指"终端"——一个字符设备,既能接收键盘输入,又能输出文字。

现代 Linux 里的 /dev/tty/dev/pts/0 都是 TTY 的后代。我们这里用串口 COM1 模拟一个最简单的 TTY。


整体架构

用户键盘输入
   ↓ 串口硬件触发 IRQ4
isr_handler → tty_recv(c)   ← 回显 + 写入 ring buffer
                ↓ 遇到 '\n'
           line_ready = 1
                ↓ 进程 syscall SYS_READ(fd=0)
           tty_read(buf, len)  ← 把一行搬到用户 buf
                ↓
           用户程序处理输入

三个关键组件:

  1. Ring Buffer:固定大小的环形缓冲区,存放还未被读走的字符
  2. 行规程(line discipline):积累字符,遇 \n 才通知"行就绪"
  3. IRQ4 中断处理:从串口读一个字节,交给 TTY

Ring Buffer

环形缓冲区是一个固定数组,加上读指针和写指针:

[ _ _ _ _ h e l l o \n _ _ ]
           ↑                 ↑
         rx_read          rx_write
rx_len = 6

写字符:rx_buf[rx_write] = c; rx_write = (rx_write + 1) % TTY_BUF_SIZE; rx_len++

读字符:c = rx_buf[rx_read]; rx_read = (rx_read + 1) % TTY_BUF_SIZE; rx_len--

写指针追上读指针就满了,满了直接丢弃新字符。读指针追上写指针就空了。两个指针永远在 [0, TTY_BUF_SIZE) 内循环,不需要移动数据。


串口中断

PC 的 COM1 串口连在 IRQ4 上。接收到一个字符时,PIC 发中断,CPU 跳到中断处理函数。

tty_init() 做两件事:

outb(0x3F8 + 1, 0x01);  // COM1 IER:打开接收中断
pic_unmask(4);           // PIC:放行 IRQ4

isr_handler 里新增:

} else if (irq == 4) {
    char c = (char)isr_inb(0x3F8);
    tty_recv(c);
}

从 COM1 数据寄存器(0x3F8)读出字符,交给 tty_recv


行规程

tty_recv 很简单,三步走:

void tty_recv(char c) {
    if (c == '\r') c = '\n';     // 回车统一转换
    serial_putchar(c);           // 回显:让用户看到自己输入的字符
    if (rx_len < TTY_BUF_SIZE) {
        rx_buf[rx_write] = c;
        rx_write = (rx_write + 1) % TTY_BUF_SIZE;
        rx_len++;
    }
    if (c == '\n') line_ready = 1;
}

tty_read 只在 line_ready 时才复制数据:

int tty_read(void *buf, uint32_t len) {
    if (!line_ready) return 0;
    // 复制直到遇到 '\n' 或读完 len 个字节
    // 复制完后重新检查缓冲区中是否还有下一个 '\n'
}

这就是"行规程"最核心的语义:用户程序以行为单位读取,每次 read 返回一整行(包含 \n)。


系统调用改动

SYS_WRITE:从原来的"直接 serial_print(ptr)“改成"调 tty_write(ptr, len)",新增了长度参数,这样可以输出包含 \0 的二进制数据。

SYS_READfd=0 特殊处理,走 tty_read;其他 fd 走原来的 VFS 路径。

case SYS_READ: {
    if (rfd == 0) {
        rn = tty_read(kbuf, len);
    } else {
        rn = fd_read(current->fd_table, rfd, kbuf, len);
    }
    ...
}

测试

用户程序(hello.asm):

_start:
    ; 输出提示
    mov rax, SYS_WRITE
    lea rdi, [rel msg_prompt]
    mov rsi, msg_prompt_len
    syscall

.read_loop:
    ; 自旋等待一行输入
    mov rax, SYS_READ
    mov rdi, 0        ; fd=0 (stdin/tty)
    lea rsi, [rel buf]
    mov rdx, 64
    syscall
    cmp rax, 0
    jle .read_loop

    ; 回显
    mov rbx, rax
    mov rax, SYS_WRITE
    lea rdi, [rel msg_echo]
    mov rsi, msg_echo_len
    syscall
    mov rax, SYS_WRITE
    lea rdi, [rel buf]
    mov rsi, rbx
    syscall

运行效果:

tty> type something (end with Enter):
hello
you typed: hello

用户输入 hello 并回车,每个字符都被立即回显,按下回车后 tty_read 返回,程序打印 “you typed: hello”。


总结

组件 职责
tty_init 开串口接收中断 + pic_unmask(4)
tty_recv IRQ4 回调:回显 + 写 ring buffer
tty_read syscall 路径:读一行
tty_write syscall 路径:输出到串口

TTY 的核心是中断驱动输入 + 行规程缓冲。字符来了不阻塞进程,进程 read 时也不轮询硬件,而是检查"行就绪"标志。这是异步 I/O 最朴素的形态。

下一章:select/poll,让进程同时等多个 fd。