MIT6.S081/6.828 实验2:Lab Shell

Mit6.828/6.S081 fall 2019的Lab2是Simple Shell,内容是实现一个简易的shell程序,本文对该实验的思路进行详细介绍,并对xv6提供的shell实现进行深刻解析。html

准备

首先来看实验要求git

  1. 实现的shell要支持 基础命令执行、重定向 (< >) 处理、管道 ( | ) 处理
  2. 不能使用malloc()动态分配内存
  3. 使用"@"代替"$"做为命令行的提示符
  4. 及时关闭文件描述符;对系统调用的异常进行处理

xv6中提供有sh.c的实现,除了重定向和管道,还对括号、列表命令、后台命令等作了支持,且总体设计较为复杂。因此咱们无需过多参考已有代码,能够选择简单的思路来知足需求,在完成后再去阅读xv6的shell实现。github

Shell本质上是一个用户程序,在用户和操做系统间创建链接。工做原理是在启动后不断接收并解析用户输入的命令,调用操做系统接口去执行命令,并把结果返回给用户。Shell运行于用户态而非内核态的好处是能够和内核彻底解耦,实现可插拔的效果,所以你能够在bash、zsh、ksh等不一样shell间轻松完成切换。shell

实验思路

下面介绍实验的总体思路,完整代码在 Github 中,并附有详细注释。数组

首先须要了解几个核心的系统调用:bash

  • fork() : 该调用会建立一个子进程,会复制一分内存到独立的进程空间,代码中根据返回值来区分是子进程 (返回0) 仍是父进程 (返回子进程的pid)。shell中会对输入的命令fork出子进程去执行,除了cd命令,由于须要修改主进程的当前路径。
  • wait():该方法会阻塞父进程,等待子进程退出后再结束,注意若是fork()了多个子进程,则须要屡次调用wait()才能等待全部子进程完成。且wait()是没法等待孙子进程的。
  • exec(char * path, char **argv):该方法会执行一个指定的命令,会将新的可执行文件加载到内存中执行,替换当前的进程空间。原程序中exec()后面的代码不会再被执行,这也是shell中须要fork进程去exec命令的缘由,否则就没法继续处理一条命令了。

主体逻辑

程序的主逻辑是在 main()方法中循环接收标准输入,fork() 出子进程进行处理,首先将接收到字符串分割为字符串数组方便处理,而后进入命令解析和执行。函数

int main(void) {
  char buf[MAXLEN];             // 用于接收命令的字符串
  char *argv[MAXARGS];          // 字符串数组(指针数组)
  int argc;                     // 参数个数

  while (getcmd(buf, sizeof(buf)) >= 0) {
    if (fork() == 0) {
      argc = split(buf, argv);  // 根据空格分割为字符串数组
      runcmd(argv, argc);       // 解析并执行命令
    }
    wait(0);                    // 等待子进程退出
  }
  exit(0);
}

getcmd() 实现较简单,基于 gets() 函数来接收标准输入,直接参考sh.c便可。直接来看处理输入命令的 split() 函数,做用是将接收到的命令根据空格分割为参数数组,方便后续解析和执行。思路是直接在源字符串上进行分割,将每一个参数的首地址收集到指针数组中,并在在末尾设置空字符"\0"进行截取,最终得到参数字符串数组。工具

int split(char * cmd, char ** argv) {
  int i = 0, j = 0, len = 0;

  len = strlen(cmd);
  while (i < len && cmd[i]) {
    while (i < len && strchr(whitespace, cmd[i])) {   // 跳过空格部分
      i++;
    }
    if (i < len) {  
      argv[j++] = cmd + i;   // 将每一个参数的开始位置放入指针数组中
    }
    while (i < len && !strchr(whitespace, cmd[i])) {  // 跳过字符部分
      i++;
    }
    cmd[i++] = 0;            // 在每一个参数后的第一个空格处用'\0'进行截断
  }
  argv[j] = 0;               // 表示参数数组的结束
  return j;                  // 返回参数个数
}

接着来到runcmd()方法,包含了对特殊符号的解析和命令的执行,参数处理思路以下:测试

  • 管道:从左往右顺序解析,找到 | 符号,对左右两边的命令分别建立子进程处理,链接标准文件描述符,并递归进入runcmd()方法
  • 重定向:遇到 < > 符号,关闭相应标准fd,打开文件
  • 普通参数:放入参数数组中,等待执行
void runcmd(char **argv, int argc) {
  int i, j = 0;
  char tok;
  char *cmd[MAXARGS];

  for (i = 0; i < argc; i++) {
    if (strcmp(argv[i], "|") == 0) {
      runpipe(argv, argc, i);       // 处理pipe
      return;
    }
  }
  for (i = 0; i < argc; i++) {
    tok = argv[i][0];               // 该参数的第一个字符
    if (strchr("<>", tok)) {
      if (i == argc-1) { 
        panic("missing file for redirection");    // 后面没有文件则报错
      }
      runredir(tok, argv[i+1]);     // 处理重定向
      i++;
    } else {
      cmd[j++] = argv[i];           // 收集参数
    }
  }
  cmd[j] = 0;
  exec(cmd[0], cmd);                // 执行命令
}

注:相比sh.c的实现,该解析方法的不足之处是没有支持符号与下一个参数连在一块儿的状况,如 echo 123 >1.txtecho 123 |grep 12,不过测试用例中的参数都是以空格分割,因此这里也就简单处理了。spa

重定向实现

在介绍 pipe (管道) 和 redir (重定向) 的实现前须要先说明下文件描述符(fd) 的概念,对于每个打开的文件会在内核中对应建立一个file对象,而且内核会为每一个进程维护一个指针数组,存储该进程的file对象的地址,而fd正是这个指针数组的索引。因此引用的路径是: fd -> 内核指针数组 -> file对象 -> 磁盘文件。

fd是一个顺序增加的整型,每一个进程默认会打开3个fd,分别是标准输入(0),标准输出(1) 和 标准错误(2)。对fd有几个经常使用的系统调用:

  • close(int fd):关闭一个fd,对应内核数组中的指针也会被移除,当文件对象的引用计数为0时,该文件才会被关闭
  • dup(int fd):复制一个fd,内核数组中会增长一个指针指向相同的文件,新建立的fd的值为当前可用的最小的整数
  • pipe(int * fd):对两个fd创建管道,对其中一个fd进行写数据,能从另外一个fd读出数据

重定向 是将进程的标准输入/输出 转移到打开的文件上。实现思路是利用fd的顺序增加的特性,使用close()关闭标准I/O的fd,而后open()打开目标文件,此时文件的fd就会自动替换咱们关闭的标准I/O的fd,也就实现了重定向。

void runredir(char tok, char * file) {
  switch (tok) {
  case '<':
    close(0);   
    open(file, O_RDONLY);
    break;
  case '>':
    close(1);
    open(file, O_WRONLY|O_CREATE);
    break;  
  default:
    break;
  }
}

管道实现

管道 是将左边进程的标准输出做为右边进程的标准输入。实现思路以下:

  • 调用pipe()链接两个fd,而后调用两次fork() 分别建立两个子进程,2个兄弟进程均继承了由管道链接起来的fd。(注: 这里调用2次fork是参考了sh.c的实现,实际发现若是每次只调用1次fork(),由父进程做为左侧输入进程,子进程进行递归fork(),一样能经过测试。)
  • 在子进程中close()关闭标准输出fd,dup()复制管道其中一端的fd,而后执行命令
  • 父进程须要调用两次wait()来等待两个子进程结束

从实现思路上也能够看出,因为管道的实现依赖于子进程对fd的继承,因此只能用于有亲缘关系的进程间通讯。

void runpipe(char **argv, int argc, int index) {    // index为|符号在数组中的位置
  int p[2];

  pipe(p);                                 // 创建管道
  if (fork1() == 0) {
    close(1);                              // 关闭标准输出
    dup(p[1]);                             // 复制管道中fd
    close(p[0]);      
    close(p[1]);
    runcmd(argv, index);                   // 递归执行左侧命令
  } 
  if (fork1() == 0) {
    close(0);
    dup(p[0]);
    close(p[0]);
    close(p[1]);
    runcmd(argv+index+1, argc-index-1);    // 递归执行右侧命令
  }
  // 关闭不须要的fd
  close(p[0]);
  close(p[1]);
  // 等待两个子进程结束
  wait(0);
  wait(0);
}

至此,基本功能就实现了。测试步骤以下:

  • Makefile文件的 UPROGS 部分追加上 $U/_nsh\
  • 执行make qemu 编译进入xv6命令行,随后咱们能够直接运行脚本: testsh nsh来执行测试case, 也能够运行nsh进入咱们的shell进行手动调试
  • 最后能够在xv6-riscv-fall1根目录下执行 make grade 进行评分。

xv6中的shell实现

xv6中的shell实如今user/sh.c中,大体思路和咱们的nsh类似,都是实现了对用户命令的循环读取、解析、执行,不过支持的命令类型更多且涉及更复杂。

1.主体逻辑

sh.c将命令解析和命令执行独立开来,首先递归地构造出结构化的命令树,而后又递归地去遍历树中的命令并执行。且对每一种支持的命令都定义告终构体,包括 可执行命令(execcmd),重定向(redircmd),管道(pipecmd),顺序命令(listcmd),后台命令(backcmd),这些命令都"继承"于一个基础cmd结构:

struct cmd {
  int type;       // 命令类型
};

且对于每种命令都实现了"构造函数",使用malloc()动态分配告终构体内存,而且强转为 cmd 结构的指针返回,等到具体使用的时候,再根据type字段中的类型,强转回具体的类型进行使用。(指针指向结构体的首地址,根据声明来访问字段,因此这里的强转不影响使用)。

这里使用了面向对象的思想,借助指针和类型强转实现了相似于"多态"的效果。这里的parsecmd()方法则像一个"工厂",根据输入的不一样构造不一样类型的命令,以基类形式统一返回,runcmd()中再根据具体类型执行不一样逻辑。

if (fork() == 0) {
  // parsecmd返回cmd,runcmd接收cmd
  runcmd(parsecmd(buf));  
}

此种设计将解析和运行独立开来,使得代码逻辑更加清晰,函数功能更单一;而且提高了可扩展性,若是后续有新的命令类型增长,只须要定义新的结构体,并编写相应的解析和处理方法就能够支持,对其余类型的命令影响较小。

2.命令解析

命令的解析和结构化在parsecmd()中实现,支持管道,重定向,多命令顺序执行,后台执行,括号组合等符号的解析。方法中大量使用了如下两个精巧的工具函数:

  • peek(char **ps, char *es, char *toks):判断字符串*ps的第一个字符是否在字符串toks中出现,es指向字符串末尾,同时该方法会移除掉字符串*ps 的前缀空白字符。如 peek(ps, es, "<>") 则用于判断当前字符串的首字符是否是 "<>" 中的一个。

  • int gettoken(char **ps, char *es, char *q, char *eq):一样传入字符串的开始(ps)和结束(es),每次调用该方法将会移除掉第一段空格及前面的内容,且传入的 q 和 eq 指向的内容就是被移除的参数。若是函数移除的内容命中了指定符号"| < >"等,就会返回该符号,不然返回常量'a'。 好比对字符串"cat < 1.txt" 执行gettoken(),那么源字符将变为"< 1.txt",q和eq指向字符串"cat"的首尾,并返回字符'a'。

parsecmd() 以pipeline的链式调用进行命令解析,顺序为 parsecmd() -> parseline() -> parsepipe() -> parseexec() -> parseblock() -> parseredirs(),分别对不一样类型的命令进行处理,从左往右不断使用peek()函数判断当前的符号,使用gettoken()获取空格分割的参数,构造树状命令结构。与传统树结构不一样的是,该命令树的每一个节点均可能是不一样的类型,好比管道命令的left和right字段都是cmd类型,但可能具体结构并不相同。

值得一提的是,解析完成后,还调用了nulterminate方法进行递归的参数截取。咱们最终执行的命令是execcmd类型,argv指针数组即指向全部参数的首地址,同时为其维护了一个eargv指针数组,取值于gettoken()返回的eq参数,指向参数列表中每一个参数的末尾地址,nulterminate()则将全部eargv指向的末尾字符置为'\0',这样便巧妙地在源字符串中完成了参数的分割。

3.命令执行

runcmd()命令执行方法递归遍历整颗命令树,根据cmd结构的type参数进行判断,作出相应处理。其中EXEC、PIPE、REDIR这三种命令和咱们的nsh实现类似,其他的几种命令则比较简单:

  • LIST:由分号 ; 分割的顺序命令,实现方法是fork一个子进程执行左命令,wait等待其完成后再执行右命令,从而实现顺序执行的效果;
  • BACK:由 & 结尾的后台命令,实现方法是fork一个子进程执行命令,父进程则直接退出。

实验代码: https://github.com/zhayujie/xv6-riscv-fall19

本文连接: https://zhayujie.com/mit6828-lab-shell.html

相关文章
相关标签/搜索