人生倒计时
-
今日已经过去小时
-
这周已经过去天
-
本月已经过去天
-
今年已经过去个月
美丽的外表下都有一颗操碎的心:内存地址翻译

本文主要根据“OperatingSystems:ThreeEasyPieces”第15章总结而来。
为了实现内存虚拟化,高效和可控也是首先需要考虑的因素。高效意味着,进程能很容易地在硬件的帮助下访问内存;可控意味着没有一个进程能随意访问别的进程的内存,从而对进程进行保护,也对操作系统进行了保护。除了高效和可控,内存虚拟化还有一个额外的要求就是灵活,灵活指的是我们希望进程能按照它想要的方式随意使用它地址空间内的进程,从而让编程变得简单。
本节就来学习操作系统如何让进程能够高效并且灵活地访问内存,同时又对进程提供足够的保护!
基于硬件的地址翻译
基于硬件的地址翻译简称地址翻译,指的是对于每一次内存访问,硬件都会把虚拟地址(还记得你看见的地址都是虚拟地址吗?)翻译成物理地址,通过物理地址才能读到真实的数据。当然,光凭硬件本身是做不到这些的,操作系统需要在关键点介入去设置硬件从而保证硬件能正确翻译,操作系统还必须管理内存,记录哪些内存是可用的,哪些内存已经被占用。
总之,通过操作系统和硬件的配合,让进程认为它拥有自己的一块内存来保存代码和数据,并且不会被破坏。然而在完美的抽象背后,只有操作系统知道,随着CPU切换运行中的进程,很多进程实际上在共享内存。是操作系统把内存虚拟化让一切变得这么简单和美观。
为了实现内存虚拟化是非常复杂的,本文先做一些假设来简化目前的讨论,随着讨论的深入,这些假设都会被推翻,我们也会认识到内存虚拟化的所有技术。*假设一:用户(进程)的地址空间在物理内存中是连续的。*假设二:地址空间的大小比较下,必须小于物理内存。*假设三:每一个地址空间的大小是相同的。
为什么需要地址翻译
基于上面的假设我们来看一个例子。请看下图的程序和使用objdump翻译成的汇编片段:

代码很简单,先加载内存中的值,然后加3,最后把计算后的值保存到内存中。从进程的角度看,代码和数据在它的地址空间内如下图所示:

可以看到,加3的代码从地址128开始(靠近代码段的顶端),变量x的值保存在地址15k的位置(靠近栈的底端),初始值是3000。当指令运行之后,从进程的角度看它会这样使用内存:
从128获取指令
执行指令(从15k的位置加载数据)
从132获取指令
执行指令(不用读取内存)
从135获取指令
执行指令(把数据写入15k的位置)
也就是说,进程认为它的地址空间是从0开始,最大是16k,所有的内存访问都必须在0-16k之间。然而,为了实现内存虚拟化,操作系统不一定会把进程放到物理内存0开始的位置。一个可能的视图如下图所示:

基于硬件的动态重定位(或者是动态迁移)
前面的介绍都是背景,下面具体来看操作系统到底是怎么实现地址翻译的。这里就引出了内存管理单元(memorymanagementunit,MMU)中最基本的两个寄存器:基地址寄存机和上界寄存器,baseandbounds。后续会使用英文,因为英文更能表达含义。
当进程最初开始运行的时候,操作系统会根据目前系统可用的内存情况,设置base寄存器的值。比如上面的例子进程的物理地址是从32k开始的,那么base寄存器会设置成32k。当进程想获取地址128的指令时,它会被翻译成32k(32768)+128=32896,这样就能从物理地址里面取到真正的指令了。这就是所谓的地址翻译!重复一下公式:物理地址=虚拟地址+基地址
那么bounds寄存器什么时候用呢?也是在进程最初运行的时候,操作系统预分配一定大小的内存给进程,bounds寄存器就会被设置成最大的值,比如上面的例子,被设置成16k。当进程试图引用大于16k的地址空间时,系统会抛出一个异常,这个异常就是因为检查了bounds寄存器中的值。
从上面的流程可以看到,操作系统是在进程运行起来之后,根据系统可用的内存状况来设置baseandbounds,这个过程被称作动态重定位。与它相对的也有静态重定位,静态重定位发生在编译器,也就是说,程序编译完了就知道基地址是什么了。可想而知,静态重定位不具有移植性,在不同内存大小的系统就需要重新编译,就算内存大小相同也要根据系统可用的内存而定。所以,目前基本上操作系统都是基于MMU中的baseandbounds寄存器,使用动态重定向的技术。
操作系统的角色
上面说到的是硬件,特别是MMU起的作用,那么操作系统起了什么作用呢?
首先,系统可用的内存列表是由操作系统维护的。有很多数据结构可以完成这个任务,随着我们的深入研究会不断介绍新的数据结构。这里我们先看最简单的数据结构叫空闲链表,freelist。FreeList很简单,把可用的内存分成很多块,每一块当作一个节点放到链表中。当新的进程来的时候,从列表中取一段内存给它,并把这段内存删掉。当进程退出后,再把内存块放到freelist里面来。
其次,在CPU做上下文切换的时候,操作系统必须要把baseandbounds的数据也存储到PCB中。这样当进程重新被调度运行的时候,它依然能找到自己的代码和数据。
第三,既然进程可以切换,baseandbounds可以被保存和读取,那么在适当的时候,操作系统也可以移动进程在内存中的位置,只需要移动之后把baseandbounds更新就可以了。这其实也是动态重定位的体现。
最后,操作系统必须在启动的时候,额外注册一个异常句柄,也就是bounds溢出的处理函数,保证进程访问界限之外的内存区域被操作系统拒绝。
总结
基于之前我们做的假设,内存空间的大小是一定的,而且小于物理内存的大小,所以freelist能满足我们的需求。但是我们应该看到,这些假设可能造成的问题。毕竟,并不是所有的进程用的地址空间都是一样的,我们系统地址空间的大小是可变的。另外,如果有些内存块太小它就没办法被分配给进程,这块内存就会被浪费掉,也就是我们说的内存碎片。







