链接器 基础

 

有时能学到知识,却学不到工夫。-- 钟云龙


Basic: https://blog.csdn.net/qq_35865125/article/details/105214201


总览

在编译系统中,链接器扮演类似“胶水”的角色。它把汇编器处理生成的 可重定位目标文件 黏合、拼接为一个可执行的ELF文件。然而,链接器并非机械地拼接目标文件,它还需要完成汇编阶段无法完成的 段地址分配、符号地址计算 以及 数据/指令内容修正的工作。

这三个主要任务涉及了链接器工作的核心流程:地址空间分配、符号解析 和 重定位。


在可重定位目标文件的section header table的各个表项中,段的虚拟地址都是默认设为0。这是因为在汇编器处理阶段,是不可能知道段的加载地址的。链接器的地址空间分配操作的主要目的是为段指定加载地址(即确定目标文件中的各个section放到可执行文件内的哪个位置)。

 

在确定了section的加载地址(简称段基址)后,根据目标文件内符号的section内偏移地址,可以计算得到符号在可执行文件内的地址(简称符号地址, 例如定义的函数的地址)。      链接器的符号解析操作并不止于计算符号地址,它还需要分析目标文件之间的符号引用的情况,计算目标文件内引用的外部符号的地址。

 

符号解析之后,所有目标文件的符号地址(e.g:在可执行文件内的地址)都已经确定。链接器通过重定位操作,修正代码段或数据段内引用的符号地址 (eg.代码段有call printf, 需要将printf修改成该函数的地址)

 

最后,链接器将以上操作处理后的文件信息导出为可执行ELF文件,完成链接的工作。

 

信息收集

对 链接器 来说, 其 输入是一 系列 的 可重定 位 目标 文件。 链接 器 欲 完成 后续 的 工作, 必须 逐个 扫描 目标 文件, 提取 需要 的 信息 进行 处理。

链接器需要分析目标文件内符号的引用情况。 之所以要分析 符号 的 引用 信息, 是因为 在 链接 器 处理 的某个目标 文件 中, 存在未定义 的符号, 即对其他目标文件符号的引用。   为了 方便 链接 器 符号 解析 的 处理, 一般 会 定义 两个 符号 集合: 一个 是 导出 符号 集合, 表示 所有 目标 文件 内 定义 的 可以 被 其他 目标 引用 的 全局 符号 集合; 另一个 是 导入符号 集合, 表示 目标文件自己内部未定义, 需要引用 其他目标文件的符号集合。

 

地址空间分配

汇编器生成目标文件时,由于无法确定段的加载地址,因此默认将段基址记为0。链接器的第一步工作便是确定需要加载段的段基址,为待加载段指定段基址的过程称为地址空间分配。

链接器为段指定基址,需要从三个方面进行考虑。

1)段加载的起始地址。

      该地址是所有加载段的起始位置,在32位Linux系统中,一般设置为0x08048000。

2)段的拼接顺序。

     链接器按序扫描各个目标文件内同名的段,并将段的二进制数据依次“摆放”。

3)段对齐方式。

      段对齐包含两个层面:段文件偏移的对齐和段基址的对齐。

在可重定位的目标文件内,一般将段的文件偏移对齐设置为4字节,不考虑段基址的对齐(段基址都是0,没有对齐的意义)。

而在可执行文件内,会将代码段“.text”的文件偏移对齐设置为16字节,其他段的文件偏移对齐方式仍默认为4字节。     而段基址的对齐则比较复杂,需要保证段的线性地址与段对应文件偏移相对于段对齐值(即页面大小,Linux下默认为4096字节)取模相等。

(Program header table内的段对齐字段p_align:  p_ align 表示 段 对齐 方式, 对齐 规则 为 p_ vaddr% p_ align= 0, 即 段 的 线性 地址 必须 是 p_ align 的 整 数倍。 一般 情况下, p_ align 取值 为 0x1000= 4096, 即 Linux 操作系统 默认的页大小)。

 

下图给出了一个地址空间分配的例子。目标文件a.o的代码段大小为0x4a字节,数据段大小为0x08字节,b.o的代码段大小为0x21字节,数据段大小为0x04字节。

 

--可执行文件内不需要section header table,这个只在object文件内需要。

 

符号解析

目标文件符号表内保存了每个定义的符号相对于所在段基址的偏移,当段的地址空间分配结束后每个段的基址都被确定下来,因此符号地址可以使用如下公式计算:

符号地址 = 段基址 + 符号相对段基址的偏移

不过在计算符号地址之前,仍需要做一些准备工作。

首先需要扫描目标文件内的符号表,获取符号的定义与引用的信息,即上文描述的导出符号集合和导入符号集合。

其次,需要对导入符号集合和导出符号集合进行合法性验证。符号验证包含两个方面:

1)符号重定义:即导出符号集合存在同名的符号。由于目标文件链接时,对符号的处理是按名检索的方式,符号重定义将导致引用该符号的文件无法确定应该具体使用哪个符号。

2)符号未定义:即导入符号集合包含导出集合不存在的符号。当目标文件引用的外部符号在其他目标文件内找不到对应的定义时,就无法确定符号的地址。一旦出现符号重定义或未定义的情况,链接器的工作就无法继续进行。

 

Note:

目标 文件 和 可执行 文件 有一个 很大 的 区别: 目标 文件 的 文件 头 的 程序 入口 点 e_ entry 字段 为 0, 而可 执行 文件 的 程序 入口 点 是一 个 线性 地址。 我们 需要 先 假定 程序 的 入口 地址 被 记录 到 一个 名为“@ start” 的 符号 内, 显然 这个 符号 不可能 是 编译器 生成 的 符号 名。 为了 保证 链接 器 可以 找到 程序 入口 点, 那么 符号 引用 验证 阶段 必须 强制 要求 导出“@ start” 符号。 至于“@ start” 符号 的 提供者, 可以 暂时 认为 来源于 一个 已有 的 目标 文件。

 

一般来说, 符号地址解析分为两个步骤:

1) 扫描所有 ELF 目标文件 的 本地 符号, 计算 本地 符号 的 地址。

2) 扫描 所有 导入集合的符号(即 某个文件需要使用其他目标文件定义的符号), 将 符号地址 传递 到 引用 该 符号 的 目标 文件 的 符号 表内。

 

重定位

 

(https://blog.csdn.net/qq_35865125/article/details/105214201

需要重定位的符号保存在各个目标文件内的 重定位表中呀, 对应名为“. rel” 开头 的 section内。 ELF 文件 需要 重定位 的 section, 一般 都对 应 一个 重定位 表。 比如 代码section 即“. text”  sectioin的 重 定位 表 保存 在“. rel. text” section内, “. data” 的 重 定位 表 保存 在“. rel. data” 内)

 

目标文件的重定位信息包含三个关键的元素:

#重定位符号——使用哪个符号的地址进行重定位;--(各个目标文件内的 重定位表中呀)

#重定位位置——在何处进行重定位;(该信息同样可以从目标文件的重定位表中获取,表中保存了需要重定位的符号名,也保存了该符号属于目标文件的哪个section,以及在这个section内的偏移, 当链接起完成地址空间分配后,目标文件内的这个section的地址也就确定了,因此根据偏移可以定位到该符号在可执行文件内的位置)。

#重定位类型——用何种方法进行重定位。

 

首先,由于重定位操作依赖于重定位符号的地址,因此在符号解析完成前是无法进行重定位的。

 

重定位类型有两种:

绝对地址重定位 和 相对地址重定位。根据不同的重定位类型对段数据进行修正操作是重定位的核心。

1)绝对地址重定位操作比较简单,需要绝对地址重定位的地方一般都是源于对符号地址的直接引用,由于汇编器不能确定符号的虚拟地址,最终使用0作为占位符填充了引用符号地址的地方。因此,绝对地址重定位操作只需要直接填写重定位符号的虚拟地址到重定位位置即可。

绝对重定位地址=重定位符号地址

 

2)相对地址重定位稍微复杂一点,需要相对地址重定位的地方一般都是源于跳转类指令引用了其他文件的符号地址

虽然汇编器不能确定被引用符号的虚拟地址,但是并不使用0作为占位符填充引用符号地址的地方,而是使用“重定位位置相对于下一条指令地址的偏移”填充该位置。链接器进行相对地址重定位操作时,会计算符号地址相对于重定位位置的偏移,然后将该偏移量累加到重定位位置保存的内容。

相对重定位地址= 重定位符号地址–重定位位置+重定位位置数据内容

                           =(重定位符号地址–重定位位置)+(重定位位置–下一条指令地址)

                           = 重定位符号地址–下一条指令地址

根据上面的计算,可以很清晰地看出最终计算的相对重定位地址正是符号地址相对于下一条指令地址的偏移,也正符合跳转类指令对操作数的要求。至于为何对相对地址重定位进行如此“繁琐”的计算,笔者认为按照这样的方式,对于不同长度和设计结构的指令,只要重定位位置的数据按照相对地址的方式进行修正,那么相对重定位地址的计算方式不变,区别仅仅是重定位位置处的数据的值不同。比如对于Intel32位跳转指令该位置数据值是–4,对于Intel64位跳转指令该位置数据值是–8。

 

下面结合一个例子描述重定位的过程。

 

 

程序入口点与运行时库

前面章节提到程序入口点地址被保存在一个名为“@start”的特殊符号内,而定义该符号的目标文件并不是编译器根据源代码生成的。那么这就有两个问题需要弄清楚:

1)为什么引入新的符号而不是main函数作为程序入口点?

2)定义新的符号的目标文件该如何得到?

首先解释第一个问题。对于main函数生成的汇编代码片段形式如下:

 

本质上讲,main函数与普通的函数并没有太大的区别:包含函数入栈代码(第3~5行)、函数体代码(第6行省略内容)和函数出栈代码(第7~9行)。  假定使用main函数作为程序入口点,即将main符号的线性地址写入ELF文件头部的e_entry字段,那么程序加载运行后会从main符号的地址位置读取指令开始执行。整个main函数执行过程不会出现任何问题,直到ret指令执行结束后。 根据ret指令的语义,程序会从栈顶取出32位的数据作为返回地址,然后跳转到该地址继续执行!然而,程序执行main函数之前,栈顶保存的数据是未知的,因此导致程序的最终行为无法预测,最常见后果是触发进程“SegmentFault”。

因此,为了让程序可以“优雅”地退出,必须构造一个main函数的调用者完成函数调用后的“清理”工作。这也为第二个问题提供了解决办法。

在Linux的系统调用中,调用号为1的系统调用是exit,使用exit可以使进程正常退出。 调用exit的汇编代码如第6~8行所示,其中寄存器eax保存exit系统调用号1,ebx保存exit系统调用的参数0,int指令触发exit系统调用退出进程。   符号“@start”处的代码会调用main函数后使用exit系统调用退出进程,在调用main函数前后可以执行一些初始化工作(第3行省略内容)和清理工作(第5行省略的内容)。

如果编译器将上述代码保存在start.s,汇编器处理后,可以得到目标文件start.o。然后,使用readelf工具查看start.o的符号表:

 

从整个编译系统的工作流程来看,start.o文件是编译系统正常工作必需的目标文件。无论编译系统处理的源代码如何定义,在最终的链接阶段必须将start.o和其他目标文件一起链接才能正常生成可执行文件。对于这样的目标文件,有一个统一的名称——“语言运行时库”。显然,start.o应该是最简单的运行时库了,它只负责引导调用main函数,别的什么也没做。

 

  ------大彻大悟-----------

根据类似的方式,可以很方便地扩展程序设计语言运行时库的功能。

比如可以定义printf.s实现标准输出函数printf,经过汇编器处理后生成printf.o目标文件。只需要源码声明使用了printf函数,在链接时将printf.o链接到可执行文件,即可在高级语言中实现标准输出的功能。   更进一步地,可以直接定义math.c文件实现数学相关的函数,经过编译器和汇编器处理后生成math.o目标文件,这样高级语言就可以进行复杂的数学计算。

如果在编译系统中实现了预处理器并支持include指令,那么像printf函数或math.c实现的函数声明语句就可以放入类似“stdio.h”或“math.h”这样的头文件内。  

如果链接器支持输入压缩包格式的文件,那么像printf.o和math.o这样的目标文件可以打包放在类似“libc.a”这样的压缩包(库)中,链接器只需要在链接之前将压缩包解压即可。    编写高级语言程序时,只要包含需要的头文件,并在链接阶段包含对应的库文件,就可以使用更强大的语言特性。

 

 

相比 而言, GCC 的 C 语言 运行时 库( C Runtime Library, CRT) 复杂得多。 回顾 第 1 章 例子:

静态 链接 时, GCC 将 C 语言 运行时 库( CRT) 内 的 5 个 重要的 目标 文件 crt1. o、 crti. o、 crtbeginT. o、 crtend. o、 crtn. o 以及 3 个 静态 库 libgcc. a、 libgcc_ eh. a、 libc. a 链接 到 可执行 文件 hello.

 

描述 GCC 静态链接 工作 流程 时涉及的5个目标文件crt1.o、crti.o、crtbeginT.o、crtend.o、crtn.o,以及3个静态库libgcc.a、libgcc_eh.a、libc.a,这些文件的功能分别为:

1)crt1.o:定义程序入口点“_start”、调用“.init”段的代码执行程序的初始化、调用main函数、调用“.finit”段的代码执行程序的清理操作。早期版本为crt0.o,不支持“.init”和“.finit”段。

2)crti.o:定义“.init”段的函数入栈代码、调用C++全局构造代码。

3)crtn.o:定义“.finit”段的函数出栈代码、调用C++全局析构代码。

4)crtbeginT.o:定义C++全局构造代码。

5)crtend.o:定义C++全局析构代码。

6)libc.a:定义C语言标准库代码。--- 应该是gcc的现成的文件,安装gcc时就带的吧。要使用其中的函数需要在代码中#include相应的头文件,头文件的作用仅仅是声明函数的存在,在链接阶段,链接器从libc.a中去获取这些函数。嘿嘿! http://www.delorie.com/djgpp/doc/libc/libc_1.html

7)libgcc.a:定义由于平台差异性的辅助函数代码。

8)libgcc_eh.a:定义C++异常处理的平台相关代码。

由此可见,对于一种高级语言,除了编译器、汇编器和链接器是必不可少的部分之外,语言的运行时库也是不可或缺的一部分。功能丰富的运行时库,可以让高级语言的表达能力更加强大。

 

ELF文件生成

 

 


Ref:

范志东; 张琼声. 《自己动手构造编译系统:编译、汇编与链接》机械工业出版社.

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

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

First Snowflakes

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值