【程序员的自我修养07】程序的装载过程——小内存如何运行大程序
绪论
大家好,欢迎来到【程序员的自我修养】专栏。正如其专栏名,本专栏主要分享学习《程序员的自我修养——链接、装载与库》的知识点以及结合自己的工作经验以及思考。编译原理相关知识本身就比较有难度,我会尽自己最大的努力,争取深入浅出。若你希望与一群志同道合的朋友一起学习,也希望加入到我们的学习群中。文末有加入方式。
介绍
经过前几篇文章的介绍,我们已经大致了解多个源文件如何编译生成可执行文件的过程。但是程序只有放在内存中才能被CPU执行,本章节向大家介绍,如何程序加载到内存中的过程。并向大家解析一直困扰我们的一个问题:小内存如何运行大程序?
概念
首先,为了避免后续了解不一致,我们先了解两个名词。程序和进程。
- 程序是一个静态的概念。它是一些预先编译好的指令和数据集合的文件。就像我们之前一直提到的可执行ELF文件。
- 进程则是一个动态的概念。它是程序运行时的一个过程。
程序的加载方式
我们知道程序是保存在磁盘上的,进程是运行在内存中的。想要运行程序最简单的方式就是将程序中所需的指令和数据全部装入内存,这样程序就可以运行了。但是为什么没有这么做呢?从jd官网查询硬盘和内存的价格对比可知。
8GB的内存售价¥139元,1T的硬盘仅售价¥399元,也就是说同样存储大小,内存的价格大约是硬盘的45倍。
因为内存是昂贵和稀有的,在这样的背景下,人们就想尽各种办法,希望在不添加内存的情况下,让更多的程序运行起来,尽可能的利用内存。后来经研究发现,程序运行是有局部性原理的。我们可以将程序最常用的部分驻留在内存中,将一些不太常用的数据放在磁盘中,这就是动态状态的基本原理。
从历史的发展来看,程序的装载分为两种方式:覆盖装入、页映射。
覆盖装入
覆盖装入原理:将挖掘内存潜力的任务交给了程序员,程序员在编写程序的时候必须要手动将工程分割成若干块,然后编写一个小的辅助代码来管理这些模块,何时应该驻留在内存,何时应该被替换。这个小的辅助代码就是所谓的覆盖管理器(Overlay Manager)。
示例:一个程序主模块main
,main
会分别调用模块A和模块B,但是A和B之间不会相互调用,这三个模块的大小分别是1024字节,字节,256字节。这样的进程,在内存中该如何分布呢?
分析:
第一种情况,采用最原始的方式,全部驻留在内存中,共需要1024+512+256=1792
字节,内存分布大致如下。
第二种,采用覆盖载入。由于模块A和模块B之间不会存在相互依赖关系,并且不会同时调用。我们可以把模块A和模块B在内存中相互覆盖,即两个模块共享块内存区域。如下:
即:当main
模块调用模块A时,覆盖管理器保证将模块A从文件中读取内存;当模块main
调用模块B时,覆盖管理器保证将模块B从文件中读入内存,由于此时模块A不会被使用,那么模块B可以装入到原来模块A所占用的内存空间。从而达到节约内存的目的,最终仅需要1536字节。
上面仅是简单示例,真实项目中,模块数量之多,关联复杂程度之高,是我们难以把控的。
综上所述,大致了解了覆盖装入的过程和使用方式,也反映出它的缺点:要求开发人员对各模块之间的应用要非常的熟悉。这在现代开发环境很难适应,因为当今任何一个项目都离不开多个团队的合作,开源工具的使用。想要在这样的环境中了解各模块之间的依赖,难于登天。
注:覆盖装入在现在已经几乎被淘汰了,仅在一些嵌入式的内存受限环境下,特别是DSP等,可能还存在使用场景。
页映射
页映射原理:它将内存和程序中的数据和指令按照“页”为单位划分成若干个页,以后所有的装载和操作的单位就是页。
示例:假设我们32位机器有16KB的内存,每个页大小位4KB;而程序的指令和数据共32KB,那么该程序运行时,其内存是如何分布的呢?
分析:我们可以将内存按页区分,分别为F0、F1、F2、F3。程序分为P0、P1、P2、P3、P4、P5、P6、P7。且程序的调用顺序分别是P0->P3->P5->P6->P1->P4
等。其内存使用大致如下:
- 程序刚执行时,入口地址在P0,装载器发现程序的P0不在内存中,于是将内存F0分配给P0,并将P0的内存装入F0。
- 运行一段时间后,程序需要运行P3部分,因为F0已经被P0占用,则把F1分配给P3,并将P3的内容装入F1。同理,P5和P6分别装入F2和F3物理内存页中。
- 当进程继续运行,需要用到P1页中内容。但是此时内存页已经被P0、P3、P5、P6占满了。装载器就必须要选择释放某一页,进而加载P1页。选择的方式有很多种,比如按照先进先出算法,最少使用算法。
- 根据步骤三,进程持续运行。
问题:从步骤三可知,由于装载器并不受程序员控制,即程序页加载到哪一个内存页,是随机,不可控的。那么就会导致,每次程序页加载到内存时,都可能需要进行重定位。这样的操作肯定会大大影响程序的运行效率,如何解决这个问题呢?带着这个问题,我们继续下去。
进程的建立
一个程序的执行,往往伴随着一个进程的创建。而一个程序到加载到内存中,并运行。大致可以归纳三个步骤。
- 创建一个独立的虚拟地址空间。
- 读取可执行文件头,并建立虚拟空间与可执行文件的映射关系。
- 将CPU的指令寄存器设置为可执行文件的入口地址,启动运行。
一、创建一个独立的虚拟地址空间
我们知道进程的虚拟空间由一组页映射函数将虚拟空间的各个页映射到响应的物理空间。这里的创建一个虚拟空间并不是创建空间,而是创建映射函数所需要的响应数据接口。也就是说,此时还没有真正分配内存,以及建立映射关系。
二、读取可执行文件头,并建立虚拟空间与可执行文件的映射关系
第一步是建立虚拟内存和物理内存的映射关系,那么这一步就是建立虚拟内存和程序的映射关系。我们知道,当程序发生页错误时,操作系统将物理内存中分配一个物理页,然后将该”缺页“从磁盘中读取到内存中,再设置缺页的虚拟页和物理页的映射关系,这样程序得以正常运行。其中核心点就是,系统捕获到缺页错误时,它应知道程序当前所需要的页在可执行文件中的哪一个位置,这就是该步骤的意义。
三、将CPU指令寄存器设置成可执行文件入口,启动运行
这一步是最简单的,即运行程序的入口地址。
其实,第三步执行,将CPU执行权限交给进程后,程序的指令和数据并没有加载到内存中。比如当我们执行入口地址0x4000e8
的代码段时,发现虚拟空间中的该地址是一个空页,则认为发生了页错误。
- CPU将控制权给操作系统
- 操作系统根据步骤二中的映射关系,
0x4000e8
虚拟地址对应程序.text
段的偏移。在物理内存中分配一个页,再将对应的程序页加载到内存页中。 - 建立该进程的虚拟页与物理页之间的映射关系。
- 把控制权交给进程。
随着页错误不断产生,操作系统也会为进程分配相应的物理页来满足进程的需求。流程大致如下:
总结
本文讲述了程序加载到内存的步骤,其实质就是建立两个映射。一、虚拟空间和物理内存的映射关系;二、虚拟空间和程序的映射关系。进程运行过程中通过不断触发页错误,将程序中的页加载到内存中。从而解释了小内存运行大程序的原理。
本文介绍了静态链接过程一些特殊场景和优化项。希望大家能够结合自己的实际工作,应用起来。
静态链接章节到此为止,后续我会陆续分享动态链接过程及应用。有兴趣的朋友,还请关注,若有任何问题,意见,都可以在评论区留言,我会及时回复。
若我的内容对您有所帮助,还请关注我的公众号。不定期分享干活,剖析案例,也可以一起讨论分享。
我的宗旨:
踩完您工作中的所有坑并分享给您,让你的工作无bug,人生尽是坦途。
本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。 如若内容造成侵权/违法违规/事实不符,请联系我的编程经验分享网邮箱:veading@qq.com进行投诉反馈,一经查实,立即删除!