欢迎您访问程序员文章站本站旨在为大家提供分享程序员计算机编程知识!
您现在的位置是: 首页

通过例子理解头文件库文件的编译链接

程序员文章站 2022-06-03 14:21:02
...

通过例子理解头文件库文件的编译链接

网传头文件的包含和静动态链接库的链接无非就是文件的赋值黏贴而已。是耶非耶?是的话,是如何复制,又是如何黏贴,今天通过这篇文章,通过简单的例子,带你们了解一下多个 cpp 文件和 h 头文件之间是如何组织在一块的。

多 cpp 和 h 文件编译链接例子

为了说明这个问题,首先我写了以下几个文件,命名分别为 1main.cpp、2b.h、2b.cpp、3c.h、3c.cpp 。容易看得出来,我这个命名是用调用顺序和包含的函数来命名的。其中,做主流的做法一样,h 文件中方函数的声明,cpp文件中放对应的函数定义。为了简洁,防多次编译的指令就不写了。

//3c.h
void c();

//3c.cpp
#include "3c.h"
#include "iostream"
 void c()
 {
	 std::cout<<"exec c function!"<<std::endl;
 }

//2b.h
 void b();

//2b.cpp
#include "2b.h"
#include "3c.h"
#include<iostream>
 void b()
 {
	 c();
	 std::cout<<"exec b function!"<<std::endl;
 }

//1main.cpp
#include "2b.h"
#include<iostream>
 int main()
 {
	 b();
	 std::cout<<"exec main function!"<<std::endl;
exit(0);
 }

从上面可以看出,主函数调用了 b,b 又调用了 c。我们来编译链接执行以下。

按照常规的思路,我们使用如下代码来编译链接和执行。

g++ -c 3c.cpp
g++ -c 2b.cpp
g++ 2b.o 3c.o 1main.cpp
./a.out

这个写法不是唯一的,你也可以编译链接直接一步到位写成 g++ 2b.cpp 3c.cpp 1main.cpp。或者用 make 的写法。为了说明下述问题,我拆开写了。

可以看到,得到了正确的结果:
通过例子理解头文件库文件的编译链接

关于方法实现位置的一个说明

现在第一个问题来了,b 函数调用了 c 函数,我们这里在编译 2b.cpp 为中间文件 2b.o 的时候,好像没有吧 2c.o 给链接进来,那么为什么编译不报错,c 函数依然有定义?

简单解释一下,这是因为,要调用的函数的地址现在的编译器是用全 0 填充,等到有定义了,就会进行重定位,一言以蔽之,就是先预留一个位置,让编译通过。其实,编译器和链接器是支持 3c.o 与 2b.o 互相调用相互的函数。为什么可以这样?这就和编译的算法有关,简单地说,人家是迭代实现的。

我们来理解下这个过程,有人说 include 无非就是把头文件的内容复制黏贴到 include 发生的地方。这样看来,几个 cpp 之间头文件的 include 无非就是 1main.cpp 中最前面插入了 b 函数的声明,2b.cpp 中插入的 c 函数的声明。在最后编译 1main.cpp 的时候,链接 2b.o 和 3c.o 无非就是把 b 函数和 c 函数黏贴过来。那么,这样看来,是不是说明被调用的 cpp 里面,把对应的头文件 include 进来就没什么用了?我们把对应的头文件 include 去掉,来测试一下。

把程序改成这样(为了方便,我写在一块了):

/*************************************/
 //3c.h
void c();


//3c.cpp
#include "iostream"
 void c()
 {
	 std::cout<<"exec c function!"<<std::endl;
 }

/*************************************/
//2b.h
 void b();


//2b.cpp
#include "3c.h"
#include<iostream>
 void b()
 {
	 c();
	 std::cout<<"exec b function!"<<std::endl;
 }


/*************************************/
//1main.cpp
#include "2b.h"
#include<iostream>
 int main()
 {
	 b();
	 std::cout<<"exec main function!"<<std::endl;
exit(0);
 }

发现结果依然是正确的。

好,通过上面的说明,我们似乎发现,对于库文件或者中间的 cpp 而言它调用的函数是否已经在他之前有定义,并没有太大关系,只要最后生成总目标的时候,函数有定义就可以了。那么,是不是说明,我们侧重的其实是函数的声明,函数的定义总是可以随便丢,反正最后能在 main 对应的文件中出现就行了。我们来验证一下。把 c 函数的定义丢到 1.main 里面。

//2b.h
xxxxxxxxxxxxx no change

//2b.cpp
xxxxxxxxxxxxx no change

//1mainc.cpp
#include "2b.h"
#include<iostream>
 void c()
 {
	 std::cout<<"exec c function!"<<std::endl;
 }

 int main()
 {
	 b();
	 std::cout<<"exec main function!"<<std::endl;
exit(0);
 }

使用 g++ 2b.cpp 1mainc.cpp 进行编译进而执行,结果是正确的。也就是说,不管你前面由 cpp 生成的中间文件的函数调用依赖关系如何,只要我在最后生成的可执行文件的时候,把相关的函数链接上来的,使用 include “复制黏贴”出来的函数声明就能找到对应的实现。中间文件的函数实现是可被占位的。只要有声明,函数可以暂时没有定义。因为这是一个自上而下的过程,中间被调函数是否能执行,在编译的时候,并不是那么重要。

很多同学认为,因为有复制黏贴的机制,而导致的最后的 c 是有定义的。其实不然,你只要保证你显式调用的函数有定义即可,如果一些函数没定义,但你自始至终没有被调用,也是允许的,比如把上面的程序中 main 的调用 b 去掉,也是对的,这时候的 c 是没有定义的,但是也是对的。

//2b.h
xxxxxxxxxxxxx no change

//2b.cpp
xxxxxxxxxxxxx no change

//1mainc.cpp
#include "2b.h"
#include<iostream>
 void c()
 {
	 std::cout<<"exec c function!"<<std::endl;
 }

 int main()
 {
	 //b();
	 std::cout<<"exec main function!"<<std::endl;
exit(0);
 }

打包成静态链接库和动态链接库进行链接

现在我们换种思路,来把 2b.cpp 和 3c.cpp 打包成链接库文件,看看前面 .o 文件提到的问题是否是一样的存在。依然使用 b 未被注释的 1mainc.cpp 文件。

首先,把 2b.cpp 打包静态链接库,1mainc.cpp 中写 c 的实现,依次执行

g++ -c 2b.cpp
ar cqs lib2b.a 2b.o
g++ 1mainc.cpp -L. -l2b
./a.out 

显然,这个打包过程是可以执行的,他不依赖于函数 c 是否已经定义。

接下来,我把 2b 和 3c 打包成动态链接库,再调用:

g++ -fpic -shared 3c.cpp 2b.cpp -o lib2b3c.so
g++ 1main.cpp -L. ./lib2b3c.so
./a.out

也没有问题,那么问题来了,如果我使用 1mainc.cpp 作为主文件,那么在 1mainc.cpp 和 3c.cpp 中同时定义了 c 函数,程序会报错还是会调用其中哪一个函数?我们测试一下。

//3c.h
xxxxxxxxxxxxx no change

//3c.cpp
xxxxxxxxxxxxx no change

//2b.h
xxxxxxxxxxxxx no change

//2b.cpp
xxxxxxxxxxxxx no change

//1mainc.cpp
#include "2b.h"
#include<iostream>
 void c()
 {
	 std::cout<<"exec ccc function!"<<std::endl;
 }

 int main()
 {
	 b();
	 std::cout<<"exec main function!"<<std::endl;
exit(0);
 }

执行:

g++ -fpic -shared 3c.cpp 2b.cpp -o lib2b3c.so
g++ 1mainc.cpp -L. ./lib2b3c.so
./a.out

可以看到的是,执行的 c 是 1.mainc.cpp 中的函数,这个有点意思了。这个怎么理解呢?值得思考。

小小的总结

所以,头文件的 include 和库文件和中间文件的链接,是可以理解为某种意义的复制黏贴的。include 直接对应的头文件内容黏贴到 include 的位置。库文件链接,等同于把 cpp 文件包括它的头文件包含,黏贴到被链接程序的某个位置,至于是什么位置,是头还是尾,因为有声明的存在,显然就变的不重要,不必要深究。

你要使用一个函数,既要包含声明,也要包含实现。函数的使用 = 声明+实现,声明要在使用之前声明,函数的实现,只要保证实际被 main 调用到的时候保证有定义就可以了。

声明使用#include把对应的文件包含进来即可。如果找不到对应的头文件,你要把文头件对应的目录添加到环境变量C_INCLUDE_PATH或者CPLUS_INCLUDE_PATH中。

实现写在某个 c/cpp 中,然后转成中间文件,或者静动态库文件,最后链接到主文件中。如果调用的是别人的库,或者中间文件,需要和包含目录一样,指定库文件目录。对应的环境变量为:LD_LIBRARY_PATH 是动态链接库的,LIBRARY_PATH 是静态库的。

可以在程序编译过程中,指定搜索路径,使用 -I 指定头文件搜索路径,使用 -L 指定库文件搜索路径。这里假定动态链接库名为 libfoo.so,静态链接库名称为libfoo.a。动静态链接库的命名规则为 libxxx.so 和 libxxx.a

-I :指定头文件路径
-i :指定头文件名字 (一般不用,c/cpp 文件里面会 include ,完全没必要)
-L:指定连接的动态库或者静态库路径
-l (L的小写):指定需要链接的库的名字

对于一个 C/C++ 程序来说,头文件一般存放一些函数的声明等,CPP 和 C 文件用来写函数的实现,可以封装为库文件,而在工程的入口,头文件和库文件的搜索路径以及具体到包含哪一个都是要有的。路径的声明:可以用环境变量;可以在编译的时候使用大写的 I 和 L 声明。具体文件的包含:INCLUDE 文件在 C/CPP 文件中使用头文件的方式写入(include <xxx.h>);库文件在(编译)链接的时候使用小写字母 l (小写的L)链接进去(-lxxx)。
需要声明的是,因库的命名规则,链接库采用简写,如链接 libc.a 写成 -lc,链接 libc.so 也写成 -lc 。如果两种库都有,动态库优先,链接静态库就需要 -static 标识,或者写全称。这里 -i 不用是因为,我们再 cpp 或者 c 文件里面已经 include 对应的头文件了。而对于动态链接库和静态库,就一定要写清楚链接的具体是哪个库,程序之间库的调用关系,要搞清楚,故而有小写 L,即 l。

做个汇总:

  • 生成静态链接库
ar cqs libxx.a xx.o
  • 生成动态链接库
g++ -fpic -shared xx.cpp xx.cpp -o libxxxx.so
g++ xxx.cpp -L. ./libxxxx.so
  • 多文件依赖的编译链接
g++ xx.cpp xxx.cpp xxxx.cpp
  • 头文件和库文件环境变量
C_INCLUDE_PATH:C头文件;
PLUS_INCLUDE_PATH:C++头文件;
LD_LIBRARY_PATH:动态链接库;
LIBRARY_PATH:静态库。
  • 修改一些重要环境变量的方式形如:export XXXX=$XXXX:YYYY

  • 链接头文件和库文件目录和链接静动态库文件的写法如下,-lxxxl 等价于 libxxx.a 或者 libxxx.so,同名动态优先,故用 static 标定静态。

 gcc main.c -I/home/usr/local/include  
 gcc main.c -L/etc/lib -lfoo
 gcc main.c -L/home/usr/local/lib -static -lfoo