动态链接编译可执行文件时.so/.lib文件的用处以及ELF与PE文件的区别
前言
Linux下编译动态链接文件会生成.so文件,而编译可执行文件时也要带上此.so文件一起;Windows下编译动态链接文件会生成.dll和.lib文件,编译可执行文件时需要带上.lib文件一起。本文主要介绍为什么可执行文件编译时需要带上.so/.lib文件,以及在这个角度下PE与ELF的一些区别。
本文以调用动态链接库中函数为例来说明。
为什么需要.so/.lib文件
举个例子,对于如下的两个文件:
/* Main.c */
int main() {
foo();
return 0;
}
/* Lib.c */
#include <stdio.h>
void foo() {
printf("this is foo");
}
Lib.c独自编译为动态链接库,Main.c编译为可执行文件,其中main()
函数调用了Lib.c中的foo()
。注意我们说main()
调用了foo()
,只是我们期望的结果,那程序怎么去寻找呢?显然我们独自编译Main.c是不可能找到foo()
的,整个编译过程都没有Lib.c的存在,上哪找?事实上编译器也不会让Main.c编译通过。
所以编译时至少需要两个信息:
- 函数定义在哪:在动态链接库中还是未定义;
- 动态链接库文件名是什么:可执行文件运行时可以加载对应的动态链接库。
正是因为需要这些信息,在编译生成使用到动态链接库的可执行文件的时候,才需要.so/.lib文件的参与:用来提供这些信息。下面我们来仔细看看编译时的一些细节问题,.so文件和.lib文件分别对应Linux下的ELF格式和Windows下的PE格式,我们分开讨论。
ELF
示例
Program.c:
/* Program.c */
int main() {
foobar(1);
return 0;
}
Lib.c:
/* Lib.c */
#include <stdio.h>
void foobar(int i) {
printf("result = %d\n", i);
}
将Lib.c编译为共享对象,生成Lib.so:
gcc -fPIC -shared Lib.c -o Lib.so
编译Program.c,生成Program.o:
gcc -c Program.c
链接Program.o和Lib.so,生成可执行文件Program:
gcc Program.o ./Lib.so -o Program
重定位
编译生成的Program.o并不知道foobar()
的位置,链接时怎么发现foobar()
的呢?在ELF下,共享对象(.so文件)的所有全局函数和全局变量都是默认导出的,保存在动态符号表.symtab段中。链接时通过查看Lib.so中的动态符号表,就能发现foobar()
,即由共享对象定义。此时在可执行文件中创建GOT和PLT,并将foobar()
加入其中,这里就不展开讲了。
对foobar()
具体是如何重定位的?我们从汇编的层次来看。先看看重定位前的Program.o:
0000000000000000 <main>:
0: 55 push %rbp
1: 48 89 e5 mov %rsp,%rbp
4: bf 01 00 00 00 mov $0x1,%edi
9: b8 00 00 00 00 mov $0x0,%eax
e: e8 00 00 00 00 callq 13 <main+0x13>
13: b8 00 00 00 00 mov $0x0,%eax
18: 5d pop %rbp
19: c3 retq
注意看e行,即调用foobar()
,其二进制指令为e8 00 00 00 00
,其中e8
表示相对地址调用指令即call
,其后的4个字节为目的地址相对于当前指令的下一条指令的偏移。显然这里的00 00 00 00
是指foobar()
的地址还未知,需要重定位。
看看重定位后的Program:
0000000000400686 <main>:
400686: 55 push %rbp
400687: 48 89 e5 mov %rsp,%rbp
40068a: bf 01 00 00 00 mov $0x1,%edi
40068f: b8 00 00 00 00 mov $0x0,%eax
400694: e8 c7 fe ff ff callq 400560 <foobar@plt>
400699: b8 00 00 00 00 mov $0x0,%eax
40069e: 5d pop %rbp
40069f: c3 retq
注意看400694,重定位后foobar()
的地址已经确定,其相对偏移为c7 fe ff ff
,我们看汇编代码给的注释是foobar@plt,即重定位到了PLT。这样运行时调用foobar()
就跳转到PLT再跳转到GOT,最后跳转到foobar()
的真实地址,这里不展开讲了。
装载动态链接库
可执行文件如何知道装载哪个共享对象?如果链接时同时给出了一些不相关的.so文件,那么可执行文件运行时会把这些.so文件全都装载吗?显然不会,可执行文件只会装载需要的共享对象,即用到了其函数或变量的共享对象,在这里就是Lib.so了。那么可执行文件是怎么记录的?我们来看看Program的.dynamic段:
Dynamic section at offset 0xe18 contains 25 entries:
标记 类型 名称/值
0x0000000000000001 (NEEDED) 共享库:[./Lib.so]
0x0000000000000001 (NEEDED) 共享库:[libc.so.6]
...
.dynamic段中记录了Lib.so,这个Lib.so来源当然就是链接时提供的啦!
仔细看.dynamic段是不是发现Lib.so的前面还有个路径标记./
?ELF可执行文件会记录.so文件路径?我们把.so文件换个位置再链接看看
gcc Program.o dym/Lib.so -o Program
看看.dynamic段:
Dynamic section at offset 0xe18 contains 25 entries:
标记 类型 名称/值
0x0000000000000001 (NEEDED) 共享库:[dym/Lib.so]
0x0000000000000001 (NEEDED) 共享库:[libc.so.6]
果然可执行文件记录的是共享对象的路径,而不是文件名。也就是说可执行文件运行时会根据.dynamic段中记录的路径去装载共享对象(即.so文件),如果此共享对象不存在,则会报错。
PE
示例
Program.c:
/* Program.c */
int main() {
foobar(1);
return 0;
}
Lib.c:
/* Lib.c */
#include <stdio.h>
#define DllExport __declspec(dllexport)
DllExport void foobar(int i) {
printf("result = %d\n", i);
}
将Lib.c编译为DLL,生成Lib.dll和Lib.lib:
cl /LD Lib.c
编译Program.c,生成Program.obj:
cl /c Program.c
链接Program.obj和Lib.lib,生成可执行文件Program.exe:
link Program.obj Lib.lib
.lib文件是什么
注意到将Lib.c编译为DLL生成了Lib.dll和Lib.lib,其中Lib.dll是动态链接库文件,那么Lib.lib又是什么呢?
这里Lib.lib称为导入库(Import Library),虽然后缀和Windows下的静态链接库相同,但实际上是两个不一样的东西。导入库(如Lib.lib)并不包含源文件(如Lib.c)的代码和数据,仅用来描述DLL(如Lib.dll)的导出符号,并包含了一部分“桩(Stub)代码”。来看看Lib.lib到底有什么:
5 public symbols
172 __IMPORT_DESCRIPTOR_Lib
38C __NULL_IMPORT_DESCRIPTOR
4BE Lib_NULL_THUNK_DATA
608 __imp__foobar
608 _foobar
...
Version : 0
Machine : 14C (x86)
TimeDateStamp: 57F89554 Sat Oct 8 14:42:28 2016
SizeOfData : 00000010
DLL name : Lib.dll
Symbol name : _foobar
Type : code
Name type : no prefix
Hint : 0
Name : foobar
...
就是记录了导出的函数foobar()
,以及DLL的名字Lib.dll。
这里还有一点要注意,DLL默认不导出任何符号(ELF默认导出所有符号),需要导出的符号需要显式声明;如这里Lib.c中通过__declspec(dllexport)
声明将foobar()
导出。
重定位
DLL中导出的符号会放在导出表(Export Table)中,并且会记录在导入库(.lib文件)中。链接时查看Lib.lib就能发现foobar()
,即由DLL定义。此时在可执行文件中创建IAT,并将foobar()
加入其中,这里就不展开讲了。
我们也通过汇编代码来看看是如何重定位的。Program.obj:
_main:
00000000: 55 push ebp
00000001: 8B EC mov ebp,esp
00000003: 6A 01 push 1
00000005: E8 00 00 00 00 call _foobar
0000000A: 83 C4 04 add esp,4
0000000D: 33 C0 xor eax,eax
0000000F: 5D pop ebp
00000010: C3 ret
和ELF类似,00000005行中E8
表示相对地址调用指令即call
,00 00 00 00
则表示foobar()
的地址还未知,需要重定位。
看看重定位后的Program.exe:
File Type: EXECUTABLE IMAGE
00401000: 55 push ebp
00401001: 8B EC mov ebp,esp
00401003: 6A 01 push 1
00401005: E8 08 00 00 00 call 00401012
0040100A: 83 C4 04 add esp,4
0040100D: 33 C0 xor eax,eax
0040100F: 5D pop ebp
00401010: C3 ret
00401011: CC int 3
00401012: FF 25 08 C1 40 00 jmp dword ptr ds:[0040C108h]
这里和ELF不同了。注意看00401005行,重定位后相对偏移为08 00 00 00
,即跳转到00401012,而00401012并不对应IAT,而是jmp dword ptr ds:[0040C108h]
,此行才对应跳转到IAT。
为什么要用一个中间跳转,不直接跳到IAT?因为PE对直接调用指令和间接调用指令是区分开的,一个是call
,而另一个是jmp dword ptr xxxx
(这里我也不太确定,如有错误还请指出),所以不能像ELF那样只用修改偏移地址,而需要另外一条指令。
中间跳转的指令从哪来?我们知道链接器一般不产生指令,那这里00401012行是从哪来的呢?答案是来自导入库,就是那个Lib.lib,这个指令又被称为桩代码。
这样运行时调用foobar()
就首先跳转到桩代码,再跳转到IAT,最后跳转到foobar()
的真实地址,这里不展开讲了。
注意:如果将需要用到的外部导入函数提前用_declspec(dllimport)
声明,则目标文件的指令直接是jmp dword ptr xxxx
仅需重定位,而不是call xxxx
需要桩代码(文末参考中有相关链接)。
装载动态链接库
ELF在链接时就使用的动态链接库,因此而在可执行文件中记录下动态链接库的名字和路径。那么PE在链接时只用到了导入库.lib文件,可执行文件又是怎么记录到动态链接库的呢?
答案其实在之前就已经给出了,看看Lib.lib的内容:
Version : 0
Machine : 14C (x86)
TimeDateStamp: 57F89554 Sat Oct 8 14:42:28 2016
SizeOfData : 00000010
DLL name : Lib.dll
Symbol name : _foobar
Type : code
Name type : no prefix
Hint : 0
Name : foobar
对于每一个导出符号,导入库不仅记录的符号的名字,还记录了所在的DLL,像这里就是Lib.dll,甚至,还记录了版本号和时间戳(这个有其他的用途,这里不解释)。
在可执行文件中,这些信息则被记录到了导入表(Import Table)中,看看Program.exe的导入表:
Section contains the following imports:
Lib.dll
40C108 Import Address Table
411190 Import Name Table
0 time date stamp
0 Index of first forwarder reference
0 foobar
KERNEL32.dll
40C000 Import Address Table
411088 Import Name Table
0 time date stamp
0 Index of first forwarder reference
...
看到Lib.dll了吧?可执行文件在运行时就会根据导入表中记录的DLL名字去寻找。我们在这里没有看到像ELF那样记录DLL路径的标记,那么PE是不是不会记录DLL的路径?答案是肯定的,PE不会记录DLL的路径。在寻找DLL时操作系统会按照预选确定的规则去寻找DLL,在可执行文件中只会记录DLL的名字(你可以做做实验验证一下,文末参考中也有MSDN对于搜索路径的规定)。
ELF和PE的主要区别
这里从动态链接库的角度总结一下ELF和PE的主要区别:
- ELF的动态链接库默认导出全部符号,而PE则默认全部不导出,需要显式声明;
- 重定位时,ELF只需要修正偏移地址,而PE则可能需要额外插入桩代码(这是由于PE对内部函数和外部函数调用的指令不同造成的);
- 可执行文件中,ELF会记录动态链接库的路径,运行时从记录的路径装载动态链接库;PE只会记录动态链接库名字等信息,运行时从预先设定的路径寻找动态链接库。
可否不用.so/.lib文件
链接时不使用.so/.lib文件也是可行的,不过这时动态链接库的加载方式也变了,不再是装载时加载而是运行时加载。这种技术称为显式运行时链接(Explicit Run-time Linking),让程序自己在运行时控制加载指定模块,并且可以在不需要该模块时将其卸载。
看看ELF和PE的例子。
ELF
ELF使用API:
- 打开动态库:
dlopen()
; - 查找符号:
dlsym()
; - 错误处理:
dlerror()
; - 关闭动态库:
dlclose()
。
Program.c:
/* Program.c */
#include <dlfcn.h>
int main() {
void* handle;
void (*foobar)(int);
handle = dlopen("./Lib.so", RTLD_NOW);
foobar = dlsym(handle, "foobar");
foobar(1);
dlclose(handle);
return 0;
}
Lib.c:
/* Lib.c */
#include <stdio.h>
void foobar(int i) {
printf("result = %d\n", i);
}
将Lib.c编译为共享对象,生成Lib.so:
gcc -fPIC -shared Lib.c -o Lib.so
编译Program.c,生成Program.o:
gcc -c Program.c
链接Program.o和DL库,生成可执行文件Program:
gcc Program.o -ldl -o Program
运行:
./Program
结果:
result = 1
### PE
ELF使用API:
- 装载动态库:`LoadLibrary()`;
- 查找符号:`GetProcAddress()`;
- 卸载动态库:`FreeLibrary()`。
Program.c:
/ Program.c /
include <windows.h>
typedef void (*Foobar)(int);
int main() {
HINSTANCE hinstLib = LoadLibrary("Lib.dll");
Foobar foobar = (Foobar)GetProcAddress(hinstLib, "foobar");
foobar(1);
FreeLibrary(hinstLib);
return 0;
}
Lib.c:
/ Lib.c /
include <stdio.h>
define DllExport __declspec(dllexport)
DllExport void foobar(int i) {
printf("result = %dn", i);
}
将Lib.c编译为DLL,生成Lib.dll和Lib.lib:
cl /LD Lib.c
编译Program.c,生成Program.obj:
cl /c Program.c
链接Program.obj,生成可执行文件Program.exe:
link Program.obj
运行:
Program.exe
结果:
result = 1
## 参考
- 俞甲子, 潘爱民. 程序员的自我修养: 链接, 装载与库[M]. 电子工业出版社, 2009.
- [Using __declspec(dllimport) and __declspec(dllexport)][declspec]
- [Search Path Used by Windows to Locate a DLL][Search Path]
[declspec]:https://msdn.microsoft.com/en-us/library/aa271769.aspx
[Search Path]:https://msdn.microsoft.com/en-us/library/7d83bc18.aspx
Great stuff. Thank you!
这篇文章总结的真是好!谢谢分享