上一章 busybox sh 成功显示了 / # 提示符,但 shell 拿不到任何输入——因为 tty_read 是忙轮询的,没有字符就直接返回 0,shell 以为收到 EOF,立刻退出。这一章实现真正的阻塞式 TTY,让 shell 能等待用户输入。

之前的问题

之前的 tty_read 大概是这样:

int tty_read(char *buf, int len) {
    // 轮询串口状态寄存器
    if (!(inb(0x3F8 + 5) & 1)) return 0;   // 没数据就返回 0
    *buf = inb(0x3F8);
    return 1;
}

这有两个问题:

  1. 没有输入时返回 0,上层程序(shell)以为是 EOF
  2. 如果上层在循环里调这个,CPU 100% 占用

正确做法:没有输入时挂起进程,等串口中断来了再唤醒。

串口中断(IRQ4)

COM1 对应 IRQ4,接在 PIC 主片的 IR4 引脚。要用串口中断,需要两步:

1. 在 PIC 上 unmask IRQ4

// PIC 主片 IMR 寄存器:0 表示开放,1 表示屏蔽
uint8_t mask = inb(0x21);
mask &= ~(1 << 4);   // 清除 bit4,开放 IRQ4
outb(0x21, mask);

2. 开启串口的接收中断

outb(0x3F8 + 1, 0x01);   // IER:bit0 = 接收数据可用中断

中断处理函数在 IRQ4 触发时读取接收到的字符:

void irq4_handler(void) {
    while (inb(0x3F8 + 5) & 1) {   // LSR bit0:数据就绪
        char c = inb(0x3F8);
        tty_input(c);               // 送入行缓冲
    }
    pic_send_eoi(4);
}

行缓冲(canonical mode)

TTY 的 canonical mode:用户输入不是一个字符一个字符地交给进程,而是按回车键之后才把整行交出去。这样用户可以在按回车前修改输入(退格)。

行缓冲实现:

static char  linebuf[256];
static int   linebuf_len = 0;
static int   line_ready  = 0;   // 是否有完整行

void tty_input(char c) {
    if (c == '\r' || c == '\n') {
        linebuf[linebuf_len++] = '\n';
        line_ready = 1;
        tty_write("\r\n", 2);    // 回显换行
    } else if (c == 0x7F) {      // 退格
        if (linebuf_len > 0) {
            linebuf_len--;
            tty_write("\b \b", 3);   // 终端退格:光标左移、空格覆盖、再左移
        }
    } else if (c == 0x03) {      // Ctrl+C
        signal_send(fg_pid, SIGINT);
    } else if (c == 0x04) {      // Ctrl+D(EOF)
        line_ready = 1;           // 空行,read 返回 0
    } else {
        linebuf[linebuf_len++] = c;
        tty_write(&c, 1);         // 回显
    }
}

回显(echo)是 TTY 的基本功能:收到字符后立即通过串口写回终端,用户才能看到自己在打什么。

阻塞等待:sti + hlt

tty_read 等待行缓冲就绪的方式:

int tty_read(char *buf, int len) {
    // 等待完整行
    while (!line_ready) {
        sti();   // 开中断
        hlt();   // 挂起 CPU,等中断唤醒
        cli();   // 恢复关中断(内核态约定)
    }

    // 取出一行
    int n = linebuf_len < len ? linebuf_len : len;
    memcpy(buf, linebuf, n);
    linebuf_len = 0;
    line_ready  = 0;
    return n;
}

sti 开中断,hlt 让 CPU 停下来等中断。串口 IRQ4 触发后,CPU 从 hlt 中醒来,执行中断处理程序,调用 tty_input 把字符存入行缓冲,EOI 之后返回 hlt 的下一条指令,循环检查 line_ready

如果按下回车,line_ready = 1,while 循环退出,读取并返回这一行。

这里有个细节:PIT 定时器(100Hz)也会让 hlt 醒来,这是好事——调度器可以趁机切换到其他进程,不会让 CPU 完全空转。

Ctrl+D 的 EOF 语义

Ctrl+D 在 canonical mode 下的含义:刷出当前行缓冲,如果行缓冲为空则返回 0(EOF)

实现:收到 0x04 时设置 line_ready = 1,读出时如果 n == 0,上层程序收到 read() 返回 0,解读为 EOF。

// tty_read 返回 0 → read() 返回 0 → shell 解读为 EOF,正常退出

最终效果

/ # echo hello
hello
/ # ls
bin  etc
/ # 

busybox sh 能正常回显输入,退格可用,按回车执行命令,Ctrl+C 发送 SIGINT,Ctrl+D 退出 shell。shell 不再退出了——tty_read 真正挂起,等用户输入。

小结

阻塞式 TTY 的核心是三件事:

串口中断:不再轮询,IRQ4 触发时读字符写入行缓冲。需要在 PIC 上 unmask IRQ4,并开启串口接收中断。

行缓冲:收集字符直到回车,统一交给进程。同时处理退格(\b \b)和特殊键(Ctrl+C、Ctrl+D)。

sti + hlt:内核态等待的标准姿势。开中断,然后挂起;中断来了,CPU 自动醒来继续执行。PIT 中断会打断 hlt,调度器可以趁机运行其他进程,CPU 利用率从 100% 降到接近 0%。