CSAPP Lab-6 Shell Lab
本次实验的任务很清晰,实现一个简单的 Unix Shell。需要用到基础的进程控制、信号处理等知识。
简单来说,实验已经提供了一些简单的功能,我们需要在此基础上,实现下面的功能:
eval
:解析和解释命令行的主例程。[70行]builtin_cmd
:识别并解释内置命令quit
(退出)、fg
(前台运行某个作业)、bg
(后台运行某个作业)和jobs
(打印作业列表)。[25行]do_bgfg
:实现bg
和fg
两个内置命令 [50 lines]waitfg
:等待前台任务完成。[20行]sigchld_handler
:捕获SIGCHILD
信号。[80行]sigint_handler
:捕获SIGINT
(ctrl-c
)信号。[15行]sigtstp_handler
:捕获SIGTSTP
(ctrl-z
)信号。[15行]
实验为我们提供了一些简单的实用功能,可以配合我们完成这个实验:
/* Here are helper routines that we've provided for you */
int parseline(const char *cmdline, char **argv);
void sigquit_handler(int sig);
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);
实验要求
你的 tsh
shell 应该具有以下特性:
- 提示符应该是字符串 “
tsh>
”。 - 用户键入的命令行应该由一个名称和零个或多个参数组成,所有参数之间用一个或多个空格分隔。如果
name
是一个内置命令,那么tsh
应该立即处理它并等待下一个命令行。否则,tsh
应该假定名称是可执行文件的路径,它将在初始子进程的上下文中加载并运行该可执行文件(在此上下文中,术语job
指的是这个初始子进程)。 tsh
不需要支持管道(|
)或I/O
重定向(<
和>
)。- 输入
ctrl-c
(ctrl-z
) 应该会导致一个SIGINT
(SIGTSTP
) 信号被发送到当前前台作业,以及该作业的任何后代(例如,它分叉的任何子进程)。如果没有前台作业,那么信号应该没有效果。 - 如果命令行以
&
结束,那么tsh
应该在后台运行该作业。否则,它应该在前台运行作业。 - 每个作业可以通过进程ID (
PID
) 或作业 ID (JID
) 来标识,JID
是由tsh
分配的正整数。jid
应该在命令行中用前缀’%
'表示。例如,“%5
”表示JID 5
,“5
”表示PID 5
。(我们已经为您提供了操作作业列表所需的所有例程。) - TSH应该支持以下内置命令:
quit
命令终止 shell。jobs
命令列出所有后台任务。bg
命令通过发送SIGCONT
信号来重启,然后在后台运行它。参数可以是PID
或JID
。
fg
命令通过发送SIGCONT
信号来重启,然后在前台运行它。参数可以是PID
或JID
。
TSH
应该回收它所有的僵死子进程。如果任何一个作业因为它接收到一个它没有捕捉到的信号而终止,那么tsh
应该识别这个事件,并使用作业的PID
和对违规信号的描述打印一条消息。
错误处理封装
如同书上提供的 csapp.c
中做的一样,我们对于每一个 Unix 系统函数,都进行封装,在封装内部对函数的返回值做出判断,如果出错则输出错误信息并停止程序。
具体的封装代码不展示,这里给出封装后的函数原型。
/* Process control wrappers */
pid_t Fork(void);
void Execve(const char *filename, char *const argv[], char *const envp[]);
pid_t Wait(int *status);
pid_t Waitpid(pid_t pid, int *iptr, int options);
void Kill(pid_t pid, int signum);
unsigned int Sleep(unsigned int secs);
void Pause(void);
unsigned int Alarm(unsigned int seconds);
void Setpgid(pid_t pid, pid_t pgid);
pid_t Getpgrp();
/* Signal wrappers */
typedef void handler_t(int);
// handler_t *Signal(int signum, handler_t *handler);
void Sigprocmask(int how, const sigset_t *set, sigset_t *oldset);
void Sigemptyset(sigset_t *set);
void Sigfillset(sigset_t *set);
void Sigaddset(sigset_t *set, int signum);
void Sigdelset(sigset_t *set, int signum);
int Sigismember(const sigset_t *set, int signum);
int Sigsuspend(const sigset_t *set);
eval
函数
eval 函数的功能是解析和解释命令行。
eval
函数在课本上有多个实现的案例,只需要将它们连接起来即可。
在书本上 P525(中文版)有一个没有信号接收和传递的 Shell,其中的 eval 函数可以作为本次的框架。
P543 有一个可以接收子进程终止信号的 Shell,可以作为发起子进程那一部分的参考。
P546 有一个显式地等待前台进程终止的方法,可以在这里借助使用。
于是可以写出下面的程序:
void eval(char *cmdline)
{
char *argv[MAXARGS];
char buf[MAXLINE];
int bg;
pid_t pid;
sigset_t mask_one, mask_all, one_prev, all_prev;
strcpy(buf, cmdline);
bg = parseline(buf, argv);
if (argv[0] == NULL) return;
Sigfillset(&mask_all);
Sigemptyset(&mask_one);
Sigaddset(&mask_one, SIGCHLD);
if (!builtin_cmd(argv)) {
Sigprocmask(SIG_BLOCK, &mask_one, &one_prev);
if ((pid = Fork()) == 0) {
Sigprocmask(SIG_SETMASK, &one_prev, NULL);
Setpgid(0, 0);
Execve(argv[0], argv, environ);
}
Sigprocmask(SIG_BLOCK, &mask_all, &all_prev);
addjob(jobs, pid, bg ? BG : FG, cmdline);
Sigprocmask(SIG_SETMASK, &all_prev, NULL);
if (!bg) waitfg(pid);
else printf("[%d] (%d) %s", pid2jid(pid), pid, cmdline);
Sigprocmask(SIG_SETMASK, &one_prev, NULL);
}
return;
}
简单解释一下信号控制,对于非内置命令,先阻塞 SIGCHLD
信号,然后进行 fork,对于子进程,因为继承了父进程的阻塞,所以先解除阻塞,然后设置进程组,最后调用 evecve
执行。
而对于父进程,要再将所有信号阻塞才能调用 addjob
。这里,之前阻塞 SIGCHLD
是避免子进程带来的竞争导致子进程结束后父进程才 addjob
,这里阻塞所有信号是为了避免调用 addjob 过程中,被信号打断导致对全局数据的访问和修改不正常。
后面判断前台运行就调用 waitfg
,后台运行就打印消息,然后解除阻塞即可。
builtin_cmd
函数
这个函数是来解释和执行内置命令的,比较简单。
int builtin_cmd(char **argv)
{
if (!strcmp(argv[0], "quit")) exit(0);
else if (!strcmp(argv[0], "jobs")) {
listjobs(jobs);
return 1;
} else if (!strcmp(argv[0], "bg") || !strcmp(argv[0], "fg")) {
do_bgfg(argv);
return 1;
} else if (!strcmp(argv[0], "&")) return 1;
return 0; /* not a builtin command */
}
对于 quit
命令,直接退出即可。
对于 jobs
命令,调用实验已经写完的 listjobs
函数就行了。
对于 fg
和 bg
命令,只需要调用我们之后将会写的 do_bgfg
函数即可。
还有一个特例,参考 P525 的解释,我们需要忽略一个单独的 &
符号,因此可以也认为这是内置命令,不执行直接返回。
do_bgfg
函数
这个函数是来解释和处理 bg
和 fg
两个内置命令的。
void do_bgfg(char **argv)
{
if (argv[1] == NULL) {
printf("%s command requires PID or %%jobid argument\n", argv[0]);
return;
}
struct job_t *job;
int id;
if (argv[1][0] == '%') {
sscanf(argv[1], "%%%d", &id);
job = getjobjid(jobs, id);
if (job == NULL) {
printf("%s: No such job\n", argv[1]);
return;
}
} else if (isdigit(argv[1][0])) {
sscanf(argv[1], "%d", &id);
job = getjobpid(jobs, id);
if (job == NULL) {
printf("%s: No such process\n", argv[1]);
return;
}
} else {
printf("%s: argument must be a PID or %%jobid\n", argv[0]);
return;
}
Kill(-job->pid, SIGCONT);
if (!strcmp(argv[0], "fg")) {
job->state = FG;
waitfg(job->pid);
} else {
job->state = BG;
printf("[%d] (%d) %s", job->jid, job->pid, job->cmdline);
}
return;
}
首先,这两个命令是带有参数的,对于不带参数的调用我们不予执行。
对于第二个参数,如果是 PID
,那么就是纯数字。如果是 JID
,那么就是 %
符号加一串数组。可以使用第一个字符来判断,如果不是数字或者 %
那么就是错误输入。如果合法,调用 sscanf
即可读取,再调用 getjobpid
或者 getjobjid
即可获得相应的进程。
最后,向相应的进程组发送 SIGCONT
使其继续执行,然后根据 fg
还是 bg
选择前台执行还是打印消息。
waitfg
函数
这个函数是显式地在前台等待子进程执行结束。
书本 P546 详细地介绍了应该怎么处理这种需求。这里只是简单地讲一下解决方法。
sigsuspend(mask)
函数的作用效果相当于下面三行:
sigprocmask(SIG_SETMASK, &mask, &prev);
pause();
sigprocmask(SIG_SETMASK, &prev, NULL);
但是,sigsuspend(mask)
是原子版本的函数,不会在这三行的命令之间被信号打断。
具体地,我们只需要使用一个 while
循环判断这个 pid
是否还是前台进程,在循环之前加上 SIGCHLD
的阻塞,在 while
循环的内部调用 sigsuspend
解除 SIGCHLD
阻塞即可。
void waitfg(pid_t pid)
{
sigset_t mask_one, mask_prev;
Sigemptyset(&mask_one);
Sigaddset(&mask_one, SIGCHLD);
Sigprocmask(SIG_UNBLOCK, &mask_one, NULL);
Sigprocmask(SIG_BLOCK, &mask_one, &mask_prev);
Sigemptyset(&mask_prev);
while (fgpid(jobs) == pid) Sigsuspend(&mask_prev);
return;
}
sigchld_handler
函数
这个函数是用来处理 SIGCHLD
信号的。
首先,根据编程规范,因为这个信号处理函数的调用过程中,可能会改变 errno
的值,我们应该将 errno
记录下来,在函数退出之前还原。
我们使用 waitpid
函数获得所有已经退出或停止的子进程,因为一个 SIGCHLD
信号可能不只代表一个退出的子进程(比如因为上一个 sigchld
的调用而阻塞 SIGCHLD
期间,有多个子进程退出,但是因为信号不排队,我们只能再收到一次 SIGCHLD
信号),因此我们要使用 while
循环判断多次。
在处理子进程退出的期间,因为涉及到全局数据的读写,因此用将所有信号阻塞。
要分为正常退出、信号退出和进程停止三种情况来做不同的处理。
void sigchld_handler(int sig)
{
int errno_old = errno;
int status;
pid_t pid;
sigset_t mask, prev;
Sigfillset(&mask);
while ((pid = waitpid(-1, &status, WNOHANG | WUNTRACED)) > 0) {
Sigprocmask(SIG_BLOCK, &mask, &prev);
if (WIFEXITED(status)) deletejob(jobs, pid);
else if (WIFSIGNALED(status)) {
printf("Job [%d] (%d) terminated by signal %d\n", pid2jid(pid), pid, WTERMSIG(status));
deletejob(jobs, pid);
} else if (WIFSTOPPED(status)) {
printf("Job [%d] (%d) stopped by signal %d\n", pid2jid(pid), pid, WSTOPSIG(status));
getjobpid(jobs, pid)->state = ST;
}
Sigprocmask(SIG_SETMASK, &prev, NULL);
}
errno = errno_old;
return;
}
另外要注意,这里的 waitpid
函数不必使用错误处理封装后的,因为我们要根据错误来判断是否还有已结束的子进程。
sigint_handler
函数
这个函数是用来处理 Ctrl-C
等原因带来的 SIGINT
信号的。
void sigint_handler(int sig)
{
int errno_old = errno;
pid_t pid;
sigset_t mask, prev;
Sigfillset(&mask);
Sigprocmask(SIG_BLOCK, &mask, &prev);
pid = fgpid(jobs);
if (pid != 0) Kill(-pid, SIGINT);
Sigprocmask(SIG_SETMASK, &prev, NULL);
errno = errno_old;
return;
}
首先和前一个函数一样,我们保存 errno
的值并在退出前恢复。
还有就是依然要阻塞所有的信号,和前面也是一样的道理。
这个处理过程要求我们向所有的前台子进程及后继发送 SIGINT
信号,因此我们先使用实验提供的 fgpid
函数获得前台作业的 pid
,然后向这个作业的进程组发送 SIGINT
信号。注意必须要是进程组,因此我们 kill
函数中的参数应该取负数。
sigtstp_handler
函数
这个函数和前一个函数几乎一模一样,不解释。
void sigtstp_handler(int sig)
{
int errno_old = errno;
pid_t pid;
sigset_t mask, prev;
Sigfillset(&mask);
Sigprocmask(SIG_BLOCK, &mask, &prev);
pid = fgpid(jobs);
if (pid != 0) Kill(-pid, SIGSTOP);
Sigprocmask(SIG_SETMASK, &prev, NULL);
errno = errno_old;
return;
}
测试
有 \(15\) 个测试文件,简单展示几个。
程序
点击这个 链接 查看。