CSAPP Lab-6 Shell Lab

本次实验的任务很清晰,实现一个简单的 Unix Shell。需要用到基础的进程控制、信号处理等知识。

简单来说,实验已经提供了一些简单的功能,我们需要在此基础上,实现下面的功能:

  • eval:解析和解释命令行的主例程。[70行]
  • builtin_cmd:识别并解释内置命令 quit(退出)、fg(前台运行某个作业)、bg(后台运行某个作业)和 jobs(打印作业列表)。[25行]
  • do_bgfg:实现 bgfg 两个内置命令 [50 lines]
  • waitfg:等待前台任务完成。[20行]
  • sigchld_handler:捕获 SIGCHILD 信号。[80行]
  • sigint_handler:捕获 SIGINTctrl-c)信号。[15行]
  • sigtstp_handler:捕获 SIGTSTPctrl-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 信号来重启,然后在后台运行它。参数可以是PIDJID
      fg 命令通过发送 SIGCONT 信号来重启,然后在前台运行它。参数可以是PIDJID
  • 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 函数就行了。

对于 fgbg 命令,只需要调用我们之后将会写的 do_bgfg 函数即可。

还有一个特例,参考 P525 的解释,我们需要忽略一个单独的 & 符号,因此可以也认为这是内置命令,不执行直接返回。

do_bgfg 函数

这个函数是来解释和处理 bgfg 两个内置命令的。

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\) 个测试文件,简单展示几个。

./20230907-csapp-shlab/image-20230907125517640

./20230907-csapp-shlab/image-20230907125615834

./20230907-csapp-shlab/image-20230907125724259

./20230907-csapp-shlab/image-20230907125032987

./20230907-csapp-shlab/image-20230907125407466

程序

点击这个 链接 查看。

posted @ 2024-04-24 13:32  hankeke303  阅读(20)  评论(0编辑  收藏  举报