文章目录
1 地址空间
1.1 早期系统
早期的机器并没有提供多少抽象给用户
操作系统曾经是一组函数(实际上是一个库),在内存中(在本例中,从物理地址0开始),然后有一个正在运行的程序(进程),目前在物理内存中(在本例中,从物理地址64KB开始),并使用剩余的内存。这里几乎没有抽象,用户对操作系统的要求也不多。
1.2 多道程序和时分共享
过了一段时间,由于机器昂贵,人们开始更有效地共享机器。因此,多道程序(multiprogramming)系统时代
开启,其中多个进程在给定时间准备运行,比如当有一个进程在等待I/O操作的时候,操作系统会切换这些进程,这样增加了CPU的有效利用率(utilization)。
具体来说,许多人意识到批量计算的局限性,尤其是程序员本身,他们厌倦了长时间的(因此也是低效率的)编程—调试循环。交互性(interactivity)
变得很重要,因为许多用户可能同时在使用机器,每个人都在等待(或希望)他们执行的任务得到及时响应。
随着时分共享变得更流行,人们对操作系统又有了新的要求。特别是多个程序同时驻留在内存中,使保护(protection)成为重要问题。人们不希望一个进程可以读取其他进程的内存,更别说修改了。
1.3 地址空间
当程序在运行的时候,利用栈(stack)
来保存当前的函数调用信息,分配空间给局部变量,传递参数和函数返回值
。最后,堆(heap) 用于管理动态分配的、用户管理的内存,就像你从C语言中调用malloc()或面向对象语言(如C ++或Java)中调用new 获得内存。
接下来,在程序运行时,地址空间有两个区域可能增长(或者收缩)。它们就是堆(在顶部)和栈(在底部)。通过将它们放在地址空间的两端,我们可以允许这样的增长:它们只需要在相反的方向增长。因此堆在代码(1KB)之下开始并向下增长(当用户通过malloc()请求更多内存时),栈从16KB开始并向上增长(当用户进行程序调用时)。
当然,当我们描述地址空间时,所描述的是操作系统提供给运行程序的抽象(abstract)。程序不在物理地址0~16KB的内存中,而是加载在任意的物理地址
。回顾前一张图中的进程A、B和C,你可以看到每个进程如何加载到内存中的不同地址。因此问题来了。
关键问题
-
如何虚拟化内存操作系统如何在
单一的物理内存
上为多个运行的进程(所有进程共享内存) 构建一个私有的、可能很大的地址空间的抽象
? -
例如,当图中的进程A尝试在地址0(我们将称其为虚拟地址,virtual address)执行加载操作时,然而操作系统在硬件的支持下,出于某种原因,必须确保不是加载到物理地址0,而是物理地址320KB(这是A载入内存的地址)。这是内存虚拟化的关键,这是世界上每一个现代计算机系统的基础。
2 内存操作API
关键问题:如何分配和管理内存在UNIX/C程序中,理解如何分配和管理内存
是构建健壮和可靠软件的重要基础。通常使用哪些接口
?哪些错误需要避免?
2.1 内存类型
- 在运行一个C程序的时候,会分配两种类型的内存。第一种称为
栈(stack)内存
,它的申请和释放操作是编译器来隐式管理
的,所以有时也称为`自动(automatic)内存。
void func() {
int x; // declares an integer on the stack
...
}
- 编译器完成剩下的事情,确保在你进入 func()
函数的时候,在栈上开辟空间。当你从该函数退出时,编译器释放内存。因此,如果你希望某些信息存在于函数调用之外,建议不要将它们放在栈上。 - 就是这种对长期内存的需求,所以我们才需要第二种类型的内存,即所谓的堆(heap)内存,其中所有的申请和释放操作都由程序员显式地完成。
void func() {
int *x = (int *) malloc(sizeof(int));
...
}
- 首先,你可能会注意到栈和堆的分配都发生在这一行:首先编译器看到指针的声明(int *
x)时,知道为一个整型指针分配空间,随后,当程序调用malloc()时,它会在堆上请求整数的空间,函数返回这样一个整数的地址(成功时,失败时则返回NULL),然后将其存储在栈中以供程序使用。
2.2 malloc()调用
malloc 函数非常简单:传入要申请的堆空间的大小,它成功就返回一个指向新申请空间的指针,失败就返回NULL。
如果为一个字符串声明空间,请使用以下习惯用法:malloc(strlen(s) + 1),它使用函数strlen()获取字符串的长度,并加上1,以便为字符串结束符留出空间。这里使用sizeof()可能会导致麻烦。
2.3 free()调用
分配内存是等式的简单部分。知道何时、如何以及是否释放内存是困难的部分。要释放不再使用的堆内存,程序员只需调用free():
int *x = malloc(10 * sizeof(int));
...
free(x);
该函数接受一个参数,即一个由malloc()返回的指针。
为什么在你的进程退出时没有内存泄露?
第一级是由操作系统执行的内存管理,操作系统在进程运行时将内存交给进程,并在进程退出(或以其他方式结束)时将其回收。第二级管理在每个进程中,例如在调用malloc()和free()时,在堆内管理。即使你没有调用free()(并因此泄露了堆中的内存),操作系统也会在程序结束运行时,收回进程的所有内存(包括用于代码、栈,以及相关堆的内存页)。无论地址空间中堆的状态如何,操作系统都会在进程终止时收回所有这些页面,从而确保即使没有释放内存,也不会丢失内存。
因此,对于短时间运行的程序,泄露内存通常不会导致任何操作问题(尽管它可能被认为是不好的形式)。如果你编写一个长期运行的服务器(例如Web服务器或数据库管理系统,它永远不会退出),泄露内存就是很大的问题,最终会导致应用程序在内存不足时崩溃。当然,在某个程序内部泄露内存是一个更大的问题:操作系统本身。
2.4 底层操作系统支持
你可能已经注意到,在讨论malloc()和free()时,我们没有讨论系统调用。原因很简单:它们不是系统调用,而是库调用
。因此,malloc库管理虚拟地址空间内的空间,但是它本身是建立在一些系统调用之上的,这些系统调用会进入操作系统,来请求更多内存或者将一些内容释放回系统。
3 机制:地址转换
在实现CPU虚拟化时,我们遵循的一般准则被称为受限直接访问(Limited Direct Execution,LDE)
。LDE背后的想法很简单:让程序运行的大部分指令直接访问硬件,只在一些关键点(如进程发起系统调用或发生时钟中断)由操作系统介入来确保“在正确的时间,正确的地点,做正确的事”。
关键问题:
- 如何高效、灵活地虚拟化内存如何实现高效的内存虚拟化?如何提供应用程序所需的灵活性?如何保持控制应用程序可访问的内存位置,从而确保应用程序的内存访问受到合理的限制?如何高效地实现这一切?
我们利用了一种通用技术,有时被称为基于硬件的地址转换(hardware-based address translation),简称为地址转换(address translation)。它可以看成是受限直接执行这种一般方法的补充。利用地址转换,硬件对每次内存访问进行处理(即指令获取、数据读取或写入),将指令中的虚拟(virtual)地址转换为数据实际存储的物理(physical)地址。因此,在每次内存引用时,硬件都会进行地址转换,将应用程序的内存引用重定位到内存中实际的位置。
当然,仅仅依靠硬件不足以实现虚拟内存,因为它只是提供了底层机制来提高效率。操作系统必须在关键的位置介入,设置好硬件,以便完成正确的地址转换。因此它必须管理内存(manage memory),记录被占用和空闲的内存位置,并明智而谨慎地介入,保持对内存使用的控制。
虚拟现实的背后是丑陋的物理事实:许多程序其实是在同一时间共享着内存,就像CPU(或多个CPU)在不同的程序间切换运行。
具体来说,我们先假设用户的地址空间必须连续地放在物理内存中。同时,为了简单,我们假设地址空间不是很大,具体来说,小于物理内存的大小。最后,假设每个地址空间的大小完全一样。别担心这些假设听起来不切实际,我们会逐步地放宽这些假设,从而得到现实的内存虚拟化。
3.1 一个例子
C语言形式可能像这样:
void func() {
int x;
x = x + 3; // this is the line of code we are interested in
编译器将这行代码转化为汇编语句,可能像下面这样(x86汇编)。
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
进程及其地址空间
然而,对虚拟内存来说,操作系统希望将这个进程地址空间放在物理内存的其他位置,并不一定从地址0开始。因此我们遇到了如下问题:怎样在内存中重定位这个进程,同时对该进程透明(transparent)?怎么样提供一种虚拟地址空间从0开始的假象,而实际上地址空间位于另外某个物理地址?
物理内存和单个重定位的进程
3.2 动态(基于硬件)重定位
在20世纪50年代后期,它在首次出现的时分机器中引入,那时只是一个简单的思想,称为基址加界限机制(base and bound),有时又称为动态重定位(dynamic relocation)。
具体来说,每个CPU需要两个硬件寄存器:基址(base)寄存器和界限(bound)寄存器,有时称为限制(limit)寄存器。这组基址和界限寄存器,让我们能够将地址空间放在物理内存的任何位置,同时又能确保进程只能访问自己的地址空间。
采用这种方式,在编写和编译程序时假设地址空间从零开始。但是,当程序真正执行时,操作系统会决定其在物理内存中的实际加载地址,并将起始地址记录在基址寄存器中。
现在,该进程产生的所有内存引用,都会被处理器通过以下方式转换为物理地址:
physical address = virtual address + base
在动态重定位的过程中,只有很少的硬件参与,但获得了很好的效果。一个基址寄存器
将虚拟地址转换为物理地址,一个界限寄存器
确保这个地址在进程地址空间的范围内。它们一起提供了既简单又高效的虚拟内存机制。
这种基址寄存器配合界限寄存器的硬件结构是芯片中的(每个CPU一对)。有时我们将CPU的这个负责地址转换的部分统称为内存管理单元(Memory Management Unit,MMU)
。随着我们开发更复杂的内存管理技术,MMU也将有更复杂的电路和功能。
转换示例
设想一个进程拥有4KB大小地址空间(是的,小得不切实际),它被加载到从16KB开始的物理内存中。
补充:数据结构——空闲列表
- 操作系统必须记录哪些空闲内存没有使用,以便能够为进程分配内存。很多不同的数据结构可以用于这项任务,其中最简单的(也是我们假定在这里采用的)是空闲列表(free
list)。它就是一个列表,记录当前没有使用的物理内存的范围。
3.3 硬件支持:总结
动态重定位:硬件要求
硬件还必须提供基址和界限寄存器(base and bounds register),因此每个CPU的内存管理单元(Memory Management Unit,MMU)都需要这两个额外的寄存器。
硬件应该提供一些特殊的指令,用于修改基址寄存器和界限寄存器,允许操作系统在切换进程时改变它们。这些指令是特权(privileged)指令
,只有在内核模式
下,才能修改这些寄存器。
3.4 操作系统的问题
- 当新进程创建时,操作系统检索这个数据结构(常被称为空闲列表,free list),为新地址空间找到位置,并将其标记为已用
- 在进程终止时(正常退出,或因行为不端被强制终止),操作系统也必须做一些工作,回收它的所有内存,给其他进程或者操作系统使用。在进程终止时,操作系统会将这些内存放回到空闲列表,并根据需要清除相关的数据结构。
- 在上下文切换时,操作系统也必须执行一些额外的操作。每个CPU毕竟只有一个基址寄存器和一个界限寄存器,但对于每个运行的程序,它们的值都不同,因为每个程序被加载到内存中不同的物理地址。因此,在切换进程时,操作系统必须保存和恢复基础和界限寄存器。具体来说,当操作系统决定中止当前的运行进程时,它必须将当前基址和界限寄存器中的内容保存在内存中,放在某种每个进程都有的结构中,如进程结构(process structure)或进程控制块(Process Control Block,PCB)中。类似地,当操作系统恢复执行某个进程时(或第一次执行),也必须给基址和界限寄存器设置正确的值。
动态重定位:操作系统的职责
- 操作系统必须提供异常处理程序(exception
handler),或要一些调用的函数,像上面提到的那样。操作系统在启动时加载这些处理程序(通过特权命令)。
受限直接执行协议(动态重定位)
请注意,地址转换过程完全由硬件处理,没有操作系统的介入。
参考文献:《操作系统导论》