[Common] 程序员的自我修养(2)

6. 可执行文件的装载与进程

寻址

整个4GB被划分为两部分,其中操作系统本身用去了一部分:从地址0xC0000000到地址0xFFFFFFFF,共1GB;剩下的0x00000000到0xBFFFFFFF共3GB都是留给进程使用的;也就是说整个进程在执行的时候,所有的代码、数据包括通过C语言malloc()等方法申请的虚拟空间之和不可以超过3GB。

程序运行就是把它加载到内存里面,但是内存经常不够用。后来研究发现,程序运行是具有局部性的,所以我们可以将程序最常用的部分驻扎到内存,然后其他不太常用的放到磁盘,这就是动态载入的基本原理。

覆盖装入和页映射是两种很典型的动态装载方法,都是利用了程序局部性原理。
动态装入的思想就是用到哪个模块,就将哪个模块装入内存,如果不用就暂时不装入,存放在磁盘中。

覆盖装入:
将挖掘内存潜力的责任交给程序员,程序员需要将代码分成很多块,并写一个覆盖管理器管理他们何时应该被载入内存。(时间换空间)
覆盖管理器来管理模块代码何时应该驻留在内存而何时应该被替换掉。
当存在多个模块时,程序员需要手工将模块按照他们之间的依赖关系组织成树状结构。

覆盖载入

一个普遍的覆盖载入的内存分布是酱紫的:(注意千万不能跨树调用哦)


树状覆盖载入

页映射:
与覆盖装入原理相似,页映射也不是一下子就把程序的所有数据和指令都装入内存,而是将内存和所有磁盘中的数据和指令按照“页”为单位划分成若干个页,以后所有的装载和操作的单位都是页。
这就是现代操作系统中的存储管理器,几乎所有主流操作系统都是按照这种方式装载可执行文件的。

页映射

从OS角度来看,一个进程最关键的特征是它拥有独立的虚拟地址空间,这使得它有别于其他进程。很多时候一个程序被执行同时都伴随着一个新的进程被创建,那么我们来看一下这种最通常的情形:创建一个进程,然后装载相应的可执行文件并且执行。在有虚拟存储的情况下,上述过程最开始只需要做三件事情:
1) 创建一个独立的虚拟地址空间;
2) 读取可执行文件头,并且建立虚拟文件空间与可执行文件的映射关系;
3) 将CPU的指令寄存器设置成可执行文件的入口地址,启动运行。

由于可执行文件在装载时实际上是被映射的虚拟空间,所以可执行文件很多时候又被叫做映像文件

准备工作后如何加载

因为段太多了,如果每个段都占用页的整数倍就很浪费,所以可以将相同读写权限的放一起:


segment & section

ELF可执行文件的程序头 Program header描述了每段有哪些section以及空间之类的信息。

VMA(虚拟内存区域),一个虚拟地址空间,也就是虚拟内存中的一段,不仅可以用于映射segment,还可以用来管理 stack 和 heap

一个进程基本上可以分为如下几种VMA区域:

  • 代码VMA,权限只读、可执行,有映像文件
  • 数据VMA,权限可读写、可执行,有映像文件
  • 堆VMA,权限为可读写,可执行,无映像文件,匿名,可向上扩展
  • 栈VMA,权限为可读写、不可执行,无映像文件,匿名,可向下扩展
vma

7. 动态链接

静态链接对空间浪费太多了,比如多个目标程序链接的时候,共用了同一个目标文件,那么link的时候这个通用的目标文件其实被加载多次。

生成可执行文件的时候其实是merge,所以多个可执行文件都会有这个公用文件,那么可执行文件加载的时候,就相当于加载了多次。

静态链接加载副本

静态链接的另一个问题就是当公用目标文件是第三方提供的时候,如果他们更新了文件,我们用的这个文件的program都需要重新链接,然后重新提供给用户。

那么当我们代码里的三方库任意一个更新了,我们代码就需要重新link发版,那么更新频率会非常高。

动态链接其实就是分割程序模块,在运行时再链接目标文件。把link推迟到运行时。

动态示例

这样当多个程序都import了同一个目标文件的时候,这个被依赖的库在内存里只有一份,而非多份,于是就节约了内存。

共享模块的好处不仅仅是节省内存,还可以减少物理页面的换入换出,还可以增加CPU缓存的命中率,因为不同进程间的数据和指令访问都集中在了同一个共享模块上。

动态链接使得程序的升级更加容易;如果有某个库需要升级,只需要替换一个目标文件,无需重新link全部程序,使得开发过程中各个模块更加独立,耦合度更小,便于不同开发者和开发组织之间独立进行开发和测试。

动态链接也会引发一些问题,比如升级以后和之前的接口不兼容,就会导致运行出错。

Linux中动态链接文件被称为动态共享对象(DSO,Dynamic Shared Objects),共享对象.so。windows中动态链接文件被称为动态链接库(Dynamic Linking Library).dll

动态链接的时间换空间

当程序模块Program1.c被编译成为Program1.o时,编译器还不知道foobar()函数的地址。但链接器将Program1.o链接成可执行文件时,这时候链接器必须确定Program1.o中所引用的foobar()函数的性质。如果foobar()是一个定义与其它静态目标模块中的函数,那么链接器将会按照静态链接的规则,将Program1.o中的foobar地址引用重定位;如果foobar()是一个定义在某个动态共享对象中的函数,那么链接器就会将这个符号的引用标记为一个动态链接的符号,不对它进行地址重定位,把这个过程留到装载时再进行

链接器如何知道foobar的引用是一个静态符号还是一个动态符号?这实际上就是我们要用到Lib.so的原因。Lib.so中保存了完整的符号信息(因为运行时进行动态链接还须使用符号信息),把Lib.so也作为链接的输入文件之一,链接器在解析符号时就可以知道:foobar是一个定义在Lib.so的动态符号。这样链接器就可以对foobar的引用做特殊的处理,使它成为一个对动态符号的引用。

所以虽然动态链接在运行时才加载so文件,但是在静态链接的时候也会作为输入,用于标记哪些符号是在动态库中的。

共享对象的最终装载地址在编译时是不确定的,而是在装载时,装载器根据当前地址空间的空闲情况,动态分配一块足够大小的虚拟地址空间给相应的共享对象。

动态链接可以在运行时,通过直接将载入地址+基址偏移量得到实际地址,因为载入地址其实可能每次不一样。我们前面在静态链接时提到过重定位,那时的重定位叫做链接时重定位(Link Time Relocation),而现在这种情况经常被称为装载时重定位(Load Time Relocation)。

GOT模块间的寻址

再一次弄丢了我发布的内容,于是以下部分我只能随意的截图了。。因为写第二次真的很烦啊。。

截屏2021-07-12 上午7.21.31.png
截屏2021-07-12 上午7.21.59.png

上面的这个副本问题和block捕获变量其实有点像,就是当你有两份数据的时候如何保证同步。

截屏2021-07-12 上午7.22.47.png

在一个程序运行过程中,可能很多函数在程序执行完时都不会被用到,比如一些错误处理函数或者是一些用户很少用到的功能模块等,如果一开始就把所有函数都链接好实际上是一种浪费。所以ELF采用了一种延迟绑定(Lazy Bingding)的做法,基本的思想就是当函数第一次被用到时才进行绑定(符号查找、重定位等),如果没有用到则不进行绑定。

截屏2021-07-12 上午7.24.29.png

动态链接器帮助我们重定位动态库,但由于他自己没有办法被自己帮,于是它自身其实是通过静态链接打入程序的。

装载共享对象:完成基本自举以后,动态链接器将可执行文件和链接器本身的符号表都合并到一个符号表当中,我们可以称它为全局符号表(Global Symbol Table)

Linux下的动态链接器是这样处理的:它定义了一个规则,那就是当一个符号需要被加入全局符号表时,如果相同的符号名已经存在,则后加入的符号被忽略。不会在编译link的时候报错重复符号。这点和静态链接不太一样。

还需要先加载所有动态库的init段

10. 内存

按理说其实你可以通过地址访问任意块内存,但内核关于占一部分,内核是不可以直接访问的。windows会将高2GB给内存,Linux会给高1GB。

截屏2021-07-12 上午7.35.00.png

还有个保留区——不是一个单一的内存区域,而是对内存中受到保护而禁止访问的内存区域总称。比如极小的地址。

Linux下一个进程里的内存分布:


截屏2021-07-12 上午7.37.49.png

动态链接映射区(heap和stack中间的)用于映射装载的动态链接库。Linux下如果可执行文件依赖其他so文件,会默认从0x400000000的地址开始分配相应空间,并将so文件载入到该空间。

栈保存了函数调用所需要的维护信息,通常被称为栈帧(stack frame)或活动记录(active record)。栈帧通常包含以下信息:

  • 函数的返回地址和参数
  • 临时变量:函数内的非静态变量及编译器自动生成的临时变量
  • 保存的上下文:包括在函数调用前后需要保持不变的寄存器
活动记录

ebp指向调用该函数前的ebp的值,这样就可以在函数返回时,ebp可以读取这个值来恢复到调用前的值ebp-4就是返回地址,ebp-8, ebp-12等就是参数地址。

以i386以例,函数调用总是:

  • 把所有或部分参数压入栈,如果有其他参数没有入栈,那么使用某些特定的寄存器传递
  • 把当前指令的下一条指令的地址压入栈
  • 跳到函数体执行

以上步骤1和2,与图10-4的活动记录的参数和返回地址对应。步骤2和3由call指令一起执行,步骤3中的函数体的标准开头为:

  • ebp入栈,对应10-4的Old EBP
  • esp的值赋给ebp
  • 分配空间,并将寄存器值保存到已分配空间,对应10-4中的保存的寄存器
多级调用举例
内存申请

mmap与windows的VirtualAlloc相似,它的作用是向操作系统申请一段虚拟地址空间,这段空间可以映射到某个文件,当它不被映射到文件时称为匿名空间,而匿名空间可以被当作堆空间。

但mmap申请的空间的起始地址和空间大小都必须是系统页大小的整数倍,对于字节很小的请求无疑是一种浪费。

如何管理一大块连续的内存空间,能够按照需求分配、释放其中的空间,这就是堆分配算法。(毕竟最开始申请了很大的一块内存)

空闲链表:实际上就是把堆中各个空闲的块按照链表的方式连接起来,当用户请求一块空间时,可以遍历整个列表,直到找到合适大小的块并且将它拆分,当用户释放空间时将它合并到空闲链表中。

空闲链表

位图:将堆划分个固定大小的块,每块大小相同。当用户请求内存时,总是分配整数块的空间,第一个块称为已分配区域的头(head),其余称为分配区域的主体(body)。

对象池:在实际应用中,被分配的堆对象的大小是较为固定的几个值,这时候就可以针对这样的特征设计一个更为高效的堆算法,称为对象池。

对象池的思路很简单,如果每次分配的空间大小都一样(假设为K字节),那么就可以以此空间大小作为一个单位,把整个空间划分为大量的K字节大小的块,每次请求时只需要找到一个小块就可以。

对象池的管理方法可以是空闲链表,也可以用位图。

你可能感兴趣的:([Common] 程序员的自我修养(2))