Skip to the content.

操作系统的任务是在多个进程之间共享计算机,并提供比硬件本身支持更有用的服务。操作系统抽象和管理底层的硬件,以使上层的程序不必关心硬件的细节。它也在多个进程之间共享硬件,这样多个进程就可以同时运行了。最终,操作系统为程序提供了受控的交互方式,让它们可以共享数据或同时工作。

操作系统使用接口来为用户程序提供服务。设计一个好的接口是困难的。一方面我们希望它简单以便于正确地实现,另一方面我们又希望能提供大量复杂的特性。解决这一矛盾的技巧是,在设计接口的时候让它们依赖一些机制,这些机制可以组合起来提供更多的通用性。

xv6实现了Unix的基本接口,并模仿了它的内部设计。

xv6有内核,用以为运行的程序提供服务。运行的程序,即进程,有自己的内存用以包含指令、数据和栈。

进程通过系统调用使用系统服务。这样,进程就在内核空间和用户空间之间切换。

在内核层面实现了进程隔离的机制。

本章的其余部分概述了xv6提供的服务:进程、内存、文件描述符、管道、文件系统,并讨论了shell。

shell只是一个普通的程序,它从用户那里读取命令并执行它们。它的代码在user/sh.c。

从内核态到用户态

在xv6启动的过程中,0号核在main函数里会执行userinit函数,标记initcode处的数据会复制到第一个用户进程的内存空间。标记initcode的数字是一段代码,它对应的内容在user/initcode.S,是要把文件init的内容放到内存里以用户态去执行。文件init编译自user/init.c。

user/init.c把文件sh放到内存里执行。文件sh编译自user/sh.c,把用户输入的命令调入内存中执行,它就是xv6的shell。shell就是不断地使用getcmd函数读取命令行的输入,然后使用runcmd函数来执行命令行的输入。

从用户态到内核态

系统调用怎么从用户态到的内核态呢?文件user/usys.pl生成user/usys.S,usys.S就是用来完成从用户态到内核态的代码。从代码可见,它是通过ecall产生一个异常来进入内核态。只要把usys.S生成的二进制文件链接进用户程序里,就可以使用系统调用了。

进程和内存

xv6的进程包含两部分:一是用户空间的内存(指令、数据和栈),一是仅内核可见的进程状态。

相关系统调用的实现请看kernel/sysproc.c。

fork

文件位置:kernel/proc.c。作用:创建一个进程,新进程的内存和父进程是完全一样的。

fork函数一次调用两次返回,这很怪异,因为一般的调用都是只返回一次的。fork从父进程返回很正常,但为什么会从子进程返回呢?这是因为子进程继承了父进程的所有资源,这一句*(np->tf) = *(p->tf)相当于子进程复制了父进程运行的“快照”,子进程里也相当于进行了一个fork函数且从下一句开始执行。

# 对fork函数的一点分析
1. p为当前进程,np为子进程。
2. uvmcopy把父进程的内存复制给子进程。
3. np->tf->a0 = 0保证了子进程返回0,因为寄存器a0保存了函数的返回值。
4. return pid对于子进程来说,在汇编层面应该实际执行的返回寄存器a0的值。
exit

文件位置:kernel/proc.c。作用:让调用它的进程停止运行并释放资源(如内存、打开的文件等)。exit有一个整型的状态参数,通常0代表成功,1代表失败。

wait

文件位置:kernel/proc.c。作用:等待当前进程的某个子进程退出;如果有子进程退出,返回该进程的pid,并把退出状态复制到传递给它的地址上。如果不关心子进程的退出状态,可以直接给wait传递参数0。

exec

文件位置:kernel/exec.c。作用:把当前进程的内存替换为文件里保存的内存镜像并执行之。exec有两个参数,第一个是要执行的程序,第二个这个程序的参数(以字符串数组的形式出现)。

exec首先用namei来打开文件path。然后,读取ELF header。xv6的程序是用ELF格式来读取的(ELF格式详见kernel/elf.h)。在一个ELF二进制里,ELF header(struct elfhdr)在头部的位置,接下来是些程序头(struct proghdr),每个程序头都描述了一个必须被载入内存里的段(section)。xv6的程序只有一个程序头,但其它系统指令和数据可能会有不同的段(section)。

第一步是快速检查文件是否正确包含了一个ELF二进制。ELF二进制的开头部分是一个魔数,0x7f后面跟字符串”ELF”。如果魔数能对的上,exec就认为这个二进制是正确的。

exec使用proc_pagetable分配了一个没有映射的新页表,然后用uvmalloc来为每个ELF段分配内存,并用loadseg把每个ELF段载入内存。loadseg使用walkaddr来找到将要写入ELF段的物理地址,然后用readi来把文件中的内容载入到该地址。

可以用objdump -p来查看程序头的内容。在程序头里filesz可能比memsz小,这表示它们之间的间隙(gap)应该用0来填充,而不是从文件里读取。

接下来该分配和初始化用户的栈了。它只为栈分配了一个页。exec依次把参数复制到栈顶,然后把到这些参数的指针记录在ustack里。在传给main函数的argv列表里,最末尾放了一个空指针。ustack的前三个入口分别是假的返回PC,argcargv的指针。

exec在栈的下面放了一个不可访问的页,这样栈溢出的时候就会报错。这个不可访问的页也使得exec可以处理太长的参数;当参数太长的时候,exec用来把参数复制到栈的函数copyout将会提示目标页不可访问,并返回1。

在准备新的内存镜像的时候,如果exec检测到一个错误(比如无效的程序段),将会跳转到标记bad,释放新的镜像,并返回1。exec必须等到系统调用执行成功才可以释放掉旧的镜像;如果旧的镜像没有了,系统调用就没有办法向它返回1了。exec的错误只可能发生在镜像创立期间。一旦镜像完成,exec就可以提交到新的页表并释放旧的页表。

exec按ELF文件定义的地址把ELF文件载入到内存。用户或进程可以在ELF文件里指定任意地址。这是危险的,因为这些地址可能指向内核,不管是有意还无意。一个不严谨的内核将导致若干不好的结果,从崩溃到对隔离机制的恶意破坏(如安全漏洞)。xv6进行了一系列检查来避免这些风险。如if(ph.vaddr + ph.memsz < ph.vaddr)检查两者相加是否溢出。在x86版的xv6中,如果ph.memsz足够大而溢出0x1000,用户是有可能把数据复制到内核里的。在RISC-V版的xv6中则无此问题,因为内核和用户分别有各自的页表。

内核开发者很容易忽略一个重要的检查,在真实的世界中由于没有进行检查而使用户程序获取内核权限的事情已经有很长的历史了。xv6对提供给内核的用户数据的检查很可能是不全面的,用户程序是有可能绕过xv6的隔离机制的。

sbrk

文件位置:kernel/sysproc.c。作用:为进程分配或回收内存。它有一个参数,代表要分配的字节数。它返回的是新分配内存的地址。

这个系统调用是通过growproc来实现的。growproc使用uvmalloc来分配内存,如果给的参数是正数。或使用uvmdealloc来释放内存,如果给的参数是负数。

uvmalloc首先使用kalloc来分配物理内存,然后再用mappages把PTE加到用户的页表里。uvmdealloc调用uvmunmap实现其功能,uvmunmap首先用walk来找到对应的PTE,然后使用kfree来释放相应的物理内存。

xv6里进程的页表不只是告诉硬件怎么映射到虚拟地址,也是分配给那个进程的物理内存页的唯一记录。这就是为什么uvmunmap在释放用户内存的时候需要对用户页表进行检查。

shell

文件位置:user/sh.c。shell结构简单,在main函数里可以看到它的主循环就是不断地使用getcmd来读取用户输入。然后使用fork来创建一个子进程。父进程调用wait来等待子进程执行命令。子进程调用runcmd来执行真正的命令。

既然fork之后肯定要执行exec,为什么不把它们合而为一呢?这是为了在I/O重定向的时候方便使用。fork复制的内存基本上是无用的,会在随后被exec替换掉,为了防止复制过程的资源浪费,可以使用虚拟内存的技术(如写时复制copy-on-write)。

在xv6里没有多用户的概念。用Unix术语来说,所有xv6进程都是作为根用户来运行的。

I/O和文件描述符

一个文件描述符是一个小的整数,它代表了一个内核管理的对象(kernel-managed object),进程对这个对象进行读写操作。进程可能通过如下方法获取文件描述符,打开文件、目录或设备,创建管道,复制一个已有的描述符。为简单起见,文件描述符指向的对象都被称为是“文件”;文件描述符抽象出来文件、管道和设备的共同点,使它们看起来都像是字节流。

在每个进程表里都把文件描述符作为索引,这样每个进程都有文件描述符的私有空间,文件描述符都是从0开始计数。按照惯例,0是标准输入,1是标准输出,2是标准错误。shell就是使用的这个约定实现的I/O重定向和管道。shell始终打开了三个文件描述符,作为控制台的默认文件描述符。

相关系统调用的实现请看kernel/sysfile.c。

read

文件位置kernel/file.c。作用:从文件描述符fd所指向的文件读取n个字节,把它们复制进buf。返回值是读取到的字节数。每个文件描述符都记录了它在文件里的偏移位置,read就是从当前偏移位置开始读取数据的,然后在那个偏移量上递增读取的字节数。

write

文件位置kernel/file.c。作用:从buf写入n个字节到文件描述符fd所指向的文件。返回写入的字节数。它对文件描述符里偏移量的操作和read是一样的。

close

作用:释放一个文件描述符,这样这个文件描述符就可以被其它的系统调用(openpipedup)使用了。总是优先分配当前进程里未使用的数字最小的描述符。


文件描述符和fork的交互可以比较容易地实现I/O重定向。fork把文件描述符表复制给了子进程,而exec在替换子进程的内存的时候并不会替换这个文件描述符表。这样shell在实现I/O重定向的时候,只要forking,重新打开关闭的文件描述符,执行新程序就可以了。

从中可见把forkexec分开的好处:不用预先执行就可以修改子进程的文件描述符。

虽然fork复制了文件描述符表,但父子进程依然共享了底层文件的偏移。这个行为有助于顺序执行顺序输出的shell命令。

dup

复制一个已有的文件描述符,返回一个新的文件描述符,这个新的文件描述符和原文件描述符指向相同的底层I/O实体。(这个底层I/O实体可能是管道、索引结点或设备)。这两个文件描述符共用同一个位移,就像用fork复制的那样。

除了forkdup,文件描述符之间是没有办法共享位移的。有了dup系统调用,我们就可以把标准输出复制给标准错误,进而在shell里实现2>&1这样的功能(把标准错误和标准输出都作为标准输出)。xv6不支持标准错误的I/O重定向,但你已经知道怎么实现它了。

文件描述符是个强有力的抽象,因为可以用同一个接口处理不同的实体:文件,设备和管道。

管道

管道用于进程间的通信,它其实是内核里一块小的缓冲区,这个缓冲区向进程提供了一对文件描述符,一个用于读而另一个用于写。向管道的一端写数据,会使管道的另一端可以读这些数据。

如下是程序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\en", 12);
  close(p[1]);
}

程序调用pipe来创建管道,并把读和写的文件描述符记录在数组p里。执行fork后,父进程和子进程都有了指向管道的文件描述符。子进程把读取端复制给文件描述符0,关闭p里的文件描述符,执行wc。当wc从标准输入里读时,它就是从管道里读。父进程关闭管道的读取端,向管道写入数据,然后关闭写入端。

当管道里没有数据的时候,管道上的read要么等数据写入,要么等写入端的文件描述符被关闭;在后一种情况下,read将返回0,就像到达了数据文件的尾部一样。如果新数据不可能再到来,read就会一直阻塞,这就是在执行wc之前子进程要关闭管道的写端的原因之一:如果有一个wc的文件描述符指向了管道的写端,wc就再也看不到文件结束了。

XV6 shell实现的管道和上面的代码是类似的,详见user/sh.c。子进程创建了一个管道。然后它为管道的左端和右端分别调用forkruncmd,并且等待两边都完成。管道的右端可能也是一个包含了管道的命令,那么所有使用了管道的这些子进程就构成了一颗树。树的叶子是命令,而内部结点是等待左子结点和右子结点都完成的进程。理论上讲可以让内部结点从管道的左端开始执行,但这会增加复杂性。

看起来管道做的事,用临时文件重定向也可以做。但实际上,管道比临时文件至少有如下优势:

  1. 管道会自动清除它自己,而使用重定向则要手动清除临时文件。
  2. 管道可以传输任意长度的数据流,而重定向则要求磁盘有足够的空间来保存所有的数据。
  3. 管道可以并行执行,而重定向只能依次执行。
  4. 如果进行的是进程间通信,管道阻塞读和写这样的语义比文件的非阻塞语义更有效。

文件系统

XV6的文件系统提供了文件和目录,文件就是字节组成的数组,而目录由带着名字的文件和其它目录组成。

全部目录构成了一个树,它从root目录开始。路径给出了一个文件或目录的位置。路径除了可以从根目录开始,也可以从进程的当前目录开始,当前目录可以用chdir系统调用来改变。

可以创建文件或目录的系统调用:mkdir创建一个新的目录,open带着标志O_CREATE可以创建一个新的数据文件,mknod创建一个设备文件。

mknod只是创建一个没有任何内容的文件。它只是初始化这个文件的元数据,把文件类型设置为设备,设置majorminor以唯一对应一个内核设备。当一个进程要打开这个设备文件的时候,内核就不会把readwrite系统调用传给文件系统了,而是传给内核设备。

fstat用于取出文件描述符所指向的那个文件的元信息,它会把这些信息填充到结构体struct stat里。

文件名不能代表文件本身,文件的底层实现是 索引结点,而一个索引结点可以有多个名字,叫做 链接link系统调用可以给已有的索引结点再创建一个文件名。

对于同一个文件,不管用它的哪个文件名读写,其效果都是一样的。一个索引结点和它的 索引结点号 是一一对应的。

unlink系统调用用于从文件系统里移除一个文件名。当文件的链接记数为0且没有文件描述符指向它,那个文件的索引结点和磁盘空间才会被释放。

mkdir, ln, mr这样的用于文件操作的shell命令,都被实现为用户态的程序。这样任何人都可以扩展用户程序了。这似乎看起来是显而易见的,但同时期的其它系统常常是把这些命令内建到shell里,然后又把shell内建到内核里。

cd 命令是个例外,它是内建在shell里的,因为它必须改变shell自身的当前工作目录。如果cd是一个常规命令,那么它就会作为子进程来运行,它改变的是子进程的工作目录。而我们想改变的父进程的工作目录并没有改变。