外观
异常控制流:进程、信号与 Shell
普通函数调用的下一步由程序指令决定;异常、系统调用、定时器中断和信号会改变这种局部视角。写一个 Shell 的难点,正是用户输入、子进程退出与信号可能交错发生,不能只按源文件从上往下推断执行顺序。
fork、exec、wait 分别改变什么
fork() 创建子进程。父进程得到子 PID,子进程得到 0,两者从调用之后继续运行。它们拥有逻辑上独立的地址空间;常见操作系统通过写时复制降低复制成本。继承的文件描述符则可能引用同一打开文件描述,因此文件偏移等内核状态可以共享。
exec 用新程序替换当前进程映像,成功后不返回,PID 不因此改变。waitpid 读取子进程状态并回收相应资源。子进程已经退出而父进程尚未回收时会留下僵尸状态;它不是仍在执行用户代码的后台程序。
sequenceDiagram participant S as Shell participant C as 子进程 participant K as 内核 S->>K: fork K-->>S: 返回子 PID K-->>C: 返回 0 C->>K: exec 新程序 S->>K: waitpid C->>K: exit K-->>S: 返回退出状态并回收
查看流程图文本
sequenceDiagram participant S as Shell participant C as 子进程 participant K as 内核 S->>K: fork K-->>S: 返回子 PID K-->>C: 返回 0 C->>K: exec 新程序 S->>K: waitpid C->>K: exit K-->>S: 返回退出状态并回收
tiny_shell.c 实现这条最短主线。内建 cd 必须在 Shell 自己的进程中执行,否则子进程改变目录后退出,父进程的工作目录仍不变。外部命令用 execvp 查找 PATH;失败的子进程用 _exit(127) 结束,避免继续执行 Shell 主循环。
sh
make -C labs/csapp tiny_shell
printf 'echo course-shell-ok\ncd /\npwd\nexit\n' | ./labs/csapp/tiny_shell输出应为 course-shell-ok 和 /。解析器仅按空白分词,没有引号、重定向、管道、通配符和后台语法;输入 echo a | cat 不会创建管道。
c
/* Foreground-only teaching shell. Tokenizes whitespace, no quoting/job control. */
#include <errno.h>
#include <signal.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <sys/types.h>
#include <sys/wait.h>
#include <unistd.h>
#define MAX_ARGS 64
int main(void) {
char *line = NULL;
size_t capacity = 0;
struct sigaction ignore = {0}, original;
ignore.sa_handler = SIG_IGN;
sigemptyset(&ignore.sa_mask);
if (sigaction(SIGINT, &ignore, &original) < 0) { perror("sigaction"); return 1; }
while (1) {
if (isatty(STDIN_FILENO)) { fputs("study> ", stdout); fflush(stdout); }
if (getline(&line, &capacity, stdin) < 0) break;
char *arguments[MAX_ARGS], *save;
size_t count = 0;
char *token = strtok_r(line, " \t\r\n", &save);
while (token && count < MAX_ARGS - 1) {
arguments[count++] = token;
token = strtok_r(NULL, " \t\r\n", &save);
}
if (token) { fputs("too many arguments\n", stderr); continue; }
arguments[count] = NULL;
if (!count) continue;
if (strcmp(arguments[0], "exit") == 0) break;
if (strcmp(arguments[0], "cd") == 0) {
if (count != 2) fputs("usage: cd directory\n", stderr);
else if (chdir(arguments[1]) < 0) perror("cd");
continue;
}
pid_t child = fork();
if (child < 0) { perror("fork"); continue; }
if (child == 0) {
/* SIG_IGN survives exec; restore default so Ctrl-C reaches command. */
struct sigaction defaults = {0};
defaults.sa_handler = SIG_DFL;
sigemptyset(&defaults.sa_mask);
if (sigaction(SIGINT, &defaults, NULL) < 0) _exit(126);
execvp(arguments[0], arguments);
perror(arguments[0]);
_exit(127);
}
int status;
pid_t result;
do { result = waitpid(child, &status, 0); } while (result < 0 && errno == EINTR);
if (result < 0) perror("waitpid");
else if (WIFSIGNALED(status)) fprintf(stderr, "terminated by signal %d\n", WTERMSIG(status));
else if (WEXITSTATUS(status)) fprintf(stderr, "exit status %d\n", WEXITSTATUS(status));
}
free(line);
if (sigaction(SIGINT, &original, NULL) < 0) return 1;
return ferror(stdin) ? 1 : 0;
}信号不是可靠计数队列
信号通知“某类事情发生了”,普通信号的多次产生可能合并。收到一次 SIGCHLD,不能推断只退出了一个孩子。完整 Shell 的回收逻辑通常要循环调用非阻塞 waitpid,直到暂时没有更多状态;停止和继续状态还需结合相应选项和宏处理。
异步 handler 能在普通代码执行到一半时运行。因此不要在其中随意调用 printf、malloc 或访问复杂容器;这些操作未必异步信号安全。最小示例只设置 volatile sig_atomic_t 标志,由主流程回收。这里 volatile 用于适合信号通信的标志读写;它不能推广成多线程互斥机制。
两个经典竞态
第一个是“删除早于加入”。父进程 fork 后准备将子进程加入作业表,但孩子先退出,SIGCHLD handler 先回收并尝试删除,随后父进程才 addjob,留下永远不会再被删除的条目。正确顺序是:fork 前屏蔽 SIGCHLD,父进程完成 addjob 后恢复;子进程在 exec 前也恢复屏蔽集合。
第二个是“检查后再睡眠”。假设代码写成 if (!done) pause(),信号可能在条件检查结束后、pause 之前到达。handler 设置 done,返回后主程序仍进入睡眠;如果没有下一次信号,就会一直等待。仅重复检查不够,必须将“临时开放信号并睡眠”作为一个原子等待步骤。
本站 process_demo 使用如下结构:
c
/* SIGCHLD 已被屏蔽,handler 只把 child_event 置 1。 */
waitmask = oldmask;
sigdelset(&waitmask, SIGCHLD);
while (!child_event) sigsuspend(&waitmask);sigsuspend 临时替换屏蔽集合并等待;handler 返回后恢复原来屏蔽集合,循环安全重查谓词。恢复到的集合仍屏蔽 SIGCHLD,所以检查与下一次等待间不会丢掉通知。这是单个已知孩子的最小模型,不是多作业 Shell 的全部实现。
c
/* Block SIGCHLD before fork, atomically unblock with sigsuspend, reap in main. */
#include <assert.h>
#include <errno.h>
#include <signal.h>
#include <stdio.h>
#include <stdlib.h>
#include <sys/types.h>
#include <sys/wait.h>
#include <unistd.h>
static volatile sig_atomic_t child_event = 0;
static void on_child(int signo) { (void)signo; child_event = 1; }
static void fail(const char *what) { perror(what); exit(1); }
int main(void) {
sigset_t blocked, oldmask, waitmask;
sigemptyset(&blocked);
sigaddset(&blocked, SIGCHLD);
if (sigprocmask(SIG_BLOCK, &blocked, &oldmask) < 0) fail("sigprocmask");
struct sigaction action = {0};
action.sa_handler = on_child;
sigemptyset(&action.sa_mask);
action.sa_flags = SA_NOCLDSTOP;
if (sigaction(SIGCHLD, &action, NULL) < 0) fail("sigaction");
pid_t pid = fork();
if (pid < 0) fail("fork");
if (pid == 0) {
if (sigprocmask(SIG_SETMASK, &oldmask, NULL) < 0) _exit(120);
_exit(42);
}
/* A shell would add pid to its job table here, still with SIGCHLD blocked. */
waitmask = oldmask;
sigdelset(&waitmask, SIGCHLD);
while (!child_event) sigsuspend(&waitmask);
int status;
pid_t got;
do { got = waitpid(pid, &status, 0); } while (got < 0 && errno == EINTR);
if (got < 0) fail("waitpid");
if (sigprocmask(SIG_SETMASK, &oldmask, NULL) < 0) fail("restore mask");
assert(WIFEXITED(status) && WEXITSTATUS(status) == 42);
puts("process: child exit=42, reaped without lost wakeup");
return 0;
}从前台 Shell 走向作业控制
完整作业表至少需要 PID/PGID、JID、前台/后台/停止状态、命令字符串。每个作业应有明确进程组,向整个组传递 SIGINT、SIGTSTP、SIGCONT 才能处理作业产生的后代;对 PID 的操作和对 PGID 的操作必须区分。
官方 Shell Lab 使用自己的前台作业转发模型与 driver。阅读 eval、内建命令、do_bgfg、waitfg 和各信号处理器时,以统一状态机为中心:退出删除;停止保留但改状态;继续恢复运行;fg 还需等待到该作业不再前台。其接口与测试见官方 Shell Lab 说明。生产 Shell 的终端控制更复杂,不能把本仓库前台示例称为完整 job control。
tiny_shell 让父进程忽略 SIGINT、孩子恢复默认动作,共享前台进程组,因此简单 Ctrl-C 可中断命令。它没有 SIGTSTP 作业控制,不应拿它运行交互式编辑器或验证完整 Shell Lab trace。
验证与排错
运行 process_demo 应输出 child exit=42, reaped without lost wakeup。对完整实验,用短命令测试孩子立即退出,再测试多个后台退出、停止后继续、无前台作业时的信号。不要用固定 sleep(1) 修补竞态:延迟只改变概率,不能建立正确顺序。
失败时画时间线:父进程何时 block、fork、addjob、unblock;子进程何时 restore、exec、exit;handler 何时回收。若退出值读取错误,先检查 WIFEXITED 再使用 WEXITSTATUS,不要把原始 status 当退出码。
自测:为什么 exec 成功之后无需再写正常返回路径?
成功的 exec 已用新映像替换当前执行程序,不会回到原调用点。返回只说明失败,必须处理错误并让子进程结束,否则它可能误入父 Shell 的控制流。
自测:一次 SIGCHLD 对应一次 waitpid 是否总是够?
不够。普通信号会合并,多个孩子可能已有状态变化。要根据程序设计循环收集可用状态,并处理 EINTR、ECHILD 等结果。本站单孩子程序简化了这个问题。
下一章:虚拟内存。