程序和内存位置
我们已经实现了一个多道程序的多任务系统, 但却在尝试运行第二个用户进程的时候遇到了问题. 如果你还记得编译Navy-apps中的程序时, 我们都把它们链接到0x3000000
或0x83000000
的内存位置, 你就会知道问题的原因了: 我们在加载第二个用户进程的时候, 就会覆盖第一个进程的内容!
出现上述问题的根本原因是, "在内存中可以同时存在多个进程"的条件被打破了. 显然我们需要对内存进行管理, 而不是让多个进程随意使用. 我们在PA3中提到操作系统有管理系统资源的义务, 在多任务操作系统中, 内存作为一种资源自然也要被管理起来. 操作系统需要记录内存的分配情况, 需要运行一个新进程的时候, 就给它分配一片空闲的内存位置, 把它加载到这一内存位置上即可.
不过, 这片空闲的内存位置是操作系统的加载器在加载时刻指定的, 但进程代码真的可以在这一内存位置上正确运行吗?
绝对代码
一般来说, 程序的内存位置是在链接时刻(link time)确定的(Navy-apps中的程序就是这样), 以前的程序员甚至在程序中使用绝对地址来进行内存访问, 这两种代码称为绝对代码(absolute code). 绝对代码会假设程序中的对象(函数和数据)位于某个固定的位置, 比如链接时重定位之后, 程序就会认为某个变量a
位于内存位置0x83001234
, 如果我们把程序加载到别的位置, 加载后变量a
的实际位置就改变了, 但程序中的绝对代码还是认为变量a
仍然位于内存位置0x83001234
, 访问0x83001234
也就无法访问真正的变量a
. 显然, 绝对代码只能在固定的内存位置才能正确运行.
但操作系统在加载时刻分配的空闲内存位置, 并不总是能让这种程序正确运行. 因此, 这个问题的一个解决方案, 就是让操作系统记录程序的加载位置, 当一个程序试图加载到一个已经被使用的内存位置时, 加载将会失败, 操作系统将返回一个错误. 为了避免加载失败, 一个方法是为每个程序维护多个不同加载地址的版本, 期望其中有一个版本可以被成功加载.
可重定位代码
但这样太麻烦了. 为什么一定要提前确定一个程序的加载位置呢? 如果我们把链接时的重定位阶段往后推迟, 不就可以打破绝对代码的限制了吗?
于是有程序员开发了一类"自重定位(self-relocation)"的特殊程序, 这种程序可以在开始运行的时候, 先把自己重定位到其它内存位置, 然后再开始真正的运行. 这种重定位类型称为"运行时(run time)重定位".
我们在PA1中提到了BIOS这个概念, 事实上, BIOS中的程序就是这种自重定位的程序. 这是因为, BIOS一般是固化在ROM中, 无法对ROM中的数据进行写操作. 计算机启动后会从BIOS中执行程序, 但这个BIOS程序马上会把自己拷贝到其它内存位置, 并进行一些重定位工作, 然后才真正开始执行. 这时候, 数据就位于可写的RAM中了, BIOS程序就可以对其进行写操作.
但对多任务操作系统来说, 这并没有真正解决问题, 因为程序在运行时刻并不知道重定位的目标内存位置是否空闲.
既然只有操作系统才知道内存是否空闲, 那就干脆让加载器来进行重定位吧, 于是有了"加载时(load time)重定位"的说法. 具体地, 加载器会申请一个空闲的内存位置, 然后将程序加载到这个内存位置, 并把程序重定位到这个内存位置, 之后才会执行这个程序. 今天的GNU/Linux就是通过这种方式来插入内核模块的.
位置无关代码
从某种程度上来说, 加载时重定位会带来额外的开销: 如果一个程序要被重复执行多次, 那么就要进行多次的加载时重定位. 早期的计算机速度较慢, 大家觉得加载时重定位的开销还是不能忽略的, 而且加载时重定位需要对整个程序进行处理, 如果程序比较大的话, 开销就更明显了.
有没有方法可以节省重定位的开销, 甚至不进行重定位呢? 但链接时的重定位又可能会产生绝对代码, 这并不是我们所希望的. 如果程序中的所有寻址, 都是针对程序位置来进行相对寻址操作, 这样的程序就可以被加载到任意位置执行, 而不会出现绝对代码的问题了,
这就是PIC(position-independent code, 位置无关代码)的基本思想. 今天的动态库都是PIC, 这样它们就可以被加载到任意的内存位置了. 此外, 如果一个可执行文件全部由PIC组成, 那么它有一个新名字, 叫PIE(position-independent executable, 位置无关可执行文件). 编译器可以通过特定的选项编译出PIE. 和一般的程序不同, PIE还能在一定程度上对恶意的攻击程序造成了干扰: 恶意程序也无法提前假设PIE运行的地址. 也正是因为这一安全相关的特性, 最近的不少GNU/Linux的发行版上配套的gcc都默认生成PIE. 不过, 使用相对寻址会使得程序的代码量增大, 性能也会受到一些影响, 但对于早期的计算机来说, 内存是一种非常珍贵的资源, 降低性能也是大家不愿意看到的, 因此对于PIC和PIE, 大家也会慎重考虑.
实现基于PIE的loader (建议二周目思考)
天下并没有免费的午餐, PIE之所以能做到位置无关, 其实是要依赖于程序中一个叫GOT(global offset table, 全局偏移量表)的数据结构. 要正确运行PIE, 加载器需要在加载程序的时候往GOT中填写正确的内容.
有兴趣的同学可以让Nanos-lite的loader支持PIE, 当然这需要了解一些ELF相关的细节, 具体细节可以参考ABI手册.
如果世界上的所有程序都是可重定位的, 或者是PIC, 内存覆盖的问题就不攻自破了. 但总有一些包含绝对代码的程序, 考虑到兼容问题, 还需要想办法运行它们. 有没有更好的, 一劳永逸的方案呢?
虚实交错的魔法
我们刚才是从程序本身来考量, 自然无法绕开绝对代码的问题. 为了解决这个问题, 我们需要从另一个方面 - 内存 - 来思考. 我们知道程序会经历编译, 链接, 加载, 运行这四个阶段, 绝对代码经过编译链接之后, 程序看到的内存地址就会确定下来了, 加载运行的时候就会让程序使用这一内存地址, 来保证程序可以正确运行. 一种尝试是把程序看到的内存和它运行时候真正使用的内存解耦开来. 这就是虚拟内存的思想.
所谓虚拟内存, 就是在真正的内存(也叫物理内存)之上的一层专门给进程使用的抽象. 有了虚拟内存之后, 进程只需要认为自己运行在虚拟地址上就可以了, 真正运行的时候, 才把虚拟地址映射到物理地址. 这样, 我们只要把程序链接到一个固定的虚拟地址, 加载的时候把它们加载到不同的物理地址, 并维护好虚拟地址到物理地址的映射关系, 就可以一劳永逸地解决上述问题了!
那么, 在进程运行的时候, 谁来把虚拟地址映射成物理地址呢? 我们在PA1中已经了解到指令的生命周期:
while (1) {
从PC指示的存储器位置取出指令;
执行指令;
更新PC;
}
如果引入了虚拟内存机制, PC就是一个虚拟地址了, 我们需要在访问存储器之前完成虚拟地址到物理地址的映射. 尽管操作系统管理着计算机中的所有资源, 在计算机看来它也只是一个程序而已. 作为一个在计算机上执行的程序而言, 操作系统必定无法干涉指令执行的具体过程. 所以让操作系统来把虚拟地址映射成物理地址, 是不可能实现的. 因此, 在硬件中进行这一映射是唯一的选择了: 我们在处理器和存储器之间添加一个新的硬件模块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中也没打算实现保护机制, 因此分段机制的各种概念, 我们也不会加入到NEMU中来.