静态链接
//a.c
#include"b.h"
extern int shared;
int main()
{
int a = 100;
swap(&a,&shared);
}
//b.c
#include"b.h"
int shared = 1;
void swap(int* a,int* b)
{
*a ^= *b ^= *a ^= *b;
}
//gcc -c a.c b.c
//得到a.o b.o
//如何将a.o和b.o链接在一起形成一个可执行文件
空间与地址分配
地址和空间的两种含义
- 在输出的可执行文件中的空间
- 在装载后的虚拟地址中的虚拟空间
对于有实际数据的段,比如.text
,它们在文件中和虚拟地址中都要分配空间,因为在两者中都存在
对于没有实际数据的段,比如.bss
,分配空间的意义只局限于虚拟地址空间,因为在文件中没有内容
可执行文件中的代码段和数据段都是由输入的目标文件合并得到的。那么链接器如何将它们的各个段合并到输出文件以及输出文件中的空间如何分配给输入文件
按序叠加
将输入的目标文件按照次序叠加起来
如果一个规模稍大的应用程序可能会有数百个目标文件,如果每个目标文件都分别有.text、.data、.bss
段,那么最后的输出文件会有成百上千个零散的段,会造成空间的浪费
因为每个段都需要有一定的地址和空间对齐要求,比如x86的硬件,段的装载地址和空间的对齐单位是页,即4096字节,也就是说如果一个段的长度只有1个字节,那么它在内存中也需要占用4096个字节,会造成内存空间大量的内存碎片
相似段合并
将相同性质的段合并到一起
现在的链接起空间分配的策略基本都采用该中方法,使用这种方法的链接器一般都采用一种叫两步链接的方法
- 空间和地址分配:扫描所有的输入目标文件,获得它们的各个段的长度、属性和位置,将它们合并,计算出输出文件中各个段合并后的长度和位置,建立映射光系。并且将输入目标文件中的符号表中所有的符号定义和符号引用都收集起来,统一放到一个全局符号表
- 符号解析和重定位(核心):使用第一步中收集到的所有信息,读取输入文件中段的数据、重定位信息,并且进行符号解析和重定位、调整代码中的地址
//使用链接器将a.o和b.o链接起来
//ld a.o b.o -e main -o ab
//-e main 表示将main函数作为程序入口,ld链接器默认的程序入口为_start
//-o ab 表示链接输出文件名为ab,默认为ab.out
//使用objdump查看链接前后地址分配情况
链接之前a.o、b.o
目标文件中所有的段都的VMA都是0,因为虚拟空间还没有被分配
链接之后ab
中各个段都被分配到了相应的虚拟地址
在Linux下,ELF可执行文件默认从地址0x08048000开始分配
符号地址的确定
链接起分配好空间和地址后,就开始计算各个符号的虚拟地址。链接器需要给每个符号加上一个偏移量,使它们调整到正确的虚拟地址
比如a.o的main函数相对于a.o的.text段的偏移是X,但是经过链接合并后,a.o的.text段位于虚拟地址0x08048094,那么main函数的地址应该是0x08088094+X
符号解析和重定位
重定位
//使用objdump -d a.o查看a.c编译成指令是如何访问shared变量和调用swap函数
重定位表
用于保存与重定位相关的信息
每一个需要被重定位的段都有一个对应的重定位表
每一个需要被重定位的地方叫一个重定位入口
重定位入口的偏移表示该入口在要被重定位的段中的位置
objdump -r a.o
//用于查看a.o中需要重定位的地方
重定位表是一个结构体数组,每个数组元素对应一个重定位入口
typedef struct {
Elf32_Addr r_offset;
Elf32_Word r_info;
}Elf32_Rel;
符号解析
readelf -s a.o
//查看a.o的符号表
未定义类型的符号是因为该目标文件中有关于它们的重定位项,所以在链接器扫描所有输入文件后,这些未定义的符号都应该能够在全局符号表中找到,否则链接器就报符号未定义错误
指令修正方式
COMMON块
弱符号机制允许同一个符号的定义存在于多个文件,但是如果一个弱符号定义在多个目标文件中,但类型不同怎么办?目前的链接器本身不支持符号的类型,即变量类型对于链接器来说是透明的,只知道一个符号的名字,但不知类型是否一致。那么当定义的多个符号类型不一致时,链接器怎么处理
- 两个或两个以上强符号类型不一致
无需额外处理,因为强符号定义本身就是非法的,链接器会报符号多重定义错误
- 有一个强符号,其他都是弱符号,出现类型不一致
- 两个或两个以上弱符号类型不一致
为弱符号类型分配空间时以弱符号类型最大的那个为准,如果有一个符号是强符号,那么最终输出结果中的符号所占空间与强符号相同
如果弱符号大于强符号,ld链接器会报警告
C++相关问题
C++的一些语言特性使之必须由编译器喝链接器共同支持才能完成工作。最主要的有两个方面:C++重复代码清除和全局构造和析构。另外由于C++语言的各种特性使其背后的数据结构异常复杂,这些数据结构往往在不同的编译器和链接器之间不能通用,使C++程序的二进制兼容成为一个问题
重复代码消除
C++编译器在很多时候会产生重复的代码,比如模、外部内联函数和虚函数表都可能在不同的编译单元中生成相同的代码
- 空间浪费
- 地址易出错。有可能两个指向同一个函数的指针会不相等
- 指令运行效率低。现代CPU都会对指令和数据进行缓存,如果同样一份指令有多份副本,那么指令Cache的命中率会降低‘
将每个模板的实例代码都单独放在一个段,每个段只包含一个模板实例。例如add<T>()
的int和float模板实例add<int>()
和add<float>()
分别在.temp.add<int>
段和.temp.add<float>
段中。这样当别的编译单元也已int和float类型实例化该模板函数后,也会生成同样的名字,这样链接器在最终链接的时候可以区分这些相同的模板实例段,然后将它们合并入最后的代码段中
问题:相同名称的段可能拥有不同的内容,可能由于不同的编译单元使用了不同的编译器版本或者编译优化选项,导致同一个函数编译处理的实际代码有所不同。这种情况下链接器可能会做出一个选择,就是随意选择其中任何一个副本作为链接的输入,然后同时提供一个警告信息
函数级别链接
全局构造和析构
C++的全局对象的构造函数在main之前被指向
Linux系统下一般程序的入口是_start
,这个函数是Linux系统库(Glibc)的一部分,当程序和Glibc库链接在一起形成最终可执行文件以后,这个函数就是程序的初始化部分的入口,程序初始化部分完成一系列初始化过程后,会调用main函数指向程序的主体,在main函数执行完成之后,回到初始化部分,它进行一些群里工作,然后结束进程
实现
ELF文件中有两个特殊的段
.init
:该段中保存的是可执行指令,构成了进程的初始化代码。当一个程序开始运行时,在main函数被调用跟之前,Glibc的初始化部分安排执行这个段中的代码
.fini
:该段保存着进程终止代码的指令,当一个程序的main函数正常退出时,Glibc会安排执行这个段中的代码
C++和ABI
如果要使两个编译器编译出来的目标文件能够相互理解,那么这两个目标文件必须满足一下条件:采用同样的目标文件格式、拥有同样的符号修饰标准、变量的内存分布方式相同、函数的调用方式相同等
将符号修饰标准、变量内存布局、函数调用方式等这些与可执行代码二进制兼容性相关的内容称为ABI
链接过程控制
链接控制脚本
链接器一般都提供多种控制整个链接过程的方法,以用来产生用户所需要的文件
- 使用命令行给链接器指定参数
- 将链接指令存放在目标文件中,编译器经常通过该方法想链接器传递指令
- 使用链接控制脚本
当使用ld链接器没有指定链接脚本时,链接器会使用默认链接脚本
ld -verbose
//查看ld默认的链接脚本
默认的ld链接脚本存放在/usr/lib/ldscrips/
下
为了更加精确控制连接过程,可以自己写一个脚本,指定该脚本为连接控制脚本
ld -T link.script
将链接指令存放在目标文件中,编译器经常通过该方法想链接器传递指令
- 使用链接控制脚本
当使用ld链接器没有指定链接脚本时,链接器会使用默认链接脚本
ld -verbose
//查看ld默认的链接脚本
默认的ld链接脚本存放在/usr/lib/ldscrips/
下
为了更加精确控制连接过程,可以自己写一个脚本,指定该脚本为连接控制脚本
ld -T link.script