CSAPP : Shell Lab

实验介绍

完成一个简单的shell程序,总体的框架和辅助代码都已经提供好了,我们需要完成的函数主要以下几个:

  • eval: 主要功能是解析cmdline,并且运行. [70 lines]
  • builtin cmd: 辨识和解析出bulidin命令: quit, fg, bg, and jobs. [25lines]
  • do bgfg: 实现bg和fg命令. [50 lines]
  • waitfg: 实现等待前台程序运行结束. [20 lines]
  • sigchld handler: 响应SIGCHLD. 80 lines]
  • sigint handler: 响应 SIGINT (ctrl-c) 信号. [15 lines]
  • sigtstp handler: 响应 SIGTSTP (ctrl-z) 信号. [15 lines]

难点主要在于对信号的处理,需要我们捕获信号,改变其对应的处理方式。
其他需要注意的地方:

  1. 系统函数的返回值检查,一定要多注意有可能出错的地方;
  2. 竞争条件,fork子进程之后,如果子进程很快就结束了,而此时主进程还没addjob就会有问题,总之就是不能假设进程之间以安全的顺序执行,这里利用互斥量的思路,主进程会阻塞子进程的信号,直到addjob之后;
  3. SIGCHLD信号处理函数,考虑多个子进程结束,以及非正常结束时waitpid的返回值,后面结合课本里详细说。

有关Shell

我们要实现的shell有两种执行模式

  1. 如果用户输入的命令是内置命令,那么 shell 会直接在当前进程执行(例如 jobs)
  2. 如果用户输入的是一个可执行程序的路径,那么 shell 会 fork 出一个新进程,并且在这个子进程中执行该程序(例如 /bin/ls -l -d)

第二种情况中,如果命令以& 结束,那么这个job在后台执行
需要支持的功能:

  • job control:允许用户更改进程的前台/后台状态以及京城的状态(running, stopped, or terminated)
    • ctrl-c 会触发 SIGINT 信号并发送给每个前台进程,默认的动作是终止该进程
    • ctrl-z 会触发 SIGTSTP 信号并发送给每个前台进程,默认的动作是挂起该进程,直到再收到 SIGCONT 信号才继续
    • jobs 命令会列出正在执行和被挂起的后台任务
    • bg job 命令可以让一个被挂起的后台任务继续执行 ,fg job 命令同理

参考课本代码

先来看看课本上我们可以参考的代码有哪些
P525 eval()函数原型

void eval(char *cmdline) 
{
    char *argv[MAXARGS]; /* Argument list execve() */
    char buf[MAXLINE];   /* Holds modified command line */
    int bg;              /* Should the job run in bg or fg? */
    pid_t pid;           /* Process id */
    
    strcpy(buf, cmdline);
    bg = parseline(buf, argv);       //解析命令行函数都提供好了
    if (argv[0] == NULL)  
    return;   /* Ignore empty lines */

    if (!builtin_command(argv)) { 
        if ((pid = Fork()) == 0) {   /* 子进程来执行job */
            if (execve(argv[0], argv, environ) < 0) {
                printf("%s: Command not found.\n", argv[0]);
                exit(0);
            }
        }
    /* 如果是前台作业,主进程需要等待子进程运行完毕 */
    if (!bg) {
        int status;
        if (waitpid(pid, &status, 0) < 0)
        unix_error("waitfg: waitpid error");
    }
    else
        printf("%d %s", pid, cmdline);
    }
    return;
}

基本功能都完成了,唯一的不足就是由于joblist的存在,需要考虑竞争条件,也就是主进程一定要先addjob,然后才能deletejob
然后看一下信号处理函数,实验代码里已经要求了,需要对以下三个信号进行处理

Signal(SIGINT, sigint_handler); /* ctrl-c /
Signal(SIGTSTP, sigtstp_handler); /
ctrl-z /
Signal(SIGCHLD, sigchld_handler); /
Terminated or stopped child */

那么就来看看书上P532的示例:

#include "csapp.h"

void sigint_handler(int sig) /* SIGINT handler */   //line:ecf:sigint:beginhandler
{
    printf("Caught SIGINT!\n");    //line:ecf:sigint:printhandler
    exit(0);                      //line:ecf:sigint:exithandler
}                                              //line:ecf:sigint:endhandler

int main() 
{
    /* Install the SIGINT handler */         
    if (signal(SIGINT, sigint_handler) == SIG_ERR)  //line:ecf:sigint:begininstall
    unix_error("signal error");                 //line:ecf:sigint:endinstall
    
    pause(); /* Wait for the receipt of a signal */  //line:ecf:sigint:pause
    
    return 0;
}

嗯,SIGINT就是我们想自己处理的信号,然后通过sigint_handler来进行自定义的处理。(当然这示例也忒简单了)

还有一个重要的信号不排队问题,涉及到父进程回收子进程:

  • 子进程结束的时候向父进程发生信号,但是内核的规矩是这样的:
    在任何时刻,一种类型至多只会有一个待处理信号(内核中负责维护待处理信号的pending位向量对应的特定信号类型只有一位),也就是说信号是不会排队的,如果处理信号A的过程中又来了信号B,信号B是会被阻塞的,此时又来信号C,那么信号C就被丢弃了,处理办法也很简单,每次处理信号的时候,用while循环尽可能多接收几个信号。

书上的对应代码P539

void handler2(int sig) 
{
    int olderrno = errno;
    while (waitpid(-1, NULL, 0) > 0) {
        Sio_puts("Handler reaped child\n");
    }
    if (errno != ECHILD)
        Sio_error("waitpid error");
    Sleep(1);
    errno = olderrno;
}

接下来再看信号阻塞,也就是要避免父进程和子进程多job列表操作的竞争
linux提供阻塞信号的隐式机制和显式机制

  • 隐式:默认阻塞当前处理程序正在处理的信号类型
  • 显式:使用sigprocmask函数

具体怎么使用可以参考课本P543的promask2.c

#include "csapp.h"

void handler(int sig)
{
    int olderrno = errno;
    sigset_t mask_all, prev_all;
    pid_t pid;

    Sigfillset(&mask_all);
    while ((pid = waitpid(-1, NULL, 0)) > 0) { /* Reap a zombie child */
        Sigprocmask(SIG_BLOCK, &mask_all, &prev_all);    //阻塞所有信号
        deletejob(pid); /* 对joblist安全删除 */
        Sigprocmask(SIG_SETMASK, &prev_all, NULL);      //恢复所有信号
    }
    if (errno != ECHILD)
        Sio_error("waitpid error");
    errno = olderrno;
}
    
int main(int argc, char **argv)
{
    int pid;
    sigset_t mask_all, mask_one, prev_one;

    Sigfillset(&mask_all);
    Sigemptyset(&mask_one);
    Sigaddset(&mask_one, SIGCHLD);
    Signal(SIGCHLD, handler);
    initjobs(); /* Initialize the job list */

    while (1) {
        Sigprocmask(SIG_BLOCK, &mask_one, &prev_one); /* 阻塞 SIGCHLD */
        if ((pid = Fork()) == 0) { /* Child process */
            Sigprocmask(SIG_SETMASK, &prev_one, NULL); /* 子进程解除阻塞 SIGCHLD */
            Execve("/bin/date", argv, NULL);
        }
        Sigprocmask(SIG_BLOCK, &mask_all, NULL); /* 阻塞所有信号*/  
        addjob(pid);  /* 对job列表的操作安全了 */
        Sigprocmask(SIG_SETMASK, &prev_one, NULL);  /* 父进程解除阻塞 SIGCHLD */
    }
    exit(0);
}

看起来挺麻烦,实际我们可以简化的,实际写的时候只阻塞SIGCHLD即可。
课本里面还提到一点,主程序显式等待某个信号处理程序进行,尤其是shell创建前台作业的时候,基本思路是用循环,当接收到信号时,在信号处理程序中把while条件更改来跳出循环,这样做存在资源浪费以及无法精准唤醒的问题,比较好的解决方法是sigsuspend,相当于一个原子操作,可以暂时取消阻塞SIGCHLD,然后pause接收信号,紧接着恢复阻塞。
否则的话,本来的pause()是挂起进程直到收到信号,那么下面的代码有可能永远休眠

while(!pid)
  pause();  //如果在while判断之后和pause之前收到信号,那就错过啦,所以需要原子操作

实验代码

经过上面对课本代码的复习,实际上好多功能都已经给出了实现方法,实验代码自然也就不难给出啦。

1. eval()

主要功能是对用户输入的参数进行解析并运行计算。如果用户输入内建的命令行(quit,bg,fg,jobs)那么立即执行。 否则,fork一个新的子进程并且将该任务在子进程的上下文中运行。如果该任务是前台任务那么需要等到它运行结束才返回。

  1. 注意每个子进程必须用户自己独一无二的进程组id,要不然就没有前台后台区分啦
  2. 在fork()新进程前后要阻塞SIGCHLD信号,防止出现竞争(race)这种经典的同步错误
void eval(char *cmdline) 
{
    char* argv[MAXARGS];
    pid_t pid;
    sigset_t mask;
    int fgorbg = parseline(cmdline,argv);
    if(argv[0] == NULL)
        return;
    sigemptyset(&mask);
    sigaddset(&mask,SIGCHLD);
    sigprocmask(SIG_BLOCK,&mask,NULL);
    if(!builtin_cmd(argv)){
        if((pid = fork()) == 0){
            sigprocmask(SIG_UNBLOCK,&mask,NULL);        //子进程也是要解除阻塞的
            if(setpgid(0,0)<0)
                 unix_error("eval: setgpid failed.\n");  
            if(execve(argv[0],argv,environ)<0){
                printf("%s: Command not found.\n",argv[0]);
                exit(0);
            }
        }
        if(!fgorbg)
            addjob(jobs,pid,FG,cmdline);
        else
            addjob(jobs,pid,BG,cmdline);
        sigprocmask(SIG_UNBLOCK,&mask,NULL);        //这里一定要addjob之后再解除阻塞
        if(!fgorbg) // FG job
            waitfg(pid);
        else
            printf("[%d] (%d) %s\n",pid2jid(pid),pid,cmdline);
    }

    return;
}

2. builtin_cmd()

判断命令是否是内置指令,是的话立即执行,不是则返回,对单独的‘&’无视

int builtin_cmd(char **argv) 
{
    if(strcmp(argv[0],"quit")==0)
        {printf("exit\n");exit(0);}
    if(strcmp(argv[0],"jobs")==0)
        {
            listjobs(jobs);
            return 1;
        }
    if(strcmp(argv[0],"bg")==0 || strcmp(argv[0],"fg")==0)
        {
            do_bgfg(argv);
            return 1;
        }
    return 0;     /* not a builtin command */
}

3. do_bgfg()

执行bg和fg指令功能

void do_bgfg(char **argv) 
{
    char *id = argv[1];
    struct job_t *job;
    int jobid;
    if(id == NULL){
        printf("%s command requires PID of jobid argument.\n",argv[0]);
        return;
    }
    if(id[0] == '%')
        jobid = atoi(id+1);
    if((job = getjobjid(jobs,jobid))==NULL){
        printf("Job does not exist.\n");
        return;
    }
    if(strcmp(argv[0],"bg")==0){
        job->state = BG;
        kill(-(job->pid),SIGCONT);
    }
    if(strcmp(argv[0],"fg")==0){
        job->state = FG;
        kill(-(job->pid),SIGCONT);
        waitfg(job->pid);
    }
    return;
}

kill函数的用法,向任何进程组或进程发送信号
int kill(pid_t pid, int sig);
参数pid的可能选择:

  1. pid大于零时,pid是信号欲送往的进程的标识。
  2. pid等于零时,信号将送往所有与调用kill()的那个进程属同一个使用组的进程。
  3. pid等于-1时,信号将送往所有调用进程有权给其发送信号的进程,除了进程1(init)。
  4. pid小于-1时,信号将送往以-pid为组标识的进程。

4. waitfg()

等待前台进程完成,这里偷懒了,没用sigsuspend,还是用了比较消耗资源的方法哈哈哈。。。

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

5. 几个信号处理函数

SIGINT处理比较简单,就是截获CTRL+C然后发给前台程序嘛

void sigint_handler(int sig) 
{
    pid_t pid = fgpid(jobs);
    int jid = pid2jid(pid);
    if(pid!=0){
        printf("Job [%d] terminated by SIGINT.\n",jid);
        deletejob(jobs,pid);
        kill(-pid,sig);
    }
    return;
}

SIGTSTP就是把CTRL+Z发给前台

void sigtstp_handler(int sig) 
{
    pid_t pid = fgpid(jobs);
    int jid = pid2jid(pid);
    if(pid!=0){
        printf("Job [%d] stopped by SIGINT.\n",jid);
        (*getjobpid(jobs,pid)).state = ST;;
        kill(-pid,sig);
    }
    return;
}

SIGCHLD是最麻烦的了,参考网上大牛的方法,需要考虑子进程返回的原因
运用waitpid()函数并且用WNOHANG|WUNTRACED参数,该参数的作用是判断当前进程中是否存在已经停止或者终止的进程,如果存在则返回pid,不存在则立即返回
通过另外一个&status参数,我们可以判断返回的进程是由于什么原因停止或暂停的。

  • WIFEXITED(status):
    如果进程是正常返回即为true,什么是正常返回呢?就是通过调用exit()或者return返回的
  • WIFSIGNALED(status):
    如果进程因为捕获一个信号而终止的,则返回true
  • WTERMSIG(status):
    当WIFSIGNALED(status)为真时,设置该值,返回导致当前状态的信号编号
  • WIFSTOPPED(status):
    如果返回的进程当前是被停止,则为true
  • WSTOPSIG(status):
    返回引起进程停止的信号
void sigchld_handler(int sig) 
{
    pid_t pid;
    int status,child_sig;
    while((pid = waitpid(-1, &status, WUNTRACED | WNOHANG)) > 0 ){  
        printf("Handling chlid proess %d\n", (int)pid);  
        /*handle SIGTSTP*/  
        if( WIFSTOPPED(status) )  
            sigtstp_handler( WSTOPSIG(status) );  
        /*handle child process interrupt by uncatched signal*/  
        else if( WIFSIGNALED(status) ) {  
            child_sig = WTERMSIG(status);  
            if(child_sig == SIGINT)  
                sigint_handler(child_sig);  
        }  
        else      
            deletejob(jobs, pid);  
    }  
    return; 
}

总结

本次Shell Lab的收获有以下几点

  1. 对Shell有了更加深刻的理解,借助实验代码实现了不带重定向的简单shell
  2. 掌握信号的正确接收处理,阻塞和解除阻塞机制,写出避免竞争的代码
  3. 父进程和子进程的fork,回收,信号传递等
  4. linux下编程规范,以及进程相关函数的使用,学到了学到了

你可能感兴趣的:(CSAPP : Shell Lab)