0%

【OS】mit6.828 Assignment shell

这次的Assignment是完成一个最基本的shell(的很小一部分功能)。代码量很小,只有不到30行,但是里面的不少东西值得说道说道。

shell是什么

shell的使用场景常常给人一种错觉--它和内核是一伙的:shell完成了用户的命令,因此shell是内核的一部分。但事实情况是shell只是个普通的用户态程序,和我们运行的HelloWorld没什么不同。它的特殊之处在于它的功能--与用户进行交互:接收用户实时输入的命令,对命令进行解析,然后根据解析结果向操作系统发送相应的请求,以完成用户的命令。换句话说,shell是个命令的中转站。我们大概可以使用如下的图示来表示shell的功能:

shell干三件事情:(1)接收用户命令;(2)解析用户命令;(3)根据解析结果向内核发送请求,以完成用户命令。在Assignment的相关源代码sh.c中我们同样可以验证这一点:先getcmd,再parsecmd,最后runcmd

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
int main(void)
{
static char buf[100];
int fd, r;
// Read and run input commands.
while(getcmd(buf, sizeof(buf)) >= 0){
if(buf[0] == 'c' && buf[1] == 'd' && buf[2] == ' '){
// Clumsy but will have to do for now.
// Chdir has no effect on the parent if run in the child.
buf[strlen(buf)-1] = 0; // chop \n
if(chdir(buf+3) < 0)
fprintf(stderr, "cannot cd %s\n", buf+3);
continue;
}
if(fork1() == 0)
runcmd(parsecmd(buf));
wait(&r);
}
exit(0);
}

说说sh.c

shell的功能我们已经在上一节进行了分解。getcmd没什么好说的,只是用来循环读入用户命令。parsecmd会将命令解析成一颗命令树。解析的过程对于没有接触过解释器的我来说还是很有意思的,其中也运用了一些精彩的技巧,另外涉及到组合命令优先级的问题。runcmd是我们需要完成的部分。我们需要对命令树中的某个特定命令进行执行。

创建进程为什么要两步?

众所周知,在Linux中创建一个进程一般需要两步:fork()exec()fork产生一个几乎与父进程一样的子进程,exec则完成将可能的可执行文件加载到子进程的地址空间、重新初始化堆栈、重新设置PC到代码段入口点等工作。

那么为什么不将这两个系统调用合并,像Windows创建进程一样直接使用createProcess呢?我考虑到有如下三个原因:

  1. 这很优雅。相较Windows创建进程需要提供的一大堆参数而言,前者甚至可以不需要提供任何参数(如果不需要exec()的话)。

  2. 使用fork()exec()创建的进程在一定程度上维持了与父进程的联系,而这种联系在很多应用场景下都是十分必要的,例如一个shell fork出的众多子进程之间通过共享某些特定的参数组成一个进程组,便于操作系统的统一调度管理。

  3. fork()exec()前可以对子进程进行一些配置,例如对子进程的输入输出进行重定向。我们在下一节中就会看到这种看似简单的配置的强大威力。

shell的强大武器:重定向、管道

我们可以把可执行程序想象成一段水管,水(数据)从一端(输入)进入水管(可执行程序),(经过处理),从另一端(输出)流出。

我们是否可以在不干涉水管的前提下指定水流的源头和去处呢?当然可以!只需要shell在fork()exec()前对进程的输入输出(stdin,stdout,stderr)进行重定向即可。当然这也要得益于Linux强大的文件抽象。

我们是否可以在不干涉水管的前提下将口径相同的水管组装在一起呢?这样我们就可以根据需求拼接任意多的水管,使得水流可以自动地从第一个水管的入口流到最后一个水管的出口。当然可以!只需要shell在fork()exec()前使用管道连接相邻的进程即可。

通过shell提供的简洁却又威力无穷的工具,我们可以把进程的运行玩出一片花来。我想现在我们会对进程创建分步的原因有了更深的理解。

再说说sh.c

好了,前面铺垫了那么多,我们回归Assignment本身。需要我们实现的部分正是上一节描述的内容(外加创建普通进程)。

进程的种类(普通、重定向、管道)由struct cmd中的type字段定义。我们首先来看普通进程。

1
2
3
4
5
6
7
8
case ' ':
ecmd = (struct execcmd*)cmd;
if(ecmd->argv[0] == 0)
_exit(0);
// Your code here ...
if (execvp(ecmd->argv[0], ecmd->argv) < 0)
fprintf(stderr, "unknown cmd\n");
break;

需要提一嘴的是exec()是个大家族,这里我们需要使用的是execvp(),具体内容RTM。

下面是输入输出重定向的进程

1
2
3
4
5
6
7
8
9
10
11
12
case '>':
case '<':
rcmd = (struct redircmd*)cmd;
// Your code here ...
close(rcmd->fd);
rcmd->fd = open(rcmd->file, rcmd->flags, S_IRWXU | S_IRGRP | S_IROTH);
if (rcmd->fd == -1)
fprintf(stderr, "redir open failed\n");

runcmd(rcmd->cmd);
close(rcmd->fd);
break;

这里需要用到的系统调用是open()close(),另外欣赏这个美丽的递归。

最后是涉及管道的进程

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
case '|':
pcmd = (struct pipecmd*)cmd;
// Your code here ...
if (pipe(p) != 0)
fprintf(stderr, "pip create failed\n");
if (fork1() == 0) {
close(1);
dup(p[1]);
close(p[0]);
close(p[1]);
runcmd(pcmd->left);
} else {
close(0);
dup(p[0]);
close(p[0]);
close(p[1]);
runcmd(pcmd->right);
wait(&r);
}
break;

由于管道连接两个进程,所以这里我们需要对两个进程的输出和输入分别进行处理。管道的创建使用了系统调用pipe()。这里通过巧妙使用close()dup()将进程的标准输入/输出的文件描述符设置为管道对应端口的文件描述符(妙啊.jpg)。另外一点是对于管道中不用的端口需要及时关闭,不然只要有文件描述符指着管道的端口,管道就不会关闭,导致进程一直认为管道的另一端会再传点什么东西过来,就有可能没法结束了。还有一点,父进程不要忘记wait(),使得子进程资源被彻底回收。

关于解析命令

突然有点懒得写了Orz,简单记录一下吧。

  • 关于命令解析的顺序:parsepipe->parseexec->parseredir
  • 解析任何命令都会返回统一的结构体指针struct cmd *,根据struct cmdtype的值再决定是哪一种具体的命令结构体,有多态的思想。
  • 两个辅助函数用处很大
    • peek:跳过空格,判断空格后的首个字符是否在备选区中
    • gettoken:跳过空格,返回下一个token