本系列文章为MIT6.S081的学习笔记,包含了参考手册、课程、实验三部分的内容,前面的系列文章链接如下
操作系统MIT6.S081:[xv6参考手册第1章]->操作系统接口
本文由MITS6.081课程内容翻译而来,由于水平有限,加之第一次学习操作系统,许多内容难免有误。敬请各位网友指出以便持续修正,谢谢!
参考内容: MIT6.S081(2020)翻译简介
这门课程的目标
①理解操作系统的设计和实现。设计是指整体的结构,实现是指具体的代码长什么样。
②通过一个名为XV6的小型操作系统深入了解具体的工作原理、获得扩展操作系统、修改并提升操作系统的相关经验
③通过研究现有的操作系统并结合课程配套的实验,能够通过操作系统接口编写系统软件。
期待操作系统支持的目标
市面上有大量不同的操作系统,通常来说它们都有一些共同的目标:
①Abstraction。通常来说,你买了一台计算机,里面包含了CPU、内存等硬件,但是这是一种非常低层级的资源。幸好我们有一些应用程序实现了高层级的接口和抽象,例如进程、文件系统。这些高层级的接口和抽象(Abstraction)方便了应用的开发,也提供了更好的移植性。
②multiplex。操作系统的另一个重要的任务是:在多个应用程序之间共用硬件资源。你可以在一个操作系统同时运行文本编辑器、程序编译器、多个数据库等应用程序。操作系统能非常神奇地在不相互干扰的前提下同时运行这些程序,这里通常被称为multiplex。
③Isolation。在操作系统中可能同时运行很多程序,当一个或多个程序出现了故障,这些程序之间互不干扰就变得非常重要。所以这里需要隔离性(Isolation),不同的活动之间不能相互干扰。
④Sharing。另一方面,不同的活动之间有时又想要相互影响,比如说数据交互、协同完成任务等。例如,我通过文本编辑器创建了一个文件,我希望我的编译器能读取文件,即我们想共享一些数据。所以,我们希望能在需要的时候实现共享(Sharing)。
⑤Security。是在很多场景下,用户并不想要共享。比如你登录到了一个公共的计算机,你不会想要其他人来读取你的文件。所以在共享的同时,我们也希望在没有必要的时候不共享。这里我们称为Security或Permission System或Access Control System。
⑥Performance。人们认为操作系统应该具有的价值是:如果你在硬件上花费了大量的金钱,你的应用程序应该拥有硬件提供的完整性能。所以操作系统必须保证自身提供的服务不会阻止应用程序获得高性能,甚至需要帮助应用程序获得高性能(Performance)。
⑦Range of uses。对于大部分操作系统,必须要支持大量不同类型的应用程序,如一个笔记本正在运行文本编辑器、正在运行游戏,或者你的操作系统需要支持数据库服务器和云计算。通常来说,设计并构造一个操作系统代价是非常大的,所以人们总是希望在相同的操作系统上运行大量的任务。所以,同一个操作系统需要能够支持大量不同的用户场景(Range of uses)。
操作系统的组织架构
过去几十年,人们将一些分层的设计思想加入到操作系统中,并运行得很好。操作系统经典的组织结构如下所示:
①底层硬件
当我们想到操作系统得组织结构时,我们首先会想到用一个矩形表示一个计算机。这个计算机有一些硬件资源,包括CPU、内存、磁盘、网卡等。我们将这些硬件资源其放在矩形的下面,所以硬件资源在最底层。
②用户空间
在这个架构的最上层,我们会运行各种各样的应用程序,如文本编辑器(VI)、C编译器(CC)、作为命令行界面(Command-line Interface, CLI)存在的Shell。这里程序都运行在同一个空间中,我们通常将其称为用户空间(Userspace)
③内核
区别于用户空间程序,有一个特殊的程序总是会在运行,它被称为Kernel。Kernel是计算机资源的守护者,当你打开计算机时,Kernel总是第一个被启动。Kernel程序只有一个,它维护数据来管理用户空间中的每一个进程。Kernel同时还维护了大量的数据结构来帮助它管理各种各样的硬件资源,以供给用户空间的程序使用。Kernel同时还有大量内置的服务,如文件系统FS,它实现了类似文件名、文件内容、目录的东西,并知道如何将文件存储在磁盘中。所以用户空间的程序会与Kernel中的文件系统交互,文件系统再与磁盘交互。
Kernel提供的服务
在这门课程中,我们主要的关注点在Kernel内部发生的事情、连接Kernel和用户空间程序的接口、Kernel内软件的架构。我们来看看Kernel提供的一些服务
①进程
每一个用户空间中的程序都被称为一个进程,它们有自己的内存和共享的CPU时间。
②内存分配
Kernel会管理各个进程内存的分配。不同的进程需要不同数量的内存,Kernel会复用内存、划分内存,并为所有的进程分配内存。
③文件内容
文件系统通常有一些逻辑分区。目前而言,我们可以认为文件系统的作用是管理文件内容并找出文件具体在磁盘中的哪个位置。
④文件名、文件目录
文件系统还维护了一个独立的命名空间,其中每个文件都有文件名,并且命名空间中有一个层级的目录,每个目录包含了一些文件。所有这些都被文件系统所管理。
⑤访问控制
当一个进程想要使用某些资源时,比如读取磁盘中的数据、使用某些内存,Kernel中的Access Control机制会决定是否允许这样的操作。对于一个分时共享的计算机(例如Athena系统),这里可能会变得很复杂。因为在Athena系统中,每一个进程可能属于不同的用户,因此会有不同Access规则来约定哪些资源可以被访问。
⑥其它
在一个真实的完备的操作系统中,还会有很多其他的服务。比如在不同进程之间通信的进程间通信服务、许多与网络关联的软件(TCP/IP协议栈)、支持声卡的软件、支持数百种不同磁盘、不同网卡的驱动。所以在一个完备的系统中,Kernel会包含大量的内容,数百万行代码。
Kernel的一些API
我们也对应用程序是如何与Kernel交互、它们之间的接口长什么样感兴趣。这里通常称为Kernel的API,它决定了应用程序如何访问Kernel。通常来说,这里是通过所谓的系统调用(System Call)来完成。系统调用与程序中的函数调用看起来是一样的,但区别是系统调用会实际运行到系统内核中,并执行内核中对于系统调用的实现。在这门课程的后面,我们再详细介绍系统调用,现在我们只学习以下系统调用在应用程序中是长什么样的。
open
fd = open("out", 1)
。如果应用程序需要打开一个文件,它会调用名为open的系统调用,并且把文件名作为参数传给open。假设现在要打开一个名为“out”的文件,那么会将文件名“out”作为参数传入。同时我们还希望写入数据,那么还会有一个额外的参数,在这里这个参数的值是1,表明我想要写文件。这里看起来像是个函数调用,但是open是一个系统调用,它会跳到Kernel。Kernel可以获取到open的参数,执行一些实现了open的Kernel代码,或许会与磁盘有一些交互,最后返回一个文件描述符对象fd(file descriptor)。之后,应用程序可以使用这个文件描述符来表示相应打开的文件。
write
write(fd, "hello\n", 6)
。如果你想要向文件写入数据,相应的系统调用是write。你需要向write传递一个由open返回的文件描述符作为第一个参数。还需要向write传递一个指向要写入数据的指针(数据通常是char型序列)作为第二个参数。所以我们这里实际上告诉内核,将内存中这个地址起始的6个字节数据写入到fd对应的文件中。第三个参数是你想要写入字符的数量。
fork
int pid = fork()
。fork是一个这样的系统调用,它创建了一个与调用进程一模一样的新的进程,并返回新进程的process ID(PID)。这里实际上会复杂的多,我们后面会有更多的介绍
注:所以对这些系统调用看起来就跟普通的函数调用一样。系统调用不同的地方是,它最终会跳到系统内核中。
设计操作系统的矛盾点
①学习操作系统比较难的一个原因是内核的编程环境比较困难。当你在编写、修改、扩展内核或者写一个新的操作系统内核时,你实际上在提供一个基础设施让别人来运行他们的程序。当程序员在写普通的应用程序时,应用程序下面是操作系统。而当我们在构建操作系统时,在操作系统下面就是硬件了,这些硬件通常会更难处理。
②学习操作系统比较难的另一个原因是当你在设计一个操作系统时,你需要满足一些列矛盾的需求。
----efficient vs abstract/portable/general-purpose
----第一个矛盾点是你想要操作系统既高效又易用。高效通常意味着操作系统需要在离硬件更近的层面进行操作。而易用则要求操作系统为应用程序提供抽象的高层级的可移植接口。所以,提供一个简单可移植,同时又高效的抽象接口需要一定的技巧。
----powerful vs simple interfaces
----另一个矛盾的点是我们想要一个提供强大功能的操作系统服务,这样操作系统才能分担运行应用程序的负担。但同时,我们也想要有简单的接口。我们不想程序员看到数量巨多,复杂且难以理解的的内核接口。因为如果他们不理解这些接口,他们就会很难使用这些接口。实际上我们是想提供既简单,同时又包含强大功能的接口。
----flexible vs secure
----最后一个矛盾点是你希望给与应用程序尽可能多的灵活性,你不想要限制应用程序,所以你需要内核具备灵活的接口。但是另一方面,你也需要在某种程度上限制应用程序,因为你会想要安全性。我们希望给程序员完全的自由,但是实际上又不能是真正的完全自由,因为我们不想要程序员能直接访问到硬件,这样可能会干扰到其它的应用程序或者干扰操作系统的行为。
③另一件使得操作系统的设计难且有趣的点是:操作系统提供了大量的特性和服务,它们趋向于相互交互。有时,这种交互以奇怪的方式进行,并且需要你仔细思考。即使在前面给出的一个简单例子中(open和fork),它们之间也可能有交互。如果一个应用程序通过open系统调用得到了一个文件描述符fd,之后这个应用程序调用了fork系统调用(fork是创建一个子进程,而对于这个子进程,父进程中的文件描述符也必须存在且可用)。所以在这里,一个通过open获得的文件描述符与fork以这种有趣的方式进行交互。当然,你需要想明白,子进程是否能够访问到在fork之前创建的文件描述符fd。在我们要研究的操作系统中答案是,Yes,需要能够访问。
④另一件有趣的事情就是操作系统需要能够满足广泛的使用场景。相同的操作系统需要既给数据库服务器使用,又给智能手机、虚拟机、嵌入式等设备使用。
⑤随着时间的推移,你的计算机所使用的硬件也在变化,或许你有了超级快的SSD存储而不是机械的硬盘。比如大概15年前,多核CPU计算机还极其稀有,而现在变得极其的流行。最近,我们又看到了网速以指数级增长。所有的这些都需要时不时的重新思考,操作系统是如何被设计的。
问答
学生提问:系统调用跳到内核与标准的函数调用跳到另一个函数相比,区别是什么?
Robert教授:Kernel在机器启动时加载,它会有特殊的权限直接访问各种各样的硬件,例如磁盘。而普通的用户程序是没有办法直接访问这些硬件的。所以,当你执行一个普通的函数调用时,你所调用的函数并没有对于硬件的特殊权限。然而,如果你触发系统调用跳到内核中,内核中的具体实现会具有这些特殊的权限,这样就能修改敏感的和被保护的硬件资源,比如访问硬件磁盘。
学生提问:对于应用程序开发人员来说,他们会基于一些操作系统做开发,真正的深入理解这些操作系统有多重要?他们需要成为操作系统的专家吗?
Robert教授:你不必成为一个专家。但是如果你花费大量时间来开发,维护并调试应用程序,你最终还是会知道大量操作系统的知识。不论你是否是有意要掌握这些知识,它们就是出现了,而你不得不去理解它们。
学生提问:对于一些例如Python的高阶编程语言(高阶是指离自然语言更接近,低阶是指离机器语言更接近如C,汇编),它们是直接执行系统调用呢,还是内部对系统调用进行了封装呢?
Robert教授:许多高阶的编程语言都离系统调用较远,这是一个事实。部分原因是很多编程语言想要提供可以在多个操作系统上运行的可移植的环境,所以它们不能依赖特定的系统调用。所以,对于这个问题的答案我认为是,如果你使用了Python,你在某种程度上就与系统调用接口隔离了。当然,在Python内部,最终还是要执行系统调用来完成相应的工作。当然,Python和许多其他的编程语言通常都有方法能直接访问系统调用。
XV6
①接下来们讨论对于应用程序来说系统调用长成什么样。由于系统调用是操作系统提供的服务的接口,所以系统调用长什么样、应用程序期望从系统调用得到什么返回、系统调用是怎么工作的,这些还是挺重要的。你会在第一个lab中使用我们在这里介绍的系统调用,并且在后续的lab中扩展并提升这些系统调用的内部实现。
②接下来我们会在XV6中展示并运行一些例子,这些例子会执行系统调用。XV6是一个简化的类似Unix的操作系统,而Unix是一个老的操作系统,但同时也是很多现代操作系统的基础,例如Linux、OS X。作为我们教学用的操作系统,XV6就要简单的多,它是受Unix启发创造的,有着相同的文件结构,但是却要比任何真实的Unix操作系统都要简单的多。因为它足够简单,所以你们极有可能在几周内很直观地读完所有的代码,同时也把相应的书也看完,这样你们就能理解XV6内部发生的一切事情了。XV6运行在一个RISC-V微处理器上,而RISC-V是MIT6.004课程讲解的处理器,所以你们很多人可能已经知道了RISC-V指令集。理论上你可以在一个RISC-V计算机上运行XV6,已经有人这么做了。但是我们会在一个QEMU模拟器上运行XV6。
③接下来,我们会展示一下代码。首先,我们在笔记本上设置好XV6。首先输入make qemu,你会发现你在实验中会经常用到这个命令。这条指令会编译并构建xv6内核和所有的用户进程,并将它们运行在QEMU模拟器下。(注意:XV6是用C语言写的。)
编译需要花费一定的时间。
现在xv6系统已经起来并运行了。$表示Shell,这是参照Unix上Shell的命令行接口。如果你用过Athena工作站,它的Shell与这里的非常像。XV6本身很小,并且自带了一小部分的工具程序,例如ls。我这里运行ls,它会输出xv6中的所有用户文件,这里只有20多个。(我这里只有19个,需要在课程中逐渐完善)
可以看到,这里还有grep,kill,mkdir和rm,或许你们对这些程序很熟悉,因为它们在Unix中也存在。
copy
我们展示的第一个系统调用是一个叫做copy的C程序。
它的源代码只有不到一页,它在第12行进入到一个循环中。在循环中,它会在第13行从输入读取一些数据,并在第16行将数据写入到输出。如果我在XV6中运行这个copy程序,它会等待输入。
我随便输入一些字符,程序会读取我输入的字符,并将相同的字符输出给我。
这个程序里面执行了3个系统调用,分别是read,write和exit。
read函数
第13行的read,它接收3个参数:
----read的第一个参数是文件描述符,指向一个之前打开的文件。Shell会确保默认情况下,当一个程序启动时,文件描述符0连接到console的输入,文件描述符1连接到了console的标准输出。所以我可以通过这个程序看到console打印我的输入。当然,这里的程序会预期文件描述符已经被Shell打开并设置好。这里的0,1文件描述符是非常普遍的Unix风格,许多的Unix系统都会从文件描述符0读取数据,然后向文件描述符1写入数据。
----read的第二个参数是指向某段内存的指针,程序可以通过指针对应的地址读取内存中的数据。这里的指针就是代码中的buf参数,在代码第10行。程序在栈里面申请了64字节的内存,并将指针保存在buf中,这样read可以将数据保存在这64字节中。
----read的第三个参数是代码想读取的最大长度,sizeof(buf)表示最多读取64字节的数据。所以这里的read最多只能从连接到文件描述符0的设备,也就是console中读取64字节的数据。
----read的返回值可能是读到的字节数,在上面的截图中也就是6(xyzzy加上结束符)。read会从一个文件读数据,如果没有读到内容了,read会返回0。如果出现了一些错误如文件描述符不存在,read或许会返回-1。在后面的很多例子中,比如第16行,我都没有通过检查系统调用的返回来判断系统调用是否出错,但是你应该比我更加小心,你应该清楚系统调用通常是通过返回-1来表示错误,你应该检查所有系统调用的返回值以确保没有错误。如果你想知道所有的系统调用的参数和返回值是什么,在XV6参考手册的第二章有一个表格展示了XV6中系统调用的相关信息。
问答
学生提问:如果read的第三个参数设置成1+sizeof(buf)会怎样?
Robert教授:如果第三个参数是65字节,操作系统会拷贝65个字节到你提供的内存中(第二个参数)。但是如果栈中的第65个字节有一些其它数据,那么这些数据会被覆盖。这里是个bug,或许会导致你的代码崩溃或者一些异常的行为。所以,作为一个程序员,你必须要小心。C语言很容易写出一些编译器能通过的,但是最后运行时出错的代码。虽然很糟糕,但是现实就是这样。有一件事情需要注意的事,这里的copy程序,或者说read、write系统调用,它们并不关心读写的数据格式,它们就是单纯的读写。而copy程序会按照8bit的字节流处理数据,你怎么解析它们完全是用应用程序决定的。所以应用程序可能会解析这里的数据为C语言程序,但是操作系统只会认为这里的数据是按照8bit的字节流。
学生提问:字节流是什么意思?
Robert教授:如果一个文件包含了一些字节,假设包含了数百万个字节,你触发了多个read,每个read读取100个字节。第一次read会读取前100个字节,第二次读取101-200个字节,第三次读取201-300个字节(字节流就是一段连续的数据按照字节的长度读取)
open函数
在前面的copy代码中,我们是假设文件描述符已经设置好了。但是一般情况下,我们需要能创建文件描述符,最直接的创建文件描述符的方法是open系统调用。下面是open的源代码,它使用了open系统调用。
这个open程序会创建一个叫做output.txt的新文件,并向它写入一些数据,最后退出。我们看不到任何输出,因为它只是向打开的文件中写入数据。
但是我们可以查看output.txt的内容。并看到open程序写入的“ooo”。
一些细节:
----代码中的第11行执行了open系统调用,将文件名output.txt作为第一个参数传入,表示要打开的文件的名称。第二个参数是一些标志位,用来告诉open系统调用在内核中的实现,我们将要创建并写入一个文件。open系统调用会返回一个新分配的文件描述符,这里的文件描述符是一个小的数字,可能是2,3,4或者其他的数字。
----之后,这个文件描述符作为第一个参数被传到了write,write的第二个参数是数据的位置(指针),第三个参数是要写入的字节数。数据就被写入到了文件描述符对应的文件中。
文件描述符:
文件描述符本质上做的就是索引到内核中一个维护进程状态的表中。内核会为每一个运行进程保存一张表,表中通过文件描述符进行索引。这张表告诉内核每个文件描述符引用的是内容什么。这里比较关键的点是,每个进程都有自己独立的文件描述符空间,所以如果运行了两个不同的程序,对应两个不同的进程,如果它们都打开一个文件,它们或许可以得到相同数字的文件描述符。但是因为内核为每个进程都维护了一个独立的文件描述符空间,这里相同数字的文件描述符可能会对应到不同的文件。
问答
学生提问:我不太熟悉C语言,这里的文件描述符与非C语言中的有什么区别?如果使用Python的话,语法上会不会简单点?
Robert教授:Python肯定提供了对于open的较好的封装。通常来说,Python提供的是更高级的函数,比如说Python不会使用指向内存的指针,并且Python会为你做更多的错误检查。当我们在Python中打开文件或者写入文件时,你在Python中的调用最终会走到跟我们例子中一样的系统调用来。
Shell
对图形化用户接口来说,XV6的Shell很像Unix的Shell,这里的Shell通常也是人们说的命令行接口。Shell是一种对于Unix系统管理来说非常有用的接口,它提供了很多工具来管理文件、编写程序、编写脚本。通常来说,当你输入内容时,你是在告诉Shell运行相应的程序。当我输入ls时,实际的意义是我要求Shell运行名为ls的程序,文件系统中会有一个文件名为ls,这个文件中包含了一些计算机指令。所以实际上当我输入ls时,我是在要求Shell运行位于文件ls内的这些计算机指令。运行ls,它实际的工作就是输出当前目录的文件列表。你可以看到第4行就是ls文件,这就是包含了计算机指令的文件。
除了运行程序以外,Shell还会做一些其他的事情。比如它允许你能重定向IO。这里我输入ls > out
这里的实际意义是我要求Shell运行ls命令,但是将输出重定向到一个叫做out的文件中。这里执行完成之后我们看不到任何的输出,因为输出都送到了out文件。现在我们知道out文件包含了一些数据,我们可以通过cat指令读取一个文件,并显示文件的内容。
之后我们可以看到ls指令相同的输出。
你也可以运行一个名为grep的指令,并将x作为参数传给grep。
grep x会搜索输入中包含x的行,我可以告诉shell将输入重定向到文件out,这样我们就可以查看out中的x。
因为out文件包含了ls的输出,所以我们可以看出有3个文件的文件名包含了x。
注: 我们将会花很多时间在Shell上,Shell是最传统、最基础的Unix接口。因为当Unix最开始被开发出来时只有简单的终端接口,例如Shell。Unix最早的用途是给多个用户分时复用机器,用户通过Shell与机器交互。
问答
学生提问:有一个系统调用和编译器的问题。编译器如何处理系统调用?生成的汇编语言是不是会调用一些由操作系统定义的代码段?
Robert教授:有一个特殊的RISC-V指令,程序可以调用这个指令,并将控制权交给内核。所以,实际上当你运行C语言并执行例如open或者write的系统调用时,从技术上来说,open是一个C函数,但是这个函数内的指令实际上是机器指令,也就是说我们调用的open函数并不是一个C语言函数,它是由汇编语言实现,组成这个系统调用的汇编语言实际上在RISC-V中被称为ecall。这个特殊的指令将控制权转给内核。之后内核检查进程的内存和寄存器,并确定相应的参数。
fork
fork会创建一个新的进程,下面是使用fork的一个简单用例。
----在第12行,我们调用了fork。fork会拷贝当前进程的内存(包含了进程的指令和数据),并创建一个新的进程。之后,我们就有了两个拥有完全一样内存的进程。fork系统调用在两个进程中都会返回,在原始的进程中,fork系统调用会返回大于0的整数,这个是新创建进程的ID。而在新创建的进程中,fork系统调用会返回0。所以即使两个进程的内存是完全一样的,我们还是可以通过fork的返回值区分旧进程和新进程。
----在第16行,你可以看到代码检查pid。如果pid等于0,那么这必然是子进程。在我们的例子中,调用进程通常称为父进程,父进程的pid必然大于0。所以父进程会打印“parent”,子进程会打印“child”。之后两个进程都会退出。接下来我运行这个程序:
分析输出:输出看起来像是垃圾数据。这里实际发生的是,fork系统调用之后,两个进程都在同时运行。QEMU实际上是在模拟多核处理器,所以这两个进程实际上就是同时在运行。所以当这两个进程在输出的时候,它们会同时一个字节一个字节的输出,两个进程的输出交织在一起,所以你可以看到两个f,两个o等等。在第一行最后,你可以看到0,这是子进程的输出。我猜父进程返回了19作为子进程的进程ID,通常来说这意味着这是操作系统启动之后的第19个进程。之后一个进程输出了child,一个进程输出了parent,这两个输出交织在一起。
总结:虽然这只是对于fork的一个简单应用,但是我们可以清晰的从输出看到这里创建了两个运行的进程,其中一个进程打印了child,另一个打印了parent。所以,fork(在子父进程中)返回不同的值是比较重要的。当我们在Shell中运行东西的时候,Shell实际上会通过fork创建一个新的进程来运行你输入的每一个指令。所以,当我输入ls时,我们需要Shell通过fork创建一个进程来运行ls。这里需要某种方式来让这个新的进程来运行ls程序中的指令,加载名为ls的文件中的指令(也就是后面的exec系统调用)。
问答
学生提问:fork产生的子进程是不是总是与父进程是一样的?它们有可能不一样吗?
Robert教授:在XV6中,除了fork的返回值,两个进程是一样的。两个进程的指令是一样的,数据是一样的,栈是一样的。同时,两个进程又有各自独立的地址空间,它们都认为自己的内存从0开始增长,但这里是不同的内存地址。 在一个更加复杂的操作系统里,有一些细节我们现在并不关心,这些细节偶尔会导致父子进程不一致。但是在XV6中,父子进程除了fork的返回值,其他都是一样的。除了内存中的内容是一样的以外,文件描述符的表单也从父进程拷贝到子进程。所以如果父进程打开了一个文件,子进程可以看到同一个文件描述符,尽管子进程看到的是一个文件描述符的表单的拷贝。除了拷贝内存中的数据以外,fork还会拷贝文件描述符表单。
exec
在接下来我展示的一个例子中,会使用echo。echo是一个非常简单的命令,它接收任何你传递给它的输入,并将输入写到输出。
在echo文件中调用了一个exec函数,我们来看看exec的代码。
代码会执行exec系统调用,这个系统调用会从指定的文件中读取并加载指令,并替代当前调用进程的指令。从某种程度上来说,这样相当于丢弃了调用进程的内存,并开始执行新加载的指令。所以第12行的系统调用exec会有这样的效果:操作系统从名为echo的文件中加载指令到当前的进程中,并替换了当前进程的内存,之后开始执行这些新加载的指令。同时,exec允许你传入一个命令行参数的数组(这里就是一个C语言中的指针数组,在上面代码的第10行设置好了一个字符指针的数组,这里的字符指针本质就是一个字符串(string))。所以这里等价于运行echo命令,并带上“this is echo” 这三个参数。
所以当我运行exec文件,我可以看到 “this is echo” 的输出。即使我运行了exec程序,exec程序实际上会调用exec系统调用,并用echo指令来代替自己,所以这里是echo命令在产生输出。
有关exec系统调用,有一些重要的细节:
①exec系统调用会保留当前的文件描述符表。所以任何在exec系统调用之前的文件描述符(例如0,1,2等)它们在新的程序中表示相同的东西。
②通常来说exec系统调用不会返回,因为exec会完全替换当前进程的内存,相当于当前进程不复存在了,所以exec系统调用已经没有地方能返回了。
③所以,exec系统调用从文件中读取指令,执行这些指令,然后就没有然后了。exec系统调用只会当出错时才会返回,因为某些错误会阻止操作系统为你运行文件中的指令(例如程序文件根本不存在,这时exec系统调用不能找到文件,exec会返回-1来表示:出错了,我找不到文件)。所以通常来说exec系统调用不会返回,它只会在kernel不能运行相应的文件时返回。
问答
学生提问:argv中的最后一个0是什么意思?
Robert教授:它标记了数组的结尾。C是一个非常底层(接近机器语言)的编程语言,并没有一个方法来确定一个数组究竟有多长。所以为了告诉内核数组的结尾在哪,我们将0作为最后一个指针。argv中的每一个字符串实际上是一块包含了数据的内存指针,但是第5个元素是0(通常来说指针0是一个NULL指针,它只表明结束)。所以内核中的代码会遍历这里的数组,直到它找到了值为0的指针。
forkexec
上面介绍的就是一个程序如何用文件中的另一个程序来替代自己。实际上,当我们在Shell中运行类似于“echo a b c”的指令、或者ls、或者任何命令时,我们不会想要代替Shell进程,所以我们不会希望Shell执行exec系统调用。如果我们这么做了,这里会用echo指令来替代Shell进程,当echo退出了,一切就结束了,所以我们不想要echo替代Shell。实际上,Shell会执行fork,之后fork出的子进程再调用exec系统调用,这是一个非常常见的Unix程序调用风格。对于那些想要运行程序但是还希望能拿回控制权的场景,可以先执行fork系统调用,然后在子进程中调用exec。
这里有一个简单的例子,来演示fork/exec程序。
----在这个程序中的第12行,调用了fork。子进程从第14行开始,我们在子进程中与前一个程序一样调用exec。子进程会用echo命令来代替自己,echo执行完成之后就退出,之后父进程重新获得了控制。
----fork会在父进程中返回大于0的值,父进程会继续在第19行执行。
wait系统调用
Unix提供了一个wait系统调用,如第20行所示。wait会等待之前创建的子进程退出。当我在命令行执行一个指令时,我们一般会希望Shell等待指令执行完成。所以wait系统调用,使得父进程可以等待任何一个子进程返回。
----这里wait的参数&status是一种让退出的子进程以一个整数(32bit的数据)的格式与等待的父进程进行通信的方式。所以在第17行,exit的参数是1,操作系统会将1从退出的子进程传递到第20行的wait,也就是等待的父进程处。
----wait将status对应的地址(&status)传递给内核,内核会向这个地址写入子进程向exit传入的参数。
----Unix中的风格是,如果一个程序成功的退出了,那么exit的参数会是0,如果出现了错误,那么就会像第17行一样,会向exit传递1。所以,如果你关心子进程的状态的话,父进程可以读取wait的参数,并决定子进程是否成功的完成了。
问答
学生提问:有关第15行的exec系统调用,在刚刚提到exec会完全走到echo程序,而不会返回到fork出的子进程(forkesec)中,所以代码有可能走到底下的16,17行吗?
Robert教授:对于上面例子中的exec,代码不会走到16,17行,因为这里就是调用了echo。但是,如果我修改代码,那就有可能会走到那两行了。首先,我先运行一下原始版本的程序
可以看出,程序执行了echo,并传入了相应的参数。同时子进程以状态0退出,表明echo成功的退出了,并且父进程在等待子进程。
接下来我修改一下代码,这次我将会运行一个不存在的指令。
为了让修改生效,我需要退出QEMU,并重建所有的东西以使得我的修改能够被编译。之后我再运行forkexec,
这一次,因为我们想要执行的指令并不存在,exec系统调用会返回,我们可以看到“exec failed!”的输出,同时exit(1)的参数1,传递给了父进程,父进程会打印出子进程的退出码。所以,exec系统调用只会在出错的时候返回给调用进程。
forkexec的一些问题:
有一些东西需要注意,这里是一个常用的写法,先调用fork,再在子进程中调用exec。这里实际上有些浪费,fork首先拷贝了整个父进程的内容,但是之后exec整个将这个拷贝丢弃了,并用你要运行的文件替换了内存的内容。某种程度上来说这里的拷贝操作浪费了,因为所有拷贝的内存都被丢弃并被exec替换。在大型程序中这里的影响会比较明显,如果你运行了一个几G的程序,并且调用fork,那么实际就会拷贝所有的内存,可能会要消耗将近1秒钟来完成拷贝,这可能会是个问题。在这门课程的后面,你们会实现一些优化,比如说copy-on-write fork,这种方式会消除fork的几乎所有的明显的低效,而只拷贝执行exec所需要的内存,这里需要很多涉及到虚拟内存系统的技巧。你可以构建一个fork,对于内存实行lazy拷贝,通常来说fork之后立刻是exec,这样你就不用实际的拷贝,因为子进程实际上并没有使用大部分的内存。
问答
学生提问:为什么父进程在子进程调用exec之前就打印了“parent waiting”?
Robert教授:这里只是巧合。父进程的输出有可能与子进程的输出交织在一起,就像我们之前在fork的例子中看到的一样,只是这里正好没有发生而已。并不是说我们一定能看到上面的输出,实际上,如果看到其他的输出也不用奇怪。我怀疑这里背后的原因是,exec系统调用代价比较高,它需要访问文件系统、访问磁盘、分配内存、读取磁盘中echo文件的内容到分配的内存中,分配内存又可能需要等待内存释放。所以,exec系统调用背后会有很多逻辑和机器指令,很明显,处理这些逻辑的时间足够长,这样父进程可以在exec开始执行echo指令之前完成输出。
学生提问:子进程可以等待父进程吗?
Robert教授:Unix并没有一个直接的方法让子进程等待父进程。wait系统调用只能等待当前进程的子进程。所以wait的工作原理是,如果当前进程有任何子进程并且其中一个已经退出了,那么wait会返回。但是如果当前进程没有任何子进程(比如在这个简单的例子中),如果子进程调用了wait,因为子进程自己没有子进程了,所以wait会立即返回-1,表明出现错误了,当前的进程并没有任何子进程。简单来说,不可能让子进程等待父进程退出。
学生提问:当我们说子进程从父进程拷贝了所有的内存,这里具体指的是什么呢?是不是说子进程需要重新定义变量之类的?
Robert教授:在编译之后,你的C程序就是一些在内存中的指令,这些指令存在于内存中。所以这些指令可以被拷贝,因为它们就是内存中的字节,它们可以被拷贝到别处。通过一些有关虚拟内存映射的技巧,可以使得子进程的内存与父进程的内存一样,这里实际就是将父进程的内存镜像拷贝给子进程,并在子进程中执行。实际上,当我们在看C程序时,你应该认为它们就是一些机器指令,这些机器指令就是内存中的数据,所以可以被拷贝。
学生提问:如果父进程有多个子进程,wait是不是会在第一个子进程完成时就退出?这样的话,还有一些与父进程交错运行的子进程,是不是需要有多个wait来确保所有的子进程都完成?
Robert教授:是的,如果一个进程调用fork两次,如果它想要等两个子进程都退出,它需要调用wait两次。每个wait会在一个子进程退出时立即返回。当wait返回时,你实际上没有必要知道哪个子进程退出了,但是wait返回了子进程的进程号,所以在wait返回之后,你就可以知道是哪个子进程退出了。
I/O重定向
最后一个例子,我们展示一下将所有这些工具结合在一起,来实现I/O重定向。
我们之前讲过,Shell提供了方便的I/O重定向工具。如果我运行下面的指令,
Shell会将echo的输出送到文件out。之后我们可以运行cat指令,并将out文件作为输入,
我们可以看到保存在out文件中的内容就是echo指令的输出。
----Shell之所以有这样的能力,是因为Shell首先会像第13行一样fork,然后在子进程中,Shell改变了文件描述符。
----文件描述符1通常是进程用来作为输出的(也就是console的输出文件符),Shell会将文件描述符1改为output文件,之后再运行指令。同时,父进程的文件描述符1并没有改变。
----所以这里先fork,再更改子进程的文件描述符,是Unix中的常见的用来重定向指令的输入输出的方法,这种方法不会影响父进程的输入输出。
----我们不会重定向Shell的输出,我们只想重定向子进程的输出。这里的工作的原因是,代码的第15行只会在子进程中执行。
----代码的第15行的意义是重定向echo命令的输出,如果我运行整个程序redirect程序。可以看到没有任何的输出。但是实际上redirect程序里面运行了echo,只是echo的输出重定向到了output.txt。
----如果我们查看output.txt,我们可以看到预期的输出。
----代码第15行的close(1)的意义是,我们希望文件描述符1指向一个其他的位置。也就是说,在子进程中,我们不想使用原本指向console输出的文件描述符1。
----代码第16行的open一定会返回1,因为open会返回当前进程未使用的最小文件描述符序号。因为我们刚刚关闭了文件描述符1,而文件描述符0还对应着console的输入,所以open一定可以返回1。
----在代码第16行之后,文件描述符1与文件output.txt关联。之后我们执行exec(echo),echo会输出到文件描述符1,也就是文件output.txt。这里有意思的地方是,echo根本不知道发生了什么,echo也没有必要知道I/O重定向了,它只是将自己的输出写到了文件描述符1。只有Shell知道I/O重定向了。
总结: 这个例子同时也演示了分离fork和exec的好处。fork和exec是分开的系统调用,意味着在子进程中有一段时间,fork返回了,但是exec还没有执行,子进程仍然在运行父进程的指令。所以这段时间,尽管指令是运行在子进程中,但是这些指令仍然是父进程的指令,所以父进程仍然可以改变东西,直到代码执行到了第19行。这里fork和exec之间的间隔,提供了Shell修改文件描述符的可能。
总结
我们这节课看了一些Unix I/O和进程的接口和抽象。这里需要记住的是,接口是相对的简单,你只需要传入表示文件描述符的整数和进程ID作为参数给相应的系统调用。而接口内部的实现逻辑相对来说是复杂的,比如创建一个新的进程,拷贝当前进程。除此之外,我还展示了一些例子。通过这些例子你可以看到,尽管接口本身是简单的,但是可以将多个接口结合起来形成复杂的用例,比如说创建I/O重定向。在下周结束之前,需要完成一个实验,这个实验中涉及更多类似于我们课堂上讲的简单小工具。