链接
链接是将各种代码和数据片段收集并组合成为一个单一文件的过程,这个文件可被加载到内存并执行
大致过程如下
- 预处理阶段:处理以 # 开头的预处理命令
- 编译阶段:翻译成汇编文件
- 汇编阶段:将汇编文件翻译成可重定位目标文件
- 链接阶段:将可重定位目标文件和 printf.o 等单独预编译好的目标文件进行合并,得到最终的可执行目标文件
一个链接的例子
// main.cint sum(int *a, int n);int array[2] = {1,2};int main(){ int val = sum(array, 2); return val;}// sum.cint sum(int *a, int n){ int i, s = 0; for (i = 0; i < n; n++) { s += a[i]; return s; }}
gcc -0g -o prog main.c sum.c
不管是读数据,调用函数还是读指令,对于 CPU 而言都是一个个的内存地址。因此,这里就需要一个连接 CPU 与程序员之间的桥梁,把程序中的符号转换成 CPU 执行时的内存地址。这个桥梁就是链接器,它负责将符号转换为地址
链接器的第一个作用就是把多个中间文件合并成一个可执行文件,多个中间文件的代码段会被合并到可执行文件的代码段,它们数据段也会被合并为可执行文件的数据段。链接器在合并多个目标文件的时候并不是简单地将各个 section 合并就可以了,还需要考虑每个目标中的符号的地址,即重定位,就是当被调用者的地址变化了,要让调用者知道新的地址是什么
两步链接
- 链接器需要对编译器生成的多个目标(.o)文件进行合并,一般采取的策略是相似段的合并,最终生成共享文件 (.so) 或者可执行文件。这个阶段中,链接器对输入的各个目标文件进行扫描,获取各个段的大小,并且同时会收集所有的符号定义以及引用信息,构建一个全局的符号表,根据符号表,也就能确定了每个符号的虚拟地址
- 对整个文件再进行第二遍扫描,这一阶段,会利用第一遍扫描得到的符号表信息,依次对文件中每个符号引用的地方进行地址替换。也就是对符号的解析以及重定位过程
静态链接
- 符号解析:每个符号对应于一个函数、一个全局变量或一个静态变量,符号解析的目的是将每个符号引用与一个符号定义关联起来
- 重定位:链接器通过把每个符号定义与一个内存位置关联起来,然后修改所有对这些符号的引用,使得它们指向这个内存位置
目标文件
- 可重定位目标文件(.o文件):可与其它可重定位目标文件在链接阶段合并,创建一个可执行目标文件
- 可执行目标文件(.out文件):可以直接在内存中执行
- 共享目标文件(.so文件):一种特殊的可重定位目标文件,可以在运行时被动态加载进内存并链接
可重定位目标文件
ELF 文件格式把各种信息,分成一个一个的 Section 保存起来
- .text Section,也叫作代码段或者指令段(Code Section),用来保存程序的代码和指令
- .data Section,也叫作数据段(Data Section),用来保存程序里面设置好的初始化数据信息
- .rodata:只读数据,例如字符串常量、const 的变量
- .bss:未初始化全局变量,运行时会置 0
- .strtab:字符串表、字符串常量和变量名
- .rel.text Secion,叫作重定位表(Relocation Table)。重定位表里,保留的是当前的文件里面未知的一些函数跳转地址,比如printf函数
- .symtab Section,叫作符号表(Symbol Table)。符号表保留了我们所说的当前文件里面定义的函数名称和对应地址的地址簿
符号和符号表
- 全局符号
- 外部符号
- 局部符号
/* ELF符号表条目 */typedef struct { int name; /* String table offset */ int value; /* Section offset,or VM address */ int size; /* Obiect size in bytes */ char type:4, /* Data,func,soction,or src file name (4 bits) */ binding:4; /* Local or global (4 bits) */ char reserved; /* Unused */ char section; /* Soction hoader index,ABS.UNDEF */} Elf_Symbol;
符号解析
处理多重定义的全局符号
- 不允许有多个同名的强符号
- 如果强符号和弱符号同名,则选择强符号
- 如果多个弱符号同名,则随意选择一个
与静态库链接
链接器使用静态库解析引用
重定位
各种符号的处理方式
- 局部变量:局部变量的内存分配与释放,都是在运行时通过 %rbp 的改变来进行的
- 静态函数:静态函数的调用地址在编译阶段就可以确定
- 外部变量、全局变量以及静态变量:初始生成时,编译器会将这些变了的地址填为0,以后链接器再将真正的地址回填
占位符处理
由编译器填 0 之后,链接器就会根据目标文件中的重定位表,链接器在处理目标文件的时候,需要对目标文件里代码段和数据段引用到的符号进行重定位,而这些重定位的信息都记录在对应的重定位表里
每个重定位项都会包含需要重定位的偏移、重定位类型和重定位符号。重定位表的数据结构是这样的:
typedef struct { Elf64_Addr› r_offset; /* 重定位表项的偏移地址 */ Elf64_Xword› r_info; /* 重定位的类型以及重定位符号的索引 */ Elf64_Sxword› r_addend; /* 重定位过程中需要的辅助信息 */} Elf64_Rela;
对于类型为 R_X86_64_PC32 的符号,如全局变量、外部变量,重定位计算方式为:S + A – P
- S 表示完成链接后该符号的实际地址。在链接器将多个中间文件的段合并以后,每个符号就按先后顺序依次都会分配到一个地址
- A 表示 Addend 的值,代表了占位符的长度
- P 表示要进行重定位位置的地址或偏移,可以通过 r_offset 的值获取到
对于静态变量,由于只在本编译单元内可见,所以最终地址就是本编译单元的.data 段的最终地址
可执行目标文件
加载可执行目标文件
动态链接
动态链接常见共享库为 so 文件或 dll 文件
在给定的文件系统中一个库只有一个文件,所有引用该库的可执行目标文件都共享这个文件,它不会被复制到引用它的可执行文件中
在内存中,一个共享库的 .text 节(已编译程序的机器代码)的一个副本可以被不同的正在运行的进程共享
得益于虚拟内存的存在,使得不同进程即使内存地址不同,也能通过动态链接加载同一份代码与数据
多了两个 section,一个是.plt,过程链接表(Procedure Linkage Table,PLT),一个是.got.plt,全局偏移量表(Global Offset Table,GOT)
动态链接带来的代价:
- 每次对全局符号的访问都要转换为对 GOT 表的访问,进行间接寻址,会比直接寻址慢
- 动态链接将链接中重定位的过程推迟到程序加载时进行。因此在程序启动的时候,动态链接器需要对整个进程中依赖的动态库进行加载和链接
从应用程序中加载和链接共享库
JNI
位置无关代码
如果两个共享库之间有引用关系的话,引用者和被引用者之间的相对位置就不能确定了,这时就需要引入地址无关代码技术。对于内部函数或数据访问,因为其相对偏移是固定的,所以可以通过相对偏移寻址的方式来生成代码;对于外部和全局函数或数据访问,则通过 GOT 表的方式,利用间接跳转将对绝对地址的访问转换为对 GOT 表的相对偏移寻址
可以加载而无需重定位的代码称为位置无关代码
- PIC数据引用
- PIC函数调用
延迟绑定
为了避免在加载时就把 GOT 表中的符号全部解析并重定位,就需要使用到延迟绑定,延迟绑定就是在 GOT 之前,插入了一个 plt
plt[x]->got[y](发现没有地址)->plt[0]->got[2](存了一个特殊的动态链接库ld-Linux.so,他会负责找到链接的函数)->将找到的地址存回got[y]
loader 通过动态修改 GOT 段,完成延迟绑定的功能
库打桩机制
- 编译时打桩
- 链接时打桩
- 运行时打桩