1 平台
1.1 硬件
Table 1. 硬件(lscpu)
Architecture: |
i686(Intel 80386) |
Byte Order: |
Little Endian |
1.2 操作系统
Table 2. 操作系统类型
操作系统(cat /proc/version) |
位数(uname -a) |
Linux version 3.2.0-4-686-pae |
i686(32bit) |
1.3 编译器
Table 3. 编译器信息
编译器(gcc -v) |
gcc (Debian 4.7.2-5) 4.7.2 |
gcc有不同级别的优化能力,-O1,-O2等级。本笔记不使用任何优化选项(不同优化级别得到的汇编指令不同)。
2 C源程序到可执行程序
在1平台下,(结合链接)一个C源程序到可执行文件的过程可分为如下环节:
Figure 1. C源程序到可执行文件过程概要
本笔记主要笔记图中绿色部分(链接功能、可重定位目标文件结构),其它部分是一个引子。
汇编器输出的文件(*.o)被称为可重定位目标文件,链接器输出的文件(main)被称为可执行目标文件。在Unix(Linux)上它们都属ELF文件。要了解EFL文件的具体格式可读《CSAPP》第七章。
3 链接器(功能)
3.1 随便说说链接器功能
链接器接收汇编器输出的可重定位目标文件,为了构造可执行程序,链接器必须对这些可重定位目标文件完成两个主要任务:
- 符号解析。将每个符号引用刚好和一个符号定义联系起来。
- 重定位。链接器把每个符号定义与一个存储位置联系起来,然后修改所有对这些符号的引用,使得它们指向这个存储位置,从而重定位这些节。
通过例子来说明什么叫“符号引用”、“符号定义”、“符号解析”、“重定位”。不用太在意这些概念本身,重点看这些概念所描述的现象。在程序例子的机器级上理解与链接相关的概念。观察可重定位目标文件、可执行目标文件、反汇编文件来理解链接器的作用。
3.2 符号解析
链接器解析符号引用的方法是将每个引用与它输入的可重定位目标文件的符号表中的一个确定的符号定义联系起来。
(1) C程序(main.c,sub_fun.c)
Table 4. main.c
1. /*main.c*/ 2. void swap(int *, int *); 3. 4. int x = 1; 5. extern int y; 6. 7. int main(void) 8. { 9. int i = 0; 10. 11. swap(&x, &y); 12. return i; 13. } |
Table 5. sub_fun.c
1. /*sub_fun.c*/ 2. int y = 2; 3. //int x = 2; 4. void swap(int *x, int *y) 5. { 6. static int temp; 7. 8. temp = *x; 9. *x =*y; 10. *y = *x; 11. } |
符号引用:在main.c程序中,“swap(&x,&y)”分别引用了符号“swap”、“x”以及“y”,这是符号引用;符号定义:在main.c和sub_fun.c中,语句“intx;”、“int y;”及“voidswap(int *x, int *y)”定义了符号x,y和swap,这是符号定义;在main.c中,语句“extern int y;”有符号解析的感觉。
(2) 输出目标文件
在shell界面依次执行以下命令:
gcc -g -c main.c sub_fun.c gcc -g main.o sub_fun.o -o main |
第一行调用cpp、ccl、as程序处理main.c和sub_fun.c输入文件,输出可重定位目标文件main.o和sub_fun.o。第二行调用ld程序(链接器)处理main.o和sub_fun.o两个输入文件,输出可执行文件main。
(3) 可重定位目标文件中的符号表
在shell界面执行以下命令:
readelf -a main.o > maino.dat readelf -a sub_fun.o > sub_funo.dat |
符号定义信息包含在可重定位目标文件的.symtab符号表中(包含全局和静态局部符号)。使用vi命令打开maino.dat和sub_funo.dat文件,截取.symtab表中与main.c和sub_fun.c中相关符号的内容如下:
Figure 2. main.o的.symtab
Figure 3. sub_fun.o的.symtab
.symtab表每列含义:
|
从可重定位目标文件中可以看出:
- main.o中的x为全局数据,定义在.data节,偏移量为0,大小为4个字节。
- main.o的main为全局函数,定义在.text节,偏移为0,共43个字节。
- mian.o的y和swap在本地都没有定义。(链接器会在其它可重定位目标文件中寻找其定义)
- sub_fun.o中的y为全局数据,其定义在.data节,偏移为0,大小为4字节。
- sub_fun.o中的swap为全局函数,定义在.text节,偏移为0,共35字节。
另外,sub_fun.o中的static变量temp在.symtab的信息为:
temp的定义在.bss节(Ndx值为4),偏移为0,大小为4字节,本地数据。
可重定位目标文件中的.symtab表列出了各个符号定义的信息。可重定位目标文件作为链接器的输入,链接器解析那些和符号定义在相同模块的本地符号的引用这个过程比较简单。(本地符号的唯一性及静态本地变量的链接器符号都由编译器保证)。对全局符号的引用,解析就没那么准确,当编译器遇到一个不是在当前模块定义的符号时,它会假设该符号是在其它某个模块定义的,故生成一个链接器符号表(.symtab),并把它交给链接器处理。链接器面对可重定位目标文件提供的.symtab表会怎么解析呢?
(4) 链接器如何解析多重定义的全局符号
编译时,编译器向汇编器输出全局符号,汇编器把这些符号定义的信息写在可重定位目标文件的.symtab表中。在.symtab中,函数和已初始化的全局变量是强符号;未初始化的全局变量是弱符号。
定义多个强符号
把sub_fun.c中的“int x = 2;”前的注释去掉。再用“输出目标文件”中的步骤输出目标文件:
Figure4. 输出目标文件
当执行第二个命令调用链接器时,链接器就会解析出“强符号多重定义”的错误。
定义一个强符号和弱符号
重新用一个例子:
Table 6 . main.c
1. /*main.c*/ 2. #include <stdio.h> 3. 4. void fun(void); 5. 6. int x = 1; 7. 8. int main(void) 9. { 10. fun( ); 11. printf("x = %d ", x); 12. return 0; 13. } |
Table 7. fun.c
1. /* fun.c*/ 2. 3. int x; 4. void fun(void) 5. { 6. x = 0; 7. } |
输出目标文件:
gcc -g -c main.c fun.c gcc -g -o main.o fun.o |
在shell下执行可执行文件:./main
输出结果为x = 0;
可重定位目标文件中的.symtab表:
Figure5. main.o的.symtab
Figure6. fun.o的.symtab表
(COM表示还未分配位置的未初始化的数据。)根据执行main的结果可以看出,链接器引用了强符号的定义。
定义多个弱符号
当定义多个弱符号时,链接器会从弱符号中任意选择一个。
3.3 重定位
一旦链接器完成了符号解析这一步,它就把代码中的每个符号引用和确定的一个符号定义联系起来。此时,链接器就知道了每个模块(文件)代码节和数据节的大小,就可以开始重定位了。
重定位由两步组成:
- 重定位节和符号定义。聚合可重定位目标文件中相同的节。并且为每个节和符号分配虚拟地址。
- 重定位节中的符号引用。
(1) 聚合
用Table4和Table 5程序例子中的可重定位目标文件和可执行目标文件来说明链接器聚合了可重定位目标文件中相同的节(先用readelf将各目标文件读出来,再查看.symtab,从符号表可得每个符号所在的节。)
(2) 分配内存地址(重定位节和符号定义)
在可重定位目标文件中,代码和数据节从地址0开始:
Figure7. 可重定位目标文件的节的地址从0开始
在可执行文件中,链接器给每个符号定义分配了一个虚拟地址:
Figure8. 可执行目标文件的.symtab表
Value的值在可执行文件中是一个绝对地址(虚拟地址)。重定位每个符号的虚拟地址后,也就得到了各节的大小,重定位也为每个节分配虚拟地址(不再是从地址0开始):
Figure9. 每个节的虚拟地址
(3) 重定位符号引用
汇编器将本地没有定义的符号写入可重定位目标文件的.symtab表,让链接器到其它可重定位目标文件中查找。同理,汇编器遇到对存储位置未知(在可重定位目标文件中,汇编器都不知道数据和代码会存放在存储器的什么位置)的符号引用时,它也会将这些符号的信息存于.rel.text和.rel.data表中。告诉链接器将可重定位目标文件合并成可执行目标文件时如何修改引用。(可重定位目标文件的可重定位表查看《CSAPP》P.451。)
用以下程序例子来说明链接器重定位符号引用这个过程:
Table 8. main.c
1. /*main.c 2. .rel.text: Use golbal 3. .rel.data: golbal's initization is address of golbal 4. */ 5. 6. int x = 1; 7. 8. extern int a; 9. 10. int *p = &a; 11. 12. void out(void); 13. void local(void); 14. 15. int main(void) 16. { 17. x = *p; 18. out(); 19. local(); 20. return 0; 21. } 22. 23. void local(void) 24. { 25. } |
Table 9. fun.c
1. /*fun.c*/ 2. 3. int a = 3; 4. void out(void) 5. { 6. } |
在shell下输出可重定位目标文件、可执行目标文件及反汇编文件:
gcc -g -c main.c fun.c gcc main.o fun.o -o main readelf -a main.o > mo.dat readelf -a main > m.dat objdump -d main.o > mof.dat objdump -d main > mf.dat |
在mo.dat文件中查看.rel.text和.rel.data表:
Figure10. main.o中的.rel.text和.rel.data表
链接器用重定位表的offset、sym.name及type字段来实现重定位,它们三个字段的含义如下:
- Offset:是需要被修改的引用的在节中的偏移。
- Sym.name:标识被修改的引用应该指向的符号。
- Type:告知链接器如何修改新的引用。
按照Type的类型来讨论重定位的过程。
重定位PC相对引用
Type为R_386_PC32时,重定位一个使用32位PC相对地址的引用。Figure10中的59行告诉链接器,从可重定位目标文件到可执行目标文件,要修改开始于.text节,偏移量为0x13处连续4个字节的值,再让PC加上“out符号地址到此地址”的偏移值。这句拗口的话的含义分别解释如下。
“开始于.text偏移量0x13处”的含义是用户编写的.text节处的第0x13字节处,解释这个地方用main.o的反汇编文件更为清楚:
Figure11. main.o反汇编文件mof.dat
e8(call指令的机器码)在.text节的偏移量为0x12处,0xfffffffc在.text节中第0x13~0x16字节处。
在重定位节和符号定义中,链接器已经为各符号和节分配了内存地址。链接器在重定位符号引用中就是要把像Figure11中的0xfffffffc修改掉。根据Figure10第59行的含义,我们需要找到.text节的虚拟地址(main地址)和out函数的虚拟地址,就可以修改Figure11图中的可重定位目标文件中.text节中偏移量为0x13的连续4字节的内容了。可在可执行目标文件中查看main()和out()的虚拟地址,用vi命令打开m.dat:
Figure12. main()和out()虚拟地址
1首先得到“引用out符号的运行地址”ADD(run) = ADD(main) + Offset = 0x80483dc + 0x13 = 0x80483ef。 2再计算out()地址与“引用out符号的运行地址”的偏移值:ADD(p) = ADD(out) - ADD(run) = 0x8048404 - 0x80483ef = 0x15。 3新引用的值为ADD(y) = ADD(p) + 原来PC中的值 = 0x15 + 0xfffffffc(-4) = 0x11。 那么Figure 11中偏移量为0x13的引用值就被更改为:0x11。 |
在可执行目标文件的反汇编中可以看到这个引用的修改:
Figure13. 可执行文件main的反汇编文件mf.dat
修改这个引用为0x00000011后,执行call指令时就可以对out()函数进行正确的调用:CPU读取call指令后PC指向下一条指令(PC= 0x80483f3),并把PC值压栈,再将PC的值加上0x00000011即PC= PC + 0x00000011 = 0x80483f3 + 0x00000011 = 0x8048404,正好为out()函数地址。out()函数执行完毕后,PC再从栈中获取压栈的值0x80483f3,再执行调用out()的下一条指令。这就是链接器重定位PC相对引用的一种过程。
重定位绝对引用
在shell界面下重新使用以下命令输出目标文件:
objdump -Dr main.o > mof.dat objdump -Dr main > mf.dat |
用vi命令打开mof.dat,找到p(满足重定位数据的条件):
Figure14. main.o中的指针变量p
结合Figure10的第64行,在.rel.data节有一个值为0的p指针。这个信息告诉链接器:这是一个32位绝对应用,开始于偏移4处,必须重定位使得它指向符号a。
首先,在重定位符号定义时,a已经被链接器分配了虚拟地址(在mf.dat查看):
Figure15. 符号a的地址
那么“引用p的地址”为:ADD(vp)= ADD(a) = 0x8049690= 0x8049690。
链接器在可执行文件main中重定位p对a的引用(在mf.dat)查看:
Figure16. 重定位绝对引用
那么在程序中对p进行间接引用*p时,对应的机器级程序是访问的(%eax),%eax= 0x8049690。
4 静态链接
静态链接属于链接器“符号解析”这个过程。由于编译系统提供将相关的可重定位目标文件打包成为“静态库”的机制,故而将其从“符号解析”中提出来单独笔记。静态库可以作为链接器的输入,链接器只拷贝静态库里被应用程序引用的可重定位目标文件。
4.1 创建静态库
(1) 为一个函数创建单独的可重定位目标文件
Table 10. addvec.c
1. void addvec( int *x, int *y, 2. int *z, int n ) 3. { 4. int i; 5. 6. for (i = 0; i < n; i++) 7. z[i] = x[i] + y[i]; 8. } |
Table 11. multevc.c
1. void multevc(int *x, int *y, 2. int *z, int n) 3. { 4. int i; 5. 6. for (i = 0; i < n; i++) 7. z[i] = x[i] * y[i]; 8. } |
在Shell下使用以下命令将每个函数创建可重定位目标文件:
gcc -c addvec.c multve.c |
此命令执行成功后,输出addvec.o和multve.o文件。
(2) 连接可重定位目标文件形成静态库
在Shell界面下使用ar命令对每个函数的可重定位目标文件创建一个静态库:
ar rcs libme.a addvec.o multve.o |
此命令执行成功后输出libme.a文件,到这里就在Linux下创建了一个静态库libme.a。
(3) 使用创建的静态库libme.a
创建一个声明静态库函数原型的文件(声明函数原型很有必要):
Table 12. slibme.h
1. void addvec(int *, int *, int *, int); 2. void multvec(int *, int *, int *, int); |
Table 13.main.c
1. /*main.c*/ 2. #include <stdio.h> 3. #include "slibme.h" 4. 5. int x[2] = {1, 2}; 6. int y[2] = {3, 4}; 7. int z[2]; 8. 9. int main(void) 10. { 11. addvec(x, y, z, 2); 12. printf("z = [%d %d] ", z[0], z[1]); 13. return 0; 14. } |
在shell下使用以下命令生成可执行文件:
gcc -static main.c -o main ./libme.a |
经这个步骤就可以生成可执行文件main。链接器在这个过程的行为:
- -static告诉编译器驱动程序(cpp+ ccl + as + ld),链接器应该构建一个完全链接的可执行目标文件,它可以加载到内存运行,在加载时无需进一步的连接。
- 当链接器运行时,它判定dddvec.o定义的addvec符号是被main.o引用的,所以它拷贝addvec.o到可执行文件。因为程序没引用任何在multvec.o中的符号,所以链接器就不会拷贝这个文件到可执行文件。
- 链接器还会拷贝lib.a中的printf.o文件及其它文件到可执行文件中。
4.2 链接器如何使用静态库来解析引用
[1] 链接器链接静态库的算法会给程序员带来困惑(《CSAPP》2eP460)
[2] 静态链接无动态链接优异,按照《C专家编程》中的说法是静态已经被淘汰了。笔记也不再分析链接器如何使用静态解析引用了。它不会偏移3中链接器解析引用的过程。
5 动态链接
在Unix(Linux)上,共享库也是目标文件,它在运行时可被加载到内存中的任意位置,并和一在内存中程序链接(看3,链接都做了什么)起来。与链接静态库相比,链接动态库解决了链接静态库的以下缺点:
[1] 库有更新时,程序员不用动手来重新链接。
[2] 所有使用同一个库函数的程序共享这个函数,不用将代码和数据(可重定位目标文件)拷贝至可执行文件中。
5.1 创建动态库
依旧使用Table11至Table 13中的程序,在Shell界面下使用以下命令来创建动态库:
gcc -shared -fPIC -o libme.so addvec.c multve.c |
-shared只是链接器创建一个共享的目标文件(共享库);-fPIC指示编译器生成与位置无关的代码。
在shell下使用以下命令输出可执行文件:
gcc -o main2 main.c ./libme.so |
用这个命令创建可执行文件时,静态执行一些链接信息(重定位和符号信息,.interp表包含动态链接库的信息,它们使得运行时可以解析对libme.so中代码和数据的引用),然后在程序加载时完成动态链接过程。此时,没有任何数据和代码被拷贝到可执行文件中。
5.2 动态链接器完成链接任务
加载动态链接程序时会加载和运行动态链接器(动态链接器本身也是一个共享目标,Linux系统上为LD-LINUX.SO)。然后动态链接器通过执行下面的重定位完成链接任务:
[1] 重定位libc.so的文本和数据到某个存储器段。
[2] 重定位libme.so的文本和数据到另一个存储器段。
[3] 重定位p2中所有对libc.so和libme.so定义的符号的引用。
《C专家编程》有专门的一章叙述“动态链接”。
6 从应用程序中加载和链接共享库
Linux系统为动态链接器提供了一个简单的接口,供用户程序指定在运行时要加载和链接哪一个共享库。以下程序使用动态链接器的接口来动态链接libme.so库,并调用它的addvec函数:
1. #include <stdio.h> 2. #include <stdlib.h> 3. #include <dlfcn.h> 4. 5. int x[2] = {1, 2}; 6. int y[2] = {3, 4}; 7. int z[2]; 8. 9. int main(void) 10. { 11. void *handle; 12. void (*addvec)(int *, int *, int *, int); 13. char *error; 14. 15. //Dynamically load shared libray that contains addvec() 16. handle = dlopen("./libme.so", RTLD_LAZY); 17. if (!handle) { 18. fprintf(stderr, "%s ", dlerror()); 19. exit(1); 20. } 21. 22. //Get a pointer to the addvec() function we just loaded 23. addvec = dlsym(handle, "addvec"); 24. if ((error = dlerror()) != NULL) { 25. fprintf(stderr, "%s ", error); 26. exit(1); 27. } 28. 29. //Now we can call addvec() just like any other function 30. addvec(x, y, z, 2); 31. printf("z = [%d %d] ", z[0], z[1]); 32. 33. //Unload the shared library 34. if (dlclose(handle) < 0) { 35. fprintf(stderr, "%s ", dlerror()); 36. exit(1); 37. } 38. return 0; 39. } |
在shell下使用以下函数输出可执行文件:
gcc -rdynamic -o main3 dll.c -ldl |
-rdynamic参数使得程序中的共享库的全局符号可用。-ldl选项,表示生成的对象模块需要使用共享库。