Shell实验

完成一个简易的Shell程序

感谢之一Yo大佬提供的思路!

实验目的

更加熟悉进程控制和信号的概念。你将通过编写一个简单的Unix shell程序来实现这一目标,实现对作业的控制。

Shell介绍

详细介绍(太长不看)

Shell是一个交互式命令行解释器,代表用户运行程序。Shell重复地打印提示符,等待stdin上输入的命令行,然后根据命令行的内容执行一些操作。

命令行是一系列由空白分隔的ASCII文本单词。命令行中的第一个单词要么是内置命令的名称,要么是可执行文件的路径名。其余的单词是命令行参数。如果第一个单词是内置命令,Shell立即在当前进程中执行该命令。否则,该单词被假定为可执行程序的路径名。在这种情况下,Shell创建一个子进程,然后在子进程的上下文中加载并运行程序。作为解释单个命令行的结果创建的子进程总称为作业。一般来说,一个作业可以由通过Unix管道连接的多个子进程组成。

如果命令行以一个 “&” 结尾,那么作业在后台运行,这意味着Shell在打印提示并等待下一个命令行之前不等待作业终止。否则,作业在前台运行,这意味着Shell等待作业终止后才等待下一个命令行。因此,在任何时候,最多只能有一个作业在前台运行。但是,任意数量的作业可以在后台运行。

本篇博客将会详细介绍 CSAPP 的 ShellLab 完成过程,实现一个简易(lou)的 shell。tsh 拥有以下功能:

  1. 可以执行外部程序
  2. 支持四个内建命令,名称和功能为:
名称 功能
quit 退出终端
jobs 列出所有后台作业
bg <job> 继续在后台运行一个处于停止状态的后台作业,<job> 可以是 PID 或者 %JID 形式
fg <job> 将一个处于运行或者停止状态的后台作业转移到前台继续运行
按下 Ctrl + C 终止前台作业
按下 Ctrl + Z 停止前台作业

实验材料中已经写好了一些函数,只要求我们实现下列核心函数:

名称 功能
eval 解析并执行指令
builtin_cmd 识别并执行内建指令
do_bgfg 执行 fgbg 指令
waitfg 阻塞终端直至前台任务完成
sigchld_handler 捕获 SIGCHLD 信号
sigint_handler 捕获 SIGINT 信号
sigtstp_handler 捕获 SIGTSTP 信号

函数实现

eval函数

在《深入理解计算机系统》第525页给出了eval函数的核心代码:

eval函数

但是在 Shell 实验中,还应当满足以下要求:

  1. 父进程在fork子进程之前必须使用sigprocmask阻塞SIGCHLD信号,然后在通过调用addjob将子进程添加到作业列表后,再次使用sigprocmask解除对这些信号的阻塞。
  2. 由于子进程继承了其父进程的阻塞向量,子进程必须确保在执行新程序之前解除对SIGCHLD信号的阻塞。父进程需要以这种方式阻塞SIGCHLD信号,以避免在父进程调用addjob之前,子进程被sigchld_handler收回(因此从作业列表中删除)的竞争条件。
  3. fork之后但在execve之前,子进程应调用setpgid(0, 0),将子进程放入一个新的进程组,其组ID与子进程的PID相同。这确保在前台进程组中只有一个进程,即shell。

setpgid函数将进程pid的进程组改为pgid。如果pid是 0, 那么就使用当前进程的PID。 如果pgid是 0,那么就用pid指定的进程的PID作为进程组 ID。

例如,如果进程 15213 是调用进程,那么setpgid(0, 0)会创建一个新的进程组,其进程组 ID 是 15213,并且把进程 15213 加人到这个新的进程 组中。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
void eval(char *cmdline) 
{
    char* argv[MAXARGS];    // 命令参数
    char buf[MAXLINE];      // 保存命令行
    int bg;                 // 判断 job 运行在 bg 还是 fg
    pid_t pid;

    sigset_t mask_all, mask_one, prev_mask;
    sigfillset(&mask_all);
    sigemptyset(&mask_one);
    sigaddset(&mask_one, SIGCHLD);

    strcpy(buf, cmdline);
    bg = parseline(cmdline, argv);

    // 忽略空指令
    if (argv[0] == NULL)
        return;

    if (!builtin_cmd(argv)) {
        // 创建子进程前阻塞SIGCHLD信号
        sigprocmask(SIG_BLOCK, &mask_one, &prev_mask);
        if ((pid = Fork()) == 0) {
            // 子进程必须确保在执行新程序之前解除对SIGCHLD信号的阻塞
            sigprocmask(SIG_SETMASK, &prev_mask, NULL);
            // 将子进程放入一个新的进程组
            setpgid(0, 0);
            if (execve(argv[0], argv, environ) < 0) {
                printf("%s: Command not found.\n", argv[0]);
                exit(0);
            }
        }

        /*
          父进程需要阻塞SIGCHLD信号,以避免在父进程调用addjob之前,
          子进程被sigchld_handler收回
        */
        sigprocmask(SIG_BLOCK, &mask_one, NULL);
        // 将子进程添加到作业列表
        addjob(jobs, pid, bg ? BG : FG, cmdline);
        

        if (!bg) {
            waitfg(pid);
        } else {
            printf("[%d] (%d) %s", pid2jid(pid), pid, cmdline);
        }

        // 再次使用sigprocmask解除对这些信号的阻塞
        sigprocmask(SIG_SETMASK, &prev_mask, NULL);
    }
    
    return;
}

其中Fork()函数定义可以在《深入理解计算机系统》第512页找到:

Fork函数

builtin_cmd函数

在《深入理解计算机系统》第525页同样给出了builtin_cmd函数的核心代码:

builtin_cmd函数

Shell 程序要实现的builtin_cmd比这个要多一些分支语句,主要用来判断quitjobsbgfg命令。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
int builtin_cmd(char **argv) 
{
    // 识别和解释内置命令:quit、fg、bg和jobs
    // 指令 tsh > quit 
    if(!strcmp(argv[0], "quit")) {
        exit(0);
    }
    // 指令 tsh > fg 和 tsh > bg
    else if(!strcmp(argv[0], "fg") || !strcmp(argv[0], "bg")) {
        do_bgfg(argv);
        return 1;
    }
    // 指令 tsh > jobs
    else if(!strcmp(argv[0], "jobs")) {
        listjobs(jobs);
        return 1;
    }
    else if(!strcmp(argv[0], "&")) {
        return 1;
    }
    
    return 0;     /* not a builtin command */
}

waitfg函数

阻塞终端直至前台任务完成意味着主程序需要显式地等待某个信号处理程序运行,一种合适的解决方法是使用sigsuspend函数。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
void waitfg(pid_t pid)
{
   // 等待前台作业完成
    sigset_t mask;
    sigemptyset(&mask);

    // sigsuspend函数提高执行效率
    while(fgpid(jobs)) {
        sigsuspend(&mask);
    }
    return;
}

还有一种思路是使用sleep函数。

1
2
3
while(!pid) {
    sleep(1);
}

当这段代码正确执行时,它太慢了。如果在while之后pause之前收到信号,程序必须等相当长的一段时间才会再次检查循环的终止条件。使用像 nanosleep这样更髙精度的休眠函数也是不可接受的,因为没有很好的方法来确定休眠的间隔。间隔太小,循环会太浪费;间隔太大,程序又会太慢。

do_bgfg函数

builtin_cmd中最重要的就是do_bgfg函数,负责作业的状态转换。

首先获取任务的PID,若为空则报错;否则发送SIGCONT信号给进程组中的每一个进程。

为了做到将SIGCONT信号发送给进程组中的每一个进程,需要将kill函数的pid参数取负值,不然就只发给指定的进程了。具体可参见kill函数在《深入理解计算机系统》第530页的定义:

kill函数的定义位于《深入理解计算机系统》第530页:

kill函数

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
void do_bgfg(char **argv) 
{
    char* cmd = argv[0];
    char* id = argv[1];
    struct job_t* job;

    if (id == NULL) {
        printf("%s command requires PID or %%jobid argument\n", cmd);
        return;
    }

    // 根据 jid/pid 获取作业
    if (id[0] == '%') {
        if ((job = getjobjid(jobs, atoi(id + 1))) == NULL) {
            printf("%s: No such job\n", id);
            return;
        }
    } 
    else if (atoi(id) > 0) {
        if ((job = getjobpid(jobs, atoi(id))) == NULL) {
            printf("(%d): No such process\n", atoi(id));
            return;
        }
    } 
    else {
        printf("%s: argument must be a PID or %%jobid\n", cmd);
        return;
    }

    // 状态转移
    if (!strcmp(cmd, "fg")) {
        job->state = FG;
        kill(-job->pid, SIGCONT);
        waitfg(job->pid);
    } 
    else if (!strcmp(cmd, "bg")) {
        job->state = BG;
        kill(-job->pid, SIGCONT);
        printf("[%d] (%d) %s", job->jid, job->pid, job->cmdline);
    }
    return;
}

信号处理

  1. sigchld_handler函数
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
void sigchld_handler(int sig) 
{
    int old_errno = errno;
    pid_t pid;
    int status;
    sigset_t mask_all, prev_mask;
    sigfillset(&mask_all);

    while ((pid = waitpid(-1, &status, WNOHANG | WUNTRACED)) > 0) {
        // 终止作业
        if (WIFEXITED(status) || WIFSIGNALED(status)) {
            sigprocmask(SIG_BLOCK, &mask_all, &prev_mask);

            // ctrl-c 终止
            if (WIFSIGNALED(status)) {
                printf("Job [%d] (%d) terminated by signal 2\n", pid2jid(pid), pid);
            }

            deletejob(jobs, pid);
            sigprocmask(SIG_SETMASK, &prev_mask, NULL);
        }
        // 停止作业
        else if (WIFSTOPPED(status)) {
            sigprocmask(SIG_BLOCK, &mask_all, &prev_mask);

            struct job_t* job = getjobpid(jobs, pid);
            job->state = ST;
            printf("Job [%d] (%d) stopped by signal 20\n", job->jid, job->pid);

            sigprocmask(SIG_SETMASK, &prev_mask, NULL);
        }
    }

    errno = old_errno;
    return;
}
  1. 处理Ctrl + C
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
void sigint_handler(int sig) 
{
    // 获取SIGINT(ctrl-c)信号
    int olderrno = errno;

    // 获取当前正在前台的任务的PID
    pid_t pid = fgpid(jobs);
    // 判断任务是否存在
    if(pid > 0) {
        // -pid表示进程组 |pid| 的所有进程
        kill(-pid, SIGKILL);
    }
    errno = olderrno;
    return;
}
  1. 处理Ctrl + Z
    同理可得:
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
void sigtstp_handler(int sig) 
{
     // 获取SIGTSTP(ctrl-z)信号
    int olderrno = errno;

    pid_t pid = fgpid(jobs);
    if(pid > 0) {
        kill(-pid, SIGTSTP);
    }
    errno = olderrno;
    return;
}

总结

通过这次实验,我加深了对进程控制和信号处理的理解,同时对于并发现象有了更直观的认识。即使是一个简易的Shell程序,也需要考虑任务的排队、信号阻塞等问题,要在心中有整体设计观念。同时在完成实验的过程中我大量参考了书本内容,也提醒我在今后的学习和实验中要注重书本的工具作用。

另外,在评测平台测试时,第一次测试时由于在Command not found后多加了一个句点而导致判错。这启示我在编程时应当小心谨慎,认真阅读要求,并且仔细检查,方能提高正确率和效率。

网站总访客数:Loading

使用 Hugo 构建
主题 StackJimmy 设计