首页> 资讯> 详情

世界资讯:第0章

2023-01-23 00:00:33 来源:哔哩哔哩

注:本文是原著的单纯翻译,Up没有新增内容。

操作系统接口/Operating system interfaces

操作系统的工作就是在多个程序之间共享一台计算机,并且提供一组比硬件独立支的功能更有用的服务。操作系统管理并抽象底层硬件,因此,比如说,一个文字处理软件不需要关心它正在使用的是什么磁盘硬件。它也在多个程序之间共享硬件好让他们(看起来)同时运行。最后,操作系统为程序提供可控的内部交互方式,以便它们可以共享数据或协同工作。


【资料图】

操作系统通过接口(Interface)来为用户程序提供服务。设计一个好的接口是很难的。一方面,我们想要接口简单且数量少,因为这样好实现。另一方面,我们也想为应用程序提供更多更复杂的功能。解决的技巧就是几个设计机制综合考虑,来设计接口以提供更多通用性。

本书使用一个具体的操作系统作为例子来说明操作系统概念。这个操作系统,xv6 ,提供了肯·汤普森和丹尼斯·里奇的Unix操作系统里的基本接口,并且模拟了Unix的内部设计。Unix提供了一组数量少但设计理念结合很好的接口,还拥有非常高水平的通用性。这个接口如此成功,现代操作系统- BSD,Mac OS X,Solaris,甚至有些出乎意料的,Microsoft Windows-都有类Unix接口。理解了xv6以后,上面这些操作系统还有其他系统理解起来就简单了。

如同图 0-1 展示的,xv6 使用了传统内核(kernel)模式,内核是一个用来提供服务以运行程序的特殊程序。每一个运行的程序,称为一个进程(process),拥有一段包含指令,数据和栈的内存。指令实现了程序的计算工作。数据就是计算操作的变量。栈组织了程序的函数调用。

当一个进程需要使用内核的服务,它其实是引用了操作系统接口中的一个函数调用。这样一个函数叫做系统调用(system call)。系统调用进入了内核;内核执行服务并返回。因此一个进程在用户空间(user space)和内核空间(kernel)之间轮流执行。

内核使用CPU的硬件保护机制来确保每个进程在用户空间执行时只能访问它自己的内存。内核执行借由硬件特权级来实现这些保护;用户程序执行执行不需要这些特权。当一个用户程序引用了一个系统调用,硬件会提升特权级别并开始执行内核中一个预设的函数。

内核提供的系统调用集合就是用户程序能看到接口。Xv6 内核提供了一组Unix内核的经典服务和系统调用。图 0-2 列出了xv6 的所有系统调用。

本章剩余内容描绘了xv6 的服务-进程,内存,文件描述符,管道还有文件系统-并使用代码片段来说明,并且讨论了shell(经典类Unix系统的常见用户界面)如何使用它们。Shell对系统调用的使用说明了它们设计的多么谨慎。

Shell是个普通程序,从用户那里读取命令并执行。事实上shell是一个用户程序,不是内核的一部分,展示了系统调用接口的威力:shell没什么特殊的。这也意味着shell很容易替换;结果就是,现代Unix系统有很多shell可选,每个都有自己的用户界面和脚本功能。Xv6 shell是Unix默认的Bourne shell的复刻。其实现代码在第8550行。

进程和内存/Processes and memory

一个xv6进程包括用户空间内存(指令,数据和栈)以及每个进程的内核专属状态。Xv6 能在进程间分时共享(time-share)硬件:它在等待执行的进程集合中切换可用的CPU。当一个进程没在执行,xv6就保存它的CPU寄存器,当下次这个进程再运行时再恢复。内核通过一个进程标识,或者叫pid,与每个进程联系。

一个进程可能使用fork系统调用来创建一个新的进程。Fork创建一个新进程,叫做子进程(child process),拥有与调用进程完全相同的内存内容,叫做父进程(parent process)。Fork在子和父中都返回。在父进程中,fork返回子的pid;在子进程中,会返回0。比如,思考如下的代码段:

Exit系统调用引发调用进程停止执行并且释放如内存和打开的文件等资源。wait系统调用返回一个当前进程已退出的子进程的pid;如果调用者没有已退出的儿子,wait等待其中一个退出。在例子中,输出内容也许不一定是下面的顺序,

取决于父还是子先执行printf。子退出后父的wait返回,引起父的print

尽管子进程在一开始的内存内容与父进程一样,父和子执行时却使用不同的内存和寄存器:改变其中一个的变量不影响另一个。比如,当wait返回值保存到父进程的pid,它并不会改变子进程的变量pid。子进程的pid值仍然是0。

Exec系统调用使用一个保存在文件系统中的文件加载后的新的内存镜象来替换调用进程的内存。文件必须有特定的格式,指出文件哪部分保存指令,哪部分是数据,从哪条指令开始执行,等等。xv6使用ELF格式,第2章会详细讨论。当exec成功后,它不会返回到调用程序;取而代之的是从ELF头中声明的入口点处的指令开始执行。Exec接受2个参数:包含可执行指令的文件以及一个string类型的参数序列。比如:

这个代码段使用/bin/echo程序带着echo hello参数列表运行的一个实例替换了调用程序。多数程序会忽略第一个参数,就是程序的名字。

Xv6 shell使用以上的调用来运行代表用户的程序。shell的主结构很简单;参考main(8701)。main循环使用getcmd读取一行用户输入。然后它调用fork,创建了shell进程的一份拷贝。子进程运行命令时父调用wait。比如,如果用户在shell中输入"echo hello",将会带"echo hello"参数来运行runcmd。runcmd运行实际的命令。对于"echo hello",它会调用exec。如果exec成功了,子进程会执行来自echo程序的指令而不是runcmd。某个时间点echo会调用exit,这将引起父进程从main的wait中返回;接下来我们会看到创建一个进程和加载一个程序做成分开的调用是明智的。

Xv6 隐式的开辟大多数用户空间内存:fork开辟子进程复制父进程内存时所需的内存,exec为可执行文件开辟足够的内存。一个进程如果在运行时需要更多内存(可能需要malloc)可以调用sbrk(n)来增加n子节的数据内存;sbrk返回新内存的位置。

Xv6 没有提供用户系统或者用户间的保护;从Unix角度看,所有xv6 的进程都以root运行。

I/O 和文件描述符/File descriptor

文件描述符是一个小整数,它代表一个内核管理的对象,进程可能会从之读取或写入。进程可以通过打开一个文件,目录,或者设备,或者创建一个管道,或者复制一个现有的描述符来获得一个文件描述符。简单来说我们常把文件描述符所代表的对象叫做一个文件;文件描述符接口抽象了文件,管道和设备之间的差异,让它们看起来都像是字节流。

在内核里,xv6使用文件描述符作为每个进程描述符表的索引,所以每个进程都有一个从0开始的描述符空间。为了方便,进程从文件描述符0(standard input)读取,写到文件描述符1(standard output),错误信息写到文件描述符2(standard error)。我们将会看到,shell利用了这个便利来实现I/O重定向和管道。Shell保证一定会有三个文件描述符保持打开(8707),作为控制台的默认描述符。

Read和write系统调用从文件描述符代表的打开的文件读取字节或写入。Read(fd, buf, n)从文件描述符fd中读取最多n个子节,把它们拷贝到buf,然后返回读取字节数。每个文件描述符都代表一个文件,有一个偏移(offset)记录进度。Read从当前文件的偏移读取数据然后再前进读取的字节数:随后的read将返回第一个read返回之后的字节。当没有字节可读了,read返回0来代表文件已达末尾。

Write(fd, buf, n)调用从buf里写入n个字节到fd文件描述符,并返回写入的字节数。只有发生错误时写入字节数才会小于n。像read一样,write从文件当前偏移写入数据,然后把偏移前进写入的字节数:每个write都是从前一write离开的地方开始写入。

下面的程序片段(cat的基本部分)从它的标准输入拷贝数据到它的标准输出。如果发生错误,它会写入一个信息到标准错误。

代码段中需要注意的是cat不知道它是从文件,控制台,或是管道读取的。同样cat也不知道它是打印到控制台,文件或者其他。文件描述符的应用以及文件描述符0是输入、1是输出的便利性让cat实现起来很简单。

Close系统调用释放一个文件描述符,让其可被未来的open,pipe,或者dup系统调用使用。一个新开辟的文件描述符总是当前进程未使用的文件描述符的最小值。

文件描述符和fork一起让I/O重定向很容易就能实现。Fork拷贝了父的文件描述符表以及它的内存,所以子可以使用与父完全相同的打开的文件。系统调用exec替换了调用进程的内存但保留了它的文件表。这个行为让shell通过forking,重新打开选定的文件描述符,然后执行新程序就实现了I/O重定向。这是一个shell运行cat < input.txt命令的简单版代码:

当子关闭了文件描述符0,open文件input.txt一定会使用确定的文件描述符:0将会是最小的可用文件描述符。Cat执行时就使用代表input.txt的文件描述符0。

Xv6 shell里I/O重定向的代码就是这样工作的。回忆一下,此时代码里shell已经fork了子shell,runcmd将调用exec来加载新程序。现在为啥把fork和exec做成分开的调用比较好已经很清楚了。因为如果它们分开了,shell可以fork一个子进程,在子进程中使用open,close,dup来改变标准输入和输出文件描述符,然后exec。被执行的程序(例子中是cat)无需做任何改变。如果fork和exec融合成一个单一的系统调用,shell将需要一些其他(也许更复杂)的方案来重定向输入和输出,或者程序本身将必须理解如何重定向I/O。

尽管fork复制了文件描述符表,每个基础文件的偏移在父与子之间是共享的。考虑这个例子:

在代码段的最后,连接到文件描述符1的文件将包含数据hello world。父的write(感谢wait,只有在子结束后才会运行)紧接着子的write完的位置继续写。这个行为让序列化shell命令能产生序列化的输出,比如echo hello; echo world > ouput.txt。

Dup系统调用可以复制已经存在的文件描述符,产生一个新的引用相同基础I/O对象的描述符。就像fork复制的文件描述符一样,两个文件描述符共享偏移。这是另一种方法写入hello world到文件:

如果两个文件描述符通过fork和dup调用,起源于相同的原始文件描述符,那么它们会共享偏移。否则文件描述符不会共享偏移,即使它们open的是同一个文件。Dup让shell可以实现这样的命令:ls exiting-file non-exiting-file > tmp1 2>&1。2>&1告诉shell把命令交给复制自文件描述符1的描述符2。Exiting-file的名字和Non-exiting-file的错误信息都会在tmp1文件显示。Xv6 shell不支持错误文件描述符的I/O重定向,但你现在知道该怎么实现了。

文件描述符是一个强力的抽象,因为它隐藏了描述符连接对象的细节:进程写入到文件描述符1也许是一个文件,控制台样的设备,或者管道。

管道/Pipes

管道是作为一对描述符形态出现的暴露给进程的小型缓冲区,一个描述符用来读一个用来写。写入数据到管道一端后就可以从管道另一端读取。管道提供了一种进程间通信的方式。

下面的示例代码把标准输入连接到管道的读取端来运行wc程序。

程序调用了pipe创建了一个新的管道,并把读取和写入文件描述符记录到数组p。Fork之后,父与子都拥有了管道两端的文件描述符。子把管道读取端复制到文件描述符0,关闭了p中的文件描述符,然后执行wc。当wc从它的标准输入读取,它是从管道读取的。父关闭了管道的读取端,写入到管道,然后关闭了管道写入端。

如果管道里没有数据可用,对管道的read要么等待管道写入数据,要么所有引用管道写入端的文件描述符全都关闭;后面这种情况read会返回0,也就是到达了一个文件的末尾。事实上read在新数据已经不可能到达之前会一直卡住,这是子必须在执行wc前关闭管道写入端的重要原因之一:如果wc的文件描述符引用了管道的写入端,那么它永远看不到文件结束。

Xv6 shell实现诸如grep fork sh.c | wc -l类的管道操作与上面的代码类似(8650)。子进程创建了一个管道并连接了它的左右两端。然后它为管道左侧调用了fork和runcmd,为管道右侧也调用了fork和runcmd,然后等待两端结束。管道右侧也许是一个自身包含管道的命令(比如 a | b | c),它自己就fork了两个子进程(一个是b,一个是c)。因此shell可能会创建一个进程树。树的叶子是命令,而内部节点是等待两侧叶子结束的进程。原则上讲,你可以让内部节点来运行管道的左侧,但这样实现起来会更复杂。

管道看起来好像并不比临时文件强力,管道命令

也可以不用管道实现:

这种情况下管道相比临时文件有四个优势:

#1 管道是自清理的;而文件重定向操作下,shell要很小心的删除/tmp/xzy。

#2 管道可以传送很大的数据流,但文件重定向要求硬盘必须有足够空间来保存数据。

#3 管道允许不同的执行阶段并行,而文件则要求第一个操作结束后第二个才能开始。

#4 如果你想实现进程间通信,管道的读取写入阻塞比语义上无阻塞的文件要更高效。

文件系统/File system

Xv6 文件系统提供数据文件,也就是无释义的字节数组,还有目录,包含了有名字的数据文件和其他目录。目录形成了一棵树,从一个叫root的特殊目录开始。像是/a/b/c这样的路径代表了根目录 / 下的a目录下的b目录下的c文件或c目录。不以/开头的路径代表的是进程当前目录(current directory)的相对路径,可以使用chdir系统调用来改变。下面的代码都打开了同一个文件(假定所有文件都存在):

第一个代码段改变进程的当前目录到/a/b;第二个则没有改变进程当前目录。

有多个系统调用可以创建一个新文件或目录:mkdir创建一个新目录,使用O_CREATE标志来open创建一个新的数据文件,mknod创建一个新的设备文件,以下代码模拟了上述情况:

Mknod在文件系统中创建了一个文件,但是文件没有内容。取而代之的是,文件的元数据把它标记为一个设备文件并记录主要和次要的设备数字(mknod的两个参数),代表唯一的内核设备。后续一个进程打开该文件,内核会把read和write系统调用转到内核设备的实现而不是文件系统。

Fstat可以获取一个文件描述符代表的对象信息。它会填充stat.h里定义的struct stat:

一个文件的名字和文件本身并不一样;一个基础文件称为一个inode,可以有不同的名字,叫做links。Link系统调用可以创建针对一个已存在文件的inode的另一个文件系统名称。这个代码段创建了同时名为a和b的文件:

对a的读取和写入跟对b的读取和写入是一样的。每个inode被唯一的inode number标识。上面的代码执行完,可以通过fstat来确定a和b引用的是同一个基础文件:二者会返回相同的inode number(ino),nlink的值是2。

Unlink系统调用从文件系统中删除一个文件名。文件的inode和它所占用的硬盘空间只有在文件的连接数为0且没有文件描述符引用它时才会释放。因此增加:

到上面的代码段,仍可以通过b来访问inode和文件内容。另外:

是用来创建一个当进程关闭fd或退出时自动清理的临时inode的惯用方法。

像是mkdir,ln,rm等的文件系统shell命令是在用户级别(user-level)实现的。这个设计让任何人都可以通过增加一个新的用户程序来拓展shell的用户级命令。事后诸葛亮的看,这个计划是显而易见的,但Unix同期的其他系统通常把这些命令内建到shell里(shell内建到kernel里)。

有个例外是cd,它是内建到shell里的(8716)。cd必须改变shell本身的当前工作目录,如果cd作为一个普通命令运行,那么shell将fork一个子进程,子进程将运行cd,改变子进程的当前工作目录。父进程(比如shell)的工作目录则不会改变。

现实世界/Real World

Unix把标准文件描述符,管道和shell方便的语法结合起来,是开发通用、可重用程序的一大利器。这个创意激发一整个“软件工具”文化,是Unix强力与流行的主要原因,shell就是第一个所谓的“脚本语言”。Unix系统调用接口在当今的BSD,Linux,Mac OS X系统中仍然存在。

Unix系统调用接口已经通过可移植操作系统接口(POSISX)标准进行了标准化。Xv6 不是POSIX兼容的。它缺少了一些系统调用(包括像lseek这种基本的),它只部分实现了系统调用的功能等。Xv6 的主要目的是简单明了的,就是提供一个类Unix系统调用接口。有几个人通过增加系统调用并提供一个简单的C库拓展了xv6 ,可以运行基本的Unix程序。然而现代系统内核提供了比xv6 更多的系统调用已经内核服务。比如,它们支持网络,视窗系统,用户级线程,很多设备的驱动等等。现代内核不断的周期性迭代,提供比POSIX更多的功能。

现代Unix驱动的系统大多都没有像早期Unix系统那样把设备直接暴露为一个特殊的文件,像是之前讨提到过的控制台设备。Unix的作者继续开发了Plan 9,把“资源即文件”的概念应用到现代设备上,把网络、显卡以及其他设备作为文件系统的文件。

文件系统的抽象是一个威力巨大的想法,最近的是在万维网上资源文件的应用。尽管如此,操作系统接口仍然有其他模型。Multics,Unix的前任,把文件存储抽象为类似内存,产生了一种不同风格的接口。Multics系统设计的复杂性直接影响了Unix的作者,他们像做的简单一些。

这本书审视了xv6 是如何实现类Unix接口的,但应用的概念和想法并不限于Unix。任何操作系统必须在基础硬件上实现多进程,实现进程间隔离,提供可控的进程间通信机制。学习完xv6,你应该能够在其他复杂的操作系统中看到xv6应用的基础概念。

关键词: READ SHELL UNIX CLOSE 文件系统 INTERFACE EXIT WAIT 事后诸葛亮 脚本语言 OPEN PIPE 操作系统 Hello_world TIME SHARE ECHO SOLARIS MAIN LINKS 从0开始 printf Offset PLAN linux 用户界面 ROOT STRING 可以通过 使用说明

上一篇:
下一篇: