MIT6.S081/6.828 实验2:Lab Shell
Mit6.828/6.S081 fall 2019的Lab2是Simple Shell,内容是实现一个简易的shell程序,本文对该实验的思路进行详细介绍,并对xv6提供的shell实现进行深入解析。
准备
首先来看实验要求:
- 实现的shell要支持 基础命令执行、重定向 (< >) 处理、管道 ( | ) 处理
- 不能使用malloc()动态分配内存
- 使用"@"代替"$"作为命令行的提示符
- 及时关闭文件描述符;对系统调用的异常进行处理
xv6中提供有sh.c
的实现,除了重定向和管道,还对括号、列表命令、后台命令等做了支持,且整体设计较为复杂。所以我们无需过多参考已有代码,可以选择简单的思路来满足需求,在完成后再去阅读xv6的shell实现。
Shell本质上是一个用户程序,在用户和操作系统间建立连接。工作原理是在启动后不断接收并解析用户输入的命令,调用操作系统接口去执行命令,并把结果返回给用户。Shell运行于用户态而非内核态的好处是可以和内核完全解耦,实现可插拔的效果,因此你可以在bash、zsh、ksh等不同shell间轻松完成切换。
实验思路
下面介绍实验的整体思路,完整代码在 Github 中,并附有详细注释。
首先需要了解几个核心的系统调用:
- 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.txt
或 echo 123 |grep 12
,不过测试用例中的参数都是以空格分割,所以这里也就简单处理了。
重定向实现
在介绍 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一个子进程执行命令,父进程则直接退出。