抽象-地址空间

在早期,构建计算机操作系统非常简单。原因是用户对操作系统的期望不高。然而一些烦人的用户提出要易于使用、高性能、可靠性等,这导致了所有这些令人头疼的问题。

早期系统

从内存来看,早期的机器并没有提供多少抽象给用户。基本上机器的物理内存看起来如下图所示:

NAME

操作系统曾经是一组函数(一个库),在内存中,然后有一个正在运行的程序(进程),目前在物理内存中,并使用剩余的内存。这里几乎没有任何抽象,用户对操作系统的要求也不多。

多道程序与分时共享

过了一段时间,由于机器昂贵,人们开始更有效的共享机器。因此多道程序系统时代开启,其中多个进程在指定时间准备运行,比如当有一个进程在等待 IO 操作的时候,操作系统会切换这些进程,这样增加了 CPU 的有效利用率。那时候,效率的提高尤其重要,因为每台机器的成本是数十万元甚至数百万元。

但很快,人们开始对机器要求更多,分时系统的时代诞生了。具体来说,很多人意识到批量计算的局限性,尤其是程序员本身,他们厌倦了长时间的编程-调试循环。交互性变得很重要,因为许多用户可能同时在使用机器,每个人都在等待他们执行的任务及时响应。

一种实现分时共享的方法,是让一个进程单独占用全部内存运行一小段时间,然后停止它,并将它所有的状态信息保存在磁盘上(包含所有的物理内存),记载其他进程的状态信息,再运行一段时间,这就实现了某种比较粗糙的机器共享。

遗憾的是,这种方法有一个大问题:太慢了,特别是当内存增长的时候。虽然保存和恢复集群器级别的状态信息相对较快,但将全部的内存信息保存到磁盘就太慢了。因此,在进程切换的时候,我们仍然将进程信息放在内存中,这样操作系统可以更有效的实现分时系统。

NAME

在上图中,有 3 个进程 ABC,每个进程拥有从 512KB 物理内存中切分出来给他们使用的一小部分内存。假定只有一个 CPU,操作系统选择运行其中一个进程,同时其他进程则在队列中等待运行。

随着分时系统变得更加流行,人们对操作系统又有了新要求。特别是多个程序同时驻留在内存中,使保护称为重要问题。人们不希望一个进程可以读写其他进程的内存。

地址空间

然而,我们必须将这些烦人的用户的需求放在心上。因此操作系统需要提供一个易用的物理内存抽象。这个抽象叫做地址空间,是运行的程序能够看到的系统中的内存。理解这个基本的抽象系统内存抽象,是了解内存虚拟化的关键。

一个进程的地址空间包含运行程序的所有内存状态。比如:程序的代码必须在内存中,因此它们也在地址空间中。当程序在运行时,利用栈来保存当前的函数调用信息,分配空间给局部变量,传递参数和函数返回值。最后,堆用户管理动态分配的、用户管理的内存,就像你从 C 语言中调用 malloc 或面向对象语言中调用 new 关键字获得内存。当然,还有其他的东西(如静态初始化的变量),但现在假设只有这 3 部分:代码、堆、栈。

NAME

上图是一个很小的地址空间。程序代码位于地址空间的顶部。代码是静态的,所以可以将其放在地址空间的顶部,我们知道程序在运行时,代码不会再需要额外的空间。

接下来在程序运行时,地址空间有两个区域可能增长,那就是堆和栈。把它们放在底部,是因为它们都希望能够增长。通过将它们放在地址空间的两端,我们可以运行这样的增长:它们只需要在相反的方向上增长。因此堆在代码之下 1KB 开始并向下增长,栈从 16KB 开始并向上增长。然而,堆和栈的这种放置方式只是一种约定,如果你愿意,可以用不同的方式来安排地址空间,当多个线程在地址空间中共存时,就没有像这样分配空间的好办法了。

当然,当我们描述地址空间时,所描述的是操作系统提供给运行程序的抽象。程序不再物理地址 0~16KB 的内存中,而是加载在任意的物理地址。回顾前图中的进程 ABC,你可以看到每个进程如何加载到内存中的不同地址。因此问题来了?

关键问题:如何虚拟化内存 操作系统如何在单一的物理内存上为多个运行的进程构建一个私有的、可能很大的地址空间的抽象?

当操作系统这样做时,我们说操作系统在虚拟化内存,因为运行的程序认为它被加载到特定地址的内存中,并且具有非常大的地址空间。现实很不一样。

比如,当前图中的进程 A 尝试在地址 0 执行加载操作时,然而操作系统在硬件的支持下,处于某种原因,必须确保不是加载到物理地址 0,而是物理地址 320KB,即 A 载入内存的地址。这是内存虚拟化的关键,这是世界上每个现代计算机系统的基础。

提示:隔离原则 隔离是构建可靠系统的关键原则。如果两个实体相互隔离,这意味着一个实体的失败不会影响另一个实体。操作系统力求让进程彼此隔离,从而防止互相造成伤害。通过内存隔离,操作系统进一步确保运行程序不会影响底层操作系统的操作。一些现代操作系统通过将某些部分与操作系统的其他部分分离,实现进一步的隔离。这样的微内核可以比整体内核提供更大的可靠性。

目标

补充:你看到的所有地址都不是真的 编写过打印指针的 C 程序吗?你看到的值是虚拟地址。有没有想过你的程序代码到底在哪里?你也可以将其打印出来,但它也是一个虚拟地址。实际上,作为用户级程序的成员,可以看到的任何地址都是虚拟地址。只有操作系统通过精妙的内存虚拟化技术,知道这些指令和数据所在的物理内存地址。所以永远不要忘记:如果你在程序中打印一个地址,那是一个虚拟地址。虚拟地址只是提供地址如何在内存中分布的假象,只有操作系统才知道物理地址。

在这一章中我们将触及操作系统的工作——虚拟化内存。操作系统不仅虚拟化内存,而且还遵循了一定的风格。为了确保操作系统这样做,我们需要一些目标来指导。

虚拟内存系统的一个主要目标是透明。操作系统实现虚拟内存的方式,应该让运行的程序看不见。因此,程序不应该感知到内存被虚拟化的事实。相反,程序的行为就好像它拥有自己的私有物理地址。在幕后,操作系统和硬件完成了所有的工作,让不同的工作复用内存,从而实现了这种假象。

虚拟内存的另一个目标是效率。操作系统应该追求虚拟化尽可能高效,包括时间上和空间上的高效。在实现高效虚拟化时,操作系统将不得不依赖硬件支持,包括 TLB 这样的硬件功能。

1    #include <stdio.h>
2    #include <stdlib.h>
3    int main(int argc, char *argv[]) {
4        printf("location of code : %p\n", (void *) main);
5        printf("location of heap : %p\n", (void *) malloc(1));
6        int x = 3;
7        printf("location of stack : %p\n", (void *) &x);
8        return x;
9    }

最后,虚拟内存的第三个目标是保护。操作系统应该确保进程受到保护,不会受其他进程影响,操作系统本省也不会受进程影响。当一个进程执行加载、存储或指令提取时,它不应该以任何方式访问或影响任何其他进程或操作系统本身的内存内容。因此,保护让我们能够在进程之间提供隔离性,每个进程都应该在自己的独立环境中运行,避免其他出错或恶意进程的影响。

在接下来的章节中,我们将重点介绍虚拟化内存所需的基本机制,包括硬件和操作系统的支持。我们还将研究一些相关的策略,包括如何管理可用内存,以及值空间不足的情况下该释放那些内存。

小结

我们介绍了操作系统的一个重要子系统:虚拟内存。虚拟内存负责为程序提供一个聚到的、稀疏的、私有的地址空间假象。其中保存了程序的所有指令和数据。操作系统在专门硬件的帮助下,通过每一个虚拟内存的索引,将其转换为物理地址,物理内存根据获得的物理地址来获取所需的信息。操作系统会同时对许多进程执行此操作,并且确保程序之间互相不影响,也不会影响操作系统。真个方法需要大量的机制和策略。