本系列文章为MIT6.S081的学习笔记,包含了参考手册、课程、实验三部分的内容
本文由xv6英文手册翻译而来,由于水平有限,加之第一次学习操作系统,许多内容难免有误。敬请各位网友指出以便持续修正,谢谢!
参考内容: xv6-book翻译(自用)第一章、 xv6-gitbook
操作系统的任务
操作系统接口设计
操作系统通过接口向用户程序提供服务,设计一个好的接口是很困难的。
①一方面,我们希望接口简单明了,因为这样更容易正确实现。
②另一方面,我们又想为应用程序提供更多更强大的功能。
解决这种矛盾的诀窍是设计接口时采用一些巧妙地机制,这些机制组合起来可以提供更通用、强大的功能。
xv6
本书使用xv6作为实例来阐述操作系统的相关概念。xv6引入了Unix操作系统部分基本接口,并模仿Unix的内部设计。在Unix中,各接口将一些机制巧妙地结合在一起,并提供了惊人的通用性,以至于包括BSD、Linux、Mac OS X、Solaris(甚至在程度上还包含Windows)在内的现代操作系统都具有类似Unix的接口。了解xv6是了解这些操作系统和许多其他操作系统的良好开端。如图1.1所示,xv6采用传统的内核模式(内核是一个为正在运行的程序提供服务的特殊程序)。每个正在运行的程序(称为进程)都拥有一个独立的内存空间,包含指令、数据和堆栈。指令实现程序的计算、 数据是计算所依赖的变量、 堆栈组织程序的调用。
系统调用
一台计算机通常有许多进程,但只有一个内核。当一个进程需要调用一次内核服务时,它会调用系统调用(操作系统接口中的调用之一)。系统调用进入内核,内核执行服务并返回。因此,一个进程总是在用户空间和内核空间之间来回流动。内核使用CPU提供的硬件保护机制来确保在用户空间中执行的每个进程只能访问自己对应的内存。内核具有实现这些保护机制所需要的硬件权限,而用户程序没有这些权限。当用户程序调用系统调用时,硬件会提升权限级别,开始执行内核中预先定义好的程序。内核提供的一系列系统调用就是用户程序所看到的操作系统接口。xv6内核提供了Unix内核的部分系统调用,如下表所示。其中,这些系统调用返回0表示无误,返回-1表示出错。
系统调用 | 描述 |
---|---|
int fork() | 创建一个进程,返回子进程的PID |
int exit(int status) | 终止当前进程,并将状态报告给wait()函数。无返回 |
int wait(int *status) | 等待一个子进程退出;将退出状态存入*status;返回子进程PID。 |
int kill(int pid) | 终止对应PID的进程,返回0或-1(-1表示错误) |
int getpid() | 返回当前进程的PID |
int sleep(int n) | 暂停n个时钟周期 |
int exec(char *file, char *argv[]) | 加载一个文件并使用参数执行它;只有在出错时才返回 |
char *sbrk(int n) | 按n 字节增长进程的内存。返回新内存的开始 |
int open(char *file, int flags) | 打开一个文件;flags表示read/write,0为读1为写;返回一个fd(文件描述符) |
int write(int fd, char *buf, int n) | 从缓冲区buf 写n个字节到文件描述符fd;返回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) | 删除一个文件 |
本章后续内容
①本章的剩余部分概述了xv6提供的系统调用(进程、内存、文件描述符、管道和文件系统),并讨论它们对应的代码、在shell(Unix的命令行用户界面)中的使用方法。通过上述讨论,可以看到这些系统调用的设计别具匠心。shell是一个普通程序,它接收用户输入的指令并执行它们。
②shell也是一个普通的用户程序,并不是内核的一部分,这说明了系统调用接口的强大性:shell没有特别之处,这也意味着shell非常容易被替换。因此,现代Unix系统有多种shell可供选择,每种都有自己的用户界面和脚本功能。xv6 shell本质上是Unix Bourne shell的一个简单实现,它的源码可以在(user/sh.c:1)找到。
进程
xv6的一个进程由用户空间内存(包含指令、数据和堆栈)和进程状态(仅对内核可见)组成。xv6为各进程提供了分时特性:xv6不断切换到可用的CPU去执行等待队列中的进程。当一个进程没有被执行时,xv6会保存它的CPU寄存器,并在下一次执行该进程时恢复它们。内核使用PID(process identifier)标识各个进程。
fork
一个进程中可以使用fork来创建另外一个新的进程。通过fork创建的新进程称为子进程,其内存中的内容与创建它的进程(父进程)完全相同。fork在父进程和子进程中都有返回,在父进程中返回子进程的PID,在子进程中返回0。
例子:
①exit
会使调用它的进程停止运行,并释放相应的资源(如内存和打开的文件)。exit接受一个整数作为参数,通常0表示成功,1表示失败。
②wait
会返回当前进程中已退出(或已终止)的子进程的PID。wait接受1个参数,子进程在调用exit时会将exit的参数传递给父进程wait的参数。如果调用者的子进程都没有退出,wait会一直等待直到有一个子进程退出。如果调用者没有子进程,wait则立即返回-1。如果父进程不关心子进程的退出状态,则可以将(int*) 0
作为wait的参数。
在这个例子中,下面的两行输出可能以任意顺序被打印,具体取决于父进程和子进程谁先调用printf。
子进程退出后,父进程的wait返回,于是父进程打印
注意: 虽然最初子进程与父进程具有相同的内存内容,但是父子进程使用不同的内存空间和寄存器存储,修改一个进程中的变量不会影响另一个进程。例如,当wait的返回值被存储到父进程的pid中时,它不会改变子进程中的pid变量。子进程中pid的值仍然为0。
exec
exec使用一个新的内存映像文件替换当前调用exec的进程的内存。该文件必须符合特定的格式,它需要指定文件的哪一部分是指令、哪一部分是数据、指令从哪里开始执行等。xv6使用ELF格式,第3章将对此进行更详细的说明。exec执行成功后,不返回调用程序。而是从ELF首部中声明的入口处开始执行从文件中加载的指令。exec有两个参数:包含可执行文件的文件名、字符串参数数组。举例如下:
这段代码将调用程序替换为/bin/echo这个程序,对应的参数列表为echo hello。大多数程序都会忽略argv的第一个参数,因为它通常是程序的名称。
xv6 shell的工作流程
xv6 shell使用上述调用来为用户执行程序。shell的主体结构很简单,详情见(user/sh.c:145)。
----主循环使用getcmd从用户那里读取一行输入。
----然后它调用fork创建一个shell子进程。
----父进程调用wait,子进程执行用户输入的命令。例如,如果用户在 shell中输入了“echo hello”,runcmd(user/sh.c:58)就会在子进程中被调用,并将“echo hello”作为参数。对于“echo hello”,runcmd会调用exec(user/sh.c:78)。如果调用exec成功,则子进程将执行echo程序。
----在某个时刻,echo会调用exit,使得父进程从main(user/sh.c:145)中的wait返回。
思考: 你可能想知道为什么fork和exec没有合并为一个调用。之后我们将看到,shell在实现I/O 重定向时利用了这种分离。为了避免创建重复进程然后立即使用exec替换它所造成的资源浪费,操作系统的内核通过使用虚拟内存技术(如写时复制)优化了fork的实现。(详情见第 4.6 节)
xv6分配内存的一些方法
xv6大部分以隐式的方式分配用户空间:
①fork为子进程分配一块内存并将父进程的内存拷贝一份至此。
②exec分配一块足够大的内存来保存可执行文件。
③一些在运行时需要更多内存的进程(例如malloc)可以调用sbrk(n)将其数据内存空间增加n字节,sbrk返回新内存的位置。
文件描述符
----文件描述符是一个比较小的整数,表示一个进程可以读取或写入的对象(该对象被内核管理)。进程可以通过打开文件、目录、设备,或通过创建管道,或通过复制现有文件描述符来获得文件描述符。为简单起见,我们通常将文件描述符所指的对象称为文件。文件描述符的接口抽象了文件、管道和设备之间的差异,使它们看起来都像字节流。
----每个进程都有一张表,而xv6内核以文件描述符作为这张表的索引,所以每个进程都有一个从0开始的文件描述符的私有空间。按照惯例,进程从文件描述符0(标准输入)读取,将输出写到文件描述符1(标准输出),并将错误消息写到文件描述符2(标准错误)。正如我们将看到的,shell利用这种约定来实现I/O重定向和管道。shell确保始终都有3个打开的文件描述符(user/sh.c:151),它们是控制台的默认文件描述符。
read和write
read和write这两个系统调用从文件描述符所指的文件中读取或者写入n个字节。
①调用read(fd, buf, n)从文件描述符fd所指的文件中最多读取n个字节(fd所指的文件可能没有n个字节),将它们拷贝到buf中,并返回读取出的字节数。每个文件对应的文件描述符都有一个与之关联的偏移量,read从当前文件偏移处读取数据,然后读取到多少个字节就将该偏移量增加多少。例如第1次读取100个字节,偏移量就增加100,下次read就从100后面的位置开始读取。read返回0表示到达文件结尾处。
②调用write(fd, buf, n)将n个字节从buf写入文件描述符fd所指的文件,并返回写入的字节数。发生错误时写入的数据将少于n个字节。与读取操作一样,写入从当前文件偏移处开始,然后在写入的过程中增加偏移量:每次写入都从前一次停止的地方开始。
例子:
以下代码(实现cat的核心代码)将数据从其标准输入复制到其标准输出。如果发生错误,它会将相应的报错信息写入到标准错误进行输出。
在上述代码中需要注意的重要一点是:cat不知道它是从文件、控制台还是管道读取数据。同样,cat不知道它是打印数据到控制台、文件还是其他什么地方。文件描述符的使用和约定(以文件描述符0为输入、文件描述符1为输出)使得我们可以轻松实现cat。
I/O重定向
文件描述符和fork交互使I/O重定向能够轻易实现。fork会复制父进程的文件描述符表和内存,这样子进程就可以准确地读写父进程打开的那个文件。exec会替换调用它的那个进程的内存,但会保留其文件描述符表。这种行为允许shell通过分叉来实现I/O重定向:fork一个子进程,在子进程中重新打开指定的文件描述符,然后调用exec来运行新的程序。下面是shell执行cat < input.txt的简化版代码:
①open的第1个参数是要打开的文件名。open的第2个参数是一个标志位,控制 open做什么操作。这个标志位可取的值在文件控制头文件(kernel/fcntl.h:1-5) 中定义,具体包含:O_RDONLY、O_WRONLY、O_RDWR、O_CREATE 和 O_TRUNC,它们表示open打开文件进行读取、写入、读取和写入、如果文件不存在则创建文件、将文件截断为0长度。
②close会释放一个文件描述符,使其可以自由地供将来的open、pipe或dup这些系统调用重用。新分配的文件描述符始终是当前进程中未使用的描述符中编号最小的。
③子进程关闭文件描述符0后,open会保证使用0作为input.txt的文件描述符(因为0是open执行时的最小可用文件描述符)。之后cat就会在标准输入指向input.txt的情况下运行,这样就只修改子进程的描述符而不会更改父进程的文件描述符。
思考:为何fork和exec是单独的两种系统调用
在fork与exec之间,主shell有机会重定向子shell的I/O,而不会干扰主shell的本身的I/O设置。可以假设存在一个forkexec系统调用,但是使用这样的调用来做I/O重定向似乎很尴尬。如果使用forkexec的话,shell需要在调用forkexec之前修改自己的I/O设置,并在执行成功之后取消这些修改。或者forkexec可以将I/O重定向的指令作为一个参数。或者(最不吸引人的是)可以让每个程序(如cat)执行自己的I/O重定向。
注: 虽然fork操作能够复制文件描述符表,但每个文件的偏移量在父子进程之间共享的。考虑这个例子:
在上述代码的末尾,文件描述符1对应的文件将包含数据 hello world。 父进程中的write从子进程write结束的地方开始继续写入(由于wait,父进程仅在子进程结束后才运行)。这种行为有助于从shell中的命令序列按照顺序输出,例如(echo hello; echo world) >output.txt。
dup
dup复制一个现有的文件描述符,返回一个指向相同I/O对象的新文件描述符。两个文件描述符共享一个偏移量,就像fork复制的文件描述符一样。下面是将hello world写入文件的另一种方法:
①如果两个文件描述符是通过一系列fork和dup调用从相同的原始文件描述符派生出的,则它们共享一个偏移量。 通过其它方式产生的文件描述符不共享偏移量,则即使它们打开的是同一个文件。
②dup允许shell执行如下命令:ls existing-file non-existing-file > tmp1 2>&1。其中,2>&1告诉shell给这条命令一个文件描述符2(它是描述符1的副本)。这样,existing-file的名称和non-existing-file的错误输出都将显示在文件tmp1中。
③xv6 shell不支持错误文件描述符的I/O重定向,但现在你应该知道如何实现它了。
④文件描述符是一种强大的抽象,因为它们隐藏了它们所连接的细节:一个进程向描述符1写出,它可能正在写入文件、控制台等设备或管道
管道
管道是一个小的内核缓冲区,它以一对文件描述符的形式提供给进程,一个用于读取,一个用于写入。将数据写入管道的一端,可以用于从管道的另一端读取。管道提供了一种进程间通信方式。
下面这段代码运行程序wc,wc的标准输入连接到一个管道的读取端。
程序调用pipe,它创建一个新的管道,并将读写文件描述符记录在数组p中。在fork之后,父子进程都有管道的文件描述符。child调用close和dup将管道的读取端拷贝到文件描述符0中。关闭p中的文件描述符,并调用exec运行wc。当wc从其标准输入中读取时,它实际上是从管道中读取。父进程向管道的写入端写入,然后关闭写入和读写端。
在管道执行read的情况下,如果没有数据可用,则管道上的读取端将一直等待数据写入或指向写入端的所有文件描述符关闭。在后一种情况下,read将返回0,就像到达数据文件的末尾一样。否则,read会一直阻塞,直到所有写端口都关闭,即不会再有新数据到来,这也是为什么我们使用exec运行wc之前要关闭子进程的写端口。如果wc的文件描述符之一指向了管道的写端口,wc永远不会看到文件结尾。
xv6 中的管道
①xv6 shell对管道的实现方式(例如 grep fork sh.c | wc -l)的方式和上面类似(user/sh.c:100)。子进程创建一个管道来连接管道的左端和右端。然后它为管道的左右两端调用fork和runcmd,并通过两次wait等待两端结束。管道的右端可能也是一个包含管道的命令,如
a | b | c
,它会fork两个新的子进程(b和c)。因此,shell可以创建进程树。这棵树的叶子是命令,中间节点是等待左右子节点执行结束的进程。
②理论上可以让内部节点运行在管道的左端,但这样做会使实现更加复杂。考虑只进行以下修改:修改sh.c,使得不为p->left执行fork,并在内部进程中运行runcmd(p->left)。然后,例如,echo hi | wc
不会产生输出,因为在runcmd中退出echo hi时,内部进程退出并且永远不会调用fork来运行管道的右端。这种不正确的行为可以通过不在runcmd中为内部进程调用exit来修复,但是这个修复使代码更加复杂化:现在runcmd需要知道它是否是内部进程。不为runcmd(p->right)分叉时也会出现相应的问题。例如,仅通过该修改,sleep 10 | echo hi
将立即打印“hi”而不是10秒后再打印,因为echo立即运行并退出,而不是等待sleep完成。由于sh.c的目标是尽可能简单,它不会试图避免创建内部进程。
管道的优点
管道似乎并不比临时文件更强大:
echo hello world | wc
可以在没有管道的情况下实现
echo hello world >/tmp/xyz; wc
但是在这种情况下,与临时文件相比,管道至少有四个优点。
①首先,管道会自动清理。如果是shell重定向的话,我们必须在完成任务后小心地删除 /tmp/xyz。
②其次,管道可以传递任意长的数据流,而文件重定向需要磁盘上有足够的可用空间来存储所有数据。
③第三,管道允许并行执行。而文件方法要求第一个程序在第二个程序开始之前完成。
④最后,如果你正在实现进程间通信,管道的阻塞读写比文件的非阻塞语义更有效。
文件和目录
xv6文件系统包含文件和目录。文件实质上就是以字节序列组成的信息载体,内核不解释文件内容。目录包含指向文件和其他目录的引用。整个目录结果就是一棵树,根结点就是root目录。/a/b/c指的是根目录下的目录a下的目录b下的文件c。不以/开头的目录表示的是相对于调用进程所在目录的目录,调用进程所在目录可以通过 chdir进行更改。
例子: 下面两个代码段都是打开同一个文件(假设所有涉及的目录都存在)。第一个代码段将当前目录更改为/a/b
。第二个对当前目录不做任何更改。
有很多的系统调用可以创建新文件和新目录:
①mkdir创建一个新目录
②open加上O_CREATE标志打开一个新的文件
③mknod创建一个新的设备文件。
例子:
其中,mknod创建一个指向某个设备的特殊文件,接受主设备号和辅助设备号这两个参数,它们唯一地标识一个内核设备。当一个进程打开一个设备文件时,内核将read和write系统调用转移到内核设备实现,而不是将它们传递给文件系统。
文件信息
文件名与文件本身不同。同一个文件(称为inode)可以有多个名称,称为链接(links)。每个链接由目录中的一个条目组成,该条目包含文件名和对inode的引用。inode保存有关文件的相关数据,包括文件类型(文件或目录或设备)、文件长度、文件内容在磁盘上的位置以及文件链接的数量。
fstat
fstat可以获取一个文件描述符指向的文件信息。它填充一个名为stat的结构体,该结构体在stat.h(kernel/stat.h)中定义如下:
link
link创建一个新的文件系统名称,与现有文件指向同一个inode。下面创建一个又叫a又叫b的新文件。
读写a相当于读写b。每个inode都由一个唯一的inode编号标识。在上面的代码中,我们可以通过fstat知道a和b都指向同样的内容,a和b都会返回同样的inode号(ino),并且nlink数会设置为2。
ulink
unlink从文件系统中删除一个名称,我们添加ulink("a")
到上述代码最后一行,会使inode和文件内容仅能通过b访问。文件的inode和保存其内容的磁盘空间仅在文件的链接数为0(即没有文件描述符引用它)时才被释放。此外,下面代码是创建一个没有名称的临时inode的惯用方式,当进程关闭fd或退出时将清理它。
文件操作程序
①Unix的文件操作程序都被实现为用户程序,例如mkdir、ln、rm。这种设计允许任何人通过添加新的用户级程序来扩展shell。现在看来,这个计划似乎很明显,但是在Unix时代设计的其他系统经常将此类命令内置到shell中(并将shell内置到内核中)。
②cd是一个例外,它在shell(user/sh.c:160)中实现。cd必须更改shell自身的当前工作目录。如果cd作为普通命令运行,那么shell将fork一个子进程,子进程将运行cd,而cd只更改子进程的工作目录,父进程(即shell)的工作目录不会改变。