Chapter 1 Operating system interfaces
xv6采用传统的内核形式,由内核为运行的程序提供服务。每个运行的程序称为进程,其内存里包含指令,数据,栈。指令实现程序的计算,数据是计算执行的对象——变量,栈组织程序的过程调用。
进程常常通过system call调用内核服务。系统调用进入内核,内核执行服务并返回。因此进程在用户空间和内核空间交替执行。
内核使用CPU提供的硬件保护机制确保用户态进程只能访问自己的地址空间。这种机制需要硬件特权,内核有特权,用户程序无。当用户程序调用system call时,硬件提升特权级,并在内核中执行预先规定的程序。
System call | 描述 |
---|---|
int fork() | 创建一个进程,返回子进程的PID |
int exit(int status) | 终止当前进程。status传递给wait()。无返回。 |
int wait(int *status) | 等待子进程结束。exit的参数传给*status。返回子进程PID。 |
int kill(int pid) | 终止进程PID,返回0,错误返回-1。 |
int getpid() | 返回当前进程的PID。 |
int sleep(int n) | 暂停n clock ticks |
int exec(char *file, char *argv[]) | 导入文件并带参数执行,只有错误才返回。 |
char *sbrk(int n) | 增加进程的内存空间n个字节,返回新空间的起始地址。 |
int open(char *file, int flags) | 打开一个文件。flags指示read/write。返回fd。 |
int write(int fd, char *buf, int n) | 从buf处向fd写n个字节。返回n。 |
int read(int fd, char *buf, int n) | 读取n个字节到buf。返回读取的字节数。如果读到文件结束,返回0。 |
int close(int fd) | 释放fd。 |
int dup(int fd) | 返回一个文件描述符,和fd指向相同的文件。 |
int pipe(int p[]) | 创建一个管道,将read和write文件描述符指向p[0]和p[1]。 |
int chdir(char *dir) | 改变当前目录。 |
int mkdir(char *dir) | 创建一个目录。 |
int mknod(char *file, int, int) | 创建一个设备文件。 |
int fstat(int fd, struct stat *st) | 将打开文件fd的信息写入 *st。 |
int stat(char *file, struct stat *st) | 将一个有名文件的信息写入 *st。 |
int link(char *file1, char *file2) | 为文件file1建立文件别名file2。 |
int unlink(char *file) | 删除一个文件 |
shell读取用户的命令并执行,是一个普通的用户程序,不是内核程序。
进程和内存
xv6进程由用户地址空间(指令,数据,栈)和对内核透明的进程状态组成。当进程处于非执行态时,xv6保存进程的CPU寄存器状态,执行时再恢复这些状态。内核为每个进程绑定一个PID。
fork系统调用:可以创建一个新的进程,和调用进程具有相同的空间内容(包括指令和数据)。调用进程和被调用进程具有不同的返回值,返回新进程PID的为父进程,返回0的为子进程。
exit系统调用:调用进程停止执行,并释放内存和打开文件。参数为0代表成功,1代表失败。
wait系统调用:返回一个(不是所有子进程,只要有一个就可以)被exit或者被kill的子进程的PID,复制子进程的status传递给wait的地址参数。如果调用进程的子进程没有exit,wait将等待。如果调用进程没有子进程,wait立即返回-1。如果父进程不关心子进程的status,则传给wait参数0地址。
初始父子进程具有相同的地址空间内容,但具有不同的地址空间和寄存器。其中一个改变变量不会影响另一个。
exec系统调用:将文件系统上的一个文件导入到一个新的地址空间镜像,替换调用进程的地址空间。这个文件具有特殊的格式,明确指出哪部分是指令,哪部分是数据,指令从哪里开始执行。xv6使用ELF格式。exec执行成功后不返回调用程序。指令从ELF header声明的entry point开始执行。
char *argv[3];
argv[0] = "echo";
argv[1] = "hello";
argv[2] = 0;
exec("/bin/echo", argv);
printf("exec error\n");
用程序/bin/echo替换调用程序。多数程序忽略第一个参数,它通常是程序名称。
sh.c:shell进程getcmd
输入命令后,调用fork系统调用创建子进程,调用wait系统调用等待子进程执行命令。子进程执行runcmd函数,调用exec系统调用执行echo hello。如果exec执行成功,子进程将执行echo程序且不再返回rumcmd。最终echo调用exit系统调用通知shell进程。
fork和exec分开设计的原因:IO重定向。先fork后exec,采用copy-on-write避免fork复制内存空间内容引起的浪费。
sbrk系统调用:fork依据父进程的内存拷贝非配内存。exec为可执行文件装入分配足够的内存。运行时进程需要更多的内存可以调用sbrk扩大数据地址空间,sbrk返回新地址空间的位置。
I/O和文件描述符
一个文件描述符是一个小整数,是一个内核管理对象,进程可以对它进行读写。进程获得一个文件描述符的方式:打开一个文件、目录、或者设备,创建一个管道,拷贝一个存在的描述符。将文件、管道、设备抽象为文件描述符,使它们看起来像字节流。
xv6进程表中用文件描述符作为对文件的索引,每个进程有一段属于文件描述符的私有空间:描述符0代表标准输入,1代表标准输出,2代表标准错误流。shell利用这种特性实现了I/O重定向和管道。shell确保console始终有3个文件描述符打开。
read系统调用:read(fd, buf, n)最多从文件描述符fd读取n个字节,拷贝到buf,返回读取的字节数。每个指向文件的描述符都有一个偏移,read从当前文件偏移读取数据,偏移推进到读取结束位置,后续的read从之前的偏移开始读取,当没有字节可以读取时,read返回0表示指向文件末尾。
write系统调用:write(fd, buf, n)从buf向文件fd写n个字节,返回写的字节数。只有错误发生返回才小于n个字节。文件偏移原理和read类似.
close系统调用:释放一个文件描述符,可被重新使用,新分配的文件描述符从最低的数字未被使用的描述符开始。
I/O重定向:文件描述符和fork共同实现I/O重定向。fork系统调用拷贝父进程的文件描述符表和它的内存空间,所以父子进程的打开文件相同。exec系统调用替换了父进程的内存空间,但保留了父进程的文件表。实现I/O重定向:fork之后,在子进程修改选择的文件描述符(让已创建的文件描述符指向其他文件,fd的值不变,但是fd指向的文件已经改变,我理解这里的作用是将使用cat功能的shell和cat功能进行分层,这样shell可以通过I/O重定向进行功能的组合从而实现更强大的功能),调用exec运行新的程序(保留修改的文件描述符指向)。
char *argv[2];
argv[0] = "cat";
argv[1] = 0;
if (fork() == 0) {
clse(0);
open("input.txt", )_RDONLY);
exec("cat", argv);
}
/**
关闭文件描述符0后,open会用文件描述符0分配给打开文件(close系统调用的作用,0是最小的可用文件描述符)。
cat执行时标准输入指向的是input.txt文件。
这里只改变了子进程的文件描述符,父进程的没有改变。
**/
I/O重定向解释了fork系统调用和exec系统调用分开的原因:shell可以重定向子进程的I/O而不破坏shell的I/O设置。假设存在forkexec系统调用,那么实现I/O重定向有点不合适。shell要么在调用forkexec前修改I/O配置,要么增加forkexec的I/O配置参数,最起码要在每个程序例如cat等做I/O重定向处理。
尽管fork系统调用复制了文件描述符表,但每个文件偏移仍被父子进程共享。
if (fork() == 0) {
write(1 "hello ", 6);
exit(0);
} else {
wait(0);
write(1, "world\n", 6);
}
/**
输出hello wrld
**/
dup系统调用:复制一个存在的文件描述符,返回一个新的文件描述符指向相同的I/O对象(文件、管道、设备)。通过fork系统调用和dup系统调用,两个来源相同的文件描述符共享一个文件偏移。否则不共享,尽管通过open系统调用打开相同的文件。
dup允许shell实现如下命令:ls existing-file non-existing-file > tmp1 2>&1
。2>&1
shell提供给命令文件描述符2,这是文件描述符1的duplicate。使得existing-file的名称和non-existing-file的错误信息都会显示在tmp1。xv6不支持异常文件描述符的I/O重定向。
文件描述符很强大,隐藏了指向细节:一个进程写一个文件描述符1,既可以写文件,又可以写设备如console,还可以写管道。
管道
管道是以一对文件描述符提供给进程的一段内存缓冲区,一端用来读,一端用来写。管道提供给进程通信的方法。
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]);
}
这个程序创建了一个新的管道,将读写文件描述符标在数组p里。fork之后,父子进程都有指向管道的文件描述符。子进程调用close系统调用和dup系统调用使文件描述符0指向管道的读端。关闭p中的文件描述符,调用exec系统调用执行wc程序。wc从标准输入读取,这个标准输入指向管道的读端。父进程关闭管道的读端,写管道,然后关闭写端。
如果没有数据可读,读管道将阻塞直到有数据写入或者所有指向写端的文件描述符被close。如果到达一个数据文件的末尾,read将会返回0。对于子进程在执行wc之前关闭管道的写端很重要,因为read可能会因此阻塞:如果wc的一个文件描述符指向了管道的写端,wc可能会一直阻塞。
xv6实现pipelines(;,&&,|| 用分割符将多个命令写在同一行):grep fork sh.c | wc -l
子进程创建一个管道链接pipelines的左右两端。分别为左右两端调用fork系统调用和runcmd函数,等待他们结束。右端的pipeline可能是一个包含管道的命令(a | b | c),它又fork两个新的进程(一个对b,一个对c)。因此,shell创建了一个进程树。叶节点是命令,内部节点是等待左右子进程完成的父进程。
管道和临时文件:echo hello wprld | wc
可以不用管道实现 echo hello world >/tmp/xyz; wc </tmp/xyz;
。管道相对于临时文件的四个优势:
- 管道可以自我清除。文件重定向时,shell必须很小心的移除/tmp/xyz;
- 管道可以传递任意长度的数据流。文件重定向要求足够的磁盘空间存储所有数据;
- 管道允许像流水线样并行执行。临时文件实现则需要顺序执行。
- 如果正在实现IPC,管道的阻塞读和写比临时文件的非阻塞读和写更有效。
文件系统
xv6文件系统提供数据文件(包含连续的字节数组)和目录(包含指向数据文件的有名目录和其他目录)。目录形成了一棵树,以一个特殊的目录/root开始。
chdir系统调用:可以改变当前路径。
chdir("/a");
chdir("b");
open("c", O_RDONLY);
open("a/b/c", O_RDONL);
//若目录和文件都存在,两种实现效果一样
//第一种实现改变当前路径,第二种实现不改变当前路径。
mkdir系统调用:创建一个新的目录。
open系统调用:带有O_CREATE标志创建一个新的数据文件。
mknod系统调用:创建一个新的设备文件。mknod(devname, major number, minor number)。
mkdir("/dir");
fd = open("/dir/file", O_CREATE|O_WRONLY");
close(fd);
mknod("/console", 1, 1);
mknod系统调用创建了一个特殊的文件指向了一个设备。一个设备文件由主设备号和从设备号(两个设备号唯一确定一个设备,主设备号标识某一类设备,从设备号区分一类设备中的不同设备)关联。当一个进程打开了一个设备文件后,内核将read和write系统调用转向内核设备而不再指向文件系统。
一个文件名称标识文件。文件根本的标识是inode,可以有多个文件名,成为links。每个link由一个目录中的entry(快捷方式)组成,entry包含一个文件名称和指向inode的链接。一个inode含有一个文件的metadata(元数据),包含文件类型(文件、目录、设备),文件长度,文件内容在磁盘上的位置,链接这个文件的link数量。
link系统调用:创建指向和一个存在的文件指向相同inode的别名文件。
open("a", O_CREATE|O_WRONLY);
link("a", "b");
对a和对b读写相同。每个inode对应唯一的inode number。a和b的fstat的关键内容相同:相同的inode number(ino)和相同的nlink数量(nlink = 2)。
unlink系统调用:从文件系统移除一个名称。只有当文件的link数量为0并且没有文件描述符指向文件时,文件的inode和相应内容的硬盘空间才会释放。
Unix从shell中提出了可调用的文件工具作为用户层程序,例如mkdir,ln,rm。这个设计允许任何人通过添加新的用户层程序扩展命令行接口。这个思路很聪明,同时期其他系统将这些命令构建到shell中,并把shell加到内核中。
一个例外是cd,它被构建进了shell。cd一定改变当前shell的工作路径。如果cd作为一个常规的命令,shell将fork一个子进程,子进程执行cd程序改变了子进程的工作路径,但父进程(shell进程)的工作路径并没有改变。
Real world
Unix结合了标准的文件描述符,管道,方便的shell操作语法,对于写出通用目的的、可复用的程序有巨大优势。这个思路引爆了"software tools"(被开发者用来创建、维护、调试、支撑其他应用和程序的一系列计算机程序)的文化,对于Unix的强大和推广至关重要,shell第一次被成为脚本语言。Unix系统调用接口今天仍用于BSD,Linux和macOS。
Unix操作系统通过Portable Operating System Interface(POSIX)标准变得标准化。xv6没有遵循POSIX标准:许多系统调用没有实现(lseek),提供的许多系统调用和标准不同。对于xv6的主要目标是简洁清晰,提供一个简单的类Unix系统调用接口。许多人用更多的系统调用接口和一个简单的可以运行基本Unix程序的C library扩展xv6。然而现代内核提供了远多于xv6的系统调用接口和内核服务。例如,支持networking, windowning systems, 用户级线程和很多设备驱动等。现代内核持续快速发展,提供了很多超越POSIX的特性。
Unix用一系列文件名称和文件描述符接口统一了不同资源类型(文件,目录,设备)的访问。 (Dave Presotto, Rob Pike, Ken Thompson, and Howard Trickey. Plan 9, a distributed system.
In In Proceedings of the Spring 1991 EurOpen Conference, pages 43–50, 1991.)该论文将“资源即文件”思想用到了网络、图形化等。然而许多起源于Unix的系统并没有遵循这个规则。
文件系统和文件描述符成为强大的抽象,但对于OS接口来说也有许多其他模型。Multics是Unix的前身,将文件存储抽象为像内存一样的方式,产生了不同的接口风格。Multics设计复杂对于Unix的设计者有直接影响,从而做出简化。
xv6没有提供用户的概念和用户保护的概念,在Unix模式中,所有的xv6处理均在root下。
任何操作系统一定实现 对底层硬件的多样处理,进程的相互隔离,提供受控的IPC机制。