g++ 静态库 动态库
最近重读了《程序员的自我修养:连接,装载与库》中的几个篇章,略过了有关编译器的底层细节。把工作中需要用到的知识抽取了出来转化成自己的知识。
从#include头文件谈起
在一个test.h
头文件当中
1 | #ifndef _TEST_H |
再写一个test.cpp
:
1 | #include "test.h" |
使用g++
命令编译成可执行文件test
:
1 | g++ -o test test.cpp |
因为 #include “xx.h” 这个宏其实际意思就是把当前这一行删掉,把 xx.h 中的内容原封不动的插入在当前行的位置。所以这个头文件的命名后缀无论叫做.h还是.txt都没有关系.
比如把test.h
更名为test.txt
然后把test.cpp
当中的#include "test.h"
更换成#include "test.txt"
再编译一下
1 | g++ -o test test.cpp |
一样能执行。
但是可不能随便把test.cpp
改成test.doc
然后妄想使用g++ -o test test.doc
来编译成功,这可是不行的。我们这里讨论的只是#include,也就是说讨论的只是预处理器,而编译的时候用到的编译器和连接器可都是认文件后缀的。
g++找头文件的参数 -I
g++ 编译的时候寻找头文件的路径默认是/usr/include
,可以在cpp文件当中指定需要头文件的路径,
比如我需要leveldb里面的db.h,那么在我的test.cpp
代码中:
1 | #include "../../leveldb/include/leveldb/db.h" |
其中../../leveldb/include/leveldb/db.h是相对test.cpp的相对路径,因为db.h不存在于/usr/include
当中。
除了在cpp文件当中显式地指定头文件的路径之外,还可以使用g++的-I参数,况且在google C++ style guide当中提到了最好是用这种方法,不要在cpp文件当中去指定路径。比如我的test.cpp
代码当中
1 | #include "db.h" |
那么我的编译命令就是
1 | g++ -I../../leveldb/include/leveldb -o test test.cpp |
同理,如果我的test.cpp
当中是
1 | #include "leveldb/db.h" |
那么我的编译命令就是
1 | g++ -I../../leveldb/include -o test test.cpp |
如果我的test.cpp
是:
1 | #include <leveldb/db.h> |
那么我就需要将../../leveldb/include
下面的这个leveldb目录cp到/usr/include
当中,再使用命令
1 | sudo idconfig |
来更新一下链接器。
然后我的编译命令就是
1 | g++ -o test test.cpp |
问题:
1,如果我只想使用头文件当中的一个函数,我却把整个头文件include进来,那么预处理的时候一替换岂不是出现了很多没用的代码?增加了编译时间?
答:是的,没错,多余的#includes
会害得编译器花费不少时间展开更多文件,处理大量输入,确实引入了很多垃圾,但是它却省了你不少笔墨,并且整个版面也看起来清爽的多。如果是使用class当中的函数,Effective C++和google C++ style guide当中提到解决方法,就是使用class声明(前置声明)来替代头文件,从而来避免多余的#include
,减少代码的编译时间,它里面也提到了最后的结论:
- 函数:用
#include
. - 类模板:优先用
#includes
. - 类:用前置声明固然不错,但小心点。若说不定,还是用
#includes
好了。 - 千万别为了避免
#includes
而把数据成员改成指针。
2,为什么经常看到与xxx.cpp同名的头文件xxx.h?
答:这是编写C++库的习惯:在xxx.h当中放入声明,而C++当中放入定义。我在下文的有关C++库的文章中将会说明。
编译器在预处理阶段做的事情:
首先是源代码test.cpp
和test.h
以及test.h
当中的头文件stdio.h
被预处理器预编译成一个.ii文件(C语言是变成一个.i文件),预处理相当于执行了
1 | g++ -E test.cpp -o test.ii |
预处理阶段主要做的事情就是处理那些源代码文件中的以#
开始的预处理指令。比如#include
和#define
等
- 将所有的
#define
删除,并且展开所有的宏定义 - 处理所有的条件预处理指令,比如
#if
,#ifdef
,#elif
,#else
,#endif
- 处理
#include
的指令,将被include的文件插入到#include
的位置,这个过程是递归进行的 - 删除所有的注释
//
和/**/
- 添加行号和文件名标识,以便编译器的内置宏
__FILE__
和__LINE__
能够使用 - 保留所有的
#pragma
编译器指令,因为编译器要用到它们
编译与链接
编译就是把预处理完成的文件进行一系列词法分析,语法分析,语义分析以及优化后产生相应的汇编代码。编译过程相当于执行
1 | g++ -S test.ii -o test.s |
编译完成之后还需要汇编,就是把汇编代码转成.o
的文件
1 | g++ -c test.s -o test.o |
链接生成可执行文件
1 | g++ test.o -o test |
由于编译过程的词法分析,语法分析等等都是编译原理的内容,工作中基本上都是借助编译器来产生.o文件,更加值得学习的其实是链接的知识,因为在现代软件开发过程当中,软件动辄上百万行代码,如果都放在一个模块当中肯定无法想象,所以现代的软件都有成百上千个模块,这些模块相互依赖。如何将这写模块组合成单一的程序就是链接器要做的工作。
问题:
1,为什么汇编不直接输出可执行文件反而要输出一个中间文件.o
呢?
答:正是因为要将代码模块化管理,让一个可执行文件由多个o文件来生成,比汇编直接生成可执行文件要更方便管理。因为如果其中一个模块的代码需要改动的话,只需要重新编译单独模块的代码,就不需要编译整个项目。
静态库与动态库
承接上文,我们知道链接器的作用是把很多的.o链接在一起成为一个可执行文件,那么库的概念就是先把需要经常用到的.o先链接在一起变成一个库,然后在其他地方需要链接那些.o文件的时候就直接链接库文件就可以了,这样更加方便管理。当然了,这些.o文件当中可不能有main函数,否则就成了可执行文件,就不是库文件了。
先来看一个超级简单的例子:
add.h
1 | #ifndef _ADD_H |
add.cpp
1 | int add(int x, int y) { |
subtract.h
1 | #ifndef _SUBTRACT_H |
subtract.cpp
1 | int subtract(int x, int y) { |
然后编译成.o文件
1 | g++ -c add.cpp -o add.o |
将两个.o文件链接成库libtest.a
1 | ar crv libtest.a add.o subtract.o |
然后写一个测试文件main.cpp,用到了add.cpp当中的add函数
1 | #include <iostream> |
将main.cpp编译成为可执行文件,编译时候连接库可以使用g++ 的 -L参数。
1 | g++ -o main main.cpp -L. -ltest |
执行main程序,可得到结果为3,说明用到了add.o当中的add函数
1 | [xiongjun@ubuntu ~/Desktop]% ./main |
我链接的是libtest.a这个库,但是用到的却是add.o当中的函数。说明了库中保存了add.o当中的内容,也就是说在写大型程序的过程当中,把相关的代码逻辑的.o文件放进一个库当中作为一个模块来管理,把main函数的.o独立出来,当main函数需要使用库当中的代码时,只需要include库提供的头文件,编译的时候再将库给链接进来就行了。
静态库和动态库的区别
我们通常把一些公用函数制作成函数库,供其它程序使用。函数库分为静态库和动态库两种。静态库在程序编译时会被连接到目标代码中,程序运行时将不再需要该静态库。动态库在程序编译时并不会被连接到目标代码中,而是在程序运行是才被载入,因此在程序运行时还需要动态库存在。
如果很多程序都使用了同一个库文件,那么如果使用静态库的话,那么这些程序当中都存在一份静态库的拷贝,当这些程序作为进程被装载进内存当中时,对内存空间是个不小的浪费,而使用动态库,只需要在内存当中保留一份动态库就可以了,在程序运行时期再载入。
由.o文件创建静态库的命令:
1 | ar crv libtest.a add.o subtract.o |
由.o文件创建动态库的命令:
动态库以.so结尾。
1 | gcc -shared -fPCI -o libtest.so add.o subtract.o |
-shared 该选项指定生成动态连接库(让连接器生成T类型的导出符号表,有时候也生成弱连接W类型的导出符号),不用该标志外部程序无法连接。相当于一个可执行文件。
这里有一个-fPIC参数,表示编译为位置独立的代码,不用此选项的话编译后的代码是位置相关的。
PIC就是position independent code, -fPIC使.so文件的代码段变为真正意义上的共享。如果不加-fPIC,则加载.so文件的代码段时,代码段引用的数据对象需要重定位,重定位会修改代码段的内容,这就造成每个使用这个.so文件代码段的进程在内核里都会生成这个.so文件代码段的copy。这就和上面说的动态库只在内存当中保留一份的说法不符合。
还有,如果在生成动态库的时候产生错误
1 | relocation R_X86_64_32 against `.rodata' can not be used when making a shared object; recompile with -fPIC |
这种错误出现于CPU是AMD64位的情况,解决方法是编译生成.o文件的时候也需要加上-fPIC选项
1 | g++ -c -fPIC add.cpp -o add.o |
有关链接时候使用-L参数
同g++的-I参数一样的,在链接库文件的时候,g++的默认路径是/usr/lib
,使用-L可以指定其他的路径,-l(小写的L)作用是选择路径下的库的名字。因为C++的库都是用lib开头,静态库的名字为libxxxx.a,动态库的名字为libxxxx.so,使用-l参数就能去掉lib前缀,直接使用
1 | g++ -o main main.cpp -L. -ltest |
说明在.
路径下寻找库文件,由于g++
的机制是先找动态库,找不到动态库再找静态库,那么这里就是先找libtest.so
的库文件,如果找不到,再去找libtest.a
。
在终端下使用ldd
命令能查看可执行文件依赖的库:
比如我的main
可执行文件的编译命令是:
1 | g++ -o main main.cpp -L. -ltest |
使用ldd
之后:
1 | [xiongjun@ubuntu ~/Desktop]% ldd main |
可以看到第二行出现了libtest.so
的库文件。
问题:
1,还是和头文件一样的问题,我的代码只需要库当中的一小部分函数,但是编译的时候把整个库给链接进来,那么岂不是增加了编译时间?
答:这里可不会像#include
头文件一样增加编译时间,因为使用库是处于链接的阶段,编译已经完成了,链接库是链接器做的工作了。至于增大可执行程序的体积,这是一定会增大的,因为我们用到库当中的函数也只是用到一部分而已,但是这一点不用特别担心,基本上现在的程序设计都是遵守UNIX的编程艺术:do one thing, do it better。提供第三方库的程序员基本上都是在优化自己代码的逻辑,让库运行得更好,而不是增加一些不属于库提供功能的代码,比如libevent库当中不可能会增加http服务器的功能代码,所以只要用到这个库的核心功能,一般来说也增加不了多少程序的体积。
2,编译的时候没有错误,但是运行的时候报错:error while loading shared libraries: xxx.so.x?
答:这是编译时链接库与运行时链接库的问题,在linux中可以查看编译时链接库的路径可以通过LIBRARY_PATH
这个环境变量:
1 | echo $LIBRARY_PATH |
但是由于g++可以指定链接库地址,所以这个环境变量显得无关紧要。
查看运行时链接库的路径通过LD_LIBRARY_PATH
环境变量:
1 | echo $LD_LIBRARY_PATH |
LD_LIBRARY_PATH
:这个环境变量指示动态连接器可以装载动态库的路径。
通过使用export
的方法能解决运行时期报错的问题:
1 | export LD_LIBRARY_PATH=$LD_LIBRARY_PATH:/usr/local/lib:. |
接入/usr/local/lib
和.
路径,如果所需要的库在这两个路径下,那么问题得以解决。
在终端用 export 指令只是暂时改变 LD_LIBRARY_PATH 的值,重启之后该值又为空,靠谱方法为:在~/.bashrc
或者 ~/.bash_profile
中加入该 export
语句,前者在每次登陆和每次打开 shell 都读取一次,后者只在登陆时读取一次。习惯是加到 ~/.bashrc
中,在该文件的未尾可采用如下语句来使设置生效:
1 | export LD_LIBRARY_PATH=$LD_LIBRARY_PATH:/usr/local/lib:. |
当然如果有root
权限的话,可以修改/etc/ld.so.conf
文件,然后调用 /sbin/ldconfig
来达到同样的目的,不过如果没有root
权限,那么只能采用输出LD_LIBRARY_PATH
的方法了。