csapp 的 Shell Lab 要求完成一 个简单的 unix shell(tsh, tiny shell), 实现外壳程序的几个基本功能:

  • 执行命令, 并为命令创建任务(job);
  • 查看或修改当前任务的状态;
  • 回收已完成的任务.
  • 接收并处理信号, 如 ctrl-c, ctrl-z 等.

tsh 不用支持 shell 的高级特性如环境变量, 重定向和管道等.

作业发放的材料里已经给 tsh 搭好了框架, 只需要填写几个关键函数就好了.

执行命令 eval

eval 函数用来执行输入给 tsh 的命令. 如果是内置命令, 就直接执行; 如果不 是就创建一个子进程, 利用 execve 加载.

需要注意的是, 在为子进程建立 shell 任务的时候, 需要先阻塞 SIGCHLD 信号, 否则如果子进程在 shell 任务创建前就结束的话, 这个任务就永远不会结束了.

sigset_t mask;
sigemptyset(&mask);
sigaddset(&mask, SIGCHLD);
sigprocmask(SIG_BLOCK, &mask, NULL);

然后父进程在添加任务后再恢复接收 SIGCHLD 信号.

if (addjob(jobs, pid, status, cmdline) != 1) {
  unix_error("addjob failed");
 }
// unblock sigchld after addjob
sigprocmask(SIG_UNBLOCK, &mask, NULL);

默认情况下, tsh 创建的子进程会属于 tsh 进程组. 子进程需要创建一个进程 组, 避免收到系统发送给 tsh 进程组的信号, 从而由 tsh 接管所有的子进程.

if ((pid = fork()) == 0) {
  // unblock sigchld
  sigprocmask(SIG_UNBLOCK, &mask, NULL);
  // reset process group id
  if (setpgid(0, 0) != 0) {
    unix_error("setpgid failed");
  }
  if (execve(argv[0], argv, environ) < 0) {
    printf("%s: command not found", argv[0]);
    exit(0);
  }

如果是前台命令的话, 就阻塞用户输入直到收到信号(ctrl-c, ctrl-z 或者子进程结束的 SIGCHLD), 可以利用 sigsuspend 来实现原子地信号唤醒. 同样需要先阻塞 SIGCHLD 信号, 避免子进程在进入等待前就结束了.

void waitfg(pid_t pid) {
  // block sigchld
  sigset_t mask, prev;
  sigemptyset(&mask);
  sigaddset(&mask, SIGCHLD);
  sigprocmask(SIG_BLOCK, &mask, &prev);

  while (pid == fgpid(jobs)) {
    sigsuspend(&prev);
  }

  // unblock sigchld
  sigprocmask(SIG_UNBLOCK, &mask, NULL);
}

信号处理

SIGINT 和 SIGTSTP 的处理都很直接, 直接把信号转发给相应的子进程就好.

void sigint_handler(int sig) {
  pid_t pid = fgpid(jobs);
  if (pid != 0 && (kill(-pid, SIGINT) != 0)) {
      unix_error("sending sigint to fg failed");
  }
}

void sigtstp_handler(int sig) {
  pid_t pid = fgpid(jobs);
  if (pid != 0 && (kill(-pid, SIGTSTP) != 0)) {
      unix_error("sending sigtstp to fg failed");
  }
}

注意这两个信号处理不需要直接管理 shell 任务, 因为子进程被终止(SIGINT)或暂停(SIGTSTP)后会向父进程发送 SIGCHLD 信号, 所以统一在 SIGCHLD 信号处理程序中处理就好了.

void sigchld_handler(int sig) {
  pid_t pid;
  int status;
  while ((pid = waitpid(-1, &status, WNOHANG|WUNTRACED)) > 0) {
    struct job_t *job = getjobpid(jobs, pid);
    if (WIFSTOPPED(status)) {
      // child process is stopped
      printf("[%d] (%d) stopped\n", job->jid, job->pid);
      job->state = ST;
    } else {
      if (WIFEXITED(status)) {
        // child process exited normally
      } else if (WIFSIGNALED(status)) {
        // child process terminited by signals
        printf("[%d] (%d) terminated by signal %d\n", job->jid, job->pid, WTERMSIG(status));
      } else {
        printf("[%d] (%d) terminated unknown\n", job->jid, job->pid);
      }

      // deletejob
      if (deletejob(jobs, pid) != 1) {
        unix_error("sigchld_handler: deletejob failed");
      }
    }
  }
}

任务管理

有了信号处理和 waitfg 之后, 改变任务状态就可以控制子进程的状态了.

bg 命令:

switch (job->state) {
case BG:
  printf("job %d is already in background\n", job->jid);
  break;
case ST:
  job->state = BG;
  if (kill(-(job->pid), SIGCONT) != 0) {
    unix_error("sending SIGCONT to bg job failed");
  }
  break;
case FG:
  job->state = BG;
  break;
}

fg 命令:

switch (job->state) {
case FG:
  printf("job %d is already in foreground\n", job->jid);
  break;
case ST:
  if (kill(-(job->pid), SIGCONT) != 0) {
    unix_error("sending SIGCONT to bg job failed");
  }
case BG:
  printf("[%d] (%d) %s\n", job->jid, job->pid, job->cmdline);
  job->state = FG;
  waitfg(job->pid);
  break;
}

小结

Shell Lab 主要考察了异常控制的进程控制和信号处理. 因为信号可能会在任意 时刻到来, 所以需要考虑信号处理逻辑的先后顺序. 必要时可以阻塞信号. sigsuspend 可以用来安全地挂起进程, 直到接收到相应信号.