我遇到的问题非常傻逼,惨痛教训:g++指令后面要紧跟源文件。。然后再跟-l动态库这些,不然会报undefined reference to SB。。。
最近在折腾各种.so,碰到了一些问题,一开始对于很多错误也没有头绪,茫然不知所措。索性化了一天多时间将<<程序员的自我修养—链接、装载与库>>中部分内容略读了一遍,主要是关于编译,链接和加载这块的。于是顺便做个笔记,方便以后回顾。基本上知道了这些,对于编译,链接和加载过程中产生的各种问题,应该就能从根本上理解并解决了。其实以前上学时也看过那本经典的<<Linker and loader>>,当时还写了篇<<链接器和加载器原理>>,不过此次会更细致深入地了解下整个编译链接和加载过程,并结合经常碰到的问题,提出一些解决方案。
广义的代码编译过程,实际上应该细分为:预处理,编译,汇编,链接。
预处理过程,负责头文件展开,宏替换,条件编译的选择,删除注释等工作。gcc –E表示进行预处理。
编译过程,负载将预处理生成的文件,经过词法分析,语法分析,语义分析及优化后生成汇编文件。gcc –S表示进行编译。
汇编,是将汇编代码转换为机器可执行指令的过程。通过使用gcc –C或者as命令完成。
链接,负载根据目标文件及所需的库文件产生最终的可执行文件。链接主要解决了模块间的相互引用的问题,分为地址和空间分配,符号解析和重定位几个步骤。实际上在编译阶段生成目标文件时,会暂时搁置那些外部引用,而这些外部引用就是在链接时进行确定的。链接器在链接时,会根据符号名称去相应模块中寻找对应符号。待符号确定之后,链接器会重写之前那些未确定的符号的地址,这个过程就是重定位。
-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查看。
.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文件,找到相应符号的定义。
#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
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真正链接进来的时候,已经没有库可以解析符号了。
链接时需要告诉链接器,在哪里找到库文件?以静态还是动态的方式链接库文件?默认情况下使用动态方式链接,这要求存在对应的.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
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-C、Java, 以及 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 如果一个弱符号出现在多个目标文件中,则选择其中占用空间最大的那个。
强弱符号都是针对变量定义,对于引用则无效,当然针对应用,也有强引用与弱引用。对于强引用来说,在链接成可执行文件时,如果找不到对应变量的定义则会报错。对于弱引用,在这种情况下,不会报错,链接器会采用一个默认值。
弱符号和弱引用的存在允许用户定义自己的实现去覆盖现有的实现。这样就可以更灵活的对程序进行扩充或裁减。
链接时符号未定义。每个目标文件中,都可能存在一些,undefined类型的符号,这种类型的符号需要在链接时,能够在链接产生的全局符号表中找到其定义,如果找不到,链接器就会产生该错误信息。产生的原因有很多,比如少链接了某个库,符号写错了等等。
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()执行完毕后,可执行文件就被装入了内存,接下来就可以执行了。
如上,对于动态链接的可执行文件在真正执行前,实际上它的很多外部符号还处于无效状态,还未与实际的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来查看
如何确定所需要的加载库?
如何解析符号?
重名的文件如何处理?
由于动态链接器本身也是一个.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,不会由动态链接器执行,而是通过程序自身的初始化代码完成。
重定位和初始化完成后,动态链接器所要做的事情就完成了,之后就会把控制权交给可执行程序的入口,开始执行程序。
上面的加载过程是在程序启动时由系统完成的,对于程序本身是透明的。如果程序想在运行时自行加载某个动态库,实现类似插件之类的机制,则需要使用如下几个函数。
用于打开一个动态库,将其加载到进程的地址空间,完成初始化过程。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段来进行模块的初始化操作。
这个函数在一个已经打开的函数库里面查找给定的符号。这个函数如下定义:
void * dlsym(void *handle, char *symbol);
函数中的参数handle就是由dlopen打开后返回的句柄,symbol是一个以’