WARNING这是《深入理解计算机系统》(CS
)的笔记,彼时还未上CO,OS等计算机系统基础课,存在理解不到位与错误之处.
这是《深入理解计算机系统》第七章-链接 的笔记
一、从整体来看链接
当编译器编译一个程序时,最后一步就是链接。它的工作是将多个源文件(模块)经过预处理(cpp)、编译(ccl)、汇编(as)后形成的可重定位目标文件“组合”起来,形成一个 完整的可执行目标文件。每个模块的可重定位目标文件中地址都是从0x00开始的,经过链接后这些不同模块的目标文件被重新组合,必然导致其中函数入口、全局变量等的地址变化,所以要修改对这些变量的引用信息,即链接。
链接过程中,链接器的主要任务有两个:
符号解析,将模块中引用的符号(表)和所有模块中所定义的相应符号(里)对应,“建立关系”。有些全局变量、函数是定义在外部其它模块中的,只在自己模块中找不到,可以体现链接器的作用。
重定位,修改所有对这些符号(函数、全局变量等)的引用,使它们指向重定位后新的内存位置。
链接可以发生在编译时、程序加载时、程序运行中(动态)。目标文件有三种:
- 可重定位目标文件
- 可执行目标文件
- 共享目标文件(特殊的可重定位目标文件,可在加载或执行时加载进内存并链接)
理解链接器,能帮助我们构建大型程序;避免一些细微而危险的编程错误(由链接导致的错误将难以调试);更底层地理解语言的作用域;以及最直接的,怎么可靠地调用第三方库。
二、可重定位目标文件
要理解链接的工作原理,必须基本了解其工作对象:不同模块的可重定位目标文件。现代 x86-64 Linux系统采用可执行可链接格式(Executable and Linkable Format, ELF),Windows采用可移植可执行格式(Portable Executable, PE),MacOS-X使用Mach-O。
本文讨论ELF格式。
1、ELF可重定位目标文件典型结构
可重定位目标文件有多个字节块组成,它们被组织成了多个节,每个节有不同的含义与作用。
典型的ELF可重定位目标文件:
典型ELF可重定位目标文件结构 |
---|
ELF头 |
.text |
.rodata |
.data |
.bss |
.symtab |
.rel.text |
.rel.data |
.debug |
.line |
.strtab |
节头目标(节信息) |
由上到下是由头到尾,最上面是地址0.
ELF头中包含了一些基本信息,包括生成该文件的系统的字的大小和字节顺序(大小端),以及其它帮助链接器语法分析和解释目标文件的信息,这些技术细节我们不用关系。
.text 包含该模块已完成编译与汇编的机器代码。函数指针(入口)都在.text中。
.rodata 包含只读数据(的值,我猜)。比如printf中的格式串和switch的跳转表等。
.data 包含已初始化的 全局变量和静态C变量。
.bss 未初始化以及初始化为0的全局和静态C变量。它不占用实际磁盘空间,运行中默认赋初值0.
tag: static关键字定义的全局变量、函数只能在本模块中访问,相当于“私有”。
也称“局部”,但这和我们平时说的局部变量不同。
注意,局部变量(定义在函数中的)不出现在目标文件中。它们由运行栈维护,隐式地表达在机器代码的组合里,不出现在目标文件。
.symtab 符号表,包含本模块定义的所有函数、全局变量、静态变量等。它以一定的格式组织,任何想要在本模块中寻找某个变量、函数的行为,都在这个表中找,然后根据表中的信息找到目标在该模块中的实际地址。
.rel.text 一个.text节中位置的列表,表示当这个目标文件被与其它目标文件链接时,.text中要修改的位置。这些位置是在编译前面的阶段完成的、
.rel.data 作用同上,不过存的是被该模块引用或定义的全部全局变量的重定位信息。有些全局变量初始化为另外的全局变量的指针,重定位时它们要修改。
.debug 调试用符号表,包含程序中定义的局部变量和类型(应该是static),定义和引用的全局变量,以及原始C源文件。需要gcc参数 -g 产生。
.line 保存机器代码和源文件中行数的映射关系,用于调试。需要gcc参数 -g 产生。
.strtab 字符串表。包含所有函数、变量、文件中节等的真实名字,文件中其它地方的“名字”都只存了它在字符串表中的偏移量(相当于地址)。
节条目表 存了所有节的位置、大小,节中条目的大小、数量等信息。
2、符号表symtab细节
符号表由汇编器构造,在.s文件中就有了。这个节相当于多个存储了符号信息的条目的数组,成员结构如下表示:
typedef struct{
int name; /* 真实名字在strtab中的偏移量 */
char type:4, /* 标记 Function or data (4 bits) */
binding:4; /* Local or global,指static or not */
short section; /* 出现位置在哪个节中的索引,比如函数出现在.text中,标号(索引)为1. */
long value; /* 出现位置在其所在节中相对于节开头的偏移量 */
long size; /* 该变量的大小事几字节,func的大小是整个函数代码块的大小 */
}
可见所有的名字都是假名,它是一个指向字符串表中真名的“地址”。而value也是指向其真值(所在位置)的“地址”。由此猜想,.data 等存储变量的节中只有变量的值。
查找目标文件中某个函数、全局变量(符号)的信息时,在符号表中找,根据其中各种地址等在其他地方找到真实值。
符号表中只有自己本模块定义的符号,不含外部定义符号。
可以用 GNU READELF程序查看目标文件。
另外,目标文件中有三个伪节,这三个节不在节条目表中出现。
ABS 存储不被重定义的符号。
UNDEF 本模块中调用了,但是在外部定义的符号。
COMMON 未被分配位置的未初始化数据目标。而.bss中的全局变量都是未初始化、因而未分配位置的全局、静态变量。两者很像。
gcc采用下面规则区分COMMON和.bss:
COMMON : 未初始化的全局变量
.bss : 未初始化的静态变量,以及初始化为0的全局或静态变量。
理由在符号解析的冲突处理中。
三、符号解析
符号解析将程序行为中所有引用 和某个模块的符号表中的对应被引用实体关联起来。比如:
/* file 1 */
void foo()
int buf[2]={1,2};
int main()
{
foo();
return 0;
}
/* file 2 */
extern int buf[];
int *bufp0=&buf[0]; //这个认为是已初始化的全局变量!
void foo()
{
return bufp0+1;
}
符号解析中,将 file 2 中的 buf (extern关键字表示在外部定义)和 file 1 中定义的 buf 关联, file 1 中的 foo 函数引用和 file 2 中定义 foo 函数实体关联。注意分清楚本体和引用!
对静态变量处理比较简单,因为它一定只能在本文件中引用,不用到外面找,因而后面的讨论不必涉及静态变量。对全局变量的处理将很麻烦。
1、解析多重定义的全局符号
一个相当可能的问题是重复定义符号(尤其在多人合作完成程序的时候很明显),包括函数名称、全局变量。Linux链接器对于这种情况的处理容易让不了解者制造难以调试的bug。
定义:
强符号:函数和已经初始化(包括初始化为0)的全局变量是强符号。
弱符号:未初始化的全局变量是弱符号。
处理规则:
1、不允许有多个同名的强符号。若有,则 error。
2、如果有一个强符号和多个弱符号同名,则选择强符号。注意此时编译器不会反馈任何错误或警告。
3、如果有多个弱符号同名,则任意选择,并反馈一个警告。
#注意上面只涉及名字而不涉及类型。同名符号可能有不同类型,因而有不同的长度、解释,任意选用导致同一个引用被映射为不同的实体,将产生难以意料的后果。对于同名符号,链接器可能将多个实体认为只有一个实体,同一个引用映射到不同地方去。比如:
int x,y;
void f();
int main{
x=1,y=2;
f();
printf("...");
return 0;
}
double x;
void f()
{
x=-0.0;
}
int为4字节,double为8字节,两个x被当做同一个东西。
调用f函数可能导致以double引用x覆盖掉x和y。
为了避免这些错误,良好的代码习惯最重要。C++的namespace也提供了一种解决。若怀疑有此类问题,可以用GCC -fno -common标志,告诉编译器遇到多重全局变量触发error。或者-werror,将所有警告改为错误。
到这里重新看 COMMON伪节和.bss的分配问题,遇到未初始化的全局变量时(这是个弱符号),编译器不能确定是否会有同名、触发规则3,放进COMMON伪节,让链接器判断。而对于初始化为0的全局变量(强符号,一定用它,要么就error),以及没有重名问题的静态变量,则放入.bss,表示“没问题,可以用”。
2、与静态库链接、用静态库解析引用
静态库:多个可重定位目标文件的集合,组合为一个 .a 文件。虽不知道里面具体是怎么个结构,但可以当成多个 .o 文件的组合,从 .a 中可以读到其中任何一个模块(一个“子” .o ),并且单独将这个模块提取出来,重定位(可以理解为“拼接”)到新的可执行目标文件中。
当源文件引用了另一个.o文件的符号时,另一个.o文件将被全部链接到可执行目标文件中,这很浪费。而如果对每个符号都单独整出一个.o,太过麻烦,于是有了静态库.a文件。
1、形成静态库:AR工具。将1.o 2.o 3.o...组织为静态库name.a:
ar rcs name.a 1.o 2.o 3.o
生成的name.a在同一个目录下。
2、进行手动链接:gcc
gcc -static -o name_executable 1.o 2.o ./my_lib.a
可见最后的静态库文件必须以路径的形式给出。
如何理解 #include “head.h”
.h文件中只有函数原型(函数名字+参数与返回值类型),和全局变量,而没有函数具体内容。它只有符号,而没有实意。函数具体内容在和.h同名的.c文件中。源文件预处理时,可以从 #include 中找到头文件,用头文件中的函数原型填充占位,最终无脑生成.o文件。也就是,链接前,#include”head.h” 在.o文件中有了头文件中有了相应函数的引用和符号,但没有对应的实意,对应的地址可能都直接是0x00。链接一步,将这些函数和全局变量从源文件中找到实意,加入可执行目标文件的.text .bss .data .symtab .strtab中,并更新所有对它们引用的地址,完成重定位。
也就是,哪怕直接没有相应的.c,编译也能进行到链接前一步,生成.o文件。
现代编译器驱动装置应该可以在预处理时识别这些头文件,自动搜索相应的.c文件编译为.o加入到待链接文件集合,不用手动一条一条地链接。
#include<XXX.h>引用标准库函数,静态库文件在usr/lib/中,比如usr/lib/libc.a等等。
#include"XXX.h"引用自定义库,编译器驱动程序在本目录下寻找。
头文件的作用只是帮助编译器在本文件下生成头文件里的符号,这些符号是外部符号,在.rel节中。链接步骤这些头文件无用了,只在可重定位目标文件集合中搜索。所以头文件对应的源文件和头文件不同名也没关系,但不合习惯。
手动链接的时候会给出一串.o文件和.a文件为gcc命令的参数,链接器以一定的规则进行处理,容易造成迷惑。
定义:E .o文件集合;D: 前面输入文件中已定义符号集合;U:前面输入文件中未定义符号集合。
1、命令依次处理命令行上输入的文件 f,链接器判断文件 f 是 .o 还是 .a ,如果是.o,加入到 E 中,修改U 和 D 来反映符号的引用与定义情况。(应该可以从 rel 节直接获取这些信息)。然后 “丢弃”这个文件,处理下一个。
2、如果是 .a 文件,就尝试匹配 U 集合中所有未定义符号和.a中所有成员定义的符号。如果成员 m 成功与 U 中符号匹配,那么 m.o 就加入到 E 中,加入后,使用E中所有成员修改U D(这当然可能导致U增加新的元素),然后继续匹配下一个成员。匹配完后,这个.a文件被丢弃。
3、顺序处理完所有文件后,如果U是非空,则链接器输出错误并终止。否则就进行E中.o文件的重定位,构建可执行目标文件。
可见,如果作为参数的文件名顺序不对,可能导致难以检查的问题,如果没把握,可以对同一个文件在参数中不同位置输入多次。
符号实质的出现,必须不晚于对其引用的出现。
以 A->B 表示A引用了B中的符号
p.o -> libx.a -> liby.a 且 liby.a -> libx.a ->p.o
最小顺序为
gcc -static -o prog p.o ./libx.a ./liby.a ./libx.a
四、重定位与可执行目标文件
重定位有两个主要任务:
1、重定位节和符号定义。将许多个.o文件中对应的节合并。
2、重定位节中的符号引用。修改引用地址,指向正确的地址。
1、重定位条目
即.rel.data 和 .rel.text。当汇编器无法确定引用的符号来自何处时,就在重定位条目中加上这个符号,并且在相应的地址处给个0(也许)。具体而言,如果汇编器在解析某个源文件时,发现有一个引用的符号在本文件中的找不到真实地址(即.text中没有这个函数块,或没有这个全局变量的地址),就会认定为需重定位的条目。
ELF重定位条目结构可用下面的结构表示:
typedef struct{
long offset; //需要重定位的引用的位置(距所属条目头的偏移量)
long type:32, //重定位类型,gcc有32种不同的重定位类型,对应不同常数
symbol:32; //该符号在符号表中的位置,后面当然也要在符号表中更新(合并)
long addend; //一个比较奇怪的常数,必须加在地址计算后面,后面会说从何而来。
}
主要的重定位类型有两种:
1、R_X86_64_PC32:重定位一个使用32位PC相对地址的引用。比如callq指令函数跳转。
2、R_X86_64_32:重定位一个32位绝对地址的引用。比如引用全局变量。
#默认小型代码模型,用32位地址,代码和数据总体小于2GB。大程序可用-mcmodel=medium 或 -mcmodel=large
编译,猜测也许对应48位、64位地址。
2、如何进行重定位
假设链接器已经为每个需要重定位所引用的原型分配了在运行时的真实地址(加载到内存中时,可执行目标文件并不是连续的,运行地址不能用文件中地址)。对于每个新节 s ,其运行时首地址为ADDR(s),对于重定位条目r,可以从symbol(它也许也更新过?先纠结实现细节!)中读取这个条目是哪个符号,对应的分配的运行时地址为ADDR(r.symbol)。
新引用算法伪代码可以这样表示:
for each section s{
for each relocation entry r{
refptr = s + r.offset; //找到要重定位的引用的文件中位置的指针
if(r.type==R_X86_64_PC32){
refaddr = ADDR(s) + r.offset //偏移量仍相同。得到产生引用处运行时绝对地址
*refptr = (unsigned)(ADDR(r.symbol) - refaddr + addend); //相对地址
}
else if(r.type==R_X86_64_32){ 修改绝对地址
*refptr = (unsigned)(ADDR(r.symbol) + addend);
}
}
}
下面以一个例子展示如何重定位:
在main函数调用另一个文件定义的sum()函数。main.o中,调用sum函数的指令如下:
e: e8 00 00 00 00 callq 13<main+0x13>,这个13也许无意义
其中e8是操作码,后面是32位偏移量,小端法。
相应的重定位条目如下:
r.offset = 0xf;
r.symbol = sum;
r.type = R_X86_64_PC32;
r.addend = 4; //这个后面解释为什么是4
假设,链接器已确定 s,也就是新的.text的运行首地址和sum函数的运行时地址(label入口)是:
ADDR(s) = ADDR(.text) = 0x404d0;
ADDR(r.symbol) = 0x4004e8;
则计算如下:
运行时产生引用位置的绝对地址是:
refaddr = ADDR(s) + r.offset;
跳转目标绝对地址是:
ADDR(r.symbol);
最后得到的重定位地址是:
*refptr = ADDR(r.symbol) - refaddr + addend = 5;
所以最后运行时机器代码是:
4004de e8 05 00 00 00 callq 4004e8 <sum>
发现 0x4004de + 0x5 != 0x4004e8;为何?
这是因为当CPU取指阶段取出call指令后,PC增加器就立刻增加PC指向了顺序下一条指令,也就是读完后PC的值直接跳到了0x4004e3,完全跳过的call指令。这时候CPU解析call指令,发现要在PC基础上+5,就有0x4004e3 + 0x5 = 0x4004e8,成立。
这就是为什么有个addend,以及上面的例子中它为什么是4。就是为了配合PC增加器每次取指完后自动指向顺序下一条指令,这在相对地址的计算上是有影响的。
相应地,绝对地址重定位就不用考虑PC增加器的影响,因而R_X86_64_32类型的重定位addend=0.
绝对地址的重定位比较简单,直接把链接器分配的运行时绝对地址放上去就行了。
3、可执行目标文件与程序加载
典型的ELF可执行目标文件和可重定位目标文件结构相似。
可执行目标文件结构 |
---|
ELF头 |
段头目表 |
.init |
.text |
.rodata |
.data |
.bss |
.symtab |
.debug |
.line |
.strtab |
节头目表 |
其中 ELF头 ~ .rodata 是只读内存段;.data ~ .bss是数据段或读写段;后面的是不加载到内存的符号表和调试信息。
ELF头中有为了加载执行该程序必要的信息,包括各个段加载到内存中的首地址、偏移、段大小等。还有一个微妙的对齐量align,使得每个段的起始地址vaddr与偏移off满足:
vaddr mod align = off mod align;
这是基于虚拟内存的优化。
加载可执行目标文件,典型内存映像:
加载程序时内存映像 |
---|
内核内存,地址>=2^48^-1 |
用户栈,运行时创建、维护 |
…用户栈向低延伸… |
…共享库向高延伸… |
共享库内存映射区域 |
…运行时内存堆向高延伸… |
用户堆,malloc创建 |
读/写段 |
只读代码段 |
Linux中的程序都运行在一个进程的上下文中,运行一个新程序的时候,shell生成一个子进程,它是父进程的副本,然后通过 execve 系统调用加载器,初始化环境。然后加载器跳到_start地址,最终调用程序文件中的main函数。(结合异常控制流理解)。
具体略。
五、动态链接共享库
1、动态链接便于理解的抽象意义
静态库的链接都是在链接阶段完成、最后的静态库是提取出需要的模板.o文件,复制副本到可执行目标文件中。如果一个有大量进程、许多目标文件的软件系统,其中有大量的对同一模板的反复链接,如果全是静态库,将造成巨大的内存浪费。
动态共享库就是为了解决这个问题。可以简单理解为,共享库以某种形式整个加载到内存中的某个部分,只加载一次,然后其它链接了这个共享库的目标文件,在加载或者运行的时候动态地重定位对于共享库模块的引用。
各个阶段的动态链接都需要动态链接工具完成。ld-Linux.so
动态库是一个 .so 文件,将多个源文件构造为一个共享库,并且在其他文件的编译中使用动态编译,用gcc指令如下:
生成共享库:
gcc -shared -o -fpic libXXX.so src1.c src2.c src3.c ...
告诉GCC要生成要适应动态解析引用的可执行目标文件:
gcc -o prog main.c ./libXXX.so
只要包含这个动态库的文件路径就行
这是在链接时进行动态链接,这时候在编译时就要加上 .so 文件。
这样在加载用动态链接的可执行目标文件的 prog 的时候,发现其中有一个 .interp 节,其中包含了动态链接器的路径名(这本就是一个共享目标。然后,加载器加载、运行这个动态链接器,然后动态链接器执行下列重定位完成链接任务:
1、重定位所用到的 .so 文件到某个内存段。
2、重定位(加载时或者运行时等)prog 所有对 .so 中的定义的符号的引用。
这是对动态链接的通俗理解。
共享库典型应用:
1、分发软件。比如微软常通过更新共享库来更新软件。
2、高性能Web服务器。
2、在应用程序中加载、链接动态库
先来看看怎么用。
Linux提供了接口,可以在代码中控制动态链接使用共享库。这样的动态链接是在运行时(runtime)进行的。
这些函数定义在 <dlfcn.h> 中。(dl: dynamic link)
使用这些接口的文件,须用带 -rdynamic 编译选项编译。(或者,动态链接的目标库必须是用 RTLD_GLOBAL 选项打开的。
gcc -rdynamic -o prog main.c -ldl
以下是这些接口函数:
void* dlopen(const char* filename, int flag);
运行到这一步,动态链接器试图加载、链接共享库filename(是个路径!)。如果成功,就返回这个共享库的句柄的指针(可以抽象理解为指向这个共享库的指针 )。如果失败,就返回NULL。
flag 是 RTLD_NOW 或者 RTLD_LAZY。前者告诉动态链接器立刻解析对外部符号的引用,后者推迟符号解析到需要执行库中代码时。
void* dlsym(void* handle, char* symbol);
链接 handle 指向的库中的某个符号,比如函数、全局变量。返回指向这个符号的指针(函数指针、全局变量指针),出错则返回NULL。
得到了这个指针后,就可以在函数中应用动态库中的函数。
int dlclose(void *filename);
试图关闭共享库。如果没有其它调用就成功关闭,返回0.关闭失败返回-1.
const char *dlerror(void);
最近一次调用 dlopen dlsym dlclose 函数所发生的错误。无错则返回NULL。
例子:
/* 编译为共享库的 libvector.c */
void addvec(int a[],int b[],int z[],int n)
{
int i;
for(i=0;i<n;i++){
z[i]=a[i]+b[i];
}
}
/*************************************/
/* main.c */
#include<stdio.h>
#include<dlfcn.h>
int x[2]={1,2};
int y[2]-{3,4};
int z[2];
int main()
{
void *handle;
void (*addvec)(int *, int *, int *, int); //需要同款函数指针(符号指针)
char *error; //存可能的错误信息.
handle=dlopen("./libvector.so",RTLD_LAZY);
if(!handle){ //错误处理
fprintf(stderr,"%s\n",dlerror());
exit(1);
}
addvec=dlsym(handle,"addvec");
if(!addvec){ //错误处理
fprintf(stderr,"%s\n",dlerror());
exit(1);
}
/* 现在可以用libvector.so 中的 addvec函数了 */
addvec(x,y,z,2);
printf("z=[%d %d]\n",z[0],z[1]);
if(dlclose(handle)<0){
fprintf(stderr,"%s\n",dlerror());
exit(1);
}
return 0;
}
/****************************/
/* 编译运行 */
gcc -shared -fpic -o libvector.so libvector.c
gcc -rdynamic -o prog main.c -ldl
gcc ./prog
3、位置无关代码PIC
如果要为共享库规定一个特定的地址,则当共享库很多时,这样固定的地址将非常难以分配,并很可能将内存拆成碎片。故共享库应当是位置无关的,可以加载到内存中的任意位置。那么如何定位共享库中的符号等?
加载目标文件时的一个事实是,不论目标文件被加载到哪里,同一个目标文件中不同节的相对距离是特定常数。
(1)PIC数据引用
当目标文件有对PIC(典型如共享库)全局变量的引用,则在其数据段最前面加上一个 全局偏移量表(Global Offset Table, GOT),可以理解为,里面是每个全局变量的运行时真实地址。由于上面那个事实,可以很容易从对应指向 .text 中某个指令的 %rip,加上某个特定的偏移量,访问到GOT中的任何一条。
这样一定可以通过自己的GOT间接访问数据。
(注意:我感觉我对这里的理解不太对)。
(2)PIC函数调用
PIC函数调用比较复杂,它是 过程链接表PLT 全局偏移表GOT 两个数据结构配合的技术。这是一种 延迟绑定,只有第一次引用这个共享库定义的函数时,才进行第一次重定位,而后续引用可以直接完成引用。
PLT的元素对应自己的函数,每个元素有三个字段。
GOT里面是一系列函数的偏移。一开始,它们并不能指向自己的函数对应的真实位置,而是指向这个函数对应的PLT元素的第二个字段。
它们中都有一些特定的位置有特殊意义。
同样以调用 libvector.so 中的函数addvec()为例:
原始GOT:
GOT[0]: addr of .dynamic //固定意义
GOT[1]: addr of reloc entries //固定意义,重定位条目位置
GOT[2]: addr of dynamic linker //固定意义
...
GOT[4]: 0x4005c6 #addvec() //指向addvec的PLT[2]的第2个字段
原始PLT:
PLT[0]: #call dynamic linker
4005a0: pushq *GOT[1]
4005a6: jmpq *GOT[2]
...
PLT[2]: #call addvec()
4005c0: jmpq *GOT[4]
4005c6: pushq $0x1 //实际上这是对函数addvec的一个编码与标记
4005cb: jmpq 4005a0
而在调用这个共享库中函数的地方,是这样的:
callq 0x4005c0 #call addvec,指向了addvec的PLT[2]的第一个字段
第一次调用addvec的时候,链接过程如下:
1、callq 0x4005c0 去到addvec对应的PLT[2]中的第1个字段。
2、jmpq *GOT[4] 去到GOT[4]所指向的位置,而GOT[4]初始指向PLT[2]的第二个字段,其实就是顺序向下一个字段(这也是为什么要这样初始化)。
3、pushq 0x1 将0x1(这是addvec函数的某种标识码,可被动态链接器识别)压入栈中。
4、jmpq 4005a0 去到调用动态链接器的部分,也就是固定的PLT[0]。
5、pushq *GOT[1] 把GOT[1]所指向的位置所存储的字(是一个重定位条目)压入栈中,同样,动态链接器可以识别这个条目。
6、jmpq *GOT[2] 跳往GOT[2]所指向的位置,这个位置就是动态链接器的位置,这之后会由动态链接器控制接下来的步骤。动态链接器可以通过压入栈中的两个参数:addvec的编号和GOT的addr of reloc entries 确定addvec函数运行时真实的位置,动态链接器将这个地址写给GOT[4],在此完成首次重定位。然后将控制交给addvec,第一次执行addvec()函数。
(GOT[4] 变为了真正的 &addvec
后续调用addvec的时候,仍旧跳到PLT[2].1,然后跳到GOT[4]所指向的空间,这里就是真正的addvec了。
六、库打桩(重载)
库打桩就是C版本的“函数重载”。但由于C不支持面向对象,所以库打桩说的一般是对标准库函数的重载。一般是对标准库函数做些包装,记录运行次数等等。
当然,会比较别扭。
1、编译时打桩
编译时打桩总体而言是利用 #define,在预处理阶段将源文件中使用的函数替换为自己的包装函数,从而欺骗编译器。比较繁琐,必须理解头文件以及链接的关系。
-I. (大写 i) 告诉gcc,在搜索通常的系统目录之前,先在当前目录中寻找头文件,这样可以欺骗对头文件的引用。
例子如下:
/* main.c */
#include<stdio.h>
#include<malloc.h>
int main()
{
void *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如下,为了避开 -I. 以及#define等,它将会提前被编译为可重定位目标文件,然后和main.c链接。
/* mymalloc.c */
#ifdef COMPILETIME
#include<stdio.h>
#include<malloc.h>
void *mymalloc(size_t size)
{
void *ptr=malloc(size);
printf("malloc(%d)=%p\n",(int)size,ptr);
return ptr;
}
void free(void *ptr)
{
free(ptr);
printf("free(%p)\n",ptr);
}
#endif
编译的时候,如下使用GCC:
gcc -DCOMPILETIME -c mymalloc.c
gcc -I. -o main main.c mymalloc.o
第一条指令告诉编译器生成mymalloc的可重定位目标文件,-DCOMPILETIME可能是告诉GCC在编译时打桩(?我也不懂)。注意此时没有 -I. 所以mymalloc.c中的malloc是标准库版本,mymalloc中定义了两个对标准库函数的包装函数。
第二条指令编译main.c,并将它和mymalloc.o链接在一起。由于有 -I. 所以main.c中的#include<malloc.h>会先在本目录中寻找,找到自定义的假的 malloc.h,然后main.c中的malloc(32)被替换为mymalloc(32),free(p)被替换为myfree(p),然后编译器编译时将它们当成外部函数,等待链接。链接时,在mymalloc.o中找到了这两个外部函数的实体,它们便被正确指向了包装函数。于是对malloc和free的重载就完成了。
注意由于提前处理了mymalloc.c,所以它里面的malloc free都是未重载的标准库版本!
运行:
./main
malloc(32)=0x9ee010
free(0x9ee010)
根据类似的原理,可以将标准库函数重载为完全不同的样子。
2、链接时打桩
Linux静态链接器支持链接时打桩。—wrap,f 标志告诉链接器,将对f的引用解析成对__wrap_f 的引用,把对符号__real_f 的引用解析成 f。这个机制就是一个换名。
例如:
/* main.c和上面一样 */
#include<stdio.h>
#include<malloc.h>
int main()
{
int *p=(int*)malloc(32);
free(p);
return 0;
}
/*******************/
/* mymalloc.c */
#ifdef LINKTIME
#include<stdio.h>
void *__real_malloc(size_t size);
void __real_free(void *ptr); //由于被文件中没有调用stdlib.h,需要这两个定义来通过编译。链接的时候,在main中有调用malloc.h,可以链接
void *__wrap_malloc(size_t size)
{
void *ptr=__real_malloc(size);
printf("malloc(%d)=%p\n",(int)size,ptr);
return ptr;
}
void __wrap_free(void *ptr)
{
__real_free(ptr);
printf("free(%p)\n",ptr);
}
#endif
编译的时候,须将内容部分和包装部分分开生成.o,然后链接。
gcc -DLINKTIME -c mymalloc.c
gcc -c main.c
gcc -W1 --wrap,malloc -W1 --wrap,free -o main main.o mymalloc.o
其中,-DLINKTIME提醒编译器这个文件用于链接时打桩。
这样,在链接过程中,main.o里面的malloc引用被链接到了mymalloc.o 的__wrap_malloc中,而mymalloc.o中的__real_malloc链接到真实的库函数中。
3、运行时打桩
运行时打桩基于动态链接器和 LD_PRELOAD 环境变量。当加载、执行程序时,动态链接器解析一个未定义的应用时,会先搜索LD_PRELOAD库,然后才搜索任何其它库(包括系统库)。
思路:在重载用的源文件中用定义的方式自定义库函数,用应用中动态链接的方式引用真正的库函数,运行时将含自定义库函数的.so文件加入LD_PRELOAD环境变量,然后运行。
例子在P495,说实话,对这里面的预处理语句,我不太理解Orz。
gcc -DRUNTIME -shared -fpic -o -mymalloc.so mymalloc.c -ldl
gcc -o main main.c
运行:
LD_PRELOAD="./mymalloc.so" ./main
它的魅力在于,有了这个mymalloc.so后,可以对任何调用了malloc free函数的C程序打桩,而不用修改源文件。
附. Linux处理目标文件的一些工具:
AR:创建静态库,插入、删除、列出、提取其中成员。
STRINGS:列出一个目标文件中所有可打印字符串。
STRIP:从目标文件中删除符号表信息。
NM:列出一个目标文件符号表中定义的符号。
SIZE:目标文件节的名字和大小。
READELF:现实目标文件的完整结构,包括ELF头中编码的所有信息。
OBJDUMP:显示目标文件的所有信息,最大作用是反汇编.text中的二进制指令。
LDD:列出一个可执行目标文件在运行时需要的共享库。