程序员的自我修养-第七章
。
之前写过一点动态链接的内容:动态链接
为什么要用动态链接
太浪费空间,很多公共库函数在内存中有很多副本。
程序开发和发布不易,尤其是使用到第三方厂商提供的库时。
动态链接:将链接推迟到运行时再进行。
可拓展性:动态链接还有一个特点就是可以在运行时动态的加载各种程序模块,这个优点就是后来被人们用来制作程序的插件(Plug-in)。
缺陷:由于新旧模块接口不兼容,导致原来程序无法运行,如windows 中的 dll hell。
动态链接的基本实现:
动态链接涉及运行时的链接及多个文件的装载,必需要有操作系统的支持,因为动态链接的情况下,进程的虚拟地址空间的分布会比静态链接情况下更为复杂,还有一些存储管理、内存共享、进程线程等机制在动态链接下也会有一些微妙的变化。
动态链接例子
编写代码如下:
1 | ttj@ttj ~/c/shell> more prog1.c prog2.c lib.c lib.h |
进行执行:
1 | ttj@ttj ~/c/shell> gcc -fPIC -shared -o lib.so lib.c |
-shared 用于产生共享对象,-fPIC 后续介绍
查看进程虚拟地址空间分布:可以发现动态链接器也被加载到了地址空间中
共享对象的装载地址不是在编译时确定的,而是装载时动态确定的。
地址无关代码
为了实现动态链接,我们首先会遇到的问题就是共享对象地址的冲突问题。如何给各个模块分配地址呢。手动分配,静态共享库 都不是主流方案。
为了解决这个模块装载地址固定的问题,我们设想是否可以让共享对象在任意地址加载?这个问题另一种表述方法就是:共享对象在编译时不能假设自己在进程虚拟地址空间中的位置。与此不同的是,静态链接的可执行文件基本可以确定自己在进程虚拟空间中的起始位置,因为可执行文件往往是第一个被加载的文件,它可以选择一个固定空闲的地址,比如32位下一般都是0x08040000,64位下一般都是0x0040000。而动态连接的可执行文件很可能启用了地址空间布局随机化(ASLR),而随机加载到虚拟内存地址中。
装载时重定位
我们前面在静态链接时提到过重定位,那时的重定位叫做链接时重定位(Link Time Relocation),而现在这种情况经常被称为装载时重定位(Load Time Relocation),在Windows中,这种装载时重定位又被叫做基址重置(Rebasing),
画线那句暂时不好理解,他的意思是说:一个共享对象文件A可能使用了另一个共享对象文件B的符号,需要对代码进行重定位,但是B的位置对不同进程是不同的,如果不同进程共享了A,即共享了A的代码部分,A中对B的引用不是正确的。?装载前,共享对象文件起始地址为 0,不仅需要对可执行文件的引用进行重定位,装载后还需要对共享对象内部的地址进行重定位(共享对象中指令对数据的引用或者调用自己的函数都需要地址,这些地址需要重定位。如下图所示,动态共享文件虚拟地址为0,装载时是肯定要修改的),但是,不同进程中,共享对象文件的装载地址不同,那么,共享对象文件中代码部分中使用的地址应该修改,但代码被多个进程共享,无法针对性修改。可以看完后面的四种方式再来看这。
还有一种理解方式:装入后,代码段可读可执行,因此不能直接修改代码段。
地址无关代码
装载时重定位是解决动态模块中有绝对地址引用的办法之一,但是它有一个很大的缺点是指令部分无法在多个进程之间共享。
其实我们的目的很简单,希望程序模块中共享的指令部分在装载时不需要因为装载地址的改变而改变。所以实现的基本想法就是把指令中那些需要被修改的部分分离出来,跟数据部分放在一起,这样指令部分就可以保持不变,而数据部分可以在每个进程中拥有一个副本。这种方案就是目前被称为地址无关代码(PIC, Position-independent Code)的技术。
对于现代的机器来说,产生地址无关的代码并不麻烦。我们先来分析模块中各种类型的地址引用方式。这里我们把共享对象模块中的地址引用按照是否为跨模块分成两类:模块内部引用和模块外部引用;按照不同的引用方式又可以分为指令引用和数据访问,这样我们就得到了如图7-4中的4种情况。(个人理解:一个 .so
文件应该是一个模块)
- 第一种是模块内部的函数调用、跳转等。
- 第二种是模块内部的数据访问,比如模块中定义的全局变量、静态变量。
- 第三种是模块外部的函数调用、跳转等。
- 第四种是模块外部的数据访问,比如其他模块中定义的全局变量。
当编译器在编译pic.c时,它实际上并不能确定变量b和函数 ext() 是模块外部的还是模块内部的,因为它们有可能被定义在同一个共享对象的其他目标文件中。由于没法确定,编译器只能把它们都当作模块外部的函数和变量来处理。MSVC编译器提供了__declspec(dllimport)编译器扩展来表示一个符号是模块内部的还是模块外部的。
类型一 模块内部调用或跳转
那么在 plc 代码中,只需要把模块内地址调用用相对地址表示就行了。
看 ext 对应到指令 fffffef3 取反加一得 10d,下一条指令地址为 116d ,相见后得到相对跳转地址 1060 (看错了,本来应该是看 bar的)
类型二 模块内部数据访问
1 | 0000044c <bar>: |
0x44f 行,调用了一个函数,同时将下一条指令 0x454保存到栈中,进入0x494行,将刚刚保存的指令复制到 ecx 寄存器中,回到 0x454行,接下来两行,将 ecx 寄存器中的值(0x454)与 0x118c ,0x28相加即可得到目标数据的地址。该段加载到内存不同地址时,ecx 中的值会发生变化,都保存着当前 ip(或pc) 值。
实测:
本机pc似乎可以直接用 ip 相对地址来访问数据,就不用像前面一样调用函数了。0x2ee9+0x114b = 0x4034,(至于为什么不加0x1148,我也不清楚),0x2e86+0x1152 = 0x3fd8,查看符号表可发现:地址正确。(他虽然是全局变量,但是有 static 修饰,他的类型和所占空间已经确定,故可以放到 bss 段,而不是 common块)
关于static 静态全局变量的测试,如下图,无报错,两个 a 之间无冲突。(看看就行了)
类型三 模块间数据访问
模块内的情况可以通过相对寻址解决,模块间的数据访问比模块内部稍微麻烦一点,因为模块间的数据访问目标地址要等到装载时才决定。我们前面提到要使得代码地址无关,基本的思想就是把跟地址相关的部分放到数据段里面,很明显,这些其他模块的全局变量的地址是跟模块装载地址有关的。ELF的做法是在数据段里面建立一个指向这些变量的指针数组,也被称为全局偏移表(Global Offset Table,GOT),当代码需要引用该全局变量时,可以通过GOT中相对应的项间接引用,
从第二中类型的数据访问我们了解到,模块在编译时可以确定模块内部变量相对与当前指令的偏移,那么我们也可以在编译时确定GOT相对于当前指令的偏移。确定GOT的位置跟上面的访问变量a的方法基本一样,通过得到PC值然后加上一个偏移量,就可以得到GOT的位置。然后我们根据变量地址在GOT中的偏移就可以得到变量的地址,当然GOT中每个地址对应于哪个变量是由编译器决定的,比如第一个地址对应变量b,第二个对应变量c等。
查看汇编代码:要定位的位置是 0x3fd8
查看段表:got段虚拟地址刚好是0x3fd8
查看重定位表:他的值也刚好是 0x3fd8
类型四 模块间调用,跳转
和上面类型三类似,区别就是,GOT项保存的是目标函数的地址。
地址无关代码总结
使用 -fPIC
参数即可以产生地址无关代码。
地址无关代码技术除了可以用在共享对象上面,它也可以用于可执行文件,一个以地址无关方式编译的可执行文件被称作地址无关可执行文件(PIE, Position-Independent Executable)。与GCC的“-fPIC”和“-fpic”参数类似,产生PIE的参数为“-fPIE”或“-fpie”。
个人理解:感觉地址无关代码就是代码中不包含绝对地址引用。一个文件被多个进程共享,不管是可执行文件还是动态共享文件,都必须是地址无关的。如果只是自己单个进程用,是不是地址无关都无所谓,哪怕用了绝对地址,值也是对的三。✨
二刷:上面说的是对的,地址无关指的是将代码放到虚拟内存的任何地方都不需要修改代码(但是要修改.got数据段)。
共享模块的全局变量问题
有一种很特殊的情况是,当一个模块引用了一个定义在共享对象的全局变量的时候,比如一个共享对象定义了一个全局变量global,而模块module.c中是这么引用的:
1 | extern int global; |
当编译器编译 module.c
时,它无法根据这个上下文判断global是定义在同一个模块的的其他目标文件还是定义在另外一个共享对象之中,即无法判断是否为跨模块间的调用。(这两段内容还是很乱。。。就感觉书上思路很奇怪,明明主程序也是)
如果 module.c
是可执行程序的一部分的话,一般来说,他不是地址无关代码,不会使用 PIC的机制,(在装入前地址就是确定好了的,装入后不会重定位)。gloabl 可能在模块内,可能在模块外。(在链接完成可能都不会确定 global 的类型和空间,就不会像静态链接把他放到 common 块中仔细想想应该不是不能判断 gloabl 在模块内还是模块外的问题,因为链接可重定向文件时,能找到该符号就应该是在模块内,否则就在模块外,我感觉真正不能判断的是在模块外是否定义了 global 的问题,为了保险起见,会在本地创建一个副本),为了让链接过程进行,就在他的 .bss 段创建了一个 global 副本。既然在他的 段中有一个副本了,在其他段中也不能有相同的符号了(即使真的是在其他地方定义的),负责会冲突。解决方法:
如果 module.c 是共享对象文件的话,他需要默认定义在内部的全局变量当作定义在其他模块的全局变量(好惨😢):
一点点拓展:
数据段地址无关性
通过上面的方法,我们能够保证共享对象中的代码部分地址无关,但是数据部分是不是也有绝对地址引用的问题呢?让我们来看看这样一段代码:
1 | static int a; |
p 是一个绝对地址,而 a 的地址会随着装载地址变化而变化,如何解决呢:装载时重定位:装载时根据装载的地址去更改对绝对地址的引用,每个进程都有数据段副本,不用担心共享的问题,相比于 PIC 来说,少了一次地址跳转,会高效一点。
延迟绑定 PLT
静态数据也需要 GOT 定位吗。
实现方法
程序一开始运行时,很多函数不会立即用到乃至结束都不会用到,开始运行时就将所有模块链接的话太浪费资源。为此 ELF 采用了一种叫延迟绑定的方法,基本思想:当函数第一次被调用时才进行绑定(符号查找,重定位)。这种做法可以加快程序的启动速度。
plt基本原理
当我们调用某个外部模块的函数时,如果按照通常的做法应该是通过GOT中相应的项进行间接跳转。PLT为了实现延迟绑定,在这个过程中间又增加了一层间接跳转。调用函数并不直接通过GOT跳转,而是通过一个叫作PLT项的结构来进行跳转。(每次访问外部函数时,都会先到 plt 项的位置,第一次访问时,会将 got 项改成正确的值,之后访问时,就可以直接访问外部函数)每个外部函数在PLT中都有一个相应的项,比如bar()函数在PLT中的项的地址我们称之为bar@plt。让我们来看看bar@plt的实现:
1 | bar@plt: |
plt 实际实现方式
第一项保存的是“.dynamic”段的地址,这个段描述了本模块动态链接相关的信息,我们在后面还会介绍“.dynamic”段。
第二项保存的是本模块的ID。
第三项保存的是 _dl_runtime_resolve() 的地址。
其中第二项和第三项由动态链接器在装载共享模块的时候负责将它们初始化。“.got.plt”的其余项分别对应每个外部函数的引用。PLT的结构也与我们示例中的PLT稍有不同,为了减少代码的重复,ELF把上面例子中的最后两条指令放到PLT中的第一项。并且规定每一项的长度是16个字节,刚好用来存放3条指令,实际的PLT基本结构如图7-9所示。
1 | PLT0: |
PLT在ELF文件中以独立的段存放,段名通常叫做“.plt”,因为它本身是一些地址无关的代码,所以可以跟代码段等一起合并成同一个可读可执行的“Segment”被装载入内存。
综上,访问外部变量时用 .got
表,访问外部函数时用 .got.plt
表和 .plt
表,交互逻辑如上面所示
动态链接相关结构
在了解了共享对象的绝对地址引用问题以后,我们基本上对动态链接的原理有了初步的了解,接下来的问题就是整个动态链接具体的实现过程了。~~绝不绝望~~~~
我们在前面的章节已经看到,动态链接情况下,可执行文件的装载与静态链接情况基本一样。首先操作系统会读取可执行文件的头部,检查文件的合法性,然后从头部中的“Program Header”中读取每个“Segment”的虚拟地址、文件地址和属性,并将它们映射到进程虚拟空间的相应位置,这些步骤跟前面的静态链接情况下的装载基本无异。在静态链接情况下,操作系统接着就可以把控制权转交给可执行文件的入口地址,动态链接情况下,在映射完可执行文件之后,操作系统会先启动一个动态链接器(Dynamic Linker)。装载部分详见这里
在Linux下,动态链接器ld.so实际上是一个共享对象,操作系统同样通过映射的方式将它加载到进程的地址空间中。操作系统在加载完动态链接器之后(操作系统加载的动态链接器),就将控制权交给动态链接器的入口地址(与可执行文件一样,共享对象也有入口地址)。当动态链接器得到控制权之后,它开始执行一系列自身的初始化操作,然后根据当前的环境参数,开始对可执行文件进行动态链接工作。当所有动态链接工作完成以后,动态链接器会将控制权转交到可执行文件的入口地址,程序开始正式执行。
.interp
段
哪个是动态链接器呢,是不是一直都是 ld-2.31.so
呢,实际上,动态链接器的位置既不是由系统配置指定,也不是由环境参数决定,而是由ELF可执行文件决定。在动态链接的ELF可执行文件中,有一个专门的段叫做“.interp”段(“interp”是“interpreter”(解释器)的缩写)。
如图所示,该链接是软连接,最终会指向真正链接器。
.dynamic
段
动态链接ELF中最重要的结构应该是“.dynamic”段,这个段里面保存了动态链接器所需要的基本信息,比如依赖于哪些共享对象、动态链接符号表的位置、动态链接重定位表的位置、共享对象初始化代码的地址等。“.dynamic”段的结构很经典,就是我们已经碰到过的ELF中眼熟的结构数组,结构定义在“elf.h”中
1 | typedef struct { |
Elf32_Dyn结构由一个类型值加上一个附加的数值或指针,对于不同的类型,后面附加的数值或者指针有着不同的含义。我们这里列举几个比较常见的类型值(这些值都是定义在“elf.h”里面的宏)
还可以通过 ldd 命令查看用来查看一个程序主模块或一个共享库依赖于哪些共享库:
动态符号表
动态链接重定位表
动态链接重定位相关结构
1 | $ readelf -r Lib.so |
1 | $gcc -shared Lib.c -o Lib.so |
可以看到Lib.c中的两个导入函数“printf”和“sleep”从“.rel.plt”到了“.rel.dyn”,并且类型也从R_386_JUMP_SLOT变成了R_386_PC32。
而R_386_RELATIVE类型多出了一个偏移为0x0000042c的入口,这个入口是什么呢?通过对Lib.so的反汇编可以知道,这个入口是用来修正传给printf的第一个参数,即我们的字符串常量“Printing from Lib.so %d\n”的地址。为什么这个字符串常量的地址在PIC时不需要重定位而在非PIC时需要重定位呢?很明显,PIC时,这个字符串可以看作是普通的全局变量,它的地址是可以通过PIC中的相对当前指令的位置加上一个固定偏移计算出来的;而在非PIC中,代码段不再使用这种相对于当前指令的PIC方法,而是采用绝对地址寻址,所以它需要重定位。
动态链接时进程堆栈初始化信息
1 | typedef struct |
遗憾的是,下面的程序无法正常运行:(之所以用argv
计算地址是因为,argv 的地址才是堆栈中的地址,argc 只是一个临时值)
1 |
|
进程栈如下:
动态链接的步骤与实现
实际链接步骤:先启动动态链接器本身,然后装载所以需要的共享对象,最后是重定位和初始化。
动态链接器自举
装载共享对象
符号的优先级
如果两个模块定义了同一个符号怎么办?
实际上Linux下的动态链接器是这样处理的:它定义了一个规则,那就是当一个符号需要被加入全局符号表时,如果相同的符号名已经存在,则后加入的符号被忽略。
由于存在这种重名符号被直接忽略的问题,当程序使用大量共享对象时应该非常小心符号的重名问题,如果两个符号重名又执行不同的功能,那么程序运行时可能会将所有该符号名的引用解析到第一个被加入全局符号表的使用该符号名的符号,从而导致程序莫名其妙的错误。
经过实际测试,结果如下:
确实,链接器只会保留一个符号,且不会报错,默认保留的是前面的那个符号。为了使结果更有说服力(防止a2没有被装载),进行了下面测试。
1 | ttj@ttj ~/c/s/quanju> more a.c a1.c a2.c |
全局符号介入和地址无关代码
比如说两个共享文件,pic.c ,temp.c , temp.c 中有bar函数,他又先被装载,很可能发生这个问题。
这个问题看起来很严重,但其实是操作系统支持的一个特性,有意设计来让一个库中的符号覆盖另一个库中的符号:(两个动态链接库中有相同的符号一定要注意全局符号接入,及覆盖问题;动态链接库才会出现这个问题,普通的编译肯定会报重定义错误的)
from kimi
动态链接器的支持
动态链接器(如Linux的ld.so或macOS的dyld)被设计为支持全局符号介入。它能够识别介入的符号,并在解析过程中优先使用介入的符号。这种机制是动态链接器的一部分,因此它不会将介入视为错误。
1 | //cat a1.c |
因为全局符号介入,导致了一个模块内的函数调用,都得当作外部符号处理(这导致了A模块的a调用A模块的b,执行时可能调用的是B模块的b,)
重定位和初始化
感觉整个过程主要分为两步:第一是找出所有全局符号,第二是对所有位于重定向表中的符号进行重定向。
Linux 动态链接器实现
显式运行时链接
支持动态链接的系统往往都支持一种更加灵活的模块加载方式,叫做显式运行时链接(Explicit Run-time Linking),有时候也叫做运行时加载。也就是让程序自己在运行时控制加载指定的模块,并且可以在不需要该模块时将其卸载。
当程序需要用到某个插件或者驱动的时候,才将相应的模块装载进来,而不需要从一开始就将他们全部装载进来,从而减少了程序启动时间和内存使用。并且程序可以在运行的时候重新加载某个模块,这样使得程序本身不必重新启动而实现模块的增加、删除、更新等,
dlopen()
打开一个共享库,并将其加载到进程的地址空间,完成初始化过程,
1 | void * dlopen(const char *filename, int flag); |
第一个参数是被加载动态库的路径,
- 如果这个路径是绝对路径(以“/”开始的路径),则该函数将会尝试直接打开该动态库;
- 如果是相对路径,那么 dlopen() 会尝试在以一定的顺序去查找该动态库文件:
- 查找有环境变量LD_LIBRARY_PATH指定的一系列目录(我们在后面会详细介绍LD_LIBRARY_PATH环境变量)。
- 查找由/etc/ld.so.cache里面所指定的共享库路径。
- /lib、/usr/lib 注意:这个查找顺序与旧的a.out装载器的顺序刚好相反,旧的a.out的装载器在装载共享库的时候会先查找/usr/lib,然后是/lib。
很有意思的是,如果我们将filename这个参数设置为0,那么dlopen返回的将是全局符号表的句柄(不管filename 是不是 0,都会返回句柄,从而获得符号),也就是说我们可以在运行时找到全局符号表里面的任何一个符号,并且可以执行它们,这有些类似高级语言反射(Reflection)的特性。全局符号表包括了程序的可执行文件本身、被动态链接器加载到进程中的所有共享模块以及在运行时通过dlopen打开并且使用了RTLD_GLOBAL方式的模块中的符号。
查看 man 中关于 RTLD_NOW 的解释
dlsym()
dlsym函数基本上是运行时装载的核心部分,我们可以通过这个函数找到所需要的符号。它的定义如下:
1 | void * dlsym(void *handle, char *symbol); |
定义非常简洁,两个参数,第一个参数是由 dlopen()返回的动态库的句柄;第二个参数即所要查找的符号的名字,一个以“ \0 ”结尾的C字符串。
如果 dlsym() 找到了相应的符号,则返回该符号的值;
没有找到相应的符号,则返回NULL。
dlsym() 返回的值对于不同类型的符号,意义是不同的。如果查找的符号是个函数,那么它返回函数的地址;如果是个变量,它返回变量的地址;如果这个符号是个常量,那么它返回的是该常量的值。这里有一个问题是:如果常量的值刚好是NULL或者0呢,我们如何判断 dlsym() 是否找到了该符号呢?这就要用到我们下面介绍的 dlerror()函数了。如果符号找到了,那么 dlerror() 返回NULL,如果没找到,dlerror() 就会返回相应的错误信息。
符号优先级
dlerror
dlclose
dlclose()的作用跟dlopen()刚好相反,它的作用是将一个已经加载的模块卸载。系统会维持一个加载引用计数器,每次使用dlopen()加载某模块时,相应的计数器加一;每次使用dlclose()卸载某模块时,相应计数器减一。只有当计数器值减到0时,模块才被真正地卸载掉。卸载的过程跟加载刚好相反,先执行“.finit”段的代码,然后将相应的符号从符号表中去除,取消进程空间跟模块的映射关系,然后关闭模块文件。
运行时装载的演示程序
1 |
|
个人的一点理解:
主要目的就是让程序加载到任何地址时,都不需要改变代码段对地址的引用。
本来,代码段和数据段都包含对绝对地址的引用,通过 got 表让代码段不需要引用绝对地址了,这些 重定向的表都放到了数据段。分为 .got .got.plt 。rel.dyn(变量) rel.plt(pic文件的函数)表示了哪些地址需要改变(代表了重定位的入口),使用哪种方式改变。