CSAPP 第七章(二)动态链接和库

People don’t buy what you do, they buy why you do it.

静态链接也存在一些问题:

  • 几乎每一个进程的text节都存在重复的代码,占用内存资源。例如c中的printf, scanf
  • 静态库需要维护并周期性更新

1. 术语

1.1. 共享库和动态链接

共享库,即能够在运行时(run time)或装载期(load time)装载到任意内存地址位置并和链接的目标模块。共享库也被称为共享目标(shared objects),在linux中通常带有.so后缀。wins中被称为DLL(dynamic link libraries)。

动态链接(dynamic linking),即上述装载和链接过程,由动态链接器(dynamic linker)执行。

2. 通过共享库进行load time动态链接

共享库通过以下两种方式共享

  1. 对任意库,文件系统中仅存在单个.so文件。该.so文件中的代码和数据被所有引用了该库的可执行目标文件共享,而不会存在于可执行目标文件中。而静态库中的代码和数据会被拷贝多份到可执行目标文件中,因而文件系统中存在多份。
  2. 内存中共享库的.text节可被不同的进程共享。

因而动态链接同时节省了磁盘空间和内存空间。动态链接的过程如下图所示。

dynamic-link.png

1
gcc -shared -fpic -o libvector.so addvec.c multvec.c

-fpic标记表明让编译器生成位置独立代码(position-independent code)。-shared标记让linker创建一个共享目标文件。

共享库创建完毕后,使用方式如下

1
gcc -o prog21 main2.c ./libvector.so

该步骤创建出能够在运行时和libvector.so动态链接的可执行目标文件prog21。linker不会拷贝libvector.so的数据或代码节到prog21中,而会拷贝重定位信息和符号表以在装载期(load time)决议到libvector.so的代码或数据引用。

当loader装载可执行目标文件prog21时,会注意到prog21中包含.interp节,其内包含了动态链接器(dynamic linker)的路径名,本身也是一个共享目标(即,ld-linux.so)。接着,loader不会直接转移控制给程序,它会装载并运行动态链接器。动态链接器会执行下面的重定位操作:

  • 重定位libc.so的text和data到内存段
  • 重定位libvector.so的text和data到另一个内存段
  • 重定位prog21中对libc.solibvector.so中符号的任意引用

最后,动态链接器会将控制交给应用程序。

3. 在运行时装载和链接共享库

之前提到的动态链接是在装载期(load time)完成的。

应用程序也可以在运行时要求动态链接器(dynamic linker)装载和链接共享库。这么做的优势是编译期(compile time)不需要链接动态共享库。一些应用场景如下:

  • 分发更新的软件:通过分发共享库的最新版本,用户下载后,之后运行程序时,就能够自动链接和装载新的共享库。
  • 构建高性能web服务器:传统web服务器使用fork和execve创建子进程并运行”CGI程序”(动态内容)。相反,现代web服务器将生成动态内容的函数打包进共享库中,当web服务器接收到请求后,其会装载和链接合适的函数,并直接调用。因为该函数会在服务器地址空间缓存下来而不用切换上下文,之后的调用仅需要函数调用的开销。同时,现有的函数也可被更新,新的函数也可在运行时被添加,而不用暂停服务器。

Linux提供的一些接口如下

1
2
3
4
5
#include <dlfcn.h>
void *dlopen(const char *filename, int flag);
void *dlsym(void *handle, char *symbol);
int dlclose (void *handle);
const char *dlerror(void);

一个示例程序如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
#include <stdio.h>
#include <stdlib.h>
#include <dlfcn.h>

int x[2] = {1, 2};
int y[2] = {3, 4};
int z[2];

int main() 
{
    void *handle;
    void (*addvec)(int *, int *, int *, int);
    char *error;

    /* Dynamically load the shared library containing addvec() */
    handle = dlopen("./libvector.so", RTLD_LAZY);
    if (!handle) {
    fprintf(stderr, "%s\n", dlerror());
    exit(1);
    }

    /* Get a pointer to the addvec() function we just loaded */
    addvec = dlsym(handle, "addvec");
    if ((error = dlerror()) != NULL) {
    fprintf(stderr, "%s\n", error);
    exit(1);
    }

    /* Now we can call addvec() just like any other function */
    addvec(x, y, z, 2);
    printf("z = [%d %d]\n", z[0], z[1]);

    /* Unload the shared library */
    if (dlclose(handle) < 0) {
    fprintf(stderr, "%s\n", dlerror());
    exit(1);
    }
    return 0;
}
1
gcc -rdynamic -o prog2r dll.c -ldl

注意到,run-time链接使用指针作为handle,因而编译生成的可执行目标文件中并没有需要重定位的全局符号或函数,而load-time链接需要动态链接器在转移控制给程序前进行全局符号和函数的重定位操作

4. 位置独立代码(Position-Independent Code)

注意: 位置独立代码仅针对共享模块的代码段,每个进程仍会有自己独立的读/写数据段

为了让多个进程能够在内存中共享一份共享库的text节,现代系统编译的共享模块的代码段能够在不被linker修改的情况下被装载到内存的任意位置。

不需要任何重定位,就能够被装载到内存的代码被称为位置独立代码(position independent code - PIC)。可以通过GCC的-fpic参数生成PIC代码。共享库必须使用该选项进行编译。

那些可执行目标模块内部的引用不需要特殊处理。可以使用PC相对寻址进行编译并由静态链接器进行重定位。然而对共享模块定义的外部例程和全局变量的引用就要求一些特殊的处理。

4.1. PIC数据引用

为了生成PIC数据引用,编译器依赖如下事实:无论目标模块被装载到内存什么位置,代码段内任意指令到数据段内任意变量的距离总是一个固定运行时常量,独立于代码和数据段的绝对内存位置。

为了生成到全局变量的位置独立引用,编译器会在数据段起始位置创建一个全局偏移量表(global offset table, GOT)。GOT包含了8字节的条目,每个条目对应一个全局数据对象(被目标模块引用的函数例程或全局变量)。编译器同时会为GOT的每一个条目生成一个重定位记录。在load time,动态链接器会重定位每一个GOT条目,使得它包含对象的绝对地址。每个引用了全局对象的目标模块包含他自己的GOT。

pic-data-ref-ex.png

上面是一个使用GOT间接获得绝对位置的例子。mov指令使用pc相对引用,由于该指令到GOT[3]的偏移量是运行时常量,因此该处引用位置无关。

当然,如果对某个全局符号的定义和引用存在于同一个共享模块中,它们之间的距离也是运行时常量(例如上面的例子),因此可以直接使用该处距离,而不用间接的GOT表。但是,如果全局符号由另一个共享模块定义就必须使用GOT表。基于这个原因,编译期对所有引用都采取使用GOT表的策略。

4.2. PIC函数调用:延迟绑定

考虑从外部调用共享库内定义的函数的情景。一种通常的方法是编译时为该函数生成一个重定位记录,然后由动态链接器在load time进行决议。然而,这并非位置独立,因为调用共享库内函数的可能是另一个共享库的代码,这样就需要修改后者的代码段。GNU编译系统使用延迟绑定(laze binding),解决这个问题。

延迟绑定将每个函数的地址的绑定推迟到该函数第一次被调用的时候。因为一个程序通常只会调用共享库内少量函数。通过这种延迟处理,可以避免在load time进行过多不必要的重定位。

4.2.1. 延迟绑定的实现

延迟绑定通过两个数据结构:GOT和procedure linkage table (PLT)实现。如果一个目标模块调用了在共享库定义的函数,它就会有自己的GOT和PLT。GOT是数据段的一部分。PLT是代码段的一部分。

  • Procedure linkage table (PLT):PLT是一个以16字节(由几条指令组成)为成员的数组。PLT[0]为一条跳转到dynamic linker的指令。每一个被可执行文件调用的共享库函数都有一个PLT条目。例如:PLT[1]调用系统启动函数(__libc_start_main)。该函数初始化执行环境、调用main函数并处理返回值。PLT[2]以及之后的条目为由用户代码调用的函数。如下图,PLT[2]调用了addvecPLT[3]调用了printf
  • Global offset table (GOT):GOT是一个以8字节地址为成员的数组。其中,GOT[0]GOT[1]包含了dynamic linker决议函数地址时需要的信息。GOT[2]是dynamic linker在ld-linux.so模块的entry point。剩余的条目对应的是需要在运行时决议的被调用的函数。最初,每个GOT条目指向其对应的PLT条目的下一个条目。

pic-func-call.png

如上面(a)图所示,addvec函数的地址在程序第一次调用时通过下面的方法延迟决议:

  1. 并非直接调用addvec函数,程序跳转到PLT[2]处代码
  2. 第一条PLT指令通过GOT[4]间接跳转。因为每一个GOT条目被初始化为PLT条目的下一条指令,因此跳转到PLT[2]
  3. addvec对应的ID(0x1)压入栈,之后PLT[2]跳转到PLT[0]
  4. PLT[0]通过GOT[1]间接为dynamic linker压入一个参数,之后通过GOT[0]间接跳入dynamic linker。dynamic linker通过压入的两个栈元素决定addvec的运行时地址,覆写GOT[4]并转移控制给addvec

之后的调用如(b)图所示:

  1. 控制转移到PLT[2]
  2. 通过GOT[4]中的地址跳转到addvec

5. library interpositioning

Linux的linker提供了库打桩(library interspositioning)技术,该技术能够拦截对共享库函数的调用,转而执行自定义的函数。

通过该技术,我们能够追踪一个库函数被调用的数量、验证并记录它的输入和输出值或是使用一个完全不同的实现取代它。

下面尝试通过库打桩技术追踪对mallocfree函数的调用。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
// 1. code/link/interpose/int.c
#include <stdio.h>
#include <malloc.h>

int main()
{
int *p = malloc(32);
free(p);
return(0);
}
// 2. code/link/interpose/malloc.h
#define malloc(size) mymalloc(size)
#define free(ptr) myfree(ptr)

void *mymalloc(size_t size);
void myfree(void *ptr);
// 3. mymalloc.c
#ifdef COMPILETIME
#include <stdio.h>
#include <malloc.h>

/* malloc wrapper function */
void *mymalloc(size_t size)
{
  void *ptr = malloc(size);
  printf("malloc(%d)=%p\n", (int)size, ptr);
  return ptr;
}

/* free wrapper function */
void myfree(void *ptr)
{
  free(ptr);
  printf("free(%p)\n", ptr);
}
#endif

5.1. 编译期

上面的代码使用C于处理器在compile time替换对mallocfree的调用。本地的malloc.h文件使得预处理器将对这两个函数的调用替换为对wrapper函数的调用。

1
2
gcc -DCOMPILETIME -c mymalloc.c
gcc -I. -o intc int.c mymalloc.o

通过-I.参数,C预处理器在查找系统目录前首先在本地查找malloc.h文件。

5.2. 链接期

Linux静态链接器通过--wrap f标志支持库打桩。该标志告知linker将对f的引用决议到__wrap_f,并将对符号__real_f的引用决议为f。对应的wrapper函数如下

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
#ifdef LINKTIME
#include <stdio.h>

void *__real_malloc(size_t size);
void __real_free(void *ptr);

/* malloc wrapper function */
void *__wrap_malloc(size_t size)
{
 void *ptr = __real_malloc(size); /* Call libc malloc */
 printf("malloc(%d) = %p\n", (int)size, ptr);
 return ptr;
}

/* free wrapper function */
void __wrap_free(void *ptr)
{
  __real_free(ptr); /* Call libc free */
  printf("free(%p)\n", ptr);
}
#endif

编译链接方式如下

1
2
3
gcc -DLINKTIME -c mymalloc.c
gcc -c int.c
gcc -Wl,--wrap,malloc -Wl,--wrap,free -o intl int.o mymalloc.o

其中,-Wl,option标志将option传递给linker,其中的逗号,会被空格取代。因此-Wl,--wrap,malloc会将--wrap malloc传递给linker。

5.3. 运行时

compile time库打桩要求访问程序的源文件。link time库打桩要求访问重定位目标文件。然而,运行时库打桩仅需要访问可执行目标文件

该技术需要使用dynamic linker的LD_PRELOAD环境变量。若LD_PRELOAD环境变量被设置为一系列共享库的路径名列表,那么load并执行程序时,dynamic linker(LD-LINUX.SO)会在搜寻其他共享库前,首先搜寻LD_PRELOAD库。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
#ifdef RUNTIME
#define _GNU_SOURCE
#include <stdio.h>
#include <stdlib.h>
#include <dlfcn.h>

/* malloc wrapper function */
void *malloc(size_t size)
{
 void *(*mallocp)(size_t size);
 char *error;

 mallocp = dlsym(RTLD_NEXT, "malloc"); /* Get address of libc malloc */
 if ((error = dlerror()) != NULL) {
  fputs(error, stderr);
  exit(1);
 }
 char *ptr = mallocp(size); /* Call libc malloc */
 printf("malloc(%d) = %p\n", (int)size, ptr);
 return ptr;
}

 /* free wrapper function */
 void free(void *ptr)
{
 void (*freep)(void *) = NULL;
 char *error;

 if (!ptr)
 return;

 freep = dlsym(RTLD_NEXT, "free"); /* Get address of libc free */
 if ((error = dlerror()) != NULL) {
 fputs(error, stderr);
 exit(1);
 }
 freep(ptr); /* Call libc free */
 printf("free(%p)\n", ptr);
}
#endif

上述代码的wrapper函数中,对dlsym的调用返回对libc中目标函数的指针。wrapper之后调用目标函数,打印trace并返回。

编译和链接方式如下,首先生成动态库和可执行文件,之后在命令行使用LD_PRELOAD执行:

1
2
3
gcc -DRUNTIME -shared -fpic -o mymalloc.so mymalloc.c -ldl
gcc -o intr int.c
LD_PRELOAD="./mymalloc.so" ./intr

值得注意的是,通过LD_PRELOAD可以对任意可执行程序实现库打桩机制。

6. 管理目标文件的工具

GNU binutils包尤其有用。

  • AR: Creates static libraries, and inserts, deletes, lists, and extracts members.
  • STRINGS: Lists all of the printable strings contained in an object file.
  • STRIP: Deletes symbol table information from an object file.
  • NM: Lists the symbols defined in the symbol table of an object file.
  • SIZE: Lists the names and sizes of the sections in an object file.
  • READELF: Displays the complete structure of an object file, including all of the information encoded in the ELF header. Subsumes the functionality of SIZE and NM.
  • OBJDUMP: The mother of all binary tools. Can display all of the information in an object file. Its most useful function is disassembling the binary instructions in the .text section.

Linux系统还提供了LDD程序以管理共享库

  • LDD: Lists the shared libraries that an executable needs at run time.

7. 小结

  • 链接的意义在于合并目标文件,决议符号,分配运行时地址。
  • 使用GCC生成共享库需要使用命令如下gcc -shared -fpic-fpic表示让编译器生成位置独立代码
  • 使用动态链接库有两种方式。两者的共同之处在于:共享库的代码和数据节被推迟到运行时装载。load time链接推迟到转移控制前初始化阶段装载。run time链接进一步推迟到程序需要时装载。
    • load time链接包含两部分任务:
      1. 静态链接时需要获取共享库的符号表。
      2. 动态链接器在转移控制给程序前,对指向共享库的全局符号和函数进行重定位。gcc -o prog prog.c liba.so
    • run time链接:使用handle获取符号并使用。gcc -rdynamic -o prog prog.c -ldl

总的来说,load time链接和run time链接区别不大,都由dynamic linker装载和链接。对于load time链接来说,因为可执行文件中存在动态库的符号信息,因此可以直接使用全局变量以及函数。而run time链接需要通过handle间接使用全局的变量和函数。

8. 参考

  • CH7 Linking Computer Systems. A Programmer’s Perspective