Skip to content

异常控制流:进程、信号与 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 等结果。本站单孩子程序简化了这个问题。

下一章:虚拟内存。