尧图网站设计 尧图网站设计YAOTU DESIGN
ARTICLE DETAIL

资讯详情

深耕网站设计与一线实操的经验洞察。

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

《从零手写操作系统 (17):终端子系统——TTY、行规程与作业控制》 前言从“裸设备I/O”到“交互式终端体验”在前面的章节中我们让进程能够接收信号、Shell能够通过fork/exec运行子程序。但此时的输入输出仍是割裂的键盘驱动直接往环形缓冲区塞扫描码串口/显存直接吐字节没有“行”的概念没有回显没有退格编辑更没有CtrlZ挂起和fg/bg切换。用户面对的不是一个“终端”而是一个裸露的硬件管道。本章我们将构建完整的TTY子系统这是Unix人机交互的灵魂所在。它将原始按键流转化为结构化的行输入将程序输出流转化为带控制的显示序列并引入作业控制Job Control让Shell真正具备多任务管理能力。本章里程碑✅ 实现TTY抽象层统一键盘输入与屏幕/串口输出✅ 实现行规程Line Discipline回显、退格、行缓冲、特殊字符处理✅ 实现作业控制原语SIGTSTPCtrlZ、SIGCONT、前台进程组切换✅ 重构Shell支持后台执行、jobs、fg、bg命令✅ 将tty_read/tty_write作为系统调用暴露给用户态✅ 验证完整终端体验行编辑、CtrlC中断、CtrlZ挂起、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自身作为PGID0的特殊组始终存在。这足以支撑教学级作业控制。CtrlZ vs CtrlC的本质区别CtrlC → SIGINT请求终止。默认动作是退出。CtrlZ → 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行模式(默认), 0raw模式 // 作业控制 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: // CtrlC send_signal_to_pgid(t-foreground_pgid, SIGINT); return; case \x1A: // CtrlZ 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(PGID0) 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_sigtstpShell作业控制命令// 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, i1, 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库那么每个程序都要自带一套编辑逻辑且CtrlC等信号只能在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_bufCtrlC/CtrlZ无效特殊字符码值错误/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三层架构驱动→行规程→系统调用✅ 行规程支持回显、退格、行缓冲、CtrlC/Z即时响应✅ 作业控制实现了进程组、前台切换、SIGTSTP内核处理、SIGTTIN背压✅ Shell获得了、jobs、fg、bg完整能力✅ 终端行为首次达到“类Unix可用”水准从此你的操作系统拥有了真正的交互式用户体验。当你第一次按下CtrlZ看到程序暂停、输入fg看到它无缝恢复、后台任务安静运行不打断输入时你感受到的是Unix设计哲学中最人性化的部分计算机应该适应人的工作流而非相反。下一章预告《文件系统进阶inode、目录树与持久化存储》当前的initrd是只读的扁平文件列表。下一章将实现真正的分层文件系统inode结构、目录项、路径解析、文件创建/删除以及基于RAMDisk的读写持久化让你的OS告别“重启即失忆”。参考资料Linux Kernel:drivers/tty/tty_io.c,drivers/tty/n_tty.cThe Design of the UNIX Operating System (Bach), Chapter 10-11xv6 Source:kernel/console.c,kernel/proc.c(job control)Stevens APUE Chapter 9 (Process Relationships) Chapter 19 (Pseudo Terminals)本系列完整代码[你的GitHub仓库链接]Commit:t1t2y3j作者注这是《从零手写操作系统》系列的第17篇。终端子系统是第一个让你同时面对“硬件时序”、“内核状态机”和“用户交互预期”三重约束的章节。如果你的CtrlZ有时有效有时无效大概率是键盘IRQ与信号投递之间的竞态——试着在tty_input_char入口处加cli/sti保护临界区。建议先实现纯canonical模式无作业控制确认行编辑完美后再叠加SIGTSTP和fg/bg。终端的正确性是用无数次手动按键测出来的自动化测试在这里几乎无用。下一章我们让文件“活”起来
返回列表