基本概念

一个正在运行的程序会做一件非常简单的事情:指令执行。处理器从内存中获取一条指令,对其进行解码,然后执行这条指令。完成这条指令后,处理器会继续执行下一条指令,以此类推,直到程序最终完成。

这就是冯诺依曼计算模型的基本概念。但实际上,在一个程序运行的同时,还有很多其他疯狂的事情正在同步进程——主要是为了让系统易于使用

有一类软件负责让程序的运行变得更加容易,甚至允许你通知运行多个程序,允许程序共享内存,让程序能够与设备交互,以及其他类似的有趣工作。这些软件统称为操作系统,因为它们负责确保系统能够易于使用且能高效的运行。

要做到这一点,操作系统主要利用一种通用的技术——虚拟化。也就是说,操作系统将物理资源转换为更加通用、更加强大且更易于使用的虚拟形式。因此我们有时也将操作系统称为虚拟机

为了让用户可以告诉操作系统做什么,从而利用虚拟机的功能(如运行程序、分配内存或访问文件)。操作系统还提供了一些接口供你调用。实际上,典型的操作系统会提供几百个系统调用以供程序调用。由于操作系统提供这些调用来运行程序、访问内存和设备,并进行其他相关的操作,我们有时也会说操作系统为应用程序提供了一个标准库

最后,因为虚拟化让许多程序运行(从而共享 CPU),让许多程序可以同时访问自己的指令和数据(从而共享内存),让许多程序访问设备(共享磁盘),所以操作系统有时又被称为资源管理器。每个 CPU、内存和磁盘都是系统的资源,因此操作系统扮演的主要角色就是管理这些资源,以做到高效公平,或者实际上会考虑其他更多的指标。

虚拟化 CPU

图 2-1 展示了我们第一个程序,所做的只是调用 spin 函数,该函数会反复检查时间并在运行一秒后返回。然后它会打印出用户在命令行中传入的字符串,并一直重复上述过程。

#include <stdio.h>
#include <stdlib.h>
#include <sys/time.h>
#include <assert.h>
#include "common.h"

int main(int argc, char *argv[]) {
	if(argc != 2) {
		fprintf(stderr, "usage: cpu <string>\n");
		exit(1);
	}
	char *str = argv[1];
	while(1) {
		Spin(1);
		pringf("s%\n", str);
	}
	return 0;
}

假设我们将这段代码保存为 cpu.c,并决定在一个单处理器的系统上编译运行。以下是我们看到的内容:

prompt> gcc -o cpu cpu.c -Wall
prompt> ./cpu "A"
A
A
A
A
^C
prompt>

系统开始运行时,该程序会反复检查时间,直到一秒钟过去。一秒钟过去之后,代码打印用户传入的字符串并继续。注意:该程序将永远保持执行,只有按下 CTRL + C,才能终止该程序。

现在我们做同样的事情,让我们运行同一个程序的多个不同实例,图 2-2 展示了这个稍复杂的例子的结果:

prompt> ./cpu A & ; ./cpu B & ; ./cpu C & ; ./cpu D &
[1] 7353
[2] 7354
[3] 7355
[4] 7356
A
B
D
C
A
B
D
C
A
C
B
D
...

尽管我们只有一个处理器,但这 4 个程序几乎同时都在运行。

事实证明,在硬件的一些帮助下,操作系统负责提供这种假象,即系统拥有非常多的虚拟 CPU 的假象。将单个 CPU (或其中一小部分)转换为看似无限数量的 CPU,从而让许多程序看似同时运行,这就是所谓的虚拟化 CPU,这是本书第一大部分的关注点。

需要一些接口来运行程序或终止程序,接口是大多数用户与操作系统交互的主要方式。

一次运行多个程序可能会引发各种新的问题,比如两个程序都想要在特定的时间执行,哪又该运行哪个呢?该问题由操作系统的策略来解决。操作系统的各种组件采用了一些不同的策略来解决该问题,所以我们将在学习操作系统实现的基本机制时研究这些策略。因此,操作系统承担了资源管理器的角色。

虚拟化内存

现代机器提供的物理内存模型非常简单。内存就是一个字节数组。要读取内存必须指定一个地址,才能访问存储在其中的数据。要写入或更新内存,也必须指定要写入给定数据的地址。

程序在运行时一直需要访问内存。程序将所有数据结构保存在内存中,并通过各种指令来访问这些数据,比如加载或保存,或利用其它明确的指令来在工作中访问内存。程序的每个指令都保存在内存中,因此每次读取指令都会访问内存。

下面的程序通过 malloc 来分配一些内存:

#include <unistd.h>
#include <stdio.h>
#include <stdlib.h>
#include "common.h"

int main(int argc, char *argv[]) {
	int *p = malloc(sizeof(int));	// a1
	assert(p != NULL);
	printf("(d%) memory address of p: %08x\n", 
		getpid(),(unsigned) p); 	// a2
	*p = 0;							// a3
	while(1) {
		Spin(1);
		*p = *p + 1;
		printf("(d%) p: %d\n", getpid(), *p); // a4
	}
	return 0;
}

该程序的输出如下:

prompt> ./mem
(2134) memory address of p: 00200000
(2134) p: 1
(2134) p: 2
(2134) p: 3
(2134) p: 4
(2134) p: 5

首先,它分配了一些内存(a1)。然后,打印出内存的地址(a2),然后将数字 0 放入新分配的内存(a3)的第一个空位中。最后,程序循环,延迟一秒钟并递增 p 中保存的地址值。在每个打印语句中,它还会打印出所谓的正在执行的进程标示符(PID)。每个运行进程都有一个唯一的 PID。

现在,我们再次运行同一个程序的多个实例,看看会发生什么。我们从示例中看到,每个正在运行的程序都在相同的地址分配了内存,但每个程序似乎都独立的更新了该地址的值。就好像每个正在运行的程序都有自己的私有内存,而不是与其他正在运行的程序共享相同的物理内存。

prompt> ./mem &; ./mem & 
[1] 24113
[2] 24114
(24113) memory address of p: 00200000
(24114) memory address of p: 00200000
(24113) p: 1
(24114) p: 1
(24114) p: 2
(24113) p: 2
(24113) p: 3
(24114) p: 3
(24113) p: 4
(24114) p: 4

实际上,这正是操作系统虚拟化内存时发生的情况。每个进程访问自己的私有虚拟内存地址空间,操作系统以这种方式映射到机器的物理内存上。一个正在运行的程序中的内存引用不会影响其他进程(或操作系统本身)的地址空间。对于正在运行的程序,它完全拥有自己的物理内存。但实际情况是,物理内存是由操作系统管理的共享资源。

并发

并发问题首先会出现在操作系统本身,比如上面关于虚拟化的例子中,操作系统同时处理很多事情,它首先运行一个程序,然后再运行一个程序,等等。

同时,并发问题并不局限于操作系统本身。事实上,现代多线程程序也存在相同的问题。

1    #include <stdio.h>
2    #include <stdlib.h>
3    #include "common.h"
4
5    volatile int counter = 0;
6    int loops;
7
8    void *worker(void *arg) {
9        int i;
10       for (i = 0; i < loops; i++) {
11           counter++;
12       }
13       return NULL;
14   }
15
16   int
17   main(int argc, char *argv[])
18   { 
19       if (argc != 2) {
20           fprintf(stderr, "usage: threads <value>\n");
21           exit(1);
22       }
23       loops = atoi(argv[1]);
24       pthread_t p1, p2;
25       printf("Initial value : %d\n", counter);
26
27       Pthread_create(&p1, NULL, worker, NULL);
28       Pthread_create(&p2, NULL, worker, NULL);
29       Pthread_join(p1, NULL);
30       Pthread_join(p2, NULL);
31       printf("Final value    : %d\n", counter);
32       return 0;
33   }

主程序利用 Pthread_create 创建了两个线程。你可以将线程看做与其他函数在同一内存空间中运行的函数,并且每次都有多个线程处于活动状态。在这个例子中,每个线程开始在一个名为 worker 的函数中运行,在该函数中,它只是一个递增计数器,循环 loops 次。

prompt> gcc -o thread thread.c -Wall -pthread
prompt> ./thread 1000
Initial value : 0
Final value   : 

你可能会猜到,两个线程完成时计数器的结果为 2000,因为每个线程都将计数器增加 1000 次。也就是说,当 loops 的输入值设为 N 时,我们预计程序的最终输出为 2N。但事实证明并非如此。

prompt> ./thread 100000 
Initial value : 0
Final value   : 143012     // huh?? 
prompt> ./thread 100000
Initial value : 0
Final value  : 137298    

在这次运行中我们提供 100000 作为输入值,得到的最终值却不是 200000。然后当我们再次运行该程序时,不仅得到了错误的结果,而且每次错误的结果还都不相同。事实上,如果以多次使用较高的 loops 值来运行该程序,甚至有可能得到正确的答案。

事实证明,这些奇怪的结果与指令如何执行有关。指令每次执行一条。遗憾的是,上面的程序中关键部分是增加共享计数器的地方,它需要 3 条指令:一条将计数器的值从内存加载到寄存器,一条将其递增,另一条将递增后的值保存到内存中。因为这 3 条指令并非以原子的方式执行,所以会发生如上奇怪的结果。

持久性

在系统内存中,数据容易丢失,因为像 DRAM 这样的设备已易失的方式存储数据。如果断电或系统崩溃,内存中的所有数据将会丢失。因此,我们需要硬件和软件来持久的存储数据。

硬件以某种输入/输出设备的形式出现。在现代系统中,硬盘驱动器是存储长期保存的信息的通用存储库,同时固态磁盘(SSD)也正在这个领域取得领先地位。

操作系统中管理磁盘的软件通常称为文件系统。因此它负责以可靠和高效的方式,将用户创建的任何文件存储在系统的磁盘上。

不像操作系统为 CPU 和内存提供的抽象,操作系统不会为每个应用程序创建专用的虚拟磁盘。相反,它假设用户经常需要共享文件中的信息。比如,在编写 C 程序时,你可能首先使用编辑器,之后,可以使用编译器将源代码转换为可执行文件,再之后,你可以运行新的可执行文件。因此,你可以看到文件如何在不同的进程之间共享。首先,编辑器创建一个文件,作为编译器的输入。编译器使用该输入文件创建一个新的可执行文件。最后,运行新的可执行文件。这样一个新的程序就诞生了。

1    #include <stdio.h>
2    #include <unistd.h>
3    #include <assert.h>
4    #include <fcntl.h>
5    #include <sys/types.h>
6
7    int
8    main(int argc, char *argv[])
9    {
10       int fd = open("/tmp/file", O_WRONLY | O_CREAT | O_TRUNC, S_IRWXU);
11       assert(fd > -1);
12       int rc = write(fd, "hello world\n", 13);
13       assert(rc == 13);
14       close(fd);
15       return 0;
16   }

为了完成这个任务,该程序向操作系统发出 3 个调用。第一个是对 open 的调用,它打开文件并创建。第二个是 write,将一些数据写入文件。第三个是 close,只是简单的关闭文件,从而表名程序不会再向其写入更多的数据。这些系统调用被转到称为文件系统的操作系统部分,然后操作系统处理这些请求,并向用户返回某些错误代码。

文件系统必须做很多操作:首先确定新数据将驻留在磁盘的哪个位置上,然后在文件系统所维护的各种结构中对其进行记录。这样做需要向底层存储系统发出 IO 请求,已读取现有结果或更新。所有编写过设备驱动程序的人都知道,让设备代表你来执行某项操作是一项复杂而详细的过程。它需要深入了解低级别设备接口的确切含义。幸运的是,操作系统提供了一种通过系统调用来访问设备的标准又简单的方法。因此,操作系统有时又被称为标准库。

当然,关于如何访问设备、文件系统如何在所述设备上持久的管理数据,还有更多细节。处于性能访问的考虑,大多数文件系统首先会延迟这些写操作一段时间,希望将其批量分组为较大的组。为了处理写入期间系统崩溃的问题,大多数文件系统都包含某种复杂的写入协议,如日志或写时复制,仔细排序写入磁盘的操作,以确保如果在写入序列期间发生故障,系统可以在之后恢复到合理的状态。为了使不同的通用操作更加高效,文件系统采用了许多不同的数据结构和访问方法,从简单的列表到复杂的 B 树。

设计目标

操作系统的工作是:它获得 CPU、内存、磁盘等物理资源,并对它们进行虚拟化;它处理并发相关的棘手问题;它之久的存储文件以确保文件长期安全。鉴于我们希望建立这样一个系统,所以要有一些目标,以帮助我们集中设计和实现,并在必要时进行折中。找到合适的折中是建立系统的关键。

一个最基本的目标是建立一些抽象,让系统变得易于使用。抽象对我们在计算机科学中做的每件事都有帮助。抽象使得编写一个大型程序称为可能,将其划分为更小且更易理解的部分,用 C 这样高级的语言编写这样的程序不用考虑汇编,用汇编代码则不用考虑逻辑门,用逻辑门来构建处理器则不用太多考虑晶体管。抽象是如此重要,以至于我们会忘记其重要性,但在这里我们会谨记。因此,在每一部分中,我们将讨论随着时间的推移而发展的一些主要抽象,为你提供一种思考操作系统各个部分的方法或思路。

**设计和实现操作系统的一个目标是提供高性能。**换言之,我们的目标是最小化操作系统的开销。虚拟化让系统变得易于使用是非常值得的,但也不会不计成本。因此,我们必须努力提供虚拟化和其他操作系统功能,同时避免过多的开销。这些开销会以多种形式出现:额外时间(更多指令)和额外空间(更多内存/磁盘)。如果有可能,我们会寻求解决方案以尽量减少这些形式的开销。但是完美并非总是可以实现的,我们会注意到这一点并在适当的情况下让人这种情况。

另一个设计目标是在应用程序之间、操作系统和应用程序之间提供保护。因为我们希望许多程序同时运行,所以要确保一个程序的恶意或偶然的不良行为不会损害其他程序。我们当然不希望应用程序能够损害操作系统本身。保护是操作系统基本原理之一的核心,这就是隔离。让进程彼此隔离是保护的关键,因此决定了 OS 必须执行的大部分任务。

**操作系统必须能够不间断运行。**当它失效时,系统上运行的所有应用程序也会失效。由于这种依赖性,操作系统往往力求提供高度的可靠性。随着操作系统变得越来越复杂,构建一个可靠的操作系统是一个相当大的挑战:事实上,该领域的许多正在进行的研究,正式专注于该问题。

其他目标也是有道理的:在我们日益增长的绿色世界中,能源效率非常重要;安全性对于恶意应用程序直观重要,尤其是在当前高度联网的时代。随着操作系统在越来越小的设备上运行,移动性变得越来越重要。根据系统的使用方式,操作系统将有不同的目标,因此可能至少以稍微不同的方式实现。但是我们会看到,我们将要介绍的关于如何构建操作系统的许多原则,则各种不同的设备上都很有意义。

简单历史

早期操作系统:只是一些库

一开始操作系统并没有做太多事情。基本是,它只是一些常用函数库。比如,不是让系统中每个程序员都编写低级 IO 处理代码,而是让 OS 提供这样的 API,以提升开发效率。

通常在这些老的大型机系统上,一次运行一个程序,并由操作员来控制。该操作员完成了你认为现代操作系统会做的所有事情,比如决定运行作业的顺序。如果你是一个聪明的开发人员,就会对这个操作员很好,这样他们可以将你的工作移到到队列的前端以尽快执行。

这种计算模式被称为批处理,先把一些工作准备好,然后由操作员以“分批”的方式运行。此时,计算机并没有以交互的方式被使用,因为这样做成本太高:让用户坐在计算机前使用它,大部分时间都会闲置,所以会导致设备每小时浪费数千美元。

超越库:保护

在超越常用服务的简单库的发展过程中,操作系统在管理机器方面扮演者更为重要的角色。其中一个重要方面是意识到代表操作系统运行的代码是特殊的。它控制了设备,因此对打它的凡事应该与对待正常应用程序的代码有所不同。不然的话,假设允许任何应用程序能够从磁盘的任何地方读取数据。因为任何程序都可以读取数据,隐私的概念就消失了。因此,将一个文件系统实现为一个库是没有意义的。

因此,系统调用的概念诞生了,它由 Atlas 计算系统率先采用。不是将操作系统例程作为一个库来提供,这里的想法是添加一些特殊的硬件指令和硬件状态,让操作系统过度变为正式的、受控的过程。

系统调用和过程调用的关键区别在于,系统调用将控制转移到 OS 中,同时提高硬件特权级别。用户应用程序以所谓的用户模式运行,这意味着硬件限制了应用程序的功能。比如,以用户模式运行的应用程序通常不能发起对磁盘的 IO 请求,不能访问任何物理内存页或在网络上发送数据包。在发起系统调用时(通常通过一个称为“trap”的特殊硬件指令),硬件将控制转移到预先指定的陷阱处理程序,并同时将特权级别提升到内核模式。在内核模式下,操作系统可以完全访问系统的硬件,因此可以执行诸如发起 IO 请求或为程序提供更多的内存等功能。当操作系统完成请求的服务时,他通过特殊的陷阱返回(return-from-trap)指令将控制权交还给用户,该指令返回到用户模式,同时将控制权交还给应用程序,回到应用离开的地方。

多道程序时代

操作系统的真正兴起是在大主机计算时代之后,即小型机时代。像数字设备公司的 PDP 系列这样经典机器,让计算机变得更加实惠。因此,不再是每个大型组织都需要拥有一台主机,而是组织内的一小群人可以拥有自己的计算机。毫不奇怪,这种成本下降的主要影响之一就是开发者活动的增加。更聪明的人接触到计算机,从而让计算机系统做出更有趣和漂亮的事情。

特别是,由于希望更好的利用资源,多道程序开始变得很普遍。操作系统不是一次只运行一项作业,而是将大量作业加载到内存中并在它们之间切换,从而提供 CPU 利用率。这种切换非常重要,因为 IO 设备很慢。在处理 IO 时让程序占用着 CPU 则会浪费 CPU 时间。

在 IO 进行和任务中断时,要支持多道程序和重叠运行。这一愿望使得操作系统开始创新,沿着多个方向进行概念发展。内存保护等问题开始变得重要。我们不希望一个程序能够访问另一个程序的内存。了解如何处理多道程序引入的并发问题也很关键。在中断存在的情况下,确保操作系统正常运行是一个很大的挑战。

当时主要的实际进展之一是引入了 UNIX 操作系统,主要归功与贝尔实验室和 Ken Thompson、Dennis Ritchie。UNIX 从不同的操作系统获得了很多好的想法,并让这些想法变得更简单易用。很快,该团队就向世界各地的人们发送含有 UNIX 源代码的磁带,其中很多人随后参与并加入到系统中。

摩登时代

除了小型计算机之外还有一种新型机器,更加便宜快速且适用于大众:今天我们称之为个人计算机。在苹果公司早期的机器和 IBM PC 的引领下,这种新机器很快就称为计算的主导力量,因为它们的低成本让每个桌子上都有一台机器,而不是每个工作小组共享一台小型机器。

遗憾的是,对于操作系统来说,个人计算机起初代表了一次巨大的倒退,因为早期的系统忘记了小型机时代的经验教训。比如早期的操作系统如 DOS 并不认为内存保护很重要。因此,恶意程序或者编写质量欠佳的程序可能会在整个内存中写入乱七八糟的数据。第一代 MacOS 采取合作的方式进行作业调度。因此,意外陷入无限循环的线程可能会占用整个系统,从而导致重新启动。这一代系统中遗漏的操作系统功能造成的痛苦列表很长,因此无法在这里给出完整的讨论。

幸运的是,进过一段时间的苦难后,小型计算机操作系统的老功能开始进入台式机。比如 MaxOS X 的核心是 UNIX,包括人们期望从这样一个成熟系统中获得的所有功能。Windows 在计算机历史中同样采用了许多伟大的思想,特别是从 Windows NT 开始,这是微软操作系统技术的一次伟大飞跃。即使在今天的手机上运行的操作系统(如 Linux),也更像小型机在 20 世纪 70 年代运行的,而不是像 20 世纪 80 年代 PC 运行的那种操作系统。很高兴看到在操作系统开发鼎盛时期出现的好想法已经进入了现代世界。更好的是,这些想法不断发展,为用户和应用程序提供了更多功能,让现代系统更加完善。

补充:UNIX 的重要性 在操作系统的历史中,UNIX 的重要性举足轻重。受早期系统的影响,尤其是 MIT 的 Multics 系统,UNIX 汇集了很多了不起的思想,创造了既简单又强大的系统。 最初的贝尔实验室 UNIX 的基础是统一原则,即构建小二强大的程序,这些程序可以连接在一起形成更大的工作流。在你输入命令的地方,shell 提供了诸如管道之类的原语,来支持这样的元编程,因此很容易将程序串起来完成更大规模的任务。 UNIX 环境对于程序开发人员很友好,并为新的 C 语言提供了编译器。程序员很容易编写自己的程序并将其分享,这使得 UNIX 大受欢迎。作为开源软件的早期形式,作者向所有请求的人免费提供副本,这种方式的帮助很大。 代码的可得性和可读性非常重要。用 C 语言编写的美丽的小内核吸引其他人来摆弄内核,添加新的、很酷的功能。 遗憾的是,随着公司试图维护其所有权和利润,UNIX 的传播速度有所降低,这是律师参与其中的不幸结果。很多公司开始拥有自己的 UNIX 变种。

补充:然后出现了 Linux 幸运的是,对于 UNIX 来说,一位名叫 Linus Torvalds 的年轻芬兰黑客决定编写它自己的 UNIX 版本,该版本严重依赖最初系统背后的原则和思想,但没有借用原来的代码集,从而避免了合法性问题。他征集了世界各地其他人的帮助,不久之后 Linux 就诞生了。