第七章 链接
链接
链接(linking)是将各种代码和数据片段收集并组合成为一个单一文件的过程,这个文件可被加载(复制)到内存并执行。
链接的目的是不用将一个大型的应用程序组织为一个巨大的源文件,而是可以把它分解为更小、更好管理的模块,可以独立地修改和编译这些模块。
当我们改变这些模块中的一个时,只需简单地重新编译它,并重新链接应用,而不必重新编译其他文件。
7.1 编译器驱动程序
要用GNU编译系统构造示例程序,我们就要通过在shell中输人下列命令来调用GCC 驱动程序:
1 | linux> gcc -Og -o prog main.c sum.c |
上图概括了驱动程序在将示例程序从ASCII码源文件翻译成可执行目标文件时的行为。
- 驱动程序首先运行C预处理器(cpp),它将C的源程序 main.c翻译成一个ASCII码的中间文件 main.i
cpp [otherarguments] main.c /tmp/main.i
- 接下来,驱动程序运行C编译器(cc1),它将main.i翻译成一个 ASCII 汇编语言文件main.s:
cc1 /tmp/main.i -Og [otherarguments] -o /tmp/main.s
- 然后,驱动程序运行汇编器(as),它将main.s翻译成一个可重定位目标文件(relo-catable object file)main.o:
as [other arguments]-o /tmp/main.o /tmp/main.s
驱动程序经过相同的过程生成sum.o。最后,它运行链接器程序ld,将main.o和sum.o以及一些必要的系统目标文件组合起来,创建一个可执行目标文件(executable object file)prog:
1 | ld -o prog [system objectfles and args]/tmp/main.o /tmp/sum.o |
直接通过 linux> ./prog
执行prog文件
shell调用操作系统中一个叫做加载器(loader)的函数,它将可执行文件prog 中的代码和数据复制到内存,然后将控制转移到这个程序的开头。
7.2 静态链接
静态链接器(static linker)以一组可重定位目标文件和命令行参数作为输入,生成一个完全链接的、可以加载和运行的可执行目标文件作为输出。
其中输人的可重定位目标文件由各种不同的代码和数据节(section)组成,每一节都是一个连续的字节序列。
具体而言:指令在一节中,初始化了的全局变量在另一节中,而未初始化的变量又在另外一节中。
为了构造可执行文件,链接器必须完成两个主要任务:
符号解析(symbolresolution)
目标文件定义和引用符号,每个符号对应于一个函数、一个全局变量或一个静态变量(即C语言中任何以static属性声明的变量)。
符号解析的目的是将每个符号引用正好和一个符号定义关联起来。
重定位(relocation)
编译器和汇编器生成从地址0开始的代码和数据节。
链接器通过把每个符号定义与一个内存位置关联起来,从而重定位这些节
在这之后,连接器修改所有对这些符号的引用,使得它们指向这个内存位置。
链接器使用汇编器产生的重定位条目(relocationentry)的详细指令,不加甄别地执行这样的重定位。?
7.3 目标文件
三种形式:
- 可重定位目标文件:包含二进制代码和数据(由编译器和汇编器生成)
其形式可以在编译时与其他可重定位目标文件合并起来,创建一个可执行目标文件。 - 可执行目标文件:包含二进制代码和数据(由链接器生成)
其形式可以被直接复制到内存并执行。 - 共享目标文件:
一种特殊类型的可重定位目标文件,可以在加载或者运行时被动态地加载进内存并链接。
从技术上来说,一个目标模块(object module)就是一个字节序列,而一个目标文件(object file)就是一个以文件形式存放在磁盘中的目标模块。
编译器和汇编器生成可重定位目标文件(包括共享目录文件)。链接器生成可执行目标文件。
将在以下章节详细阐述
7.4 可重定位目标文件
- 代码段(.text)
- 只读数据(.rodata)
- 数据段(.data)(已初始化)
- BSS段(.bss)(未初始化)
- 符号表
- 重定位信息(.rel.data)
- 调试信息等(.debug)
具体举例为
1 | int global = 42; // .data段 |
通过以下方式查看文件结构:
1 | objdump -h example.o |
1 | Sections: |
ELF文件结构
- ELF头(ELF Header)
- 程序头表(Program Header Table)
- 节(Sections)
- 节头表(Section Header Table)
ELF头
ELF头以应该16字节的序列开始,描述生成该文件的系统的字的大小和字节顺序
ELF头剩下的部分包含帮助链接器语法分析和解释目标文件的信息。其中包括
- ELF头的大小
- 目标文件的类型(如可重定位可执行或者共享的)
- 机器类型(如x86-64)
- 节头部表(section headertable)的文件偏移,以及节头部表中条目的大小和数量。
不同节的位置和大小是由节头部表描述的,其中目标文件中每个节都有一个固定大小的条目(entry)
节
夹在ELF头和节头部表中间的都是节,具体节情况请参考csapp p467(pdf503)
- . text : 已编译程序的机器代码。
- . rodate :只读数据,比如 printf 语句中的格式串和开关语句的跳转表。
- . data :已初始化的全局和静态 C 变量。局部 C变鼠在运行时被保存在栈中,既不出现在.data 节中,也不出现在.bss 节中。
- . bss :未初始化的全局和静态 C 变量,以及所有被初始化为0 的全局或静态变量。在目标文件中这个节不占据实际的空间,它仅仅是一个占位符。
- . symtab :一个符号表,它存放在程序中定义和引用的函数和全局变量的信息。
- .rel.text :一个 .text节中位置的列表,当链接器把这个目标文件和其他文件组合时,需要修改这些位置。
- .rel.data :被模块引用或定义的所有全局变最的重定位信息。
- .debug :一个调试符号表,其条目是程序中定义的局部变量和类型定义,程序中定义和引用的全局变量,以及原始的 C源文件。只有以-g选项调用编译器驱动程序时,才会得到这张表。
- .line :原始 C 源程序中的行号和.text 节中机器指令之间的映射。只有以-g选项调用编译器驱动程序时,才会得到这张表。
- .strtab :一个字符串表,其内容包括.symtab 和 .debug 节中的符号表,以及节头部中的节名字。字符串表就是以 null 结尾的字符串的序列。
ELF文件的作用
- 编译阶段 :
- 存储目标代码
- 保存符号信息
- 记录重定位信息
- 链接阶段 :
- 符号解析
- 重定位处理
- 地址分配
- 运行阶段 :
- 程序加载
- 动态链接
- 内存映射
- 调试阶段 :
- 提供调试信息
- 支持符号级调试
- 异常处理支持
7.5 符号和符号表
每个可重定位目标模块m都有一个符号表,它包含m定义和引用的符号的信息
链接器的上下文中,有三种不同的符号:
由模块m定义并能被其他模块引用的全局符号:
全局链接器符号对应于非静态的函数和全局变量。1
2
3
4
5
6// 全局变量
int globalVar = 42; // 全局变量
void globalFunction() { // 非静态函数
// 函数实现
}1
由其他模块定义并被模块m引用的全局符号(外部符号):
对应于在其他他模块中定义的非静态C函数和全局变量。1
2
3
4
5
6
7
8
9// 在链接的文件中使用
extern int globalVar; // 声明全局变量
extern void globalFunction(); // 声明非静态函数
void useGlobalSymbols() {
globalVar = 100; // 使用全局变量
globalFunction(); // 调用非静态函数
}只被模块m定义和引用的局部符号:
对应于带static属性的C函数和全局量。这些符号在模块m中任何位置都可见,但是不能被其他模块引用。1
2
3
4
5
6
7
8
9
10
11// 静态变量
static int localVar = 42; // 静态全局变量
static void localFunction() { // 静态函数
// 函数实现
}
void useLocalSymbols() {
localVar = 100; // 使用静态全局变量
localFunction(); // 调用静态函数
}
定义为带有C static属性的本地过程变量是不在栈中管理的。相反,编译器在.data或.bss中为每个定义分配空间,并在符号表中创建一个有唯一名字的本地链接器符号。
符号表
符号表是由汇编器构造的,使用编译器输出到汇编语言.s文件中的符号。如下展示了每个条目的格式
上面的示例有如下的一些解释:
name是字符串表中的字节偏移,指向符号的以nul1结尾的字符串名字。
value是符号的地址
- 对于可重定位的模块来说,value是距定义目标的节的起始位置的偏移。
- 对于可执行目标文件来说,该值是一个绝对运行时地址
size:符号占用字节数(目标的大小)
type通常要么是数据,要么是函数、其他(如节名、源文件路径等)
binding作用域
- 本地
- 全局
可以使用objdump查看符号表
1 | # 查看符号表 |
符号表还可以包含各个节的条目,以及对应原始源文件的路径名的条目。所以这些目标的类型也有所不同。
符号的节(Section)分配
- 每个符号都被分配到目标文件的某个节
- 由section字段表示,该字段也是一个到节头部表的索引。
但这还有有三个特殊的伪节(pseudosection)(只有可重定位目标文件中才有这些伪节,可执行目标文件中是没有的),它们在节头部表中是没有条目的:
- ABS代表不该被重定位的符号
- UNDEF代表未定义的符号
在本目标模块中引用,但是却在其他地方定义的符号 - COMMON表示还未被分配位置的未初始化的数据目标
- value字段表示对齐要求
- size字段表示最小大小
- 与.bss的区别:
- COMMOM:未初始化的全局变量
- .bss:未初始化的静态变量,初始化未0的全局变量和静态变量
对应的示例说明
1 | // 全局未初始化变量 - 放在 COMMON |
符号表展示样例
符号表中输出示例如下:
1 | example.o: file format elf64-x86-64 |
在这个输出中:
l
表示局部符号(local symbol)g
表示全局符号(global symbol)F
表示函数(function)O
表示对象(object),即变量.text
、.data
、.bss
等表示符号所在的段
同时我们也可以通过以下命令查看动态符号表
1 | objdump -T filename |
7.6 符号解析
链接器解析符号引用的方法是将每个引用与它输入的可重定位目标文件的符号表中的一个确定的符号定义关联起来。
编译器只允许每个模块中每个局部符号有一个定义。静态局部变量也会有本地链接器符号,编译器还要确保它们拥有唯一的名字。
对全局符号的引用解析:
当编译器遇到一个不是在当前模块中定义的符号(变量或函数名)时,会假设该符号是在其他某个模块中定义的,生成一个链接器符号表条目,并把它交给链接器处理。如果链接器在它的任何输入模块中都找不到这个被引用符号的定义,就输出一条(通常很难阅读的)错误信息并终止。
在多个目标文件可能会定义相同名字的全局符号的情况中,链接器必须要么标志一个错误,要么以某种方法选出一个定义并抛弃其他定义。
7.6.1 链接器如何解析多重定义的全局符号
链接器的输人是一组可重定位目标模块。每个模块定义一组符号,有些是局部的(只对定义该符号的模块可见),有些是全局的(对其他模块也可见)。
强符号
定义:在符号表中具有唯一定义的符号,通常由函数或全局变量生成。
- 已初始化的全局变量
- 函数定义
- 使用
extern
声明的变量
弱符号
定义:在符号表中可以有多个定义的符号,允许被覆盖。
- 未初始化的全局变量
- 使用
__attribute__((weak))
声明的符号 - C++ 中的
inline
函数
根据强弱符号的定义,Linux链接器使用下面的规则来处理多重定义的符号名:
- 规则1:不允许有多个同名的强符号。
- 规则2:如果有一个强符号和多个弱符号同名,那么选择强符号。
- 规则3:如果有多个弱符号同名,那么从这些弱符号中任意选择一个
7.6.2 与静态库链接
所有的编译系统都提供一种机制:将所有相关的目标模块打包成为一个单独的文件,称为静态库(static library),它可以用做链接器的输人。
静态库
当链接器构造一个输出的可执行文件时,它只复制静态库里被应用程序引用的目标模块。
相关的函数可以被编译为独立的目标模块,然后封装成一个单独的静态库文件。然后,应用程序可以通过在命令行上指定单独的文件名字来使用这些在库中定义的函数。在链接时,链接器将只复制被程序引用的目标模块。这就减少了可执行文件在磁盘和内存中的大小。
比如,使用C标准库和数学库中函数的程序可以用形式如下的命令行来编译和链接:
1 | linux> gcc main.c /usr/lib/libm.a /usr/lib/libc.a |
在链接时,链接器将只复制被程序引用的目标模块
这就减少了可执行文件在磁盘和内存中的大小。
另一方面,应用程序员只需要包含较少的库文件的名字
总结特点
- 编译时链接 :静态库在编译阶段被链接到最终的可执行文件中,程序执行时不再需要外部的库文件。
- 独立性 :因为所有的库代码已经包含在可执行文件中,所以程序运行时不需要依赖于外部的库文件。
- 增加可执行文件的体积 :由于所有库代码都被直接嵌入到可执行文件中,因此会增加可执行文件的体积。
- 不易更新 :一旦程序已经编译为可执行文件,修改静态库的内容需要重新编译程序。
静态库的实现:存档
在 Linux系统中,静态库以一种称为存档(archive)的特殊文件格式存放在磁盘中。
存档文件是一组连接起来的可重定位目标文件的集合,有一个头部用来描述每个成员目标文件的大小和位置。存档文件名由后缀(.a)标识。
为了创建这个可执行文件,我们要编译和链接输人文件main.o和1ibvector.a:
1 | linux> gcc -c main2.c |
或等价使用
1 | linux> gcc -c main2.c |
下图括了链接器的行为。-static参数告诉编译器驱动程序,链接器应该构建一个完全链接的可执行目标文件,它可以加载到内存并运行,在加载时无须更进一步的链接。-lvector参数是1ibvector.a的缩写,-.参数告诉链接器在当前目录下查找 libvector.a.
当链接器运行时,它判定 main2.o引用了 addvec.o定义的 addvec符号,所以复制addvec.o到可执行文件。因为程序不引用任何由multvec.o定义的符号,所以链接器就不会复制这个模块到可执行文件。链接器还会复制libc.a中的printf.o模块,以及许多C运行时系统中的其他模块。
7.6.3 链接器如何使用静态库来解析引用
在符号解析阶段,链接器从左到右按照它们在编译器驱动程序命令行上出现的顺序来扫描可重定位目标文件和存档文件。
(驱动程序自动将命令行中所有的.c文件翻译为.o文件。)在这次扫描中,链接器维护
- 可重定位目标文件的集合E(这个集合中的文件会被合并起来形成可执行文件)
- 一个未解析的符号(即引用了但是尚未定义的符号)集合U
- 以及一个在前面输入文件中已定义的符号集合D。
初始时,E、U 和 D 均为空。
对于命令行上的每个输人文件f,链接器会判断f是一个目标文件还是一个存档文件。
- 如果f是一个目标文件,那么链接器把f添加到E,修改U和D来反映f中的符号定义和引用,并继续下一个输人文件。
- 如果f是一个存档文件
- 那么链接器就尝试匹配U中未解析的符号和由存档文件成员定义的符号。
- 如果某个存档文件成员m,定义了一个符号来解析U中的一个引用
- 那么就将m加到E中
- 链接器修改U和D来反映m中的符号定义和引用。
- 更新U集合:从U中移除已解析的符号
- 更新D集合:将新定义的符号加入D
对存档文件中所有的成员目标文件都依次进行这个过程,直到U和D都不再发生变化。
此时,任何不包含在E中的成员目标文件都简单地被丢弃,而链接器将继续处理下一个输人文件。
如果当链接器完成对命令行上输入文件的扫描后,U是非空的,那么链接器就会输出一个错误并终止。否则,它会合并和重定位E中的目标文件,构建输出的可执行文件。
注意事项
由于链接器按照命令行中目标文件和库文件的 从左到右顺序 依次处理它们,并且只扫描每个文件一次。所以会遇到顺序错误导致的链接失败的问题。
原因如下:如果命令行中 定义符号的库文件出现在引用这些符号的目标文件之前 ,当链接器扫描到这个库文件时,它不会发现任何未解析的符号需要解决(因为目标文件还没被处理)。
- 结果 :链接器会忽略该库文件的内容。
- 随后 :当链接器处理到目标文件并发现未解析符号时,因为之前的库文件已经被扫描过,未定义的符号无法被解析,最终导致链接失败。
解决办法:
为了避免这种链接错误,程序员需要确保 引用符号的目标文件出现在定义符号的库文件之前 。一般的准则是将所有的库文件放在命令行的结尾。
附加
关于库的一般准则是将它们放在命令行的结尾。如果各个库的成员是相互独立的(也就是说没有成员引用另一个成员定义的符号),那么这些库就可以以任何顺序放置在命令行的结尾处。
另一方面,如果库不是相互独立的,那么必须对它们排序,使得对于每个被存档文件的成员外部引用的符号s,在命令行中至少有一个s的定义是在对s的引用之后的。
假设 foo.c调用1ibx.a和 1ibz.a中的函数,而这两个库又调用 liby.a中的函数。那么,在命令行中libx.a和1ibz.a必须处在liby.a之前:
7.7 重定位
在计算机系统中, 重定位 (Relocation)是将程序从编译时生成的虚拟地址调整为最终加载到内存中的实际地址的过程,以确保程序可以正确运行。
一旦链接器完成了符号解析这一步,就把代码中的每个符号引用和正好一个符号定义(即它的一个输人目标模块中的一个符号表条目)关联起来。此时,链接器就知道它的输人目标模块中的代码节和数据节的确切大小。
现在就可以开始重定位步骤:将合并输人模块,并为每个符号分配运行时地址。
两个步骤
重定位节和符号定义
- 链接器将所有相同类型的节合并为同一类型的新的聚合节。
- 然后,链接器将运行时内存地址赋给新的聚合节,赋给输人模块定义的每个节,以及赋给输人模块定义的每个符号。
当这一步完成时,程序中的每条指令和全局变量都有唯一的运行时内存地址了。
链接时重定位
- 发生时机 :在链接器将多个目标文件和库文件合并成一个可执行文件时。
- 目的 :
- 将所有目标文件的符号引用解析为具体的地址。
- 为目标文件分配全局地址空间(代码段、数据段等)。
- 链接器的任务 :
- 修改程序中所有需要调整的地址,使得它们在可执行文件中是正确的。
- 结果 :生成了一个可执行文件,通常以绝对地址为准。
重定位节中的符号引用
在这一步中,链接器修改代码节和数据节中对每个符号的引用,使得它们指向正确的运行时地址。
要执行这一步,链接器依赖于可重定位目标模块中称为重定位条目(relocationentry)的数据结构
加载时重定位
- 发生时机 :在操作系统将可执行文件加载到内存中时。
- 目的 :
- 将程序中的绝对地址调整为其在内存中的实际加载地址。
- 这通常发生在操作系统加载动态库或执行程序时。
- 加载器的任务 :
- 根据加载基地址(Base Address),调整程序中的所有绝对地址。
- 修改指令中的地址引用(如跳转指令和全局变量引用)。
- 结果 :程序在内存中可以正确执行。
7.7.1 重定位条目
当汇编器生成一个目标模块时,它并不知道数据和代码最终将放在内存中的什么位置。它也不知道这个模块引用的任何外部定义的函数或者全局变量的位置。
因此,无论何时汇编器遇到对最终位置未知的目标引用,它都会生成一个重定位条目,告诉链接器在将目标文件合并成可执行文件时如何修改这个引用。
重定位条目是目标文件中的一部分数据结构,用于帮助链接器或加载器完成重定位过程。它记录了目标文件中需要修改地址的指令或数据的位置,以及修改地址所需的信息。代码的重定位条目放在 .rel.text
中,已初始化数据的重定位条目放在 .rel.data
中。
一个重定位条目通常包括以下字段:
- 位置(Offset) :
- 需要修改的地址在目标文件中的偏移位置。
- 例如,一条指令的地址引用,或者全局变量的存储位置。
- 符号(Symbol) :
- 对应的符号名称,用于链接器查找定义该符号的具体位置。
- 例如,函数名或全局变量名。
- 重定位类型(Type) :
- 指明该地址如何进行调整,例如:
- 绝对地址(直接修改为实际地址)。
- 相对地址(基于当前程序计数器的偏移量)。
- 指明该地址如何进行调整,例如:
- 附加信息(Addend) (某些格式可能有):
- 有时重定位条目会包含一个附加的常量,用于参与最终地址的计算。
两种最基本的ELF定位类型:
- RX8664PC32。重定位一个使用32位PC相对地址的引用。
- 回想一下3.6.3节,一个PC相对地址就是距程序计数器(PC)的当前运行时值的偏移量。当CPU执行一条使用PC相对寻址的指令时,它就将在指令中编码的32位值加上PC的当前运行时值,得到有效地址(如ca11指令的目标),PC值通常是下一条指令在内存中的地址。
- RX866432。重定位一个使用32位绝对地址的引用。
- 通过绝对寻址,CPU直接使用在指令中编码的32位值作为有效地址,不需要进一步修改。
支持x86-64小型代码模型
7.7.2 重定位符号引用?
重定位符号引用是重定位过程中解决目标文件中符号未解析问题的核心。它指的是将目标文件中的符号引用(如变量名或函数名)替换为符号的实际地址或偏移值,以确保程序在运行时能正确访问这些符号。
重定位PC相对引用
链接器首先计算出引用的运行时地址,
然后更新该引用,
1 | refaddr = ADDR(s) + r.offset; |
例题:
重定位条目r由4个字段组成
链接器已确定
使用算法,得到下面的计算
cpu 计算为:PC+偏移量
程序计数器(Program Counter,简称PC)是CPU中的一个重要寄存器,它用于存储下一条将要执行的指令的地址。
始执行call时PC不会改变,但是执行过call后PC才进行改变
RET
指令会从栈中弹出返回地址,并将PC的值更新为该返回地址,跳回到子程序调用之后的位置继续执行。
则callq指令对于sum的重定位引用为:05
重定位绝对引用
1 | *refptr = (unsigned) (ADDR(r.symbol) + r.addend) |
假设链接器
因为不需要考虑偏移量,直接得到
对应到
7.8 可执行目标文件
可执行目标文件的格式类似于可重定位目标文件的格式。ELF头描述文件的总体格式。
还包括程序的入口点(entry point),也就是当程序运行时要执行的第一条指令的地址。.text、.rodata和.data节与可重定位目标文件中的节是相似的,除了这些节已经被重定位到它们最终的运行时内存地址以外。.init节定义了一个小函数,叫做init,程序的初始化代码会调用它。因为可执行文件是完全链接的(已被重定位),所以它不再需要.rel节。
ELF可执行文件被设计得很容易加载到内存,可执行文件的连续的片(chunk)被映射到连续的内存段。
程序头部表(programheader table)描述了这种文件映射到内存中的信息。图 7-14 展示了可执行文件prog的程序头部表,是由OBJDUMP显示的。
- off:目标文件中的偏移
- vaddr/paddr :内存地址
- align :对齐要求
- filesz :目标文件中的段大小
- memsz :内存中的段大小
- flags :运行时访问权限
该程序头部表表示:根据可执行目标文件的内容初始化两个内存段。
第一段:代码段
- 第一行描述的段(Read-only code segment) :
- 类型 :代码段(
r-x
表示只读和可执行)。 - 文件偏移 (off):
0x0
,表示这个段从文件的开头开始。 - 虚拟地址 (vaddr):
0x400000
,该段加载到内存后,起始地址是0x400000
。 - 物理地址 (paddr):
0x400000
,对于大部分系统,这个地址和虚拟地址通常一致。 - 对齐方式 (align):
2**21
,表示该段在内存中按 2 的 21 次方(2MB)对齐。 - 文件大小 (filesz):
0x69c
(1692 字节),表示代码段在 ELF 文件中的大小。 - 内存大小 (memsz):
0x69c
(1692 字节),表示代码段在内存中的大小。 - 标志 (flags):
r-x
,只读且可执行。
flags r-x
表示第一个代码段具有读和执行的访问权限,开始于内存 (vaddr 0x0000000000400000
) 0x400000处,总共的内存大小为(memsz 0x69c
)0x69c个字节,并且将目标文件的前(filesz 0x69c
)0x69c个字节的内容导入内存进行初始化,其中包括 :
- ELF头
- 程序头部表
- .init
- .text
- .rodate
第二段:数据段
- 第二行描述的段(Read/write data segment) :
- 类型 :数据段(
rw-
表示可读和可写,但不可执行)。 - 文件偏移 (off):
0xdf8
,表示该段在文件中从偏移0xdf8
开始。 - 虚拟地址 (vaddr):
0x600df8
,该段加载到内存后,起始地址是0x600df8
。 - 物理地址 (paddr):
0x600df8
,通常与虚拟地址相同。 - 对齐方式 (align):
2**21
,即 2MB 对齐。 - 文件大小 (filesz):
0x228
(552 字节),表示数据段在 ELF 文件中的大小。 - 内存大小 (memsz):
0x230
(560 字节),表示该段在内存中的大小。 - 标志 (flags):
rw-
,可读、可写,不可执行。
flags rw-
表示有读写权限,开始于内存0x600df8处,总内存大小为:0x230,并且将目标文件的偏移量0xdf8处开始的228个字节的内容导入内存进行初始化。
不难发现,内存段二中内存空间还剩下8个字节,这些多余的空间对应于运行时将被初始化为0的 .bss
数据。
段对齐计算
对齐是为了优化内存访问效率。对齐方式由 align
指定。这里为 2**21
,即 2MB 对齐。
对齐公式
以上面例子中的第二段来说:
1 | vaddr mod align = 0x600df8 mod 0x200000 = 0xdf8 |
7.9 加载可执行目标文件
1 | linux> ./prog |
shell 通过调用某个驻留在存储器中称为加载器(loader)的操作系统代码来运行它。任何Linux程序都可以通过调用execve函数来调用加载器
加载器将可执行目标文件中的代码和数据从磁盘复制到内存中,然后通过跳转到程序的第一条指令或入口点来运行该程序。这个将程序复制到内存并运行的过程叫做加载。
每个 Linux程序都有一个运行时内存映像
在 Linux x86-64系统中,代码段总是从地址0x400000处开始,后面是数据段。运行时堆在数据段之后,通过调用 ma11oc库往上增长。堆后面的区域是为共享模块保留的。用户栈总是从最大的合法用户地址(2*8-1)开始,向较小内存地址增长。栈上的区域,从地址(2^48-1)开始,是为内核(kernel)中的代码和数据保留的,所谓内核就是操作系统驻留在内存的部分。
为了简洁,我们把堆、数据和代码段画得彼此相邻,并且把栈顶放在了最大的合法用户地址处。实际上,由于.data段有对齐要求(见7.8节),所以代码段和数据段之间是有间隙的。同时,在分配栈、共享库和堆段运行时地址的时候,链接器还会使用地址空间布局随机化(ASLR,参见3.10.4节)。虽然每次程序运行时这些区域的地址都会改变,它们的相对位置是不变的。
当加载器运行时,它创建类似于图7-15所示的内存映像。在程序头部表的引导下,加载器将可执行文件的片(chunk)复制到代码段和数据段。
接下来,加载器跳转到程序的人口点,也就是_start函数的地址。这个函数是在系统目标文件ctrl.o中定义的,对所有的C程序都是一样的。
_start函数调用系统启动函数1ibcstartmain,该函数定义在 libc.so中。它初始化执行环境,调用用户层的 main 函数,处理 main 函数的返回值,并且在需要的时候把控制返回给内核。
7.10 动态链接共享库
静态链接库的缺点:
- 主存浪费
- 磁盘浪费
- 更新困难
共享库是一个目标模块,在运行或加载时,可以加载到任意的内存地址,并和一个在内存中的程序链接起来。这个过程称为*动态链接(dynamic linking),是由一个叫做动态链接器(dynamic linker)*的程序来执行的。
共享库也称为共享目标(sharedobject),在Linux系统中通常用.so后缀来表示。微软的操作系统大量地使用了共享库,它们称为DLL(动态链接库)。
共享库(共享目标):
- 存储可被多个程序动态加载和使用的函数或数据的文件。
- 在Linux系统中常用.so后缀来表示,windows中大量的使用了共享库,称为DLL(动态链接库)。
动态链接(dynamic linking):
- 在程序运行时,将可执行文件与共享库结合的机制,而不是在编译或链接阶段直接将库嵌入到可执行代码中。
- 由动态链接器(dynamic linker)的程序来执行的
共享的过程
共享库是以两种不同的方式来“共享”的。
首先,在任何给定的文件系统中,对于一个库只有一个.so文件。所有引用该库的可执行目标文件共享这个so文件中的代码和数据,而不是像静态库的内容那样被复制和嵌入到引用它们的可执行的文件中。其次,在内存中,一个共享库的.text节的一个副本可以被不同的正在运行的进程共享。
为了构造图7-6中示例向量例程的共享库1ibvector.so,我们调用编译器驱动程序,给编译器和链接器如下特殊指令:
1 | linux> gcc -shared -fpic -o libvector.so addvec.c multvec.c |
-fpic选项指示编译器生成与位置无关的代码,-shared选项指示链接器创建一个共享的目标文件。一旦创建了这个库,随后就要将它链接到图7-7的示例程序中:
1 | linux> gcc -o prog2l main2.c ./libvector.so |
这样就创建了一个可执行目标文件prog21,而此文件的形式使得它在运行时可以和1ibvector.so链接。
基本的思路是当创建可执行文件时,静态执行一些链接,然后在程序加载时,动态完成链接过程。认识到这一点是很重要的:此时,没有任何libvector.so的代码和数据节真的被复制到可执行文件prog21中。反之,链接器复制了一些重定位和符号表信息,它们使得运行时可以解析对libvector.so中代码和数据的引用。
当加载器加载和运行可执行文件prog21时,它利用7.9节中讨论过的技术,加载部分链接的可执行文件 prog21。接着,它注意到prog21包含一个.interp节,这一节包含动态链接器的路径名,动态链接器本身就是一个共享目标(如在Linux系统上的 ld-linux.so)。
加载器不会像它通常所做地那样将控制传递给应用,而是加载和运行这个动态链接器。然后,动态链接器通过执行下面的重定位完成链接任务:
- 重定位 1ibc.so的文本和数据到某个内存段。
- 重定位 1ibvector.so的文本和数据到另一个内存段。
- 重定位 prog2l中所有对由1ibc.so和libvector.so定义的符号的引用。
最后,动态链接器将控制传递给应用程序。从这个时刻开始,共享库的位置就固定了,并且在程序执行的过程中都不会改变。
7.11 从应用程序中加载和链接共享库
应用程序还可能在它运行时要求动态链接器加载和链接某个共享库,而无需在编译时将那些库链接到应用中。
其思路是将每个生成动态内容的函数打包在共享库中。当一个来自Web浏览器的请求到达时,服务器动态地加载和链接适当的函数,然后直接调用它,而不是使用fork和execve在子进程的上下文中运行函数。
函数会一直缓存在服务器的地址空间中,所以只要一个简单的函数调用的开销就可以处理随后的请求了。
Linux动态链接器接口:
dlopen函数加载和链接共享库filename。用已用带RTLD GLOBAL 选项打开了的库解析 filename 中的外部符号。如果当前可执行文件是带-dynamic选项编译的,那么对符号解析而言,它的全局符号也是可用的。flag参数必须要么包括RTLDNOW,该标志告诉链接器立即解析对外部符号的引用,要么包括RTLDIAZY标志,该标志指示链接器推迟符号解析直到执行来自库中的代码。这两个值中的任意一个都可以和RTLDGLOBAL 标志取或。
7.12 位置无关代码
可以加载而无需重定位的代码称为位置无关代码(Position-Independent Code,PIC)。用户对GCC使用-fpic选项指示GNU编译系统生成PIC代码。
共享库的编译必须总是使用该选项。
在一个x86-64系统中,对同一个目标模块中符号的引用是不需要特殊处理使之成为PIC。可以用PC相对寻址来编译这些引用
PIC数据引用
先需要知道一个前提
无论我们在内存中的何处加载一个目标模块(包括共享目标模块),数据段与代码段的距离总是保持不变。因此,代码段中任何指令和数据段中任何变量之间的距离都是一个运行时常量,与代码段和数据段的绝对内存位置是无关的。
生成对全局变量PIC引用的编译器利用了这个事实。
它在数据段开始的地方创建了一个表,叫做全局偏移量表(Global Offset Table,GOT)。在 GOT中,每个被这个目标模块引用的全局数据目标(过程或全局变量)都有一个8字节条目。
编译器还为GOT中每个条目生成一个重定位记录。在加载时,动态链接器会重定位GOT中的每个条目,使得它包含目标的正确的绝对地址。每个引用全局目标的目标模块都有自己的GOT。
在下面的例子中
addvec例程通过 GOT[3]间接地加载全局变量 addcnt的地址,然后把addcnt在内存中加1。这里的关键思想是对GOT[3]的PC相对引用中的偏移量是一个运行时常量。
- 因为 addcnt是由 libvector.so模块定义的,编译器可以利用代码段和数据段之间不变的距离,产生对addcnt的直接PC相对引用,并增加一个重定位,让链接器在构造这个共享模块时解析它。
- 不过,如果addcnt是由另一个共享模块定义的,那么就需要通过GOT进行间接访问。在这里,编译器选择采用最通用的解决方案,为所有的引用使用 GOT.
PIC函数调用
延迟绑定
延迟绑定把函数地址的解析推迟到它实际被调用的地方,能避免动态链接器在加载时进行成百上千个其实并不需要的重定位。
延迟绑定通过两个表结构的协作来实现:
过程链接表(PLT)
PLT是一个数组,其中每个条目是16字节代码。PLT[0]是一个特殊条目,它跳转到动态链接器中。每个被可执行程序调用的库函数都有它自己的PLT条目。每个条目都负责调用一个具体的函数。
全局偏移量表(GOT)
正如我们看到的,GOT是一个数组,其中每个条目是8字节地址。和PLT联合使用时,GOT[0]和GOT[1]包含动态链接器在解析函数地址时会使用的信息。GOT[2]是动态链接器在1d-linux.so模块中的人口点。其余的每个条目对应于一个被调用的函数,其地址需要在运行时被解析。每个条目都有一个
相匹配的 PLT条目。
- Title: 第七章 链接
- Author: time will tell
- Created at : 2024-12-18 10:50:50
- Updated at : 2024-12-22 16:57:18
- Link: https://sbwrn.github.io/2024/12/18/第七章 链接/
- License: This work is licensed under CC BY-NC-SA 4.0.