跳过正文

Shell Lab

·3206 字
笔记 计算机系统 实验

1. 前言
#

实验目的: 实现一个简易的 shell 程序,功能包括:

  • 执行外部程序
  • 支持四个内建命令:
    • quit:退出终端
    • jobs:列出所有后台作业
    • bg <job>:继续在后台运行一个处于停止状态的后台作业
    • fg <job>:将后台作业转移到前台继续运行
  • 支持以下快捷键:
    • Ctrl + C:终止前台作业
    • Ctrl + Z:暂停前台作业

需要的前置知识:

  • 进程:
    • 进程的概念
    • 进程如何抽象为逻辑控制流
    • 多个逻辑控制流的并发性
  • 进程控制:
    • 获取进程 ID
    • 创建和终止进程
    • 回收子进程
    • 加载并运行程序
  • 信号:
    • 信号的发送与接收
    • 信号的阻塞

准备工作:

  1. 解压数据包:tar xvf shlab-handout.tar
  2. 阅读 shelab-overview.pdf

实验环境:
WSL2 (Ubuntu 20.04)


2. 代码实现
#

2.1 eval
#

定义: void eval(char *cmdline);

功能: 分析用户输入的命令并执行。

实现:

void eval(char *cmdline) {
    char *argv[MAXARGS]; /* argument list execve() */
    sigset_t mask;       /* signal mask to block signals */
    pid_t pid;           /* process id */

    int bg = parseline(cmdline, argv);
    if (argv[0] == NULL) /* Ignore empty lines */
        return;
    if (builtin_cmd(argv)) /* Execute built-in commands immediately */
        return;

    sigemptyset(&mask);
    sigaddset(&mask, SIGCHLD);
    sigprocmask(SIG_BLOCK, &mask, NULL); /* Block SIGCHLD */

    if ((pid = fork()) == 0) {                    /* Child process */
        setpgid(0, 0);                            /* Set the child process group ID */
        sigprocmask(SIG_UNBLOCK, &mask, NULL);    /* Unblock SIGCHLD */
        if (execve(argv[0], argv, environ) < 0) { /* Execute the command */
            printf("%s: Command not found\n", argv[0]);
            exit(0);
        }
    }

    /* Parent process */
    addjob(jobs, pid, bg ? BG : FG, cmdline);
    if (!bg)         /* Foreground job */
        waitfg(pid); /* Wait for it to terminate */
    else             /* Background job */
        printf("[%d] (%d) %s", pid2jid(pid), pid, cmdline);
    sigprocmask(SIG_UNBLOCK, &mask, NULL); /* Unblock SIGCHLD */
}

说明:

  • 屏蔽信号: 在 fork 前阻塞 SIGCHLD 信号,避免竞态条件:
    1. 主进程可能在调用 addjob 前切换到子进程。
    2. 子进程可能快速结束并发送 SIGCHLD 信号。
    3. 主进程调用 sigchld_handler 删除作业后,addjob 会因作业已被删除而导致混乱。
  • 设置进程组: 在 execve 前调用 setpgid(0, 0)
    1. 子进程成为新进程组的组长(进程组 ID 等于其 PID),确保信号正确转发。
    2. 支持前台进程创建子进程,形成进程树。
    3. 通过进程组 ID 向前台作业发送信号,可一次性覆盖所有相关进程。

2.2 builtin_cmd
#

定义: int builtin_cmd(char **argv);

功能: 执行内置命令。

实现:

int builtin_cmd(char **argv) {
    if (!strcmp(argv[0], "quit"))
        exit(0);
    if (!strcmp(argv[0], "jobs")) {
        listjobs(jobs);
        return 1;
    }
    if (!strcmp(argv[0], "bg") || !strcmp(argv[0], "fg")) {
        do_bgfg(argv);
        return 1;
    }
    return 0; /* Not a built-in command */
}

2.3 do_bgfg
#

定义: void do_bgfg(char **argv);

功能: 执行 bgfg 命令。

实现:

void do_bgfg(char **argv) {
    if (argv[1] == NULL) { /* No argument provided */
        printf("%s command requires PID or %%jobid argument\n", argv[0]);
        return;
    }

    struct job_t *job;
    pid_t pid;
    int jid;

    if (argv[1][0] == '%') { /* Job ID provided */
        if ((jid = strtol(argv[1] + 1, NULL, 10)) == 0) {
            printf("%s: argument must be a PID or %%jobid\n", argv[0]);
            return;
        }
        if ((job = getjobjid(jobs, jid)) == NULL) {
            printf("%s: No such job\n", argv[1]);
            return;
        }
    } else { /* PID provided */
        if ((pid = strtol(argv[1], NULL, 10)) == 0) {
            printf("%s: argument must be a PID or %%jobid\n", argv[0]);
            return;
        }
        if ((job = getjobpid(jobs, pid)) == NULL) {
            printf("(%s): No such process\n", argv[1]);
            return;
        }
    }

    if (!strcmp(argv[0], "bg")) { /* bg command */
        job->state = BG;
        kill(-job->pid, SIGCONT);
        printf("[%d] (%d) %s", job->jid, job->pid, job->cmdline);
    } else { /* fg command */
        job->state = FG;
        kill(-job->pid, SIGCONT);
        waitfg(job->pid);
    }
}

说明:
由于发送信号时需要发送给当前job对应的所有进程, 因此在 kill 函数中使用负 PID 值将信号发送给整个进程组


2.4 waitfg
#

定义: void waitfg(pid_t pid);

功能: 阻塞直到前台作业完成。

实现:

void waitfg(pid_t pid) {
    sigset_t mask;
    sigemptyset(&mask);
    while (pid == fgpid(jobs))
        sigsuspend(&mask); /* Wait for foreground job to terminate */
}

说明:

  1. 为什么不使用简单的自旋:
    • 自旋 while (pid == fgpid(jobs)); 会导致 CPU 占用过高
    • 不断检查条件的循环会浪费处理器资源
    • 在多任务系统中表现不佳,影响其他进程的性能
  2. sleep 函数的局限:
    • 虽然 sleep 可以减少 CPU 占用
    • 但难以确定最佳睡眠时间:太短仍会浪费资源,太长则会导致响应延迟
    • 无法准确响应子进程状态变化
  3. pause 函数的风险:
    • pause 会挂起进程直到收到信号
    • 存在竞态条件:如果在检查条件和调用 pause 之间信号已到达, 将导致 pause 永久阻塞(信号已被处理但进程仍在等待)
  4. sigsuspend 的优势:
    • 提供原子操作:在一个不可中断的步骤中同时解除信号屏蔽并挂起进程
    • 避免了 pause 的竞态条件问题
    • 等价于原子执行以下三个操作:
      sigprocmask(SIG_SETMASK, &mask, &prev); // 临时设置信号掩码
      pause();                                // 等待信号
      sigprocmask(SIG_SETMASK, &prev, NULL);  // 恢复原信号掩码
      
    • 确保高效等待且不会错过信号或产生死锁

2.5 sigchld_handler
#

定义: void sigchld_handler(int sig);

功能: 处理 SIGCHLD 信号。

实现:

void sigchld_handler(int sig) {
    struct job_t *job;
    int status;
    pid_t pid;

    while ((pid = waitpid(-1, &status, WNOHANG | WUNTRACED)) > 0) {
        job = getjobpid(jobs, pid);

        if (WIFSTOPPED(status)) { /* Child stopped by signal */
            job->state = ST;
            printf("Job [%d] (%d) stopped by signal %d\n", job->jid, pid, WSTOPSIG(status));
            continue;
        }
        if (WIFSIGNALED(status)) /* Child terminated by signal */
            printf("Job [%d] (%d) terminated by signal %d\n", job->jid, pid, WTERMSIG(status));

        deletejob(jobs, pid);
    }
}

说明:

  • 为何使用 while 循环回收子进程:
    1. Linux 系统中,信号是不排队的。由于信号的传递仅仅是将对应的位置为1,因此如果在 sigchld_handler 执行期间,有多个子进程终止或停止,SIGCHLD 信号将会被丢弃而不是排队。
    2. 因此,信号处理函数需要循环调用 waitpid,以确保所有已改变状态 (终止或停止) 的子进程都被正确处理和回收。
  • 为何使用 WNOHANG | WUNTRACED:
    • WNOHANG: 此选项使 waitpid 成为非阻塞调用。
      • 如果当前没有子进程终止或停止,waitpid 会立即返回 0,而不是挂起等待。
      • while 循环的条件 (pid = waitpid(...)) > 0 会在没有更多子进程需要处理时终止循环。
    • WUNTRACED: 此选项使得 waitpid 不仅在子进程终止时返回,在子进程被信号停止 (例如 SIGTSTP, SIGSTOP) 时也会返回。
      • Shell 需要跟踪作业的状态,包括它们是被终止了还是仅仅被停止了。通过 WUNTRACED,处理函数可以捕获到子进程的停止事件,并相应地更新作业状态 (如代码中的 job->state = ST;)。

2.6 sigint_handler
#

定义: void sigint_handler(int sig);

功能: 转发 SIGINT 信号给前台进程。

实现:

void sigint_handler(int sig) {
    pid_t pid = fgpid(jobs); /* Get foreground job PID */
    if (pid != 0)
        kill(-pid, SIGINT); /* Send SIGINT to foreground job */
}

2.7 sigtstp_handler
#

定义: void sigtstp_handler(int sig);

功能: 转发 SIGTSTP 信号给前台进程。

实现:

void sigtstp_handler(int sig) {
    pid_t pid = fgpid(jobs); /* Get foreground job PID */
    if (pid != 0)
        kill(-pid, SIGTSTP); /* Send SIGTSTP to foreground job */
}

3. 测试
#

3.1 单个测试
#

  1. 每次更改tsh.c后, 使用make编译新的可执行文件
  2. 使用make test*查看测试样例的输出结果
  3. 使用make rtest*查看参考样例的输出结果, 并与测试样例对比

查看Makefile文件, 可以看出执行make test01时, 实际上执行了以下命令:

./sdriver.pl -t trace01.txt -s ./tsh -a -p

即使用提供的perl脚本, 在./tsh中执行trace01.txt中的命令, 并读取输出

为什么要使用Makefile?

考虑以下场景: 你正在维护一个多文件的工程, 对某些文件的代码进行了改动. 如果没有Makefile, 你每次都需要需要手动对每个文件重新输入数个参数的编译命令, 然后手动对目标文件进行链接, 这通常是无法接受的.


3.2 全部测试
#

  1. Makefile 中添加:
test-all: test01 ... test16
rtest-all: rtest01 ... rtest16
  1. 执行以下命令:
make test-all > test_output.txt
make rtest-all > rtest_output.txt
  1. 在vscode中右键第一个文件, 选择select for compare; 再右键第二个文件, 选择compare with selected, 即可对比全部输出结果.

4. 总结
#

从整体的角度来看, shell的核心逻辑是十分简单的, 无非就是先fork, 后exec, 外加处理一些内置命令. 下方的伪代码展示了一个最简单的shell:

while true:
    command = read()
    if command == "quit":
        break

    pid = fork()
    if pid == 0:
        exec(command)
    else:
        wait(pid)

但是实际的代码要更加复杂一些. 事实上, 所有的代码都是源于功能的需要而产生的, 因此我们可以从需求的角度来回顾代码.


4.1 任务
#

本质上, 引入job的目的在于我们希望shell能够同时管理多个进程, 而不是只运行一个进程.

  1. 首先, 我们需要保存每个进程的pid
  2. 为了便于用户操作和shell内部管理, 我们再为每个任务分配一个jid
  3. 此时一个shell可以管理多个进程, 但是我们一般希望将需要频繁交互的任务放在前台, 而需要长时间运行的任务需要处于后台, 有时我们还会挂起某些任务, 因此还需要维护每个任务的state
  4. 最后, 为了方便调试, 我们为每个任务保存运行它的指令cmdline

这样我们就得到了tsh.c中的job_t结构:

struct job_t {             /* The job struct */
    pid_t pid;             /* job PID */
    int jid;               /* job ID [1, 2, ...] */
    int state;             /* UNDEF, BG, FG, or ST */
    char cmdline[MAXLINE]; /* command line */
};
struct job_t jobs[MAXJOBS]; /* The job list */

代码实现:

  1. eval中加入addjob
  2. builtin_cmd中支持jobs命令
  3. do_bgfg中能够修改任务的状态
  4. waitfg中能够获取pid是否为前台任务
  5. sigchld_handler时对于暂停的信号修改对应任务状态
  6. sigint_handlersigtstp_handler中要将信号发给前台任务

4.2 信号
#

信号的引入可能是更加必要且有用的. 有以下原因

  1. 管理多个进程时, 我们不能对每个进程都显式地使用wait, 尤其因为后台进程是非阻塞的, 我们不知道其何时会完成. 因此我们需要通过其完成时发送的SIGCHLD进行回收
  2. 有时我们会希望暂停或是终止某个任务, 或者将某个挂起的任务继续运行, 这些都需要通过信号来实现

代码实现

  1. eval中为防止与job的竞态, 需要阻塞信号
  2. do_bgfg时发送SIGCONT信号
  3. waitfg中使用sigsuspend来等待前台进程
  4. 增加三个对应的sig_handler

4.3 进程组
#

引入进程组的原因与信号的发送密切相关.
在发送信号时, shell需要正确的转发信号. 在linux中, 信号的发送是通过进程组实现的. 如果不设置进程组, 子进程会默认与父进程同属一个进程组, 因此需要为每个新进程单独设立进程组.

代码实现

  1. eval中在execsetpgid
  2. 使用kill发送信号时pid参数需要取负值
TsukasaYatoki
作者
TsukasaYatoki
仍然向往着那片青空