Job of operating system:
操作系统使得多个程序分享一台计算机,提供一系列仅靠硬件无法支持的服务。
- 管理与抽象低级别硬件(如:文件处理程序不需要关注使用哪种硬盘)
- 使得多个程序分享硬件(programs that can run at the same time)
- 为程序提供可控的相互交流(分享数据或工作)的方式。
操作系统是通过interface为用户程序提供服务,然而设计良好的接口并不容易:
一方面,希望接口简单且应用场景单一,以实现起来更简单;
一方面,希望提供复杂的feature给应用。
So,设计依赖机制较少,但可结合起来以提供更具通用性的接口。
本书使用一个操作系统作为例子来诠释操作系统的概念。xv6 系统提供了一些由 Ken Thompson 与 Dennis Ritchie 的 Unix系统引入的基本接口,同时也模仿了Unix 系统的内部设计。Unix 系统提供的狭窄接口能很好底结合起来,以实现令人惊喜的通用性。此接口是如此的成功,以至于现代操作系统,BSD,Linux,Mac OS X, Solaris, 甚至Microsoft Windows 都具有类 Unix 的接口。理解 xv6 是理解这些操作系统的不错的开端。
正如 图1.1所示,xv6 采用了传统的kernel形式,内核是向用户程序提供服务的特定程序。每个运行的程序称之为进程(process),都具有其对应的内存空间(memory),内存中包含指令、数据与栈(instructions, data, and a stack)。
- 指令 (instructions) 实现程序的计算 (computation)
- 计算基于变量 (data)
- 栈组织程序的过程调用 (procedure calls)
一台计算机通常有很多进程,但仅一个内核。
当进程需要 invoke 内核服务时,程序 invoke 系统调用 (system call),系统调用进入内核,内核服务并返回,所以进程在用户空间 user space 与内核空间 kernel space 之间交替执行。
内核使用CPU的硬件保护机制 (hardware protection mechanisms) 以保证每个执行在用户空间的程序只能访问自己的内存。内核拥有实现此硬件保护机制所需的硬件特权 (hardware privileges) ,用户程序 (user program) 无此特权。当用户程序 invokes 一个系统调用时,硬件会提高特权级别并开始执行内核中的 pre-arranged 函数。
内核提供的系统调用集是用户程序可见的 interface。Xv6内核提供的服务与系统调用,是传统 UNIX内核提供的子集。
本章的剩余部分将提到 xv6 系统的服务:进程,内存,文件描述符,管道以及一个文件系统,通过代码段与讨论shell使用这些服务的方法,来详述xv6服务。shell(Unix的命令行用户接口)对系统调用的使用显示了这些系统调用设计的精巧。
shell 读取用户输入并执行,其作为一个用户程序,而非内核的一部分,也体现了 system call interface 的强大之处。shell 并不特殊,易被取代, 现代UNIX系统有一系列shell可供选择。The xv6 shell is a simple implementation of the essence of the Unix Bourne shell.
1.1 进程与内存
一个 xv6 进程包含用户空间的内存(指令,数据与栈),以及仅内核可见的各进程状态。xv6 为等待执行的进程间分配CPU秒。当一个进程不在执行时,xv6 保存其CPU寄存器,下次执行时恢复。内核为每个进程关联一个进程标识符(PID)。
一个进程(父进程)可以通过 fork 系统调用创建新进程。fork 创建的新进程称之为子进程,子进程拥有内存的内容与父进程相同。fork 在子进程与父进程都返回,具体而言,父进程中返回子进程的 PID,子进程中返回 0。例如,下面代码段
int pid = fork();
if(pid > 0){
printf("parent: child=%d\n", pid); //1
pid = wait((int *) 0);
printf("child %d is done\n", pid);
} else if(pid == 0){
printf("child: exiting\n");//2
exit(0);
} else {
printf("fork error\n");
}
exit 系统调用终止调用进程的执行,释放资源,如内存及打开的文件。Exit 接受一个整型数,一般 0 表示成功,1 表示失败。wait 系统调用返回当前进程的某个退出/终止的子进程的PID,拷贝子进程的退出状态,并传递给 wait 的地址参数,如果调用进程无子进程退出,wait 等待某个子进程退出;若调用进程无子进程,wait 立刻返回 -1。当然,如果调用进程不在乎子进程的退出状态,完全可以给 wait 传递一个 0 地址。
(注意1,2 处的打印顺序不一定)
尽管,开始子进程的内存内容与父进程相同,但是,二者通过不同的内存与寄存器执行,改变某个进程中的某个变量不会影响另一个进程中该变量的值。如,wait 返回值存在父进程的 pid 中,这不会改变子进程的pid 变量,其值依然为0。
exec 系统调用使用加载自文件的新 memory image取代调用进程的内存。使用的文件必须为特定格式,以指明文件的哪个部分为指令、数据,以及从哪个指令开始执行等。xv6 使用 ELF 格式,第三章将讨论更多细节。当 exec 成功执行时,不会返回到调用进程,加载自文件的指令从ELF header 中声明的入口点开始执行。exec 有两个参数:文件名,字符串数组。
char *argv[3];
argv[0] = "echo";
argv[1] = "hello";
argv[2] = 0;
exec("/bin/echo", argv);
printf("exec error\n");
此代码段用 参数列表为 “echo hello” 的/bin/echo 程序实例取代调用进程。大多数程序忽略参数数组的首元素(一般为程序名)。
xv6 shell 使用以上的系统调用代替用户执行程序。shell 的主要结构很简单,参考 user/sh.c 内的main函数。主循环使用 getcmd 读取用户的一行输入。然后调用 fork 拷贝 shell 进程。父进程调用 wait,而子进程执行命令。例如,如果用户向 shell 输入 “echo hello”,runcmd 被调用的同时被传递了参数 "echo hello",runcmd 实际执行命令。对于 "echo hello",exec 将被调用。若 exec 成功,子进程将执行来自 echo 而非 runcmd 的指令。某个点,echo 调用 exit ,这将使得父进程从 wait 返回。
为什么 fork 与 exec 不作为一个调用整体实现呢?之后我们将发现这种分隔在shell 实现 IO重定向时大有用处。立即替换(exec)一个刚刚被创建的复制进程是一种浪费,为了避免这种浪费,通过虚拟内存技术,如写时复制,内核优化了 fork 的实现。
xv6 大多数时候分配内存是显式的:fork 分配子进程所需用来拷贝父进程内存的内存,exec 分配存储可执行文件的内存。一个进程在运行时若需要更多空间,可调用 sbrk 实现增长 n 字节的数据内存。sbrk 返回新内存的位置。
1.2 I/O 与 文件描述符
文件描述符是小的整型,指代一个进程可读写的,受内核管理的对象。一个进程可以通过打开一个文件、目录、设备或创建一个管道,或复制一个已存的描述符来获得一个文件描述符。为了简要起见,我们通常将一个文件描述符指向的对象视为一个 “文件”,文件描述符接口通过抽象,消除了文件、管道、设备之间的差异性,使之都可被视为字节流。输入输出将被视为 I/O。