:终端会话与作业控制——fg/bg/jobs/Ctrl+Z》)
前言从“命令执行器”到“交互式工作环境”在前面的章节中我们的Shell已经能解析命令、创建进程、等待退出。但它仍然是一个“批处理模拟器”你无法在vim编辑时按CtrlZ切回Shell无法用bg让后台任务继续运行无法用jobs查看挂起的任务列表。更致命的是多个进程同时向终端输出时会互相覆盖键盘输入会被所有进程争抢——这不是一个可用的交互环境。作业控制Job Control的本质是“将终端的所有权在进程组之间安全转移”。它涉及三个层面的协作内核提供进程组/会话原语和TTY信号分发终端驱动实现行纪律和特殊字符识别Shell维护作业表并响应用户命令。三者缺一不可。本章我们将打通这条完整链路让你的OS拥有真正的交互式Shell体验。本章里程碑✅ 进程组与会话数据结构pgid、sid、tty归属✅ TTY层CtrlC/CtrlZ/Ctrl\信号分发✅sys_setpgid/sys_getpgrp/sys_setsid系统调用✅ Shell作业表记录每个job的pid/pgid/state/cmd✅ fg/bg/jobs命令实现与终端前台切换✅ 验证vim ↔ Shell无缝切换多作业状态正确追踪核心概念作业控制不是Shell功能而是“三方协议”进程组、会话、终端的三层抽象许多初学者误以为“作业”是Shell的概念。实际上作业是内核对“共享同一终端的一组相关进程”的建模。三层抽象各司其职层级标识符作用生命周期进程组PGID信号投递单元CtrlC发给整组随最后一个成员退出而销毁会话SID终端归属单元一个会话绑定一个TTY随会话leader退出而解除绑定前台进程组TTY→PGID当前拥有终端读写权的进程组由Shell通过tcsetpgrp切换⚠️关键洞察只有前台进程组才能读取终端输入。后台进程组尝试read()时会收到SIGTTIN而被自动挂起尝试write()时根据TOSTOP标志决定是允许还是收到SIGTTOU。这个机制由内核TTY驱动强制执行而非Shell轮询。如果你的实现把“禁止后台读”放在Shell层就永远无法阻止子进程绕过Shell直接操作fd 0。终端所有权是内核级资源Shell只是它的管理员。CtrlZ的精确语义CtrlZ不是“暂停当前进程”而是“向当前前台进程组发送SIGTSTP”。这意味着如果前台是一个管道cat | grep foo两个进程都会收到SIGTSTPSIGTSTP可以被捕获或忽略与SIGSTOP不同允许程序做清理后再挂起Shell本身必须忽略SIGTSTP否则用户会把Shell自己挂起挂起后Shell自动将自己设为新的前台进程组恢复终端控制权任何一步出错都会导致终端“死掉”Shell被挂起则无人接管终端未切换前台则恢复的进程仍无法读键盘SIGTSTP被错误地发给Shell则整个会话冻结。作业表的“影子状态”问题Shell的作业表是内核进程状态的缓存。两者可能不一致子进程可能在Shell不知情的情况下被信号杀死、被其他进程wait、或自行改变进程组。Shell必须在每次显示jobs、执行fg/bg前重新验证状态通过waitpid(WNOHANG)和getpgid。盲目信任缓存会导致fg一个已死进程、或对孤儿进程组发送信号。作业控制的健壮性取决于Shell对“缓存失效”的偏执程度。实战代码内核进程组与会话支持// kernel/process.h typedef struct process { // ... 原有字段 ... pid_t pgid; // 进程组ID pid_t sid; // 会话ID struct tty *tty; // 所属终端NULL无终端 } process_t; // kernel/sys_process.c int sys_setpgid(pid_t pid, pid_t pgid) { process_t *target; if (pid 0) target current_process; else target find_process(pid); if (!target) return -ESRCH; // ★ 安全检查不能跨会话移动进程 if (target-sid ! current_process-sid) return -EPERM; // 如果pgid0使用目标进程PID作为新PGID if (pgid 0) pgid target-pid; // 检查目标进程组是否存在于同一会话 if (pgid ! target-pid !find_pgid_in_session(target-sid, pgid)) return -EPERM; // 不允许加入不存在的进程组 target-pgid pgid; return 0; } pid_t sys_getpgrp(void) { return current_process-pgid; } pid_t sys_setsid(void) { // ★ 会话leader不能再次setsid if (current_process-pgid current_process-pid) return -EPERM; current_process-sid current_process-pid; current_process-pgid current_process-pid; current_process-tty NULL; // 脱离原终端 return current_process-sid; }TTY层的信号分发// kernel/tty.c #include signal.h #include process.h // ★ 终端行纪律处理特殊字符 void tty_input_char(struct tty *tty, char c) { switch (c) { case \x03: // CtrlC → SIGINT kill_pgrp(tty-fg_pgid, SIGINT); return; case \x1a: // CtrlZ → SIGTSTP kill_pgrp(tty-fg_pgid, SIGTSTP); // ★ Shell的SIGTSTP handler会调用tcsetpgrp切换前台 return; case \x1c: // Ctrl\ → SIGQUIT kill_pgrp(tty-fg_pgid, SIGQUIT); return; default: // 正常字符放入输入缓冲区 tty_buf_push(tty-inbuf, c); break; } } // ★ 后台进程读终端时自动挂起 ssize_t tty_read(struct tty *tty, char *buf, size_t count) { process_t *proc current_process; // 如果当前进程不在前台进程组 if (proc-pgid ! tty-fg_pgid) { // ★ 发送SIGTTIN挂起进程 send_signal(proc, SIGTTIN); // schedule后返回-EINTR用户态应重试 return -EINTR; } // 前台进程正常读取 return tty_buf_pop(tty-inbuf, buf, count); } // ★ 切换前台进程组由Shell调用 int tty_set_fg_pgrp(struct tty *tty, pid_t pgid) { // 验证pgid属于同一会话 if (!find_pgid_in_session(tty-sid, pgid)) return -EPERM; tty-fg_pgid pgid; return 0; }Shell作业表与fg/bg/jobs// user/shell/job.c #include syscall.h #include string.h #define MAX_JOBS 64 typedef enum { JOB_RUNNING, JOB_STOPPED, JOB_DONE } job_state_t; typedef struct { pid_t pgid; job_state_t state; char cmd[256]; int job_id; // %1, %2... } job_t; static job_t jobs[MAX_JOBS]; static int num_jobs 0; static int next_job_id 1; // ★ 添加新作业 int add_job(pid_t pgid, const char *cmd, job_state_t initial_state) { if (num_jobs MAX_JOBS) return -1; job_t *j jobs[num_jobs]; j-pgid pgid; j-state initial_state; strncpy(j-cmd, cmd, sizeof(j-cmd)-1); j-job_id next_job_id; return j-job_id; } // ★ 刷新作业状态关键防止影子状态 void refresh_jobs(void) { for (int i 0; i num_jobs; ) { job_t *j jobs[i]; int status; // WNOHANG WUNTRACED获取停止和终止状态 pid_t ret waitpid(-j-pgid, status, WNOHANG | WUNTRACED); if (ret 0) { if (WIFSTOPPED(status)) { j-state JOB_STOPPED; } else if (WIFEXITED(status) || WIFSIGNALED(status)) { j-state JOB_DONE; // 移除已完成作业 memmove(jobs[i], jobs[i1], (num_jobs - i - 1) * sizeof(job_t)); num_jobs--; continue; // 不递增i } } i; } } // ★ fg命令将作业切到前台 int cmd_fg(const char *arg) { refresh_jobs(); int job_id parse_job_spec(arg); // 解析%1或命令前缀 job_t *j find_job_by_id(job_id); if (!j) { printf(No such job\n); return 1; } // ★ 关键三步发SIGCONT → 切前台 → 等待 kill(-j-pgid, SIGCONT); tcsetpgrp(0, j-pgid); // 将终端前台设为该进程组 // 阻塞等待该作业停止或结束 int status; waitpid(-j-pgid, status, WUNTRACED); // ★ 作业再次停止或结束后Shell收回终端 tcsetpgrp(0, getpgrp()); if (WIFSTOPPED(status)) { j-state JOB_STOPPED; printf([%d] Stopped %s\n, j-job_id, j-cmd); } else { j-state JOB_DONE; } return 0; } // ★ bg命令后台继续运行 int cmd_bg(const char *arg) { refresh_jobs(); job_t *j find_job_by_id(parse_job_spec(arg)); if (!j) { printf(No such job\n); return 1; } kill(-j-pgid, SIGCONT); j-state JOB_RUNNING; printf([%d] Running %s \n, j-job_id, j-cmd); return 0; } // ★ jobs命令 int cmd_jobs(void) { refresh_jobs(); for (int i 0; i num_jobs; i) { const char *state_str[] {Running, Stopped, Done}; printf([%d]%c %s %s\n, jobs[i].job_id, (i num_jobs-1) ? : -, state_str[jobs[i].state], jobs[i].cmd); } return 0; }Shell启动时的会话初始化// user/shell/main.c int main(int argc, char **argv) { // ★ Shell必须是会话leader且拥有终端 if (getpgrp() ! getpid()) { setsid(); // 创建新会话 } // 将自己设为前台进程组 tcsetpgrp(0, getpgrp()); // ★ 忽略作业控制相关信号防止Shell自己被挂起 signal(SIGTSTP, SIG_IGN); signal(SIGTTIN, SIG_IGN); signal(SIGTTOU, SIG_IGN); // SIGCHLD用于异步感知子进程状态变化 signal(SIGCHLD, sigchld_handler); // 主循环... while (1) { char *line read_line(); execute_pipeline(line); // 内部调用add_job/setpgid/tcsetpgrp } }关键细节解析1. 为什么Shell必须忽略SIGTSTP/SIGTTIN/SIGTTOU因为Shell是终端的“管理者”。如果用户在Shell提示符下按CtrlZSIGTSTP会发给前台进程组——此时就是Shell自己。如果Shell被挂起就没有进程能接收后续输入、切换前台、恢复其他作业。终端将彻底失去响应。POSIX明确规定交互式Shell必须忽略这三个信号。注意Shell的子进程不应继承这些忽略设置因此fork后必须重置为默认handler。2. 为什么fg要先kill(SIGCONT)再tcsetpgrp而非反过来如果先切前台再发SIGCONT存在一个窗口期进程组已是前台但仍处于stopped状态。此时如果终端有pending输入内核会尝试投递给一个stopped的前台进程组行为未定义或导致输入丢失。正确的顺序是先确保进程可运行SIGCONT再授予终端所有权tcsetpgrp。这保证了前台切换的瞬间接收方一定处于可接受输入的状态。3. 为什么waitpid要用负PGID而非正PID因为作业是进程组级别的实体。一个管道cat file | sort包含两个进程但它们属于同一个作业。如果用正PID只wait其中一个另一个的状态变化会被遗漏导致作业表与实际不一致。waitpid(-pgid, ...)等待进程组中任意子进程的状态变化确保管道的任一成员停止或退出都能被Shell感知。这是作业控制正确性的基础。调试Checklist作业控制排查症状可能原因排查方法CtrlZ无反应TTY未识别\x1a/SIGTSTP未发送/Shell未忽略SIGTSTP导致自身被挂hexdump TTY输入确认\x1a到达kprintf kill_pgrp调用验证Shell启动时signal(SIGTSTP, SIG_IGN)生效fg后终端无响应tcsetpgrp失败/SIGCONT未发送/进程组不属于当前会话kprintf tcsetpgrp返回值确认kill(-pgid, SIGCONT)在tcsetpgrp之前验证目标pgid与Shell同sidbg后进程仍stoppedSIGCONT发给了错误的pgid/进程忽略了SIGCONT确认kill参数为负pgid用ps查看进程状态测试kill -CONT手动验证jobs显示已死进程refresh_jobs未调用/waitpid未用WNOHANG/WUNTRACED确认每次jobs/fg/bg前调用refresh_jobs验证waitpid flags包含WUNTRACEDdump waitpid返回值和WIF*宏结果后台进程read不挂起TTY未检查fg_pgid/SIGTTIN未发送在tty_read入口kprintf当前pgid vs tty-fg_pgid确认send_signal(SIGTTIN)路径被执行Shell fork的子进程无法被作业控制子进程未setpgid/继承了Shell的信号忽略确认fork后子进程调用setpgid(0,0)验证子进程signal(SIGTSTP, SIG_DFL)重置黄金法则作业控制调试的终极武器是终端状态快照函数。实现debug_dump_tty_state()同时打印①TTY的fg_pgid和sid②当前进程的pgid/sid/tty指针③所有作业的pgid/state/cmd④最近10次tty_input_char的字符hex值。作业控制bug几乎总是“某个时刻终端所有权与进程状态不匹配”fg_pgid指向已死进程组、Shell的sid与TTY的sid不一致、子进程脱离了会话。只有同时看到四层状态才能定位不一致的根源。不要单独调试Shell或单独调试内核——作业控制是跨越用户态和内核态的协议断点打在任何一侧都只能看到一半真相。本章小结与下一步今天我们让Shell从“命令执行器”进化为“交互式工作环境”✅ 实现了进程组/会话/终端三层抽象的内核支持✅ TTY层正确处理CtrlC/Z/\并分发信号到前台进程组✅ Shell维护作业表并实现fg/bg/jobs命令✅ 终端前台切换协议保证所有权安全转移✅ 后台进程读终端自动挂起防止输入争抢从此你的操作系统拥有了真正的交互式Shell体验。当你第一次在vim中按CtrlZ切回Shell、用fg %1恢复编辑、用jobs查看所有任务状态时你见证的是OS从“能跑程序”到“能作为日常开发环境使用”的质变时刻。下一章预告《管道与重定向进阶命名管道、Here Document与Shell语法扩展》当前的管道仅支持匿名pipe和简单重定向。下一章将实现FIFO命名管道、here document、EOF语法、以及更复杂的shell语法扩展让你的Shell支持脚本编写和更丰富的I/O组合。参考资料POSIX.1-2017: General Terminal Interface, Job ControlStevens Rago, Advanced Programming in the UNIX Environment, Ch.9 Process RelationshipsLinux Kernel:drivers/tty/tty_io.c,kernel/signal.c(kill_pgrp)GNU Bash Source:jobs.c,sig.c,execute_cmd.c本系列完整代码[你的GitHub仓库链接]Commit:j0b1c2t作者注这是《从零手写操作系统》系列的第28篇。作业控制是整个教程中跨层交互最密集、状态机最复杂的章节。TTY驱动、信号子系统、进程管理、Shell四层必须严格遵循POSIX作业控制协议任何一层的偏差都会导致终端“死掉”这种灾难性后果。强烈建议先实现最简单的单进程CtrlZ/fg通路无管道、无bg确认终端所有权切换正确后再扩展到多进程作业和完整命令集。把“信号分发”、“前台切换”、“作业表管理”分成三个独立里程碑并在每个里程碑后用真实交互式测试验证终端可用性是避免在四方协议迷宫中崩溃的关键纪律。下一章我们让Shell从“交互工具”走向“脚本语言”