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

准备

首先来看实验要求

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

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.txtecho 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一个子进程执行命令,父进程则直接退出。

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

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

MIT6.S081/6.828 实验2:Lab Shell的更多相关文章

  1. MIT6.S081/6.828 实验1:Lab Unix Utilities

    Mit6.828/6.S081 fall 2019的Lab1是Unix utilities,主要内容为利用xv6的系统调用实现sleep.pingpong.primes.find和xargs等工具.本 ...

  2. 【MIT6.S081/6.828】手把手教你搭建开发环境

    目录 1. 简介 2. 安装ubuntu20.04 3. 更换源 3.1 更换/etc/apt/sources.list文件里的源 3.2 备份源列表 3.3 打开sources.list文件修改 3 ...

  3. 【翻译】【中英对照】【企业库6】动手实验 Hands-On Lab 日志应用程序块索引页

    Logging Application Block Hands-On Lab for Enterprise Library 企业库的日志应用程序块动手实验 This walkthrough shoul ...

  4. 云计算与大数据实验:Hbase shell终端操作之数据操作一

    [实验目的] 1)学会向表中添加记录 2)学会添加记录时动态添加列 3)学会查看一条记录 4)学会查看表中的记录总数 5)学会删除记录 [实验原理] Hbase shell作为Hbase数据的客户端, ...

  5. 云计算与大数据实验:Hbase shell基本命令操作

    [实验目的] 1)了解hbase服务 2)学会启动和停止服务 3)学会进入hbase shell环境 [实验原理] HBase是一个分布式的.面向列的开源数据库,它利用Hadoop HDFS作为其文件 ...

  6. 云计算与大数据实验:Hbase shell操作用户表

    [实验目的] 1)了解hbase服务 2)学会hbase shell命令操作用户表 [实验原理] HBase是一个分布式的.面向列的开源数据库,它利用Hadoop HDFS作为其文件存储系统,利用Ha ...

  7. 云计算与大数据实验:Hbase shell操作成绩表

    [实验目的] 1)了解hbase服务 2)学会hbase shell命令操作成绩表 [实验原理] HBase是一个分布式的.面向列的开源数据库,它利用Hadoop HDFS作为其文件存储系统,利用Ha ...

  8. Linux实验:hdfs shell基本命令操作(一)

    [实验目的] 1)熟练hdfs shell命令操作 2)理解hdfs shell和linux shell命令 [实验原理] 安装好hadoop环境之后,可以执行hdfs shell命令对hdfs 的空 ...

  9. Linux实验:hdfs shell基本命令操作(二)

    [实验目的]    1)熟练hdfs shell命令操作    2)理解hdfs shell和linux shell命令[实验原理]    安装好hadoop环境之后,可以执行hdfs shell命令 ...

  10. CS:APP配套实验 Data Lab

    刚刚完成注册博客,想写一篇随笔,方便以后自己回顾.如果恰好也能帮助到你,是我的荣幸. 这次随笔是记载我的计算机系统(CS:APP,Computer Systems:A Programer's Pers ...

随机推荐

  1. pytorch入门 - LetNet5 神经网络

    1.LetNet5简介 LeNet5是由Yann LeCun等人在1998年提出的一种卷积神经网络架构,主要用于手写数字识别.它是早期卷积神经网络的成功应用之一,为现代深度学习模型奠定了基础.LeNe ...

  2. C++学习思维导图

    C++思维导图 整个的思维导图大概的架构如下,Xmind.SVG.PDF格式的下载链接都在下面了,如有需要可自取 Xmind文件分享:https://cnblogs-img.oss-cn-hangzh ...

  3. 2022 电赛C题 巡线基础模块代码(带控制)

    巡线功能模块 from maix import camera, display, gpio, pwm class FindLine(): def __init__(self): self.THRESH ...

  4. ArrayList与LinkedList的增删改查

    ArrayList: 1 package com.lv.study.am.first; 2 3 //ArrayList 有下标 可重复 有序(添加到集合里面的顺序)不等于排序 4 5 6 import ...

  5. Shell常用指令操作

    sed命令 替换目录下所有文件的某个字符串 sed -i 's/test/yuhaohao/g' `grep -lr yuhaohao *` export 函数到外部使用 functon test() ...

  6. 深入浅出容器学习--Docker网络

    一.Docker的网络概念 容器网络模型主要包含了三个概念: network:网络,这里可以理解为一个Driver,是一个第三方网络栈,包含多种网络模式. 单主机网络模式(none.host.brid ...

  7. 工程师都喜欢的一款自动生成网格的仿真软件——Hyperworks到底好不好用?

    HyperWorks是一款广泛应用于工程仿真和优化的软件平台,其中包括了许多强大的工具和功能.其中的网格自动生成工具是其重要组成部分之一,对于工程仿真和优化来说具有重要的意义.那么,HyperWork ...

  8. 那么iPaaS平台的应用场景有哪些呢?

    随着全球步入数字化转型的关键阶段,企业的各类业务功能正面临前所未有的颠覆性革新.传统的.孤立的信息系统和业务管理模式已难以适应快速变化的市场环境和日益增长的业务复杂性.iPaaS(Integratio ...

  9. 监控Streamlit中每段代码的rerun情况

    把代码放到不同的函数中并用下面的装饰器包裹即可在log中输出rerun次数. 依赖: loguru import streamlit as st from functools import wraps ...

  10. ICEE-Power-IGBT(电压驱动型, 绝缘栅双极型晶体管)的功率转换实例: 电磁加热

    电磁加热器是用IGBT模块实现小电压信号控制超大电流调制成高频高功率电磁感应电能. IGBT模块全称"绝缘栅双极型晶体管"由BJT(双极型三极管)和MOS(缘栅型场效应管)组成的复 ...