编译过程和符号表重定位问题:转载至:点击打开链接
对于代码的编译问题千头万绪从何说起呢,首先来说一下计算机是如何处理应用程序的,实质上应用程序是通过操作系统来应用机器指令操控硬件设施完成各种任务的,就从编译的环节开始谈起吧,众所周知,程序开发人员所写的代码实际上计算机是没有办法去认识的,那么就必须通过编译将其转换为计算机可以认识的机器指令,在有操作系统根据具体指令从硬件上分配内存处理程序段。以下从预编译,编译,汇编,链接,来简单的说一下程序的编译过程。
2.1编译预处理
在这个阶段主要是宏定义的展开,以及头文件的递归处理,即展开所有的以#开头的编译命令,删除所有注释,添加行号和文件名标识,保留所有的#program。
2.2编译阶段
将程序代码段按字符流格式进行切割,处理,主要是词法分析,语法分析,语义分析以及优化等阶段,编译完成后生成中间代码。
2.3汇编
将编译后的中间代码通过汇编器模块生成计算机能够识别的机器指令用以操控硬件设施生成目标代码(可重定位目标代码)。
2.4链接
通过链接器模块将各种目标代码以及库文件(*.lib文件),资源文件(*,rec)进行链接处理最终生成可以执行的*.exe文件。
2.5重定位问题
通过一个例子来看:假如我们有两个头文件和两个源文件分别叫做function1.h和function2.h以及function1.cpp和function2.cpp文件其中function1.h内容如下
Function1.h
#ifndef _FUNCTION1_H
#define _FUNCTION1_H
Int g_val;
Int Add(int m, int n);
#endif
Function1.cpp
g_val=10;
Int Add(int m, int n)
{
Return m+n;
}
Function2.cpp其中包含了main函数内容如下
#include “function1.h”
Int main()
{
Int l_valfri=3;
Int l_valsec=4;
g_val=14;
Int result=Add(l_valfri,l_valsec);
Return 0;
}
对于这样的代码编译器在编译function2.cpp时对于外部符号g_val 和外部函数Add该如何决议呢,这里又会涉及到可重定位文件中的符号表问题。
其实在可重定位目标文件之中会存在一个用来放置变量和其入口地址的符号表,当编译过程中能够找到该符号的定义时就将该符号入口地址更新到符号表中否则就对该符号的地址不做任何决议一直保留到链接阶段处理。通过两个例子来看符号表的结构。
在编译过程中function1.cpp文件的可重定位目标文件中的符号表如下
变量名 |
内存地址 |
g_val |
0x100 |
Add |
0x200 |
|
|
为什么可以做到对于符号g_val和Add分配内存地址呢,因为在编译阶段就能够在function1.cpp文件中找到他们的定义,所以能够进行明确的内存地址分配。
再来看看function2.cpp所生成的可重定位目标文件的结构:
变量名 |
内存地址 |
g_val |
0x00 |
Add |
0x00 |
为什么会出现这样的状况。因为在编译阶段虽然可以看到这些符号变量的声明,但却找不到他们的定义所以编译器陷入了一个决而未决的境地。
将包含文件展开时,function2.cpp大概会是这个样子很明显只有符号变量的声明但是没有定义。
#ifndef _FUNCTION1_H
#define _FUNCTION1_H
Int g_val;
Int Add(int m, int n);
#endif
Int main()
{
Int l_valfri=3;
Int l_valsec=4;
g_val=14;
Int result=Add(l_valfri,l_valsec);
Return 0;
}
先将他们存放在符号表中但却不去为他们进行内存关联一直等到链接阶段在进行处理。
重定位发生于目标代码链接阶段,在链接阶段链接器就会查找符号表,当他发现了function2.cpp的符号表之中任然有没有决议的内存地址时,链接器就会查找所有的目标代码文件,一直到他找到了function1.cpp所生成的目标代码文件符号表时发现了这些没有决议的符号变量的真正内存地址,这是function2.cpp所生成的目标代码文件就会更新它的符号表,将这些尚未决议的符号变量的内存地址写进其符号表中。
更新之后的function2.obj文件符号表
变量名 |
内存地址 |
g_val |
0x100 |
Add |
0x200 |
|
|
当所有的符号变量都能够找到合法的内存地址时,链接阶段重定位完成。
静态和动态链接:转载至点击打开链接
即使是最简单的HelloWorld的程序,它也要依赖于别人已经写好的成熟的软件库,这就是引出了一个问题,我们写的代码怎么和别人写的库集成在一起,也就是链接所要解决的问题。
首先看HelloWorld这个例子:
-
- 1 #include
- 2
- 3 int main(int argc, char** argv)
- 4 {
- 5 printf("Hello World! argc=%d\n", argc);
- 6 return 0;
- 7 }
HelloWorld的main函数中引用了标准库提供的printf函数。链接所要解决的问题就是要让我们的程序能正确地找到printf这个函数。
解决这个问题有两个办法:一种方式是在生成可执行文件的时候,把printf函数相关的二进制指令和数据包含在最终的可执行文件中,这就是静态链接;另外一种方式是在程序运行的时候,再去加载printf函数相关的二进制指令和数据,这就是动态链接。
每个源文件都会首先被编译成目标文件,每个目标文件都提供一些别的目标文件需要的函数或者数据,同时又从别的目标文件中获得一些函数或者数据。因此,链接的过程就是目标文件间互通有无的过程。
本文根据《程序员的自我修养》一书中关于静态和动态链接总结而成,欢迎指正并推荐阅读原书。
静态链接
静态链接就是在生成可执行文件的时候,把所有需要的函数的二进制代码都包含到可执行文件中去。因此,
链接器需要知道参与链接的目标文件需要哪些函数,同时也要知道每个目标文件都能提供什么函数,这样链接器才能知道是不是每个目标文件所需要的函数都能正确地链接。如果某个目标文件需要的函数在参与链接的目标文件中都找不到的话,链接器就报错了。
目标文件中有两个重要的接口来提供这些信息:一个是符号表,另外一个是重定位表。
利用Linux中的readelf工具就可以查看这些信息。
首先我们用命令gcc -c -o main.o main.c 来编译上面main.c文件来生成目标文件main.o。然后我们用命令readelf -s main.o来查看main.o中的符号表:
- Symbol table '.symtab' contains 11 entries:
- Num: Value Size Type Bind Vis Ndx Name
- 0: 00000000 0 NOTYPE LOCAL DEFAULT UND
- 1: 00000000 0 FILE LOCAL DEFAULT ABS main.c
- 2: 00000000 0 SECTION LOCAL DEFAULT 1
- 3: 00000000 0 SECTION LOCAL DEFAULT 3
- 4: 00000000 0 SECTION LOCAL DEFAULT 4
- 5: 00000000 0 SECTION LOCAL DEFAULT 5
- 6: 00000000 0 SECTION LOCAL DEFAULT 7
- 7: 00000000 0 SECTION LOCAL DEFAULT 8
- 8: 00000000 0 SECTION LOCAL DEFAULT 6
- 9: 00000000 36 FUNC GLOBAL DEFAULT 1 main
- 10: 00000000 0 NOTYPE GLOBAL DEFAULT UND printf
我们重点关注最后两行,从中可以看到main.o中提供main函数(Type列为FUNC,Ndx为1表示它是在本目标文件中第1个Section中),同时依赖于printf函数(Ndx列为UND)。
因为在编译main.c的时候,编译器还不知道printf函数的地址,所以在编译阶段只是将一个“临时地址”放到目标文件中,在链接阶段,这个“临时地址”将被修正为正确的地址,这个过程叫重定位。所以链接器还要知道该目标文件中哪些符号需要重定位,这些信息是放在了重定位表中。很明显,在main.o这个目标文件中,printf的地址需要重定位,我们还是用命令readelf -r main.o来验证一下,这些信息是保存在.rel.textSection中:
- Relocation section '.rel.text' at offset 0x400 contains 2 entries:
- Offset Info Type Sym.Value Sym. Name
- 0000000a 00000501 R_386_32 00000000 .rodata
- 00000019 00000a02 R_386_PC32 00000000 printf
那么既然main.o依赖于printf函数,你可能会问,printf是在哪个目标文件里面?printf函数是标准库的一部分,在Linux下静态的标准库libc.a位于/usr/lib/i386-linux-gnu/中。你可以认为标准库就是把一些常用的函数的目标文件打包在一起,用命令ar -t libc.a可以查看libc.a中的内容,其中你就可以发现printf.o这个目标文件。在链接的时候,我们需要告诉链接器需要链接的目标文件和库文件(默认gcc会把标准库作为链接器输入的一部分)。链接器会根据输入的目标文件从库文件中提取需要目标文件。比如,链接器发现main.o会需要printf这个函数,在处理标准库文件的时候,链接器就会把printf.o从库文件中提取处理。当然printf.o依赖的目标文件也很被一起提取出来。库中其他目标文件就被舍弃掉,从而减小了最终生成的可执行文件的大小。
知道了这些信息后,链接器就可以开始工作了,分为两个步骤:1)合并相似段,把所有需要链接的目标文件的相似段放在可执行文件的对应段中。2)重定位符号使得目标文件能正确调用到其他目标文件提供的函数。
用命令gcc -static -o helloworld.static main.c来编译并做静态链接,生成可执行文件helloworld.static。因为可执行文件helloworld.static已经是链接好了的,所以里面就不会有重定位表了。命令 readelf -S helloworld.static | grep .rel.text将不会有任何输出(注:-S是打印出ELF文件中的Sections)。经过静态链接生成的可执行文件,只要装载到了内存中,就可以开始运行了。
动态链接
静态链接看起来很简单,但是有些不足。其中之一就对磁盘空间和内存空间的浪费。标准库中那些函数会被放到每个静态链接的可执行文件中,在运行的时候,这些重复的内容也会被不同的可执行文件加载到内存中去。同时,如果静态库有更新的话,所有可执行文件都得重新链接才能用上新的静态库。动态链接就是为了解决这个问题而出现的。所谓动态链接就是在运行的时候再去链接。
理解动态链接需要从两个角度来看,一是从动态库的角度,而是从使用动态库的可执行文件的角度。
从动态库的角度来看,动态库像普通的可执行文件一样,有其代码段和数据段。为了使得动态库在内存中只有一份,需要做到不管动态库装载到什么位置,都不需要修改动态库中代码段的内容,从而实现动态库中代码段的共享。而数据段中的内容需要做到进程间的隔离,因此必须是私有的,也就是每个进程都有一份。因此,动态库的做法是把代码段中变化的部分放到数据段中去,这样代码段中剩下的就是不变的内容,就可以装载到虚拟内存的任何位置。那代码段中变化的内容是什么,主要包括了对外部函数和变量的引用。
我们来看一个简单的例子吧,假设我们要把下面的代码做成一个动态库:
- 1 #include
- 2 extern int shared;
- 3 extern void bar();
- 4 void foo(int i)
- 5 {
- 6 printf("Printing from Lib.so %d\n", i);
- 7 printf("Printing from Lib.so, shared %d\n", shared);
- 8
- 9 bar();
- 10 sleep(-1);
- 11 }
用命令gcc -shared -fPIC -o Lib.so Lib.c将生成一个动态库Lib.so(-shared是生成共享对象,-fPIC是生成地址无关的代码)。该动态库提供(导出)一个函数foo,依赖(导入)一个函数bar,和一个变量shared。
这里我们需要解决的问题是如何让foo这个函数能正确地引用到外部的函数bar和shared变量?程序装载有个特性,代码段和数据段的相对位置是固定的,因此我们把这些外部函数和外部变量的地址放到数据段的某个位置,这样代码就能根据其当前的地址从数据段中找到对应外部函数的地址(前提是谁能帮忙在数据段中填上这个外部函数的正确地址,下面会讲)。动态库中外部变量的地址是放在.got(global offset table)中,外部函数的地址是放在了.got.plt段中。
如果你用命令readelf -S Lib.so | grep got将会看到Lib.so中有这样两个Section。他们就是分别存放外部变量和函数地址的地方。
- [20] .got PROGBITS 00001fe4 000fe4 000010 04 WA 0 0 4
- [21] .got.plt PROGBITS 00001ff4 000ff4 000020 04 WA 0 0 4
到此为止,我们知道了动态库是把地址相关的内容放到了数据段中来实现地址无关的代码,从而使得动态库能被多个进程共享。那么接着的问题就谁来帮助动态库来修正.got和.got.plt中的地址。
那么我们就从动态链接器的角度来看看吧!
静态链接的可执行文件在装载进入内存后就可以开始运行了,因为所有的外部函数都已经包含在可执行文件中。而动态链接
的可执行文件中对外部函数的引用地址在生成可执行文件的时候是未知的,所以在这些地址被修正前是动态链接生成的可执行文件是不能运行的。因此,动态链接生成的可执行文件运行前,系统会首先将动态链接库加载到内存中,动态链接器所在的路径在可执行文件可以查到的。
还是以前面的helloworld为例,用命令gcc -o helloworld.dyn main.c来以动态链接的方式生成可执行文件。然后用命令readelf -l helloworld.dyn | grep interpreter可以看到动态链接器在系统中的路径。
- [Requesting program interpreter: /lib/ld-linux.so.2]
当动态链接器被加载进来后,它首先做的事情就是先找到该可执行文件依赖的动态库,这部分信息也是在可执行文件中可以查到的。用命令readelf -d helloworld.dyn,可以看到如下输出:
- Dynamic section at offset 0xf28 contains 20 entries:
- Tag Type Name/Value
- 0x00000001 (NEEDED) Shared library: [libc.so.6]
或者用命令ldd
helloworld.dyn
,可以看到如下输出:
- linux-gate.so.1 => (0x008cd000)
- libc.so.6 => /lib/i386-linux-gnu/libc.so.6 (0x00a7a000)
- /lib/ld-linux.so.2 (0x0035d000)
都表明该可执行文件依赖于libc.so.6这个动态库,也就是C语言标准库的动态链接版本。
如果某个库依赖于别的动态库,它们也会被加载进来直到所有依赖的库都被加载进来。
当所有的库都被加载进来以后,类似于静态链接,动态链接器从各个动态库中可以知道每个库都提供什么函数(符号表)和哪些函数引用需要重定位(重定位表),然后修正.got和.got.plt中的符号到正确的地址,完成之后就可以将控制权交给可执行文件的入口地址,从而开始执行我们编写的代码了。
可见,动态链接器在程序运行前需要做大量的工作(修正符号地址),为了提高效率,一般采用的是延迟绑定,也就是只有用到某个函数才去修正.got.plt中地址,具体是如何做到延迟绑定的,推荐看《程序员的自我修养》一书。
小结
链接解决我们写的程序是如何和别的库组合在一起这个问题。每个参与链接的目标文件中都提供了这样的信息:我有什么符号(变量或者函数),我需要什么符号,这样链接器才能确定参与链接的目标文件和库是否能组合在一起。静态链接是在生成可执行文件的时候把需要的所有内容都包含在了可执行文件中,这导致的问题是可执行文件大,浪费磁盘和内存空间以及静态库升级的问题。动态链接是在程序运行的时候完成链接的,首先是动态链接器被加载到内存中,然后动态链接器再完成类似于静态链接器的所做的事情。