抽象:地址空间
早期系统
从内存来看,早期的机器并没有提供多少抽象给用户。基本上,机器的物理内存看起来如图所示。
操作系统曾经是一组函数(实际上是一个库),在内存中(在本例中,从物理地址0开始),然后有一个正在运行的程序(进程),目前在物理内存中(在本例中,从物理地址64KB开始),并使用剩余的内存。
多道程序和时分共享
过了一段时间,由于机器昂贵,人们开始更有效地共享机器。因此,多道程序系统和分时系统分别开启了。
在下图中,有3个进程(A、B、C),每个进程拥有从512KB物理内存中切出来给它们的一小部分内存。假定只有一个CPU,操作系统选择运行其中一个进程(比如A),同时其他进程(B和C)则在队列中等待运行。
随着时分共享变得流行,人们对操作系统又有了新的要求。特别是多个程序同时驻留在内存中,使保护(protection)成为重要问题。
地址空间
为了解决这些问题,操作系统需要提供一个易用(easy to use)的物理内存抽象。这个抽象叫作地址空间(address space),是运行的程序看到的系统中的内存。
一个进程的地址空间包含运行的程序的所有内存状态。比如:程序的代码(code,指令)必须在内存中,因此它们在地址空间里。当程序在运行的时候,利用栈(stack)来保存当前的函数调用信息,分配空间给局部变量,传递参数和函数返回值。最后,堆(heap)用于管理动态分配的、用户管理的内存。当然,还有其他的东西(例如,静态初始化的变量),但现在假设只有这3个部分:代码、栈和堆。
在下图的例子中,我们有一个很小的地址空间(只有16KB)。程序代码位于地址空间的顶部(在本例中从0开始,并且装入到地址空间的前1KB)。代码是静态的(因此很容易放在内存中),所以可以将它放在地址空间的顶部,我们知道程序运行时不再需要新的空间。
当我们描述地址空间时,所描述的是操作系统提供给运行程序的抽象。程序不在物理地址0~16KB的内存中,而是加载在任意的物理地址。但是运行的程序意识不到这点,它认为自己被加载到特定地址(例如0)的内存中,并且具有非常大的地址空间。这就是虚拟内存系统需要做的事情。
目标
虚拟内存(VM)系统的一个主要目标是透明(transparency)。操作系统实现虚拟内存的方式,应该让运行的程序看不见。因此,程序不应该感知到内存被虚拟化的事实,相反,程序的行为就好像它拥有自己的私有物理内存。
虚拟内存的另一个目标是效率(efficiency)。操作系统应该追求虚拟化尽可能高效(efficient),包括时间上(即不会使程序运行得更慢)和空间上(即不需要太多额外的内存来支持虚拟化)。在实现高效率虚拟化时,操作系统将不得不依靠硬件支持,包括TLB这样的硬件功能。
最后,虚拟内存第三个目标是保护(protection)。操作系统应确保进程受到保护(protect),不会受其他进程影响,操作系统本身也不会受进程影响。当一个进程执行加载、存储或指令提取时,它不应该以任何方式访问或影响任何其他进程或操作系统本身的内存内容(即在它的地址空间之外的任何内容)。
内存操作API
在运行一个C程序的时候,会分配两种类型的内存。第一种称为栈内存,它的申请和释放操作是编译器来隐式管理的,所以有时也称为自动(automatic)内存。第二种类型的内存,即所谓的堆(heap)内存,其中所有的申请和释放操作都由程序员显式地完成。
malloc
malloc函数非常简单:传入要申请的堆空间的大小,它成功就返回一个指向新申请空间的指针,失败就返回NULL。
#include <stdlib.h>
...
void *malloc(size_t size);
free
要释放不再使用的堆内存,程序员只需调用free():
int *x = malloc(10 * sizeof(int));
...
free(x);
该函数接受一个参数,即一个由malloc()返回的指针。分配区域的大小不会被用户传入,必须由内存分配库本身记录追踪。
常见错误
在使用malloc()和free()时会出现一些常见的错误。
忘记分配内存
许多例程在调用之前,都希望你为它们分配内存。例如,例程strcpy(dst, src)将源字符串中的字符串复制到目标指针。但是,如果不小心,你可能会这样做:
char *src = "hello";
char *dst; // oops! unallocated
strcpy(dst, src); // segfault and die
没有分配足够的内存
另一个相关的错误是没有分配足够的内存,有时称为缓冲区溢出(buffer overflow)。一个常见的错误是为目标缓冲区留出“几乎”足够的空间。
char *src = "hello";
char *dst = (char *) malloc(strlen(src)); // too small!
strcpy(dst, src); // work properly
忘记初始化分配的内存
在这个错误中,程序员正确地调用malloc(),但忘记在新分配的数据类型中填写一些值。这样的话程序最终会遇到未初始化的读取(uninitialized read),它从堆中读取了一些未知值的数据。
忘记释放内存
另一个常见错误称为内存泄露(memory leak),如果忘记释放内存,就会发生。在长时间运行的应用程序或系统(如操作系统本身)中,这是一个巨大的问题,因为缓慢泄露的内存会导致内存不足,此时需要重新启动。
在用完之前释放内存
有时候程序会在用完之前释放内存,这种错误称为悬挂指针(dangling pointer)。随后的使用可能会导致程序崩溃或覆盖有效的内存(例如,你调用了free(),但随后再次调用malloc()来分配其他内容,这重新利用了错误释放的内存)。
反复释放内存
程序有时还会不止一次地释放内存,这被称为重复释放(double free)。这样做的结果是未定义的。
注:系统中实际存在两级内存管理。第一级是由操作系统执行的内存管理,操作系统在进程运行时将内存交给进程,并在进程退出(或以其他方式结束)时将其回收。第二级管理在每个进程中,例如在调用malloc()和free()时,在堆内管理。即使你没有调用free(),操作系统也会在程序结束运行时,收回进程的所有内存。
机制:地址转换
我们利用了一种通用技术,有时被称为基于硬件的地址转换(hardware-based addresstranslation),简称为地址转换(address translation)。利用地址转换,硬件对每次内存访问进行处理(即指令获取、数据读取或写入),将指令中的虚拟(virtual)地址转换为数据实际存储的物理(physical)地址。因此,在每次内存引用时,硬件都会进行地址转换,将应用程序的内存引用重定位到内存中实际的位置。
当然,仅仅依靠硬件不足以实现虚拟内存,因为它只是提供了底层机制来提高效率。操作系统必须在关键的位置介入,设置好硬件,以便完成正确的地址转换。因此它必须管理内存(manage memory),记录被占用和空闲的内存位置,并明智而谨慎地介入,保持对内存使用的控制。
假设
我们先假设用户的地址空间必须连续地放在物理内存中。同时,为了简单,我们假设地址空间不是很大,具体来说,小于物理内存的大小。最后,假设每个地址空间的大小完全一样。这些假设听起来不切实际,不过我们会逐步地放宽这些假设,从而得到现实的内存虚拟化。
简单的实例
设想一个进程的地址空间如图所示。这里我们要检查一小段代码,它从内存中加载一个值,对它加3,然后将它存回内存。你可以设想,这段代码的C语言形式可能像这样:
void func() {
int x;
x = x + 3; // this is the line of code we are interested in
编译器将这行代码转化为汇编语句,可能像下面这样(x86汇编)。我们可以用Linux的objdump或者Mac的otool将它反汇编:
128: movl 0x0(%ebx), %eax ;load 0+ebx into eax
132: addl $0x03, %eax ;add 3 to eax register
135: movl %eax, 0x0(%ebx) ;store eax back to mem
这段代码很简单,它假定x的地址已经存入寄存器ebx,之后通过movl指令将这个地址的值加载到通用寄存器eax(长字移动)。下一条指令对eax的内容加3。最后一条指令将eax中的值写回到内存的同一位置。
可以看到代码和数据都位于进程的地址空间,3条指令序列位于地址128(靠近头部的代码段),变量x的值位于地址15KB(在靠近底部的栈中)。
从程序的角度来看,它的地址空间从0开始到16KB结束。它包含的所有内存引用都应该在这个范围内。然而,对虚拟内存来说,操作系统希望将这个进程地址空间放在物理内存的其他位置,并不一定从地址0开始。因此我们遇到了如下问题:怎样在内存中重定位这个进程,同时对该进程透明?
下图展示了一种方法,说明这个进程的地址空间被放入物理内存后可能的样子。可以看到,操作系统将第一块物理内存留给了自己,并将上述例子中的进程地址空间重定位到从32KB开始的物理内存地址。剩下的是两块内存空闲(16~32KB和48~64KB)。
动态(基于硬件)重定位
为了更好地理解基于硬件的地址转换,我们先来讨论它的第一次应用。称为基址加界限机制(base and bound),有时又称为动态重定位(dynamic relocation)。
具体来说,每个CPU需要两个硬件寄存器:基址(base)寄存器和界限(bound)寄存器,有时称为限制(limit)寄存器。这组基址和界限寄存器,让我们能够将地址空间放在物理内存的任何位置,同时又能确保进程只能访问自己的地址空间。
采用这种方式,在编写和编译程序时假设地址空间从零开始。但是,当程序真正执行时,操作系统会决定其在物理内存中的实际加载地址,并将起始地址记录在基址寄存器中。在上面的例子中,操作系统决定加载在物理地址32KB的进程,因此将基址寄存器设置为这个值。
当进程运行时,该进程产生的所有内存引用,都会被处理器通过以下方式转换为物理地址:
physical address = virtual address + base
进程中使用的内存引用都是虚拟地址(virtual address),硬件接下来将虚拟地址加上基址寄存器中的内容,得到物理地址(physical address),再发给内存系统。而界限寄存器则提供了访问保护。在上面的例子中,界限寄存器被置为16KB。如果进程需要访问超过这个界限或者为负数的虚拟地址,CPU将触发异常,进程最终可能被终止。
将虚拟地址转换为物理地址,这正是所谓的地址转换(address translation)技术。由于这种重定位是在运行时发生的,而且我们甚至可以在进程开始运行后改变其地址空间,这种技术一般被称为动态重定位(dynamic relocation)。
需要的硬件支持
我们来总结一下需要的硬件支持。首先,我们需要两种CPU模式。操作系统在特权模式,可以访问整个机器资源。应用程序在用户模式运行,只能做有限的操作。只要一个位,也许保存在处理器状态字中,就能说明当前的CPU运行模式。在一些特殊的时刻(如系统调用、异常或中断),CPU会切换状态。
硬件还必须提供基址和界限寄存器(base and bounds register),因此每个CPU的内存管理单元(Memory Management Unit,MMU)都需要这两个额外的寄存器。用户程序运行时,硬件会转换每个地址,即将用户程序产生的虚拟地址加上基址寄存器的内容。硬件也必须能检查地址是否有用,通过界限寄存器和CPU内的一些电路来实现。
硬件应该提供一些特殊的指令,用于修改基址寄存器和界限寄存器,允许操作系统在切换进程时改变它们。这些指令是特权指令,只有在内核模式下,才能修改这些寄存器。
最后,在用户程序尝试非法访问内存(越界访问)时,CPU必须能够产生异常(exception)。在这种情况下,CPU应该阻止用户程序的执行,并安排操作系统的“越界”异常处理程序(exception handler)去处理。CPU还必须提供一种方法,来通知它这些处理程序的位置,因此又需要另一些特权指令。
操作系统的职责
为了支持动态重定位,硬件添加了新的功能,使得操作系统有了一些必须处理的新工作。硬件支持和操作系统管理结合在一起,实现了一个简单的虚拟内存。
第一,在进程创建时,操作系统必须采取行动,为进程的地址空间找到内存空间。它可以把整个物理内存看作一组槽块,标记了空闲或已用。当新进程创建时,操作系统检索这个数据结构(常被称为空闲列表,free list),为新地址空间找到位置,并将其标记为已用。
第二,在进程终止时,操作系统也必须做一些工作,回收它的所有内存,给其他进程或者操作系统使用。在进程终止时,操作系统会将这些内存放回到空闲列表,并根据需要清除相关的数据结构。
第三,在上下文切换时,操作系统也必须执行一些额外的操作。每个CPU毕竟只有一个基址寄存器和一个界限寄存器,但对于每个运行的程序,它们的值都不同,因为每个程序被加载到内存中不同的物理地址。因此,在切换进程时,操作系统必须保存和恢复基础和界限寄存器。具体来说,当操作系统决定中止当前的运行进程时,它必须将当前基址和界限寄存器中的内容保存在内存中,放在某种每个进程都有的结构中,如进程结构(process structure)或进程控制块(Process Control Block,PCB)中。类似地,当操作系统恢复执行某个进程时(或第一次执行),也必须给基址和界限寄存器设置正确的值。
第四,操作系统必须提供异常处理程序(exception handler),或要一些调用的函数,像上面提到的那样。操作系统在启动时加载这些处理程序(通过特权命令)。例如,当一个进程试图越界访问内存时,CPU会触发异常。
存在的不足
遗憾的是,这个简单的动态重定位技术有效率低下的问题。例如,从例子中可以看到,重定位的进程使用了从32KB到48KB的物理内存,但由于该进程的栈区和堆区并不很大,导致这块内存区域中大量的空间被浪费。这种浪费通常称为内部碎片(internal fragmentation),指的是已经分配的内存单元内部有未使用的空间(即碎片),造成了浪费。在我们当前的方式中,即使有足够的物理内存容纳更多进程,但我们目前要求将地址空间放在固定大小的槽块中,因此会出现内部碎片。所以,我们需要更复杂的机制,以便更好地利用物理内存,避免内部碎片。
**粗体** _斜体_ [链接](http://example.com) `代码` - 列表 > 引用
。你还可以使用@
来通知其他用户。