C/C++头文件和库
2024-04-08 09:28:35 # 技术

GCC介绍🎯

GCC 最初是「GNU C Compiler」的简称,从名字也可以看出,其原本只是当作一个 C 语言的编译器。而经过十几年的发展,GCC 已经不仅仅能编译 C 程序,而且支持 C++、Java 程序的编译,GCC 也不再是「GNU C Complier」的意思,而表示「GNU Compiler Collection」,也即是 GNU 编译器集合的意思。
GCC 是一组编译工具的总称,其软件包中包含众多的工具,按其类型,主要有以下分类:

  • 源代码预处理程序:cppcpp0
  • C编译器:cccc1cc1plusgcc
  • C++ 编译器:c++cc1plusg++
  • 库文件:libgcc.alibgcc_eh.alib_gcc_s.solibiberty.alibstdc++.alibstdc++.solibsup++.a
    gcc 和 g++实际上是一个编译器驱动程序,它虽然不知道如何解析或编译源代码,但它知道如何调用实际的编译器、汇编器和链接器来处理源代码。

    关于 gcc 和 g++:
    g++ 不仅会把 .cpp 文件当作 C++程序文件调用 cc1plus 进行编译,而且还会把 .c 文件当作 C++语言文件(在 .c 文件前后分别加上- xc++-xnone,强行变成 C++),并调用 cc1plus 进行编译,此外在链接过程中还会默认链接上 C++标准库。
    gcc 会把 .c 文件当作是 C 语言文件从而调用 cc1 进行编译,而遇到 .cpp 文件时会处理成 C++语言,并调用 cc1plus 进行编译。不过 gcc 默认不会链接到 C++标准库,因此需要手动加上 -lstdc++ 链接选项。

在使用gcc/g++编译程序时,编译过程可以被细分为以下四个阶段:

  • 预处理(Pre-Processing):首先调用 cpp 命令进行预处理,主要实现对源代码编译前的预处理,比如将源代码中指定的头文件包含进来,预处理后的 C 和 C++程序文件分别是 .i.ii 文件(预处理后的文件仍是 C/C++代码)。
  • 编译(Compiling):调用 cc1/cc1plus 命令进行编译,作为整个编译过程中的一个中间步骤,该过程会将源代码翻译生成汇编代码,也即 .s 文件。
  • 汇编(Assembling):汇编过程是针对汇编代码的步骤,调用as命令进行工作,生成扩展名为.o的目标文件。
  • 链接(Linking):当所有的目标文件都生成以后,使用ld命令调用链接器完成最后的链接工作。

头文件处理🏷️

什么是头文件

头文件(HeadFile)是一个包含某个库的外部声明函数和变量的文件,其扩展名通常为 .h
在使用头文件的时候,由「预处理器指令」 #include 负责加载,当预处理器看到 #include 标记时,就会用标记的头文件的内容替代 #include
预处理器的一项重要预处理功能是「头文件保护符」,
头文件保护符依赖于预处理变量,用于防治重复包含头文件内容
。预处理变量有两种状态:已定义和未定义。
#define 指令把一个名字设定为预处理变量,另外两个指令则负责分别检查某个指定的预处理变量变量是否已经定义:#ifdef 当且仅当变量已经定义时为真,#ifndef 当且仅当变量未定义时为真。一旦检查结果为真,则执行后续操作直到遇到 #endif 指令为止。
使用这些功能就能有效防止头文件内容被重复包含:

1
2
3
4
#ifndef TEST_H_
#define TEST_H_
// 省略代码
#endif

假设上述内容是头文件test.h的内容,在第一次包含test.h文件时,#ifndef的检查结果为真,预处理器将顺序执行后面的操作直至遇到#endif为止。此时预处理变量TEST_H_的值变为已定义,而且该头文件的内容也会被拷贝到使用#include指令标记的位置。后面如果再一次包含test.h文件,则#ifndef的检查结果为假,编译器将忽略#ifndef#endif之间的部分。

头文件的加载方式

#include预处理器指令加载头文件的形式有两种:尖括号<>和双引号""。两者的主要区别主要体现在「搜索路径」上。但都遵循相同的搜索规则,也即使用搜索到的符合要求的第一个头文件。
使用「尖括号」时的搜索顺序为:

  1. 通过 gcc -I 指定的目录
  2. 通过环境变量 C_INCLUDE_PATH 指定的目录
  3. 最后查找系统的内定目录

使用「双引号」时的搜索顺序:

  1. 项目当前目录
  2. 通过GCC参数gcc -I指定的目录
  3. 通过环境变量C_INCLUDE_PATH指定的目录
  4. 最后查找系统的内定目录

可以看到,使用双引号加载头文件时,会首先从当前目录进行搜索,若未找到,则按照使用尖括号的方式继续搜索。

头文件的搜索目录

可以使用cpp -v命令来查看GCC的内定搜索目录,也即所谓的官方路径(标准的系统头文件路径):

这实际上同时包含了 C 和 C++的标准头文件目录。如果想明确查看 C 或 C++的标准头文件路径可以分别使用下图中标识的命令进行查看:

注意⚠️:
命令中「反引号」的作用是将其所包含的命令执行结果作为命令调用。
「usr」并不是用户「user」的缩写,而是「Unix System Resource」的缩写。

在编译程序时,如果我们自己的头文件没有放到官方路径下面,我们可以通过gcc -I来指定头文件路径,编译器在编译程序时,就会到用户指定的路径目录下去搜索该头文件。此外,还可以通过设置环境变量来添加头文件的搜索路径。在Linux环境下常用的环境变量有:

  • PATH:可执行程序的搜索路径
  • C_INCLUDE_PATH:C语言头文件的搜索路径
  • CPLUS_INCLUDE_PATH:C++头文件的搜索路径
  • LIBRARY_PATH:库文件搜索路径

链接库的处理🏷️

什么是库文件

C/C++中提供某个库通常有三种方法:

  • 头文件(.h)+ 源代码(.c.cc
  • 头文件(.h)+ 静态库(Linux下的.a文件和Windows下的.lib文件)
  • 头文件(.h)+ 动态库(Linux下的.so文件和Windows下的.dll文件)

头文件是预处理和编译时所必须的,静态库是链接时必须的,动态库是运行时必须的。
程序在使用一个函数之前,应该首先声明该函数。为了便于使用,通常的做法是把同一类函数或数据结构以及常数的声明放在一个头文件中。头文件中也可以包括任何相关的类型定义和宏。在程序源代码文件中则使用预处理指令 #include 来引用相关的头文件。
库文件中包含一系列的程序实现。如果我们需要将某个程序的实现提供给客户使用,但又不想客户看到程序实现的源代码,那我们可以将源代码文件编译成库文件,库文件是汇编后的二进制代码,是二进制的,在库文件中看不到源代码。库文件和可执行文件的区别在于,库文件不是独立程序,它们是向其他程序提供服务的代码。使用库文件的好处不仅在于对源代码进行保密,重要的是可以减少重复编译的时间,增强程序的模块化。
简单来说,头文件中有函数的声明,而库文件中实现函数的定义。头文件的主要作用在于多个代码文件全局变量(函数)的重用、防止定义的冲突,对各个被调用函数给出一个描述,其本身不需要包含程序的逻辑实现代码,它只起描述性作用,用户程序只需要按照头文件中的接口声明来调用相关函数或变量,链接器会从库中寻找相应的实际定义代码。也即,库文件通过头文件向外导出接口,用户程序通过头文件查找库文件。

静态库和动态库

前面已经提到,库文件就是汇编后的二进制代码,库文件本身分为两种:

  • 静态库文件(Linux下的.a文件和Windows下的.lib文件)
  • 动态库文件(Linux下的.so文件和Windows下的.dll文件)

静态库

静态库文件其实就是汇编后的二进制代码(.o 文件)的一个压缩文件(archive),里面可以有一个或多个 .o 文件。程序的运行都要经过编译和链接两个步骤。
假如有文件 header.cc,可以使用命令 gcc -c header.cc 进行编译,生成 header.o 中间文件,使用命令 ar -r libheader.a header.o 可以生成 libheader.a 静态库文件。因此我们说静态库文件其实就是对 .o 中间文件进行的封装,使用 nm libheader.a 命令可以查看其中封装的中间文件以及函数符号。
header.h文件内容如下:

1
2
3
4
5
6
7
8
#ifndef HEADER_H_
#define HEADER_H_

#include <iostream>

void print_hello();

#endif

header.cc文件内容如下:

1
2
3
4
5
6
7
#include <iostream>

#include "header.h"

void print_hello() {
std::cout<<"Hello world!"<<std::endl;
}

执行结果如下图所示:

链接静态库就是链接静态库中的.o文件,这和直接编译多个文件再链接成可执行文件一样,不过使用静态库省略了中间的编译环节,提高了效率。
链接静态库:g++ main.cc -I./header -L./header -lheadermain.cc文件内容如下所示:

1
2
3
4
5
6
#include <iostream>
#include "header/header.h"

int main() {
print_hello();
}

执行结果如下图所示:

动态库

动态库的创建方式有些不同。首先还是使用 gcc 进行汇编处理生成二进制目标代码 .o 文件,g++ -fPIC -c header.cc,因为汇编后的汇编代码要去做动态库,所以这里多了一个 -fPIC 选项,该选项创建与地址无关的编译程序(PIC,Position Independent Code),是为了能够在多个应用程序间共享(用来确定库中函数的链接位置)。然后再使用 gcc 从二进制目标代码生成动态库,g++ -shared -o libheader.so header.o 。生成动态库的结果如下图所示:

动态库的链接命令和静态库文件一样,不过是优先链接.so动态库文件,没有动态库再链接.a静态库文件。链接执行结果如下图所示:

虽然静态库和动态库的链接命令一样,但是做的事情不太一样。链接静态库文件是把库里面的函数实现复制到了可执行文件里面,所以可执行文件生成以后有没有静态库文件就不重要了。而动态链接只是在可执行文件里面记录了某些函数所要使用的动态库文件,真正的链接是在运行的时候发生的,只有运行我们生成的可执行文件,到了需要使用动态库文件里面的函数的时候,那个函数才会被加载到内存中再执行。所以涉及到了操作系统寻找动态链接库的路径问题。可以使用ldd程序查看我们生成的「可执行文件」都用到了哪些动态库文件: ldd a.out,输出结果如下图所示:

可以看到,虽然在创建「可执行文件」的时候成功链接了动态库,但「可执行文件」指示并没有找到我们的动态库 libheader.so,「可执行文件」并不能成功运行。
要让系统找到动态库文件就必须设置动态库文件的路径了,如果要在动态库文件的搜寻路径里面加上动态库所在的文件路径,或者将动态库文件放到动态库的搜寻路径里面。详情见[[#运行时定位动态库文件]]。

库文件搜索路径

静态库链接时搜索路径的顺序:

  • gcc/g++命令中-L参数指定的路径
  • 环境变量LIBRARY_PATH指定的静态链接库文件搜索路径
  • 最后查找默认库目录
    • /lib 标准共享库和静态库
    • /usr/lib 标准共享库和静态库
    • /usr/local/lib 本地函数库

动态库链接时、==执行时==搜索路径的顺序:

  • 编译目标代码时指定的动态库搜索路径
  • 环境变量LD_LIBRARY_PATH指定的动态库搜索路径,它指定程序动态链接库文件搜索路径
  • 配置文件/etc/ld.so.conf中指定的动态库搜索路径
  • 默认的动态库搜索路径/lib
  • 默认的动态库搜索路径/usr/lib

高速缓存动态库

Linux大多是将函数库做成动态函数库。因为内存的访问速度远远高于硬盘,所以如果将常用的动态函数库加载到内存中,当软件套件要采用动态函数库时,就不需要重新从硬盘里读取,就可以提高动态函数库的读取速度。使用ldconfig命令和/etc/ld.so.conf配置文件可以帮我们做到这一点:

  1. 首先,在配置文件/etc/ld.so.conf中记录「需要读入高速缓存的动态函数库所在的目录」;
  2. 使用ldconfig [-f conf] [ -C cache] [-p]命令将/etc/ld.so.conf中标记的动态库读进高速缓存;
  3. /etc/ld.so.cache文件中会记录所有读入了高速缓存的动态库文件名称;

运行时定位动态库文件

前面提到了可执行文件在运行时没有找到我们链接的动态库,那么可执行文件是如何定位动态库文件的呢:

  1. 当系统加载可执行代码的时候,不光要知道所依赖的动态库的名称,还要知道其绝对路径。此时就需要「动态载入器」ld.so和 ld-linux.so*(Dynamic Linker/Loader),ld.so程序处理a.out二进制格式文件,ld-linux.so*处理更加现代的ELF二进制格式。这两个程序具有相同的行为,使用相同的支持文件和程序(lddldconfig/etc/ld.so.conf),都是在程序运行期间起作用。
  2. 动态载入器的搜索顺序如下,找到库文件后将其载入内存
    1. ELF文件的DT_RPATH段;
    2. 环境变量LD_LIBRARY_PATH
    3. /etc/ld.so.cache文件列表;
    4. /lib和/usr/lib目录;

在这里我们可以通过如下方式让我们的可执行文件找到我们的动态库:

  • 如果动态库安装在/lib或者/usr/lib目录下,那么动态载入器默认能够找到,不需要其他操作;
  • 如果动态库安装在其他目录下,需要将该目录添加到/etc/ld.so.cache文件中,操作步骤如下:
    • /etc/ld.so.conf文件中加入动态库文件所在目录的路径
    • 运行ldconfig,该命令将会把动态库文件读入高速缓存,并在/etc/ld.so.cache文件中记录该动态库

操作结果如下图所示:

参考文献