深入理解静态链接和动态链接

符号与符号表

每个可重定义文件o都有一个符号表,它包含m定义和引用的符号(外链接的)的信息,即以下的全局符号和外部符号。三种不同的符号:

  • 全局符号。由文件o定义并能被其它模块引用,对应于非静态的全局变量和函数
  • 外部符号。由其它文件定义并能被文件o引用的全局符号,称为外部符号。
  • 局部符号。只能被文件定义和引用,对应于static 函数和 static 变量

链接器的两个主要任务是:

  • 符号解析。将每个符号引用正好和一个符号定义关联起来。
  • 重定位。编译器和汇编器生成从地址0开始的代码和数据节。链接器通过把每个符号定义与一个内存位置关系起来,从而重定位这些节,然后修改所有对这些符号的引用,使得它们指向这个内存位置。

具有多重定义的全局符号,或者是强或者是弱,函数和已初始化的全局变量是强符号,未初始化的全局变量是弱符号。不允许有多个同名的强符号;一强多弱,选强;多弱,任选一个。可使用GCC-fno-common来禁止多重定义全局符号;或使用-Werror,把所有警告变为错误

静态链接

将所有相关的目标模块打包成一个单独的文件,称为静态库。当链接器构造一个可执行文件时,只复制静态库中被引用的目标模块。

Linux中,静态库以存档(archive)的格式存放在磁盘中,由后缀.a标识。创建静态库,使用AR工具: ar rcs libvec.a addvec.o multvec.o

gcc中静态链接库来构造可执行文件时,可加上选项-static

静态库解析引用:
在符号解析阶段,链接器按照它们在命令行上出现的顺序,从左至右扫描可重定位文件和存档文件(.c文件自动翻译为.o文件)。在这次扫描中,链接器维护一个可重定位文件的集合E(这个集合中的文件会被合并起来形成可执行文件),一个未解析的符号(引用但未定义)集合U,一个在前面的输入文件中已定义的符号集合D。初始时,E、U、D均为空

  • 若输入文件f是一个目标文件,将f添加至E,并修改U和D来反映f中的符号定义和引用。
  • 若f是一个存档文件,链接器会尝试匹配U中未解析的符号和由存档文件成员定义的符号。若某存档文件成员m,定义了一个符号来解析U中的一个引用,那么就将m添加到E中,并修改U和D来反映m中的符号和引用。

因此命令行上库和目标文件的顺序非常重要。库一般放在命令的末尾,否则可能会出现引用无法解析的问题。可以知道,若目标文件的某个符号被引用,则该目标文件中的所有定义和引用的符号都会被加入到符号表中

重定位:

  • 重定位节和符号定义。
    • 链接器将所有相同类型的节合并为同一类型的新的聚合了。比如将输入模块的所有.data节合并成一个节,这个节成为可执行目标文件的.data节。
    • 链接器将运行时内存地址赋给新的聚合节、输入模块定义的每个节和符号。这时程序中的每条指定和全局变量都有唯一的运行时内存地址了
  • 重定义节中的符号引用。链接器修改代码节和数据节中对每个符号的引用,使得它们指向正确的运行时地址。
    深入理解静态链接和动态链接
    深入理解静态链接和动态链接

加载可执行文件

执行如./prog的命令时:

  • shell通过调用称为加载器的操作系统代码来运行它。任何Linux程序都可以通过调用execve函数来调用加载器
  • 加载器将可执行目标文件的代码和数据从磁盘复制到内存中,然后通过跳转到程序的第一条指令或入口点来执行程序。这个过程称为加载
  • 当加载器运行时,它创建如下图所示的内存映像(内核指操作系统驻留在内存的部分)。首先将可执行文件的片复制到代码段和数据段,接下来,加载器跳转到程序的入口点,也就是_start函数的地址,这个函数定义在系统目标文件ctrl.o中。_start函数调用系统启动函数__libc_start_main,该函数定义在libc.so中。它初始化执行环境,调用用户层的main函数,处理main函数的返回值,并且在需要时把控制权返回给内核。

深入理解静态链接和动态链接

未初始化的数据段(.bss)的内容并不存放在磁盘文件中,原因是,内核在程序开始运行前将它们设置为0.需要存放在磁盘程序文件中的只有只读代码段和初始化数据段(.data)

动态链接

共享库共享库是一个目标模块,在运行或加载时,可以加载到任意的内存地址,并和一个在内存中的程序链接起来。这个过程称为动态链接。

对于一个共享库,只有 个.so文件。引用该库的可执行目标文件共享这个.so文件的代码和数据;在内存中,一个共享库的.text节的一个副本可以被不同的正在运行的进程共享。

创建共享库:
gcc -shared -fpic -o libvec.so addvec.o multvec.o
(-shared表示创建一个共享的目标文件,-fpic表示生成与位置无关的代码)

在程序加载时,没有任何共享库的代码和数据节真的被复制到可执行文件中,而是链接器复制了一些重定位和符号表信息,使得运行时可以解析对共享库代码和数据的引用。

Linux提供了一些接口,允许应用程序在运行时加载和链接共享库(头文件<dlfcn.h>,编译时命令行要包含选项-ldl):

void *dlopen(const char* filename,int flag);

返回:若成功则为指向句柄的指针,若出错则为NULL

dlopen加载和链接共享库filename。函数已经使用RTLD_GLOBAL选项(库解析全局变量在随后的其它的链接库中变得可以使用)打开了库,解析库中的外部符号。

若可执行文件是带-rdynamic选项编译的,则也可解析库中的全局符号。

flag参数有两种:RTLD_LAZY:推迟符号解析直到执行来自库中的代码;
RTLD_NOW:立即解析对外部符号的引用

void *dlsym(void* handle, char* symbol)

返回:若成功则为指向符号的指针,若出错则为NULL

其中句柄是前面已经打开了的共享库的句柄。若要测试调用dlsym产生的错误,在调用dlsym之前,应先调用dlerror清除之前的错误。然后再调用dlsym和dlerror。

有两个特殊的伪句柄,RTLD_DEFAULT和RTLD_NEXT。 前者将使用默认库搜索顺序找到所需符号的第一个匹配项。 后者将在当前库的下一个搜索顺序中找到符号的匹配项。

动态库的搜索路径优先顺序由高至低为:

  • 编译目标代码时指定的动态库搜索路径;
  • 环境变量LD_LIBRARY_PATH指定的动态库搜索路径;
  • 配置文件/etc/ld.so.conf中指定的动态库搜索路径;//配置后要运行 ldconfig命令才能生效
  • 默认的动态库搜索路径/lib;
  • 默认的动态库搜索路径/usr/lib;

用户一般会指定自定义库的搜索路径,此时的库搜索优先顺序为:自定义库---->默认库。因此RTLD_NEXT的作用一般为:获取指向默认库中的符号的指针。从而实现在默认库的代码外,添加自定义的代码。

int dlclose(void* handle);

返回:若成功返回0,若出错返回-1

如果没有其它共享库还在使用这个共享库,dlclose就卸载该共享库

const char* dlerror(void);

返回:前面三个函数其中一个调用失败,则为错误消息,若调用成功,则为NULL

库打桩

允许用户截获对共享库函数的调用,取而代之执行自己的代码。基本思想为:给定一个需要打桩的目标函数,创建一个包装函数,它的原型与目标函数完全一样。使用某种特殊的打桩机制,就可以欺骗系统调用包装函数而不是目标函数。包装函数通常会执行它自己的逻辑,然后调用目标函数,再将目标函数的返回值传递给调用者

运行时打桩

只需要能访问可执行目标文件。基于动态链接器的LD_PRELOAD环境变量。如果LD_PRELOAD环境变量被设置为一个共享库路径名的列表(以空格或分号分隔),那么当你加载和执行一个程序时,需要解析未定义的引用时,动态链接器会先搜索LD_PRELOAD库,然后才搜索其它库。