前言:从“裸设备I/O”到“交互式终端体验”

        在前面的章节中,我们让进程能够接收信号、Shell能够通过fork/exec运行子程序。但此时的输入输出仍是割裂的:键盘驱动直接往环形缓冲区塞扫描码,串口/显存直接吐字节,没有“行”的概念,没有回显,没有退格编辑,更没有Ctrl+Z挂起和fg/bg切换。用户面对的不是一个“终端”,而是一个裸露的硬件管道。

        本章我们将构建完整的TTY子系统,这是Unix人机交互的灵魂所在。它将原始按键流转化为结构化的行输入,将程序输出流转化为带控制的显示序列,并引入作业控制(Job Control)让Shell真正具备多任务管理能力。

本章里程碑:

  • ✅ 实现TTY抽象层:统一键盘输入与屏幕/串口输出
  • ✅ 实现行规程(Line Discipline):回显、退格、行缓冲、特殊字符处理
  • ✅ 实现作业控制原语:SIGTSTP(Ctrl+Z)、SIGCONT、前台进程组切换
  • ✅ 重构Shell:支持&后台执行、jobs、fg、bg命令
  • ✅ 将tty_read/tty_write作为系统调用暴露给用户态
  • ✅ 验证完整终端体验:行编辑、Ctrl+C中断、Ctrl+Z挂起、fg恢复

核心概念:三层架构与作业控制语义
TTY ≠ 键盘 + 屏幕

        Unix终端的经典三层模型:

层级职责本章实现
硬件驱动层读写物理设备(PS/2、VGA、UART)已有keyboard.c / console.c
行规程层 (LDISC)行编辑、回显、信号生成、流量控制★ 本章核心新增
TTY接口层向用户态提供read/write/ioctl统一APIsys_tty_read / sys_tty_write

        行规程是中间那层“智能胶水”。它拦截原始按键,决定哪些字符回显、哪些存入行缓冲、哪些立即触发信号。没有行规程的终端就像没有语法解析器的编译器——能接收输入,但无法理解意图。

作业控制的核心:进程组与前台/后台

        Unix作业控制依赖两个关键抽象:

  • 进程组(Process Group):一组相关进程的集合,由PGID标识。同一管道或同一Shell启动的命令属于同一进程组。
  • 前台进程组:当前拥有终端控制权的进程组。只有前台进程组才能读取tty输入;后台进程组尝试read时收到SIGTTIN被暂停。

⚠️ 本章简化:不实现完整的session/ctty机制。使用全局变量foreground_pgid追踪前台进程组,Shell自身作为PGID=0的特殊组始终存在。这足以支撑教学级作业控制。

Ctrl+Z vs Ctrl+C的本质区别
  • Ctrl+C → SIGINT:请求终止。默认动作是退出。
  • Ctrl+Z → SIGTSTP:请求暂停。默认动作是将进程置为STOPPED状态,并将终端控制权交还Shell。

        SIGTSTP的处理必须在内核侧完成状态转换,因为用户态Handler无法安全地将自己挂起并通知父进程。这与SIGINT的纯信号投递有本质不同。


实战代码
TTY结构与行规程
// tty.h
#define TTY_BUF_SIZE 256
#define LDISC_BUF_SIZE 256

typedef struct tty {
    // 原始输入缓冲(来自键盘驱动)
    uint8_t raw_buf[TTY_BUF_SIZE];
    volatile int raw_head, raw_tail;
    
    // ★ 行规程缓冲(编辑后的完整行)
    char ldisc_buf[LDISC_BUF_SIZE];
    int ldisc_len;              // 当前行已积累字符数
    int ldisc_ready;            // 1=遇到\n,整行可读
    
    // 终端属性
    int echo_enabled;           // 是否回显
    int canonical_mode;         // 1=行模式(默认), 0=raw模式
    
    // 作业控制
    int foreground_pgid;        // 当前前台进程组ID
} tty_t;

extern tty_t system_tty;
// tty.c - 行规程核心
#include "tty.h"
#include "console.h"
#include "process.h"
#include "signal.h"

tty_t system_tty = {
    .echo_enabled = 1,
    .canonical_mode = 1,
    .foreground_pgid = 0,
};

// ★ 由键盘驱动在IRQ中调用
void tty_input_char(char c) {
    tty_t *t = &system_tty;
    
    if (!t->canonical_mode) {
        // Raw模式:直接入队,不做任何处理
        t->raw_buf[t->raw_head++ % TTY_BUF_SIZE] = c;
        return;
    }
    
    // === Canonical模式:行规程处理 ===
    
    // 特殊字符优先处理
    switch (c) {
    case '\x03': // Ctrl+C
        send_signal_to_pgid(t->foreground_pgid, SIGINT);
        return;
        
    case '\x1A': // Ctrl+Z
        send_signal_to_pgid(t->foreground_pgid, SIGTSTP);
        return;
        
    case '\b':   // Backspace
    case 0x7F:   // DEL
        if (t->ldisc_len > 0) {
            t->ldisc_len--;
            if (t->echo_enabled) {
                console_putc('\b');
                console_putc(' ');
                console_putc('\b');
            }
        }
        return;
        
    case '\n':
    case '\r':
        // 行结束:标记就绪,回显换行
        if (t->echo_enabled) console_putc('\n');
        t->ldisc_buf[t->ldisc_len++] = '\n';
        t->ldisc_ready = 1;
        // 唤醒等待读取此tty的进程
        tty_wakeup_readers();
        return;
    }
    
    // 普通可打印字符
    if (c >= 0x20 && t->ldisc_len < LDISC_BUF_SIZE - 1) {
        t->ldisc_buf[t->ldisc_len++] = c;
        if (t->echo_enabled) console_putc(c);
    }
}

// sys_tty_read: 阻塞直到一行就绪
int sys_tty_read(interrupt_frame_t *frame) {
    char *user_buf = (char *)frame->ebx;
    int count = frame->ecx;
    tty_t *t = &system_tty;
    
    // ★ 仅前台进程组可读
    if (current_process->pgid != t->foreground_pgid) {
        // 后台进程尝试读tty → SIGTTIN
        current_process->sig_pending |= (1 << SIGTTIN);
        current_process->state = PROC_STOPPED;
        schedule();
        // 被SIGCONT唤醒后重新检查
        return -1; // EINTR
    }
    
    // 阻塞等待整行
    while (!t->ldisc_ready) {
        current_process->state = PROC_WAITING;
        current_process->wait_channel = (uint32_t)&t->ldisc_ready;
        schedule();
    }
    
    // 拷贝整行到用户缓冲
    int copy_len = t->ldisc_len < count ? t->ldisc_len : count;
    memcpy(user_buf, t->ldisc_buf, copy_len);
    
    // 重置行缓冲
    t->ldisc_len = 0;
    t->ldisc_ready = 0;
    
    return copy_len;
}
SIGTSTP内核侧处理与作业切换
// signal.c 补充
void handle_sigtstp(process_t *proc) {
    // ★ SIGTSTP的默认动作在内核侧完成,不走用户态handler
    proc->state = PROC_STOPPED;
    
    // 如果该进程属于前台进程组,将控制权交还Shell(PGID=0)
    tty_t *t = &system_tty;
    if (proc->pgid == t->foreground_pgid) {
        t->foreground_pgid = 0; // Shell收回前台
        
        // 通知Shell有新作业停止(通过SIGCHLD)
        process_t *shell = find_process_by_pgid(0);
        if (shell) {
            shell->sig_pending |= (1 << SIGCHLD);
            if (shell->state == PROC_WAITING || 
                shell->state == PROC_SLEEPING) {
                shell->state = PROC_READY;
            }
        }
    }
    
    kprintf("[TTY] PID %d (PGID %d) stopped by SIGTSTP\n", 
            proc->pid, proc->pgid);
}

// sys_sigreturn中对SIGTSTP的特殊处理已在check_and_deliver_signal中分流
// SIGTSTP永远不进用户态handler,直接在deliver阶段调用handle_sigtstp
Shell作业控制命令
// user/shell.c - 作业控制扩展
#define SYS_GETPGID  12
#define SYS_SETPGID  13
#define SYS_TCSETPGRP 14  // 设置前台进程组

static int jobs_count = 0;
static struct { int pgid; int pid; int stopped; char cmd[64]; } jobs[16];

static void cmd_jobs(void) {
    for (int i = 0; i < jobs_count; i++) {
        const char *state = jobs[i].stopped ? "Stopped" : "Running";
        printf("[%d] %s\t%s (pgid=%d)\n", 
               i+1, state, jobs[i].cmd, jobs[i].pgid);
    }
}

static void cmd_fg(const char *arg) {
    int job_idx = atoi(arg) - 1;
    if (job_idx < 0 || job_idx >= jobs_count) {
        printf("fg: invalid job\n"); return;
    }
    // 将指定作业设为前台并发送SIGCONT
    asm volatile("int $0x80" :: "a"(SYS_TCSETPGRP), "b"(jobs[job_idx].pgid));
    // 等待前台作业完成或再次停止
    int status;
    sys_wait(&status);
    // 更新jobs表...
}

static void cmd_bg(const char *arg) {
    int job_idx = atoi(arg) - 1;
    if (job_idx < 0 || job_idx >= jobs_count) {
        printf("bg: invalid job\n"); return;
    }
    // 仅发送SIGCONT,不设前台
    asm volatile("int $0x80" : : "a"(9/*SYS_KILL*/), 
                 "b"(jobs[job_idx].pgid), "c"(SIGCONT));
    jobs[job_idx].stopped = 0;
}

// exec前设置子进程PGID
static int run_command(char *cmd, int background) {
    int pid = sys_fork();
    if (pid == 0) {
        if (!background) {
            // 前台子进程:PGID = 自己的PID
            asm volatile("int $0x80" :: "a"(SYS_SETPGID), "b"(0), "c"(0));
        } else {
            // 后台子进程:PGID = 自己的PID(独立作业)
            asm volatile("int $0x80" :: "a"(SYS_SETPGID), "b"(0), "c"(0));
        }
        sys_exec(cmd);
        printf("exec failed: %s\n", cmd);
        sys_exit(1);
    }
    
    if (!background) {
        // 前台:设其为前台进程组并等待
        asm volatile("int $0x80" :: "a"(SYS_TCSETPGRP), "b"(pid));
        int status;
        sys_wait(&status);
        // Shell自动收回前台(由SIGTSTP/SIGCHLD处理保证)
    } else {
        // 后台:记录到jobs表,不等待
        jobs[jobs_count].pgid = pid;
        jobs[jobs_count].pid = pid;
        jobs[jobs_count].stopped = 0;
        strncpy(jobs[jobs_count].cmd, cmd, 63);
        jobs_count++;
        printf("[%d] %d\n", jobs_count, pid);
    }
    return pid;
}

关键细节解析

1. 为什么行缓冲放在内核而非用户态?

        如果行编辑在用户态完成(如readline库),那么每个程序都要自带一套编辑逻辑,且Ctrl+C等信号只能在read返回后才能被检测。内核侧行规程保证了所有程序共享一致的编辑体验,且特殊字符能在任意时刻被即时响应。这也是为什么Unix的stty配置是per-tty而非per-process的。

2. 为什么SIGTSTP必须内核处理而不能交给用户Handler?

        假设用户注册了SIGTSTP handler并在其中调用pause()等待SIGCONT。问题在于:在handler执行期间进程仍处于RUNNING状态,终端仍认为它是前台进程。其他进程无法获得终端输入权。只有内核原子地完成“STOPPED状态+前台切换”两步操作,才能保证作业控制的正确性。POSIX允许捕获SIGTSTP,但要求handler返回后若未显式忽略则仍执行默认停止动作——本章简化为完全禁止用户捕获SIGTSTP。

3. 后台进程读tty为什么要发SIGTTIN而非直接返回错误?

        如果后台进程read返回EAGAIN,它可能会busy-loop重试,耗尽CPU。SIGTTIN将其暂停是一种背压机制:告诉进程“现在不该你读,等你被fg回来再说”。当用户执行fg %1时,Shell先发SIGCONT再设前台,进程恢复后重新执行read,此时已是前台,正常返回数据。这是Unix作业控制优雅性的体现:用信号协调而非轮询竞争。


调试Checklist:终端与作业控制排查
症状可能原因排查方法
按键无回显但程序能读到echo_enabled未初始化/tty_input_char未走canonical路径dump system_tty.echo_enabled;确认键盘IRQ调用了tty_input_char而非直接写raw_buf
Ctrl+C/Ctrl+Z无效特殊字符码值错误/send_signal_to_pgid未找到目标进程kprintf在switch分支入口dump收到的字符值;确认foreground_pgid与实际子进程pgid匹配
fg后进程不恢复SIGCONT未发送/tcsetpgrp未更新foreground_pgid在cmd_fg中kprintf确认syscall参数;dump system_tty.foreground_pgid变化
后台进程抢占输入SIGTTIN未实现/后台判断条件错误确认sys_tty_read中pgid比较逻辑;测试后台cat是否被正确暂停
退格删除多个字符\b处理未考虑多字节/回显序列不完整测试单字符退格是否正常;确认console_putc('\b')在你的显示驱动中正确左移光标
jobs列表状态不更新SIGCHLD未处理/wait未区分WIFSTOPPED在Shell的SIGCHLD handler中dump wait返回的status;确认WIFSTOPPED宏正确提取

🔧 黄金法则:终端调试的最佳工具是双窗口对照。一个窗口跑你的OS,另一个窗口开着Linux终端运行相同命令序列,逐键对比行为差异。不要凭记忆判断“应该怎样”——Unix终端的行为规范藏在四十年的兼容性包袱里,很多“反直觉”行为其实是正确的。当你不确定时,Linux就是标准答案。


本章小结与下一步

        今天我们构建了操作系统的人机交互灵魂:

  • ✅ 实现了完整的TTY三层架构:驱动→行规程→系统调用
  • ✅ 行规程支持回显、退格、行缓冲、Ctrl+C/Z即时响应
  • ✅ 作业控制实现了进程组、前台切换、SIGTSTP内核处理、SIGTTIN背压
  • ✅ Shell获得了&、jobs、fg、bg完整能力
  • ✅ 终端行为首次达到“类Unix可用”水准

        从此,你的操作系统拥有了真正的交互式用户体验。当你第一次按下Ctrl+Z看到程序暂停、输入fg看到它无缝恢复、后台任务安静运行不打断输入时,你感受到的是Unix设计哲学中最人性化的部分:计算机应该适应人的工作流,而非相反。

下一章预告:《文件系统进阶:inode、目录树与持久化存储》

        当前的initrd是只读的扁平文件列表。下一章将实现真正的分层文件系统:inode结构、目录项、路径解析、文件创建/删除,以及基于RAMDisk的读写持久化,让你的OS告别“重启即失忆”。


参考资料
  • Linux Kernel: drivers/tty/tty_io.c, drivers/tty/n_tty.c
  • The Design of the UNIX Operating System (Bach), Chapter 10-11
  • xv6 Source: kernel/console.c, kernel/proc.c (job control)
  • Stevens APUE Chapter 9 (Process Relationships) & Chapter 19 (Pseudo Terminals)
  • 本系列完整代码:[你的GitHub仓库链接](Commit: t1t2y3j)

📝 作者注:这是《从零手写操作系统》系列的第17篇。终端子系统是第一个让你同时面对“硬件时序”、“内核状态机”和“用户交互预期”三重约束的章节。如果你的Ctrl+Z有时有效有时无效,大概率是键盘IRQ与信号投递之间的竞态——试着在tty_input_char入口处加cli/sti保护临界区。建议先实现纯canonical模式(无作业控制),确认行编辑完美后再叠加SIGTSTP和fg/bg。终端的正确性是用无数次手动按键测出来的,自动化测试在这里几乎无用。 下一章,我们让文件“活”起来!

Logo

openEuler 是由开放原子开源基金会孵化的全场景开源操作系统项目,面向数字基础设施四大核心场景(服务器、云计算、边缘计算、嵌入式),全面支持 ARM、x86、RISC-V、loongArch、PowerPC、SW-64 等多样性计算架构

更多推荐