本 Lab 需要实现一个简易的 shell,主要考察对进程和信号的理解,以及对与其相关的 POSIX API 的使用,对应知识点为书中的第 8 章内容。

思路

实验要求

实现一个简单的 shell,要求支持如下特性:

  1. 输入 ctrl-c 触发 SIGINT 信号,输入 ctrl-z 触发 SIGTSTP 信号,发送给给前台运行的任务和依赖于这些任务的子任务(子进程)。
  2. 如果命令行以 & 结尾,那么本次作业将被置于后台运行,否则置于前台运行。
  3. 每个作业可以通过 PID(process id)或 JID(job id)来指定,其中 JID 需要加上前缀 %
  4. 支持下列内建命令:
    1. quit:终止 shell 的运行。
    2. jobs:列出所有的后台作业。
    3. bg <job>:重启 <job>(PID 或者 JID),通过发出 SIGCONT 信号,然后将其运行在后台。
    4. fg <job>:重启 <job>(PID 或者 JID),通过发出 SIGCONT 信号,然后将其运行在前台。
  5. 回收所有的僵尸进程。

命令行解释执行

eval 函数的作用是解析并执行 shell 输入的命令行。对于内建命令(builtin command)而言,应该立即在 shell 中进行处理;而对于非内建命令而言,应该使用 fork + execve 的组合,创建一个指定的进程来进行处理。

tsh 不需要支持管道功能,因此一个作业只对应一个进程。

fork 的功能是创建一个子进程,该子进程的虚拟地址空间完全拷贝自其父进程,且程序计数器的位置同样位于该 fork 函数处。区别在于,父进程的返回值为子进程的 pid,子进程的 pid 为 0,因此可以根据这一特性来对父进程和子进程进行分别处理:

  • 子进程需要先使用 setpgid(0, 0) 将进程组 id 设置为自己的 pid,将自己与 tsh “脱离”,确保此时前台进程组只有 tsh 本身,防止被 tsh 接收到的信号所影响。然后再使用 execve,用指定的可执行程序替换拷贝自父进程的虚拟地址空间,并开始从头执行。
  • 父进程则需要根据本次输入的命令行,创建其对应的作业数据结构 struct job_t,并加入作业队列 jobs 中。同时,由于前台作业(命令行末尾不带 & 符号)需要一直占用终端,因此当本次作业为前台作业时,需要调用 waitfg 进行等待,直到作业的类型不再是前台运行(FG)。
if ((pid = fork()) == 0) {
    setpgid(0, 0);  // 将进程组设置为自己的 pid
    if (execve(argv[0], argv, environ) < 0) {
        printf("execve %s error\n", argv[0]);
        exit(1);
    }
} else {
    if (bg) {
        addjob(jobs, pid, BG, cmdline);
        printf("[%d] (%d) %s", getjobpid(jobs, pid)->jid, pid, cmdline);
    } else {
        addjob(jobs, pid, FG, cmdline);
        waitfg(pid);
    }
}

根据书中 8.5.6 节的描述,可能会出现一种情况:父进程 fork 子进程后,子进程一直被调度执行,直到运行结束称为一个僵尸进程(zombie)并向父进程发送 SIGCHLD 信号。等到父进程被调度时,他先响应 SIGCHLD 信号并将成为僵尸进程的子进程收割(reap),同时尝试将作业从作业队列中移除,此时事实上不会做任何事情,因为作业还没有加入到作业队列中。再接下来父进程继续执行 fork 后的代码,才将作业加入到作业队列中,这明显不对!

针对这类恼人的同步问题,可以采用 sigprocmask (如下所示是 sigprocmask 的使用说明)在适当的位置设置信号屏蔽字来解决。对于本问题,可以在父进程中先屏蔽对 SIGCHLD 信号的处理,等到使用 addjob 将作业加入作业队列后才开始响应 SIGCHLD 信号,这样即便是子进程先执行完毕并向父进程发送 SIGCHLD 信号,父进程也能确保在 addjob 执行完后才处理这一信号。

  • generated by GPT4o

sigprocmask 介绍

sigprocmask 是一个用于操作信号屏蔽字(signal mask)的系统调用。它允许程序暂时屏蔽或取消屏蔽某些信号的接收,进而控制信号的处理行为。通过它,程序可以避免在特定代码段中处理中断信号,从而进行更细粒度的控制。

sigprocmask 的原型:

#include <signal.h>

int sigprocmask(int how, const sigset_t *set, sigset_t *oldset);
  • how
    • SIG_BLOCK:将 set 中的信号加入到当前信号屏蔽字中(即阻塞这些信号)。
    • SIG_UNBLOCK:从当前信号屏蔽字中移除 set 中的信号(即解除屏蔽这些信号)。
    • SIG_SETMASK:将当前信号屏蔽字设置为 set 中的信号集(即完全替换当前的信号屏蔽字)。
  • set:指向包含信号集的 sigset_t 类型变量,该变量指定要阻塞或解除阻塞的信号。
  • oldset:指向 sigset_t 类型的变量,用来保存当前的信号屏蔽字,以便稍后恢复。

信号集 sigset_t 的操作:

  • 初始化信号集:
    • sigemptyset(sigset_t *set):清空信号集。
    • sigfillset(sigset_t *set):将所有信号加入信号集。
    • sigaddset(sigset_t *set, int signum):将特定信号加入信号集。
    • sigdelset(sigset_t *set, int signum):从信号集中移除特定信号。

此外,为了防止其他可能的并发错误出现,可以在对全局变量的修改前先屏蔽所有信号。

sigset_t mask_all, mask_one, prev_one;

sigfillset(&mask_all);
sigemptyset(&mask_one);
sigaddset(&mask_one, SIGCHLD);

sigprocmask(SIG_BLOCK, &mask_one, &prev_one);
if ((pid = fork()) == 0) {
    sigprocmask(SIG_SETMASK, &prev_one, NULL);
    setpgid(0, 0);  // 将进程组设置为自己的 pid
    if (execve(argv[0], argv, environ) < 0) {
        printf("execve %s error\n", argv[0]);
        exit(1);
    }
} else {
    sigprocmask(SIG_BLOCK, &mask_all, NULL);
    if (bg) {
        addjob(jobs, pid, BG, cmdline);
        sigprocmask(SIG_SETMASK, &prev_one, NULL);
        printf("[%d] (%d) %s", getjobpid(jobs, pid)->jid, pid, cmdline);
    } else {
        addjob(jobs, pid, FG, cmdline);
        sigprocmask(SIG_SETMASK, &prev_one, NULL);
        waitfg(pid);
    }
}

bg/fg: 已停止作业恢复执行

在内建命令中,quit 直接让程序正常退出即可,jobs 只需调用已实现的函数 listjobs,真正需要实现的是 bgfg,它们的作用是将处于停止状态的作业恢复到后台(前台)执行。

实现方式无非分两步:

  1. 参数解析,提取出作业 ID(分为 PID 和 JID)。
  2. 根据 bgfg 的不同,分别进行处理,将作业运行在前台或是后台。

参数解析其实也就是字符串处理,这部分与本章的异常控制流关系不大,在此不过多赘述。

根据参数得到对应的作业 job 后,首先需要它的运行状态更改为指定的状态(BGFG),然后向 job 对应的进程组(kill 的 pid 参数为 -job->pid)发送 SIGCONT 信号,将内核在响应此信号后会重新调度该进程组内的所有进程执行。

sigprocmask(SIG_BLOCK, &mask_all, &prev_one);
job->state = state;
sigprocmask(SIG_SETMASK, &prev_one, NULL);
kill(-job->pid, SIGCONT);

最后,对于前台运行而言,父进程需要调用 waitfg 进行等待;对于后台运行而言,打印作业信息(具体格式见 tshref.out 的内容)。

if (state == FG) {
    waitfg(job->pid);
} else {
    printf("[%d] (%d)", job->jid, job->pid);
    for (int i = 0; argv[i] != NULL; ++i) {
        printf(" %s", argv[i]);
    }
    printf("\n");
}

信号处理函数

这部分看似简单,实则很容易出错,值得仔细说说。

最开始,我在 sigint_handlersigtstp_handler 中进行了对作业队列的操作,在 sigint_handler 中使用 deletejob 删除作业,在 sigtstp_handler 中将作业状态修改为 ST

这在前面的测试用例中工作一切正常,但在最后的 trace16 中,程序却阻塞在了 mystop.cif (kill(-pid, SIGTSTP) < 0) 位置,而如果按下键盘的 ctrl-c 或 ctrl-z 却能够响应信号,程序也正常终止或暂停。

通过查阅网上资料,在 实验四:Shell-Lab(下) - 知乎 找到了问题的原因。

程序 mystop 使用 kill 发送信号时,信号是直接发送到内核进行处理的,而不会经过 shell,shell 也就不会调用 sigtstp_handler 将作业状态更改为 ST,shell 因此认为 mystop 程序一直处于前台运行状态,也就保持阻塞。

既然 sigint_handlersigtstp_handler 在这种情况下无法被调用,那么就需要在其他信号处理函数中进行处理,答案便是 sigchld_handler。事实上,子进程并非只有在终止时才向父进程发送 SIGCHLD 信号,而是在状态发生改变后就会。我们可以利用这一点,将所有对作业队列的操作都放到 sigchld_handler 中,而 sigint_handlersigtstp_handler 只作一个转发作用。

void sigchld_handler(int sig) 
{
    ...
    while ((pid = waitpid(-1, &status, WNOHANG | WUNTRACED)) > 0) {
        if (WIFEXITED(status)) {             // 子进程正常退出
            ...
        } else if (WIFSTOPPED(status)) {     // 子进程暂停
            ...
        } else if (WIFSIGNALED(status)) {    // 子进程终止
			...
        }
    }
}

代码

eval

void eval(char *cmdline) 
{
    int bg;
    pid_t pid;
    char *argv[MAXARGS];
    sigset_t mask_all, mask_one, prev_one;  // 用于父-子进程同步
    
    sigfillset(&mask_all);
    sigemptyset(&mask_one);
    sigaddset(&mask_one, SIGCHLD);

    bg = parseline(cmdline, argv);

    if (builtin_cmd(argv)) return;
    if (access(argv[0], X_OK) != 0) {
        printf("%s: Command not found\n", argv[0]);
        return;
    }
    sigprocmask(SIG_BLOCK, &mask_one, &prev_one);
    if ((pid = fork()) == 0) {
        sigprocmask(SIG_SETMASK, &prev_one, NULL);
        setpgid(0, 0);  // 将进程组设置为自己的 pid
        if (execve(argv[0], argv, environ) < 0) {
            printf("execve %s error\n", argv[0]);
            exit(1);
        }
    } else {
        sigprocmask(SIG_BLOCK, &mask_all, NULL);
        if (bg) {
            addjob(jobs, pid, BG, cmdline);
            sigprocmask(SIG_SETMASK, &prev_one, NULL);
            printf("[%d] (%d) %s", getjobpid(jobs, pid)->jid, pid, cmdline);
        } else {
            addjob(jobs, pid, FG, cmdline);
            sigprocmask(SIG_SETMASK, &prev_one, NULL);
            waitfg(pid);
        }
    }
}

built_cmd

int builtin_cmd(char **argv) 
{
    if (!argv[0]) {
        return 1;
    } else if (strcmp(argv[0], "quit") == 0) {
        exit(0);
    } else if (strcmp(argv[0], "jobs") == 0) {
        listjobs(jobs);
    } else if (strcmp(argv[0], "bg") == 0) {
        do_bgfg(argv, BG);
    } else if (strcmp(argv[0], "fg") == 0) {
        do_bgfg(argv, FG);
    } else {
        return 0;
    }
    return 1;
}

do_bgfg

// 根据字符串 arg(格式为 %jid 或 pid)获取对应的 job
// 成功返回 job 指针,失败返回 NULL
struct job_t *getjob_arg(const char *arg) {
    int isjid, id;
    const char *parg;
    struct job_t *job;

    isjid = 0;
    if (arg[0] == '%') {
        isjid = 1;
        ++arg;
    }
    parg = arg;
    while (*parg != '\0') {
        if (!isdigit(*parg)) {
            printf("bg/fg: argument must be a PID or %%jobid\n");
            return NULL;
        }
        ++parg;
    }
    id = atoi(arg);
    if (isjid) {
        job = getjobjid(jobs, id);
        if (job == NULL) {
            printf("(%s): No such process\n", arg);
        }
    } else {
        job = getjobpid(jobs, id);
        if (job == NULL) {
            printf("%s: No such job\n", arg);
        }
    }
    return job;
}

// 与题目提供的接口有些不同(加了个 state 参数),感觉这样更好处理一些...
void do_bgfg(char **argv, int state) 
{
    sigset_t mask_all, prev_one;
    struct job_t *job;

    sigfillset(&mask_all);

    if (argv[1] == NULL) {
        printf("fg command requires PID or %%jobid argument\n");
        return;
    }

    job = getjob_arg(argv[1]);
    if (job == NULL) {
        return;
    }

    sigprocmask(SIG_BLOCK, &mask_all, &prev_one);
    job->state = state;
    sigprocmask(SIG_SETMASK, &prev_one, NULL);
    kill(-job->pid, SIGCONT);
    if (state == FG) {
        waitfg(job->pid);
    } else {
        printf("[%d] (%d)", job->jid, job->pid);
        for (int i = 0; argv[i] != NULL; ++i) {
            printf(" %s", argv[i]);
        }
        printf("\n");
    }
}

waitfg

void waitfg(pid_t pid)
{
    while (1) {
        struct job_t *job = getjobpid(jobs, pid);
        if (job == NULL || job->state != FG) break;
    }
}

sigchld_handler

void sigchld_handler(int sig) 
{
    pid_t pid;
    int status;
    sigset_t mask_all, prev_one;
    
    sigfillset(&mask_all);

    while ((pid = waitpid(-1, &status, WNOHANG | WUNTRACED)) > 0) {
        sigprocmask(SIG_BLOCK, &mask_all, &prev_one);
        if (WIFEXITED(status)) {
            deletejob(jobs, pid);
        } else if (WIFSTOPPED(status)) {
            printf("Job [%d] (%d) stopped by signal 20\n", pid2jid(pid), pid);
            getjobpid(jobs, pid) -> state = ST;
        } else if (WIFSIGNALED(status)) {
            printf("Job [%d] (%d) terminated by signal 2\n", pid2jid(pid), pid);
            deletejob(jobs, pid);
        }
        sigprocmask(SIG_SETMASK, &prev_one, NULL);
    }
}

sigint_handler

void sigint_handler(int sig) 
{
    pid_t pid = fgpid(jobs);
    kill(-pid, sig);
}

sigtstp_handler

void sigtstp_handler(int sig) 
{
    pid_t pid = fgpid(jobs);
    kill(-pid, sig);
}