CSAPP Shell Lab
本 Lab 需要实现一个简易的 shell,主要考察对进程和信号的理解,以及对与其相关的 POSIX API 的使用,对应知识点为书中的第 8 章内容。
思路
实验要求
实现一个简单的 shell,要求支持如下特性:
- 输入 ctrl-c 触发 SIGINT 信号,输入 ctrl-z 触发 SIGTSTP 信号,发送给给前台运行的任务和依赖于这些任务的子任务(子进程)。
- 如果命令行以
&
结尾,那么本次作业将被置于后台运行,否则置于前台运行。 - 每个作业可以通过 PID(process id)或 JID(job id)来指定,其中 JID 需要加上前缀
%
。 - 支持下列内建命令:
quit
:终止 shell 的运行。jobs
:列出所有的后台作业。bg <job>
:重启<job>
(PID 或者 JID),通过发出 SIGCONT 信号,然后将其运行在后台。fg <job>
:重启<job>
(PID 或者 JID),通过发出 SIGCONT 信号,然后将其运行在前台。
- 回收所有的僵尸进程。
命令行解释执行
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
,真正需要实现的是 bg
和 fg
,它们的作用是将处于停止状态的作业恢复到后台(前台)执行。
实现方式无非分两步:
- 参数解析,提取出作业 ID(分为 PID 和 JID)。
- 根据
bg
和fg
的不同,分别进行处理,将作业运行在前台或是后台。
参数解析其实也就是字符串处理,这部分与本章的异常控制流关系不大,在此不过多赘述。
根据参数得到对应的作业 job
后,首先需要它的运行状态更改为指定的状态(BG
或 FG
),然后向 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_handler
和 sigtstp_handler
中进行了对作业队列的操作,在 sigint_handler
中使用 deletejob
删除作业,在 sigtstp_handler
中将作业状态修改为 ST
。
这在前面的测试用例中工作一切正常,但在最后的 trace16 中,程序却阻塞在了 mystop.c
的 if (kill(-pid, SIGTSTP) < 0)
位置,而如果按下键盘的 ctrl-c 或 ctrl-z 却能够响应信号,程序也正常终止或暂停。
通过查阅网上资料,在 实验四:Shell-Lab(下) - 知乎 找到了问题的原因。
程序 mystop
使用 kill
发送信号时,信号是直接发送到内核进行处理的,而不会经过 shell,shell 也就不会调用 sigtstp_handler
将作业状态更改为 ST
,shell 因此认为 mystop
程序一直处于前台运行状态,也就保持阻塞。
既然 sigint_handler
和 sigtstp_handler
在这种情况下无法被调用,那么就需要在其他信号处理函数中进行处理,答案便是 sigchld_handler
。事实上,子进程并非只有在终止时才向父进程发送 SIGCHLD 信号,而是在状态发生改变后就会。我们可以利用这一点,将所有对作业队列的操作都放到 sigchld_handler
中,而 sigint_handler
和 sigtstp_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);
}