xv6
a simple, Unix-like teaching operating system
Russ Cox, Frans Kaashoek, Robert Morris
原文:
https://pdos.csail.mit.edu/6.828/2019/xv6/book-riscv-rev0.pdf
1.1 进程和内存
xv6进程由用户空间内存(指令,数据和栈)和仅对内核可见的每个进程的状况组成。xv6支持分时特性:在可用的CPU上不停地切换等待运行的进程。当一个进程退出执行时,xv6保存它的CPU寄存器,并在下次运行时恢复它们。内核通过进程标识符或pid与每个进程相关联。
进程可以使用fork系统调用创建一个新进程。Fork创建一个称为子进程的新进程,其内存内容与调用进程(称为父进程)完全相同。Fork在父、子进程中都会返回。在父进程中,fork返回子进程的pid;在子进程中返回零。例如,考虑用C语言[5]编写的下列程序片段:
1 | int pid = fork(); |
2 | if(pid > 0){ |
3 | printf("parent: child=%d\n", pid); |
4 | pid = wait(0); |
5 | printf("child %d is done\n", pid); |
6 | } else if(pid == 0){ |
7 | printf("child: exiting\n"); |
8 | exit(0); |
9 | } else { |
10 | printf("fork error\n"); |
11 | } |
exit系统调用会让调用进程停止执行并释放如内存和打开的文件等资源。exit接受一个整型状态参数,通常0代表成功而1代表失败。wait系统调用返回当前进程的子进程pid并把子进程的状态复制到传递给wait的地址;如果调用者的子进程没有一个退出,则wait会等待直到有一个子进程退出。如果父进程不关心子进程的退出状态,它会传递一个0地址给wait。
在这个例子中,输出的行为:
parent: child=1234 注:1234仅为举例说明的值,实际运行时,可能是其他值。
child: exiting
输出顺序取决于父进程还是子进程先调用printf。子进程退出后且父进程的wait返回,父进程会打印
parent: child 1234 is done
尽管期初父子进程拥有相同的内存内容,但正在执行的父子进程却使用不同的内存和寄存器:改变其中一个某变量不会影响另一个,举例来说,当父进程wait的返回值已经存进pid,并不会改变子进程的pid。子进程pid仍然是0。
注:在实际调试时,与文档的描述有点出入,比如,exit()和wait()无输入参数。程序运行结果如下。
exec系统调用将调用进程的内存替换为文件系统中某个文件存储内存镜像。文件必须有一个特殊格式,详细说明了哪部分存指令,哪部分是数据,从哪个指令开始执行等等。xv6使用ELF格式,第3章会有详细说明。当exec成功执行,它并不会返回给调用程序,取而代之的是ELF文件头中声明的入口指令加载并执行。exec接受两个参数:可执行文件的名称和一个string*型的参数序列。比如:
char *argv[3];
argv[0] = "echo";
argv[1] = "hello";
argv[2] = 0;
exec("/bin/echo", argv);
printf("exec error\en");
这段程序使用 /bin/echo 的一个实例并以 echohello 为参数运行,替换了调用程序。多数程序会忽略第一个参数,通常是程序的名称。
xv6的shell使用上述的调用以用户的名义运行程序。shell的主结构很简单;请看main(user/sh.c:145)。主循环通过getcmd读取一行用户的输入。然后调用fork,创建一个shell进程的副本。当子进程执行命令的时候,父进程调用wait。举例来说,如果用户已经输入了echo hello到shell中,会以“echohello”为参数并调用runcmd。runcmd(user/sh.c:58)执行实际的命令。对于“echo hello”,它会调用exec(user/sh.c:78)。如果exec成功子进程会从echo执行指令,而不是runcmd。在某个时刻echo会调用exit,这会使得其父进程从wait返回,参看main(user/sh.c:145)。
程序运行结果如下。
你可能在想为啥fork和exec不能合成一个调用呢;待会我们就会看到创建进程和加载程序分离调用在I/O重定向上有妙用。为了避免创建一个复制进程然后立即替换掉它带来的浪费,对于这种场景系统内核会借助虚拟内存技术比如copy-on-write等对fork的实现进行优化。
xv6 通常隐式地分配用户的内存空间:fork分配子进程需要拷贝父进程内存所需的内存,exec为可执行文件分配足够的内存空间。进程在运行时若需要更多内存(也许是malloc)可以调用sbrk(n)来将其数据内存增加n个字节;sbrk返回新内存的位置。
xv6没有用户这个概念当然更没有不同用户间的保护隔离措施。按照Unix的术语来说,所有的xv6进程都以root用户执行。
1.2 I/O和文件描述符
文件描述符是一个小整型数,代表了一个进程可能会读取或写入的被内核管理的对象。进程可能通过打开一个文件、目录、设备、创建一个管道、或者复制已经存在的文件描述符等多种方式获得文件描述符。简单起见,我们常常把文件描述符指向的对象称为“文件”。文件描述符的接口是对文件、管道、设备等的抽象,这种抽象使得它们看上去就是字节流。
每个进程都有一张表,而xv6内核就以文件描述符作为这张表的索引,所以每个进程都有一个从0开始的文件描述符空间。按惯例,进程从文件描述符0(标准输入)读取,写入到文件描述符1(标准输出),从文件描述符2输出错误信息(标准错误输出)。就像我们所看到的,shell利用这个约定来实现I/O重定向和管道。shell确保它始终打开三个文件描述符(user/sh.c:151),默认情况下,这三个文件描述符是控制台(console)的文件描述符。
user/sh.c
系统调用read和write从文件描述符所指的文件中读或者写字节。read(fd, buf, n)从fd读最多n个字节(fd可能没有n个字节),将它们拷贝到buf中,然后返回读出的字节数。每一个指向文件的文件描述符都和一个偏移关联。read从当前文件偏移处读取数据,然后把偏移增加读出字节数。紧随其后的read会从新的起点开始读数据。当没有数据可读时,read就会返回0,表示读文件结束。
write(fd,buf,n)向文件描述符fd写入buf里的n个字节,然后返回写入的字节数。只有发生错误的时候才会写入少于n个字节。就像read一样,write也从当前文件的偏移处开始写,在写的过程中这个偏移将随着写的字节数而增加。
下面的程序段(道出了程序cat的本质)从它自己的标准输入拷贝数据到它自己的标准输出。如果发生错误,它会向标准错误写入一个信息。
1 | char buf[512]; |
2 | int n; |
3 | for(;;){ |
4 | n = read(0, buf, sizeof buf); |
5 | if(n == 0) |
6 | break; |
7 | if(n < 0){ |
8 | fprintf(2, "read error\en"); |
9 | exit(); |
10 | } |
11 | if(write(1, buf, n) != n){ |
12 | fprintf(2, "write error\en"); |
13 | exit(); |
14 | } |
15 | } |
重点来了,代码段中cat并不知道它是从文件、控制台、还是管道读取的。类似的cat也并不知道它是打印到控制台、文件还是别的什么地方。文件描述符的使用和约定(文件描述符0是标准输入,1是标准输出)使得我们可以轻松地实现cat。
程序运行结果如下。如何退出运行比较麻烦。画红线的是输入信息,没画线的是输出。
close系统调用会释放一个文件描述符,以便让未来的open,pipe或者dup(后边有)等系统调用可以再利用。新分配的文件描述符总是当前进程未使用的最小的那个。
文件描述符与fork的配合使用使得I/O重定向很容易实现。fork拷贝其父进程内存的同时也拷贝了文件描述符列表,所以子进程和父进程的文件描述符表相同。exec系统调用会替换调用它的进程的内存但会保留文件描述符表。这个行为让shell可以通过fork重新打开选定的文件描述符,然后执行新程序来实现I/O重定向。下面是shell执行命令cat < input.txt简化版代码。
1 | char *argv[2]; |
2 | argv[0] = "cat"; |
3 | argv[1] = 0; |
4 | if(fork() == 0) { |
5 | close(0); |
6 | open("input.txt", O_RDONLY); |
7 | exec("cat", argv); |
8 | } |
运行结果如下。
在子进程关闭文件描述符0后,open保证在新打开的input.txt文件时会使用文件描述符0,因为0是最小可用文件描述符。然后cat就基于文件描述符0代表的input.txt执行。
xv6 shell中用于I/O重定向的代码就是这样工作的(user/sh.c:82)。回想一下,在代码的这个节点,shell已经fork了子shell,runcmd将调用exec加载新的程序。现在应该明白为什么fork和exec分开调用是一个好主意。在这种情况下,shell可以fork一个子进程,在子进程中使用open、close、dup来更改标准的输入和输出文件描述符,然后执行exec。不需要对正在执行的程序(在我们的示例中是cat)进行任何更改。如果fork和exec合并到一个系统调用中,shell将需要一些其他(可能更复杂)方案来重定向标准输入和输出,或者程序本身必须了解如何重定向I/O。
usr/sh.c
尽管fork复制文件描述符表,但每个底层文件偏移量在父子进程之间共享。考虑这个例子:
1 | if(fork() == 0) { |
2 | write(1, "hello ", 6); |
3 | exit(0); |
4 | } else { |
5 | wait(0); |
6 | write(1, "world\n", 6); |
7 | } |
在代码段最后,附加到文件描述符1的文件将包含hello world数据。父进程的write(由于wait的原因,它只在子进程完成后运行)从子进程的write离开的地方开始。此行为有助于从shell命令序列生成顺序输出,比如(echo hello;echo world)> output.txt。
运行结果。
dup系统调用复制一个存在的文件描述符,返回一个指向同一个输入/输出对象的新描述符。两个文件描述符共享一个文件偏移,就像fork复制的文件描述符那样。这是另外一种写hello world到文件的方法:
1 | fd = dup(1); |
2 | write(1, "hello ", 6); |
3 | write(fd, "world\n", 6); |
如果两个文件描述符是通过一系列fork和dup调用从同一个原始文件描述符派生的,那么它们共享一个偏移量。否则,文件描述符不共享偏移量,即使它们是由对同一文件的打开调用导致的。Dup允许shell实现如下命令:lsexisting-file non-existing-file > tmp1 2>&1。2>&1告诉shell给命令一个与描述符1重复的文件描述符2。这样existing-file的名字和non-exsiting-file的错误输出都将出现在tmp1中。xv6 shell不支持错误文件描述符的I/O重定向,但是现在您知道如何实现它了。
ls fs.img abc >tmp1 定向输出到tmp1中,然后2定向输出到1(2>&1)
文件描述符是一个强大的抽象,因为它隐藏了连接对象的细节:进程向文件描述符1写入也许是写到一个文件,一个设备如控制台,或者一个管道。