文章目录
  1. 1. 从#include头文件谈起
    1. 1.1. g++找头文件的参数 -I
    2. 1.2. 问题:
    3. 1.3. 编译器在预处理阶段做的事情:
  2. 2. 编译与链接
    1. 2.1. 问题:
  3. 3. 静态库与动态库
    1. 3.1. 静态库和动态库的区别
    2. 3.2. 有关链接时候使用-L参数
    3. 3.3. 问题:

最近重读了《程序员的自我修养:连接,装载与库》中的几个篇章,略过了有关编译器的底层细节。把工作中需要用到的知识抽取了出来转化成自己的知识。

从#include头文件谈起

在一个test.h头文件当中

1
2
3
4
5
6
7
#ifndef _TEST_H
#define _TEST_H
#include <stdio.h>
void test() {
printf("hello, world\n");
}
#endif

再写一个test.cpp:

1
2
3
4
5
6
#include "test.h"
int main() {
test();
return 0;
}

使用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.cpptest.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
2
3
4
5
6
#ifndef _ADD_H
#define _ADD_H

int add(int x, int y);

#endif

add.cpp

1
2
3
int add(int x, int y) {
return x + y;
}

subtract.h

1
2
3
4
5
6
#ifndef _SUBTRACT_H
#define _SUBTRACT_H

int subtract(int x, int y);

#endif

subtract.cpp

1
2
3
int subtract(int x, int y) {
return x - y;
}

然后编译成.o文件

1
2
g++ -c add.cpp -o add.o
g++ -c subtract.cpp -o subtract.o

将两个.o文件链接成库libtest.a

1
ar crv libtest.a add.o subtract.o

然后写一个测试文件main.cpp,用到了add.cpp当中的add函数

1
2
3
4
5
6
7
8
#include <iostream>
#include "add.h"

using namespace std;
int main() {
cout << add(1, 2) << endl;
return 0;
}

将main.cpp编译成为可执行文件,编译时候连接库可以使用g++ 的 -L参数。

1
g++ -o main main.cpp -L. -ltest

执行main程序,可得到结果为3,说明用到了add.o当中的add函数

1
2
[xiongjun@ubuntu ~/Desktop]% ./main 
3

我链接的是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
2
3
4
5
6
7
8
[xiongjun@ubuntu ~/Desktop]% ldd main
linux-vdso.so.1 => (0x00007ffd475f1000)
libtest.so (0x00007f0264f9f000)
libstdc++.so.6 => /usr/lib/x86_64-linux-gnu/libstdc++.so.6 (0x00007f0264c9b000)
libc.so.6 => /lib/x86_64-linux-gnu/libc.so.6 (0x00007f02648d6000)
libm.so.6 => /lib/x86_64-linux-gnu/libm.so.6 (0x00007f02645d0000)
/lib64/ld-linux-x86-64.so.2 (0x00007f02651a1000)
libgcc_s.so.1 => /lib/x86_64-linux-gnu/libgcc_s.so.1 (0x00007f02643ba000)

可以看到第二行出现了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的方法了。

文章目录
  1. 1. 从#include头文件谈起
    1. 1.1. g++找头文件的参数 -I
    2. 1.2. 问题:
    3. 1.3. 编译器在预处理阶段做的事情:
  2. 2. 编译与链接
    1. 2.1. 问题:
  3. 3. 静态库与动态库
    1. 3.1. 静态库和动态库的区别
    2. 3.2. 有关链接时候使用-L参数
    3. 3.3. 问题: