MIT6.828 HW2 Shell

环境

系统Ubuntu 20.04 64位系统

HW地址:HW2-Shell

正文

本次实验难度一般般,不需要写很多的代码,并且能够帮我们熟悉常用的Unix system call((),比如说open,close等等。在正式做这个作业之前,务必先阅读xv6Book chapter 0。记下chapter 0中的对于各个system call的详细描述。这样能帮助我们对于本次作业的那些代码的理解。

实验

下载实验用的shll,去这个超链接把里面的sh.c拷贝出来。然后把下面的语句写入到一个叫t.sh文件:

ls > y
cat < y | sort | uniq | wc > y1
cat y1
rm y1
ls |  sort | uniq | wc
rm y

接下来呢,把sh.c编译下,就会得到一个a.out

gcc sh.c

然后运行下面命令:

./a.out <  t.sh

不出意外的话应该会看到一大堆的错误信息。这是因为你还没实现sh.c里面的代码。你可能不知道<什么意思,问题不大,到这里为止下面的先别看了,先去看xv6Book的chapter0吧

实现最简单的命令
如果编译了刚才那个sh.c,得到了a.out,如果我们使用

./a.out

然后在里面输入ls,会发现是没有结果的,报错。课程里面让我们去看下exec的man page。看过xv6book我们应该知道如果要执行一个命令,应该使用exec这个系统调用。这个系统调用有两个参数:

int execv(const char *path, char *const argv[]);

第一个表示需要运行的程序的路径,比如说,ls, wc等等。第二个参数表示程序所需呀的参数,比如说ls /,打印根目录下的所有的目录。

Most programs ignore the first argument, which is conventionally the name of the program

xv6book提到了说大多数的程序忽略了第一个参数,因为它通常是程序的名字。也就是说,agrv[0]是程序名字,agrv是参数

struct execcmd {
  int type;              // ' '
  char *argv[MAXARGS];   // arguments to the command to be exec-ed
};

懂了吧,那么答案就很简单了,我们通过系统调用execv来执行,第一个argv[0]是程序名字,argv是参数。答案呼之欲出:

  case ' ':
    ecmd = (struct execcmd*)cmd;
    if(ecmd->argv[0] == 0)
      _exit(0);
    // fprintf(stderr, "exec not implemented\n");
    // Your code here ...
    execv(ecmd->argv[0],ecmd->argv);
    break;

你再试试,是不是此时可以使用/bin/ls打印目录了呢?


实现最简单的命令

可以看到,输入/bin/ls,当前目录下的内容都输出了。

IO redirection

上面的实验已经可以运行一些简单的命令了,但是对于IO redirection还是无法实现的。你在./a.out里面输入下面的命令:

echo "6.828 is cool" > x.txt
cat < x.txt

在当前目录下,并没有出现x.txt。所以接下来就要去实现IO redirection了。看完xv6 book 关于 IO redirection 这一块内容后。应该已经知道了任何一个程序都有三个标准file descriptor:

standard input: 0
standard output: 1
standard error:2

我没有去认真研究过这些标准输入所代表的意义是什么。助于理解,我直接将标准输出理解为往shell输出结果,标准输入就是命令的输入内容。下面以xv6 book中 cat< input.txt作为一个例子:

char *argv[2];
argv[0] = "cat";
argv[1] = 0;
if(fork() == 0) {
  close(0);
  open("input.txt", O_RDONLY);
  exec("cat", argv);
}

大概就是这样的代码,回想一下close()和open()这两个系统调用的解释:

close():The close system call releases a file descriptor, making it free for reuse by a future open, pipe, or dup system call (see below)
open():
A process may obtain a file descriptor by opening a file, directory, or device, or by creating a pipe, or by duplicating an existing descriptor. A newly allocated file descriptor is always the lowest-numbered unused descriptor of the current process

理解了吧,close()会释放一个file descriptor,留给其他open(),pipe()使用。新分配的一个file descriptor总是可用的最小的file descriptor。
还有一个fork(),关于fork()的解释,回想一下最开始在shell当中执行一个程序的过程。

  1. main (8701) 这里一个while(getcmd(buf, sizeof(buf)) >= 0)等待来自用户的输入
  2. 然后父进程(shell)进入到wait(),父进程(shell)调用了fork(复制了一份shell,子进程)执行runcmd()函数
  3. 然后runcmd中判断此时的是IO redirection 还是pipe还是说就去执行一个程序

所以说要去执行一个新的程序的时候,必然是要fork一个程序,然后让父shell进入wait()。上面cat< input.txt的例子自然就很好理解,先fork一个程序,这个程序是真正去执行命令的。我相信你如果仔细的阅读过xv6book,已经能感受到file descriptor 到底妙在哪里。下图是一个进程:

process

不需要关注0 1 和这两个file descriptor所对应的到底是文件,还是device,还是一个dup得到的新的file descriptor。我只要往里面操作就行了,不必要关注它到底是什么。
所以上面代码真的太好理解了,close(0),释放0这个file descriptor,此时0就是lowest-number的file descriptor,然后我们重新open("input.txt"),就把0给了指向input.txt这个文件。接下来的任务就是交给cat去做了。所以如何实现一个IO答案呼之欲出了吧,先关闭对应的file descriptor,然后open一个新的好了,然后再去执行命令。
实现IO redirection:

  case '<':
    rcmd = (struct redircmd*)cmd;
    // fprintf(stderr, "redir not implemented\n");
    // Your code here ...
    close(rcmd->fd);
    if(open(rcmd->file, rcmd->flags) < 0){
      fprintf(2, "open %s failed\n", rcmd->file);
      _exit(0);
    }
    runcmd(rcmd->cmd);
    break;

再去实现在IO redirection这一节开始,那两句代码吧,看看是否已经得到了x.txt。这里会因为我们在open()没有指定permission,所以打开这个文件的时候记得+sudo。

我的实验结果:


IO redirection

成功了!

实现Pipe

Pipe说白就是内核当中一段缓存,是以一对descriptor供文件使用大,一个指向了pipe的写端,一个指向了pipe的读端口。pipe的作用就是让两个进程之间通信。我个人曾经在pipe这里花了不少时间,肯定上面两个多。为了更好理解,先来说一下xb6 book中示例代码的:

int p[2];
char *argv[2];
argv[0] = "wc";
argv[1] = 0;
pipe(p);
if(fork() == 0) {
    close(0);
    dup(p[0]);
    close(p[0]);
    close(p[1]);
    exec("/bin/wc", argv);
} else {
    close(p[0]);
    write(p[1], "hello world\n", 12);
    close(p[1]);
}

现在我们应该很熟悉了,我们需要执行一个程序肯定先fork一个新的进程。父进程与子进程都有一对file descriptor指向了这个pipe(注意,这个pipe是子父进程共用的,试想一下如果子父进程各自有管道,那还怎么通信呢?)。上述的示例代码要完成的任务是: wc程序的standard input连到pipe的read一端。
上述代码解释
首先先使用system call,pipe()建立一个pipe,并且p[1]是write一端的file descriptor,p[0]是read一端的descriptor。接着fork一个子进程,它负责去执行wc实际的内容。子进程和进程有相同的内容,所以父进程首先if(fork() == 0),条件不成立,但是创建了子进程。于是父进程跳转到了:

else {
    close(p[0]);
    write(p[1], "hello world\n", 12);
    close(p[1]);
}

此时父进程,关闭了管道的wirte一端,如果我们不关闭,如果程序一直往里面塞数据,那不是永远都看不到结束了?所以我们close(p[0]),接着父进程wirte(p[1]),往pipe的写端写入数据就行。好,接下来去子程序:

if(fork() == 0) {
    close(0);
    dup(p[0]);
    close(p[0]);
    close(p[1]);
    exec("/bin/wc", argv);
}

在子进程中if(fork() == 0)条件成立,所以我们首先先关闭子进程的standard input,然后dup(p[0]),接着关闭pipe的两个fiel descriptor。这里有有疑问的地方就是可能是父进程不是关闭了吗,怎么子进程还要关闭。这样理解,pipe是一块内存,每一个进程要通过一对file descriptor来写入或者读。简而言之,pipe是只有一个,但是每一个进程往pipe操作的时候是使用各自的file descriptor的。dup()的目的也很好理解,close(0)把standard input空出来,然后dup,那么就把pipe的read指向了standard input。自然wc读取的数据都是来自pipe。解释完毕~

PS:

If no data is available, a read on a pipe waits for either data to be written or all
file descriptors referring to the write end to be closed; in the latter case, read will re-
turn 0, just as if the end of a data file had been reached.The fact that read blocks until it is impossible for new data to arrive is one reason that it’s important for the child to close the write end of the pipe before executing wc above: if one of wc’s file descriptors referred to the write end of the pipe, wc would never see end-of-file.

在执行wc之间,一定要关闭write,否者wc永远看不到end of file。第一次看到这里也有点没理解。回想一下,管道是公用的,但是有各自的file desciptor。pipe会一直等着数据来或者所有指向write端的file descriptor关闭的时候(子父进程都有file descriptor指向pipe),read()会返回0。这种形式就是阻塞式的(block),如果我们不把子父进程的write端的close了,那么wc就永远看不到end of file。因为read一直就阻塞着,只有在all file descriptors referring to the write end to be closed的时候才返回。懂了吧,这就解释了为什么一定要先关闭所有的wirte端,才可以执行程序。

上面代码还不是一个真正的pipe,下面代码就是一个真正的pipe:

echo "hello world!" | wc -l

pipe的实际意义就是嘛我们可以同时运行多个进程,前一个进程的输出是后一个进程的输入。中间通过的媒介就是pipe,示意图如下:


pipe

设想一下,我们要在shell里面运行两个程序,一个echo "hello world",另外一个 wc -l。所以很自然的需要fork两个进程。然后echo输出到p[1],wc从p[0]读取。
pipe实现的详细思路:
如上图所示的例子,我们首先 fork(),把close(1)。把标准输出替换为p[1],这样一来就把输入内容发送到pipe当中去了。再把剩下的工作较给echo,echo程序来负责把内容发送到pipe。别忘了发送完了记得关闭pipe,所以close(p[0]),close(p[1])。此时file descriptor 1可用,所有dup(p[1])。echo的standard output就指向p[1]。
与之相类似,wc先close(0),然后dup(p[0]),这样一来就可以从pipe读取数据。用完了记得关闭,剩下的任务较给wc去做。

不知道各位老铁懂了没,不得不说真的设计的太妙了,如此的通用,不需要做任何的改变。将输入输出抽象出来,太妙了!!!

pipe的具体实现

  case '|':
    pcmd = (struct pipecmd*)cmd;
    // fprintf(stderr, "pipe not implemented\n");
    // Your code here ...
    if(pipe(p) < 0) {
      fprintf(stderr, "fail to create pipe\n");
      _exit(0);
    } 
    if(fork1() == 0){
      close(1);
      dup(p[1]);
      close(p[0]);
      close(p[1]);
      runcmd(pcmd->left);
    }
    if(fork1() == 0){
      close(0);
      dup(p[0]);
      close(p[0]);
      close(p[1]);
      runcmd(pcmd->right);
    }
    close(p[0]);
    close(p[1]);
    wait(&r);
    wait(&r);
    break;

对照一下上面的图,应该可以理解的,别忘了,和前面一样,父进程(shell)和两个子进程(这俩也是shell,只不过他们去执行wc和echo了)有相同的file descriptor, 我们还要关闭所有父进程中的的指向read和write的file descriptor。这样以来pipe就只有echo 和 wc在使用了。在xv6 book当中有一句话提到a shell may create a tree of process。这句话很关键,比如说一个pipe: a | b,b= c |d 。b进程又可以分为两个子进程。所以我们最开始在执行命令的时候是fork的是a | b, 然后执行a | b的时候,fork a进程,在fork()b进程,这就解释了为什么在pipe的时候又要fork().


Pipe

这是我的实验结果,可以看到pipe已经正常工作了!!

结尾

chapter 0 看了两边才理解这些内容,一开始草草看了下,没有记住多少的东西。后面结合这个作业和chapter 0好好看。受益匪浅,体会到了unix一些设计是多么的高雅。以前不知道pipe,这次学习也知道pipe和IO redirection还是挺好用的。Linux 上手不容易,用习惯了越来越发现它的的好处!本次实验有一些challenge,不会做,暂时放弃。

你可能感兴趣的:(MIT6.828 HW2 Shell)