前言
本章主要学习链接器
主要学习分离式编译、c/c++中链接导致的错误、动态库
讲的比较好的PIC实现
文件头
windows的文件头称为PE头
linux的文件头称为ELF头
.text 已编译的程序的机器代码
.rodata 只读数据
.data 已初始化的全局和静态C变量
.bss(better save space) 未初始化的全局和静态C变量,以及所有被初始化为0的全局或静态变量。仅仅是个占位符,运行时在内存中分配这些变量,初始化为0
.symtab 一个符号表,存放程序中定义和引用的函数和全局变量的信息,每个可重定位目标都有该表,除非用STRIP指令去除
.rel.text 当链接器把该目标问价和别的可执行文件合并的时候,需要修改该表(一般来说不需要)
.rel.data 被模块引用或者定义的所有全局变量的重定位信息
.debug -g选项调用编译器驱动程序时,才能得到该表
.line 行号到.text指令的映射
.strtab 一个字符串表,包括.symtab和.debug.中的符号表,以及节头部中的节名字(C-style的字符串)
.common 没有初始化的全局变量
.und 没有定义的全局函数
und、common是伪节,仅在重定位文件中,执行文件无
链接器的主要任务
1.符号解析
强符号 : 函数,已经初始化的全局变量
弱符号 : 未初始化的全局变量
约定
1.不允许多个强符号
2.一强多弱选一强
3.多弱任选一个
当弱符号的声明不匹配时,链接器并不会报错
这很容易导致某个头文件中的变量被莫名其妙的修改了...特别是这还可以导致const变量被修改
当你和别人合作时,如果有弱符号相撞...很容易导出非常难查的错误
建议设置链接器,在遇到多重定义的弱符号时,触发错误
2.重定位
符号解析完成后(将每个引用和一个符号定义关联起来),链接器就知道输入的代码节和数据节的确切大小,然后就可以开始重定位
1.步骤一 : 重定位节和符号定义
首先将所有.o文件的相同类型的节合并成一个节
然后链接器将运行时内存地址赋给新的聚合节(中的每个节、符号)
这一步完成时,每一个全局变量和指令都有唯一的运行时地址了
2.步骤二 : 重定位节中的符号引用
修改代码节和数据节中对每个符号的引用,使之指向正确的运行时地址 (这一步依赖于重定位条目)
当汇编器遇到位置未知的引用,就会生成一个重定位条目(重定位条目放在.rel.date和.rel.text中)
重定位条目表
typedef struct
{
long offset; //需要被修改的引用的节偏移
long type : 32; //重定位类型
long symbol : 32; //被修改引用应该指向的符号
long addend; //一个有符号常数,一些重定位类型需要它对被修改引用的值做偏移调整
} Elf64_Rela;
重定位算法
ELF中一共有32种重定位条目类型,我们只关心两种
因为内存对齐的原因,磁盘上的符号地址和运行时符号地址有出入
从磁盘文件到装载到内存,节的起始地址会变,但是节内偏移不变
用ADDR(s)代表符号s的运行时地址,假设下面算法运行时,链接器已经为每个节和符号安排了运行时地址
for (s : section) //枚举每个节
{
for (r : relocation_entry in s) //枚举每个节中的重定位条目
{
refptr = s + r.offset; //需要修改的地方
if (r.type == R_X86_64_PC32) //相对地址引用,pc + 偏移量
{
//*refptr = 目标符号的运行时地址 - 引用的运行时地址 + r.addend
refaddr = ADDR(S) + r.offset; //这里求出了运行时的地址
*refptr = ADDR(r.symbol) - refaddr;
*refptr += r.addend;
}
else if (r.type == R_X86_64_32) //绝对地址引用
{
*refptr = ADDR(r.symbol) + r.addend;
}
}
}
如何加载可执行文件
每个Linux程序都有一个运行时内存映像:
在Linux X86-64系统中,代码总是从地址0x400000开始,后面是数据段,运行时堆在数据段之后,通过调用malloc库往上增长,堆后面的区域是为共享模块保留的。
加载器实际是如何工作的?咕咕咕
静态库
https://www.cnblogs.com/skynet/p/3372855.html
简单来说,就是提供一种打包机制,简化链接。Linux下使用ar工具、Windows下vs使用lib.exe
优点是运行时无需进一步的链接,缺点是比较浪费空间,并且在更新时要重新编译整个文件
链接器如何使用静态库解析引用
比较绕,先来捋捋概念
1.目标文件 : .o文件
2.存档文件 : 也就是静态库
3.可重定位目标文件的集合 E
4.一个未解析的符号集合 U
5.一个在前面输入文件中已定义的符号集合 D
开始时,EDU都为空
注意事项
1.可重定位文件a,b某些部分可能会相互引用,这导致了命令行中的文件名可能需要多次出现消除依赖
2.解析过程和写c/c++的直觉相反,c/c++先写头文件,再写main,链接过程是类似main.o在前,lib_std.o在后面(一般把库.o写到最后面)
3.有依赖关系的库一定要做拓扑排序,并可能多次出现以消除依赖
解析步骤
1 对于命令行上的每个文件 f ,链接器会判断 f 是一个目标文件还是存档文件
1.1 如果是目标文件
链接器将会把这个文件添加到集合E,并根据符号引用情况修改集合U和D的状态。然后处理下一个文件。
1.2 如果是存档文件
链接器将尝试匹配集合U中未解析的符号和存档文件成员定义的符号,如果存档文件的成员m定义了一个符号来解析U中的一个引用,
那么就将m加入到集合E中,然后修改U和D的状态。对存档文件中的每个成员都重复这个过程,直到U和D不再发生变化,然后简单地丢弃不包含在集合E中的成员目标文件。然后链接器继续处理下一个文件。
2 判断集合U是否为空
如果链接器扫描完命令行上的所以文件后,集合U仍不为空,则说明引用了未定义的符号,则链接器将会报错并终止程序。
如果链接器扫描完命令行上的所以文件后,集合U仍为空,则将合并和重定位E中的目标文件,并输出可执行文件。
动态库
特点
1.节省空间(一个动态库,在内存中只有一份拷贝)
2.动态库把对一些库函数的链接载入推迟到程序运行的时期
3.可以实现进程之间的资源共享。(因此动态库也称为共享库)
4.将一些程序升级变得简单
5.甚至可以真正做到链接载入完全由程序员在程序代码中控制(显示调用)
6.动态库在处理类型、虚函数,方面有一些很大的缺陷 : DLL Hell
动态链接共享库
使用动态库生成可执行文件的过程中,静态的执行一部分链接,然后在程序加载时,动态完成剩余部分的链接过程。没有任何的动态库代码和数据节真的被复制到可执行文件中,而是,复制了一些重定位和符号表信息,它们使得运行时可以解析对动态库中代码和数据的引用。
咕咕咕
位置无关代码 PIC
现代系统使用一种方法来编译共享模块的代码段,使得可以把它们加载到内存的任何位置而无需链接器修改。
1.mov指令要求一个绝对地址,怎么转化成相对地址
重要特性
1.数据段和指令段之间的距离是一个常量
2.X86上指令相对偏移的计算
如何把数据绝对地址变为数据相对地址
思路 :
1.段与段之间的距离是固定的
2.通过拿到指令地址计算出指令段的地址
3.计算出数据段的地址
那么就可以计算出数据段的地址(x64上可以直接访问RIP,x86不行),取巧的办法
call GET_ADDR // 将下一条指令的地址压栈
GET_ADDR :
pop ebx // 弹栈得到ip寄存器的值
用全局偏移表GOT来实现数据位置无关
GOT是一张在date数据段中存储的表,里面记录了很多全局变量的段内绝对地址
假设一条指令想要引用一个变量,并不是直接去用绝对地址,而是去引用GOT里的一个entry。
通过计算GOT表的地址 + entry的偏移,即得到数据的绝对地址
PIC代码具有性能上的缺陷。现在每次全局变量引用都需要5条指令而不是1条,同时GOT还需要占用额外的内存空间。并且,PIC代码需要使用额外的寄存器来保存GOT表项的地址。在寄存器较多的机器上,这不是什么大问题。但是在寄存器较少的IA32系统中,缺少哪怕一个寄存器都可能会触发将寄存器内容暂存在堆栈里。
2.怎么延迟绑定函数地址
延迟绑定需要在两个数据结构之间进行密集而复杂的交互
GOT和过程连接表(procedure linkage table, PLT)。
如果一个目标模块调用了共享库中的任意函数,那么它就有它自己的GOT和PLT。
GOT是.data段的一部分。PLT是.text段的一部分。
库打桩机制
和windows下的hook类似,可以拦截动态库中的调用
下面是它的基本思路:给定一个需要打桩的目标函数,创建一个包装函数,它的原型与目标函数完全一样。使用某种特殊的打桩机制,你就可以欺骗系统调用包装函数而不是目标函数了。包装函数通常会执行它自己的逻辑,然后调用目标函数,再将目标函数的返回值传递给调用者。
打桩可以发生在编译时、链接时或当程序被加载和执行的运行时。
库打桩最重要的就是实现mymalloca能调用malloca这样,自引用
1.编译时打桩(需要访问源代码)
int.c
#include <stdio.h>
#include <malloc.h>
int main()
{
int *p = malloc(32);
free(p);
return 0;
}
malloc.h
#define malloc(size) mymalloc(size)
#define free(ptr) myfree(ptr)
void* mymalloc(size_t size);
void myfree(void *ptr);
mymalloc.c
#ifdef COMPILETIME
#include <stdio.h>
#inlcude <malloc.h>
void* mymalloc(size_t size)
{
void *ptr = malloc(size);
printf("malloc(%d) = %p\n",int(size),ptr);
return ptr;
}
void myfree(void *ptr)
{
free(ptr);
printf("free(%p)\n",ptr);
}
#endif COMPILETIME
-I.:指示C预处理器在搜索通常的系统目录前,先在当前目录中查找malloc.h
gcc -DCOMPILETIME-c mymalloc.c //-DCOMPILETIME等效于#define DCOMPILETIME
gcc -I -o intc int.c mymalloc.o
待实操
2.链接时打桩(需要访问可重定位对象文件)
#ifdef LINKTIME
#include <stdio.h>
void *__real_malloc(size_t size);
void __real_free(void *ptr);
void *__wrap_malloc(size_t size)
{
void* ptr = __real_malloc(size); // 调用libc::malloc
printf("malloc(%d) = %p\n",int(size),ptr);
return ptr;
}
void __wrap_free(void *ptr)
{
__real_free(ptr); // 调用 libc::free
printf("free(%p)\n",ptr);
}
#enif
gcc -DLINKTIME -c mymalloc.c
gcc -c int.c
gcc -W1,--wrap,malloc -W1,--wrap,free -o int1 int.o mymalloc.o
-W1,option标志把option传递给链接器,option中的每个逗号都要替换为一个空格,所以-w1,--wrap,malloc
就把--wrap malloc传递给链接器
3.运行时打桩(需要访问可执行文件)
这个很厉害的机制基于动态链接器的LD_PRELOAD环境变量
待填