C++—— 编译时链接相关问题
文章目录
首先提出几个问题:
在安装C++第三方依赖库的时候,一般会有一个
.0
文件,还有一些
.h
文件,然后可能还会涉及到修改环境变量PATH,或者把这些编译生成的文件拷贝到一些特定的文件夹中等等。。但是,为什么要做这些操作呢?
下面就来捋一捋这些关系
1. C++程序编译的工作流程
对于一个C++程序而言,从代码到可执行程序一共有四个过程:
1.1 预处理,得到 .i 预处理后到文件
将源代码的.c 、.cpp 、.h 等文件包含到一个文件中。在这个过程中会使用一些预处理指令要求编译器使用什么样的方式包含这些文件。预处理结束之后对于c语言编译器会生成一个.i 文件。C++会生成.ii文件。
- 将所有的#define删除,并且展开所有的宏定义;
- 处理所有条件编译指令,如#if,#ifdef等;
- 处理#include预编译指令,将被包含的文件插入到该预编译指令的位置。该过程递归进行,及被包含的文件可能还包含其他文件。
- 删除所有的注释//和 /**/;
- 添加行号和文件标识,如#2 “hello.c” 2,以便于编译时编译器产生调试用的行号信息及用于编译时产生编译错误或警告时能够显示行号信息;
- 保留所有的#pragma编译器指令,因为编译器须要使用它们;
1.2 编译,得到 .s汇编程序
编译过程就是把预处理完的文件进行一系列词法分析,语法分析,语义分析及优化后生成相应的汇编代码文件.
不同的优化等级:
- O1 :优化会消耗少多的编译时间,它主要对代码的分支,常量以及表达式等进行优化。
- O2:会尝试更多的寄存器级的优化以及指令级的优化,它会在编译期间占用更多的内存和编译时间。
- O3:在O2的基础上进行更多的优化。例如使用伪寄存器网络,普通函数的内联,以及针对循环的更多优化。
1.3 汇编,得到 .o 目标文件
汇编器是将汇编代码转变成机器可以执行的命令,每一个汇编语句几乎都对应一条机器指令。汇编相对于编译过程比较简单,根据汇编指令和机器指令的对照表一一翻译即可。
1.4 链接, 得到可执行文件
链接器ld将各个目标文件组装在一起,解决符号依赖,库依赖关系,并生成可执行文件。
2. 编译时候的一些细节
2.1 头文件.h 和源文件.cpp 的区别
头文件.h
:内含函数声明、宏定义、结构体定义等内容。
源文件.cpp
:内含函数实现,变量定义等内容。
实际上头文件和源文件没有区别,只不过是通过这两种文件来形成良好的代码风格。在编译的时候,一般默认都是对源文件.cpp
进行编译,源文件中一般都会#include "***.h"
,这个时候会把头文件.h
中的代码都拷贝到源代码中,然后再进行编译。
此时有一个问题,如果多个源文件都包含了同一个头文件,那么头文件中定义的一些变量会不会发生冲突呢?如果发生了,该怎么解决呢?
答案是会发生,但是可以解决,解决办法就是ifndef
关键字,利用这个关键字,保证在一个可执行文件中,头文件代码不会被拷贝多次。
2.2 编译器怎么去找头文件?
在使用一些IDE的时候,它们已经帮忙做好了这些工作,会在编译的时候帮我们添加头文件路径,但是我们自己去编译的时候,比如书写CMakeLists、配置vscode时,就会发现,需要我们手动的去添加头文件路径。比如写CMakeLists.txt时,此时我的文件夹路径是:
/MyProject
|-/include/test.h
|-/src/test.cpp
|-/example/main.cpp
此时,我们要在编译的时候,告诉编译器,去哪里找到test.h
这个头文件,因此就需要手动添加:
include_directories(${CMAKE_CURRENT_BINARY_DIR}/include)
这一行就表示,我们把当前编译目录底下的include
文件夹囊括进来了,找头文件的时候可以在这个里面找。
那么其他的一些系统文件呢?
答案是编译器会从系统的目录中进行查找,但一般是优先查找用户手动指定的,如果有冲突,那么会优先使用用户自己设定的。并且,在使用#include
关键字的时候,使用<***>
括号表示优先从系统目录中找,使用"***.h"
表示从优先自身定义的目录中找。
2.3 既然我现在能找到了我要用的文件 .h,那么为什么还要把依赖库编译成 .o文件,然后再去链接呢?
这个问题,以前也困扰了我很久,但是一直没有花时间花心思去解决它。
首先,头文件中,一般都是函数定义,具体实现都会在.cpp
文件中。另外,C/C++的编译都是针对cpp文件进行的,当我们使用了其他文件中的内容时候,会通过编译的最后一步链接来进行处理。
比如现在有一个文件结构:
/my_lib
|
|--/addlib
|--/addlib/add.h
|--/addlib/add.cpp
|--test.cpp
我们在add.h
中,声明一个函数
#ifndef _ADD_H
#define __ADD_H
#include <iostream>
int add(int a, int b);
#endif
我们在add.cpp
中,对声明的函数进行定义
#include "add.h"
#include <iostream>
int add(int a, int b){
return a+b;
}
定义完成,此时我们对这个文件进行编译,编译成一个静态库,此时使用g++
,参数为-c
,该命令会生成汇编文件,文件中是二进制代码
编译出错,是因为未在add.cpp
同等文件夹底下进行编译,目标找不到add.h
头文件(一般是在同级目录中找,然后再就是从系统环境变量PATH
中找)。在一般的IDE,如Visual Studio中,是可以忽略这一步的,因为在这种IDE中,会自动帮你处理好这种,它会有一个类似 include ${currentPath}/*/*.h
的步骤。
下一步就是将汇编代码,打包成静态库,用ar
命令,第一个参数是生成后的名字,往后的参数就是所有的打包文件
可以看到,此时有.a
文件生成,下一步就可以在其他代码中进行链接了,在test.cpp
文件中有
#include <iostream>
#include "./addlib/add.h"
using namespace std;
int main(){
int number1 = 10;
int number2 = 90;
cout << "the result is " << add(number1, number2) << endl;
return 0;
}
编译:
此时我们还没有告诉编译器,去哪里找我们这个头文件,去哪里找这个静态库,默认是去系统目录中,但没有找到,所以报错,因此需要加上参数-L
,告诉编译器去那里找静态文件,然后在加上静态库的名字,名字的规则一般是 -l
+ 库名,比如我们这个,就是-ladd
。
2.4 是否能将这个库做成类似于第三方依赖库的效果?
答案是可以!
首先,修改test.cpp
的include,实际上第一次就应该这样写,应该默认是从同级目录底下找
#include <iostream>
#include "addlib/add.h"
using namespace std;
int main(){
int number1 = 10;
int number2 = 90;
cout << "the result is " << add(number1, number2) << endl;
return 0;
}
然后,我们去/usr/local/lib
文件夹底下,做一个软链接
ln -s /root/codingFile/my_lib/addlib/libadd.a
同样的,在/usr/local/include
文件夹底下,做一个软链接
ln -s /root/codingFile/my_lib/addlib
此时,test.cpp
无论在哪个文件夹底下,都能够正常编译运行!
原理就是编译器会从系统目录中去寻找对应的链接,实际上,第三方依赖库也是这种做法,所以在几个特定的系统目录下,能够看到很多的静态链接,动态链接。
2.6 既然我们只需要 .h 文件和 .a / .so链接库文件即可运行,那为什么每个第三方依赖库都要重新自己编译呢?
在入门计算机视觉的时候,第一步就是编译opencv
,当时也是踩坑无数,后来学到了RPC
,编译过bRPC
,protobuf
这些,也踩过坑,但是一直没有像现在这样缕清思路。
好了回到这个问题,为什么要重新编译一次呢?这是因为每台机器环境配置不一样,可以看到有些第三方依赖库是对CMake
,gcc
,g++
这些对版本都是有要求的,不同的版本可能会造成不同的差异,因此,需要手动编译。C++的痛点也在这里,并没有一个很规范的包管理,不像python
,Golang
这些语言,拥有比较好的包管理机制。