地址空间和地址转换
2023-1-20|2023-1-20
DieInADream
type
status
date
slug
summary
tags
category
icon
password
Property
Jan 20, 2023 02:33 PM
威斯康辛州大学操作系统书籍《Operating Systems: Three Easy Pieces》读书笔记系列之 Virtualization(虚拟化)。本篇为虚拟化技术的基础篇系列第三篇(Memory Virtualization),内存虚拟化。内存虚拟化又将分为两篇,本篇为第一篇内存虚拟化之基础 - 地址空间和地址转换
Dialogue
- Every address generated by a user program is a virtual address.
The Abstraction: Address Spaces
Early Systems
- 早期的系统未提供太多内存的抽象给用户,物理内存的分布如图所示。操作系统曾经是一组函数(实际上是一个库),在内存中(在本例中,从物理地址 0 开始),然后有一个正在运行的程序(进程),目前在物理内存中(在本例中,从物理地址 64KB 开始),并使用剩余的内存。

Multiprogramming and Time Sharing
- 多道程序(multiprogramming):多个进程在给定时间准备运行,比如当有一个进程在等待 I/O 操作的时候,操作系统会切换这些进程,这样增加了 CPU 的有效利用率(utilization)。
- 时分共享(Time Sharing):因为厌倦了长时间的(因此也是低效率的)编程—调试循环。交互性(interactivity)变得很重要,因为许多用户可能同时在使用机器,每个人都在等待(或希望)他们执行的任务及时响应。
- 一种实现时分共享的方法,是让一个进程单独占用全部内存运行一小段时间,如早期的系统一样,然后停止它,并将它所有的状态信息保存在磁盘上(包含所有的物理内存),加载其他进程的状态信息,再运行一段时间,这就实现了某种比较粗糙的机器共享。
- 但该方法的问题在于太慢了,主要因为需要将内存信息保存到磁盘,磁盘速度较慢,所以考虑在进程切换时的进程信息仍然保存在内存中可以高效地实现时分共享。
- 如图所示,三个进程 ABC,每个进程拥有从 512KB 物理内存中切出来给它们的一小部分内存。假定只有一个 CPU,操作系统选择运行其中一个进程(比如 A),同时其他进程(B 和 C)则在队列中等待运行。

- 将进程信息都保存在内存中也就引入了新的问题,内存区域之间需要进行隔离或保护,防止其他进程修改了当前进程的内存区域。
The Address Space
- 操作系统为了解决上述问题,对物理内存提供一层抽象,也就是地址空间(Address Space),即运行的程序看到的系统的内存。
- 一个进程的地址空间包含运行的程序的所有内存状态。
- 程序的代码(code,指令)必须在内存中,因此它们在地址空间里。
- 当程序在运行的时候,利用栈(stack)来保存当前的函数调用信息,分配空间给局部变量,传递参数和函数返回值。
- 堆(heap)用于管理动态分配的、用户管理的内存,就像你从 C 语言中调用
malloc()或面向对象语言(如 C ++或 Java)中调用 new 获得内存。 - 还有其他的区域(例如,静态初始化的变量),但现在假设只有这 3 个部分:代码、栈和堆。
- 如下图所示例子中,有一个 16KB 的地址空间,顶部 0-1KB 为代码对应的分段,因为指令在运行过程中所占用的空间不会发生变化,即静态的,所以分配一个固定大小的空间即可。但是堆和栈是动态的,堆在顶部,栈在底部,同时向中间部分增长,当用户动态分配内存的时候,堆从 1KB 开始向下增长,当用户进行程序调用的时候,栈从 16KB 向上增长。

- 需要注意的是,我们描述地址空间时,所描述的是操作系统提供给运行程序的抽象(abstract)。程序不在物理地址 0~16KB 的内存中,而是加载在任意的物理地址。
- 例如,当图 13.2 中的进程 A 尝试在地址 0(我们将称其为虚拟地址,virtual address)执行加载操作时,然而操作系统在硬件的支持下,出于某种原因,必须确保不是加载到物 理地址 0,而是物理地址 320KB(这是 A 载入内存的地址)。这是内存虚拟化的关键,这是世界上每一个现代计算机系统的基础。
- 如下代码打印出 main() 函数(代码所在地方)的地址,由
malloc()返回的堆空间分配的值,以及栈上一个整数的地址 - 代码在地址空间开头,然后是堆,而栈在这个大型虚拟地址空间的另一端,这些地址都是虚拟的,并且将由操作系统和硬件翻译成物理地址,以便从真实的物理位置获取该地址的值
- CRUX:操作系统如何在单一的物理内存上为多个运行的进程(所有进程共享内存)构建一个私有的、可能很大的地址空间的抽象?
Goals
- 虚拟内存(VM)系统的一个主要目标是透明(transparency)操作系统实现虚拟内存的方式,应该让运行的程序看不见。因此,程序不应该感知到内存被虚拟化的事实,相反,程序的行为就好像它拥有自己的私有物理内存。在幕后,操作系统(和硬件)完成了所有的工作,让不同的工作复用内存,从而实现这个假象。
- 虚拟内存的另一个目标是效率(efficiency)。操作系统应该追求虚拟化尽可能高效(efficient),包括时间上(即不会使程序运行得更慢)和空间上(即不需要太多额外的内存来支持虚拟化)。在实现高效率虚拟化时,操作系统将不得不依靠硬件支持,包括 TLB 这样的硬件功能。
- 虚拟内存第三个目标是保护(protection)。操作系统应确保进程受到保护(protect),不会受其他进程影响,操作系统本身也不会受进程影响。当一个进程执行加载、存储或指令提取时,它不应该以任何方式访问或影响任何其他进程或操作系统本身的内存内容(即在它的地址空间之外的任何内容)。因此,保护让我们能够在进程之间提供隔离(isolation)的特性,每个进程都应该在自己的独立环境中运行,避免其他出错或恶意进程的影响。
Summary
- 虚拟内存系统负责为程序提供一个巨大的、稀疏的、私有的地址空间的假象,其中保存了程序的所有指令和数据。操作系统在专门硬件的帮助下,通过每一个虚拟内存的索引,将其转换为物理地址,物理内存根据获得的物理地址但获取所需的信息。操作系统会同时对许多进程执行此操作,并且确保程序之间互相不会受到影响,也不会影响操作系统。整个方法需要大量的机制(很多底层机制)和一些关键的策略。
Interlude: Memory API
Types of Memory
- 栈 Stack 内存,它的申请和释放操作是编译器来隐式管理的,所以有时也称为自动(automatic)内存。通过在函数中声明即可,剩余事情由编译器完成,进入函数时开辟栈上的内存空间,退出函数时,释放对应的内存。但是对于某些函数调用之外的内存,则需要借助堆。
- 堆(heap)内存,其中所有的申请和释放操作都由程序员显式地完成。如下代码所示展示了如何在堆上分配一个整数,得到指向它的指针。关于这一小段代码有两点说明。首先,你可能会注意到栈和堆的分配都发生在这一行:首先编译器看到指针的声明(int * x)时,知道为一个整型指针分配空间,随后,当程序调用
malloc()时,它会在堆上请求整数的空间,函数返回这样一个整数的地址(成功时,失败时则返回 NULL),然后将其存储在栈中以供程序使用。
The malloc() AND free() Call
malloc函数非常简单:传入要申请的堆空间的大小,它成功就返回一个指向新申请空间的指针,失败就返回 NULL
- 要释放不再使用的堆内存,程序员只需调用
free(),该函数接受一个参数,即一个由malloc()返回的指针。
Common Errors
- 很多高级语言支持自动内存管理,即采用 new 分配内存,不用手动显式释放,会有专门的 GC 来进行垃圾回收。
- 忘记分配内存
- segmentation fault
- 没有分配足够的内存
- buffer overflow
- 忘记初始化分配的内存
- uninitialized read
- 忘记释放内存
- memory leak
- 在用完之前释放内存
- dangling pointer
- 反复释放内存
- double free
系统中实际存在两级内存管理。第一级是由操作系统执行的内存管理,操作系统在进程运行时将内存交给进程,并在进程退出(或以其他方式结束)时将其回收。第二级管理在每个进程中,例如在调用malloc()和free()时,在堆内管理。即使你没有调用free()(并因此泄露了堆中的内存),操作系统也会在程序结束运行时,收回进程的所有内存(包括用于代码、栈,以及相关堆的内存页)。无论地址空间中堆的状态如何,操作系统都会在进程终止时收回所有这些页面,从而确保即使没有释放内存,也不会丢失内存。
Underlying OS Support
malloc()和free()都是库调用,malloc 库管理虚拟地址空间内的空间,但是它本身是建立在一些系统调用之上的,这些系统调用会进入操作系统,来请求本多内存或者将一些内容释放回系统。
- 一个这样的系统调用叫作 brk,它被用来改变程序分断(break)的位置:堆结束的位置。它需要一个参数(新分断的地址),从而根据新分断是大于还是小于当前分断,来增加或减小堆的大小。另一个调用 sbrk 要求传入一个增量,但目的是类似的。
- 你还可以通过
mmap()调用从操作系统获取内存。通过传入正确的参数,mmap()可以在程序中创建一个匿名(anonymous)内存区域——这个区域不与任何特定文件相关联,而是与交换空间(swap space)相关联
Mechanism: Address Translation
- CPU 虚拟化遵循的一般准则被称为受限直接访问:让程序运行的大部分指令直接访问硬件,只在一些关键点(如进程发起系统调用或发生时钟中断)由操作系统介入来确保“在正确时间,正确的地点,做正确的事”。
- CRUX:如何实现高效的内存虚拟化?如何提供应用程序所需的灵活性?如何保持控制应用程序可访问的内存位置,从而确保应用程序的内存访问受到合理的限制?如何高效地实现这一切?
- 我们利用了一种通用技术,有时被称为基于 硬件的地址转换(hardware-based address translation),简称为地址转换(address translation)。它可以看成是受限直接执行这种一般方法的补充。利用地址转换,硬件对每次内存访问进行处理(即指令获取、数据读取或写入),将指令中的虚拟(virtual)地址转换为数据实际存储的物理(physical)地址。因此,在每次内存引用时,硬件都会进行地址转换,将应用程序的内存引用重定位到内存中实际的位置。
- 仅仅依靠硬件不足以实现虚拟内存,因为它只是提供了底层机制来提高效率。操作系统必须在关键的位置介入,设置好硬件,以便完成正确的地址转换。因此它必须 管理内存(manage memory),记录被占用和空闲的内存位置,并明智而谨慎地介入,保持对内存使用的控制。
An Example
- 假设用户的地址空间必须连续地放在物理内存中。同时,为了简单,我们假设地址空间不是很大,具体来说,小于物理内存的大小。最后,假设每个地址空间的大小完全一样。别担心这些假设听起来不切实际,我们会逐步地放宽这些假设,从而得到现实的内存虚拟化。
- x86 下的汇编如下:假定 x 的地址已经存入寄存器 ebx,之后通过 movl 指令将这个地址的值加载到通用寄存器 eax(长字移动)。下一条指令对 eax 的内容加 3。最后一条指令将 eax 中的值写回到内存的同一位置。
- 代码和数据都位于进程的地址空间,3 条指令序列位于地址 128(靠近头部的代码段),变量 x 的值位于地址 15KB(在靠近底部的栈中)。

- 如果这 3 条指令执行,从进程的角度来看,发生了以下几次内存访问:
- 从地址 128 获取指令;
- 执行指令(从地址 15KB 加载数据);
- 从地址 132 获取命令;
- 执行命令(没有内存访问);
- 从地址 135 获取指令;
- 执行指令(新值存入地址 15KB)。
- 从程序的角度来看,它的地址空间(address space)从 0 开始到 16KB 结束。它包含的所有内存引用都应该在这个范围内。然而,对虚拟内存来说,操作系统希望将这个进程地址空间放在物理内存的其他位置,并不一定从地址 0 开始。因此我们遇到了如下问题:怎样在内存中重定位这个进程,同时对该进程透明(transparent)?怎么样提供一种虚拟地址空间从 0 开始的假象,而实际上地址空间位于另外某个物理地址?
- 如图所示展示了一个例子,说明这个进程的地址空间被放入物理内存后可能的样子。从图 15.2 中可以看到,操作系统将第一块物理内存留给了自己,并将上述例子中的进程地址空间重定位到从 32KB 开始的物理内存地址。剩下的两块内存空闲(16~32KB 和 48~64KB)。

Dynamic (Hardware-based) Relocation
- 每个 CPU 需要两个硬件寄存器:基址(base)寄存器和界限(bound)寄存器,有时称为限制(limit)寄存器。
- 这组基址和界限寄存器,让我们能够将地址空间放在物理内存的任何位置,同时又能确保进程只能访问自己的地址空间。
- 采用这种方式,在编写和编译程序时假设地址空间从零开始。但是,当程序真正执行时,操作系统会决定其在物理内存中的实际加载地址,并将起始地址记录在基址寄存器中。
- 在上面的例子中,操作系统决定加载在物理地址 32KB 的进程,因此将基址寄存器设置为这个值。当进程运行时,该进程产生的所有内存引用,都会被处理器通过以下方式转换为物理地址:
- 进程中使用的内存引用都是虚拟地址(virtual address),硬件接下来将虚拟地址加上基址寄存器中的内容,得到物理地址(physical address),再发给内存系统。如上面例子中的第一条指令
128: movl 0x0(%ebx), %eax: - 程序计数器(PC)首先被设置为 128。
- 当硬件需要获取这条指令时,它先将这个值加上基址寄存器中的 32KB(32768),得到实际的物理地址 32896,然后硬件从这个物理地址获取指令。
- 接下来,处理器开始执行该指令。
- 这时,进程发起从虚拟地址 15KB 的加载,处理器同样将虚拟地址加上基址寄存器内容(32KB),得到最终的物理地址 47KB,从而获得需要的数据。
- 将虚拟地址转换为物理地址,这正是所谓的 地址转换(address translation)技术。也就是说,硬件取得进程认为它要访问的地址,将它转换成数据实际位于的物理地址。由于这种重定位是在 运行时 发生的,而且我们甚至可以在进程开始运行后改变其地址空间,这种技术一般被称为 动态重定位(dynamic relocation)
- 界限 bound 寄存器提供了访问保护。在上面的例子中,界限寄存器被置为 16KB。如果进程需要访问超过这个界限或者为负数的虚拟地址,CPU 将触发异常,进程最终可能被终止。界限寄存器的用处在于,它确保了进程产生的所有地址都在进程的地址“界限”中。
- 这种基址寄存器配合界限寄存器的硬件结构是芯片中的(每个 CPU 一对)。有时我们将 CPU 的这个负责地址转换的部分统称为 内存管理单元(Memory Management Unit,MMU)。随着我们开发更复杂的内存管理技术,MMU 也将有更复杂的电路和功能。
Hardware Support: A Summary
- 首先,正如在 CPU 虚拟化的章节中提到的,我们需要两种 CPU 模式。
- 操作系统在特权模式(privileged mode,或内核模式,kernel mode),可以访问整个机器资源。
- 应用程序在用户模式(user mode)运行,只能做有限的操作。
- 只要一个位,也许保存在处理器状态字(processor status word)中,就能说明当前的 CPU 运行模式。在一些特殊的时刻(如系统调用、异常或中断),CPU 会切换状态。
- 硬件还必须提供基址和界限寄存器(base and bounds register),因此每个 CPU 的内存管理单元(Memory Management Unit,MMU)都需要这两个额外的寄存器。用户程序运行时,硬件会转换每个地址,即将用户程序产生的虚拟地址加上基址寄存器的内容。硬件也必须能检查地址是否有用,通过界限寄存器和 CPU 内的一些电路来实现。
- 硬件应该提供一些特殊的指令,用于修改基址寄存器和界限寄存器,允许操作系统在切换进程时改变它们。这些指令是 特权(privileged)指令,只有在 内核模式 下,才能修改这些寄存器。
- 最后,在用户程序尝试非法访问内存(越界访问)时,CPU必须能够产生异常(exception)。在这种情况下,CPU 应该阻止用户程序的执行,并安排操作系统的“越界”异常处理程序(exception handler)去处理。操作系统的处理程序会做出正确的响应,比如在这种情况下终止进程。类似地,如果用户程序尝试修改基址或者界限寄存器时,CPU 也应该产生异常,并调用“用户模式尝试执行特权指令”的异常处理程序。CPU 还必须提供一种方法,来通知它这些处理程序的位置,因此又需要另一些特权指令。

Operating System Issues
- 硬件支持和操作系统管理结合在一起,实现了一个简单的虚拟内存。具体来说,在一些关键的时刻操作系统需要介入,以实现基址和界限方式的虚拟内存

- 在进程创建时,操作系统必须采取行动,为进程的地址空间找到内存空间。由于我们假设每个进程的地址空间小于物理内存的大小,并且大小相同,这对操作系统来说很容易。它可以把整个物理内存看作一组槽块,标记了空闲或已用。当新进程创建时,操作系统检索这个数据结构(常被称为空闲列表,free list),为新地址空间找到位置,并将其标记为已用。
- 在图 15.2 中,操作系统将物理内存的第一个槽块分配给自己,然后将例子中的进程重定位到物理内存地址 32KB。另两个槽块(16~32KB,48~64KB)空闲,因此空闲列表(free list)就包含这两个槽块。

- 在进程终止时(正常退出,或因行为不端被强制终止),操作系统也必须做一些工作,回收它的所有内存,给其他进程或者操作系统使用。在进程终止时,操作系统会将这些内存放回到空闲列表,并根据需要清除相关的数据结构。
- 在上下文切换时,操作系统也必须执行一些额外的操作。每个 CPU 毕竟只有一个基址寄存器和一个界限寄存器,但对于每个运行的程序,它们的值都不同,因为每个程序被加载到内存中不同的物理地址。因此,在切换进程时,操作系统必须保存和恢复基址和界限寄存器。具体来说,当操作系统决定中止当前的运行进程时,它必须将当前基址和界限寄存器中的内容保存在内存中,放在某种每个进程都有的结构中,如进程结构(process structure)或进程控制块(Process Control Block,PCB)中。类似地,当操作系统恢复执行某个进程时(或第一次执行),也必须给基址和界限寄存器设置正确的值。
- 当进程停止时(即没有运行),操作系统可以改变其地址空间的物理位置,这很容易。要移动进程的地址空间,操作系统首先让进程停止运行,然后将地址空间拷贝到新位置,最后更新保存的基址寄存器(在进程结构中),指向新位置。当该进程恢复执行时,它的(新)基址寄存器会被恢复,它再次开始运行,显然它的指令和数据都在新的内存位置了。
- 操作系统必须提供异常处理程序(exception handler),或要一些调用的函数,像上面提到的那样。操作系统在启动时加载这些处理程序(通过特权命令)。例如,当一个进程试图越界访问内存时,CPU 会触发异常。在这种异常产生时,操作系统必须准备采取行动。


Summary
- 通过虚拟内存使用的一种特殊机制,即地址转换(address translation),扩展了受限直接访问的概念。利用地址转换,操作系统可以控制进程的所有内存访问,确保访问在地址空间的界限内。这个技术高效的关键是硬件支持,硬件快速地将所有内存访问操作中的虚拟地址(进程自己看到的内存位置)转换为物理地址(实际位置)。所有的这一切对进程来说都是透明的,进程并不知道自己使用的内存引用已经被重定位.
- 一种特殊的虚拟化方式,称为基址加界限的动态重定位。基址加界限的虚拟化方式非常高效,因为只需要很少的硬件逻辑,就可以将虚拟地址和基址寄存器加起来,并检查进程产生的地址没有越界。基址加界限也提供了保护,操作系统和硬件的协作,确保没有进程能够访问其地址空间之外的内容。保护肯定是操作系统最重要的目标之一。没有保护,操作系统不可能控制机器(如果进程可以随意修改内存,它们就可以轻松地做出可怕的事情,比如重写陷阱表并完全接管系统)。
- 遗憾的是,这个简单的动态重定位技术有 效率低下 的问题。重定位的进程使用了从 32KB 到 48KB 的物理内存,但由于该进程的栈区和堆区并不很大,导致这块内存区域中大量的空间被浪费。这种浪费通常称为 内部碎片(internal fragmentation),指的是已经分配的内存单元内部有未使用的空间(即碎片),造成了浪费。
- 在我们当前的方式中,即使有足够的物理内存容纳更多进程,但我们目前要求将地址空间放在固定大小的槽块中,因此会出现内部碎片。所以,我们需要更复杂的机制,以便更好地利用物理内存,避免内部碎片。第一次尝试是将基址加界限的概念稍稍泛化,得到分段(segmentation)的概念.
- Utterance