.c怎么转.exe?10000字长文带你深剖编译过程!
链接
链接:链接是将各种代码和数据收集并组合成一个文件的过程,最终得到的文件可以被加载到内存执行。
早期的计算机系统中,链接是手动执行的,到了现代计算机系统中,链接是由链接器自动完成的。在大型应用程序的开发过程中,我们不可能将所有功能实现都放在一个源文件中,而是分解成更小的模块,当我们修改其中一个模块时,我们只需要重新编译这个修改后的模块,而其它模块是不需要重新编译的。
解决的问题
1、构建大型程序
当我们遇到缺少库文件或者库文件版本不兼容而导致的链接错误,就需要理解链接器是如何使用库文件来解析引用的。
2、避免一些危险的编程错误
后面介绍具体示例。
3、理解语言的作用域是如何实现的
例如,全局变量和局部变量的区别是什么,当我们看到一个static属性的变量或者函数时,实际的意义到底是什么?
4、理解其他重要的系统概念
例如程序的加载和运行、虚拟内存、内存映射等等
5、更好的利用共享库
编译过程
以下有两个c文件:main.c和sum.c
main.c
int sum(int* a, int n);
int array[2] = {1, 2};
int main(){
int val = sum(array, 2);
return val;
}
sum.c
int sum(int* a, int n){
int i, s = 0;
for(i = 0; i < n; i++){
s += a[i];
}
return s;
}
以上代码执行的过程是,main.c文件中创建了一个整数数组array,并在main函数中调用sum函数对整数数组进行求和。
在Linux系统中,输入以下命令得到可执行程序prog(或别的可执行文件名),关于以下命令的理解戳这里
Linux> gcc -Og -o prog main.c sum.c
编译流程图
开始手动编译:
预处理阶段
运行以下命令,将源程序main.c翻译成main.i
Linux> cpp -o main.i main.c
cpp指的是C预处理器(c preprocessor)
使用gcc完成相同预处理任务命令如下
Linux> gcc -E -o main.i main.c
-E选项限制gcc只进行预处理,不做编译、汇编以及链接的操作。
main.i文件是一个ASCII码的中间文件
预编译中进行的操作(都属于文本操作)
- 头文件的包含
- define定义符号的替换
- 注释删除
编译阶段
运行以下命令,将main.i编译成main.s
Linux> cc -S -o main.s main.i
cc指的是c编译器(c compiler)
使用gcc完成相同编译任务命令如下
Linux> gcc -S -o main.s main.i
-S选项表示只对文件进行编译,不做汇编和链接的处理。
编译中做的操作:
- 把C语言代码翻译成汇编代码
汇编阶段
运行以下命令,将main.s汇编成一个可重定位目标文件main.o(可重定位在后面)
Linux> as -o main.o main.s
as指的是汇编器(assembler)
使用gcc完成相同汇编任务命令如下
Linux> gcc -c -o main.o main.s
经过同样操作,还可以获得sum.o文件。
汇编中做的操作:
- 形成符号表
- 把汇编指令翻译成二进制指令
链接阶段
将可重定位目标文件以及必要的系统文件组合起来,以下命令将main.o、sum.o、ctr1.o、crti.o、crtbeginT.o、crtend.o、crtn.o链接在一起打包成.exe文件,其中crt是c runtime的缩写。
具体命令如下
ld指的是链接器,-static表示采用静态链接的方式
链接中做的操作:
- 合并段表
- 符号表的合并和重定位
在shell中执行以下命令运行得到的exe文件
Linux> ./prog
运行是shell调用操作系统中的加载器(loader)函数实现的,加载器将可执行文件中的代码和数据复制到内存中,然后将CPU的控制权转移到prog的程序开头,随后开始执行。
以上就是程序从源文件到可执行文件的整个过程。
可重定位目标文件
示例代码
main.c
#include<stdio.h>
int count = 10;
int value;
void func(int sum){
printf("sum is:%d\n", sum);
}
int main(){
static int a = 1;
static int b = 0;
int x = 1;
func(a + b + c);
return 0;
}
ELF文件
执行以下命令:
Linux> gcc -c main.c
Linux> wc -c main.o
wc指的是查看文件main.o的大小,-c表示以字节形式统计。
每一个可重定位目标文件大致可以分为下图三个部分。
通过以下命令读取ELF文件相关内容
Linux> readelf -h main.o
-h指的是只显示header信息
输出结果:
看到开始的十六个字节
- 前四个字节表示魔数,魔数是用来确认文件类型的,操作系统在加载可执行文件时会确认魔数是否正确,如果不争取就会拒绝加载。
- 第五个字节用来表示ELF文件类型,0x1表示32位,0x2表示64位。
- 第六个字节表示字节序,0x1表示小端法,0x2表示大端法。
- 第七个字节表示ELF文件的版本号,通常都是1。
- 最后九个字节ELF的标准中没有定义,用0填充。
根据Type信息,我们可以看到文件类型是REL可重定位文件,除此之外还有可执行文件和共享文件两种类型。
根据Size of this header信息,确定了ELF header的长度位64字节,所以可以确定section在ELF文件中的起始位置是0x40。
根据Number of section headers和Size fo section headers,我们可以计算出整个表的大小是832字节 。
根据Starr of section headers,可以计算出整个ELF文件大小为1896字节。
Section属性
使用以下命令打印描述不同section属性的表。
Linux> readelf -S main.o
运行结果:
除了第一项,其他每个表项都对应一个section,我们可以看到整个ELF文件一共包含12个section。
其中offset表示每个section的起始位置,size表示section的大小。
- .text这个section中存放的是已经编译好的机器代码
- .data这个section是用来存放已初始化的(全局变量和静态变量)
- .bss这个section是用来存放未初始化的(全局变量和静态变量)以及被初始化为0的(全局变量和静态变量),可以看到.bss只占了4个字节且起始位置和.rodata这个section的起始位置一样,它仅仅是一个占位符,并不占据实际空间,区分已初始化和未初始化的变量是为了节省空间,当程序运行时,会在内存中分配这些变量,并把初始值设为0。
- .rodata这个setion是用来存放只读数据的。
- 其他的setion来保存与程序相关的信息,如下图。
符号和符号表
查看符号表(.symtab)命令如下。
Linux> readelf -s main.o
运行结果:
我们可以看到符号func和符号main,分别对应我们定义的函数main和func,所以在符号表Type一列类型为FUNC(函数),由于函数main和func是全局可见的,所以Bind字段为GLOBAL(全局可见),Ndx一列表示section的索引值(UND——(undefined)未定义),索引值可根据section header table的第一列来确定,Value表示函数相对于.text Section起始位置的偏移量,Size表示所占字节数,Vis在C语言中未使用,可忽略。
这里主要到Ndx为com的时候,其实表示在COMMON Section中,COMMON和.bss区别如下图。
a.2254和b.2255是名称修饰,放置静态变量的名称冲突。
此外还有一些符号名没有显示的表项,实际上他们的符号名就是section名称,例如索引值为2的Ndx等于1,那么它的符号名就是.text。
局部变量在运行时栈中被管理,链接器对此类符号不感兴趣,所以局部变量的信息不会出现在符号表中。
总结:
对于每个可重定位目标文件都有一个符号表,这个符号表包含该模块定义和引用的符号信息,在链接器的上下文中,有三种不同的符号。
- 第一种是由该模块定义,同时能被其他模块引用的全局符号
- 第二种是被其他模块定义,同时被该模块引用的全局符号,这些符号称为外部符号
- 第三种时只能被该模块定义和引用的局部符号(static)
符号解析与静态链接
示例代码
linkerror.c
void foo(void);
int main(){
foo();
return 0;
}
找不到符号定义
如果我们对以上文件进行编译和汇编操作,而不进行链接操作,命令如下。
gcc -c linkerror.c
此时编译和汇编是没有问题的,这是因为当编译器遇到一个不是在当前模块定义的符号时,它会假设该符号是在其他某个模块中定义的(等待链接过来)。
输入命令打开符号表查看一下,发现汇编器为foo生成了相应的符号,尽管源程序中只是声明了函数foo。
readelf -s linkerror.o
当链接生成可执行文件时,链接器在其他输入模块中都找不到这个被引用符号(foo)的定义,此时就会输出一条错误信息,并且终止链接的操作,具体命令如下。
gcc -Wall -Og -o linkerror linkerror.c
-Wall指的是显示所有警告。
输出结果:
多个可重定位文件中定义了同名的全局符号
强符号:函数和已初始化的全局变量
**偌符号:**未初始化的全局变量
在编译时,编译器向汇编器输出每个全局符号,汇编器把这个强弱信息隐含的编码放在符号表中
处理多重定义的符号名,分三种情况:
1、多个同名的强符号一起出现
例如下图
图中两个源文件中都有函数名为main的函数,在这种情况下,链接器将生成一条错误信息,这是因为强符号main被定义了两次。
除此之外,具有相同的已初始化的全局变量名也会产生类似的错误,如下图。
2、一个强符号和多个同名弱符号一起出现
如下图。
图中bar3.c中的x未初始化,所以链接器会选择foo3.c中定义的强符号,此时链接器可以生成可执行文件,不会报错。
3、如果x都是未初始化的,那么它们都属于弱符号
此时链接器任意选择一个作为x。
典型错误示例
在x86-64机器上,double类型占8个字节,int类型占4个字节,此时在f中执行的代码会把y的地址也一同修改,此时就出现错误。为了避免这类错误,可以在编译时添加**-fno-common的编译选项,这个选项会告诉链接器,当遇到多重定义的全局符号时,触发一个错误,或者使用-Werror**选项,这个选项会把所有的警告都变成错误。
链接器如何使用静态库
c语言平常使用的prinf就是放在静态库中。以ISO C99为例,它定义了标准的I/O、字符串操作和整数数学函数(例如函数atoi、printf、scanf、strcpy、rand等等这些函数都在libc.a的库中)
在Linux系统中,静态库以一种称为archive的特殊文件格式存放在磁盘上,archive文件是一组可重定位目标文件的集合。
通过以下命令查看libc.a这个静态库包含哪些目标文件
objdump -t /usr/lib/x86-64-Linux-gnu/libc.a > libc
将输出的内容重定向到目标文件libc中,使用以下命令搜索到printf.o
grep -n printf.o libc
打开文件,可以看到printf被定义在了printf.o中。
这里也可以使用ar命令,将libc.a中所有的目标文件解压到当前目录。
ar -x /usr/lib/x86-64-Linux-gnu/libc.a
统计出libc.a中一共包含多少个目标文件
ls | wc -l
输入ls可以查看有哪些文件。
静态库是如何构造的
示例代码
addvec.c
int addcnt = 0;
void addvec(int *x, int*y, int *z, int n){
int i;
addcnt++;
for(i = 0; i < n; i++){
z[i] = x[i] + y[i];
}
}
multvec.c
int multcnt = 0;
void multvec(int *x, int *y, int *z, int n){
int i;
multcnt++;
for(i = 0; i < n; i++){
z[i] = x[i] * y[i];
}
}
执行以下命令
gcc -c addvec.c mulvec.c
ls
此时得到以下文件
输入以下命令,构建静态库文件
ar rcs libvector.a addvec.o mulvec.o
此时我们得到了一个名为libvector.a的静态库。
静态库的用法
代码示例
#include<stdio.h>
#include"vector.h"
int x[2] = {1, 2};
int y[2] = {3, 4};
int z[2];
int main()
{
addvec(x, y, z, 2);
printf("z = [%d %d]\n", z[0], z[1]);
return 0;
}
函数main调用了函数addvec,其中头文件vector.h中定义了libvector.a中的函数原型,为了创建可执行文件,我们首先编译源文件main.c,然后在链接的时候加入静态库libvector.a。
Linux> gcc -c main.c
Linux> gcc -static -o prog main.o ./libvector.a
指定-static选项,gcc在连接时对项目所有的依赖库都尝试去搜索名为lib.a的静态库文件,完成静态连接,如果找不到就报错了。
链接流程
当链接器运行时,它确定了main.o中引用了addvec.o中定义的addvec符号,所以链接器就从libvector.a中复制addvec.o到可执行文件,因为程序中没有引用mulvec.o中定义的符号,所以链接器就不会将这个模块复制到可执行文件,除此之外,链接器还会从libc.a中复制printf.o模块以及其他C runtime所需的模块,最终将这些文件打包成可执行文件。
链接器是如何使用静态库来解析引用的
1、符号解析阶段
链接器从左到右按照命令行中出现的顺序来扫描可重定位文件和静态库文件,例如针对以下命令,链接器先处理main.o,再处理libvector.a,最后处理libc.a。
Linux> gcc -static -o prog main.o ./libvector.a
由于编译器链接程序总是会把libc.a传给链接器,所以命令中不必显式的引用libc.a。
在扫描的过程中,链接器一共维护了三个集合
- 第一个是集合E:在链接器扫描的过程中发现了可重定位目标文件就会放到这个集合中,在链接即将完成的时候,这个集合中的文件最终会被合并起来形成可执行文件。
- 第二个是集合U:链接器会把引用了但是尚未定义的符号放在这个集合里
- 第三个是集合D:它用来存放输入文件中已定义的符号
链接最开始时,这三个集合均为空,对于命令行上的每一个输入文件f,链接器都会判断f是一个目标文件还是一个静态库文件,如果f是一个目标文件,那么链接器会把f添加到集合E中,同时修改集合U和D来反映f中的符号定义和引用,例如针对上述命令,链接器会判断main.o是一个目标文件那么链接器会把main.o放到集合E中。
通过main.o的符号表可以看到这个目标文件中存在两个不在当前模块中定义的符号分别是addvec和printf。
接下来,链接器把符号addvec和printf放到集合U中,此时链接器假设它们在其他模块被定义,所以并不会报错,除此之外,main.o中已经定义的全局符号x、y、z以及main会放到集合D中,此时集合D和U分别反映了main.o的符号定义以及引用。
当文件main.o处理完之后,然后继续处理下一个输入文件,此时我们发现下一个文件是一个静态库文件,那么链接器就尝试在这个静态库文件中寻找U中未解析的符号,当链接器发现./libvector.a静态库中的成员addvec.o中存在未定义的符号addvec的定义,此时就把addvec.o加到集合E中,然后将集合U中的addvec删除,如果addvec.o中还定义了其他的符号,还要添加到集合D中,所以addcnt也要被添加到集合D中,链接器处理完成员addvec.o之后,还要处理mulvec.o,对于静态库文件中的所有成员目标文件都要依次进行上述处理过程,直到集合U和集合D不再发生变化。
此时,任何不包含在集合E中的成员目标文件都被简单的丢弃,对于这个例子,mulvec.o被丢弃,addvec.o被保留,最后链接器还要扫**描libc.a,printf会加入集合E中,**集合U中的printf被删除,上述操作执行完毕后,如果集合U是空的,链接器会合并集合E中的文件来生成可执行文件,如果链接器完成对命令行所有的输入文件的扫描后,集合U是非空的,那就说明程序中使用了未定义的符号,此时链接器就会输出一个错误并终止。
以上就是链接器使用静态库来解析引用的过程,这里要注意的是输入顺序,如果将输入顺序调换,那么链接就会失败,用上述过程走一遍就知道原理。
通常情况下,关于库的一般使用准则就是将它们放在命令行的结尾,如果各个库是相互独立的,也就是各个库成员之间没有相互引用时,那么这些库就可以以任意顺序放在命令行的结尾处,另一方面,如果库不是独立的,那么必须对它们进行排序。
**如果出现了相互引用,库需要重复出现!**例如foo.c调用了libx.a中的函数,其中libx.a调用了liby.a中的函数,然而liby.a又调用了libx.a中的函数,需执行以下命令或者把libx.a和liby.a合并成一个静态库文件。
Linux> gcc foo.c libx.a liby.a libx.a
重定位
重定位:在这个过程中,链接器合并输入模块,并为每个符号分配运行时地址
具体重定位的过程分为两步:1.重定位节和符号定义、2.重定位节中的符号引用
重定位节和符号定义
再次看到这个代码示例
main.c
int sum(int* a, int n);
int array[2] = {1, 2};
int main(){
int val = sum(array, 2);
return val;
}
sum.c
int sum(int* a, int n){
int i, s = 0;
for(i = 0; i < n; i++){
s += a[i];
}
return s;
}
链接器把main.o和sum.o中所有相同类型的section合并为一个新的section,例如,新合成的text section就是可执行文件的text section。合成之前,main.o和sum.o中的text section都是从0开始的,假设合成后的text section的起始地址是0x4004d0,原因是在64位的Linux系统中,ELF可执行文件默认从地址0x400000处开始分配,由于ELF文件的header以及text section之前还有一些其他的信息,所以假定text section是从4004d0开始的。
这一步完成后,程序中的每条指令和全局变量都有了唯一的运行时内存地址。
重定位section中的符号引用
例如函数main调用了函数sum,下图中这条call指令所对应的就是函数sum的调用,此时call指令的目的地址是0,显然这不是函数sum的真正地址。
在这一步中,链接器需要修改对符号sum的引用,使它指向正确的运行时地址,这一步依赖于可重定位条目的数据结构。
重定位条目
可重定位目标文件是由汇编器产生的,当汇编器在生成可重定位目标文件时,并不知道数据和代码最终放在内存的什么位置,除此之外,汇编器也不知道该模块所引用的外部定义的函数以及全局变量的位置,所以当汇编器遇到最终位置不确定的符号引用时,它就产生一个重定位条目。
这个可重定位条目的功能是用来告诉链接器在合成可执行文件时应该如何修改这个引用,关于代码的重定位条目放在**.rel.text中,对于已初始化数据的重定位条目放在.rel.data**中。
结构体定义如下
每一个条目由四个字段组成:
- 第一个字段offset表示被修改的引用的节偏移量
- 链接器会根据第二个字段type来修改新的引用
- 第三个字段symbol表示被修改的引用是哪一个符号
- 第四个字段是符号常数,一些类型的重定位要使用该符号常数对被修改应用的值做偏移调整
ELF中定义了多达32种不同的重定位类型,不过我们只需要关注其中两种最基本的重定位类型即可,分别是相对地址的重定位和绝对地址的重定位,如下图。
链接器如何使用重定位条目来进行重定位
重定位相对引用
指令call的起始地址位于字节偏移0xe的位置,oxe8表示指令操作码,在重定位之前,紧跟在操作码之后的内容被汇编器填充为0,接下来,链接器需要根据重定位条目来确定这部分内容,对于函数sum的重定位条目由4个字段组成,如下图。
首先,链接器需要根据重定位条目计算出引用的运行时地址,具体计算方法是通过main的起始地址与重定位条目中的偏移量字段相加,函数main的起始地址在重定位第一步可得到,前面我们假设为0x4004d0,这样一来,我们就得到了引用的运行时地址,然后更新这个符号引用,使得它在运行时指向sum函数(起始地址假设为0x4004e8)。具体计算方法是用sum的起始地址减去刚才计算得到的运行时地址然后再加上addend字段做一下修正(因为pc(rip)已经跳转到下一条指令,故需算术调节回来)实际上这一步就是求两个地址之间的相对位置。
经过上述计算,在最终得到的可执行程序中,call指令的形式如下图所示。
也就是说程序运行时,指令call存放在地址0x4004de处,当CPU执行call指令时,此时PC的值为0x4004e3,因为PC的值为正在执行指令的下一条指令的地址。
执行这条指令分为两步
第一步,CPU先把PC的值压入栈中,因为指令call要执行函数调用,接下来要发生跳转,函数执行完毕后,还要继续执行这一条add指令,所以要先把PC的值压栈保存。
第二步,修改PC的值,具体的修改方式就是用当前PC的值加上偏移量,根据刚才计算得到的偏移量为0x5,二者相加得到的地址为0x4004e8,恰好就是函数sum的第一条指令。
综上所述,就是重定位相对引用的具体过程。
重定位绝对引用
如下图,mov指令把array的起始地址传给了寄存器edi,这一条mov指令的起始地址是0x9,bf表示mov指令的操作码,紧跟在bf之后的就是对符号array引用的绝对地址,对符号array的引用也对应一条可重定位条目。
其中offset字段告诉编译器要从偏移量0xa开始修改,这里的类型是绝对地址引用,addend字段默认为0。
假设链接器已经确定array所在的data section位于0x601018处,所以这里的绝对地址引用就是0x601018。
当执行完重定位之后,这一条mov指令中的源操作数由0变成了0x601018,由于x86采用小端法存储数据,所以这里按着字节逆序进行替换,结果如下图所示。
以上就是重定位绝对地址引用的计算过程。
经过上述重定位之后,我们就可以确定目标文件的text section 和 data section的内容,当程序执行加载的时候,加载器会把这些section中字节直接复制到内存里,不用执行任何修改就可以执行。
可执行文件
格式
可执行文件相关信息如下图。
可执行文件格式和可重定位目标文件的格式对比如下图。
可执行文件也包含一个ELF header,它描述了该文件的总体格式,其中有一项是程序的入口,也就是程序运行时要执行的第一条指令的地址。
可执行文件中的**.init节定义了一个名为_init的函数**,程序的初始化代码会调用这个函数进行初始化,关于.text、rodata以及.data section与可重定位目标文件中的节是类似的,不过这些节已经被重定位到最终的运行时内存地址上,因此可执行文件中不再需要rel section,以上就是可执行文件的大致情况。
程序运行时,可执行文件中的代码和数据需要被加载到内存执行,不过还有一部分内容不会被加载到内存,例如符号表和调试信息,具体如下图所示。
程序头部表(段头部表)
程序头部表中描述了代码段,数据段与内存的映射关系,具体如下图所示。
代码段和内存的映射关系
首先是只读代码段,flags标志中的r-x表示这个段只有读和执行的权限;同样对于数据段,**flags标志中的rw-**表示可读可写,但是不可执行。
代码段中off表示这个段在可执行文件中的偏移量,由于代码段位于可执行文件的开始处,所以off=0,vaddr和paddr表示这个段开始于内存地址0x400000处,代码段的大小是filesz,即0x69c个字节,所以在内存中也是占0x69c个字节,这些内容包括ELF header、程序头部表以及.init、.text和.rodata节的内容。
数据段和内存的映射关系
从图中看出数据段的起始地址是0x600df8,这个段在目标文件中占0x228个字节,不过在内存中需要占0x230个字节,多出来的8个字节用于存放.bss section的数据,虽然这个.bss section不占用可执行文件的空间,但是.bss section中的数据在运行时需要被初始化为0,所以数据段加载到内存需要多8个字节的空间。
对于任何一个段,链接器必须选择一个起始地址vaddr,使得vaddr mod align = off mod align,align是头部表中指定的对齐量,如下图。
可执行文件是如何加载运行的
输入以下命令,运行可执行程序prog.exe
Linux> ./prog
按下回车键后,shell程序通过调用加载器来运行这个程序,所有的Linux程序都可以通过调用execve函数来调用加载器。
接下来,加载器将可执行文件中的代码和数据从磁盘复制到内存中,然后跳转到程序的入口来运行该程序。
这个将程序从磁盘复制到内存并运行的过程叫做加载。
每一个Linux程序都有一个运行时内存镜像,具体如下图所示
在Linux x86-64的系统中,代码段总是从地址0x400000处开始,然后是数据段,运行时堆在数据段之后,堆的增长方向是从低地址到高地址,堆后面的区域是为共享模块保留的,这个区域把堆和栈隔开了,用户栈的起始地址是2的48次方减1,这里是最大的合法用户地址,关于栈的增长方向是从高地址到低地址的,再往上,从地址2的48次方开始,是为操作系统的代码和数据保留的,这部分内存空间对用户是不可见的,从这个图可以看出,代码段、数据段以及堆之间都是相邻的,同时还把栈的起始地址放在了最大的合法用户地址处,实际上对于数据段有地址对齐的要求,所以代码段和数据段之间是有间隙的,同时,为了防止程序受到攻击,在分配栈、共享库以及堆的运行地址时,链接器还会用到地址空间随机化的策略,所以每次程序运行时,这些区域的地址都会改变,不过它们的相对位置是不变的。
当加载器运行时,它为程序创造图中所示的内存镜像,根据程序头部表的内容,加载器将可执行文件的section复制到内存相应的位置,接下来加载器跳转到程序的入口处,也就是**_start函数的地址**,这个start函数在系统目标文件ctrl.o中定义,对于所有C程序都是一样的,ctrl.o属于C运行时库中的内容,接下来函数**_start调用系统启动函数__libc_start_main**,这个函数位于libc.so中,它的作用是初始化执行环境,然后,调用用户层的main函数,接下来开始执行可执行文件prog中的main函数,当程序prog执行完毕后,函数main的返回值还是由libc.so中的这个函数处理,并且在需要的时候把系统的控制权交还给操作系统,如下图。
动态链接共享库
静态库的缺点
- 静态库需要定期维护和更新
- 几乎每个C程序都要使用标准的I/O函数,所以这些函数的代码会被复制到每个进程的代码段中,内存资源的极大浪费
为解决缺陷,提供共享库技术
共享库
共享库是一种特殊的可重定位目标文件,在Linux系统中通常用.so的后缀表示,Windows系统中也使用了大量的共享库,例如以dll结尾文件就属于共享库,共享库在运行或加载时,可以被加载到任意内存地址,还能和一个在内存中的程序链接起来,这个过程称为动态链接,具体是由动态链接器来执行的。
共享库的构造
构造命令如下。
gcc -shared -fpic -o libvector.so addvec.c mulvec.c
-shared是指示编译器创建一个共享的目标文件
-fpic是告诉编译器生成位置无关的代码,这样共享库才能被加载到任意内存位置
构造可执行程序
使用该库构造可执行程序,命令如下
gcc -o prog2 main.c ./libvector.so
与静态库的链接命令相比,libvector.so中的代码和数据并没有真的被复制到可执行文件prog2中,这个操作只是复制了符号表和一些重定位信息,当可执行程序prog2被加载运行时,加载器会发现prog2中存在一个名为.interp的section,这个section中包含了动态链接器的路径名,实际上这个动态链接器本身也是一个共享目标文件(ld-linux.so)。接下来,加载器会将这个动态链接器加载到内存中运行,然后由动态链接器执行重定位代码和数据的工作,如下图。
动态链接器执行重定位代码和数据的过程
- 将libc.so的代码和数据重定位到某个内存段
- 重定位libvector.so中的代码和数据到另一个内存段
- 重定位prog2由libc.so和libvector.so定义的符号引用
- 动态链接器把控制权交给应用程序prog2(此时共享库的位置就固定了,在程序的执行过程中都不会改变)
- prog2开始执行
应用程序还可能在它运行时要求动态链接器加载和链接某个共享库,而无需在编译时操作。
动态链接应用
- 分发软件:Windows应用开发者发布一个共享库新版本,用户下载这些新版本共享库,下一次运行程序将自动链接和加载新的共享库。
- 构建高性能Web服务器
实现运行时加载和链接共享库
Linux提供了一个接口运行在运行时加载和链接共享库,函数如下。
void *dlopen(const char *filename, int flag);
handle = dlopen("./libvector.so", RTLD_LAZY);
使用函数dlopen可以动态的加载共享库libvector.so,标志RTLD_LAZY指示链接器将符号解析的操作延迟,一直推迟到共享库中的代码执行时再进行符号解析。
然后调用函数dlsym,该函数输入有两个,一个是函数dlopen已经打开的共享库的句柄,另外一个是符号名,如果该符号存在就返回符号的地址,否则返回NULL。
void *dlsym(void *handle, char *symbol);
addvec = dlsym(handle, "addvec");
接下来就可以正常调用函数addvec来实现我们需要的功能。
如果没有其他共享库使用libvector.so,最后就可以调用函数dlclose来卸载这个共享库。
int dlclose(void *handle);
dlclose(handle);
库打桩机制
库打桩的基本思想:
给定一个需要打桩的目标函数,创建一个包装函数,它的原型与目标函数完全一样。使用某种特殊的打桩机制,就可以欺骗操作系统调用包装函数而不是目标函数。包装函数会有自己的逻辑,然后再去调用目标函数,最后将目标函数的返回值传递给调用者。
库打桩的作用:
可以截获对共享库函数的调用,取而代之执行自己的代码。
1.编译时打桩
(1)明确:编译时打桩需要能够访问程序的源代码。
(2)编译时打桩的主要思想:
就是让C预处理器在搜索通常的系统目录之前,先在当前目录中查找头文件,如果在当前目录中查找到对应的头文件,就会使用此头文件,而不会再去寻找其他的。
(3)如何进行编译时打桩:
第一步:首先在当前目录中存放好需要被调用的头文件,这个头文件里面有设计好的包装函数。(加入在本地保存的文件叫"malloc.h",包装函数所在文件叫“mymalloc.c”)
第二步:编译和链接,如下:
linux> gcc -DCOMPILETIME -c mymalloc.c
linux> gcc -I. -o intc int.c mymalloc.o
使用“-I.”进行编译链接,可以告诉C预处理器在搜哟通常的系统目录之前,现在当前目录中寻找malloc.h。
注:
①这里的-DCOMPILETIME是mymalloc.c里面定义的一个宏,这个宏作为mymalloc.c程序的开关
②mymalloc.c里面的包装函数分别是mymalloc()和myfree(),这两个函数内部各自调用系统的malloc()和free(),并在调用完毕打印出申请和释放的地址及大小。通过这样的打桩机制,可以看到申请空间的起始地址。
2.链接时打桩
Linux静态链接器支持用–wrap f标志进行链接时安装,这个标志告诉链接器,把对符号f的引用解析成_ _ wrap_f。
(1)明确:链接时打桩需要能够访问程序的可重定位对象文件。
(2)链接时打桩的主要思想:
链接时打桩就是在输入链接指令的时候将“-wl,f”写入到链接指令中,然后让程序链接指定库中的对应的f这个函数
例如:
linux> gcc -DLINKTIME -c mymalloc
linux> gcc -c int.c
linux> gcc -wl,–wrap,malloc -wl --wrap,free -o intl tin.o mymalloc.o
(3)如何进行链接时打桩:
注:下面的symbol可以替换成你想要进行打桩的目标函数名。–wrap是GCC的选项,用来进行链接时打桩。
第一步:链接时打桩就是当我们把目标文件链接成可执行文件的时候,加上“-wl,option”,这里的“-wl,option”标志会把option传递给链接器,然后使得option里面的每一个成员从原本的以逗号分割变为以空格分割,如:option是 “ --wrap,symbol ” 变为 “–wrap symbol ”
第二步:最后链接器将 “–wrap symbol ”引用解析对应的成包装函数“_ _ wrap_ symbol”。
第三步:注意的是,包装函数写成_ _ wrap_‘目标函数名’,而包装函数里面需要调用的目标函数要写成“_ _ real_ ‘目标函数名’ ”的格式,这样在引用解析的时候就会去掉“_ _ real_”,从而调用目标函数。
3.运行时打桩
(1)明确:运行时打桩只需要能够访问可执行目标文件即可。
(2)运行时打桩的基本思想:
将LD_PRELOAD环境变量设置为一个共享库路径名的列表,当加载和执行一个程序,需要解析未定义的引用时,动态链接器会先搜索LD_PRELOAD库,然后才搜索其他的库。
注意:使用LD_PRELOAD,当我们加载和执行任意可执行文件时,可以对任何可执行程序的库函数调用打桩。
(3)如何进行运行时打桩:
运行时打桩就是在输入命令进行连接的时候设置“LD_PRELOAD”环境变量为共享库路径名的列表。
例如:
linux> LD_PRELOAD=“./mymalloc.so” ./intr
除此之外,需要注意的是:因为是在运行时打桩,所以应该考虑到在应用程序被加载后执行前,动态链接器加载和连接共享库的场景,所以我们的共享库文件应该是使用Linux系统为动态链接器提供的接口"dlsym、dlerror……"等接口函数来编写包装函数。