虚拟地址空间
通过Nanos-lite的支撑, 我们已经在NEMU中成功把仙剑奇侠传跑起来了! 这说明我们亲自构建的NEMU这个看似简单的机器, 同样能支撑真实程序的运行, 丝毫不逊色于真实的机器! 不过, 我们目前还是只能在这个机器上同时运行一个程序, 这是因为Nanos-lite目前还只是一个单任务的操作系统. 那为了同时运行多个程序, 我们的NEMU和Nanos-lite还缺少些什么呢?
我们知道, 现在的计算机可以"同时"运行多个进程. 这里的"同时"其实只是一种假象, 并不是指在物理时间上的重叠, 而是操作系统很快地在不同的进程之间来回切换. 切换的频率大约是10ms一次, 一般的用户是感觉不到的. 而让多个进程"同时"运行的一个基本条件, 就是不同的进程要拥有独立的存储空间, 它们之间不能相互干扰.
一个很自然的想法, 就是让操作系统的loader直接把不同的程序加载到不同的内存位置就可以了. 我们在PA3中提到操作系统有管理系统资源的义务, 在多任务操作系统中, 内存作为一种资源自然也是要被管理起来: 操作系统需要记录内存的分配情况, 需要运行一个新程序的时候, 就给它分配一片空闲的内存位置, 把它加载到这一内存位置上即可.
这个方法听上去很可靠, 但对程序来说就不是这么简单了. 回想我们编译Navy-apps中的程序时, 我们都把它们链接到0x4000000的内存位置. 这意味着, 如果我们正在运行仙剑奇侠传, 同时也想运行hello程序, 仙剑奇侠传的内容将会被hello程序所覆盖! 最后的结果是, 仙剑奇侠传无法正确运行, 从而也无法实现"多个程序同时运行"的美好愿望.
或者, 我们可以尝试把不同的程序链接到不同的内存位置. 然而新问题又来了, 我们在编译链接的时候, 怎么能保证程序将来运行的时候它所用到的内存位置是空闲的呢? 况且, 我们还希望一个程序能同时运行多个进程实例, 例如在浏览器中同时打开多个页面浏览不同的网页. 这是多么合理的需求啊! 然而这种方式却没法实现.
所以如果要解决这个问题, 我们的方法就需要满足一个条件: 在程序被加载之前, 我们不能对程序被加载到的内存位置有任何提前的假设. 很自然地, 为了实现多任务, 我们必须在系统栈的某些层次满足这个条件.
一种方式是从程序本身的性质入手. 事实上, 编译器可以编译出PIC(position-independent code, 位置无关代码). 所谓PIC, 就是程序本身的代码不对将来的运行位置进行任何假设, 这样的程序可以被加载到任意内存位置也能正确运行. PIC程序不仅具有这一灵活的特性, 还能在一定程度上对恶意的攻击程序造成了干扰: 恶意程序也无法提前假设PIC程序运行的地址. 也正是因为这一安全相关的特性, 最近的不少GNU/Linux的发行版上配置的gcc都默认生成PIC程序. 多神奇的功能啊! 然而, 天下并没有免费的午餐, PIC程序之所以能做到位置无关, 其实是要依赖于程序中一个叫GOT(global offset table, 全局偏移量表)的数据结构. 要正确运行PIC程序, 操作系统中的动态加载器需要在加载程序的时候往GOT中填写正确的内容. 但是, 先不说GOT具体如何填写, 目前Nanos-lite中的loader是个raw program loader, 它无法在可执行文件中找到GOT的位置. 因此, 在Nanos-lite上运行PIC程序目前并不是一个可行的方案.
我们要寻求另一种解决方案了. 既然我们无法运行PIC程序, 我们还是只能让程序链接到一个固定的内存位置. 问题貌似又回到原点了. 我们来仔细琢磨一下我们的需求: 我们需要在让程序认为自己在某个固定的内存位置的同时, 把程序加载到不同的内存位置去执行. 这个看似自相矛盾的需求, 其实里面正好蕴藏着那深刻的思想. 说是自相矛盾, 是因为思维定势会让我们觉得, "固定的内存位置"和"不同的内存位置"必定无法同时满足; 说是蕴藏着深刻的思想, 我们不妨换一个角度来想想, 如果这两个所谓的"内存位置"并不是同一个概念呢?
为了让这个问题的肯定回答成为可能, 虚拟内存的概念就诞生了. 所谓虚拟内存, 就是在真正的内存(也叫物理内存)之上的一层专门给程序使用的抽象. 有了虚拟内存之后, 程序只需要认为自己运行在虚拟地址上就可以了, 真正运行的时候, 才把虚拟地址映射到物理地址. 这样, 我们只要把程序链接到一个固定的虚拟地址, 加载程序的时候把它们加载到不同的物理地址, 并维护好虚拟地址到物理地址的映射关系, 就可以实现我们那个看似不可能的需求了!
绝大部分多任务操作系统就是这样做的. 不过在讨论具体的虚拟内存机制之前, 我们先来探讨最关键的一个问题: 程序运行的时候, 谁来把虚拟地址映射成物理地址呢? 我们在PA1中已经了解到指令的生命周期:
while (1) {
从EIP指示的存储器位置取出指令;
执行指令;
更新EIP;
}
如果引入了虚拟内存机制, EIP就是一个虚拟地址了, 我们需要在访问存储器之前完成虚拟地址到物理地址的映射. 尽管操作系统管理着计算机中的所有资源, 在计算机看来它也只是一个程序而已. 作为一个在计算机上执行的程序而言, 操作系统不可能有能力干涉指令执行的具体过程. 所以让操作系统来把虚拟地址映射成物理地址, 是不可能实现的. 因此, 在硬件中进行这一映射是唯一的选择了: 我们在处理器和存储器之间添加一个新的硬件模块MMU(Memory Management Unit, 内存管理单元), 它是虚拟内存机制的核心, 肩负起这一机制最重要的地址映射功能. 需要说明的是, 我们刚才提到的"MMU位于处理器和存储器之间"只是概念上的说法. 事实上, 虚拟内存机制在现代计算机中是如此重要, 以至于MMU在物理上都实现在处理器芯片内部了.
但是, 只有操作系统才知道具体要把虚拟地址映射到哪些物理地址上. 所以, 虚拟内存机制是一个软硬协同才能生效的机制: 操作系统负责进行物理内存的管理, 加载程序的时候决定要把程序的虚拟地址映射到那些物理地址; 等到程序真正运行之前, 还需要配置MMU, 把之前决定好的映射落实到硬件上, 程序运行的时候, MMU就会进行地址转换, 把程序的虚拟地址映射到操作系统希望的物理地址.
分段
关于MMU具体如何进行地址映射, 目前主要有两种主流的方式. 最简单的方法就是, 物理地址=虚拟地址+偏移量. 这种最朴素的方式就是段式虚拟内存管理机制, 简称分段机制. 直觉上来理解, 就是把物理内存划分成若干个段, 不同的程序就放到不同的段中运行, 程序不需要关心自己具体在哪一个段里面, 操作系统只要让不同的程序使用不同的偏移量, 程序之间就不会相互干扰了.
分段机制在硬件上的实现可以非常简单, 只需要在MMU中实现一个段基址寄存器就可以了. 操作系统在运行不同程序的时候, 就在段基址寄存器中设置不同的值, MMU会把程序使用的虚拟地址加上段基址, 来生成真正用于访问内存的物理地址, 这样就实现了"让不同的程序使用不同的段"的目的. 作为教学操作系统的Minix就是这样工作的.
实际上, 处理器中的分段机制有可能复杂得多. 例如i386为了兼容它的前身8086, 引入了段描述符, 段选择符, 全局描述符表(GDT), 全局描述符表寄存器(GDTR)等概念, 段描述符中除了段基址之外, 还描述了段的长度, 类型, 粒度, 访问权限等等的属性, 为了弥补段描述符的性能问题, 又加入了描述符cache等概念... 我们可以目睹一下i386分段机制的风采:
15 0 31 0
LOGICAL +----------------+ +-------------------------------------+
ADDRESS | SELECTOR | | OFFSET |
+---+---------+--+ +-------------------+-----------------+
+------+ V |
| DESCRIPTOR TABLE |
| +------------+ |
| | | |
| | | |
| | | |
| | | |
| |------------| |
| | SEGMENT | BASE +---+ |
+->| DESCRIPTOR |-------------->| + |<------+
|------------| ADDRESS +-+-+
| | |
+------------+ |
V
LINEAR +------------+-----------+--------------+
ADDRESS | DIR | PAGE | OFFSET |
+------------+-----------+--------------+
咋看之下真是眼花缭乱, 让人一头雾水.
在NEMU中, 我们需要了解什么呢? 什么都不需要. 现在的绝大部分操作系统都不再使用分段机制, 就连i386手册中也提到可以想办法"绕过"它来提高性能: 将段基地址设成0, 长度设成4GB, 这样看来就像没有段的概念一样, 这就是i386手册中提到的"扁平模式". 当然, 这里的"绕过"并不是简单地将分段机制关掉(事实上也不可能关掉), 我们在PA3中提到的i386保护机制中关于特权级的概念, 其实就是i386分段机制提供的, 抛弃它是十分不明智的. 不过我们在NEMU中也没打算实现保护机制, 因此i386分段机制的各种概念, 我们也不会加入到NEMU中来.