前言

使用的课本为《深入理解计算机系统》的第2版(文章中简称"课本"),第三版和第二版中的要求可能有差别,这点请注意;另外,实验指导书中会有关于实验细节的介绍,请仔细阅读。内容仅作记录(本人也是参考别人才完成的),请谨慎参考。

实验前的准备

完成实验压缩包的下载、解压等工作。
仔细阅读实验指导书中的内容,下面是对关键内容的提炼:
本次实验旨在更深入的了解进程控制和信号的相关知识,我们需要自己来编写一个能够支持简单的作业管理的Unix shell程序

在tsh.c文件中已经给出了程序所需的函数框架,其中部分函数的功能已经提供给我们了,我们不断完善其中函数的内容来实现shell的功能,需要完善的函数的功能和大致行数如下:

1、eval:对命令行进行求值、解析。 [70行]
2、builtin_cmd:检查第一个命令行参数是否是一个内置命令,如:quit、fg、bg、job。[25行]
3、do_bgfg:执行bg、fg指令。[50行]
4、waitfg:等待前台作业完成。[20行]
5、sigchld_handler:对SIGCHLD信号处理。[80行]
6、sigint_handler:对SIGINT信号处理。[15行]
7、sigtstp_handler:对SIGTSTP信号处理。[15行]

最终实现的tsh应当具有如下特征:
1、提示符应当为字符串“tsh>”
2、用户输入的命令行有名称、0或多个参数组合,所有的参数由一个或多个空格分隔。如果名称(用户键入的第一部分)是内置命
令,则tsh应该立即处理它并等待下一个命令,否则,tsh应当将其认为是可执行文件的路径,它将在初始子进程的上下文中加
载并运行
3、tsh不需要支持管道 | 和 I/O重定向 < >
4、键入ctrl-c (ctrl-z)将会将SIGINT (SIGTSTP)信号发送到当前的前台作业以及其所有的后代。如果没有前台作业,则该信号将被
认为无效
5、如果命令行以“&”结束,则tsh应当在后台运行这个作业,否则在前台运行这个作业
6、每个作业可以由进程ID (PID)和作业ID (JID)标识,ID是由tsh分配的正整数,JID应当在命令行中以前缀 % 表示,例如“%5”表
示JID 5,“5”表示PID 5
7、tsh应当支持如下的内置命令:
      quit:终止shell
      jobs:列出所有的后台作业
      bg:通过发送SIGCONT信号重启一个作业,然后在后台运行。参数可以是PID或JID
      fg:通过发送SIGCONT信号重启一个作业,然后在前台运行。参数可以是PID或JID
8、tsh应当回收所有的僵死子进程。如果有作业因接收到未捕获的信号而终止,则tsh应当识别这个事件并打印一条包含作业PID和有害信号描述的消息

实验提供了一些供测试的文件可以用来测试得到的tsh功能的正确性,另外还提供了一个供参考的shell,通过对二者的输出进行比较来检测功能实现是否正确:
测试方法:
比如要测试trace01.txt中的命令是否能够被正确执行:
unix> ./sdriver.pl -t trace01.txt -s ./tsh -a “-p”

unix> make test01
以同样的方式可以查看其在参考shell的输出结果:
unix> ./sdriver.pl -t trace01.txt -s ./tshref -a “-p”

unix> make rtest01

需要注意的点:

  1. 需要考虑waitpid、kill、fork、execve、setpid和sigprocmask函数的使用,waitpid的WUNTRACED和WNOHANG选项
  2. 在实现信号处理程序时,需要在kill中使用“-pid”参数将SIGINT和SIGTSTP信号发送给整个前台进程组
  3. 一个比较棘手的部分是处理waitfg和sigchld_handler函数之间的工作分配,推荐使用以下的方式来实现:
    -在waitfg函数中,使用循环包围sleep函数
    -在sigchld_handler函数中,仅调用一次waitpid函数
  4. 在eval函数中,父级必须在fork创建子进程之前使用sigprocmask来阻塞SIGCHLD信号,在调用addjobs函数将子级作业添加到作业列表之后使用sigprocmask来取消阻塞。由于子级继承了父级的阻塞向量,因此子级必须确保在执行新程序之前先解除对SIGCHLD信号的阻塞。
    父级需要以这种方式来阻塞SIGCHLD信号,避免子级在调用addjob函数之前被sigchld_handler函数回收导致竞争状况
  5. 从标准的Unix shell运行我们自己的shell,这个shell在前台进程组中运行。如果我们自己编写的shell随后创建了一个子进程,那么默认情况下,这个子进程也将成为前台进程组的成员。输入ctrl-c会将SIGINT信号发送到前台组中的每个进程,同样这个信号会发送到我们自己编写的shell和该shell下创建的每一个进程,这显然时不正确的。
    为此提供解决方案:在使用fork之后,execve之前,子进程调用setpgid(0,0),这将会将子进程放入一个新的进程组中,该组的组ID与该子进程的PID相同。这样可以确保前台进程组中只有一个进程,即shell。当输入ctrl-c之后,shell将会捕获SIGINT信号并将其转发给适当的前台作业。

实验过程

实验为我们提供了trace01~trace16这些用于测试的文件,使用make testxx可以使用对应的测试文件中的命令来测试tsh相关功能的正确性,使用make rtestxx可以查看测试文件中的命令在标准程序下的输出内容,通过比较二者的输出,可以判断功能实现的正确性。

trace01

在这里插入图片描述
可以看到,在trace01中对CLOSE和WAIT命令进行了测试
执行make test01和make rtest01将运行结果进行对比:
在这里插入图片描述
可以发现,这两个命令无需补充代码即可得到正常的结果。

trace02、trace03

在这里插入图片描述
可以看到,这两个测试文件中对quit命令进行了测试,这将涉及到eval函数、builtin_cmd函数。

eval

功能介绍:
      eval函数的主要作用是调用parseline函数对以空格分割的命令行参数进行解析,并构造最终会传递给execve的argv向量。命令行的第一个参数是一如果个内置的shell命令,马上就会解释这个命令,如果是一个可执行目标文件,会在一个新的子进程的上下文中加载并运行这个文件。
      如果命令行最后一个参数是“&”字符,那么parseline返回1,表示应该在后台执行该程序(shell不会等待它完成),否则返回0,表示应该在前台执行这个程序(shell等待它完成)。
      在解析完命令行之后,eval函数调用builtin_cmd函数,检查第一个参数是否是一个内置的shell命令。如果是,他就会立即解释这个命令,并返回1,如果不是内置命令,则返回0,需要新建一个子进程,并利用execve来通过参数给出的路径寻找处可执行文件并在子进程中执行,如果找不到,则输出命令未找到,结束子进程。[课本p502、p503]
需要注意的点:
      为避免像“竞争”这样的同步错误的发生,在调用fork之前阻塞SIGCHLD信号,由于子进程继承了它们父进程的被阻塞集合,所以我们必须在调用execve之前,解除子进程中阻塞的SIGCHLD信号。[课本p520]
显式地阻塞和取消阻塞信号:
应用程序可以用sigprocmaks函数显式地阻塞和取消阻塞选择的信号:
       #include <signal.h>
       int sigprocmask(int how, const sigset_t *set, sigset_t *oldset);
       int sigemptyset(sigset_t *set);
       int sigfillset(sigset_t, *set);
       int sigaddset(sigset_t *set, int signum);
       int sigdelset(sigset_t *set, int signum);
       int sigismember(const sigset_t *set, int signum);

  1. sigprocmask
    此函数改变当前已阻塞信号的集合(blocked)。具体行为依赖于how的值:
    SIG_BLOCK:添加set中的信号到blocked中
    SIG_UNBLOCK:从blocked中删除set中的信号
    SIG_SETMASK:blocked=set
    如果oldset非空,blocked之前的值会保存在oldset中
  2. sigemptyset
    初始化set为空集
  3. sigfillset
    将每个信号添加到set中
  4. sigaddset
    添加signum到set
  5. sigdelset
    从set中删除signum
  6. sigismember
    如果signum是set的成员,那么返回1,否则返回0 [课本p517]

Linux信号:
在这里插入图片描述[课本p505]

void eval(char *cmdline) 
{
    char *argv[MAXARGS]; //传递给execve()的参数列表
    char buf[MAXLINE];   //用来存命令行输入的内容
    int bg;              //程序在前台还是后台执行
    pid_t pid;           //进程号
    sigset_t mask;

    //解析命令行,得到是否是后台命令
    strcpy(buf, cmdline);
    bg = parseline(buf, argv);
    if(argv[0] == NULL)
        return;
    //判断是否是内置命令
    if(!builtin_cmd(argv))
    {
        sigemptyset(&mask); //对阻塞信号集初始化(清空)
        sigaddset(&mask, SIGCHLD); //将SIGCHLD信号加入到阻塞信号集中

        sigprocmask(SIG_BLOCK, &mask, NULL); //阻塞SIGCHLD信号
        if((pid = fork()) == 0)
        {
			 /*子进程继承了父进程的阻塞信号集合,需要在execve之前
				解除子进程中阻塞的SIGCHLD信号*/
            sigprocmask(SIG_UNBLOCK, &mask, NULL);
            setpgid(0, 0); 
			/*将子进程加入一个新的进程组,ID与子进程PID相同,
              详细查看“实验前的准备/需要注意的点5”*/
            if(execve(argv[0], argv, environ) < 0)
            {
                //没有找到相关可执行文件的情况下,打印信息,直接退出
                printf("%s: Command not found.\n", argv[0]);
                exit(0);
            }
        }
    }
    return;
}

[课本p503、p520]

builtin_cmd

功能介绍:
      在trace02和trace03中完成的是内置命令“quit”,该命令表示结束当前进程,故需要判断命令行第一个参数是否为“quit”,如果是,则直接退出进程。

int builtin_cmd(char **argv) 
{
    if(!strcmp(argv[0], "quit")) //命令为内置命令quit
        exit(0);                   //结束当前进程
    return 0;     /* not a builtin command */
}

[课本p503]

测试比较

在这里插入图片描述

trace04

在这里插入图片描述
可以看到,这个测试文件要求运行一个后台工作,这将涉及到eval函数、waitfg函数、sigchld_handler函数。

eval

功能介绍:
      在原有eval函数的基础上添加将作业添加到后台作业管理函数的使用
需要注意的点:
      跟之前消除“竞争”问题一样,通过在调用fork之前,阻塞SIGCHLD信号,然后在我们调用了addjob之后就取消阻塞这些信号,保证在子进程被添加到作业列表中之后回收该子进程。因为可能会在addjob之前调用deletejob,这导致作业列表中出现一个不正确的条目,对应一个不再存在而且永远也不会被删除的作业 (听起来有点惊悚呀)。
[课本p519、p520]

void eval(char *cmdline) 
{
    char *argv[MAXARGS]; //传递给execve()的参数列表
    char buf[MAXLINE];   //用来存命令行输入的内容
    int bg;              //程序在前台还是后台执行
    pid_t pid;           //进程号
    sigset_t mask;

    //解析命令行,得到是否是后台命令
    strcpy(buf, cmdline);
    bg = parseline(buf, argv);
    if(argv[0] == NULL)
        return;
    //判断是否是内置命令
    if(!builtin_cmd(argv))
{
        sigemptyset(&mask); //对阻塞信号集初始化(清空)
        sigaddset(&mask, SIGCHLD); //将SIGCHLD信号加入到阻塞信号集中

        sigprocmask(SIG_BLOCK, &mask, NULL); //阻塞SIGCHLD信号
        if((pid = fork()) == 0)
        {
			 /*子进程继承了父进程的阻塞信号集合,需要在execve之前
				解除子进程中阻塞的SIGCHLD信号*/
            sigprocmask(SIG_UNBLOCK, &mask, NULL);
            setpgid(0, 0); 
			/*将子进程加入一个新的进程组,ID与子进程PID相同,
              详情可查看“实验前的准备/需要注意的点5”*/
            if(execve(argv[0], argv, environ) < 0)
            {
                //没有找到相关可执行文件的情况下,打印信息,直接退出
                printf("%s: Command not found.\n", argv[0]);
                exit(0);
            }
        }
        //创建完成子进程后,父进程addjob
        addjob(jobs, pid, bg?BG:FG, cmdline);
        sigprocmask(SIG_UNBLOCK,&mask, NULL); //addjob执行完解除阻塞

        //fg则等待前台运行完成
        if(!bg)
        {
            waitfg(pid);
        }
        else
        {
            printf("[%d] (%d) %s", pid2jid(pid), pid, cmdline);
        }

    }
    return;
}

[课本p503、p520]

waitfg

功能介绍:
      等待pid进程不再是前台进程

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

sigchld_handler

功能介绍:
回收子进程
waitpid
      一个进程可以通过调用waitpid函数来等待它的子进程终止或者停止。
      #include <sys/types.h>
      #include<sys/wait.h>
      pid_t waitpid(pid_t pid,int *status,int options);
1、判定等待集合的成员(涉及到第一个参数)
      等待集合的成员由参数pid来确定:
      如果pid>0,那么等待集合就是一个单独的子进程,它的进程ID等于pid
      如果pid = -1,那么等待集合就是由父进程所有的子进程组成的
2、修改默认行为(涉及到第三个参数)
默认情况下,options=0,waitpid挂起调用进程的执行,指导它的等待集合中的一个子进程终止,通过将options设置为常量WNOHANG和WUNTRACED的各种组合,可以修改默认行为:
      WNOHANG:如果等待集合中的任何子进程都还没有终止,那么就立即返回(返回0)
      WUNTRACED:挂起调用进程的执行,直到等待集合中的一个进程变成已终止或者被停止。返回的PID为导致返回的已终止或被停止子进程的PID
      WNOHANG| WUNTRACED:立即返回,如果等待集合中没有任何子进程被停止或已终止,那么返回值为0,否则返回值等于那个被停止或者已终止的子进程的PID
3、检查已回收子进程的退出状态(涉及到第二个参数)
      如果 status 参数是非空的,那么 waitpid 就会在 status 参数中放上关于导致返回的子进程的状态信息。 wait.h 头文件定义了解释 status 参数的几个宏:
• WIFEXITED (status) : 如果子进程通过调用 exit 或者一个返回 (return) 正常终止,
就返回真。
• WEXITSTATUS (status): 返回一个正常终止的子进程的退出状态。只有在 WIFEXITED
返回为真时,才会定义这个状态。
•WIFSIGNALED (status): 如果子进程是因为一个未被捕获的信号终止的,那么就返回
真(将在 8.5 节中解释说明信号)。
•WTERMSIG (status 汃返回导致子进程终止的信号的数量。只有在 WIFSIGNALED
(status) 返回为真时,才定义这个状态。
• WIFSTOPPED (status) : 如果引起返回的子进程当前是被停止的,那么就返回真。
• WSTOPSIG (status): 返回引起子进程停止的信号的数量。只有在 WIFSTOPPED
(status) 返回为真时,才定义这个状态。
4、错误条件
      如果调用进程没有子进程,那么 waitpid 返回 -1, 并且设置 errno ECHILD 。如果
waitpid 函数被一个信号中断,那么它返回 -1, 并设置 errno EINTR。
[课本p495、p496]

void sigchld_handler(int sig) 
{
    int olderrno = errno; //保存errno的值,在处理结束后恢复
    pid_t pid;
    int status;

	/*立即返回,等待集合为父进程的所有子进程,如果等待集合中的子进程
	  都没有被停止或终止,则返回值为0;如果有一个停止或终止,返回该子
      进程的PID。
      这里用if和while均可,但实验指导书上说只用一次waitpid
      详见“实验前的准备/需要注意的点3”*/
    if((pid=waitpid(-1, &status, WNOHANG | WUNTRACED)) > 0)
{
        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));
            struct job_t *job = getjobpid(jobs, pid);
            job->state = ST; //切换对应的任务状态,ST表示stop状态
        }
    }
    errno = olderrno; //恢复errno的值,防止它被改变
    return;
}

[课本p498]

测试比较

在这里插入图片描述

trace05

在这里插入图片描述
可以看到,这个测试文件要求实现内置命令“jobs”的功能,使用make trace05可以看出这将调用listjobs函数,将所有的作业打印出来,这涉及到builtin_cmd函数。

builtin_cmd

功能介绍:
      在原有的基础上增加对“jobs”内置命令的判断,如果命令行第一个参数为jobs,则调用listjobs函数,同时返回1(内置命令)。

int builtin_cmd(char **argv) 
{
    if(!strcmp(argv[0], "quit")) //命令为内置命令quit
        exit(0);                   //结束当前进程
    else if(!strcmp(argv[0], "jobs")) //命令为jobs内置命令
    {
        listjobs(jobs);                 //调用listjobs函数
        return 1;
    }
    return 0;     /* not a builtin command */
}

测试比较

在这里插入图片描述

trace06

在这里插入图片描述
可以看到,这个测试文件是对键盘按下ctrl-c的处理,进入信号处理函数,然后终止前台正在运行的函数,这将涉及到sigint_handler函数。

sigint_handler

功能介绍:
      当用户按下ctrl-c时,向前台发送SIGINT信号。
用kill函数发送信号
      #include<sys/types.h>
      #include<signal.h>
      int kill(pid_t pid,int sig);
      进程可以通过调用kill函数发送信号给其他进程,如果第一个参数为pid,那么kill函数将会发送信号sig(第二个参数)给进程pid,如果第一个参数为-pid,那么kill函数将会发送信号sig给进程组pid中的每个进程。[课本p507、p508]

void sigint_handler(int sig) 
{
    int pid = fgpid(jobs); //通过fgpid函数获取当前的前台进程
    if(pid > 0)             //当前是否有前台进程,没有则直接返回
    {
        kill(-pid, SIGINT); //使用kill函数发送SIGINT信号给前台进程组
    }
    return;
}

测试比较

在这里插入图片描述

trace07

在这里插入图片描述
可以看到,这个测试文件是对前面两个功能内置命令jobs和键盘输入ctrl-c的综合测试,如果前面的测试没有问题的话,这个文件的测试内容应该可以顺利通过。

测试比较

在这里插入图片描述

trace08

在这里插入图片描述
可以看出,这个测试文件是对键盘按下ctrl-z的处理,进入信号处理函数,将对前台程序进行挂起,这涉及到sigstp_handler函数。

sigstp_handler

功能介绍:
      当用户按下ctrl-z时,向前台发送SIGTSTP信号。

void sigtstp_handler(int sig) 
{
    int pid = fgpid(jobs); //通过fgpid函数获取当前的前台进程
    if(pid > 0)             //当前是否有前台进程,没有则直接返回
    {
        kill(-pid, SIGTSTP); //使用kill函数发送SIGINT信号给前台进程组
    }
    return;
}

测试比较

在这里插入图片描述

trace09、trace10

在这里插入图片描述
在这里插入图片描述
可以看到,这两个测试文件始对bg和fg指令进行解析运行的测试,涉及到do_bgfg函数、builtin_cmd函数。

builtin_cmd

功能介绍:
      在原有的基础上添加对“bg”、“fg”命令的判断,如果是“bg”或“fg”命令,则执行do_bgfg函数。

int builtin_cmd(char **argv) 
{
    if(!strcmp(argv[0], "quit")) //命令为内置命令quit
        exit(0);                 //结束当前进程
    else if(!strcmp(argv[0], "jobs")) //命令为jobs内置命令
    {
        listjobs(jobs);                 //调用listjobs函数
        return 1;
}
    else if(!strcmp(argv[0], "fg") || !strcmp(argv[0], "bg"))
    {                                    //命令为fg或bg
        do_bgfg(argv);                 //调用do_bgfg函数
        return 1;
    }
    return 0;     /* not a builtin command */
}

do_bgfg

功能介绍:
      通过argv[0]判断前后台;
      之后考虑argv[1]的四种情况,如果为NULL则打印相关信息并返回(具体打印什么内容,可以先使用供参考的rtsh测试),如果第一个字符为%(即argv[1][0]),则argv[1]可能为% jid这样的参数,根据jid查找对应的job是否存在,如果argv[1]为一个pid值,同样需要判断其是否存在,既不是%jid也不是pid则打印相关信息后返回;
      当进程存在时,根据前后台,改变工作对应的状态,同时发送继续执行的信号(SIGCONT)

void do_bgfg(char **argv) 
{
    struct job_t *job;
    int pid;
    if(argv[1] == NULL) //如果argv[1]为NULL,则打印相关信息后直接返回
    {
        printf("%s command requires PID or %%jobid argument\n", argv[0]);
        return;
    }
    else if(argv[1][0] == '%') //第二个参数的第一个字符为%,可能是 %jid
    {
        int jid = atoi(argv[1]+1); //将第一个字符之后的部分转为数字
        job = getjobjid(jobs, jid); //根据jid查找对应的job
        if(job == NULL)              //如果job不存在,打印相关信息后返回
        {
            printf("%%%d: No such job\n", jid);
            return;
        }
        pid = job->pid;             //jid对应的job存在,给pid赋值
    }
    else
    {
        pid = atoi(argv[1]); //剩下一种情况参数可能为pid,将参数转为数字
        if(pid <= 0)          //不是pid也不是jid,打印相关信息后返回
        {
            printf("%s: argument must be a PID or %%jobid\n", argv[0]);
            return;
        }
        job = getjobpid(jobs, pid); //根据pid查找对应的job
        if(job == NULL)               //如果不存在,打印相关信息后返回
        {
            printf("(%d): No such process\n", pid);
            return;
        }
    }
    if(!strcmp(argv[0], "bg"))       //后台
    {
        job->state = BG;              //设置job的状态为BG(后台运行)
        printf("[%d] (%d) %s", job->jid, pid, job->cmdline);
        kill(-pid, SIGCONT);//使用kill对其进程组发送继续执行的信号
        return;
    }
    else if(!strcmp(argv[0], "fg")) //前台
    {
        job->state = FG;         //设置job的状态为FG(前台执行)
        kill(-pid, SIGCONT);    //使用kill对其进程组发送继续执行的信号
        waitfg(pid);             //等待其执行完毕
        return;
    }
    return;
}

测试比较

在这里插入图片描述
在这里插入图片描述

trace11~trace16

这些测试文件是对之前测试内容的组合,在之前测试每次测试无误的情况下,这些测试基本无误,经测试确实如此。
其中trace15是对之前所有功能的综合测试

测试比较

在这里插入图片描述

写在后面

想要进一步优化可以进行错误处理包装函数 [课本附录p694],很多文章中提到的“当试图访问全局结构变量的时候暂时block所有的信号,然后还原”这个原则,我在书中没有找到对应的内容(可能是第三版书中的),所以没有添加。

Logo

DAMO开发者矩阵,由阿里巴巴达摩院和中国互联网协会联合发起,致力于探讨最前沿的技术趋势与应用成果,搭建高质量的交流与分享平台,推动技术创新与产业应用链接,围绕“人工智能与新型计算”构建开放共享的开发者生态。

更多推荐