• [lab]csappshell lab


    Shell lab

    该 lab, 要求我们利用 fork + exec 原理, 拓展课本上的示例, 完成简单的 tsh, 它包含

    • 子进程命令执行
    • job 管理
    • 信号控制

    更具体的, 我们要实现

    • eval: 解析/执行命令的主程序.
    • builtin cmd: 识别,执行内置命令 quit, fg, bg, and jobs.
    • do bgfg: 实现内置命令 bgfg.
    • waitfg: 等待前台job(作业)完成.
    • sigchld handler: 接受处理 SIGCHILD 信号.
    • sigint handler: 接受处理 SIGINT (ctrl-c) 信号.
    • sigtstp handler: 接受处理 `SIGTSTP (ctrl-z) 信号.

    首先要对 Unix Shells 有个总体的概览(翻译原文).

    Unix Shell

    shell 就是一个交互式的命令行解释器, 以用户权限执行程序. 它打印提示符, 等待 stdin 输入的命令行, 然后执行一些由输入制定的操作.

    命令行是一串由空格分割的 ASCII 字符串. 第一个字符串指定 shell 内置指令或带路径名的可执行文件. 剩下的字符串都是它的参数. 如果是内置指令, shell 直接在当前进程执行对应逻辑, 否则 fork 出一个子进程,然后在子进程上下文中加载执行给定程序文件. 子进程就代表了一个 job, 它是被执行一个命令行. 通常, 一个 job 可以包含多个由 Unix 管道连接的子进程.

    如果命令行以 & 结尾, 那么 job 就会在后台执行, 即 shell 不会主动等待 job 执行完毕, 就开始打印提示符, 接受下一条命令行. 否则 job 就是在前台运行, 即 shell 会等待当前 job 执行完后再接受下一条命令行. 因此, 在任意时刻, 最多有一个前台 job, 但可以有任意数量的后台 job.

    Unix shells 支持作业管理(job control)的概念, 即允许用户将 job 在前后台间切换, 然后切换 job 的中进程的状态(running ,stopped, terminated). 输入ctrl-c会向前台 job 的所有进程发送一条SIGINT信号, 该信号的默认行为是终止该进程. 相同的, 输入ctrl-z会向前台 job 的所有进程发送一条SIGTSTP信号, 该信号的默认行为是将进程转入停止状态, 后续需要 SIGCONT 信号来恢复执行. Unix shells 还需要提供一些内置命令来支持作业控制, 如:

    • jobs 列出属于当前 shell 的 job.
    • bg 将一个停止的后台 job 转为运行.
    • fg 将一个暂停或停止的后台 job 转入前台运行.
    • kill 终止 job

    测试与实现

    sdriver 是测试驱动程序, 自带了16个用例, 使用 make test<i> 运行, 并给出tshref 完成的示例, 使用make rtest<i>对照运行.
    由于每次运行进程号会变化, 输出结果只能手动对比, 我们就按这16个用例来一步步实现.

    主函数

    在进入解析命令行的循环前, 初始化工作要注册新号处理函数, job 管理是通过 job_t 结构体全局数组, 并提供了一些辅助函数.

    • addjob
    • deletejob
    • fgpid
    • getjobpid
    • getjobjid
    • pid2jid
    • listjobs

    首先我们要完成eval函数, 它接受命令行作为参数, 进行一次处理, 我们先忽略信号处理, 根据注释, 写出如下代码

    void eval(char *cmdline) 
    {
      char* argv[MAXARGS];
      pid_t pid;
      int jid;
    
      int bg = parseline(cmdline, argv);
      if (argv[0] == NULL || builtin_cmd(argv)) {
        return;
      }
    
      if ((pid = Fork()) == 0) {
        // argv 指向的数据是 parseline 里的静态变量, 我们不需要再去清理, 但感觉会有生命周期问题.
        Execve(argv[0], argv, environ);
      }
    
      jid = nextjid;
      addjob(jobs, pid, bg ? BG : FG. cmdline);
      if (bg) {
        // print ...
      } else {
        waitfg(pid);
      }
    }
    

    这里我偷懒直接对unix函数用了csapp封装版, parseline 是 lab 已经为我们写好的, 那下一步是 buildin_cmd 和 waitfg.

    int builtin_cmd(char **argv) 
    {
        // quit, jobs, bg or fg
        static const char* quit_str = "quit"; 
        static const char* jobs_str = "jobs"; 
        static const char* bg_str = "bg"; 
        static const char* fg_str = "fg"; 
        if (strcmp(argv[0], quit_str) == 0) {
            exit(0);
        } else if (strcmp(argv[0], jobs_str) == 0) {
            listjobs(jobs);
        } else if (strcmp(argv[0], bg_str) == 0 || strcmp(argv[0], fg_str) == 0) {
            // TODO
            do_bgfg(argv);
        } else {
            return 0;     /* not a builtin command */
        }
        return 1;
    }
    
    void waitfg(pid_t pid)
    {
        // 简单的思路就是忙等待 pid
        // 然后把 pid 从jobs里删掉
        // 但考虑作业管理后就是有问题的, 后面再调整.
        while (waitpid(pid, NULL, 0) <= 0) {
        }
        deletejob(jobs, pid);
        return;
    }
    

    ok, 这样写大概能通过前5个样例, 现在我们还没有考虑信号管理和作业控制, 只是简单的把 fork -> exec -> waitpid 这一套流程打通了. 然后我们先处理信号函数

    void sigint_handler(int sig) 
    {
        pid_t pid = fgpid(jobs);
        if (pid < 1) {
            return;
        }
    
        Kill(pid, SIGINT);
        struct job_t* job = getjobpid(jobs, pid);
        printf("Job [%d] (%d) terminated by signal %d\n", job->jid, job->pid, sig);
        deletejob(jobs, pid);
        return;
    }
    
    void sigtstp_handler(int sig) 
    {
        pid_t pid = fgpid(jobs);
        if (pid < 1) {
            return;
        }
        Kill(pid, SIGTSTP);
        struct job_t* job = getjobpid(jobs, pid);
        job->state = ST;
        printf("Job [%d] (%d) stoped by signal %d\n", job->jid, job->pid, sig);
        return;
    }
    

    INT/TSTP 信号都是发给前台 job 的, 先找到它对应的 pid, 发送对应信号, 然后修改 job 对应状态.

    然后是前后台作业切换

    void do_bgfg(char **argv) 
    {
        if (argv[1] == NULL) {
            // print ... 
            return;
        }
        struct job_t* job;
        int jid = -1, pid = -1;
        if (argv[1][0] == '%') {
            // job id
            jid = atoi(argv[1]+1);
            job = getjobjid(jobs, jid);
        } else {
            // process id
            pid = atoi(argv[1]);
            job = getjobpid(jobs, pid);
        }
        if (pid == 0 || jid == 0) {
            // print ...
            return;
        }
        int bg = (strcmp(argv[0], "bg") == 0);
        if (job == NULL) {
            // print ...
            return;
        }
        // 无论是前台还是后台, 都要发送 SIGCONT 信号
        Kill(job->pid, SIGCONT);
        job->state = bg ? BG : FG;
        if (bg) {
            // print ...
        } else {
            // 转入前台后, 要等待新的前台 job.
            waitfg(job->pid);
        }
        return;
    }
    

    看起来没什么问题, 要求的函数也都实现了, 一跑测试发现, 06都过不了, 这时候可以参照一下提示,

    1. waitfg 的实现是忙等待, 控制权回不到 driver 上, 因此要改成 sleep
    2. kill 信号的发送对象, 不能只是pid, 考虑子进程B又创建了新的子进程C, C是接受不到信号的. 可以将子进程的父进程设置为0号进程, 这样子进程就不属于当前进程的进程组了, 通过 -pid 发信号就可以发送到子进程及后续的进程上.
    3. 信号屏蔽, 参照书上信号屏蔽的例子,
    sigset_t mask_all, mask_one, prev_one;
    int main()
    {
      //... 
    
      Sigfillset(&mask_all);
      Sigemptyset(&mask_one);
      Sigaddset(&mask_one, SIGCHLD);
      // Signal
    }
    
    void exec(char* cmdline) 
    {
      // 这里应该是防止 fork 完了, 子进程直接执行完成, 导致错过信号.
      Sigprocmask(SIG_BLOCK, &mask_one, &prev_one);
      if ((pid = Fork()) == 0) {
          Setpgid(0, 0);
          Sigprocmask(SIG_SETMASK, &prev_one, NULL);
          if (execve(argv[0], argv, environ) < 0) {
              printf("%s: Command not found\n", argv[0]);
              exit(1);
          }
      } 
      jid = nextjid;
      // addjob 前屏蔽所有信号, 保证函数不会被中断
      Sigprocmask(SIG_BLOCK, &mask_all, NULL);
      addjob(jobs, pid, bg ? BG : FG, cmdline);
      // 恢复信号接收
      Sigprocmask(SIG_SETMASK, &prev_one, NULL);
    }
    

    最后是sigchld_handler, 当子进程终止/停止时会触发. 因此这个函数也需要 waitpid 来获取状态发生转变的 pid 和状态, 但这样会和 waitfg 冲突, 因此我们不在 waitfg 做 waitpid, 而是使用原子变量同步.

    
    volatile sig_atomic_t  child_pid;
    void waitfg(pid_t pid)
    {
        while (child_pid != pid) {
            sleep(1);
        }
        return;
    }
    
    void sigchld_handler(int sig) 
    {
        pid_t pid;
        int stat;
        // 设置原子变量, 且不污染 errno.
        int olderrno = errno;
        child_pid = waitpid(-1, &stat, WNOHANG | WUNTRACED);
        errno = olderrno;
        pid = child_pid;
    
        if (pid != 0) {
            // 返回一个有效的 pid, 根据状态做操作.
            // 注意把 sigint_handler sigtstp_handler 中的作业管理也转移到这里了. 不这样做会在 16 出错
            if (WIFEXITED(stat)) {
                deletejob(jobs, pid);
            } else if (WIFSIGNALED(stat)) {
                struct job_t* job = getjobpid(jobs, pid);
                printf("Job [%d] (%d) terminated by signal %d\n", job->jid, job->pid, WTERMSIG(stat));
                deletejob(jobs, pid);
            } else if (WIFSTOPPED(stat)) {
                struct job_t* job = getjobpid(jobs, pid);
                 printf("Job [%d] (%d) stopped by signal %d\n", job->jid, job->pid, WSTOPSIG(stat));
                job->state = ST;
            }
        }
        return;
    }
    

    以上就是我的解题思路, 但参照了博客, 发现自己还是有遗漏的地方 sigchld_handler , 需要循环 waitpid , 否则会遗漏, 作业控制方面也需要屏蔽信号, 否则如果删除作业时被中断切换走, 作业列表可能已经发生了变化(一个进程先停止再终止).

    总结:

    这个lab 包含了 fork 执行, 作业控制, 信号控制, 这些 shell 的基本内容和原理, 比较容易出错的就是信号控制, 要清楚它的本质是个异步模型, 不能再用单线程顺序执行的逻辑.

  • 相关阅读:
    windows下Mysql免安装版,修改my_default.ini配置文件无效的解决办法
    下压桟(LIFO)
    Dijkstra的双栈算术表达式求值算法
    获取中文的完整拼音并输出
    解析一个文件夹所有文件的中文,并输出到某一文本文档中
    在含有中英文字符串的信息中,提出中文的方法
    创建计算字段
    Docker 常用命令
    mqtt常用命令及配置
    LOG4J
  • 原文地址:https://www.cnblogs.com/xxrlz/p/16110451.html
Copyright © 2020-2023  润新知