mit6.828 Assignment 2 shell
这次的 Assignment 是完成一个最基本的 shell(的很小一部分功能)。代码量很小,只有不到 30 行,但是里面的不少东西值得说道说道。
shell 是什么
shell 的使用场景常常给人一种错觉 -- 它和内核是一伙的:shell 完成了用户的命令,因此 shell 是内核的一部分。但事实情况是 shell 只是个普通的用户态程序,和我们运行的 HelloWorld 没什么不同。它的特殊之处在于它的功能 -- 与用户进行交互:接收用户实时输入的命令,对命令进行解析,然后根据解析结果向操作系统发送相应的请求,以完成用户的命令。换句话说,shell 是个命令的中转站。我们大概可以使用如下的图示来表示 shell 的功能:
shell 干三件事情:(1)接收用户命令;(2)解析用户命令;(3)根据解析结果向内核发送请求,以完成用户命令。在 Assignment 的相关源代码 sh.c 中我们同样可以验证这一点:先 getcmd,再 parsecmd,最后 runcmd。
1 | int main(void) |
说说 sh.c
shell 的功能我们已经在上一节进行了分解。getcmd 没什么好说的,只是用来循环读入用户命令。parsecmd 会将命令解析成一颗命令树。解析的过程对于没有接触过解释器的我来说还是很有意思的,其中也运用了一些精彩的技巧,另外涉及到组合命令优先级的问题。runcmd 是我们需要完成的部分。我们需要对命令树中的某个特定命令进行执行。
创建进程为什么要两步?
众所周知,在 Linux 中创建一个进程一般需要两步:fork() 和 exec()。fork 产生一个几乎与父进程一样的子进程,exec 则完成将可能的可执行文件加载到子进程的地址空间、重新初始化堆栈、重新设置 PC 到代码段入口点等工作。
那么为什么不将这两个系统调用合并,像 Windows 创建进程一样直接使用 createProcess 呢?我考虑到有如下三个原因:
这很优雅。相较 Windows 创建进程需要提供的一大堆参数而言,前者甚至可以不需要提供任何参数(如果不需要
exec()的话)。使用
fork()和exec()创建的进程在一定程度上维持了与父进程的联系,而这种联系在很多应用场景下都是十分必要的,例如一个 shell fork 出的众多子进程之间通过共享某些特定的参数组成一个进程组,便于操作系统的统一调度管理。在
fork()后exec()前可以对子进程进行一些配置,例如对子进程的输入输出进行重定向。我们在下一节中就会看到这种看似简单的配置的强大威力。
shell 的强大武器:重定向、管道
我们可以把可执行程序想象成一段水管,水(数据)从一端(输入)进入水管(可执行程序),(经过处理),从另一端(输出)流出。
我们是否可以在不干涉水管的前提下指定水流的源头和去处呢?当然可以!只需要 shell 在 fork() 后 exec() 前对进程的输入输出(stdin,stdout,stderr)进行重定向即可。当然这也要得益于 Linux 强大的文件抽象。
我们是否可以在不干涉水管的前提下将口径相同的水管组装在一起呢?这样我们就可以根据需求拼接任意多的水管,使得水流可以自动地从第一个水管的入口流到最后一个水管的出口。当然可以!只需要 shell 在 fork() 后 exec() 前使用管道连接相邻的进程即可。
通过 shell 提供的简洁却又威力无穷的工具,我们可以把进程的运行玩出一片花来。我想现在我们会对进程创建分步的原因有了更深的理解。
再说说 sh.c
好了,前面铺垫了那么多,我们回归 Assignment 本身。需要我们实现的部分正是上一节描述的内容(外加创建普通进程)。
进程的种类(普通、重定向、管道)由 struct cmd 中的 type 字段定义。我们首先来看普通进程。
1 | case ' ': |
需要提一嘴的是 exec() 是个大家族,这里我们需要使用的是 execvp(),具体内容 RTM。
下面是输入输出重定向的进程
1 | case '>': |
这里需要用到的系统调用是 open() 和 close(),另外欣赏这个美丽的递归。
最后是涉及管道的进程
1 | case '|': |
由于管道连接两个进程,所以这里我们需要对两个进程的输出和输入分别进行处理。管道的创建使用了系统调用 pipe()。这里通过巧妙使用 close() 和 dup() 将进程的标准输入 / 输出的文件描述符设置为管道对应端口的文件描述符(妙啊.jpg)。另外一点是对于管道中不用的端口需要及时关闭,不然只要有文件描述符指着管道的端口,管道就不会关闭,导致进程一直认为管道的另一端会再传点什么东西过来,就有可能没法结束了。还有一点,父进程不要忘记 wait(),使得子进程资源被彻底回收。
关于解析命令
突然有点懒得写了 Orz,简单记录一下吧。
- 关于命令解析的顺序:parsepipe->parseexec->parseredir
- 解析任何命令都会返回统一的结构体指针
struct cmd *,根据struct cmd中type的值再决定是哪一种具体的命令结构体,有多态的思想。 - 两个辅助函数用处很大
peek:跳过空格,判断空格后的首个字符是否在备选区中gettoken:跳过空格,返回下一个 token