跳转至

内存

一、抽象:地址空间

  • 在早期,操作系统曾经是一组函数(实际上是一个库),那时候操作电脑的都是专业人员,通常只有一个程序在运行,我们可以说,程序直接控制硬件。
  • 过了一段时间,人们开始更有效的共享机器(因为机器太贵),多道程序系统,甚至时分系统开始流行。
  • 一开始人们使用独占的方式运行程序,运行一个程序一小段时间,然后停止它,并将它所有的状态信息保存在磁盘上(包含所有的物理内存),加载其他进程的状态信息,再运行一段时间。
  • 将全部的内存信息保存到磁盘就太慢了,因此,在进程切换的时候,我们仍然将进程信息放在内存中,这样操作系统可以更有效率地实现时分共享。
  • 当多个程序同时驻留在内存中时,保护(protection) 成为重要问题。人们不希望一个进程可以读取其他进程的内存,更别说修改了。
  • 因此操作系统需要提供一个易用 (easy to use)的物理内存抽象。这个抽象叫作地址空间(address space),是运行的程序看到的系统中的内存。
  • 当我们描述地址空间时,所描述的是操作系统提供给运行程序的抽象(abstract)。当操作系统这样做时,我们说操作系统在虚拟化内存(virtualizing memory)。

二、内存操作API

  • 栈(stack)内存,它的申请和释放操作是编译器来隐式管理的,所以有时也称为自动(automatic)内存。例如void func(){int x;},编译时,编译器完成剩下的事情,确保在你进入函数的时候,在栈上开辟空间。当你从该函数退出时,编译器释放内存。如果你希望某些信息存在于函数调用之外,建议不要将它们放在栈上。
  • 堆(heap) 内存,对长期内存的需求我们需要第二种类型的内存,即所谓的堆(heap) 内存,其中所有的申请和释放操作都由程序员显式地完成。

malloc()调用

malloc 函数非常简单:传入要申请的堆空间的大小,它成功就返回一个指向新申请空 间的指针,失败就返回 NULL。

free()调用

要释放不再使用的堆内存,程序员只需调用 free()。你可能会注意到,分配区域的大小不会被用户传入,必须由内存分配库本身记录追踪。

常见错误

  • 忘记分配内存:会导致段错误(segmentation fault)。
  • 没有分配足够的内存:当分配的内存不足以容纳存入的字节,会导致其覆盖后面不属于自己的内存空间,我们称为缓冲区溢出(buffer overflow)。
  • 忘记初始化分配的内存:未初始化的读取(uninitialized read),它从堆中读取了一些未知值的数据,而不是零值。
  • 忘记释放内存:忘记释放内存会导致内存泄露(memory leak),但是仅发生程序运行期间,当运行结束后,该程序的所有内存都会被操作系统回收。
  • 在用完之前释放内存:这种错误称为悬挂指针(dangling pointer),用可能会导致程序崩溃或覆盖有效的内存。
  • 反复释放内存:重复释放(double free)会导致分配库可做各种奇怪的事情,崩溃是常见的结果。
  • 错误地调用free():无效的释放(invalid free)是危险的,传入一些其他的值,坏事就可能发生(并且会发生),应该避免。

许多新语言都支持自动内存管理(automatic memory management)。在这样的语言中,当你调用类似 malloc()的机制来分配内存时(通常用 new 或类似的东西来分配一个新对象),你永远不需要调用某些东西来释放空间。事实上, 垃圾收集器(garbage collector)会运行,找出你不再引用的内存,替你释放它。

底层操作系统支持

在讨论 malloc()和 free()时,我们没有讨论系统调用。原因很简单:它们不是系统调用,而是库调用。因此,malloc 库管理虚拟地址空间内的空间,但是它本身是建立在一些系统调用之上的,这些系统调用会进入操作系统,来请求本多内存或者将一些内容释放回系统。

  • brk() 它被用来改变程序分断(break)的位置:堆结束的位置。它需要一个参数(新分断的地址),从而根据新分断是大于还是小于当前分断,来增加或减小堆的大小。另一个调用 sbrk 要求传入一个增量,但目的是类似的。
  • mmap() 调用从操作系统获取内存。通过传入正确的参数,mmap() 可以在程序中创建一个匿名(anonymous)内存区域——这个区域不与任何特定文件相关联, 而是与交换空间(swap space)相关联。
  • calloc() 分配内存,并在返回之前将其置零。
  • realloc() 创建一个新的更大的内存区域,将旧区域复制到其中,并返回新区域的指针。

三、虚拟内存

目标

  • 虚拟内存(VM)系统的一个主要目标是透明(transparency)。程序不应该感知到内存被虚拟化的事实,相反,程序的行为就好像它拥有自己的私有物理内存。
  • 虚拟内存的另一个目标是效率(efficiency)。操作系统应该追求虚拟化尽可能高效 (efficient),包括时间上(即不会使程序运行得更慢)和空间上(即不需要太多额外的内存 来支持虚拟化)。在实现高效率虚拟化时,操作系统将不得不依靠硬件支持,包括 TLB 这样 的硬件功能(我们将在适当的时候学习)。
  • 虚拟内存第三个目标是保护(protection)。操作系统应确保进程受到保护(protect),不会受其他进程影响,操作系统本身也不会受进程影响。当一个进程执行加载、存储或指令提取时,它不应该以任何方式访问或影响任何其他进程或操作系统本身的内存内容(即在它的地址空间之外的任何内容)。因此,保护让我们能够在进程之间提供隔离(isolation) 的特性,每个进程都应该在自己的独立环境中运行,避免其他出错或恶意进程的影响。

基于硬件的地址转换(hardware-based address translation)

简称为地址转换(address translation)。它可以看成是受限直接执行这种一般方法的补充。

  • 利用地址转换,硬件对每次内存访问进行处理(即指令获取、 数据读取或写入),将指令中的虚拟(virtual)地址转换为数据实际存储的物理(physical)地址。
  • 因此,在每次内存引用时,硬件都会进行地址转换,将应用程序的内存引用重定位到内存中实际的位置。
  • 当然,仅仅依靠硬件不足以实现虚拟内存,因为它只是提供了底层机制来提高效率。 操作系统必须在关键的位置介入,设置好硬件,以便完成正确的地址转换。
  • 因此它必须管理内存(manage memory),记录被占用和空闲的内存位置,并明智而谨慎地介入,保持对内存使用的控制。

动态(基于硬件)重定位

在 20 世纪 50 年代后期,地址转换在首次出现的时分机器中引入,那时只是一个简单的思想,称为基址加界限机制(base and bound),有时又称为动态重定位(dynamic relocation)。

  • 每个 CPU 需要两个硬件寄存器:基址(base)寄存器和界限(bound)寄存器,有时称为限制(limit)寄存器。
  • 当程序真正执行时, 操作系统会决定其在物理内存中的实际加载地址,并将起始地址记录在基址寄存器中。
  • 进程中使用的内存引用都是虚拟地址(virtual address),硬件接下来将虚拟地址加上基址寄存器中的内容,得到物理地址(physical address)。
  • 界限寄存器提供了访问保护。如果进程需要访问超过这个界限或者为负数的虚拟地址,CPU 将触发异常,进程最终可能被终止。
  • 这种基址寄存器配合界限寄存器的硬件结构是芯片中的(每个 CPU 一对)。有时我们将 CPU 的这个负责地址转换的部分统称为内存管理单元(Memory Management Unit,MMU)。
  • 用户程序运行时, 硬件会转换每个地址,即将用户程序产生的虚拟地址加上基址寄存器的内容。硬件也必须能检查地址是否有用,通过界限寄存器和 CPU 内的一些电路来实现。

操作系统的问题

在一些关键的时刻操作系统需要介入,以实现基址和界限方式的虚拟内存

  • 第一,在进程创建时,操作系统必须采取行动,为进程的地址空间找到内存空间。
  • 第二,在进程终止时(正常退出,或因行为不端被强制终止),操作系统也必须做一些工作,回收它的所有内存,给其他进程或者操作系统使用。
  • 第三,在上下文切换时,操作系统也必须执行一些额外的操作。当操作系统决定中止当前的运行进程时,它必须将当前基址和界限寄存器中的内容保存在内存中,放在某种每个进程都有的结构中,如进程结构(process structure)或进程控制块(Process Control Block,PCB)中。类似地,当操作系统恢复执行某个进程时(或第一次执行),也必须给基址和界限寄存器设置正确的值。
  • 第四,操作系统必须提供异常处理程序(exception handler),或要一些调用的函数。操作系统在启动时加载这些处理程序(通过特权命令),trap表。

分段

如果我们将整个地址空间放入物理内存,那么栈和堆之间的空间并没有被进程使用,却依然占用了实际的物理内存。因此,简单的通过基址寄存器和界限寄存器实现的虚拟内存很浪费。另外,如果剩余物理内存无法提供连续区域来放置完整的地 址空间,进程便无法运行。

  • 在 MMU 中引入不止一个基址和界限寄存器对,而是给地址空间内的每个逻辑段(segment)一对。
  • 一个段只是地址空间里的一个连续定长的区域,在典型的地址空间里有 3 个逻辑不同的段:代码、栈和堆。
  • 分段的机制使得操作系统能够将不同的段放到不同的物理内存区域,从而避免了虚拟地址空间中的未使用部分占用物理内存。
  • 只有已用的内存才在物理内存中分配空间,因此可以容纳巨大的地址空间,其中包含大量未使用的地址空间(有时又称为稀疏地址空间,sparse address spaces)。

如果我们试图访问非法的地址,例如访问的地址超出了堆的边界,硬件会发现该地址越界,因此陷入操作系统,很可能导致终止出错进程。这就是每个 C 程序员 都感到恐慌的术语的来源:段异常(segmentation violation)或段错误(segmentation fault)。

段的表示

  • 显式(explicit)方式,就是用虚拟地址的开头几位来标识不同的段,剩下的位则是偏移量。
  • 隐式(implicit)方式,硬件通过地址产生的方式来确定段。例如,如果地址由程序计数器产生(即它是指令获取),那么地址在代码段。如果基于栈或基址指针,它一定在栈段。其他地址则在堆段。

栈和共享

  • 除了基址和界限外,硬件还需要知道段的增长方向(用一位区分,比如 1 代表自小而大增长,0 反之),根据标识位决定计算方式。
  • 要节省内存,有时候在地址空间之间共享(share)某些内存段是有用的。为了支持共享,需要一些额外的硬件支持,这就是保护位(protection bit)。为每个段增加了几个位,标识程序是否能够读写该段,或执行其中的代码。

细粒度的分段

到目前为止,我们的例子大多针对只有很少的几个段的系统(即代码、栈、堆)。我们可以认为这种分段是粗粒度的(coarse-grained),因为它将地址空间分成较大的、粗粒度的块。相对的将地址空间划分为大量较小的段,这被称为细粒度(fine-grained)分段。

  • 支持许多段需要进一步的硬件支持,并在内存中保存某种段表(segment table)。
  • 有了操作系统和硬件的支持,编译器可以将代码段和数据段划分为许多不同的部分。

操作系统支持

  • 操作系统在上下文切换时,各个段寄存器中的内容必须保存和恢复。每个进程都有自己独立的虚拟地址空间,操作系统必须在进程运行前,确保这些寄存器被正确地赋值。
  • 新的地址空间被创建时,操作系统需要在物理内存中为它的段找到空间。

四、空虚空间管理