读书笔记(三十一) 《装载、链接与库》#3 动态链接与装载

已发布在微信公众号上,点击跳转

背景:

看此书的起源是我在了解Linux注入技术的时候翻阅到的,由于注入技术需要用到很多ELF格式的内容,很多网络上的技术文章都指向了同一本书。也刚好周围的同事有此书,便翻阅了一下,这一番翻阅打开了我对程序世界的又一扇大门。

很快我就自己买了此书并阅读完成,整本书给我很大的震撼,让我对程序从编译到链接到装载有了更深刻的认识。

我把我的整个学习过程以及对书本的理解,用自己的语言和自己画的图表达出来,让读者能够更容易接受到我所学的知识。

目标:

了解编译过程 了解动态库和静态库的装载细节 了解可执行程序装载和执行过程 了解可执行文件和动态库的数据格式

疑问:

c/c++编译器是如何将cpp编译为可执行文件的?

多个c/c++文件是如何编译成一个可执行文件的?

操作系统内存是如何初始化和管理的?

动态库和静态库的链接和装载过程是怎样的?

操作系统的用户态和内核态是如何运作的?

正文:

前面我们说了ELF格式,以及ELF格式的静态链接过程,Windows也有自己的二进制文件格式,这里简单说一下Windows部分的格式。

Windows的二进制文件格式称为PE(Protable executable),PE文件格式与ELF同根同源,都是COFF格式发展而来。 Windows平台使用Visual C++编译器产生的目标文件使用COFF文件,它们的结构很大程度上都是相同的。 “cl”是Visual C++的编译器,即“Compiler”的缩写,Visual C++还提供了用于查看目标文件和可执行文件的工具“dumpbin”。

可执行文件装载

在讲解执行文件装载前先简单介绍下虚拟空间,因为可执行文件装载需要虚拟空间支持,那么什么是虚拟空间呢?

虚拟空间

虚拟空间很多人都认为是一个真实的空间,其实不然,它是我们想象出来的空间,其实并不存在。 而我们在理解的时候却需要承认这个空间的存在,即每个进程都拥有一个自己想象的虚拟空间,地址从0到0xFFFFFFFFF(32位设备)

操作系统上的使用的内存空间都是虚拟地址空间,而非实际物理空间,它是由操作系统虚构出来的一个地址空间。

这就像是操作系统给了每个进程一个世界那样,在这个世界里,进程可以自由的申请和释放内存,而不需要理会物理内存如何分配和释放。

每个进程中的内存从虚拟地址都从0开始,到0xFFFFFFFF结束,其中有1G的空间专门为内核空间所用,用户空间也做了不同的分段。因此整个虚拟空间地址可以分为:

.kernel 内核空间段
.stack 栈内存段
.libraries  动态库映射段(文件映射段)
.heap 堆内存段
.bass 未初始化的全局/静态变量段
.data 已初始化全局/静态变量段
.text 程序指令字节段

其中栈内存大小是固定的,只有1MB内存,栈会不断向下分配内存,回收时只需要调整栈顶指针,因此它的分配和回收效率很高,而堆内存则不断向上分配内存,效率相对较低,回收时也容易产生内存碎片。

对于Windows操作系统来说,它的进程虚拟地址空间划分是操作系统占用2GB,用户空间剩下2GB。

从硬件层面上来说原先的总线32位地址只能访问最多4GB物理内存。但自从扩展至36位地址线之后,Intel修改了页映射的方式,使得新的映射方式可以访问到更多的物理内存,这个扩展方式叫做PAE(Physical Address Extension)。原理就是多块物理内存随时交换映射为同一个虚拟内存地址,这样应用程序就不需要管超出部分的内存空间了。不过这只是在32位机时代的补救办法,现在逐渐淘汰了,但这个方法在其他领域里仍然在被使用。

Linux和Windows操作系统都使用页映射方式管理虚拟内存和物理内存之间的关系

虚拟内存与物理内存之间由一个页表作为映射连接它们之间的关系

当我们访问一块虚拟空间时,操作系统发现没有这块地址没有被连接到真实的物理地址,此时才真正从真实物理内存中找到一块内存块用页表连接起来,这才真正分配成功并且可以使用。

每次分配实际物理内存,也并不是分配一整块申请的虚拟内存空间,而是按页大小来分配。即,实际物理空间在使用到时才真正分配,而虚拟空间则已经先行分配。

因此我们在虚拟内存上申请的连续内存,有可能在物理内存上是不连续的。当然,申请连续内存获得实际连续内存的概率要大很多。

对虚拟空间来说,一块内存只是一个数据结构,没有实际的占用,也没有真正的空间之说,只是我们说起来容易理解一些。

程序启动时虚拟内存中很多内存地址都没有被用到,因此也没有对应的物理页,只有当使用到该虚拟内存页时,发现有物理内存缺页的情况,才会发起物理内存申请和映射。

除了虚拟内存,不一定有实际的物理内存外,实际的物理地址上的内存,也不一定在实际物理内存中。 操作系统又在实际物理内存上加了一个系统用于更好的利用实际物理内存空间,即Swap。

当检测到某些物理内存上一次使用时间过长时,则会被Swap置换到硬盘空间,为实际物理内存腾出更多空间给其他进程使用。

我们前文提到,在虚拟存储中,现代的硬件MMU提供了地址转换功能,帮助我们在虚拟地址和物理地址之间的转换。

那么执行文件是怎么被装载进进程的呢?

大致过程为,先创建一个进程,然后将可执行文件装载进进程,再开始运行程序。

其中装载可执行文件时需要分三步:

  1. 创建一个独立的虚拟地址空间
  2. 读取可执行文件头,并建立虚拟空间与可执行文件的映射关系
  3. 将CPU的指令寄存器设置成可执行文件的入口地址,开始运行

注意,创建一个虚拟空间实际上并不是创建空间,而是创建映射函数所需要的相应的数据结构。在i386的Linux下,创建虚拟地址空间实际上只是分配一个页目录就可以了。

另外,可执行文件与虚拟空间建立映射关系时,不会有磁盘和内存的装载,当我们读取可执行文件内容时才发生“缺页”并从磁盘中读取文件内容,装载到虚拟内存再通过页表分配实际物理内存。 因此,由于可执行文件在装载时实际上是被映射的虚拟空间地址,所以可执行文件很多时候被称为映像文件(Image)。

ELF可执行文件引入了Segment的概念,Segment段合并了多个Section,这样的好处是装载时能更高效且方便。 所有相同属性的Section都会归类到一个Segment并映射到同一个匿名虚拟内存区域中(VMA,Anonymous Virtual Memory Area)。

Linux内核装载ELF过程

内核装载ELF的过程可以描述为如下步骤:

  1. 调用fork()创建一个新的进程
  2. 新的进程调用execve()执行指定的ELF文件
  3. 进入execve()后,Linux内核就开始进行真正的装载工作
  4. 先调用sys_execve()进行一些参数检查与复制
  5. 再调用do_execve(),它会先查找被执行的文件,去读文件的前128个字节做文件检查,比如魔数的检查。
  6. 调用search_binary_handle()搜索和匹配合适的可执行文件装载处理过程,比如ELF可执行文件的装载处理过程为load_elf_binary(),以及装载可执行脚本程序的处理过程叫做load_script()。
  7. load_elf_binary()执行完毕后由于把系统调用的返回地址改为了装载ELF程序的入口地址,
  8. 所以当sys_execve()从内核态返回到用户态时,EIP寄存器直接跳转到ELF程序的入口地址,
  9. 于是新的程序开始执行,ELF可执行文件装载完成。

其中load_elf_binary()执行过程为:

  1. 检查ELF可执行文件的有效性,比如魔数、ELF头数据中与Segment的数量
  2. 获取动态链接的“.interp”段,并设置动态链接器路径。
  3. 根据ELF可执行文件的头描述,对ELF文件进行映射,比如代码、数据、只读数据。
  4. 初始化ELF进程环境,比如进程启动时EDX寄存器的地址应该是DT_FINI的地址。
  5. 将系统调用的返回地址修改成ELF可执行文件的入口点,入口点对于静态链接来说就是ELF头中的e_entry所指的地址,对于动态链接的ELF可执行文件入口点就是动态链接器。

Windows PE的装载过程比ELF简单一些:

  1. 读取文件第一页,里面包含了DOS头、PE文件头和段表
  2. 检查进程地址空间中目标地址是否可用。如果不可用则另外选一个装载地址,这里可执行文件装载不存在问题,主要针对DLL装载。
  3. 获取段表中提供的信息,将PE文件中所有的段一一映射到地址空间中相应的位置。
  4. 如果装载地址不是目标地址,则进行Rebasing重定位。
  5. 装载所有PE文件所需要的DLL文件。
  6. 对PE文件中的所有导入符号进行解析。
  7. 根据PE头中指定的参数,建立初始化栈和堆空间。
  8. 建立主线程并启动进程。

动态链接

注意,静态链接和动态链接,与静态库和动态库是两个概念,前者说的是程序链接,后者说的是库的不同组装方式。

静态链接由于必须在编译时就确定好库文件而且每个进程都需要载入一份内存,所以它的缺点是更多内存、更多磁盘空间、模块更新困难。

我们可以想象一下,每个程序内部都增加一份1MB库程序的内存,那么100个进程就要浪费近100MB的内存,如果磁盘中有2000个这样的程序,就要浪费近2GB磁盘空间,而且在编译后还不能及时更新某个模块,弊端确实挺大的。

动态链接就很好的解决了这个问题,它不仅可以使得不同进程之间共享一个库内存,还可以减少物理页的换入换出,也可以增加CPU缓存的命中率。 当我们升级程序库时,只要简单的覆盖原文件而无须将所有程序再重新链接一遍,当程序下次运行时新版本库文件就会自动装载到内存完成升级目标。

注意,动态链接与静态链接在内存分布上也不同。静态链接后的库程序是可执行文件内不可分割的整体,动态链接后的库程序则有自己的独立内存模块。

动态库的动态链接过程

其实在需要进行动态装载动态库的可执行文件中,已经装载好了一个ld-2.6.so动态库,它实际上就是Linux下的动态链接器。

我们在开启一个进程时,在装载完执行文件后,进程首先会把控制权交给动态链接器,由它完成所有的动态链接工作,再把控制权交给进程开始执行。

当我们在代码中写下动态库的函数时,在程序模块动态装载时,应该不需要因为装载地址的改变而改变。 所以实现动态链接的基本想法就是把指令中那些需要被修改的部分分离出来,放到数据那里去,这样指令部分就可以保持不变,而数据部分可以在每个进程中拥有一个副本。

这种方案称为,地址无关代码(PIC,Position-independent Code)技术方案,通常我们在编译时会加上PIC这个标记,就是告诉编译器,我们这个库是地址无关的。

如果一个库不是以地址无关(PIC)模式编译的,那么毫无疑问,它需要在装载时被重定位,即在启动执行时就需要装载库文件并且重定位所有与库文件有关的函数调用地址。

如果一个库是以地址无关(PIC)模式编译的,那么就不会在装载时对整个库函数相关调用进行重定位,而是会用延迟绑定(PLT)的方式实时定位函数。

static int a;
extern int b;
extern void ext();

void bar()
{
  a = 1; // 模块内数据访问
  b = 2; // 外部模块数据访问
}

void foo()
{
  bar(); // 模块内函数调用
  ext(); // 外部模块函数调用
}

以上图为例模块内和模块间的数据访问和函数调用方法,

  1. 模块内的函数调用使用相对地址调用
  2. 模块内的数据访问使用相对寻址方式
  3. 模块外的数据访问,由于无法知道外部数据的具体地址,所以需要借助全局偏移表(GOT)获取外部数据
  4. 模块外的函数调用,由于无法知道外部函数的具体地址,所以需要借助全局偏移表(GOT)获取外部函数地址后再调用

这样说来GOT就是关键中的关键,那么GOT是怎么工作的呢?

大致的方案得从编译说起,我们知道在使用printf,scanf(),strlen()这样的公用库函数时都需要加载公共库libc.so,但公共库并没有被合并到可执行文件中,也就没有可依赖的地址规则。

所以编译器在编译这些外部函数的时候,其实并不知道它们的调用地址是多少,无法填充真实地址。 但编译器会填充一个地址指向一段动态程序,当这个函数真正被调用时,先调用到动态程序,再由动态程序去寻找真正的调用地址,最后再调用真实地址的函数。

这种方法就是延迟绑定(PLT),即进程启动加载外部so后并不立刻对任何调用到so函数的指令做重定位,而是当这些外部函数被调用到时才去寻找并且绑定函数与真实地址的关系。

动态链接对全局和静态数据的访问要进行复杂的GOT定位,然后再间接寻址,对外部模块中的函数调用先定位GOT,然后再进行间接跳转。

GOT延迟绑定(PLT)运行原理

从上图中我们可以知道PLT有3个关键点:

  1. 跳转到动态绑定程序
  2. 查找外部函数地址的程序
  3. GOT表中数据的存储与读取

我们就从这三个关键点入手说明PLT的运行原理

当我们在代码里编写外部模块函数,在编译时编译器为我们留下的其实是PLT程序的地址,这个地址就是专门为这个外部函数服务的动态绑定程序,在.plt段里,符号以@plt结尾。

动态绑定程序中有3条指令:

.plt段里动态绑定程序才是真实的查找函数地址的过程,其查找函数真实地址步骤为:

ELF将GOT拆分成两个表,“.got”和“.got.plt”,其中“.got”用来保存全局变量引用地址,“.got.plt”用来保存函数引用地址。

其中“.got.plt”的前三项有着特殊意义:

三项中的第二项和第三项由动态链接器在装载模块时将它们填充。 第四项也稍有些特殊,通常会将跳转指令的通用程序放在第四项中以减少代码重复,因此“.got.plt”基本上前4项都是确定的内容。

动态链接相关的数据结构

在整个PLT绑定、查找和定位过程中用到了好几个数据段,这里介绍一下。

.interp段里保存了一个字符串,这个字符串就是可执行文件件所需要的动态链接器的路径,Linux下动态链接器通常都在“/lib/ld-linux.so.2”,它通常是个软连接指向真正的库。

.dynamic段是ELF动态链接最重要的结构,里面保存了动态链接器所需要的基本信息,比如依赖于哪些共享对象、动态链接符号表的位置、动态链接重定位表的位置、共享对象初始化代码的地址等。

它有点像ELF文件头,只是ELF文件头中保存的是静态链接时需要的信息,比如符号表、重定位表等,.dynamic可以看成是动态链接下所需要的信息。

.dynsym段是动态符号表,与.symtab符号表不同的是,.dynsym动态符号表只保存了与链接相关的符号,也就是外部接口符号表,对于那些模块内部的符号则不保存。

为了在.dynsym中查找到对应的符号,会有一个动态符号字符串表.dynstr和符号哈希表.hash来辅助定位和查找符号字符串,加快了符号查找过程。

.rel.dyn和.rel.plt段位动态链接时的重定位表,与我们之前在静态链接里介绍的.rel.text和.rel.data的用途类似,存储的是需要重定位的符号和全局数据。

与.rel.text和.rel.data的区别在于,.rel.dyn实际上只是修正地址的引用,并不是实际数据地址,他修正的位置其实位于.got以及数据段中;同样的,.rel.plt修正的也只是函数地址的引用,而不是实际地址,其真正地址位于.got.plt中。

了解了这几个段的用途,我们可以把调用动态库的延迟重定位的整个过程串连起来了:

  1. 程序调用动态库中函数
  2. 跳转到PLT程序集中
  3. 从GOT中获取真实函数地址,有则直接跳转
  4. 没有则从.rela.plt中获取相关信息并调用_dl_runtime_resolve()查找真实函数地址
  5. _dl_runtime_resolve()根据查找到的so中的.dynsym动态符号表找到真实函数地址
  6. 将真实地址填充到GOT中并跳转
  7. 库函数调用结束

动态链接器自己重定位

我们说动态链接器本身就是一个库文件(ld-linux.so),所以在进程把控制权交给动态链接器前必须先要将这个库装载到进程中,由于此时又没有程序能去重定位这个动态链接器的库,因此动态链接库必须自己来给自己做重定位,这个重定位过程称为“自举”。

自举的过程可以简单描述为:

  1. 通过自己的.dynamic段,找到它自己的重定位表和符号表
  2. 再根据重定位表和符号表,重定位所有符号表

自举期间不能调用函数,因为模块内函数调用必须采用GOT/PLT方式,但GOT/PLT没有被重定位,因此自举代码不可以使用任何全局变量或调用函数。

动态链接器自举完成后,就可以通过.dynamic段获取所有需要装载的动态库依次装载、解析、重定位。 每个需要被装载的动态库,在装载完成后它的符号表都会被合并到全局符号表(Global Symbol Table)中,全局符号表包含了进程中所有动态链接需要的符号。

运行时链接

前面说的静态链接和动态链接都是在程序还未完全启动前做的事情,运行时链接则是在程序进行中加载动态库的一种方式。

运行时链接的接口有4个,分别是:

  1. dlopen,装载指定动态库
  2. dlsym,获取动态库中的某个符号的地址
  3. dlerror,获取上次调用的错误信息
  4. dlclose,关闭并卸载动态库

运行时链接与动态链接的一模一样(除了自举部分),区别一个在启动时装载,另一个则在程序运行时装载。

注意,由于所有加载的动态库中的符号都会合并到全局符号表,因此在加载多个库时如果有重复的符号,则后加载的符号可以根据参数选择是否覆盖前面的符号表,默认是不覆盖的。

这里作者做了一个试验,因为在Windows中有个rundll的小程序可以直接调用DLL中的函数并返回结果,所以作者想在Linux上也实现一个。 于是自己写代码读取动态库so中的函数并调用后打印返回结果,其原理就是利用这4个函数,先dlopen加载指定so,再根据函数名调用dlsym找到函数地址并且调用,最后调用dlerror卸载动态库。

已发布在微信公众号上,点击跳转

· 读书笔记, 前端技术

感谢您的耐心阅读

Thanks for your reading

  • 版权申明

    本文为博主原创文章,未经允许不得转载:

    读书笔记(三十一) 《装载、链接与库》#3 动态链接与装载

    Copyright attention

    Please don't reprint without authorize.

  • 微信公众号,文章同步推送,致力于分享一个资深程序员在北上广深拼搏中对世界的理解

    QQ交流群: 777859752 (高级程序书友会)