本章讲链接,即编译后的程序主体和各种库链接在一起实现程序的可执行文件,从而实现程序的模块化。
目录
为什么要理解链接器:
- 理解链接器有助于构造大型程序
- 理解链接器能够让你理解程序变量的作用域规则
- 理解链接能够避免一些相关错误
- 链接器产生的可执行文件在虚拟内存、程序进程等有较大作用
- 理解链接器能让你充分利用共享库
为什么用链接器?
模块化
- 程序可以编写为一个较小的源文件的集合,而不是一个整体巨大的一团。
- 可以构建公共函数库 (稍后详述)
- 例如:数学运算库, 标准C库
效率
-
时间: 分开编译
-
更改一个源文件,编译,然后重新链接。
-
不需要重新编译其他源文件。
-
空间: 库
-
可以将公共函数聚合为单个文件。
-
而可执行文件和运行内存映像只包含它们实际使用的函数的代码,即按需编译链接
-
静态链接
使用编译器驱动程序(compiler driver)进行程序的翻译和链接**😗*
-
gcc -Og -o prog main.c sum.c
-
``./prog`
-
具体过程如下图
编译器驱动程序
大多数编译系统提供编译器驱动程序,代表构建程序时对语言预处理器、编译器、汇编器和链接器的动作总和。
例如,上述第一个语句的过程可以用以下语句表示:
链接的工作过程
步骤1:符号解析
- 程序定义和引用符号(全局变量和函数):
void swap() {…} /* define symbol swap*/
swap(); /* reference symbol swap */
int *xp = &x; /* define symbol xp, reference x */
- 由汇编器将符号定义存储在目标文件中的符号表中
- 符号表是一个结构体的数组,每个条目包括符号的名称、大小和位置。
- 在符号解析步骤中,链接器将每个符号引用与一个确定的符号定义关联起来
步骤2:重新定位
- 将多个单独的代码节 (sections)和数据节合并为单个节。
- 将符号从它们在 .o 文件中的 相对位置 重新定位到可执行文件中的最终绝对内存位置 。
- 用它们的新位置,更新所有对这些符号的引用 。
目标文件
目标文件的类型
- 可重定位目标文件 (.o 文件)
- 包含的代码和数据,其形式能与其他可重定位目标文件相结合,以形成可执行的目标文件。
- 每一个 .o 文件是由一个源 (.c) 文件生成的
- 可执行目标文件 (a.out文件)
- 包含的代码和数据,其形式可以直接复制到内存并执行。
- 共享目标文件 (.so 文件)
- 特殊类型的可重定位目标文件,它可以在加载时或运行时,动态地加载到内存并链接。
- 在 Windows 中称为动态链接库 (Dynamic Link Libraries, DLL)
目标文件的结构
现代的x86-64Unix/Linux采取ELF(Executable and Linkable Format,可执行与可链接格式)可执行文件格式。
以下展示ELF文件的格式:
-
ELF头:以16字节的序列表述了生成该文件的系统的字大小和字节顺序。剩下的部分包括文件类型(.o .exec .so)、机器类型、节头表位置等。(可以用readelf -l main查看)
- 包含内容:生成该文件的系统的字的大小和字节顺序,ELF 头的大小,目标文件的类型,机器类型(如 x86-64),节头部表的文件偏移,节头部表中条目的大小和数量。
-
段头表/程序头表
- 页面大小,虚拟地址内存段(节),段大小
- 可执行文件必备
-
.text:机器代码。
-
.rodata:只读数据,如printf的格式串、swich的跳转表。
-
.data:已初始化的全局和静态变量。
-
.bss:
- 未初始化/初始化为0的全局和静态变量。
- 仅有节头,节本身不占用磁盘空间,仅仅是一个占位符。
-
.symtab:符号表,函数和全局/静态变量的信息。
-
.rel.text:.text节中的可重定位信息。包括在合并后的可执行文件中需要修改的指令地址。
-
.rel.data:.data节中的可重定位信息。包括在合并后的可执行文件中需要修改的指针数据的地址。
上两者在重定位条目会有相关讨论
-
.debug:调试符号表。(gcc -g)
-
节头部表:每个节的偏移量、大小。
符号和符号表
每一个可重定位目标模块m都包含一个符号表,其包含定义和引用的符号信息,包括以下三类:
-
全局变量,由m模块定义而被其他模块引用的全局符号。全局链接器对应于非静态的C函数和全局变量。
-
外部符号,即由其他模块定义而被m模块引用的全局符号。对应于其他模块定义的非静态的C函数和全局变量
-
本地局部符号,只能被模块m定义和引用的局部符号(非局部变量)。对应于m中带static属性的C函数和全局变量。这个符号不是程序的本地局部变量
- 有趣的是,链接器对本地非静态程序变量不感兴趣,这些符号往往在运行时栈进行管理。
接触过C++和Java的朋友,应该知道。它们为了包含私有性,使用public 和 private 声明,其中C语言中的static就是对应于private声明,可以起到一样的效果。
使用编译器输出到汇编语言.s文件的符号,汇编器会使用符号构成符号表。.symtab中包含ELF符号。如图为ELF符号表条目。
value中若在可重定位目标文件中,是距离定义目标起始位置的偏移,如在可执行目标文件中,则是一个固定值(绝对运行时地址)。
符号解析
局部符号
-
本地非静态C变量:存储在栈上
-
本地静态C变量:存储在.bss或.data
-
编译器在.data为每个x的定义分配 空间。
-
在符号表中创建具有唯一名称的局部 符号(本地链接器符号),如x.1和 x.2
int f() { static int x = 0; return x; } int g() { static int x = 1; return x; }
重复符号
链接器如何解析重复的符号定义
-
程序符号要么是强符号,要么是弱符号
-
强: 函数和初始化全局变量
-
弱: 未初始化的全局变量
-
符号处理规则
-
规则 1: 不允许多个同名的强符号
-
每个强符号只能定义一次
-
否则 : 链接器错误
-
-
规则 2: 若有一个强符号和多个弱符号同名,则选择强符号
- 对弱符号的引用将被解析为强符号
-
规则 3: 如果有多个弱符号,选择任意一个
下有两个命令:
-
gcc –fno-common
:对多重定义的全局符号报错 -
gcc Werror
:所有警告都变为错误
这两个命令都是判断符号链接错误的方法
符号噩梦
重载的处理
C++ 和 Java 中能使用重载函数,是因为编译器将每个唯一的方法和参数列表组合编码成一个对链接器来说唯一的名字。这种编码过程叫做重整(mangling),而相反的过程叫做恢复(demangling)。
幸运的是,C++ 和 Java 使用兼容的重整策略。一个被重整的类名字是由名字中字符的整数数量,后面跟原始名字组成的。比如,类 Foo 被编码成 3Foo。方法被编码为原始方法名,后面 “_ _”,加上被重整的类名,再加上每个参数的单字母编码。比如,Foo::bar(int,long) 被编码为 bar__3Fooil。重整全局变量和模板名字的策略是相似的。
全局变量
-
避免:如果能的话
-
否则
-
如果可以,使用 static
-
定义时初始化它
-
-
使用 extern 声明引用的外部全局符号
重定位
一旦链接器完成了符号解析这一步,就把代码中的每个符号引用和正好一个符号定义(即它的一个输入目标模块中的一个符号表条目)关联起来。此时,链接器就知道它的输入目标模块中的代码节和数据节的确切大小。现在就可以开始重定位步骤了,在这个步骤中,将合并输入模块,并为每个符号分配运行时地址。重定位由两步组成:
-
重定位节和符号定义。
在这一步中,链接器将所有相同类型的节**合并为同一类型的新的聚合节。**例如,来自所有输入模块的.data节被全部合并成一个节,这个节成为输出的可执行目标文件的.data节。然后,链接器将运行时内存地址赋给新的聚合节,赋给输人模块定义的每个节,以及赋给输人模块定义的每个符号。当这一步完成时,程序中的每条指令和全局变量都有唯一的运行时内存地址了。
-
重定位节中的符号引用。
在这一步中,链接器修改代码节和数据节中对每个符号的引用,使得它们指向正确的运行时地址。要执行这一步,链接器依赖于可重定位目标模块中称为重定位条目( relocation entry)的数据结构
重定位条目
当汇编器遇到对最终位置的目标引用时,就会生成一个重定位条目,告诉链接器在合并目标文件为可执行文件时如何修改这个引用。
代码的重定位条目放在 .rel.text 中,已初始化数据的重定位条目放在 .rel.data 中。
每个重定位条目都代表了一个必须被重定位的引用。
重定位条目的格式
ELF 定义了 30 种不同的重定位类型。以下是其中最基本的两种:
-
R_X86_64_PC32:重定位一个使用 32 位 PC 相对地址的引用。
-
- 什么是 PC 相对地址:一个 PC 相对地址就是距程序计数器的值的偏移量。当 CPU 执行到一条使用 PC 相对寻址的指令时,就将在指令中编码的 32 位偏移量值加上 PC 的当前运行时值,得到有效地址,PC 值通常是下一条指令在内存中的地址。
-
R_X86_64_32:重定位一个使用 32 位绝对地址的引用。通过绝对寻址,CPU 直接使用在指令中编码的 32 位值作为有效地址。
这两种类型都使用了 x86-64 小型代码模型,该模型假设可执行目标文件中的代码和数据的总体大小小于 2GB,因此可以通过 32 位地址来访问。GCC 默认使用小型代码模型。此外还有中型代码模型和大型代码模型。
重定位引用
重定位 PC 相对引用
PC 相对引用的机制:在引用中存放着与 PC 的值偏移量。这实际上是符号定义的地址与符号引用的地址差。在实际运行时,当执行到了符号引用的指令时,PC 中的值就是符号引用的地址,加上 与 PC 的偏移量(即符号定义与符号引用的地址差)就得到了符号定义的地址。
具体来说是以下状态:
在main函数中引用sum函数(在sum.o中定义,call指令开始于节偏移0xe的地方,重定位条目:
r.offset = 0xf
r.symbol = sum
r.type = R_X86_64_PC32
r.addend = -4
这些字段告诉链接器修改开始于偏移量 0xf 处的 32 位 PC 相对引用,这样在运行时它会指向 sum 例程。现在,假设链接器已经确定
ADDR(s) = ADDR(.text) = 0x4004d0
和ADDR(r.symbol) = ADDR(sum) = 0x4004e8
运行时地址:引用者的地址 = refaddr = ADDR(s) + r.offset = 0x4004d0 + 0xf = 0x4004df
然后,更新该引用,使得它在运行时指向 sum 程序:
*refptr = (unsigned) (ADDR(r.symbol) + r.addend - refaddr)
= (unsigned) (0x4004e8 + (-4) - 0x4004df)
= (unsigned) (0x5)
得到
4004de: e8 05 00 00 00 callq 4004e8 <main+0x13> #sum()
我们sum的地址是4004e8,4004de+5(这条指令占5字节)=4004e3,这是当前rip的值,rip+5(相对偏移)=4004e8,这确实是sum的地址.
重定位绝对引用
绝对引用的机制:引用中存放的就是符号定义的绝对地址
例如:
mov 指令将 array 的地址(一个 32 位立即数值)复制到寄存器%edi 中。mov 指令开始于节偏移量 0x9 的位置,包括 1 字节操作码 Oxbf,后面跟着对 array 的 32 位绝对引用的占位符。
r.offset = 0xa
r.symbol = array
r.type = R_X86_64_32
r.addend = 0
这些字段告诉链接器要修改从偏移量 0xa 开始的绝对引用,这样在运行时它将会指向 array 的第一个字节。
ADDR(r.symbol) = ADDR(array) = 0x601018
,则
*refptr = (unsigned) (ADDR(r.symbol) + r.addend)
= (unsigned) (0x601018 + 0)
= (unsigned) (0x601018)
进而
4004d9: bf 18 10 60 00 mov $0x601018,%edi # %edi = &array
可执行目标文件
-
其中 ELF头 描述了文件的总体格式,还包括程序的入口点,即程序运行时要执行的第一条指令的地址。
-
段头部表和节头部表描述了可执行文件中的片到内存映像中的段的映射关系。它描述了各节在可执行文件中的偏移、长度、在内存映射中的偏移等。
-
.text, .rodata, .data 节与可重定位目标文件中的节相似,但已经重定位到它们最终的运行时内存地址。
-
_init 节定义了一个小函数 _init,程序的初始化代码会调用它。
-
可执行文件是完全链接的,因此比可重定位目标文件少了 .rel 节。
加载可执行目标文件
Linux shell 中运行可执行目标文件的方式:在命令行中输入文件的名字(用带 ./ 的相对路径表示)。
加载和加载器
加载
加载器将可执行目标文件的代码和数据从磁盘复制到内存,然后跳转到程序的第一条指令或入口点来运行程序。
任何 Linux 程序都可以通过 execve 函数来调用加载器。
每个 Linux 程序都有一个运行时内存映像。代码段总是从 0x400000 处开始,后面是数据段,然后是运行时堆段,通过调用 malloc 库往上增长。堆后面的区域是为共享模块保留的。
用户栈总是从最大的用户地址 2^48-1 开始,向较小内存地址增长。从地址 2^48 开始是留给内核的。
在分配栈、共享库、堆的运行时地址的时候,链接器还会使用地址空间布局随机化,所以每次程序运行时这些区域的地址都会改变。
加载器的运行步骤
加载器运行时,创建一个内存映像(虚拟地址空间),在程序头部表的引导下,将可执行文件的片复制到代码段和数据段。然后加载器跳转到程序的入口点,即 _start 函数的地址(函数在系统目标文件 ctrl.o 中定义),_start 函数调用系统启动函数 __libc_start_main(定义在 libc.o 中),__libc_start_main 初始化执行环境,调用用户层的 main 函数,处理 main 函数的返回值,并在需要时把控制返回给内核。
动态链接共享库
传统方案:静态库
可以将多个相关的目标模块打包成一个单独的文件,称为静态库。
通过静态库,相关的函数可以被编译为独立的目标模块,然后封装成一个单独的静态库文件。
静态库可以用作链接器的输入。链接器在构造可执行文件时,从静态库中复制被应用程序引用的目标模块,其他未用到的模块则不会复制。
在 Linux 系统中,静态库以一种称为存档的特殊文件格式存放磁盘中。存档文件是一组连接起来的可重定位目标文件的集合,有一个头部用来描述每个成员目标文件的大小和位置。存档文件后缀名为 .a 。
理解:静态库和存档文件可以当作一个东西。存档是文件层面的描述,静态库是模块层面的描述。
静态库的创建
gcc -c addvec.c multvec.c # 将 addvec.c 和 multvec 两个文件编译成两个可重定位目标文件
ar rcs libvector.a addvec.o multvec.o # 采用 ar 工具将上一步生成的两个可重定位目标文件 addvec.o 和 multvec.o 封装到静态库 libvector.o 中。
静态库应用时的链接
以以下例程为例:
#include <stdio.h>
#include "vector.h"
int x[2] = {1, 2};
int y[2] = {3, 4};
int z[2];
int main(void)
{
addvec(x, y, z, 2);
printf("z = [%d %d]\n",z[0], z[1]);
return 0;
}
而在libvector.a里面存在:
void addvec(int *x, int *y,int *z, int n) {
int i;
for (i = 0; i < n; i++)
z[i] = x[i] + y[i];
}
void multvec(int *x, int *y,int *z, int n)
{
int i;
for (i = 0; i < n; i++)
z[i] = x[i] * y[i];
} //multvec.c
则:
链接器解析外部引用的算法:
-
按照命令行的顺序扫描.o与.a文件
-
在扫描期间,保持一个当前未解析的引用列表U
-
对于每个新的.o或.a文件(obj文件),利用该目标文件中定义的符号,尝试解析列表U中尚未解析的符号引用。
-
如在扫描结束时,在未解析符号列表U中仍存在条目,那么就报错!
问题:
-
命令行中的顺序很重要!
-
准则: 将库放在命令行的末尾
动态库的解释
静态库的缺点
- 在存储的可执行文件中存在重复(例如每个程序都需libc)
- 在运行的可执行文件中存在重复
- 系统库的小错误修复要求每个应用程序显式地重新链接
共享库
-
包含代码和数据的目标文件,在(程序)加载时或运行时,共享库被动态地加载并链接到应用程序中
-
在 linux 中,静态链接库是 .a 文件,动态链接库是 .so 文件。在windows 中,静态链接库是 .lib 文件,动态链接库是 .dll 文件。
-
共享库实例
生成共享库的方式:
gcc -shared -fpic -o libvector.so addvec.c multvec.c
#将 addvec.c 和 multvec.c 封装到动态库 libvector.so 中
# -fpic 选项指示编译器生成与位置无关的代码。
# -shared 选项指示链接器创建一个共享的目标文件。
共享库的链接方式
-
加载时链接:当可执行文件首次加载和运行时进行动态链接
- Linux的常见情况是,由动态链接器(ld-linux.so)自动处理
- 标准C 库(libc.so)通常是动态链接的
-
运行时链接:在程序开始运行后**(通过编程指令)**进行动态链接
- 在Linux中,通过调用**dlopen()**接口完成的分发软件
- 高性能web服务器
- 运行时库打桩
-
示例:
在 main2.c 函数中,调用了共享库 libvector.so 中的 addvec 函数,因此要将 main2.c 和共享库 libvector.so 链接起来。
gcc -o prog21 main2.c ./libvector.so # 创建了一个可执行目标文件 prog21
将 main2.o 和 libvector.so 链接并不是将 libvector.so 中的内容拷贝到了可执行文件 prog21 中,而是链接器复制了一些 libvector.so 中的重定位和符号表信息,以便运行时可以解析对 libvector.so 中代码和数据的引用。
动态链接器完成链接的操作:
- 重定位 libc.so 的文本和数据到某个内存段。(理解:这里的意思是将 libc.so 的内容加载到内存中?)
- 重定位 libvector.so 的文本和数据到另一个内存段。
- 重定位 prog21 中所有对由 libc.so 和 libvector.so 定义的符号的引用。
上述操作完成后,共享库的位置就固定了,且程序执行的过程中都不会改变。
理解
-
动态链接库是在程序运行或加载时才动态链接的,但并不意味着在执行之前不需要进行其他操作:链接时链接器要与动态链接库进行一次部分链接以获取到它的重定位和符号表信息。
-
要在程序中使用动态链接库,也需要在源文件中包含相关的头文件。
从应用程序中加载和链接共享库
动态链接:应用程序在运行时要求动态链接器加载和链接某个共享库(共享库即动态链接库)。
动态链接的应用:
- 分发软件。软件开发者常利用共享库来分发软件更新,它们生成共享库的新版本,用户只需要下载共享库并替代当前版本,下一次运行应用程序时,应用将自动链接和加载新的共享库。
- 构建高性能 Web 服务器:许多 Web 服务器使用基于动态链接的方法来生成动态内容。将每个生成动态内容的函数打包在共享库中,当一个浏览器请求达到时,服务器就动态加载并链接相应函数,然后直接调用它,而非创建新的进程来运行函数。
dlopen 函数
Linux 系统为动态链接器提供了一个简单接口 dlopen 函数,允许应用程序在运行时加载和链接共享库。
#include <dlfcn.h>
void *dlopen(const char *filename, int flag); //若成功就返回指向句柄的指针,否则返回 NULL。
dlopen 函数加载和链接共享库 filename
dlsym 函数
dlsym 函数用来调用共享库中的函数
#include <dlfcn.h>
void *dlsym(void *handle, char *symbol); //若成功,返回指向符号 symbol 的指针,若出错返回 NULL
两个输入参数中,handle 是一个指向前面已经加载链接了的共享库的句柄,symbol 是一个符号(可以是一个函数名)如果该符号存在,就返回符号的地址,否则返回 NULL。
以 symbol 是一个函数名为例,dlsym 返回该函数的地址,用户用一个函数指针接受返回的地址后,即可以通过该函数指针调用动态链接库中的函数。
注意:这要求提前知道动态链接库中的函数名及形参列表,返回类型。
dlclose 函数
如果没有其他共享库还在使用这个共享库,dlclose 函数就卸载该共享库。
#include <dlfcn.h>
int dlclose(void *handle); //若成功返回 0,出错返回 -1。
dlerror 函数
#include <dlfcn.h>
const char *dlerror(void); //如果前面对 dlopen, dlsym, dlclose 的调用失败,则返回用字符串表示的错误消息,否则返回 NULL。