【链接装载与库】静态链接

静态链接

当我们有两个目标文件时,如何将它们链接起来形 成一个可执行文件?这个过程中发生了什么?这基本上就是链接的核心内容:静态链接。
我们将使用下面这两个源代码文件“a.c”和 “b.c”作为例子展开分析

a.c

extern int shared;

int main(){
    int a = 100;
    swap(&a , &shared);
}

b.c

int shared = 1;

void swap(int *a , int* b){
    *a ^=*b ^= *a ^=*b;
}

首先我们使用gcc将“a.c”和“b.c” 分别编译成目标文件“a.o”和 “b.o”

gcc -c a.c b.c -fno-stack-protector

空间与地址分配

对 于多个输入目标文件,链接器如何将它们的各个段合并到输出文件?或者说,输出文件中的 空间如何分配给输入文件?

相似段合并

实际的方法是将相同性质的段合并到一起,比如将所有输入文件的“.text”合并 到输出文件的“.text”段,接着是“.data”段、“.bss” 段等

在这里插入图片描述

".bss"段在目标文件和可执行文件中并不占用文件的空间,但 是它在装载时占用地址空间。所以链接器在合并各个段的同时,也将“.bss”合并,并且分 配虚拟空间

“链接器为目标文件分配地址和空间”这句话中的“地址和空间”其实有两个含义
:第 一个是在输出的可执行文件中的空间;第二个是在装载后的虚拟地址中的虚拟地址空间。比如“.text”和“ .data”来说,它们在文件中和虚拟地址中都要分配空 间,因为它们在这两者中都存在;而对于“.bss”这样的段来说,分配空间的意义只局限于 虚拟地址空间

链接器一般都采用一种叫两步链接的方法。也就是说整个链接过程分两步

  • 第一步空间与地址分配
    扫描所有的输入目标文件,并且获得它们的各个段的长度、属性和位置,并且将输入目标文件中的符号表中所有的符号定义和符号引用收集起来,统一 放到一个全局符号表。这一步中,链接器将能够获得所有输入目标文件的段长度,并且将它们合并,计算出输出文件中各个段合并后的长度与位置,并建立映射关系。
  • 第二步符号解析与重定位
    使用上面第一步中收集到的所有信息,读取输入文件中段 的数据、重定位信息,并且进行符号解析与重定位、调整代码中的地址等。事实上第二步是链接过程的核心,特别是重定位过程。

我们使用ld链接器将“a.o”和“b.o”链接起来:

ld a.o b.o -e main -o ab

编译源码到目标文件时(gcc -c),一定要加“-fno-stack-protector”,不然默认会调函数“__stack_chk_fail”进行栈相关检查,然而是手动裸ld去链接,没有链接到“__stack_chk_fail”所在库文件,所以在链接过程一定会报错: undefined reference to `__stack_chk_fail’。解决办法不是在链接过程中,而是在编译时加此参数,强制gcc不进行栈检查,从而解决。此外,ld 的时候加上参数"-e main"就可以了,意思是将main函数作为程序入口,ld 的默认程序入口为_start,-o ab 表示链接输出文件名为ab,

我们使用objdump 来查看链接前后地址的分配情况

在这里插入图片描述
在这里插入图片描述
在这里插入图片描述
在这里插入图片描述

VMA 表示Virtual Memory Address,即虚拟地址,LMA表示 Load Memory Address,即加载地址

  • 我们关心上面各个段中的VMA和 Size, 而忽略文件偏移
    在链接之前,目标文件中的所有段的VMA 都是0,因为虚拟空间还没有被分配,等到链接之后,可执行文件 “ab” 中的各个段都被分配到了相应的虚拟地址。(在Linux下,ELF可执行文件默认从地址0x08048000开始分配)

在这里插入图片描述

符号地址的确定

在第 一步的扫描和空间分配阶段,链接器按照前面介绍的空间分配方法进行分配,这时候输入文件中的各个段在链接后的虚拟地址就已经确定了。当前面一步完成之后,链接器开始计算各个符号的虚拟地址,只不过链接器须要给每个符号加上一个偏移量,使它们能够调整到正确的虚拟地址。

符号解析与重定位

重定位

在完成空间和地址的分配步骤以后,链接器就进入了符号解析与重定位的步骤,这也是 静态链接的核心内容
使用 objdump 的“-d”参数可以看到“a.o”的代码段反汇编结果

objdump -d a.o

在这里插入图片描述

当源代码“a.c”在被编译成目标文件时,编译器并不知道 “shared”和 “swap”的地址, 因为它们定义在其他目标文件中。所以编译器就暂时把地址0看作是“shared”的地址,我们可以看到这条 “mov” 指令中,关于 “shared”的地址部分为“0x00000000”

在这里插入图片描述

另外一个是偏移为0x26 的指令的一条调用指令,它其实就表示对 swap 函数的调用

在这里插入图片描述

这条指令共5个字节,前面的0xE8 是操作码,它指令是一条近址相对位移调用指令后面4个字节就是被调用函数的相对于调用指令的下一条指 令的偏移量。在没有重定位之前,相对偏移被置为0xFFFFFFFC (小端),它是常量“ -4” 的补码形式
紧跟在这条 call 指令后面的那条指令为 add 指令,add指令的地址为0x2b, 而相对于add 指令偏移为“-4”的地址即0x2b-4=0x27.所以这条
call指令的实际调用地址为0x27。我们可以看到0x27 存放着并不是 swap 函数的地址,跟前 面 “shared”一样,“0xFFFFFFFC” 只是一个临时的假地址,因为在编译的时候,编译器并 不知道 “swap” 的真正地址。

编译器把把真正的地址计算工作留给了链接器链接器在 完成地址和空间分配之后就已经可以确定所有符号的虚拟地址了,那么链接器就可以根据符号的地址对每个需要重定位的指令进行地位修正。我们用objdump 来反汇编输出程序 “ab” 的代码段,可以看到main 函数的两个重定位入口都已经被修正到正确的位置

objdump -d ab

在这里插入图片描述

重定位表

那么链接器是怎么知道哪些指令是要被调整的呢?在ELF文件中,有一个叫重定位表的结构专门用来保存这些与重定位相关的信息

对于可重定位的ELF文件来说,它必须包含有重定位表,用来描述如何修改相应的段 里的内容。对于每个要被重定位的ELF段都有一个对应的重定位表,而一个重定位表往往 就是ELF文件中的一个段,所以其实重定位表也可以叫重定位段。比如代码段“ .text” 如有要被重定位的地方,那么会有一个相对应叫“ rel.text”的段保存了代码段的重定位表

我们可以使用 objdump 来查看目标文件的重定位表

objdump -r a.o

在这里插入图片描述
在这里插入图片描述

这个命令可以用来查看“a.o”里面要重定位的地方,即“a.o”所有引用到外部符号的地址。每个要被重定位的地方叫一个重定位入口, 我们可以看到“a.o” 里面有两个重定位入口。重定位入口的偏移表示该入口在要被重定位的段中的位置 ,RELOCATION RECORDS FOR [.text] 表示这个重定位表是代码段的重定位表

符号解析

其实重定位过程也伴随着符号的解析过程。重定位的过程中,每个重定位的入口都是对一个 符号的引用。当链接器须要对某个符号的引用进行重定位时,它就要确定这个符号的目 标地址。这时候链接器就会去查找由所有输入目标文件的符号表组成的全局符号表,找到相 应的符号后进行重定位。

查看“a.o”的符号表

readelf -s a.o

在这里插入图片描述

“GLOBAL” 类型的符号,除了 “main”函数是定义在代码段之外,其他两个 “shared” 和 “swap”都是 “UND”, 即 “undefined”未定义类型,这种未定义的符号都是因为该目标 文件中有关于它们的重定位项。所以在链接器扫描完所有的输入目标文件之后,所有这些未定义的符号都应该能够在全局符号表中找到,否则链接器就报符号未定义错误。

COMMON块

于弱符号机制允许同一个符号的定义存在于多个文件中。如果一个弱符号定义在多个目标文件中,而它们的类型又不同,怎么办?
主要分三种情况

  • 两个或两个以上强符号类型不一致
  • 有一个强符号,其他都是弱符号,出现类型不一致
  • 两个或两个以上弱符号类型不一致

第一种情况是无须额外处理的,因为多个强符号定义本身就是非法的,链接器会报符号多重定义错误;链接器要处理的就是后两种情况

  • 现在的编译器和链接器都支持一种叫 COMMON块的机制
    按照COMMON类型的链接规则,原则上讲最终链接后输出文件中弱符号的大小以输入文件中最大的那个为准。

当然 COMMON类型的链接规则是针对符号都是弱符号的情况,如果其中有一个符号为强符号,那么最终输出结果中的符号所占空间与强符号相同。值得注意的是,如果链接过程中有弱符号大小大于强符号,那么ld链接器会报警告

C++相关

重复代码消除

C++ 编译器在很多时候会产生重复的代码,比如模板、外部内联函数和虚函数表。当一个模板在多个 编译单元同时实例化成相同的类型的时候,必然会生成重复的代码

  • 一个比较有效的做法就是将每个模板的实例代码都单独地存放在一个段里,每个段只包含一个模板实例

全局构造与析构

程序的一些特定的操作必须在main 函数之前被执行,还有一些操作必须在 main 函数之后被执行,其中很具有代表性的就是C++ 的全局对象的构造和析构函数。因 此ELF 文件还定义了两种特殊的段。

  • .init该段里面保存的是可执行指令,它构成了进程的初始化代码。因此,当一个程序开 始运行时,在main 函数被调用之前, Glibc的初始化部分安排执行这个段的中的代码。
  • .fini该段保存着进程终止代码指令。因此,当一个程序的 main 函数正常退出时, Glibc 会安排执行这个段中的代码。

静态库链接

  • 一个程序如何做到输入输出呢?
    最简单的办法是使用操作系统提供的应用程序编程接口
    标准库的“printf”函数来输出一个字符串,“printf”函数对字符串进行一些必要的处理以后,最后会调用操作系统提供的API。 各个操作系统下,往终端输出字符串的 API 都不一样,在 Linux 下,它是一个 “write”的系统调用

库里面还带有那些很常用的函数。一个静态库可以简单地看成一组目标文件的集合。通常人们使用“ar”压缩程序将这些目标文件压缩到一起,并且对其进行编号和索引,以便于查找和检索,就形成了libc.a 这个静态库文件,我们也可以使用 “ar”工具来查看这个文件包含了哪些目标文件

ar -t libc.a

在这里插入图片描述

当我们编译和链接一个普通C程序的时候,不仅要用到C语言库libc.a, 而且还有其他一些辅助性质的目标文件和库

关键的三个步骤,第一步是调用cc1 程序,这个程序实际上 就是GCC的C语言编译器,它将“hello.c”编译成一个临时的汇编文件“/tmp/ccUhtGSB.s”: 然后调用as程序,as程序是GNU的汇编器,它将“/tmp/ccUhtGSB.s” 汇编成临时目标文 件“/tmp/ccQZRPL5.o”, 这个“/tmp/ccQZRPL5.0” 实际上就是前面的“hello.o”; 接着最关 键的步骤是最后一步,GCC调用collect2程序来完成最后的链接。(实际上 collect2 可以看作是 ld 链接器的一个包装,它会调用 ld 链接器来完成对目标文 件的链接,然后再对链接结果进行一些处理)

链接控制过程

我们在使用ld链接器的时候,没有指定链接脚本,其实ld 在用户没有指定链接脚 本的时候会使用默认链接脚本。我们可以使用下面的命令行来查看ld 默认的链接脚本

ld -verbose

默认的ld链接脚本存放在/usr/lib/ldscripts/下
链接控制脚本“程序”使用一种特殊的语言写成,即ld的链接脚本语言


  • 1
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值