前言:从“命令执行器”到“交互式工作环境”
在前面的章节中,我们的Shell已经能解析命令、创建进程、等待退出。但它仍然是一个“批处理模拟器”:你无法在vim编辑时按Ctrl+Z切回Shell,无法用bg让后台任务继续运行,无法用jobs查看挂起的任务列表。更致命的是,多个进程同时向终端输出时会互相覆盖,键盘输入会被所有进程争抢——这不是一个可用的交互环境。
作业控制(Job Control)的本质是“将终端的所有权在进程组之间安全转移”。它涉及三个层面的协作:内核提供进程组/会话原语和TTY信号分发;终端驱动实现行纪律和特殊字符识别;Shell维护作业表并响应用户命令。三者缺一不可。本章我们将打通这条完整链路,让你的OS拥有真正的交互式Shell体验。
本章里程碑:
- ✅ 进程组与会话数据结构:pgid、sid、tty归属
- ✅ TTY层Ctrl+C/Ctrl+Z/Ctrl+\信号分发
- ✅
sys_setpgid/sys_getpgrp/sys_setsid系统调用 - ✅ Shell作业表:记录每个job的pid/pgid/state/cmd
- ✅ fg/bg/jobs命令实现与终端前台切换
- ✅ 验证:vim ↔ Shell无缝切换,多作业状态正确追踪
核心概念:作业控制不是Shell功能,而是“三方协议”
进程组、会话、终端的三层抽象
许多初学者误以为“作业”是Shell的概念。实际上,作业是内核对“共享同一终端的一组相关进程”的建模。三层抽象各司其职:
| 层级 | 标识符 | 作用 | 生命周期 |
|---|---|---|---|
| 进程组 | PGID | 信号投递单元(Ctrl+C发给整组) | 随最后一个成员退出而销毁 |
| 会话 | SID | 终端归属单元(一个会话绑定一个TTY) | 随会话leader退出而解除绑定 |
| 前台进程组 | TTY→PGID | 当前拥有终端读写权的进程组 | 由Shell通过tcsetpgrp切换 |
⚠️关键洞察:只有前台进程组才能读取终端输入。后台进程组尝试read()时会收到SIGTTIN而被自动挂起;尝试write()时根据TOSTOP标志决定是允许还是收到SIGTTOU。这个机制由内核TTY驱动强制执行,而非Shell轮询。如果你的实现把“禁止后台读”放在Shell层,就永远无法阻止子进程绕过Shell直接操作fd 0。终端所有权是内核级资源,Shell只是它的管理员。
Ctrl+Z的精确语义
Ctrl+Z不是“暂停当前进程”,而是“向当前前台进程组发送SIGTSTP”。这意味着:
- 如果前台是一个管道
cat | grep foo,两个进程都会收到SIGTSTP - SIGTSTP可以被捕获或忽略(与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; // 如果pgid==0,使用目标进程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': // Ctrl+C → SIGINT kill_pgrp(tty->fg_pgid, SIGINT); return; case '\x1a': // Ctrl+Z → 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[i+1], (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提示符下按Ctrl+Z,SIGTSTP会发给前台进程组——此时就是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:作业控制排查
| 症状 | 可能原因 | 排查方法 |
|---|---|---|
| Ctrl+Z无反应 | 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同sid |
| bg后进程仍stopped | SIGCONT发给了错误的pgid/进程忽略了SIGCONT | 确认kill参数为负pgid;用ps查看进程状态;测试kill -CONT手动验证 |
| jobs显示已死进程 | refresh_jobs未调用/waitpid未用WNOHANG/WUNTRACED | 确认每次jobs/fg/bg前调用refresh_jobs;验证waitpid flags包含WUNTRACED;dump 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层正确处理Ctrl+C/Z/\并分发信号到前台进程组
- ✅ Shell维护作业表并实现fg/bg/jobs命令
- ✅ 终端前台切换协议保证所有权安全转移
- ✅ 后台进程读终端自动挂起,防止输入争抢
从此,你的操作系统拥有了真正的交互式Shell体验。当你第一次在vim中按Ctrl+Z切回Shell、用fg %1恢复编辑、用jobs查看所有任务状态时,你见证的是OS从“能跑程序”到“能作为日常开发环境使用”的质变时刻。
下一章预告:《管道与重定向进阶:命名管道、Here Document与Shell语法扩展》
当前的管道仅支持匿名pipe和简单重定向。下一章将实现FIFO命名管道、here document、<<EOF语法、以及更复杂的shell语法扩展,让你的Shell支持脚本编写和更丰富的I/O组合。
参考资料
- POSIX.1-2017: General Terminal Interface, Job Control
- Stevens & Rago, "Advanced Programming in the UNIX Environment", Ch.9 Process Relationships
- Linux 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作业控制协议,任何一层的偏差都会导致终端“死掉”这种灾难性后果。强烈建议先实现最简单的单进程Ctrl+Z/fg通路(无管道、无bg),确认终端所有权切换正确后再扩展到多进程作业和完整命令集。把“信号分发”、“前台切换”、“作业表管理”分成三个独立里程碑,并在每个里程碑后用真实交互式测试验证终端可用性,是避免在四方协议迷宫中崩溃的关键纪律。下一章,我们让Shell从“交互工具”走向“脚本语言”!