《操作系统导论》阅读笔记(八)虚拟内存

/ 0评 / 1

阅读笔记主要来自原书第 13 章。该章对虚拟内存进行了简要的介绍。

1、早期系统

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

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

2、多道程序和时分共享

过了一段时间,由于机器昂贵,人们开始更有效地共享机器。因此,多道程序系统时代开启,其中多个进程准备好在给定的时间运行,操作系统将在它们之间切换,例如当一个进程决定执行 I/O 时。这样增加了 CPU 的有效利用率。那时候,效率的提高尤其重要,因为每台机器的成本是数十万美元甚至数百万美元。

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

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

遗憾的是,这种方法有一个问题:太慢了,特别是当内存增长的时候。虽然保存和恢复寄存器级的状态信息(程序计数器、通用寄存器等)相对较快,但将全部的内存信息保存到磁盘就太慢了。因此,在进程切换的时候,仍然将进程信息放在内存中,这样操作系统可以更有效率地实现时分共享。

在上图中,有 3 个进程(A、B、C),每个进程拥有从 512KB 物理内存中切出来给它们的一小部分内存。假定只有一个 CPU,操作系统选择运行其中一个进程(比如 A),同时其他进程(B 和 C)则在队列中等待运行。

然而,多个程序同时驻留在内存中,使保护成为重要问题。人们不希望一个进程可以读取其他进程的内存,更别说修改了。

简单的内存分配策略存在的问题,摘自《程序员的自我修养:链接、装载与库》1.5 节 内存不够怎么办

  1. 地址空间不隔离。所有程序都直接访问物理地址,程序所使用的内存空间不是相互隔离的。恶意的程序可以很容易改写其他程序的内存数据,以达到破坏的目的;有些非恶意的、但是有臭虫的程序可能不小心修改了其他程序的数据,就会使其他程序也崩溃,这对于需要安全稳定的计算环境的用户来说是不能容忍的。用户希望他在使用计算机的时候,其中一个任务失败了,至少不会影响其他任务。
  2. 内存使用效率低。由于没有有效的内存管理机制,通常需要一个程序执行时,监控程序就将整个程序装入内存中然后开始执行。如果我们忽然需要运行程序 C,那么这时内存空间其实已经不够了,这时候我们可以用的一个办法是将其他程序的数据暂时写到磁盘里面,等到需要用到的时候再读回来。由于程序所需要的空间是连续的,那么这个例子里面,如果我们将程序 A 换出到磁盘所释放的内存空间是不够的,所以只能将 B 换出到磁盘,然后将 C 读入到内存开始运行。可以看到整个过程中有大量的数据在换入换出,导致效率十分低下。
  3. 程序运行的地址不确定。因为程序每次需要装入运行时,都需要给它从内存中分配一块足够大的空闲区域,这个空闲区域的位置是不确定的。这给程序的编写造成了一定的麻烦,因为程序在编写时,它访问数据和指令跳转时的目标地址很多都是固定的,这涉及程序的重定位问题。

3、地址空间

为此,操作系统需要提供一个易用的物理内存抽象。这个抽象叫作地址空间(address space),是运行的程序看到的系统中的内存。理解这个基本的操作系统内存抽象,是了解内存虚拟化的关键。

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

在下图中,有一个很小的地址空间(16KB)。程序代码位于地址空间的顶部(从 0 开始,并且装入到地址空间的前 1KB)。代码是静态的(因此很容易放在内存中),所以可以将它放在地址空间的顶部。

接下来,在程序运行时,地址空间有两个区域可能增长(或者收缩)。它们就是堆(在顶部)和栈(在底部)。把它们放在那里,是因为它们都希望能够增长。通过将它们放在地址空间的两端,可以允许这样的增长:它们只需要在相反的方向增长。因此堆在代码(1KB)之下开始并向下增长(当用户通过 malloc() 请求更多内存时),栈从 16KB 开始并向上增长(当用户进行函数调用时)。然而,栈和堆的这种放置方法只是一种约定,也可以用不同的方式安排地址空间。

堆、栈的地址高低? 栈的增长方向?
进程地址空间的分布取决于操作系统,栈向什么方向增长取决于操作系统与 CPU 的组合。
不要把别的操作系统的实现方式套用到 Windows 上。
x86 硬件直接支持的栈确实是“向下增长”的:push 指令导致 sp 自减一个 slot,pop 指令导致 sp 自增一个 slot。其它硬件有其它硬件的情况。
...
double a0[4]这个声明告诉编译器的是:我需要在栈帧里分配一块连续的空间,大小为sizeof(double)*4,并且让a0引用该空间的起始位置(最低地址);而不是说:我要根据栈的增长方向,先分配a0[0],然后分配a0[1],再分配a0[2],最后分配a0[3],于是如果栈是向下增长那a0[1]就应该比a0[0]在更低的地址——不是这样的。

#include <stdio.h>

void fun()
{
    printf("fun: %p\n", &fun);
}

int main()
{
    printf("main: %p\n", &main);
    fun();
    int a[4] = {1, 2, 3, 4};
    for (int i = 0; i < 4; ++i)
        printf("%p\n", &a[i]);
    return 0;
}

/*
ubuntu@EXP:~$ ./main 
main: 0x5641c1bf718c
fun: 0x5641c1bf7169
0x7ffc0f1e3df0
0x7ffc0f1e3df4
0x7ffc0f1e3df8
0x7ffc0f1e3dfc
*/

当描述地址空间时,所描述的是操作系统提供给运行程序的抽象。程序不在物理地址 0~16KB 的内存中,而是加载在任意的物理地址。因此问题来了。

操作系统如何在单一的物理内存上为多个运行的进程(所有进程共享内存)构建一个私有的、可能很大的地址空间的抽象?

当操作系统这样做时,我们说操作系统在虚拟化内存,因为运行的程序认为它被加载到特定地址(例如 0)的内存中,并且具有非常大的地址空间(例如 32 位或 64 位)。实际上区别很大。

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

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

4、目标

操作系统不仅虚拟化内存,还有一定的风格。为了确保操作系统这样做,需要一些目标来指导。

  1. 虚拟内存系统的一个主要目标是透明。操作系统实现虚拟内存的方式,应该让运行的程序看不见。因此,程序不应该感知到内存被虚拟化的事实,相反,程序的行为就好像它拥有自己的私有物理内存。在幕后,操作系统(和硬件)完成了所有的工作,让不同的工作复用内存,从而实现这个假象。
  2. 虚拟内存的另一个目标是效率。操作系统应该追求虚拟化尽可能高效,包括时间上(即不会使程序运行得更慢)和空间上(即不需要太多额外的内存来支持虚拟化)。在实现高效率虚拟化时,操作系统将不得不依靠硬件支持,包括 TLB 这样的硬件功能。

  3. 最后,虚拟内存第三个目标是保护。操作系统应确保进程受到保护,不会受其他进程影响,操作系统本身也不会受进程影响。当一个进程执行加载、存储或指令提取时,它不应该以任何方式访问或影响任何其他进程或操作系统本身的内存内容(即在它的地址空间之外的任何内容)。因此,保护可以在进程之间提供隔离的特性,每个进程都应该在自己的独立环境中运行。

补充:你看到的所有地址都不是真的

写过打印出指针的 C 程序吗?你看到的值(一些大数字,通常以十六进制打印)是虚拟地址。有没有想过你的程序代码在哪里找到?你也可以打印出来,是的,如果你可以打印它,它也是一个虚拟地址。实际上,作为用户级程序的程序员,可以看到的任何地址都是虚拟地址。只有操作系统,通过精妙的虚拟化内存技术,知道这些指令和数据所在的物理内存的位置。所以永远不要忘记:如果你在一个程序中打印出一个地址,那就是一个虚拟的地址。虚拟地址只是提供地址如何在内存中分布的假象,只有操作系统(和硬件)才知道物理地址

这里有一个小程序,打印出 main() 函数(代码所在地方)的地址,由 malloc() 返回的堆空间分配的值,以及栈上一个整数的地址:

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

在 64 位的 Linux 上面运行时,得到以下输出:

location of code : 0x55ca1c4cd189
location of heap : 0x55ca1cccb6b0
location of stack : 0x7ffca95a8454

从可以看到代码在地址空间开头,然后是堆,而栈在这个大型虚拟地址空间的另一端。所有这些地址都是虚拟的,并且将由操作系统和硬件翻译成物理地址,以便从真实的物理位置获取该地址的值。

在接下来的主题中,将重点介绍虚拟化内存所需的基本机制,包括硬件和操作系统的支持。还将研究一些较相关的策略,你会在操作系统中遇到它们,包括如何管理可用空间,以及在空间不足时哪些页面该释放。通过这些内容,你会逐渐理解现代虚拟内存系统真正的工作原理。

5、小结

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

发表回复

您的电子邮箱地址不会被公开。