拓十年匠心定制 · 商业建站与技术教学双线并行 咨询热线:400-886-1026 service@lmnt.cn
ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

《从零手写操作系统 (17):终端子系统——TTY、行规程与作业控制》

《从零手写操作系统 (17):终端子系统——TTY、行规程与作业控制》
前言:从“裸设备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。终端的正确性是用无数次手动按键测出来的,自动化测试在这里几乎无用。下一章,我们让文件“活”起来!

返回列表