7.4 KiB
操作系统的任务是在多个进程之间共享计算机,并提供比硬件本身支持更有用的服务。操作系统抽象和管理底层的硬件,以使上层的程序不必关心硬件的细节。它也在多个进程之间共享硬件,这样多个进程就可以同时运行了。最终,操作系统为程序提供了受控的交互方式,让它们可以共享数据或同时工作。
操作系统使用接口来为用户程序提供服务。设计一个好的接口是困难的。一方面我们希望它简单以便于正确地实现,另一方面我们又希望能提供大量复杂的特性。解决这一矛盾的技巧是,在设计接口的时候让它们依赖一些机制,这些机制可以组合起来提供更多的通用性。
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函数且从下一句开始执行。
- p为当前进程,np为子进程。
- uvmcopy把父进程的内存复制给子进程。
np->tf->a0 = 0
保证了子进程返回0,因为寄存器a0保存了函数的返回值。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
的前三个入口分别是
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/sysfile.c。
read
文件位置kernel/file.c。作用:从文件描述符所指向的文件读取n个字节。
write
文件位置kernel/file.c。作用:从文件描述符所指向的文件写入n个字节。