• linux 编译,链接和加载


    我遇到的问题非常傻逼,惨痛教训:g++指令后面要紧跟源文件。。然后再跟-l动态库这些,不然会报undefined reference to SB。。。


    1.   序

    最近在折腾各种.so,碰到了一些问题,一开始对于很多错误也没有头绪,茫然不知所措。索性化了一天多时间将<<程序员的自我修养—链接、装载与库>>中部分内容略读了一遍,主要是关于编译,链接和加载这块的。于是顺便做个笔记,方便以后回顾。基本上知道了这些,对于编译,链接和加载过程中产生的各种问题,应该就能从根本上理解并解决了。其实以前上学时也看过那本经典的<<Linker and loader>>,当时还写了篇<<链接器和加载器原理>>,不过此次会更细致深入地了解下整个编译链接和加载过程,并结合经常碰到的问题,提出一些解决方案。


    2.   编译和链接
    2.1.  编译过程

    广义的代码编译过程,实际上应该细分为:预处理,编译,汇编,链接。

    预处理过程,负责头文件展开,宏替换,条件编译的选择,删除注释等工作。gcc –E表示进行预处理。

    编译过程,负载将预处理生成的文件,经过词法分析,语法分析,语义分析及优化后生成汇编文件。gcc –S表示进行编译。

    汇编,是将汇编代码转换为机器可执行指令的过程。通过使用gcc –C或者as命令完成。

    链接,负载根据目标文件及所需的库文件产生最终的可执行文件。链接主要解决了模块间的相互引用的问题,分为地址和空间分配,符号解析和重定位几个步骤。实际上在编译阶段生成目标文件时,会暂时搁置那些外部引用,而这些外部引用就是在链接时进行确定的。链接器在链接时,会根据符号名称去相应模块中寻找对应符号。待符号确定之后,链接器会重写之前那些未确定的符号的地址,这个过程就是重定位。


    2.1.1.   相关选项

    -WL:这个选项可以将指定的参数传递给链接器。

    比如使用”-Wl,-soname,my-soname”,GCC会将-soname,my-soname传递给链接器,用来指定输出共享库的SO-NAME。

    -shared:表示产生共享对象,产生的代码会在装载时进行重定位。但是无法做到让一份指令由多个进程共享。因为单纯的装载时重定位会对程序中所有指令和数据中的绝对地址进行修改。要做到让多个进程共享,还需要加上-fPIC。

    -fPIC:地址无关代码,是为了能让多个进程共享一份指令。基本思想就是将指令中需要进行修改的那部分分离出来,跟数据放到一块。这样指令部分就可以保持不变,而需要变化的那部分则与数据一块,每个进程都有自己的一份副本。

    -export-dynamic:默认情况下,链接器在产生可执行文件时,为了减少符号表大只会将那些被其他模块引用到的符号放到动态符号表。也就是说,在共享模块引用主模块时,只有那些在链接时被共享模块引用到的符号才会被导出。当程序使用dlopen()加载某个共享模块时,如果该共享模块反向引用了主模块的符号,而该符号可能在链接时因为未被其他模块引用而未被导出到动态符号表,这样反向引用就会失败。这个参数就是用来解决这个问题的。它表示,链接器在产生可执行文件时,将所有全局符号导出动态符号表。

    -soname:指定输出共享库的SO-NAME。

    -I:。指定头文件搜索路径。

    -l:指定链接某个库。指定链接的比如是libxxx.so.x.y.z的一个库,只需要写-lxxx即可,编译器根据当前环境,在相关路径中查找名为xxx的库。xxx又称为共享库的链接名(link name)。不同的库可能具有同样的链接名,比如动态和静态版本,libxxx.a libxxx.so。如果链接时采用-lxxx,那么链接器会根据输出文件的情况(动态/静态)选择合适的版本。比如如果ld采用了-static参数,就会使用静态版本,如果使用了-Bdynamic(这也是默认情况),就会使用动态版本。

    -L:指定链接时查找路径,多个路径用逗号分隔

    -rpath:这种方式可以指定产生的目标程序的共享库查找路径。还有一个类似选项-rpath-link,与-rpath选项的区别在于,-rpath选项指定的目录被硬编码到可执行文件中,-rpath-link选项指定的目录只在链接阶段生效。这两个选项都是链接器ld的选项。更多链接器选项可以通过man ld查看。


    2.1.2.   编译与链接

    .so和.a的生成,可执行文件的生成。.a的生成只需要编译阶段,而可执行文件的生成还需要进行链接。静态库文件的生成很简单,主要就是分两步,第一步将源文件生成目标文件,可以使用gcc –c,第二步就是将目标文件打包,可以通过ar实现。所以该过程只要求源文件能够通过gcc –c这个命令即可。

    共享库的生成要复杂一些。可以有三种方法生成:
    $ld -G
    $gcc -shared
    $libtool
    用ld最复杂,用gcc -shared就简单的多,但是-shared并非在任何平台都可以使用。-shared 该选项指定生成动态连接库(让连接器生成T类型的导出符号表,有时候也生成弱连接W类型的导出符号),不用该标志外部程序无法连接。GNU提供了一个更好的工具libtool,专门用来在各种平台上生成各种库。在编译生成某个.so文件时,比如liba.so,虽然它里面可能用到了libb.so的东西,但是在生成a.so时是可以不加-lb的,因为so的生成不会进行符号解析和重定位。

    以GCC为例,它在编译静态库/动态库时到底使用了什么命令?比如:gcc –v -shared hello.c -o libhello.so。ld –G用来产生.so文件,也是gcc链接时实际调用的命令。

    生成可执行文件时,如果链接的是静态库,那么链接器会按照静态链接规则,将对应的符号引用进行重定位。而如果是动态库,链接器会将这个符号标记为动态链接的符号,不进行重定位,而是在装载时再进行。所以,尽管是动态链接,如果是已经进入到了链接阶段,那么也需要能在相应的.so中找到某符号的定义,否则也会引发Undefined reference to的链接错误。因为链接器只有通过.so文件,才能判断某符号是个动态链接符号,所以也需要读取这些.so文件,找到相应符号的定义。


    2.1.3.   头文件查找

    #include有两种写法形式,分别是:

    #include <> : 直接到系统指定的某些目录中去找某些头文件。

    #include “” : 先到源文件所在文件夹去找,然后再到系统指定的某些目录中去找某些头文件。

    gcc寻找头文件的路径(按照1->2->3的顺序):

     1. 在gcc编译源文件的时候,通过参数-I指定头文件的搜索路径,如果指定路径有多个路径时,则按照指定路径的顺序搜索头文件。命令形式如:“gcc -I /path/where/theheadfile/in sourcefile.c“,这里源文件的路径可以是绝对路径,也可以是相对路径。比如设当前路径为/root/test,include_test.c如果要包含头文件“include/include_test.h“,有两种方法:

    1)include_test.c中#include “include/include_test.h”或者#include "/root/test/include/include_test.h",然后gcc include_test.c即可

    2)include_test.c中#include <include_test.h>或者#include <include_test.h>,然后gcc –I include include_test.c也可

     2.通过查找gcc的环境变量来搜索头文件位置,分别是:

    CPATH/C_INCLUDE_PATH/CPLUS_INCLUDE_PATH/OBJC_INCLUDE_PATH。 

     3. 再在缺省目录下搜索,分别是:

    /usr/include

    /usr/local/include

    /usr/lib/gcc-lib/i386-Linux/2.95.2/include

    最后一行是gcc程序的库文件地址,各个用户的系统上可能不一样。gcc在默认情况下,都会指定到/usr/include文件夹寻找头文件。 gcc还有一个参数:-nostdinc,它使编译器不再系统缺省的头文件目录里面找头文件,一般和-I联合使用,明确限定头文件的位置。在编译驱动模块时,由于特殊的需求必须强制GCC不搜索系统默认路径,也就是不搜索/usr/include,要用参数-nostdinc,还要自己用-I参数来指定内核头文件路径,这个时候必须在Makefile中指定。

    当#include使用相对路径的时候,gcc最终会根据上面这些路径,来最终构建出头文件的位置。如#include <sys/types.h>就是包含文件/usr/include/sys/types.h


    2.1.4.   链接时库书写顺序

    链接过程中库的顺序

    Q: 有几个库文件A.a、B.a、common.a,前两者用到了定义在后者中的例程,如果把   common.a放在前面,链接器报告存在无法解析的符号名,放在最后则无问题。
    A: Floyd Davidson <floyd@ptialaska.NET>
    链接器按照命令行上指定顺序搜索库文件和目标文件(.a .o),二者之间的区别在   于.o文件被全部链接进来,而只从库文件中析取所需模块,仅当某个模块可以解析当前尚未成功解析的符号时,该模块被析取后链接进来。如果库文件无法解析任何当前尚未成功解析的符号,不从中析取也不发生链接。

    Unix编程新手的常见问题是数学函数并不在标准C库中,而是在libm.a中   

    cc -lm foo.c

    这里foo.c用到了数学库中的符号,但是链接器无法正确解析。当搜索到libm.a时,   来自foo.c的数学函数符号尚未出现,因此不需要析取libm.a的任何模块。接下来 foo.o链接进来,增加了一批尚未成功解析的符号,但已经没有libm.a可供使用了, 因此数学库必须在foo.o之后被搜索到。

     cc foo.c –lm

    在你的问题中,如果common.a首先被搜索到,因为不匹配尚未成功解析的符号,而被丢弃。结果A.a和B.a真正链接进来的时候,已经没有库可以解析符号了。


    2.1.5.   链接时库文件查找

    链接时需要告诉链接器,在哪里找到库文件?以静态还是动态的方式链接库文件?默认情况下使用动态方式链接,这要求存在对应的.so动态库文件,如果不存在,则寻找相应的.a静态库文件。若在编译时向gcc传入-static选项,则使用静态方式链接,这要求所有库文件都必须有对应的*.a静态库。

    1.gcc会去找-L
    2.再找gcc的环境变量LIBRARY_PATH  
    3.再找内定目录 /lib /usr/lib /usr/local/lib,这是当初编译gcc时,写在文件里的

    需要澄清一下:

    l  ldconfig做的事情都与运行程序时有关,跟编译时一点关系都没有

    l  需要注意的是LD_LIBRARY_PATH通常只是在程序运行时告诉loader该去哪查找共享库,比如gcc编译时的链接器可能就不会去查找LD_LIBRARY_PATH。

    l  查找时如果找到了同名的动态库和静态库如何处理?当我们指定一个路径下的库文件名时,假如此时同时存在xxx.a和xxx.so的两个库形式,那么优先选择.so链接(共享库优先)。如果使用了-static,找到了.so是否会使用?如果使用了-Bdynamic,那找到了.a会不会使用?.a的生成能否依赖.so?它所依赖的这些.so能否不加入-l参数列表?

    l  系统中链接器与加载器的区别?编译时的链接器是ld,加载器则位于ld-linux.so。加载器是否去/etc/ld.so.conf中目录下去寻找所需的.so,还是依赖于ldconfig去更新ld.so.cache文件,?答案是依赖于ldconfig去更新cache。

    l  系统中的ld与gcc采用的链接器的区别?gcc –v查看gcc调用 as/ld 之类程序的时候传给它们的参数。通过gcc命令进行链接,与直接使用ld的区别?库查找路径是否不同?gcc的LIBRARY_PATH,应该是gcc本身用的环境变量。

    l  GCC,gcc与g++?GCC在预处理阶段会调用cpp,在编译阶段调用gcc或g++,汇编阶段调用as,最后链接阶段比较复杂,在GCC内部的这一步调用如下:
    $ ld -dynamic-linker /lib/ld-linux.so.2 /usr/lib/crt1.o 
    /usr/lib/crti.o /usr/lib/gcc-lib/i686/3.3.1/crtbegin.o
    -L/usr/lib/gcc-lib/i686/3.3.1 hello.o -lgcc -lgcc_eh
    -lc -lgcc -lgcc_eh /usr/lib/gcc-lib/i686/3.3.1/crtend.o
    /usr/lib/crtn.o


    2.2.  GCC

    GCC(GNU Compiler Collection,GNU编译器套装),是一套由 GNU 开发的编程语言编译器。它是一套以 GPL 及 LGPL 许可证所发行的自由软件,也是 GNU计划 的关键部分,亦是自由的 类Unix 及苹果计算机 Mac OS X 操作系统的标准编译器。GCC(特别是其中的C语言编译器)也常被认为是跨平台编译器的事实标准。

    GCC 原名为 GNU C 语言编译器(GNU C Compiler),因为它原本只能处理 C语言。GCC 很快地扩展,变得可处理 C++。之后也变得可处理 Fortran、Pascal、Objective-CJava, 以及 Ada 与其他语言。

    gcc在后台实际上经历了预处理,汇编,编译,链接这几个过程,我们可以通过-v参数查看它的编译细节,如果想看某个具体的编译过程,则可以分别使用-E,-S,-c和 -O,对应的后台工具则分别为cpp,cc1/gcc/g++,as,ld。


    2.3.  常见错误
    2.3.1.   Multiple definition

    符号可以分为强符号和弱符号。比如初始化了的全局变量就是强符号,未初始化的全局变量为弱符号。针对强弱符号,有如下规则:

    l  不允许强符号重定义,及不同的目标文件中不能有同名的强符号。Multiple definition错误就是违反了这种规定。

    l  如果一个符号在某个文件中是强符号,在另一个文件中是弱符号,那么选择强符号。

    l  如果一个弱符号出现在多个目标文件中,则选择其中占用空间最大的那个。

    强弱符号都是针对变量定义,对于引用则无效,当然针对应用,也有强引用与弱引用。对于强引用来说,在链接成可执行文件时,如果找不到对应变量的定义则会报错。对于弱引用,在这种情况下,不会报错,链接器会采用一个默认值。

    弱符号和弱引用的存在允许用户定义自己的实现去覆盖现有的实现。这样就可以更灵活的对程序进行扩充或裁减。


    2.3.2.   Undefined reference to

    链接时符号未定义。每个目标文件中,都可能存在一些,undefined类型的符号,这种类型的符号需要在链接时,能够在链接产生的全局符号表中找到其定义,如果找不到,链接器就会产生该错误信息。产生的原因有很多,比如少链接了某个库,符号写错了等等。


    3.   加载
    3.1.  可执行文件装载过程

    Linux系统通过execve()系统调用来执行程序,系统会为相应格式的文件查找合适的装载处理函数。elf格式文件的加载主要通过load_elf_binary()完成,有如下过程:

    l  检查文件格式有效性,比如magic number,文件头

    l  寻找动态链接的.interp段,设置动态链接器路径

    l  根据elf文件描述,对elf文件进行映射,比如代码,数据

    l  初始化elf进程环境,比如进程启动时EDX寄存器地址应该是DT_FINI地址

    l  将系统调用的返回地址,修改成elf可执行文件入口点,该入口点取决于程序的链接方式,对于静态链接的可执行文件,这个入口点就是elf文件中e_entry所指向的地址,对于动态链接的elf,程序入口点就是动态链接器。{如何判断elf采用的是静态链接还是动态链接的?可以通过对它执行ldd命令,如果是静态链接,会输出:statically linked}

    load_elf_binary()执行完毕后,可执行文件就被装入了内存,接下来就可以执行了。


    3.2.  动态库加载
    3.2.1.   工作原理

    如上,对于动态链接的可执行文件在真正执行前,实际上它的很多外部符号还处于无效状态,还未与实际的so文件联系起来,因此还有一个动态链接过程。操作系统会首先加载动态链接器ld.so(/lib/ld-linux.so.2),加载完这个so后,系统就会把控制权交给它,然后它会进行一些初始化操作,根据当前环境参数对可执行文件进行动态链接工作。动态链接器会寻找所需要的.so文件并进行装载,然后进行符号查找及重定位。如果找不到所需要的符号定义就会产生“undefined symbol”错误。

    在elf文件中有一个.interp段,该段指定了所要采用的动态链接器路径。操作系统会根据该段内容,选择加载的动态链接器。可以通过objdump –s a.out查看该段内容,也可以如下命令来查看:readelf –l a.out|grep interpreter。

    .dynamic段,保存了动态链接器所需的基本信息,比如依赖于哪些共享对象,动态链接符号表的位置,动态链接重定位表的位置,共享对象初始化代码的位置。可以通过readelf –d a.out来查看该段内容,其中比较重要的有如下几个:

    DT_RPATH:.so搜索路径

    DT_INIT:初始化代码地址

    DT_FINIT:结束代码地址

    DT_NEED:依赖的.so

    .dynsym段,动态符号表段。可以通过readelf –sD *.so来查看

    如何确定所需要的加载库?

    如何解析符号?

    重名的文件如何处理?


    3.2.2.   加载过程

    由于动态链接器本身也是一个.so,因此首先要完成自己的重定位过程,称为“自举(bootstrap)”。

    完成自举之后,动态链接器会将可执行文件和它本身的符号表合并到到一个全局符号表中。然后链接器开始查找所需要的.so,在前面提到的.dynamic段中有一个DT_NEEDED,它指出了可执行文件所依赖的.so,据此链接器就可以找到所需要的.so集合,然后开始装载该.so,完成后再从它需要的.so集合中取出一个.so,如果在此时找不到某个.so文件就会产生“cannot open shared object file”错误。如果找到相应文件,就会打开它,读取相应的elf文件头和.dynamic段,然后将其代码段和数据段进行映射。如果该.so还依赖于其他.so,就会把其他.so加入到待装载集合中,实际上是一个图的遍历问题,上面的过程实际上就是广度优先遍历。

    当新对象被加载之后,它的符号表会被合并到全局符号表中,所以当所有对象加载之后,全局符号表中应该包含了进程进行动态链接所需要的所有符号。这个过程里面存在一个称为“全局符号介入(global symbol interpose)”问题,假设a.out依赖了c.so和d.so,而c.so依赖于a.so,d.so依赖于b.so,但是a.so和b.so存在一个同名的符号,此时编译链接无法发现这个同名的符号,因为编译a.out时只需要链接c.so和d.so即可,这样链接器无法发现在a.so和b.so中存在同名的符号,这样只能加载时解决,而动态链接器有这样的一个规则:当一个符号需要加入全局符号表时,如果相同的符号名已经存在,那么后加入的符号会被忽略。所以,对于这种情况要格外注意。

    上面步骤完成后,就会开始进行符号解析和重定位,链接器会开始遍历可执行文件和每个.so的重定位表,将它们的GOT/PLT中每个需要重定位的位置进行修正。此时,如果某些符号还是无法解析,就会报出“undefined symbol”错误。

    重定位完成之后,如果.so中有“.init”段,动态链接器会执行这里的代码,以实现初始化过程,比如静态/全局对象的初始化。相应的也会有“.finit”来进行退出操作。可执行文件本身的.init,不会由动态链接器执行,而是通过程序自身的初始化代码完成。

    重定位和初始化完成后,动态链接器所要做的事情就完成了,之后就会把控制权交给可执行程序的入口,开始执行程序。


    3.3.  显式运行时链接

    上面的加载过程是在程序启动时由系统完成的,对于程序本身是透明的。如果程序想在运行时自行加载某个动态库,实现类似插件之类的机制,则需要使用如下几个函数。


    3.3.1.   dlopen()

    用于打开一个动态库,将其加载到进程的地址空间,完成初始化过程。C语言原形是:

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

    如果文件名filename是以“/”开头,也就是使用绝对路径,那么dlopne就直接使用它,而不去查找某些环境变量或者系统设置的函数库所在的目录了。否则dlopen()就会按照下面的次序查找函数库文件:

    1. 环境变量LD_LIBRARY指明的路径。

    2. /etc/ld.so.cache中的函数库列表。

    3./lib目录,然后/usr/lib。不过一些很老的a.out的loader则是采用相反的次序,也就是先查 /usr/lib,然后是/lib。

    同时如果filename值设为NULL的话,那么dlopen将返回全局符号表的句柄。也就是说可以在运行时找到全局符号表里的任何一个符号,并可以执行它们。全局符号表中包括了可执行程序本身,被动态链接器加载到进程中的所有动态库以及通过dlopen打开并使用了RTLD_GLOBAL方式的模块中的符号。

    dlopen()函数中,参数flag的值必须是RTLD_LAZY或者RTLD_NOW,RTLD_LAZY的意思是resolve undefined symbols as code from the dynamic library is executed,而RTLD_NOW的含义是resolve all undefined symbols before dlopen() returns and fail if this cannot be done'。如果有任何未定义的符号引用的绑定工作无法完成,那么dlopen就会返回错误,这两种方式必须二选一。此外还有RTLD_GLOBAL,可以与上面两种方式之一一起使用,表示将被加载的模块的全局符号合并到进程的全局符号表中,这样以后加载的模块就可以使用这些符号。

    如果有好几个函数库,它们之间有一些依赖关系的话,例如X依赖Y,那么你就要先加载那些被依赖的函数。例如先加载Y,然后加载X。dlopen()函数的返回值是一个句柄,然后后面的函数就通过使用这个句柄来做进一步的操作。如果打开失败dlopen()就返回一个NULL。如果一个函数库被多次打开,它会返回同样的句柄。

    同时dlopen还会执行被加载模块的.init段来进行模块的初始化操作。


    3.3.2.   dlsym()

    这个函数在一个已经打开的函数库里面查找给定的符号。这个函数如下定义:

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

    函数中的参数handle就是由dlopen打开后返回的句柄,symbol是一个以’’结尾的字符串。如果dlsym()函数没有找到需要查找的symbol,则返回NULL。dlsym的返回值对于不同的符号类型具有不同的意义,如果符号是个函数,则返回的就是函数地址,如果是变量,就是变量的地址,如果是常量就是该常量的值。如果该常量值刚好是NULL,那如何区分它是没有找到该常量,还是该常量就是NULL呢?这就需要看dlerror(),如果符号找到了dlerror()就会返回NULL,否则会返回相应的错误信息。

    符号优先级,前面已经提过,在加载过程中如果发现符号名冲突,先载入的符号会优先,这种优先级方式成为装载序列(load ordering)。进程通过dlopen载入对象时,动态链接器在进行符号解析和重定位时,都是采用装载序列。

    但是在使用dlsym进行符号地址查找时,如果是在全局符号表中进行查找,即在dlopen时,参数filename为NULL,由于全局符号表采用的是装载序列,因此dlsym使用的也是装载序列。但是,如果是通过某个dlopen打开的共享对象进行符号查找的话,采用的是一种称为依赖序列(dependency ordering)的优先级方式。即它会以被打开的那个共享对象为根节点,进行广度优先遍历,直到找到该符号。{!因为如果dlopen调用时,未采用RTLD_GLOBAL方式的话,那么被打开对象的符号表不会加入全局符号表,这样它和它依赖的模块可能就会都有自己的符号表,而没有一个全局的符号表}


    3.3.3.   dlerror()

    通过调用dlerror()函数,我们可以获得最后一次调用dlopen(),dlsym(),或者dlclose()的错误信息。 它的返回值类型是char *,如果返回NULL,表示上一次调用成功,如果不是NULL,则代表相应的错误信息。


    3.3.4.   dlclose()

    dlopen()函数的反过程就是dlclose(),dlclose()用于将已经加载的模块卸载。系统会维护一个加载引用计数器,当调用dlclose的时候,就把这个计数器的计数减一,如果计数器为0,则真正的释放掉。真正释放的时候,如果函数库里面有_fini()这个函数,则自动调用_fini()这个函数,做一些必要的处理。dlclose()返回0表示成功,非0值表示错误。


    3.4.  共享库版本命名规则
    3.4.1.   Libname.x.y.z

    X代表主版本号,y代表次版本号,z代表发布版本号。主版本号代表库的重大升级,不同主版本号的库之间是不兼容的。次版本号代表了库的增量升级,即增加一些新的符号接口,且保持原来的符号不变,具有向后兼容性,即依赖于旧版本库的程序在新版本库上也可以运行。发布版本号,代表了库的一些bug fix和性能优化,不添加任何新的接口,也不对现有接口进行修改。


    3.4.2.   So-Name

    由上可知主版本号和此版本号实际上决定了程序的接口。通常程序中都会包含了所依赖的库的名称和主版本号,系统是通过一种称为SO-NAME的命名机制来记录共享库的依赖关系。每个共享库都有一个对应的SO-NAME,即名称和主版本号。在linux系统中,会为每个共享库在它所在的目录创建一个跟SO-NAME相同的并且指向它的软连接。比如系统中有一个/lib/liba.1.1.2那么系统的共享库管理程序就会创建一个/lib/liba.1的软连接。

    SO-NAME会指向系统中相同主版本号的最新版的那个库,使用SO-NAME的目的在让所有依赖于某个共享库的模块,在编译,链接和运行时,都使用SO-NAME,而不使用详细的版本号。前面提过,在.dynamic段中的DT_NEEDED描述了模块所依赖的共享库,为了保证兼容性,采用SO-NAME进行.dynamic段的依赖描述。通过readelf –d liba.so,可以查看到elf文件的DT_NEEDED部分。


    3.5.  符号版本机制

    待看。


    3.6.  系统配置

    ldconfig,当系统安装或更新一个共享库就需要运行这个工具,它会遍历所有的默认共享库目录,比如/lib /usr/lib,然后更新所有的软连接,使它们指向最新版的共享库;如果是新安装的共享库,则会为它创建软连接。


    3.7.  运行时库查找过程

    动态链接器对保存在.dynamic段的DT_NEEDED的共享库的查找有一定的规则,如果DT_NEEDED保存的是绝对路径,那么动态链接器就按这个路径去查找,如果是相对路径,那么动态链接器会在/lib /usr/lib和/etc/ld.so.conf配置文件指定的目录中进行查找。

    ld.so.conf是一个配置文件,可能包含其他的配置文件,存放了路径信息。为了避免每次查找共享库都去遍历这些目录,linux系统提供了ldconfig。ldconfig除了负责每个共享库目录下的共享库SO-NAME的创建删除和更新外,它还会将这些SO-NAME收集起来存放到/etc/ld.so.cache中。当查找共享库时,直接去/etc/ld.so.cache里找即可,而它的结构则是专为查找优化过的。如果在这里没有找到,它会继续查找/lib和/usr/lib,如果还未找到就宣告失败。

    所以,如果在系统指定的共享库目录下,添加删除或更新任何一个共享库,或者更改了/etc/ld.so.conf的配置,都应该运行ldconfig这个程序。以便调整SO-NAME和/etc/ld.so.cache,而很多安装软件包实际在往系统安装共享库之后都会调用ldconfig。

    此外,还可以通过LD_LIBRARY_PATH来改变库搜索路径。如果为进程设置了LD_LIBRARY_PATH,那么在启动时,动态链接器会首先在LD_LIBRARY_PATH指定的目录下查找。但是,有不少声音主张要避免使用 LD_LIBRARY_PATH 变量,尤其是作为全局变量。这些声音是:
    * LD_LIBRARY_PATH is not the answer - http://prefetch.net/articles/linkers.badldlibrary.html
    * Why LD_LIBRARY_PATH is bad - http://xahlee.org/UnixResource_dir/_/ldpath.html
    * LD_LIBRARY_PATH - just say no - http://blogs.sun.com/rie/date/20040710

    综上,动态链接器会按照如下顺序加载或查找共享库:

    l  链接时由-rpath选项指定的目录(已被硬编码到可执行文件中)

    l  LD_LIBRARY_PATH指定的目录

    l  路径缓存文件/etc/ld.so.cache指定的路径

    l  默认共享库目录,先/usr/lib,后/lib

    LD_PRELOAD,可以预先装载一些共享库。由LD_PRELOAD指定的文件,会在动态链接库按指定规则搜索共享库之前加载。比LD_LIBRARY_PATH指定的还要优先,同时无论程序是否依赖于它,LD_PRELOAD指定的共享库都会被装载。由于全局符号介入这一机制,LD_PRELOAD指定的库的符号会覆盖后面加载的库的同名符号。这就使得我们可以修改标准c库的某些或某几个函数,而不影响其他使用。比如我们可以在实验程序执行时通过设置LD_PRELOAD,来让系统优先加载我们修改后的库。


    3.8.  常见错误
    3.8.1.   undefined symbol

    我们将函数和变量称为符号,函数名和变量名就是符号名。每个目标文件都有一个符号表。表中记录了所用到的所有符号。通常,链接过程只关注全局性的符号,即那些本模块引用自别处的符号,及可能被别处引用的在本模块定义的那些。

    可以使用readelf,objdump,nm来查看符号。此外使用C++filt工具可以查看被编译器改名(为支持重载及名字空间等机制)后的符号对应的原始名称。

    动态加载时,如果找不到某个符号引用的定义,就会产生该错误。通常是该符号所在的动态库未被加载,也就是说DT_NEEED缺少了某个.so。解决方式就是在链接程序时,使用-l指定所需要的库。


    3.8.2.   Double free or corruption

    原因可能有多种:比如malloc了一段内存空间,但是写的时候越界了,这实际上会导致corruption;链接了同一个库的动态和静态两个版本,且该库内具有全局或静态变量;不同的库内含有相同的全局或静态变量。这里主要关注下由于链接多个具有同名变量的库的情况。

    在通常情况下,共享库都是通过使用附加选项 -fpic 或 -fPIC 进行编译,从目标代码产生位置无关的代码(Position Independent Code,PIC),使用 -shared选项将目标代码放进共享目标库中。位置无关代码需要能够被加载到不同进程的不同地址,并且能得以正确的执行,故其代码要经过特别的编译处理:位置无关代码(PIC)对常量和函数入口地址的操作都是采用基于基寄存器(base register)BASE+ 偏移量的相对地址的寻址方式。即使程序被装载到内存中的不同地址,即 BASE 值不同,而偏移量是不变的,所以程序仍然可以找到正确的入口地址或者常量。

    然而,当应用程序链接了多个共享库,如果在这些共享库中,存在相同作用域范围的同名静态成员变量或者同名 ( 非静态 ) 全局变量,那么当程序访问完静态成员变量或全局变量结束析构时,由于某内存块的 double free 会导致 core dump,这是由于 Linux 编译器的缺陷造成的。

    链接时符号查找原理如下:

               1、应用程序在链接阶段时,会顺序生成符号表。也就是说,在应用程序中涉及到的符号,会在链接文件中逐个顺次查找
               2、一旦查找到符号,就停止本符号的查找工作,转向第二个符号的查找
               3、如果没有用到.a里的符号,即查找的过程中没有涉及到该.a,则不会在程序中链接该.a
               4、对于.so,无论是否涉及到符号查找,均会进行加载
               5、so的加载和卸载会涉及到自身内存分配和释放,而.a不会(.a相当于.o的集合,.o直接静态编译到应用程序,成为程序一部分)
               6、.a和.o有不同,.a是.o的集合,但是,.o必定会加载,.a不一定会加载(只加载符号表相关的.o)

    这样,对于有不同库的同名全局变量,只会产生一个符号,但是由于.so本身在卸载的时候会对全局变量进行析构,同时如果多个共享库,或者程序本身具有该全局变量,这样就会出现重复free的情况,导致double free错误。

    解决方法:使用选项-fpie或-fPIE,此时生成的共享库不会为静态成员变量或全局变量在GOT中创建对应的条目(通过objdump或readelf命令可以查看),从而避免了由于静态对象“在同一地址构造两次,析构两次”而对同一内存区域释放两次引起的程序 core dump(重复构造没有问题,但是重复析构会导致double free,但是如果构造函数有内存分配动作,是否会导致内存泄露?)。

    选项-fpie和-fPIE与-fpic及-fPIC的用法很相似,区别在于前者总是将生成的位置无关代码看作是属于程序本身,并直接链接进该可执行程序,而非存入全局偏移表GOT中;这样,对于同名的静态或全局对象的访问,其构造与析构操作将保持一一对应。

    还有如果具有同名变量的库一个是.so和一个是.a,将.so放到前面,也可以避免这个问题,根据上面的链接规则,当在.so中找到该符号时,那么.a中的内容就不会被链接了。更详细内容可以参考这两篇文章:库冲突 技巧:多共享动态库中同名对象重复析构问题的解决方法


    3.8.3.   LD_DEBUG

    LD_DEBUG这个环境变量可以打开动态库的调试功能,输出很多信息,对于开发调试动态库很有帮助。比如设置LD_DEBUG=files,就可以看到整个装载过程。此外它还可以设置为:

    l  bindings:显示动态链接的符号绑定过程

    l  libs:显示共享库的查找过程

    l  versions:显示符号的版本依赖关系

    l  reloc:显示重定位过程

    l  symbols:显示符号表查找过程

    l  statistics:显示动态链接过程中的各种统计信息

    l  all:显示以上所有信息

    l  help:显示帮助信息


    4.   相关工具
    4.1.  file

    file程序是用来判断文件类型的,比如可以用命令file xxx.tar.tar看一下文件类型,然后用tar加适当的参数解压。在这里主要是可以通过file来查看某文件是否链接了动态库。

    $ file a.out
    a.out: ELF 32-bit LSB executable, Intel 80386,
    version 1 (SYSV), dynamically linked (uses shared
    libs), not stripped

    最后一个not stripped表示该文件含符号表(可以利用命令strip去除符号表)


    4.2.  nm 

    用于列出目标文件的符号。可以通过-C选项,来显示符号的可读形式,即未被mangle的形式。-D可以用来显示动态符号。其中U类型的符号,代表该目标文件中未定义的那些symbol,而这些symbol通常都是定义在其他文件中,T表示该symbol在此文件中有定义。所以,nm最常用的地方在于,查看这个文件中是否包含某函数的定义,包含哪些未定义符号,因此在产生链接问题时,通常需要对U和T类型格外关注


    4.3.  c++filt

    c++filt可以将mangle的符号进行还原。


    4.4.  ldd

    ldd可以用来查看.so所依赖的共享库文件列表及未找到的.so,ldd –r还会报告缺少的目标对象和函数。

    ldd实际上是个脚本,能够显示可执行模块的dependency,其原理是通过设置一系列的环境变量,如下:LD_TRACE_LOADED_OBJECTS、LD_WARN、LD_BIND_NOW、LD_LIBRARY_VERSION、LD_VERBOSE等。当LD_TRACE_LOADED_OBJECTS环境变量不为空时,任何可执行程序在运行时,它都会只显示模块的dependency,而程序并不真正执行。

    ldd显示可执行模块的dependency的工作原理,其实质是通过ld-linux.so(elf动态库的装载器)来实现的。我们知道,ld-linux.so模块会先于executable模块程序工作,并获得控制权,因此当上述的那些环境变量被设置时,ld-linux.so选择了显示可执行模块的dependency。


    4.5.  readelf

    它是专门针对 ELF 文件格式的解析器,但是它并不提供反汇编功能,可以通过objdump进行反汇编。readelf可以用来查看头信息,符号信息,动态重定位信息等elf内部各个部分。


    4.6.  ldconfig

    ldconfig是一个动态链接库管理命令。为了让动态链接库为系统所共享,还需运行动态链接库的管理命令--ldconfigldconfig  命令的用途,主要是在默认搜寻目录(/lib和/usr/lib)以及动态库配置文件/etc/ld.so.conf内所列的目录下,搜索出可共享的动态链接库(格式如前介绍,lib*.so*),进而创建出动态装入程序(ld.so)所需的连接和缓存文件.缓存文件默认为/etc/ld.so.cache,此文件保存已排好序的动态链接库名字列表。


    4.7.  strip

    可以用来清理共享库和可执行文件中的符号信息和调试信息。也可以通过ld –s或ld –S,二者区别是:-S消除调试符号信息,-s消除所有符号信息。也可以通过gcc参数”-Wl,-s”或”-Wl,-S”来向ld传递这两个参数。


    5.   问题分析
    5.1.  分析步骤

    无论是编译,链接还是加载,本质上都需要去寻找某些符号的定义。当找不到对应的符号时通常都会产生某些错误,类似于上面提到的那些。

    实际上基本上都需要确定以下问题。需要哪些符号的定义?去哪里寻找这些符号的定义?找到的符号定义是否满足要求?

    具体来说,比如动态库加载时,需要确定需要加载哪些.so?去哪找到这些.so?这些.so中是否包含了所有的未定义符号?加载哪些.so,是由可执行文件及各个.so的.dynamic段中的DT_NEEDED决定的。去哪寻找这些.so,则是由各种路径确定的。如果出现.so文件找不到或符号未定义或版本不正确之类的动态库相关问题时,通常的分析步骤如下:

    l  查看-l参数,确定可执行程序已经链接了所需的库文件

    l  使用find,在当前系统中查找,看是否能够找到该库文件

    l  确定系统当前的库搜索路径

    l  如果可以找到该库文件,则需要确定它所在目录是否已经在库搜索路径中,如果不在则将它加入合适路径

    l  如果它刚好也在库搜索路径中,需要确定路径中是否还有同名的库文件

    l  如果它是唯一的存在于库搜索路径中的库文件,则详细分析该文件:

    ?  使用ldd查看该.so的依赖性

    ?  使用nm查看其符号表,找到未定义符号,nm –a *.so|grep U

    l  如果还找不到问题所在,设置LD_DEBUG环境变量,进行调试

    此外,如果是诡异的编译问题,还可以利用gcc的-E,-S,-c和 –O分阶段进行,查看每个阶段的输出是否正常。同时为每个命令加上-v,查看实际执行的命令,同时还可以看到头文件及库文件的搜索路径。比如可能因为宏替换,导致某些用户定义的变量被其他的一些宏定义替换,或者因为头文件搜索路径顺序,导致找到了一个错误的头文件。


    5.2.  实例分析

    最近碰到的一个问题是:调用dlopen时产生undefined symbol,采用了RTLD_NOW方式。对于undefined symbol:

    类似上面的步骤,使用ldd查看它现在依赖的.so,使用nm找到那个未定义的symbol,如果是c++程序,使用c++filt将它转换为可读形式,确认该symbol定义所在的库,然后判断该库是否已经在加载列表中,或者库的版本是否正确。

    最终确定原因是:我们的可执行程序,本身会调用dlopen去在运行时加载某些.so,但是这些.so文件在生成时的-l参数,没有加入那些该.so所依赖的其他.so。这样该.so文件的DT_NEEDED列表就是不完整的,这样加载器就不会去加载它们,这样某些符号就找不到它们的定义。

    解决方案有两个:一个是保持现有.so不变,我们在编译可执行文件的-l参数中加入这些必需的库的。另一个是修改现有.so,在-l中加入它所依赖的那些库。第一个方案,只需要修改可执行文件即可,但是这种方式不是一种彻底的解决方案,因为比如该.so文件被其他程序使用时,仍然会产生这种问题,因此根本的解决方案是,.so文件本身在编译时就要在-l参数中写入它所依赖的那些库。


    5.3.  编译链接和加载实验
    5.3.1.   源代码

    //test.h

    void test();

    //test.c

    #include <iostream>

    using namespace std;

    void test()

    {

       cout << “test” << endl;

    }

    //main.c

    #include “test.h”

    int main()

    {

    test();

    return 0;

    }


    5.3.2.   创建静态库

    gcc –v -c test.c; ar r libtest.a test.o

    [admin@clu01-gala16.dev.sd.aliyun.com]$gcc -v -c test.cpp

    Using built-in specs.

    Target: x86_64-redhat-linux

    Configured with: ../configure --prefix=/usr --mandir=/usr/share/man --infodir=/usr/share/info --enable-shared --enable-threads=posix --enable-checking=release --with-system-zlib --enable-__cxa_atexit --disable-libunwind-exceptions --enable-libgcj-multifile --enable-languages=c,c++,objc,obj-c++,java,fortran,ada --enable-java-awt=gtk --disable-dssi --enable-plugin --with-java-home=/usr/lib/jvm/java-1.4.2-gcj-1.4.2.0/jre --with-cpu=generic --host=x86_64-redhat-linux

    Thread model: posix

    gcc version 4.1.2 20080704 (Red Hat 4.1.2-46)

     /usr/libexec/gcc/x86_64-redhat-linux/4.1.2/cc1plus -quiet -v -D_GNU_SOURCE test.cpp -quiet -dumpbase test.cpp -mtune=generic -auxbase test -version -o /tmp/ccdMLquk.s

    ignoring nonexistent directory "/usr/lib/gcc/x86_64-redhat-linux/4.1.2/../../../../x86_64-redhat-linux/include"

    #include "..." search starts here:

    #include <...> search starts here:

     /usr/lib/gcc/x86_64-redhat-linux/4.1.2/../../../../include/c++/4.1.2

     /usr/lib/gcc/x86_64-redhat-linux/4.1.2/../../../../include/c++/4.1.2/x86_64-redhat-linux

     /usr/lib/gcc/x86_64-redhat-linux/4.1.2/../../../../include/c++/4.1.2/backward

     /usr/local/include

     /usr/lib/gcc/x86_64-redhat-linux/4.1.2/include

     /usr/include

    End of search list.

    GNU C++ version 4.1.2 20080704 (Red Hat 4.1.2-46) (x86_64-redhat-linux)

            compiled by GNU C version 4.1.2 20080704 (Red Hat 4.1.2-46).

    GGC heuristics: --param ggc-min-expand=100 --param ggc-min-heapsize=131072

    Compiler executable checksum: 927721cb17bef594f560fa66ec50ff62

     as -V -Qy -o test.o /tmp/ccdMLquk.s

    GNU assembler version 2.17.50.0.6-12.el5 (x86_64-redhat-linux) using BFD version 2.17.50.0.6-12.el5 20061020

    可以看到加上-v之后,就能看到各个步骤的具体命令,还能看到头文件搜索路径。


    5.3.3.   创建动态库

    gcc  -shared test.cpp -o libtest.so –fPIC -v

    [admin@clu01-gala16.dev.sd.aliyun.com]$gcc  -shared test.cpp -o libtest.so -fPIC -v

    Using built-in specs.

    Target: x86_64-redhat-linux

    Configured with: ../configure --prefix=/usr --mandir=/usr/share/man --infodir=/usr/share/info --enable-shared --enable-threads=posix --enable-checking=release --with-system-zlib --enable-__cxa_atexit --disable-libunwind-exceptions --enable-libgcj-multifile --enable-languages=c,c++,objc,obj-c++,java,fortran,ada --enable-java-awt=gtk --disable-dssi --enable-plugin --with-java-home=/usr/lib/jvm/java-1.4.2-gcj-1.4.2.0/jre --with-cpu=generic --host=x86_64-redhat-linux

    Thread model: posix

    gcc version 4.1.2 20080704 (Red Hat 4.1.2-46)

     /usr/libexec/gcc/x86_64-redhat-linux/4.1.2/cc1plus -quiet -v -D_GNU_SOURCE test.cpp -quiet -dumpbase test.cpp -mtune=generic -auxbase test -version -fPIC -o /tmp/ccv7EbFP.s

    ignoring nonexistent directory "/usr/lib/gcc/x86_64-redhat-linux/4.1.2/../../../../x86_64-redhat-linux/include"

    #include "..." search starts here:

    #include <...> search starts here:

     /usr/lib/gcc/x86_64-redhat-linux/4.1.2/../../../../include/c++/4.1.2

     /usr/lib/gcc/x86_64-redhat-linux/4.1.2/../../../../include/c++/4.1.2/x86_64-redhat-linux

     /usr/lib/gcc/x86_64-redhat-linux/4.1.2/../../../../include/c++/4.1.2/backward

     /usr/local/include

     /usr/lib/gcc/x86_64-redhat-linux/4.1.2/include

     /usr/include

    End of search list.

    GNU C++ version 4.1.2 20080704 (Red Hat 4.1.2-46) (x86_64-redhat-linux)

            compiled by GNU C version 4.1.2 20080704 (Red Hat 4.1.2-46).

    GGC heuristics: --param ggc-min-expand=100 --param ggc-min-heapsize=131072

    Compiler executable checksum: 927721cb17bef594f560fa66ec50ff62

     as -V -Qy -o /tmp/ccmzTeZF.o /tmp/ccv7EbFP.s

    GNU assembler version 2.17.50.0.6-12.el5 (x86_64-redhat-linux) using BFD version 2.17.50.0.6-12.el5 20061020

     /usr/libexec/gcc/x86_64-redhat-linux/4.1.2/collect2 --eh-frame-hdr -m elf_x86_64 --hash-style=gnu -shared -o libtest.so /usr/lib/gcc/x86_64-redhat-linux/4.1.2/../../../../lib64/crti.o /usr/lib/gcc/x86_64-redhat-linux/4.1.2/crtbeginS.o -L/usr/lib/gcc/x86_64-redhat-linux/4.1.2 -L/usr/lib/gcc/x86_64-redhat-linux/4.1.2 -L/usr/lib/gcc/x86_64-redhat-linux/4.1.2/../../../../lib64 -L/lib/../lib64 -L/usr/lib/../lib64 /tmp/ccmzTeZF.o -lgcc --as-needed -lgcc_s --no-as-needed -lc -lgcc --as-needed -lgcc_s --no-as-needed /usr/lib/gcc/x86_64-redhat-linux/4.1.2/crtendS.o /usr/lib/gcc/x86_64-redhat-linux/4.1.2/../../../../lib64/crtn.o


    5.3.4.   创建可执行文件

    LIBRARY_PATH=.;export LIBRARY_PATH

    g++ main.cpp -ltest

    gcc头文件搜索路径

    -iquote用于搜索"#include "file""形式的头文件

    -I

    C_PATH:类似于-I但优先级在-I之后,可以用于任何类型语言的预处理(比如c,c++)

    -isystem

    C_INCLUDE_PATH:c语言的,类似于-isystem,但优先级在-isystem之后

    CPLUS_INCLUDE_PATH:c++的

    OBJC_INCLUDE_PATH:Objective-C的

    gcc库文件搜索路径

    -L

    LIBRARY_PATH:可以用来指定库文件搜索路径,但是优先级在-L之后

    LD_LIBRARY_PATH:对于gcc编译不起作用,只与加载有关

    测试-static,如果无.a,有.so是否可以?答案是必须是.a的库,否则不行

    测试-Bdynamic,如果无.so,有.a是否可以?答案是可以


    5.3.5.   加载测试

    测试-rpath:g++ main.cpp -ltest -Wl,-rpath=. ,-rpath只对加载起作用,对链接无作用,通过它可以把运行时需要的动态库绝对路径写在可执行文件里

    测试LD_PRELOAD:在加载阶段器作用,无论可执行文件链不链接,加载器都会加载它

    测试LD_LIBRARY_PATH:在加载阶段起作用

    测试ldconfig:可以直接对当前路径应用ldconfig,这样也可以将其加入

    测试/etc/ld.so.conf:直接只将路径添加到该文件,不起作用,必须执行ldconfig

    测试/etc/ld.so.cache:加载时会直接从该处查找


    6.   参考资料

    程序员的自我修养—链接、装载与库

    GCC编译的背后( 预处理和编译 汇编和链接 )

    An Introduction to GCC 学习笔记

    LINUX下如何用GCC编译动态库

    gcc生成静态库和动态库

    GCC编译优化指南

    深入理解软件包的配置、编译与安装


    7.   附录

    http://www.gentoo.org/proj/en/base/amd64/howtos/fpic.xml

  • 相关阅读:
    粗浅看Struts2和Hibernate框架
    使用nexus搭建Maven私服
    在线支付功能的设计及其实现
    用户注册的邮箱激活模块的设计与实现
    WebService案例入门(基础篇)
    过滤器应用案例分析
    Java web文件上传下载
    Servlet常用操作(基础)
    AndroidStudio中导入SlidingMenu报错解决方案
    新浪微博Oauth2.0授权认证及SDK、API的使用(Android)
  • 原文地址:https://www.cnblogs.com/xiaouisme/p/6723565.html
Copyright © 2020-2023  润新知