操作系统导论学习笔记(五)

内存使用的演化

最开始,操作系统只是库函数,和用户程序各占有一部分物理内存

接着,为了更有效地共享机器,多道程序开始流行,但很快,人们希望提高系统的交互性,时分系统开始流行,需要让多个程序共享内存,一种方式是类似上下文切换,将内存中的数据移动到磁盘中保存,以及从磁盘加载恢复,但是这个方式的问题是太慢了,尤其是程序占用内存越来越大的情况下,另一种方式是多个程序的内存同时驻留,CPU 选择某个进程执行,这种方式带来的问题是如何隔离及保护内存,毕竟不希望其他进程访问甚至修改当前进程的内存。

基于此,操作系统提供了易于使用的物理内存抽象,这个抽象叫做 地址空间 address space,是运行程序看到的系统中的内存。理解这个抽象,是理解虚拟内存的关键。

地址空间

一个进程的 地址空间 包含运行的程序的所有内存状态,比如代码需要加载到内存中,需要栈空间保存函数调用信息,为局部变量、参数、函数返回地址开辟空间,堆管理用户动态申请的空间,还有其他东西,比如静态变量等,这些都是地址空间的一部分。

不过为了简化讨论,我们假设只有代码、栈、堆这 3 部分。

地址空间简单示例

地址空间简单示例

代码是固定的,程序运行期间不会发生变化,我们把它放在起始 0-1KB 部分

堆和栈在程序运行期间都有可能增长或缩小,比如用户申请内存、调用函数等情况,堆放在代码段后面向下增长,栈在地址空间底端向上增长。

以上是一种约定俗称,不是强制(比如当多个线程在地址空间中共存时)。

这里的地址空间,是操作系统提供给程序的抽象,而不是实际的物理内存地址,而是加载在任意的物理地址。

操作系统将程序看到的地址空间,转换为实际的物理地址,并从物理内存中获取内容,这是虚拟化内存中的关键。

虚拟内存目标

关键问题:操作系统如何在单一的物理内存上为多个运行的进程(所有进程共享内存)构建一个私有的、很大的地址空间抽象?
当操作系统这样做时,我们就说操作系统在 虚拟化内存 virtualizing memory

虚拟内存的 3 个目标

  1. 透明:应用程序不会察觉虚拟内存的存在,这些工作由操作系统和硬件在幕后完成,站在应用程序的角度,和直接使用物理内存无异
  2. 高效:时间上(不会因为虚拟内存而使应用程序运行变慢)和空间上(不会需要太多额外内存来支持虚拟内存实现),因此操作系统需要硬件支持来达到高效虚拟化内存的目的(比如 TLB)
  3. 保护:当一个进程加载、存储或执行指令时,不应该以任何形式访问或修改其他程序或操作系统本身的内存(即它地址空间之外的任何内存)。每个进程都应该在自己独立的环境中运行,避免其他出错或恶意进程的影响

虚拟内存所需 API

关键问题:如何分配和管理内存
在 UNIX/C 程序中,理解如何分配和管理内存是构建健壮和可靠软件的重要基础。通常使用哪些接口?需要避免哪些错误?

内存分配 malloc()

在运行一个 C 程序的时候,会分配两种内存

  • 栈内存
    • 由编译器自动分配,隐式管理
    • 进入函数时,编译器自动在栈上开辟内存,退出函数后,自动释放
  • 堆内存
    • 所有的申请和释放都由程序员显式完成 void *malloc(size_t size);
    • 存活时间由程序员决定

内存释放 free()

申请内存是内存管理中简单的部分,复杂并且容易出错的部分是释放内存 free(void* x);

容易出现的错误包括:

  • 忘记释放
  • 重复释放
  • 提前释放
  • 传入 free 的值不是 malloc 返回值
  • 忘记分配内存
  • 忘记初始化内存
  • 分配内存空间不足,最常见是给字符串分配内存时没有为字符串结束符 \0 预留空间 malloc(strlen(str)+1);

内存管理分级

内存管理分为两级:

  1. 第一级是由操作系统执行的内存管理,操作系统在进程执行时,将内存交给进程,并在进程退出(或以其他形式退出)时将其回收。
  2. 第二级管理在每个进程中,例如在调用 malloc() 和 free() 时,在堆内管理。

操作系统会在程序执行结束时,收回进程的所有内存(包括用于代码、栈、以及相关堆的内存页)。无论地址空间中堆的状态如何,操作系统都会在进程终止时收回所有这些页面,从而确保即使没有释放内存,也不会丢失内存。

因此短时间运行的程序,泄露内存通常不会导致问题,但是如果是长期运行的服务器(例如 Web 服务器或数据库管理系统),泄露内存就是很大的问题,最终会导致程序在内存不足时崩溃。

虚拟内存机制

关键问题:如何高效、灵活地虚拟化内存
如何实现高效的内存虚拟化?如何提供应用程序所需的灵活性?如何保持控制应用程序可访问的内存位置,从而确保应用程序的内存访问受到合理的限制?如何高效地实现这一切?

利用一种通用技术,有时被称为基于硬件的地址转换(hardware-basedaddress translation),简称为 地址转换(address translation)

硬件对每次内存访问进行处理,将指令中的虚拟地址转换为数据实际存储的物理地址,通过这种地址转换,将应用程序的内存引用重定位到内存中实际的位置。

硬件完成的地址转换只是底层机制,还需要操作系统在关键的位置介入,设置好硬件,以便完成正确的转换。为了做到这点,操作系统需要对内存进行管理,记录被占用内存和空闲内存位置,并明智而谨慎地介入,保持对内存使用的控制。

简单化假设

对内存虚拟化的第一次尝试,做几个简化假设:

  1. 假设用户的地址空间必须连续地放在物理内存中
  2. 假设地址空间小于物理内存
  3. 每个地址空间的大小相等

我们会逐步放宽这些假设,从而得到现实中的内存虚拟化,目前先看下基于这 3 条简化假设的地址转换

地址转换的一次尝试

每个 CPU 需要两个寄存器:基址(base) 寄存器和界限 (bound) 寄存器,用于完成动态(基于硬件的)地址转换。

  • 基址寄存器提供定位,使用基址寄存器中的地址和地址空间的虚拟地址相加即可得到实际物理地址
  • 界限寄存器提供保护,限制地址空间大小,当程序想要访问超出界限的内存时,会触发异常中断
动态地址转换对硬件的要求

动态地址转换对硬件的要求

除了硬件自动完成的地址转换,操作系统也需要完成一些事情,包括:

  1. 程序执行之前,为进程寻找可用的内存,为了做到这一点,以及基于 3 个简化假设,操作系统需要维护一个空闲列表,用于记录空闲内存位置
  2. 程序执行结束后(正常或异常结束),回收程序使用的内存,比如重新维护空闲列表
  3. 程序执行过程中,发生上下文切换时,从进程结构(比如 PCB)中保存或恢复基址和界限寄存器
  4. 操作系统需要提供异常处理程序,当越界访问内存发生时供硬件自动跳转并进行处理
动态地址转换对操作系统的要求

动态地址转换对操作系统的要求

这种地址转换方式存在效率低的问题,固定的内存划分会导致大量空间被浪费:已经分配的内存单元内部有未使用的空间(即碎片)被称为 内部碎片 internal fragmentation