好了闲话暂且不说,我们本篇的内容不会很多,我会再次从hello程序谈起,结合链接、进程和虚拟存储器的整个工作过程,希望这些会让你有新的收获。最后,我还会谈起一些可能的迷惑。
在介绍加载之前,我们首先介绍下这两个概念,windows虚拟内存同Unix下的交换分区(swap space)概念类似,它是用少量的磁盘空间作为内存的拓展,是为了缓解机器的内存紧张而引入的东西。对于物理存储器较小的机器,如果设定了虚拟内存,在内存紧张的情况下将其中暂时不用的页面置换出来,暂时存放在虚拟内存中,待到需要的时候再从虚拟内存调入存储器中。可能我们遇到我类似的情况,在打开的多个程序中,如果长时间不用某一个程序,而在某一个瞬间忽然打开,就能够听到磁盘的转动声音,这个可能就是内存将页面从虚拟内存重新载入引起的。
虚拟内存在windows下对应有一个pagefile.sys系统分页文件,我们可以在计算机属性卡下对进行设置,如果对于内存空间比较充裕的,可以考虑删除分页文件(浪费磁盘空间)。
那么虚拟存储器和虚拟内存有什么不同或者相关之处呢?呃..虚拟内存作为物理存储器的拓展,从而在一定程度上限制了系统中可以分配的虚拟页面总数。它们之间的关系可以用一种场景来描述,当程序中使用malloc在堆中开辟了空间,在内存紧张的情况下,此时如果堆中对应的页面应经被修改,且需要被置换出去,该页面必须被暂时存放在虚拟内存中。这是因为在磁盘文件中并没有与之对应的存储器空间。这个正是上一篇中我们在存储器映射提到过的匿名文件。但是,另一方面,我们不能简单的把虚拟存储器就当做虚拟内存。因为虚拟存储机制不会严格依赖于虚拟内存,还记得存储器是如何做映射以便关联一个磁盘对象?在我们关闭虚拟内存功能时,并不会使虚拟存储器机制消失,那么这时如果内存中的页面被修改而需要被置换出去时,系统会怎么做?首先这种情况,肯定是在内存不够用的情况下发生,那么系统直接会发出类似于out of memory的警告;或者另外一种情况,系统可能会保留了一部分的磁盘空间用来做页文件,但如果这部分空间不够用时,系统同样会给出警告。
在CSI-I篇中,我介绍过Hello程序的编译过程以及其在计算机硬件系统中的数据加载和传送过程,但从操作系统角度来讲,hello程序的加载过程到底会是怎样的,它会是如何使用虚拟存储器的呢?接下来,就让我们带着这个疑问去探究其加载的整个过程。
在CSI-VIII链接一篇中,我们介绍过可执行模块的加载,但并不完全准确和完整。因为我们并没有介绍在加载过程中关于虚拟存储器的详细内容。
为了理解hello程序的实际加载过程,必须要理解进程、虚拟存储器和存储器映射的概念。在Unix系统中,每个程序都运行在一个进程上下文中,有自己的虚拟地址空间。当外壳运行一个程序时(在终端执行./hello),父进程生成一个子进程,它是父进程的一个复制品。子进程通过execve系统调用启动加载器。加载器首先删除子进程现有的虚拟存储器段,并创建一组新的代码、数据、堆和栈段。新的堆和栈段被初始化为零。随后会映射私有区域,为程序的文本、数据、bss和栈区域创建新的区域结构,其中文本和数据都是由程序文件本身提供的,也就是说,这部分的内容会被直接映像到虚拟地址空间,同样的,对于共享对象,比如.dll,.so的共享对象,也是由程序文件本身提供的,这些共享对象加载到物理存储器后,可能会被多个进程共享使用。不过对于栈和堆以及.bss区,这部分区域是由内核创建,习惯上我们称为匿名文件或者请求二进制零页面,在CPU第一次引用这部分区域的内容时,内核会在物理存储其中寻找一个合适的牺牲页面,并用匿名文件去替换牺牲页面同时更新页表。
当文件映射完成之后,加载器随后设置修改进程上下文的程序计数器,使其指向文本区域的入口点,加载器跳转到_start地址开始执行,它最终会调用应用程序的main函数。除了一些头部信息,在加载过程中没有任何从磁盘到存储器数据拷贝,直到CPU引用一个被映射的虚拟页才会进行拷贝,此时,操作系统利用它的页面调度机制自动将页面从磁盘传送到存储器。
可以看到,整个加载的流程并不是很难理解,Unix系统其实是将虚拟存储器组织成一个区域(或者段)的集合。一个区域就是已经存在着的(已分配的)虚拟存储器的连续片。这些页时以某种方式相关联的。例如代码段、数据段、堆、共享库段,以及用户栈都是不同的区域,每个存在的页面都是保存在某一个区域之中的,而不属于某一个区域的虚拟页是不存在的,并且不能被进程引用。
我们都知道C函数Malloc(暂不谈new,它底层是由malloc实现的)提供了分配存储空间的功能,能在堆中为我们创建指定大小的内存空间,在未真正了解虚拟存储器之前,我们可能以为它真的会在物理存储器中为我们创建指定大小的内存空间。事实上,并不是这样的,在引入了进程虚拟地址空间的概念后,进程中所有代码执行都是在其私有的地址空间内进行的,可是我们好奇malloc是如何创建并和物理存储器关联起来的,我们之前提到过堆、栈区以及.bss区都是匿名文件,是由内核来创建,这是因为系统内核并不能通过程序文件的映射来完成,因为磁盘文件本身并不包含和堆区对应的任何静态数据实体。内核通过代码中开辟堆区的大小调整堆区虚拟页面,系统内核只需要更新PTE页表条目就可以完成该操作,但这时候并没有在物理存储器中为程序开辟空间,直到代码中真正引用到该虚拟页面即堆区空间时,内核才会为我们的页面寻找一个合适的牺牲页面,然后再将虚拟页面映射到物理存储器并同时更新页表的PTE。对应的,如果程序中通过free释放了内存,那么对应的过程也是类似的。
关于Malloc的具体实现,我会在下一篇再做介绍。
我们知道虚拟存储器作为存储器的保护工具,不只是因为虚拟存储机制为每个进程提供了独立的地址空间,这意味某一进程不能随便访问其他进程私有地址空间。另一方面,我们还介绍了地址翻译过程中,通过PTE,我们也能控制CPU对虚拟页面的读、写执行权限,例如只读区只允许程序读取数据而不允许写操作,栈区一般具有读写的权限。特殊地,在可允许的情况下,栈还可以具有可执行的权限,之所以一般栈不具备可执行的权限是为了防止可能的类似缓冲区溢出攻击所带来的危害。我们可以通过下面的代码来证实上面所说:
Int main(){
Char s[]=”\x90\x90\x90\x3c”;
((void(__stdcall*)())&s[0])();
Return 0;
}
首先要说明的是,代码能够顺利执行的前提是编译器能够关闭数据执行保护(DEP)机制,否则会触发页面保护机制导致系统报告”段错误”。在这段代码中字符数组s是属于栈区的数据内容,其代表的是nop和ret构成的指令(X86),接下来的代码执行栈中的这些指令。能够顺利通过执行说明栈具备了可执行的权限,当然我们在程序中为了安全起见,并不建议赋予栈可执行的权限。这些控制正是通过PTE的读写权限位来标记的。
刚接触指针时,我们可能常常会犯下面类似的错误:
Int * pval=(int*)malloc(sizeof(int));
*pval=5;
我们会忘记判断返回值或者假设程序总能从堆中取到指定大小的空间,对于Malloc来说,它并不保证一定会取到足够大小的空间,所以添加对返回值的判断是有必要的,我们常常用pval和NULL指针来做比较,NULL指针被简单的定位为0,代表为空。对NULL指针对象的引用会导致一个空指针异常,在windows的进程地址空间中,存在一个NULL指针分区,该分区从地址空间的起始地址开始(即0x00000000),如果程序视图访问该分区地址空间的数据,或者将数据写入该分区的地址空间,那么CPU就会触发一个违规访问(即Null Pointer Exception)。所以对于malloc不能找到足够的内存来满足需求时,就返回NULL,如果代码这时候对其进行引用,就会触发NULL指针分区的保护机制。
还有,对于c++中new操作符来说,我们可能并不需要对其返回的地址指针进行检测,这是由于如果new分配空间操作失败,c++引入的异常处理机制使得我们还来不及取到返回值就已经抛出异常,这类运行时异常会导致程序假死或者终止。对于new操作可能会像下面这样:
Void* _new(size)throw Exception{
…..
Try{
p=Malloc(size);
…..
}catch(Exception e){
}
Return p;
}
到这里,关于虚拟存储器的绝大部分内容就算介绍完了,希望你能有所收获,关于所讨论的内容,如果有任何错误和不当之处,望指正。
CSI-S3:虚拟存储器(二)-再谈hello程序,布布扣,bubuko.com
原文:http://blog.csdn.net/u012960981/article/details/23672387