xv6book阅读 chapter1

发布时间 2023-11-05 15:06:47作者: 白日梦想家-c

xv6book主要研究了xv6如何实现它的类Unix接口,但是其思想和概念不仅仅适用于Unix。任何操作系统都必须将进程多路复用到底层硬件上,相互隔离进程,并提供受控制的进程间通信机制。

1 了解xv6

xv6是一个模仿unix内部设计的操作系统,其提供了unix中对应的部分系统调用。理解xv6对于我们理解linux将会是一个很好的开端。xv6的内核实际上就是一个运行中的程序,一个特殊的程序为其它进程提供服务。当我们的进程需要内核服务时,就使用一些系统调用,这是操作系统留给用户的接口,系统调用会使之进入内核态,执行一些用户态没有的服务,并可以访问一些特殊的数据,因此程序的执行是在用户空间和内核空间中交替实现的。

内核程序是通过CPU提供的硬件保护机制来实现程序在用户态执行时只访问自己的内存,这一点在计算机组成原理中有体现,当我们运行一个程序时会通过基址寄存器以及变址寄存器来实现访存,通过这两个寄存器的值来确保我们的访问不会越界。

shell:shell只是一个普通的用户程序,用于读取用户命令并执行它们,要注意shell并不属于内核,因此某种意义上讲,它的可替代性很高,这就是为什么我们可以在各种操作系统中见到各种各样的shell。关于xv6的shell实现可以在user/sh.c中看到。

xv6所拥有的系统调用:

2 进程和内存

xv6进程的用户空间由指令(代码段)、数据、堆栈组成,对内核而言,进程号pid是用以唯一标识进程的方法。每一个进程可以用fork来创建一个子进程,子进程会拥有父进程的所有东西,包括堆栈也会有对应的副本。fork在子进程和父进程中都有返回,但返回值不同,在父进程中返回的是子进程的pid,而在子进程中返回的是0(注意这不是子进程的pid=0).

接下来用示例简单介绍几个系统调用:

if(pid > 0){
  printf("parent: child=%d\n", pid);
  pid = wait((int *) 0);
  printf("child %d is done\n", pid);
} else if(pid == 0){
  printf("child: exiting\n");
  exit(0);
} else {
  printf("fork error\n");
}

exit系统调用会使得调用它的进程结束并释放所有的资源,它用参数0来表示正常退出,用1来表示异常退出。系统调用wait会返回一个已经退出的(或被杀死的)子进程的pid,当前进程没有子进程时返回-1,有子进程时阻塞等待某个子进程的退出,其参数用于接收子进程退出的状态,如果不需要,直接给0地址即可。

再介绍一个exec系统调用,该调用从存储在文件系统中的文件中加载新的内存映像替换调用进程的内存,即exec调用成功后程序剩下的代码不再执行,会执行新的代码和数据。但是对于这样的文件必须有一个特定的格式,它指定文件的哪一部分保存指令,哪一部分是数据,从哪条指令开始,等等。而xv6采用的时ELF格式,这个在第三章会有介绍。当exec执行成功时,它不会返回到调用程序;相反,指令会从ELF头中声明的入口点开始执行。exec接受两个参数:包含可执行文件的文件名和字符串参数数组。
示例如下:

char *argv[3];
argv[0] = "echo";
argv[1] = "hello";
argv[2] = 0;
exec("/bin/echo", argv);
printf("exec error\n");

大多数程序会忽略数组的第一个参数,即argv[0]中的程序名不是必须的,因为操作系统会根据指定的路径来执行程序,而该程序的输出是hello。在xv6 shell实现源码(sh.c)中可以看到,主函数用getcmd()循环读入用户输入的字符,在循环体中会判定是cd命令还是其它,如果是其它命令,就会用fork创建一个子进程来解析命令并执行。

或许有人会疑惑为什么不把fork和要执行的系统调用合并成一个system call来使用,稍后会看到shell在实现I/O重定向时利用了这种分离。而为了避免创建重复进程并立刻替换它的这种浪费行为(使用exec),内核通过使用虚拟内存技术(如copy on write-写时复制)来为这个用例优化fork的实现(第四章会讲)。

最后一点就是xv6在大多数时候会隐式的进行空间的分配,fork会为子进程的父进程内存副本分配所需的内存,exec会为可执行文件分配足够的内存。而一个进程在运行时需要更多的内存,则可以调用sbrk(n)来使其内存增加n个字节。

3 IO和文件描述符

一个文件描述符其实就是一个小的整数,用于表示进程可以读取或写入的内核管理对象,一个进程可以通过打开一个文件、设备、管道等来获取文件描述符,文件描述符接口抽象了文件、设备、管道之间的差异,使它们看起来就像是字节流,而隐藏了它们所连接的对象的细节。

按照惯例,对一个进程来说,文件描述符0用于读取数据,文件描述符1用于写入,文件描述符2用于写入错误信息,而shell就利用这个惯例来实现IO重定向以及,在xv6中shell会确保上述三个文件描述符总是处在打开状态的。这三个文件描述符读取和写入的对象通常是指控制台上的信息,也就是我们键盘输入的,以及要打印到屏幕上的,但要注意这个对象不是绝对的,因为它可更改。

read(fd,buf,n)从fd表示的文件中读取n个字节到缓冲区buf中,当读到文件末时返回0,否则返回读取的字节数。write(fd,buf,n)写n个字节的数据从缓冲区buf到文件描述符fd中,返回写入字节数,如果写入字节数少于n,则写入失败。无论是读还是写,都是通过一个偏移量offset来实现开始位置的定位。

close系统调用释放一个文件描述符,以便后续使用,一个进程中分配fd时总是从未使用的里面选一个最小的来优先使用。

在介绍了基本的IO相关调用后,可以开始聊一下IO重定向的问题了。I/O 重定向是指改变程序默认的输入和输出源,主要是依赖fork和文件描述符来实现的,fork会拷贝父进程的文件描述符列表到子进程,子进程开始时打开的文件与父进程是完全相同的,系统调用exec在替换调用进程的内存时会保留其文件表,这种行为允许shell通过fork实现I/O重定向,即在子进程中重新打开所选的文件描述符,然后调用exec来运行新程序。

通过一个例子来理解上述的IO重定向可能会更容易一些,比如命令cat < input.txt,该命令表示从文件 input.txt 中读取内容,并将其作为 cat 命令的标准输入。而cat 命令会将这些内容打印到标准输出(通常是终端),以显示文件内容,其大致实现如下:

char *argv[2];
argv[0] = "cat";
argv[1] = 0;
if(fork() == 0) {
  close(0);
  open("input.txt", O_RDONLY);
  exec("cat", argv);//用cat来替换子进程,cat会将标准输入的内容打印到终端
}

在子进程中我们关闭了文件描述符0,并重新更改了标准输入是来自于input.txt,但是父进程不受任何影响,而且在exec调用后,子进程的文件描述符列表信息依旧不会改变,至此我们实现了IO重定向。

现在可以回到上一节的问题:为什么在shell中不把fork和其它的call结合成一个调用来执行,因为我们有时需要自定义一些东西,比如IO自由重定向就依赖于这种分离,这样的实现并不会影响主进程。而内核所提供的接口既要合适,还要让他方便我们做各种各样的事情。

最后需要关注的一点就是在使用dupfork时会共享文件描述符的偏移量,因为它们所操作的对象是同一个。其中dup是复制一个现有的文件描述符,返回一个引用相同底层I/O对象的新文件描述符。

4 管道

管道的本质是一个小型的内核缓冲区,它暴露两个文件描述符给进程,分别表示管道的两端,一般默认p[0]是读端,p[1]是写端,接下来的一个示例是实现了父子进程间的管道通信,并且用exec("/bin/wc", argv)来执行wc程序

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]);
}

在子进程中先是关闭标准输入,然后用dup让p[0]变成了标准输入,随后关闭管道两端,执行wc程序,而父进程中则是通过管道写端向子进程写入。

在之前的了解中已经得知,shell在处理我们的命令时会通过创建一个子进程来执行,在子进程中对于pipe的处理方式为:创建一个子进程执行管道左边的命令,并将命令的结果通过管道传输给右边,创建另一个子进程执行右边的命令,在sh.c:100可以看到这样的实现:

case PIPE:
    pcmd = (struct pipecmd*)cmd;
    if(pipe(p) < 0)
      panic("pipe");
    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(0);
    wait(0);
    break;

xv6book对于这一实现细节做了讨论,即为什么分别对管道两边创建子进程来执行是最合适的。讨论中提到可以考虑修改实现,让中间的进程不创建左子进程,直接在当前进程中执行 runcmd(p->left),然后再执行右侧进程。这样带来的问题就是执行完后会立即退出,即管道右边的命令不会执行,我们可以通过其它的方式来 保证执行,但无疑那样做会让代码变得更加复杂。
 
pipe的本质是一个小型的缓冲区,那么我们每次创建一个临时文件来替代pipe不是也可以吗?例如用echo hello world >/tmp/xyz; wc </tmp/xyz来替换echo hello world | wc,作者给出了四点使用管道的好处:

  • pipe可以自动清理它们自己,而临时文件需要我们每次用完记得删除它们
  • 管道可以传输很长的字节流,但文件重定向需要在磁盘上确保一块足够大的空间,但我们在处理时很难确定这个空间的大小
  • 管道允许两端并行执行读写操作,而临时文件必须要等一方写完,另一方再进行读
  • 如果要实现进程间通信,管道的阻塞读写比文件的非阻塞语义更有效

5 文件系统

xv6中用/来表示根目录,如果一个目录路径不是从/开始的,则表示该路径是相对于当前目录的,可以用chdir来更改当前目录。

用于创建目录或文件的方式有很多:mkdir可以创建一个新的目录,open使用O_CREATE标志创建一个新的数据文件,mknod创建一个新的设备文件

文件名与文件本身是不同的;同一个底层文件(称为inode)可以有多个名称(称为链接)。每个链接由目录中的一个条目组成;该目录条目包含一个文件名和对索引节点的引用。索引节点inode保存有关文件的元数据,包括文件类型(文件、目录或设备)、长度、文件内容在磁盘上的位置以及到文件的链接数。

fstat系统调用从文件描述符引用的inode中检索信息。它填充一个stat结构体,在stat.h (kernel/stat.h)中有定义,link系统调用创建另一个文件名,该文件名引用与现有文件相同的inode。

open("a", O_CREATE|O_WRONLY);
link("a", "b");

此时对a文件的读写等同于对b文件的读写。与之对应的是unlink,释放一个链接,当文件的链接计数为零并且没有文件描述符引用它时,文件的inode和保存其内容的磁盘空间才会被释放。