[CSAPP]shlab 进程控制与信号

简介

对应章节：第八章
实验内容：写一个简单的支持任务控制的 Unix shell
实验目的：熟悉进程控制与信号处理
实验讲义：http://csapp.cs.cmu.edu/3e/shlab.pdf

Unix Shells 是什么

维基百科中对 Unix shell 的解释：
A Unix shell is a command-line interpreter or shell that provides a command line user interface for Unix-like operating systems.

按照实验讲义中的描述，Unix shell 具有以下特性：

shell 是一个交互式的命令行解释器，代表用户运行程序。

shell 会反复打印提示符，等待用户输入命令行，然后执行相应的操作。

命令行是由空白字符分隔的 ASCII 文本单词序列。第一个单词是内置命令名或可执行文件的路径名，其余单词是命令行参数。

如果第一个单词是内置命令，shell 会立即执行该命令。否则，shell 会创建一个子进程，并在子进程中加载和运行程序。这些子进程统称为一个 job。

如果命令行以 & 结尾，则 job 在后台运行，shell 不会等待它结束就打印下一个提示符。否则，job 在前台运行，shell 会等待它结束。

Unix shell 支持作业控制，允许用户在前后台之间移动 job，以及改变 job 中进程的状态 (运行、停止或终止)。

按 Ctrl-C 会给前台 job 发送 SIGINT 信号，默认操作是终止进程。按 Ctrl-Z 会给前台 job 发送 SIGTSTP 信号，默认操作是将进程置于停止状态。

Unix shell 提供了一些内置命令来支持作业控制，如 jobs、bg、fg、kill 等。

我们的 tsh 是什么

本次实验要实现一个叫 tsh 的简单的 shell，它具有以下特性：

提示符应该是字符串 "tsh>"。

用户输入的命令行由一个名称和零个或多个参数组成，所有元素用一个或多个空格分隔。如果名称是内置命令，tsh 应该立即处理它并等待下一个命令。否则，tsh 应该假定名称是可执行文件的路径，并在初始子进程的上下文中加载和运行它。在这种情况下，"job" 指代这个初始子进程。

tsh 不需要支持管道 (|) 或 I/O 重定向 (< 和 >)。

按 Ctrl-C (Ctrl-Z) 应该向当前前台 job 及其所有子进程发送 SIGINT (SIGTSTP) 信号。如果没有前台 job，信号应该无效。

如果命令行以 & 结尾，tsh 应该在后台运行 job。否则，它应该在前台运行 job。

每个 job 可以用进程 ID (PID) 或 job ID (JID) 标识，JID 是 tsh 分配的正整数，在命令行上用前缀 % 表示，如 "%5"。

tsh 应该支持以下内置命令:

quit: 终止 shell。

jobs: 列出所有后台 job。

bg <job>: 通过发送 SIGCONT 信号重启 <job>，并在后台运行它。<job> 可以是 PID 或 JID。

fg <job>: 通过发送 SIGCONT 信号重启 <job>，并在前台运行它。<job> 可以是 PID 或 JID。

tsh 应该回收所有僵尸子进程。如果任何 job 因为收到它没有捕获的信号而终止，tsh 应该识别这个事件并打印一条消息，包含 job 的 PID 和造成这个问题的信号描述。
具体来说我们需要在实验代码框架的基础上实现以下函数：

eval：解析命令行的主要程序。

builtin_cmd：识别并运行内置命令 (quit, fg, bg and jobs)。

do_fgbg：实现bg和fg这两个内置命令。

waitfg：等待一个前台任务完成。

sigchld_handler：处理 SIGCHILD 信号。

sigint_handler：处理 SIGINT (ctrl-c) 信号。

sigtstp_handler：处理 SIGTSTP (ctrl-z) 信号。
接下来我们以合理的顺序完成这些函数并解析。

完成 tsh

在完成 tsh 时应当阅读官方提供的 trace 文件，逐个实现其功能并在最后完善错误处理等语句，在这里整体讲解最后的结果。

任务管理

先别急，在开始完成这些函数之前我们首先要了解代码框架提供的任务列表管理工具
在 shell 中任务的状态有以下几种：

FG (foreground)：前台运行

BG (background)：后台运行

ST (stopped)：停止
在所有任务中，只有一个能处于 FG 状态，任务的几种状态的转换条件如下图所示：

tsh 任务的数据结构如下:

Copy
struct job_t {           /* The job struct */
pid_t pid;             /* job PID */
int jid;               /* job ID [1, 2, ...] */
int state;             /* UNDEF, BG, FG, or ST */
char cmdline[MAXLINE]; /* command line */
};

任务列表以全局变量形式存储：

Copy
struct job_t jobs[MAXJOBS]; /* The job list */

tsh 提供了以下的函数来管理任务

Copy
void clearjob(struct job_t *job);
void initjobs(struct job_t *jobs);
int maxjid(struct job_t *jobs);
int addjob(struct job_t *jobs, pid_t pid, int state, char *cmdline);
int deletejob(struct job_t *jobs, pid_t pid);
pid_t fgpid(struct job_t *jobs);
struct job_t *getjobpid(struct job_t *jobs, pid_t pid);
struct job_t *getjobjid(struct job_t *jobs, int jid);
int pid2jid(pid_t pid);
void listjobs(struct job_t *jobs);

结合它们的名字、参数和返回值，很好理解其作用，在这里不做解释

waitfg

我们要实现的第一个函数，功能是阻塞当前进程，直到 pid 不再是前台进程

Copy
void waitfg(pid_t pid) {
while (pid == fgpid(jobs)) {
sleep(0);
}
return;
}

builtin_command

当用户输入的是内建指令时立即执行， 否则返回 0

Copy
int builtin_cmd(char **argv) {
char *cmd = argv[0];
if (strcmp(cmd, "quit") == 0) {
exit(0); /* execute it immediately */
} else if (strcmp(cmd, "fg") == 0 || strcmp(cmd, "bg") == 0) {
do_bgfg(argv);
return 1; /* is a builtin command */
} else if (strcmp(cmd, "jobs") == 0) {
listjobs(jobs);
return 1;
}
return 0; /* not a builtin command */
}

只需要识别输入的arg[0](即程序名)，如果是三种内建指令之一，则执行相应的函数，否则返回 0

do_bgfg

执行内建指令 fg 和 bg

Copy
void do_bgfg(char **argv) {
struct job_t *jobp = NULL;

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

if (isdigit(argv[1][0])) { /* fg/bg pid */
pid_t pid = atoi(argv[1]);
if ((jobp = getjobpid(jobs, pid)) == 0) {
printf("(%d): No such process\n", pid);
return;
}
} else if (argv[1][0] == '%') { /* fg/bg %jid */
int jid = atoi(&argv[1][1]);
if ((jobp = getjobjid(jobs, jid)) == 0) {
printf("%s: No such job\n", argv[1]);
return;
}
} else {
printf("%s: argument must be a PID or %%jobid\n", argv[0]);
return;
}

if (strcmp(argv[0], "bg") == 0) {
jobp->state = BG;
kill(-jobp->pid, SIGCONT);
printf("[%d] (%d) %s", jobp->jid, jobp->pid, jobp->cmdline);
} else if (strcmp(argv[0], "fg") == 0) {
jobp->state = FG;
kill(-jobp->pid, SIGCONT);
waitfg(jobp->pid);
}
return;
}

首先说明一下，参数argv为一个二位指针数组，每个元素都是一个指向字符串的指针，第一个元素argv[0]为该命令的名称如”fg“，后续的元素argv[1]、argv[2]等则是该命令的参数，最后一个元素为 NULL 代表结束。

如果用户输入 fg %1，则 argv 数组会是 {"fg", "%1", NULL}。

如果用户输入 bg 2345，则 argv 数组会是 {"bg", "2345", NULL}。

在这里有三个 if 语句：
第一个 if 语句：判断输入参数是否为空。
第二个 if 语句：判断输入参数为 pid 格式还是 % jid 格式，并获取当前的任务指针。
第三个 if 语句：判断当前执行的是 fg 还是 bg 命令，并相应地设置任务的 state 字段，发送 SIGCONT 信号给进程所属的进程组，如果是 fg 命令还需要等待其结束。

eval

执行用户输入的命令行

Copy
void eval(char *cmdline) {
char *argv[MAXARGS];
int bg = parseline(cmdline, argv);
pid_t pid;
sigset_t mask, prev_mask;

if (builtin_cmd(argv) == 0) { /* not a builtin command */
// 屏蔽 SIGCHLD 信号
sigemptyset(&mask);
sigaddset(&mask, SIGCHLD);
sigprocmask(SIG_BLOCK, &mask, &prev_mask);

pid = fork();
if (pid == 0) {
// 在子进程中解除对 SIGCHLD 信号的屏蔽
sigprocmask(SIG_SETMASK, &prev_mask, NULL);
setpgid(0, 0);
if (execve(argv[0], argv, environ) < 0) {
printf("%s: Command not found\n", argv[0]);
exit(1);
}
} else {
if (!bg) {
addjob(jobs, pid, FG, cmdline);
} else {
addjob(jobs, pid, BG, cmdline);
struct job_t *job = getjobpid(jobs, pid);
printf("[%d] (%d) %s", job->jid, pid, cmdline);
}
sigprocmask(SIG_SETMASK, &prev_mask, NULL);

if (!bg) {
waitfg(pid);
}
}
}
return;
}

使用parseline解析命令行得到 argv 数组和 bg 标志。

使用builtin_cmd判断若是内建指令直接执行。

不是内建指令使用fork创建子进程复制一份 tsh。

在子进程中，使用setpgid(0, 0)将子进程的进程组编号设为自己的进程号，即创建一个以它为领导的进程组，否则子进程会默认加入父进程的用户组，想一想这样向子进程发送信号的时候会发生什么？

在父进程中向任务列表中添加任务，如果是前台进程等待其结束，如果是后台进程打印相关信息。

在创建子进程前屏蔽 SIG_CHILD 信号，在父进程执行完 addjob 后解除屏蔽，想想要不然会什么样？同时因为子进程继承父进程的阻塞向量，所以也要在子进程中解除屏蔽。

信号处理

我们的 tsh 不应该在收到 SIGINT 或 SIGTSTP 时终止，而是转发给子进程，在收到 SIG_CHILD 时应当在任务系统中删除子进程。所以我们需要使用 signal 函数来修改和信号相关联的默认行为，替换为我们的处理程序。在编写处理程序时为了保证安全我们需要遵循一些原则：

G0. 处理程序尽可能简单。

G2. 保存和恢复 errno。

G3. 阻塞所有的信号，在访问共享全局数据结构时。

sigchld_handler

当进程接收到 SIGCHILD 信号时的处理程序

Copy
void sigchld_handler(int sig) {
int olderrno = errno;
pid_t pid;
int status;
sigset_t mask, prev_mask;
sigfillset(&mask);

while ((pid = waitpid(-1, &status, WNOHANG | WUNTRACED)) > 0) {
if (WIFEXITED(status)) {
sigprocmask(SIG_BLOCK, &mask, &prev_mask);
deletejob(jobs, pid);
sigprocmask(SIG_SETMASK, &prev_mask, NULL);
} else if (WIFSIGNALED(status)) {
sigprocmask(SIG_BLOCK, &mask, &prev_mask);
struct job_t *job = getjobpid(jobs, pid);
printf("Job [%d] (%d) terminated by signal %d\n", job->jid, pid, WTERMSIG(status));
deletejob(jobs, pid);
sigprocmask(SIG_SETMASK, &prev_mask, NULL);
} else if (WIFSTOPPED(status)) {
sigprocmask(SIG_BLOCK, &mask, &prev_mask);
struct job_t *job = getjobpid(jobs, pid);
job->state = ST;
printf("Job [%d] (%d) stopped by signal %d\n", job->jid, pid, WSTOPSIG(status));
sigprocmask(SIG_SETMASK, &prev_mask, NULL);
}
}

errno = olderrno;
return;
}

这段程序有以下几个重点：

在开始保存 errno，在结束时恢复。

在操作全局变量 jobs 时使用sigprocmask阻塞所有的信号操作完后恢复阻塞向量。

使用 while 循环来获取结束的子进程，因为 SIGCHLD 信号可能会被阻塞，所以我们一次要处理尽可能多的已终止子进程。

我们使用WIFEXITED()、WIFSIGNALED()和WIFSTOPPED() 来检查子进程的状态，并执行相应的操作。

sigint_handler

当进程接收到 SIGINT 信号时的处理程序

Copy
void sigint_handler(int sig) {
pid_t pid;
int olderrno = errno;
sigset_t mask, prev_mask;
sigfillset(&mask);

sigprocmask(SIG_BLOCK, &mask, &prev_mask);
pid = fgpid(jobs);
sigprocmask(SIG_SETMASK, &prev_mask, NULL);
if (pid > 0) {
kill(-pid, sig);
}

errno = olderrno;
return;
}

注意保存恢复 errno。

在操作全局变量时屏蔽所有信号。

sigtstp_handler

当进程接收到 SIGTSTP 信号时的处理程序

Copy
void sigtstp_handler(int sig) {
pid_t pid;
int olderrno = errno;
sigset_t mask, prev_mask;
sigfillset(&mask);

sigprocmask(SIG_BLOCK, &mask, &prev_mask);
pid = fgpid(jobs);
sigprocmask(SIG_SETMASK, &prev_mask, NULL);
if (pid > 0) {
kill(-pid, sig);
}

errno = olderrno;
return;
}

与上一条类似。

总结

通过这个实验，实现了一个带任务控制的简单 Shell，对异常控制流和信号有了更好的理解。