CSAPP Shell Lab 实战:用 C 手写支持作业控制的系统级 Shell
简介:本资源是面向计算机系统课程学习者与操作系统实践者的CSAPP Shell Lab高分参考实现,聚焦北大与CMU联合课程中Shell编程核心能力训练,帮助学生深入理解命令行解析、进程控制、管道重定向及信号处理等底层机制。压缩包为7KB的ZIP文件,仅含1个C源文件(shellLab.c),该文件实现了符合实验要求的简易Shell解释器,涵盖内置命令支持、作业控制、信号响应与I/O重定向等关键功能,代码结构清晰、注释详尽,便于对照实验手册逐项验证与调试。已有2707人学习下载,适合作为课程实验的思路参考、代码比对与排错依据,尤其适合在完成基础实现后,用于分析满分方案的设计逻辑、错误处理策略及系统调用使用规范。
1. CSAPP Shell Lab 是什么:不是写个 while 循环就完事的“系统级手写 shell”,而是用 C 拆解 fork/exec/wait 的黑匣子,让每个./tsh进程都暴露在信号、作业控制、管道和内置命令的交叉火力下
CSAPP Shell Lab(全称Computer Systems: A Programmer’s Perspective第八章配套实验)是全球顶尖 CS 课程中公认的“系统编程分水岭”——它不考你ls -la能不能敲对,而是逼你亲手用纯 C 实现一个功能完整、行为严格对标 bash 的简化 shell:支持前台/后台作业、Ctrl+Z 挂起、Ctrl+C 终止、jobs查看、fg/bg切换、管道|、重定向< > >>,甚至要处理kill %1这种带作业号的信号发送。北大与 CMU 的版本虽细节微调(如 CMU 要求更严格的信号屏蔽,北大强调waitpid的非阻塞轮询),但核心目标一致:让你在fork()返回的那一刻,看清父子进程的资源继承关系;在execve()失败时,亲手回收僵尸进程;在SIGCHLD来袭的毫秒级窗口里,不漏掉任何一个子进程的退出状态。这不是脚本题,没有#!/bin/bash可逃;这是用 800 行 C 代码,在 Linux 内核提供的系统调用接口上,搭一座能扛住ps aux | grep tsh | wc -l压测的桥。适合刚啃完《深入理解计算机系统》第 8 章(异常控制流)、手写过fork()示例但没真正 debug 过waitpid(-1, &status, WNOHANG)返回值的同学——如果你的tsh在连续./tsh -c "sleep 1 &; sleep 2 &; jobs"后只显示一个作业,或者fg %2直接卡死,那这篇笔记就是为你写的。
2. 从零启动:用最小可运行骨架打通 fork/exec/wait 闭环,避开“进程一创建就消失”的玄学翻车
2.1 初始化作业表与信号处理:为什么jobs命令永远为空?因为你没建好进程元数据容器
Shell 的核心不是执行命令,而是管理作业(job)生命周期。CMU 和北大版本均要求维护一个全局struct job_t jobs[MAXJOBS]数组,每个元素记录作业 ID、进程组 ID(PGID)、所有子进程 PID、状态(FG/BG/STOPPED)及命令行字符串。关键点在于:作业表必须在main()开头就 memset 清零,且addjob()必须在fork()之后、execve()之前调用——否则fork()创建的子进程若execve()失败(如命令不存在),父进程需立即deletejob(),但此时作业表尚未注册,jobs命令自然查不到。
// tsh.c 全局定义(必须!) #define MAXJOBS 16 struct job_t { pid_t pid; // 作业 leader 进程 PID int jid; // 作业 ID(1~MAXJOBS) int state; // FG, BG, or STOPPED char cmdline[MAXLINE]; // 命令行原始字符串 }; struct job_t jobs[MAXJOBS]; // main() 开头初始化(不可省略) int main(int argc, char **argv) { int i; for (i = 0; i < MAXJOBS; i++) { jobs[i].pid = 0; // 0 表示空闲槽位 jobs[i].jid = 0; jobs[i].state = 0; jobs[i].cmdline[0] = '\0'; } // ... 后续初始化 }提示:
jobs[i].pid = 0是判断槽位空闲的唯一依据,不要用-1或NULL。CMU 版本测试脚本会直接检查jobs[i].pid == 0来判定作业是否被正确清理。
2.2 最小化eval()执行逻辑:三步走清空 fork/exec/wait 链路,拒绝“子进程一闪而过”
eval()是 shell 的心脏,它解析命令、决定是否后台执行、创建进程并等待。北大版允许./tsh -c "cmd"单次执行,CMU 版本强制交互式循环。无论哪种,最简成功路径是:前台命令 →fork()→ 子进程execve()→ 父进程waitpid()。以下是剥离管道/重定向后的最小骨架:
void eval(char *cmdline) { char *argv[MAXARGS]; int bg; // 1=后台, 0=前台 pid_t pid; // 1. 解析命令行(使用 parseline,CMU 已提供) bg = parseline(cmdline, argv); if (argv[0] == NULL) return; // 空行 // 2. 内置命令优先处理(如 quit, jobs, bg, fg) if (builtin_cmd(argv)) return; // 3. 创建子进程 if ((pid = fork()) == 0) { // 子进程 // 设置进程组 leader(关键!否则 Ctrl+C 无法终止整个管道) setpgid(0, 0); // 执行命令 if (execve(argv[0], argv, environ) < 0) { printf("%s: Command not found\n", argv[0]); exit(0); // 子进程必须 exit,不能 return! } } else if (pid < 0) { // fork 失败 unix_error("fork error"); } else { // 父进程 // 添加作业(注意:必须在 fork 后、execve 前!) addjob(jobs, pid, bg, cmdline); if (!bg) { // 前台作业:阻塞等待 int status; if (waitpid(pid, &status, 0) < 0) unix_error("waitfg: waitpid error"); } // 后台作业:立即返回,由 SIGCHLD 处理器回收 } }参数说明:
parseline():CMU 提供的解析函数,自动识别&并设置bg标志,同时将argv数组末尾设为NULL。setpgid(0, 0):将子进程设为新进程组 leader,确保Ctrl+C发送SIGINT到整个进程组而非单个进程。这是fg/bg和信号控制的基石,漏掉则所有作业控制失效。waitpid(pid, &status, 0):第三个参数0表示阻塞等待,WNOHANG仅用于后台作业的轮询回收。
3. 作业控制实战:用sigprocmask和sigaction构建信号安全的作业状态机,绕开waitpid的竞态陷阱
3.1 信号屏蔽与原子操作:为什么jobs显示作业却fg不起来?因为SIGCHLD抢在addjob()完成前触发
作业控制的核心矛盾是:父进程在fork()后需立即addjob()注册作业,但子进程可能瞬间exit()触发SIGCHLD,而此时作业表尚未写入,sigchld_handler()调用deletejob()会因 PID 匹配失败导致作业残留。解决方案是:在fork()前临时屏蔽SIGCHLD,addjob()完成后再恢复。CMU 版本明确要求此步骤,北大版虽未强调,但实测高并发下必现。
void eval(char *cmdline) { // ... 解析命令 ... sigset_t mask, prev_mask; // 1. 屏蔽 SIGCHLD(关键!) sigemptyset(&mask); sigaddset(&mask, SIGCHLD); sigprocmask(SIG_BLOCK, &mask, &prev_mask); // 阻塞 SIGCHLD if ((pid = fork()) == 0) { // 子进程:恢复信号掩码(子进程继承父进程掩码) sigprocmask(SIG_SETMASK, &prev_mask, NULL); setpgid(0, 0); execve(argv[0], argv, environ); exit(0); } else if (pid > 0) { // 父进程:先添加作业,再解除屏蔽 addjob(jobs, pid, bg, cmdline); sigprocmask(SIG_SETMASK, &prev_mask, NULL); // 恢复,允许 SIGCHLD if (!bg) { // 前台:等待时 SIGCHLD 仍会被处理,但作业已注册 waitfg(pid); } } }3.2waitfg()的健壮实现:用WUNTRACED捕获暂停状态,避免fg命令卡死
waitfg()不仅要等进程结束,还要响应Ctrl+Z导致的暂停(SIGTSTP)。若只用waitpid(pid, &status, 0),fg %1时若作业处于STOPPED状态,waitpid()会立即返回(因进程未终止),但tsh误判为执行完毕,导致fg无响应。正确做法是:waitpid()加WUNTRACED标志,捕获WIFSTOPPED(status)事件,并在fg时用kill(-pgid, SIGCONT)恢复。
void waitfg(pid_t pid) { int status; while (1) { // WUNTRACED:捕获暂停状态;WNOHANG:非阻塞(实际此处阻塞) if (waitpid(pid, &status, WUNTRACED) > 0) { if (WIFSTOPPED(status)) { // 进程被暂停,需在 fg 时发送 SIGCONT printf("Job [%d] (%d) stopped\n", pid2jid(pid), (int)pid); return; } else if (WIFEXITED(status) || WIFSIGNALED(status)) { // 正常退出或被信号终止 deletejob(jobs, pid); return; } } else if (errno == EINTR) { continue; // 被其他信号中断,重试 } else { unix_error("waitfg: waitpid error"); } } }注意:
waitpid()的options参数必须包含WUNTRACED,否则WIFSTOPPED()永远为假。CMU 测试用例tshref会故意kill -STOP %1,若你的waitfg()不处理此状态,fg %1将无限等待。
4. 内置命令与作业管理:jobs/bg/fg的底层逻辑,不是字符串匹配而是 PID-PGID 双索引查找
4.1jobs命令:遍历作业表时,为什么jid显示错乱?因为你没按 PGID 排序
jobs命令输出格式为[1]+ Running ./test1 &,其中[1]是作业 ID(jid),+表示当前前台作业。jid 不是数组下标,而是按作业创建顺序递增的编号(1~MAXJOBS),且+和-标记依赖于最近两个作业的 jid。关键逻辑:
jobs遍历jobs[]数组,跳过pid==0的空槽;- 对每个有效作业,用
pid2jid(pid)查找其 jid; - 当前前台作业(即最后执行的前台作业)标记为
+,前一个为-,其余无标记。
void list_jobs(struct job_t *jobs) { int i; for (i = 0; i < MAXJOBS; i++) { if (jobs[i].pid != 0) { char *status; if (jobs[i].state == FG) status = "Foreground"; else if (jobs[i].state == BG) status = "Background"; else status = "Stopped"; printf("[%d] %-12s %d %s\n", jobs[i].jid, status, (int)jobs[i].pid, jobs[i].cmdline); } } }4.2fg/bg命令:%N语法解析与信号发送,kill(-pgid, sig)是唯一正解
fg %1的本质是:找到 jid=1 的作业 → 获取其pid→ 获取其进程组 ID(PGID)→ 向整个进程组发送SIGCONT(恢复)或SIGINT(前台中断)。绝不能对单个 PID 发送信号,否则管道中的多个进程无法同步响应。CMU 版本要求getpgid(pid)获取 PGID,北大版可直接用pid(因作业 leader 即 PGID)。
// builtin_cmd() 中处理 fg/bg if (!strcmp(argv[0], "fg") || !strcmp(argv[0], "bg")) { if (argv[1] == NULL) { printf("%s command requires PID or %%jobid argument\n", argv[0]); return 1; } pid_t pid = 0; int jid = 0; // 解析 %N 或 PID if (argv[1][0] == '%') { // %N 格式 jid = atoi(&argv[1][1]); pid = jid2pid(jobs, jid); } else { // 直接 PID pid = atoi(argv[1]); jid = pid2jid(pid); } if (pid == 0) { printf("%s: No such job\n", argv[1]); return 1; } // 发送信号到进程组(-pid 表示进程组) if (!strcmp(argv[0], "fg")) { kill(-pid, SIGCONT); // 恢复并置于前台 // 设置为前台作业 struct job_t *j = getjobpid(jobs, pid); j->state = FG; waitfg(pid); // 等待其结束或暂停 } else { kill(-pid, SIGCONT); // 恢复后台运行 struct job_t *j = getjobpid(jobs, pid); j->state = BG; } return 1; // 已处理,不执行后续 eval }提示:
kill(-pid, sig)中的负号-是关键,表示向进程组发送信号。若写成kill(pid, sig),仅影响 leader 进程,管道中其他进程不受影响。
5. 避坑指南:CSAPP Shell Lab 五大血泪经验,每一条都来自真实翻车现场
5.1 现象:./tsh -c "ls | wc -l"输出 0,但手动执行ls | wc -l正确
原因:管道实现中,父进程未关闭pipefd[0]和pipefd[1],导致子进程wc的stdin一直等待 EOF(因ls的stdout未关闭,管道未断开)。
解决:在fork()后,每个子进程必须关闭不需要的管道端口。ls子进程关闭pipefd[0](读端),wc子进程关闭pipefd[1](写端),父进程关闭全部端口。
5.2 现象:连续执行sleep 1 &; sleep 2 &; jobs后,jobs只显示一个作业
原因:addjob()中未正确分配 jid,或jobs[i].pid = 0判断逻辑错误,导致新作业覆盖旧槽位。
解决:addjob()必须遍历jobs[]找第一个pid==0的槽位,jid设为该槽位索引+1(jobs[i].jid = i+1),且jobs[i].pid = pid必须在fork()后立即赋值。
5.3 现象:Ctrl+C无法终止后台作业,只能杀掉tsh自身
原因:未调用setpgid(0, 0),子进程与tsh同属一个进程组,Ctrl+C的SIGINT只发给tsh,子进程收不到。
解决:fork()后,子进程中第一行必须是setpgid(0, 0),确保其成为独立进程组 leader。
5.4 现象:fg %1后作业恢复,但jobs仍显示Stopped
原因:fg发送SIGCONT后,未更新作业状态为BG或FG,jobs显示仍为旧状态。
解决:fg/bg处理完信号后,必须显式调用getjobpid()获取作业指针,并设置j->state = FG或BG。
5.5 现象:./tsh退出时,后台作业变成僵尸进程(ps aux | grep Z)
原因:SIGCHLD处理器中waitpid()使用了WNOHANG,但未循环等待所有已退出子进程,导致部分子进程状态未被回收。
解决:sigchld_handler()中用while (waitpid(-1, &status, WNOHANG) > 0)循环回收,直到waitpid()返回0(无子进程可回收)或-1(错误)。
6. 验证与调试:用strace和gdb定位信号与系统调用黑洞,把tsh变成透明黑匣子
6.1 用strace捕获系统调用序列:看清fork/execve/waitpid的真实参数
strace是验证tsh行为的终极武器。运行strace -f -e trace=fork,execve,waitpid,kill,signal ./tsh -c "sleep 1 &",输出类似:
2345 fork() = 2346 2345 waitpid(2346, 0x7ffedf9a2bdc, WNOHANG) = 0 2346 setpgid(0, 0) = 0 2346 execve("/bin/sleep", ["sleep", "1"], [/* 58 vars */]) = 0 2345 --- SIGCHLD {si_signo=SIGCHLD, si_code=CLD_EXITED, si_pid=2346, ...} --- 2345 waitpid(2346, [{WIFEXITED(s) && WEXITSTATUS(s) == 0}], WNOHANG) = 2346关键观察点:
fork()返回值是否匹配pid;execve()的argv[0]是否为绝对路径(若失败,tsh应打印错误);waitpid()是否在SIGCHLD后被调用,且si_pid与作业 PID 一致;kill()的pid参数是否为负数(进程组)。
6.2gdb调试信号处理器:在sigchld_handler断点处,检查作业表实时状态
启动gdb ./tsh,设置断点:
(gdb) b sigchld_handler (gdb) r -c "sleep 1 &" # 触发 SIGCHLD 后 (gdb) p jobs[0] # 查看第一个作业结构体 (gdb) p jobs[0].pid (gdb) p jobs[0].state血泪经验:gdb默认不跟踪子进程,需set follow-fork-mode child才能在子进程中调试execve()。但sigchld_handler()是父进程的,故无需切换。
6.3 对比tshref的黄金标准:用diff逐行比对输出,定位语义差异
CMU 提供tshref(参考 shell),北大版可自行编译。将你的tsh与tshref的输出用script录制对比:
# 录制 tshref 输出 script -c "./tshref < test01.in" ref.out # 录制你的 tsh 输出 script -c "./tsh < test01.in" my.out # 过滤掉时间戳和 shell 提示符,只比对核心输出 grep -v "^Script started" ref.out | grep -v "^Script done" > ref.clean grep -v "^Script started" my.out | grep -v "^Script done" > my.clean diff ref.clean my.clean注意:tshref的jobs输出中,+和-标记严格按作业创建顺序,若你的jid分配逻辑有偏差,diff会立刻暴露。
我当年在 CMU 的 lab 里,花 17 小时卡在SIGCHLD竞态上,最后发现是fork()前忘了sigprocmask——那一行代码加进去,jobs命令突然就活了。现在每次写 shell 相关代码,第一反应不是fork(),而是sigemptyset(&mask); sigaddset(&mask, SIGCHLD);。这已经成了我的肌肉记忆。希望帮到你。
本文还有配套的精品资源,点击获取