news 2026/10/7 19:46:47

《从零手写操作系统 (28):终端会话与作业控制——fg/bg/jobs/Ctrl+Z》

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
《从零手写操作系统 (28):终端会话与作业控制——fg/bg/jobs/Ctrl+Z》
前言:从“命令执行器”到“交互式工作环境”

在前面的章节中,我们的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”。这意味着:

  1. 如果前台是一个管道cat | grep foo,两个进程都会收到SIGTSTP
  2. SIGTSTP可以被捕获或忽略(与SIGSTOP不同),允许程序做清理后再挂起
  3. Shell本身必须忽略SIGTSTP,否则用户会把Shell自己挂起
  4. 挂起后,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后进程仍stoppedSIGCONT发给了错误的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从“交互工具”走向“脚本语言”!

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/7 19:45:09

CPF-Ionic 组织介绍

CPF-Ionic 组织-专注于 Ionic / Capacitor 生态鸿蒙化的开源组织 CPF-Ionic&#xff08;Cross-Platform Framework Ionic&#xff09;是 AtomGit 上专注于 Ionic / Capacitor 生态鸿蒙化的开源组织&#xff0c;汇集了 Ionic 中国社区的框架及三方插件资源。组织的目标是&#…

作者头像 李华
网站建设 2026/10/7 19:45:06

全学科通用 · 双检定稿对照指南

官网入口&#xff1a;笔乐颂AI - 首页 这两年毕业论文的审查逻辑变了&#xff1a;光把重复率降下来已经不够&#xff0c;"查重 AIGC 检测"双检成为多数高校的标配流程。很多同学的论文不是死在内容上&#xff0c;而是死在"不知道改哪里、改了没证据"上。…

作者头像 李华
网站建设 2026/10/7 19:44:05

Linux PCI/PCIe驱动开发实战:从枚举到BAR映射与故障排查

Linux下写PCI设备驱动&#xff0c;很多人第一反应是去翻LDD3那本经典。书没毛病&#xff0c;但真到项目里你会卡住的往往不是语法&#xff0c;而是对PCI/PCIe这套总线到底怎么发现设备、怎么分配资源、驱动又是怎么跟硬件“对上眼”的没有整体概念。我早年接手一块PCIe FPGA加速…

作者头像 李华
网站建设 2026/10/7 19:44:04

FC存储网络的确定性时延:从350ns到480天零事故

1. 为什么“可用”不等于“可靠”&#xff1a;从银行核心交易系统的一次真实抖动说起去年三季度&#xff0c;某股份制银行在上线新一代实时风控引擎时&#xff0c;遭遇了一次持续47毫秒的交易延迟尖峰。表面看&#xff0c;所有链路监控指标都在绿区——FC交换机端口无丢包、链路…

作者头像 李华
网站建设 2026/10/7 19:41:48

Mac 安装 CC Switch 后 401 报错,把 endpoint 改到 TaoToken 的排查记录

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/10/7 19:41:46

Manus与OpenClaw的区别与联系:从任务编排到工具调用的架构对比

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华