内存地址翻译

这是 《Operating System: Three Easy Pieces》 的第 4 篇读书笔记。

有关 虚拟内存地址的翻译机制( Mechanism )

OS 对内存的掌控意味着 OS 会确保 一个运行中的应用程序只能访问得到属于它自己的内存,不能访问其他应用程序的内存

关键点:如何高效灵活地虚拟化内存?

关键点分解:

  • How can we build an efficient virtualization of memory?
  • How do we provide the flexibility needed by applications?
  • How do we maintain control over which memory locations an application can access, and thus ensure that application memory accesses are properly restricted?
  • How do we do all of this efficiently?

通常使用的技术,叫做 基于硬件的地址翻译 ( hardware-based address translation ),或者就简称 地址翻译 ( address translation )。

利用这个,硬件会把每个内存访问的请求,转换成目标信息实际存储在硬件介质上的物理地址,也就是常说的,把 指令所代表的虚拟地址转变成物理物理地址

对内存访问的请求是指,读取指令、从内存指定位置加载数据到指定位置、把内存指定位置的存储数据到内存的另一指定位置等操作。

有了这个硬件的帮助之后,当然并不能马上就提供了所谓的 “虚拟化内存”,这只是为高效地虚拟化内存而提供了底层的机制而已。

如同 操作系统 在虚拟化 CPU 的时候采用的办法,操作系统必须在合适的时候 “接入” 进来,看看 哪些进程是不是干了坏事(访问了不该访问的内存地址)?现在的内存空间还剩下多少?要不要整理下空闲的内存以便满足下次的高效内存分配请求?....

诸如此类的事情就是 操作系统 在 管理内存( manage memory ) 。

再一次重申,所有这些工作的目标其实都是为了提供一个美好的抽象(虚拟化内存),所有的应用程序都拥有自己自由的内存,在那里存储着属于它的代码和数据。

我觉得原文说的特别好,翻译不出来,这里粘贴一下:

Once again the goal of all of this work is to create a beautiful illu- sion: that the program has its own private memory, where its own code and data reside. Behind that virtual reality lies the ugly physical truth: that many programs are actually sharing memory at the same time, as the CPU (or CPUs) switches between running one program and the next. Through virtualization, the OS (with the hardware’s help) turns the ugly machine reality into something that is a useful, powerful, and easy to use abstraction.

开始假设

第 2 篇笔记《Policies on Scheduling the CPU》 的描述方法一样,从简单的假设开始一步一步走向复杂。

现在一开始,我们对内存的虚拟化是非常简单的。

  • 用户使用的地址空间都是连续的的物理内存
  • 为了描述简单起见,用户使用的地址空间都不太大,而且都必须小于物理内存
  • 构成地址空间的每一个单元都是同样大小的

先看一个简单的例子

这个例子是为了更好地理解 为实现虚拟地址翻译我们需要做些什么,以及我们为什么需要虚拟地址翻译。

假设有一个进程的地址空间,如下图:

然后我们在这个模型的假设之上运行一小段 C 代码:

void func() {
    int x;
    x = x + 3;
}

这段代码会从内存加载一个变量的值,接着给这个值加上 3 ,然后再把它写回内存去

C 编译器会将这段代码编译成如下的汇编代码,我们看汇编代码比较直接一点:

128: movl 0x0(%ebx), %eax
132: addl $0x03, %eax
135: movl %eax, 0x0(%ebx)

上面这段代码就是首先假设了变量 x 的内存地址被存储在了寄存器 ebx 里,接着使用指令 movl 将那个内存地址所存储着的值移动到通用目的寄存器 eax 去,然后紧接着的一条指令会把 3 加到 eax 里存储的值上面,最后一条指令把更新后的 eax 寄存器里的值存储回 ebx 寄存器所存储的内存地址中去。

在 Figure 15.1 中,你可以看到这段代码是存储在进程地址空间什么位置的:代码起始在内存位置 128 B 处,而变量 x 则位于内存地址 15 KB 处,而且如图所示,x 的初始值为 3000。

从进程的角度来看,这段代码会发生如下的内存访问:

  1. 读取位于内存地址 128 B 处的指令
  2. 执行这条指令( 从内存位置 15 KB 处加载 x 的值 )
  3. 读取位于内存地址 132 B 处的指令
  4. 执行这条指令( 这里没发生内存访问 )
  5. 读取位于内存地址 135 B 处的指令
  6. 执行这条指令( 把寄存器 eax 的值存储到内存地址 15 KB 处 )

从一个程序员的角度来看的话,他/她所写的这段代码被 OS 加载到内存里后,能够使用的地址空间从 0 开始,一直到 16 KB。这个进程产生的所有内存访问都必须在这个地址范围内。

那么问题来了,操作系统想要把把内存虚拟化,进程从 0 开始的内存地址就不一定要对应到物理地址的 0 B 处了,操作系统是如何实现这层偷偷的转换的?

一个简单的例子如下图所示:


简单的把 64 KB 的物理内存切分为 4 等份,一份给操作系统保留,另外的其中一份就给这个进程好了,从物理内存地址的 32 KB 开始,到 48 KB 结束,刚好 16 KB。

但,这样子缺陷是很多的,比如说,不够灵活。

复杂点了——基于硬件的动态重定位

它是在 20 世纪 50 年代第一次被引进 时分操作系统(time-sharing machine)的,当时被叫做 base and bounds,也被叫做 动态重定位 ( dynamic relocation ) (这里可能翻译的不准确)。

为了实现这个特性,我们需要使用到 CPU 的两个寄存器,一个我们叫做 基址寄存器( base register ),另外一个叫做 边界寄存器( bounds register ) ,这两个名字是根据上下文而起的,不要跟别的介绍操作系统的书中说的内存寻址相关的概念混淆。

这两个寄存器存储着进程能访问的物理内存地址界限。

当进程被加载进内存,操作系统就会决定这个进程的内存该放到物理内存的哪里,然后设定 基址寄存器 的值,在上面的内存模型中,基址寄存器的值就被设为 32 KB。

现在,地址翻译就来咯

physical address = virtual address + base

每一个由进程产生的、要进行内存访问的地址都叫做 虚拟地址( virtual address ),硬件就是会把这 虚拟地址 加上 基址寄存器 里的值,得到的就是 物理地址( physical address )

还是拿上面的简单模型来说,这条指令:

128: movl 0x0(%ebx), %eax

当要执行时,*程序计数器( PC,program counter )* 会被设为 128。硬件需要从物理内存中加载这条指令,就会把基址寄存器里的值 32 KB ( 32768 )加上程序计数器的值,最后得到 32896 这个物理内存地址,然后把这个值发给相关的硬件发起内存访问。访问成功,然后进程就能执行这条指令了。后面的两条指令也是差不多的。

虚拟内存地址 转换成 物理内存地址 的过程就叫做 地址翻译 (Address Translation)。因为这个翻译过程是在进程运行时实时发生的,只有在进程加载后才知道要访问的目标物理内存在哪里,而且对于进程的每一次运行操作系统都不一点会把它放到同样的位置,因为这个过程是 dynamic relocation 的,这里我翻译为 动态重定位

你可能也注意到了,*边界寄存器( bounds register )* 干嘛去了,没见用上呢。

确实是,但也许你也已经猜到了,边界寄存器的值在计算物理内存的时候一同发给相关硬件,由硬件去检测即将生成的这个物理地址是否超越了进程本该访问的内存地位逻辑上范围。现在我们知道,如果发生了错误,硬件会产生错误信息,操作系统可以根据这些信号产生中断信息。

边界寄存器 的值可以存实际的内存界限的值,也可以存一个区间值。

通常,内存管理单元( MMU,memory management uniut ) 就是用来干这些的,一块集成电路。

也有基于软件的地址翻译

这类地址翻译叫做 静态重定位(static relocation),有一个叫做 加载器( loader ) 的软件会负责这项工作。

举个例子说,这条这条指令 movl 1000, %eax 是由一个起始在内存地址 3000 处的进程拥有的,那么 加载器就会把这条指令改写成 movl 4000, %eax

静态翻译有些问题。比如说,没有提供保护机制,内存越界了操作系统都不知道,失去了基于硬件的地址翻译的控制权。

总结下,目前为止,硬件所提供的帮助

这里总结了一下目前为止 操作系统 虚拟化 CPU,虚拟化内存所需要的硬件支持。

  • 操作系统运行在 特权模式 ( priviledged mode ) 或者叫做 内核模式( kernel mode ) 下,这个模式下操作系统有权访问整个物理内存;而用户的进程只能运行在 用户模式,进程的操作与限制。当发生这种切换时(进程发出系统调用、出现某种异常、或者操作系统到时间介入了等),硬件就会协助切换,而切换可能会影响某个处理器的状态标识吧。
  • 基址寄存器边界寄存器,或者是 内存管理单元( MMU )
  • 硬件也应该提供些指令让操作系统可以修改 基址寄存器边界寄存器 的值,它们也应当要在内核模式下运行。
  • 异常,比如说当发生了内存越界访问了,硬件应该产生些特殊的状态或者设置些标记,好让操作系统调用既定 exceptiong handler 来处理。

下面是书中的总结表格:

有了硬件支持,操作系统该做什么?

有了硬件的支持,这些操作系统可做的、而且必须要做的事情变多了。

首先,给进程分配内存

首先,当一个进程要被创建出来的时候,必须要在内存中给它分配空间。这可不是一件简单的活儿,所幸的是,我们的模型还很简单,每个进程的地址空间很小,而且大小相同,比物理内存也小得多。

把物理内存看做一个数组,等分成 进程地址空间大小的 几份,找到一个空闲的放进去即可。

当然操作系统还得跟踪那块内存是否空闲,常用的一个数据结构叫做 空闲链表 ( free list )

接着,还要记得回收

当一个进程要结束了(无论它是正常终止,还是因为意外出错被操作系统系统终止),操作系统要把这个进程的所有内存资源全部收回,否则就会造成内存泄漏。

回收了进程的内存,要把这块内存放入 空闲链表,其实就是在相关的数据结构里记录下这块内存的状态,同时还得清理下其他必要的数据结构。

别忘了,还有进程上下文切换

继续拿上面的 基址寄存器 和 边界寄存器 作模型,当一个进程被挂起让出 CPU 的执行权时,它的这两个寄存器的值要被保存起来,然后操作系统跳下一个能执行的进程,把新进程的基址寄存器和边界寄存器的值设置到好。

出错了,也要有办法

总是有人写些恶意的程序想来捉弄 OS,OS 当然不啥,可是也很善良,你做了些啥坏事,它还会提醒你,还可能给你提供回调函数供你出错的时候处理下。这就是异常处理,或者叫做 中断

举例子说,当进程发生了内存越界访问,操作系统此时必须能够知晓并且在硬件的帮助下开始介入,这时程序计数器就不会再执行进程的指令,CPU 此时会切换到 内核态, 操作系统此时会调用预先准备好的错误处理代码。这些代码可能会给出错的进程一个最后的机会,执行它的回调函数,执行完后就跟这个进程说 Bye Bye 了。

图示硬件和操作系统的交互

以时间线前进的顺序展现交互过程

总结( Summary )

本篇介绍的 地址翻译 机制,使得 操作系统得以感知和控制进程对内存的访问,这才是这个机制背后的重点

控制了入口,就有了话语权

当然,本篇介绍的这点简单的模型局限性也是挺明显的:因为堆和栈内存的大部分程序可能并不会使用,而且内存分配是固定大小的,所以就算操作系统把内存分配给进程了,它还是空闲着的,没有投入使用,这就造成了浪费,也就是所谓的 内存内部碎片( internal fragmentation )问题

因此,操作系统对于内存的管理需要更复杂的策略。

当我们现在调用内存相关的函数时,现代操作系统其实在背后帮我们做了很多。

参考
http://pages.cs.wisc.edu/~remzi/OSTEP/vm-mechanism.pdf

Comments
Write a Comment