这次实验要求实现一个简单的shell,它具有以下功能:

  • 包含以下built-in函数(在shell进程中执行并等待返回):
    • The quit command terminates the shell.
    • The jobs command lists all background jobs.
    • The bg <job> command restarts <job> by sending it a SIGCONT signal, and then runs it in the background. The <job> argument can be either a PID or a JID.
    • The fg <job> command restarts <job> by sending it a SIGCONT signal, and then runs it in the foreground. The <job> argument can be either a PID or a JID.
  • fork一个子进程(称为job)去加载执行一个可执行文件。
  • 输入ctrl-c (ctrl-z)对前台进程发送SIGINT (SIGTSTP)信号。
  • 支持&参数,让一个job在后台运行。
  • shell进程要收割所有僵尸子进程。

在开始完成这次实验之前,了解Linux进程和信号的原理是很有必要的。这次实验中使用了以下几个函数:waitpid, kill, fork, execve, setpgid, sigprocmask

值得一提的是,这个lab的测试脚本不如之前几次的好。由于竞争条件有一定概率对输出结果不造成影响,只靠简单的一两次测试是无法说明程序的安全性的。网上很多答案其实是有问题的,要做对还是得多debug。

首先,使用的系统调用函数都应该像书上Fork那样,加一层包装,在检查到异常返回值时报错,退出程序:

pid_t Fork(void) {
    pid_t pid;
    if ((pid = fork()) < 0)
        unix_error("Fork error");
    return pid;
}

int Sigprocmask(int how, const sigset_t *set, sigset_t *oldset) {
    int rtn;
    if ((rtn = sigprocmask(how, set, oldset)) == -1)
        unix_error("Sigprocmask error");
    return rtn;
}

不过,由于这是一次作业,不是很有必要,我就直接用裸露的系统调用函数了。所有的包装,都可以在CSAPP附带的源码文件csapp.c中找到。

要实现的函数共有7个,首先是evaleval处理输入命令,判断它是否为builtin函数,如果是,直接执行直接返回,否则fork一个子进程在前台或者后台执行之。在fork之前,要把SIGCHLD信号阻塞掉,以防竞争条件;在子进程调用execve之前,要解除SIGCHLD的阻塞,不然父进程的阻塞集合会被子进程继承。在将子进程PID添加至作业列表前,阻塞所有信号,因为信号处理程序里也会操作作业列表,应该阻塞信号以保护该共享数据结构。最后,根据子进程是在前台执行还是后台执行,主进程选择调用waitfg等待子进程。

void eval(char *cmdline) {
    char *argv[MAXARGS]; /* Argument list execve() */
    char buf[MAXLINE];   /* Holds modified command line */
    int bg;              /* Should the job run in bg or fg? */
    pid_t pid;           /* Process id */

    strcpy(buf, cmdline);
    bg = parseline(buf, argv);
    if (argv[0] == NULL)
        return; /* Ignore empty lines */

    if (!builtin_cmd(argv)) {
        // initialize sigset_ts
        sigset_t mask_chid, mask_all, prev_one;
        sigfillset(&mask_all);
        sigemptyset(&mask_chid);
        sigaddset(&mask_chid, SIGCHLD);
        // block SIGCHID before fork
        sigprocmask(SIG_BLOCK, &mask_chid, &prev_one);
        if ((pid = Fork()) == 0) { /* Child runs user job */
            // unblock SIGCHID before execve
            sigprocmask(SIG_SETMASK, &prev_one, NULL);
            // put the child in a new process group
            setpgid(0, 0);
            if (execve(argv[0], argv, environ) < 0) {
                printf("%s: Command not found\n", argv[0]);
                exit(0);
            }
        }
        // add the child to job list
        sigprocmask(SIG_BLOCK, &mask_all, NULL);
        addjob(jobs, pid, bg ? BG : FG, cmdline);
        sigprocmask(SIG_SETMASK, &prev_one, NULL);

        if (!bg) { // wait for foreground process
            waitfg(pid);
        } else {
            printf("[%d] (%d) %s", pid2jid(pid), pid, cmdline);
        }
    }
    return;
}

如果输入的命令是一个builtin函数,那么便由主进程执行之。函数do_bgfg负责唤醒被停止的子进程,并将其放置在前台或后台执行。在测试样例中,有对do_bgfg的不合法输入进行测试,所以不能预设do_bgfg的输入都是合法的。在接收到参数后,首先要判断接收到的是一个jid还是一个pid,获取对应子进程的pid后,发送SIGCONT唤醒之,并在作业列表中修改其状态。如果是在前台进行,就调用waitfg等待其完成。

int builtin_cmd(char **argv) {
    if (!strcmp(argv[0], "quit")) /* quit command */
        exit(0);
    else if (!strcmp(argv[0], "jobs")) { // list all background jobs
        listBGjobs(jobs);
    } else if (!strcmp(argv[0], "bg") || !strcmp(argv[0], "fg")) {
        // restart a job and run it in background/foreground
        do_bgfg(argv);
    } else if (!strcmp(argv[0], "&")) { /* Ignore singleton & */ }
    else {  // not builtin command
        return 0;
    }
    return 1;
}

void do_bgfg(char **argv) {
    struct job_t *job;
    // invalid input
    if (argv[1] == NULL) {
        printf("%s command requires PID or %%jobid argument\n", argv[0]);
        return;
    }
    if (!isdigit(argv[1][0]) && argv[1][0] != '%') {
        printf("%s: argument must be a PID or %%jobid\n", argv[0]);
        return;
    }
    if (argv[1][0] == '%') {    // receive a jid
        job = getjobjid(jobs, atoi(&argv[1][1]));
        if (job == NULL) {
            printf("%s: No such job\n", argv[1]);
            return;
        }
    } else {    // receive a pid
        job = getjobpid(jobs, atoi(argv[1]));
        if (job == NULL) {
            printf("(%s): No such process\n", argv[1]);
            return;
        }
    }
    // restart by sending SIGCONT
    kill(-job->pid, SIGCONT);
    if (!strcmp(argv[0], "fg")) { // wait for foreground process
        job->state = FG;
        waitfg(job->pid);
    } else {
        job->state = BG;
        printf("[%d] (%d) %s", job->jid, job->pid, job->cmdline);
    }
}

waitfg不可以使用waitpid,因为如果前台进程被暂停或者改成后台了,waitpid等待的pid就失效了。所以要忙等待,不时就要重新获取前台子进程的pid,在前台pid不再是参数pid时说明前台进程暂停/终止/变后台了,主进程也不再等待它。

void waitfg(pid_t pid) {
    while (pid == fgpid(jobs)) {
        sleep(1);
    }
}

接下来是三种信号的signal handler。首先要注意的一个问题是,printf并不是异步安全的,在信号处理程序中不能用(因为偷懒我就用了),应该用书上的sio_puts

在接收到一个SIGCHID信号后,处理程序使用waitpid收割子进程。waitpid带有参数WNOHANGWUNTRACEDWNOHANG是为了支持while判断,在处理程序返回前尽可能地收割子进程,以免因阻塞SIGCHLD信号导致一些子进程发送的SIGCHLD信号被抛弃。WUNTRACED使得该处理程序也能捕获因信号而停止的子进程。 在捕获到子进程后,应该判断子进程发送SIGCHLD信号的原因。如果是正常结束,只需要将其从作业列表中删除就可以;如果是被SIGINT信号中断,那么输出提示信息后将其从作业列表中删除;如果是被SIGTSTP信号停止,那么将其在工作列表中的状态改变。最后,判断waitpid是否返回错误;如果有,输出error提示的错误信息。由于waitpid带有参数WNOHANG,所以不可再像书上那样判断信号处理过程是否有异常(即使用errno != ECHILD做判断条件);带有参数WNOHANG下,因调用进程没有孩子而返回时只是把pid设为0,而不会改变errno。理论上,在进入信号处理程序之前要保存原来的errno,在退出后要恢复原来的errno,使得信号处理过程中不影响errno。我在SIGCHID的信号处理程序中采用了这种写法,另外两个就没写了。

在键盘上输入ctrl+c,会向前台进程组发送一个SIGINT信号。事实上的前台进程组其实只包含主进程,它fork出来的子进程由于setpgid(0, 0,已经不再和主进程在同个进程组里。在接收到SIGINT信号以后,主进程应该向逻辑上的“前台进程组”发送SIGINT信号;收到信号的子进程终止,像主进程发送SIGCHLD信号。它们在作业列表中的操作交由SIGCHLD的信号处理程序处理。SIGTSTP信号同理,在主进程接收到信号以后暂停前台进程组。暂停以后,前台进程组在工作列表中的变化也交由SIGCHLD信号处理程序处理。

void sigchld_handler(int sig) {
    int olderrno = errno;
    pid_t pid;
    int status;
    sigset_t mask_all, prev_all;
    sigfillset(&mask_all);
    while ((pid = waitpid(-1, &status, WNOHANG|WUNTRACED)) > 0) {
        if (WIFEXITED(status)) { // exit normally
            sigprocmask(SIG_BLOCK, &mask_all, &prev_all);
            deletejob(jobs, pid);
            sigprocmask(SIG_SETMASK, &prev_all, NULL);
        } else if (WIFSIGNALED(status)) { // killed by signal
            // should be sio_puts!
            printf("Job [%d] (%d) terminated by signal %d\n", pid2jid(pid), pid, WTERMSIG(status));
            sigprocmask(SIG_BLOCK, &mask_all, &prev_all);
            deletejob(jobs, pid);
            sigprocmask(SIG_SETMASK, &prev_all, NULL);
        } else if (WIFSTOPPED(status)) { // stopped by signal
            printf("Job [%d] (%d) stopped by signal %d\n", pid2jid(pid), pid, WSTOPSIG(status));
            struct job_t *job = getjobpid(jobs, pid);
            if (job != NULL) {
                job->state = ST;
            }
        }
    }
    // check errno on error
    if (pid == -1 && errno != ECHILD)
        unix_error("waitpid error");
    errno = olderrno;
}

void sigint_handler(int sig)  {
    pid_t pid = fgpid(jobs);
    if (pid != 0) { // kill the whole group
        kill(-pid, sig);
    }
}

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

即使是这样一个短小的应用程序,为了满足信号安全条件,需要做的保护措施也是很多的。我没有严格地遵从,因为测试样例很简单,并没有严格地要求安全性。不过我在文中把我认为应该注意的地方都说了一下。总得来说,这次实验大概可以算是CSAPP实验系列中最简单之一,不过它的坑很多。