系统在执行代码的时候,遇到对应的函数,是如何跳转到对应函数体内运行的?函数是怎么保存在可执行文件当中的。
生成可执行文件的过程可分为预处理、编译、汇编、链接。链接就是将不同的源文件组合在一起,形成一个单一可执行文件的过程,其中涉及到函数调用、全局变量。链接是由一个叫做链接器的程序自动执行的。
考虑这样一个C程序,包含两个源文件。main函数调用swap函数,它交换全局数组buf中的两个元素。
//main.c
void swap();
int buf[2] = {1,2};
int main(){
swap();
return 0;
}
//swap.c
extern int buf[];
int *bufp0 = &buf[0];
int *bufp1;
void swap() {
int temp;
bufp1 = &buf[1];
temp = *bufp0;
*bufp0 = *bufp1;
*bufp1 = temp;
}
通过gcc编译器,c文件经过预处理、编译、汇编生成o文件(可重定位目标文件),最后再链接器的帮助下生成可执行目标文件。
gcc -c main.c
通过上述命令,-c代表编译、汇编指定的源文件,但不进行链接,可以生成main.o文件,也就是可重定向目标文件。
Linux系统下,o文件、可执行文件都属于ELF类型文件。下图展示了一个典型的ELF可重定位目标文件。
每一行代表一节,存储不同的信息。
readlf -h main.o
上述命令可以查看o文件的文件头详细信息。
每个可重定位文件m,包含main.o和swap.o都有一个符号表symtab,包含三种符号:
symtab不包含任何函数内部的局部变量,这些变量在运行时由栈进行管理,链接器对此类符号不感兴趣。如果是带static的局部变量,是不在栈中管理的,编译器在data或bss中为每个变量分配空间,并在符号表中创建一个有唯一名字的本地链接器符号。
符号表是由汇编器构造的,就是s文件中的一部分。符号表实际上就是一个数组,数组里的每一个元素叫表目,是一个结构体,里面存储着符号的具体信息。
readlf -s main.o
上述命令可以查看o文件的符号表详细信息。
我们重点关注后面三个,buf是一个全局变量,main是一个全局函数,swap是一个声明的函数,但这个函数并不在main.c,甚至我还没用编写swap.c文件。
有三个特殊的伪节,他们在节表头中是没有表目的,也就是没他们的位置:ABS代表不该被重定位的符号,UND代表未定义的符号(在本文件引用,但却在其他地方定义的变量、函数)、COM代表还未被分配位置的未初始化的数据目标。
d是一个静态局部变量,Bind=LOCAL代表局部的,它存储在bss字节偏移量为0的地方(最开始),大小为4字节。后面加2294是为了区分开,同一个文件不同函数内部同名的static局部变量,让他们用于唯一的名字,类似于路人甲、路人乙。像全局变量就没这担忧,因为编译的时候就会报错。
而b是一个未初始化的静态变量,实际值并不需要保存,也不存在于bss之中,所以Ndx=COM。但会记录它的偏移和大小。value=4,是因为前面存放了一个静态局部变量d。
接下来看看swap.o的符号表。
bufp0是data节的已初始化的全局变量,buf的UND代表是引用外部的符号,bufp1的COM代表未初始化的全局变量,swap函数是text节的内容。temp是局部变量,不会出现在符号表。
到这里,基本就弄清了函数、变量是怎么被记录在符号表的,所以后面链接的工作其实就是把UND类型的变量、函数与其他文件的链接起来。
因为每个文件的本地符号只有一个定义,所以完成本地符号的解析是很简单的。但是对于全局符号就比较麻烦。当编译器遇到一个不是在本文件中定义的,就会生成一个符号表表目(上面描述的),并把这个锅甩给了后面的链接器。链接器的输入是多个o文件,如果它在其他的o文件均找不到这个被引用的符号,它就会输出一条错误信息并终止。
在C++中允许重载函数,这些函数在源文件中都有相同的名字,却有不同的参数列表。编译器就会像处理静态局部变量一样,用函数名称+参数列表类型组合生成一个唯一的名字,这样就可以区分调用到底是哪个重载函数。
链接器如何处理多处定义的全局符号? a文件定义了全局变量vec,b文件也定义了全局变量vec,在前面三部曲,预处理、汇编、编译都发现不了这个问题,因为它们互相都不知道对方的底细,只有链接器能看到全部人的底细。
链接器把符号分为强符号、弱符号,并规定了几条规则:
函数和已初始化的全局变量是强符号,未初始化的全局变量是弱符号。
//a.c
void main(){}
//b.c
void main(){}
如上所示,两个不同的文件都定义了main函数,这是个强符号,就会因为违反规则1而报错。
//a.c
void f(void);
int x = 1;
void main(){
f();
printf("x = %d", x);
}
//b.c
int x;
void f(){
x = 2;
}
两个文件都定义了x,但是a文件的x是已定义的,属于强符号,b文件的x是未定义的,属于弱符号。所以根据规则2,f函数内部的x指的就是a文件的那个x。最后输出x=2.
一旦链接器完成了符号解析这一步,它就把代码中的每一个符号引用和确定的一个符号定义(就是某个文件中的符号表中的一个表目)联系起来。同时,链接器也知道输入的o文件text节、data节的位置和大小。
重定位这一步主要就是合并输入的o文件,并未每个符号(包括函数和变量)分配运行时的地址。重定位由两步组成:
当汇编器生成o文件(可重定位目标文件)时,它不知道这个文件不用的任何外部定义的函数或全局变量的位置,比如main文件就不知道swap函数在哪里。当汇编器遇到这种情况,就会生成一个重定位表目,告诉链接器在将目标文件合并成可执行文件时如何修改这个引用。代码的重定位表目放在.relo.text中,已初始化数据的重定位表目放在.relo.data中,就在符号表.symtab的后面两个节。
重定位表目的数据结构如下所示:
offset是需要被修改的引用的节偏移。symbol表示需要重定位的符号。type告知链接器如何修改新的引用,重定位类型。
ELF定义了11中不同的重定位类型,需要了解最基本的两种。看不懂没关系,后面有具体的例子。
R_386_PC32执行的是相对引用,就好像在一个赛道跑步,在当前位置的基础上再往前跑100米;而R_386_32是绝对地址,也就是你跑去跑道100米的位置。
//main.c
void swap();
int buf[2] = {1,2};
int main(){
swap();
return 0;
}
通过objdump -d main.o
查看text节的反汇编代码。
从上面可以看到,callq指令(q后缀可以省略)开始于text节偏移0x09处,由1个字节的操作码0xe8(代表call)和随后的32位引用0x00000000(十进制0)组成。call指令就是调用swap函数的汇编指令。
所以重定位表目r就生成为,r.offset = 0xe , r.symbol = swap, r.type = R_386_PC32。
这些数据告诉链接器修改开始于偏移量0xe处的32位PC相关引用,使得它在运行时指向swap函数。目前偏移量0xe这个位置并不是swap函数。因为还没开始链接。
通过gcc main.c swap.c -o main
生成可执行文件main,通过objdump -d main
查看可执行文件的汇编代码。
下面看看如何计算操作码0xe8后面的32位相对引用0x07。当CPU正在执行callq指令的时候,PC寄存器存储的是下一条指令的值,也就是0x4004e4,不是0x4004df。而ADDR(swap) = 0x4004eb,就可以计算出swap函数距离PC寄存器当前运行值的偏移量为0x4004eb - 0x4004e4 = 0x07 (小端字节顺序存储)。为了执行后续的swap函数,CPU执行了两个步骤:
这样整个程序就跳进了swap函数的第一行,完成了函数的跳转。