彻底搞懂C++链接
链接器
将编译器产生的目标文件打包成可执行文件或者库文件。
符号决议
确保所有目标文件中的符号引用都有唯一的定义。
函数私有的局部变量被放在了代码段中,作为机器指令的操作数。
编译器在遇到外部定义的全局变量或者函数时只要能在当前文件找到其声明,编译器就认为编译正确。而寻找使用变量定义的这项任务就被留给了链接器。链接器的其中一项任务就是要确定所使用的变量要有其唯一的定义。
为了让链接器工作的轻松一点编译器还是多做了一点工作的,这部分工作就是符号表(Symbol table)。
符号表中的内容:
- 该目标文件中引用的全局变量以及函数;
- 该目标文件中定义的全局变量以及函数;
算法:
- 链接器解析目标文件集合,维护3个集合E(可执行文件)、D(定义的符号)、U(未定义的符号)。
- 一个一个的解析,比如如果目标程序用到了目标模块A中的函数,那么将目标模块A加入到E中,然后更新D和U。
- 最后如果U中还有符号,那么就会报链接错误。
库和可执行文件的生成
静态库:
由一堆目标文件打包而成, 使用者只需要使用其中的函数而无需关注该函数来自哪个目标文件(找到函数实现所在的目标文件是链接器来完成的,从这里也可以看出,不是所有静态库中的目标文件都会用到,而是用到哪个链接器就链接哪个)。静态库极大方便了对其它团队所写代码的使用。
静态库是使用库的最简单的方法,如果你想使用别人的代码,找到这些代码的静态库并简单的和你的程序链接就可以了。
动态库:
动态库允许使用该库的可执行文件仅仅包含对动态库的引用而无需将该库拷贝到可执行文件当中。
动态链接:
- 加载时动态链接
- 加载理解为程序从磁盘复制到内存的过程。
- 当把可执行文件复制到内存后,且在程序开始运行之前,操作系统会查找可执行文件依赖的动态库信息(主要是动态库的名字以及存放路径),找到该动态库后就将该动态库从磁盘搬到内存,并进行符号决议,如果这个过程没有问题,那么一切准备工作就绪,程序就可以开始执行了,如果找不到相应的动态库或者符号决议失败,那么会有相应的错误信息报告为用户,程序运行失败。
- 概括为两个阶段:
- 阶段一,将动态库信息写入可执行文件;
- 阶段二:加载可执行文件时依据动态库信息进行动态链接
- 运行时动态链接;
- 在可执行文件被启动运行之前,可执行文件对所依赖的动态库信息一无所知,只有当程序运行到需要调用动态库所提供的代码时才会启动动态链接过程。
- 在Linux下通过使用dlopen、dlsym、dlclose这样一组函数在运行时链接动态库。当这些API被调用后,同样是首先去找这些动态库,将其从磁盘copy到内存,然后查找程序依赖的函数是否在动态库中定义。这些过程完成后动态库中的代码就可以被正常使用了。
在动态链接下,可执行文件当中会新增两段,即dynamic段以及GOT(Global offset table)段。
- dynamic段中保存了可执行文件依赖哪些动态库,动态链接符号表的位置以及重定位表的位置等信息。
- GOT段保存了全局变量和库中定义的函数。
动态库和静态库对比
- 动态库优点:
- 共享代码段,节省内存空间;
- 方便实现插件;
- 方便Bug修改,只需要替换动态库,不需要重新编译程序;
- 动态库缺点:
- 加载时动态链接会导致程序速度变慢;
- Linux下有PLT + GOT实现延迟绑定,用到库中的函数时才会去进行符号解析和重定位。
- 动态链接下的可执行文件不可以被独立运行;
- 加载时动态链接会导致程序速度变慢;
- 静态库优点:
- 使用简单;
- 静态库缺点:
- 可执行文件过大;
重定位
程序的运行过程就是CPU不断的从内存中取出指令然后执行执行的过程。
对于函数调用来说比如我们在C/C++语言中调用简单的加法函数add,其对应的汇编指令可能是这样的:
call 0x4004fd
其中0x4004fd即为函数add在内存中的地址,当CPU执行这条语句的时候就会跳转到0x4004fd这个位置开始执行函数add对应的机器指令。
我们在C语言中对一个全局变量g_num不断加一来进行计数,其对应的汇编指令可能是这样的:
mov 0x400fda %eax
add $0x1 %eax
把内存中 0x400fda 这个地址的数据放到寄存器当中,然后将寄存器中的数据加一,在这里g_num这个全局变量的内存地址就是0x400fda。
可执行文件中代码以及数据的运行时内存地址是链接器指定的,也就是上面示例中add的内存地址0x4004fd其是链接器指定的。确定程序运行时地址的过程就是这里重定位(Relocation)。
为什么这个过程叫做重定位呢,之所以叫做重定位是因为确定可执行文件中代码和数据的运行时地址是分为两个阶段的,在第一个阶段中无法确定这些地址,只有在第二个阶段才可以确定,因此就叫做重定位。
编译器的工作
编译器在将源文件编译生成目标文件时可以确定一下两件事:
- 定义在该源文件中函数的内存地址;
- 定义在该源文件中全局变量的内存地址;
注意这里的内存地址其实只是相对地址,相对于谁的呢,相对于自己的。
为什么只是一个相对地址呢?
因为在生成一个目标文件时编译器并不知道这个目标文件要和哪些目标文件进行链接生成最后的可执行文件,而链接器是知道要链接哪些目标文件的。因此编译器仅仅生成一个相对地址。
而对于引用类的变量,也就是在当前代码中引用而定义是在其它源文件中的变量,对于这样的变量编译器是无法确定其内存地址的,这不是编译器需要关心的,确定引用类变量的内存地址是链接器的任务,链接器在进行链接时能够确定这类变量的内存地址。因此当编译器在遇到这样的变量时,比如使用了外部定义的函数时,其在目标文件中对应的机器指令可能是这样的:
call 0x000000
也就是说对于编译器不能确定的地址都设置为空(0x000000),同时编译器还会生成一条记录,该记录告诉链接器在进行链接时要修正这条指令中函数的内存地址,这个记录就放在了目标文件的.rel.text段中。相应的如果是对外部定义的全局变量的使用,则该记录放在了目标文件的.rel.data段中。即链接器需要在链接过程中根据.rel.data以及.rel.text来填好编译器留下的空白位置。
生成目标文件后,编译器完成任务,编译器确定了定义在该源文件中函数以及全局变量的相对地址。对于编译器不能确定的引用类变量,编译器在目标文件的.rel.text以及.rel.data段中生成相应的记录告诉链接器要修正这些变量的地址。
此时目标文件的大致模样:
- 数据段;
- 代码段;
- 符号表;
- .rel.data;
- .rel.text;
- .dynamic(动态库相关);
接下来就是链接器的工作。
链接器的工作
两个阶段:
-
阶段一:重定位节和符号定义;
-
阶段二:重定位符号引用;
阶段一:重定位节和符号定义
链接器会将所有的目标文件进行合并,所有目标文件的数据段合并到可执行文件的数据段,所有目标文件的代码段合并到可执行文件的代码段。当所有合并完成后,各个目标文件中的相对地址也就确定了。因此在这个阶段,链接器需要修正目标文件中的相对地址。
当所有目标文件的同类型段合并完毕后,数据段和代码段中的相对地址都被链接器修正为最终的内存位置,这样所有的变量以及函数都确定了其各自位置。
至此,重定位的第一阶段完成。接下来是重定位的第二阶段,即引用符号的重定位。
阶段二:重定义符号引用
相对地址是编译器在编译过程中确定了,在链接器完成后被链接器修正为最终地址,而对于编译器没有确定的所引用的外部函数以及变量的地址,编译器将其记录在了.rel.text和.rel.data中。
我们知道编译器引用外部变量时将机器指令中的引用地址设置为空(比如call 0x000000),并将该信息记录在了目标文件的.rel.text以及.rel.data段中。因此在这个阶段链接器依次扫描所有的.rel.text以及.rel.data段并找到相应变量的最终地址(这些位置都已在第一阶段确定),并将机器指令中的0x000000修正为所引用变量的最终地址就可以了。
GOT + PLT 实现位置无关代码。
GOT + PLT的精华文章:https://blog.youkuaiyun.com/linyt/article/details/51635768