前言

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表示相对地址调用指令即call00 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