头文件

头文件的存在,目的是把接口和实现分离,便于多文件编程中的组织,比如

  • 在多文件的项目中,把函数声明都集中到若干头文件中,在源文件中引用它们,便于跨文件的函数调用
  • 在提供库的同时,我们也需要提供库的使用接口(头文件),通过头文件中的类和函数声明,用户可以知道如何使用这个库
  • 在使用库的时候,首先需要在源代码中引用头文件,然后在链接步骤中链接需要的库文件

gcc 查找头文件

gcc 在编译过程中,预处理环节需要 include 相应的头文件,这里存在一个问题:如何找到头文件?

gcc 存在专门的选项:-Ipath,也可以写成-I path,带不带空格都可以,但是只能后接一个路径,如果使用多个路径就需要多个-I,例如

1
gcc hello.c -I mydir1 -I mydir2

如果直接写完整路径加文件名,那么不存在查找文件的问题,但是如果使用的是不完整路径加文件名,则存在查找顺序的问题。在 include 语句中,双引号和尖括号引用头文件的查找顺序有一点区别:

  • 双引号 include 的查找顺序:
    1. 使用#include的源文件所在的路径
    2. -I 指定的路径
    3. 环境变量CPATHC_INCLUDE_PATHCPLUS_INCLUDE_PATH包含的路径
    4. 内定路径
  • 尖括号 include 的查找顺序:
    1. -I 指定的路径
    2. 环境变量CPATHC_INCLUDE_PATHCPLUS_INCLUDE_PATH包含的路径
    3. 内定路径

注意:

  • 查找顺序是重要的,如果两个路径下有同名的头文件,gcc 会使用先找到的那个,这很可能导致 bug
  • 环境变量CPATH被 c/c++共用,C_INCLUDE_PATHCPLUS_INCLUDE_PATH只在处理相应的文件时使用
  • 内定路径是由 gcc 和平台决定的,我们无法改变

可以使用下面的命令查看当前 gcc 使用的头文件搜索路径(环境变量和内定路径),注意有反引号

1
2
3
4
# c
`gcc -print-prog-name=cc1` -v
# c++
`gcc -print-prog-name=cc1plus` -v

也可以在 gcc 中加入-v选项,这样会打印编译过程的详细信息。

注意include通常是递归的,即一个头文件可能还包括另一个头文件,我们考虑如下的情景:

  1. A include B
  2. B include C
  3. C include D

那么为了将内容完整地拷贝到 A 中,需要首先完成 B 的头文件查找,查找顺序是:

  1. A 所在的位置
  2. 其它路径

然后完成 C 文件的查找,查找顺序是:

  1. B 所在的位置
  2. A 所在的位置
  3. 其它路径

然后完成 D 文件的查找,查找顺序是:

  1. C 所在的位置
  2. B 所在的位置
  3. A 所在的位置
  4. 其它路径

即按照递归顺序倒序查找所在的目录。

gcc 与 g++区别

GNU gcc 是一个整体,但是在处理 c/c++文件时,我们可以分别采用gcc或者g++命令, 两者效果类似,可以理解为g++命令在gcc命令之上又针对 c++进行了一层封装, 因此区别主要是对待 c/c++文件时的细节处理,例如:

  1. g++ 对于.c/.cpp 结尾的文件全都默认当作 c++文件处理。
  2. gcc 对于.c 视作 c 文件,对于.cpp 视作 c++文件处理。
  3. 对于 STL 标准库,如果使用 g++会自动链接进来,如果使用 gcc 则需要加参数-lstdc++显式地完成,并且可能有细节差异。
  4. 对于预定义宏,两者支持的宏不完全一样。

涉及的其他细节我们不用关注,只需要关注最重要的标准库,我们用如下例子体现:

1
2
3
4
5
6
//helloworld2.cpp
#include <iostream>
int main(){
std::cout<<"hello world,cpp\n";
return 0;
}

首先使用 gcc 完成

1
2
3
4
5
6
7
8
gcc helloworld2.cpp
# 会报错iostream cout各种未定义

gcc helloworld2.cpp -lstdc++ -o helloworld2
# 链接STL后可以顺利进行

./helloworld2
hello world,cpp

然后使用 g++完成,无需显式链接即可自动进行。

1
2
3
4
g++ helloworld2.cpp -o helloworld2

./helloworld2
hello world,cpp

补充

C 语言提供的头文件例如 stdio.h,在 C++层面又提供了 cstdio 进行对应的封装,需要留意的是:这两者并不完全相同,可能在 cstdio 里面触发或修改某些宏,导致运行结果不完全一致。(例如在 MinGW 上,下面两个头文件的区别会导致程序对 long double 类型处理时有不同)

1
2
3
#include <stdio.h>

#include <cstdio>

建议 C++使用封装后的头文件。

库文件

库的存在,目的之一是为了把一些基础功能封装,在链接阶段或者运行阶段直接使用,方便功能复用;目的之二是为了隐藏代码,使用二进制的库文件不需要直接暴露源代码,方便发布产品的同时隐藏功能实现的细节。

根据是否直接包含到可执行文件中,在运行期间是否需要,可以分为静态库和动态库。

根据平台和编译器的不同,c/c++的静态库和动态库处理在 windows+msvc,MinGW+gcc 和 Linux+gcc 三种情景下各不相同,windows 对动态库非常不友好。本文将基于 Linux+gcc,实现基本的静态库和动态库的编写。

注意:关于库文件的习惯命名

  • 在 Linux 中,静态库通常名称为libxxx.a,动态库通常名称为libxxx.so.x.y.z
  • 在 windows 中,静态库通常名称为xxx.lib文件,动态库通常名称为xxx.dll(DLL 辅助部分xxx.lib);
  • 在 mingw 中,静态库通常名称为libxxx.a,动态库通常名称为libxxx.dll(DLL 辅助部分libxxx.dll.a)。

编程实验

在接下来,我们将编写完成一个名为 foo 的库,头文件为foo.h

1
2
3
// foo.h
int getnum();
void sayhello();

源文件有两个版本

  • foo1.cpp \(\Rightarrow\) 静态库libfoo.a
  • foo2.cpp \(\Rightarrow\) 动态库libfoo.so

测试代码如下

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
// foo1.cpp
#include "foo.h"
#include <iostream>

int getnum(){
return 1;
}

void sayhello(){
std::cout<<"hello,I am foo1\n";
}

// foo2.cpp
#include "foo.h"
#include <iostream>

int getnum(){
return 2;
}

void sayhello(){
std::cout<<"hello,I am foo2\n";
}

这里foo1.cppfoo2.cpp都对头文件foo.h中的两个函数getnum()sayhello()分别进行了实现。

我们将在main.cpp中引用头文件foo.h,用多种方式调用这个库 foo

1
2
3
4
5
6
7
8
// main.cpp
#include "foo.h"

int main(){
sayhello();

return 0;
}

目录结构如下

1
2
3
4
5
6
7
8
9
10
11
|-bin
|-include
foo.h
|-lib
|-all
|-shared
|-static
|-src
foo1.cpp
foo2.cpp
main.cpp

我们将在lib/static存在静态库,在lib/shared存放动态库,在lib/all存放动态库和静态库。

静态库的编写

在 Linux 中使用 gcc 编写一个静态库,需要编译和打包两步

1
2
3
4
5
6
7
8
# 编译获得目标文件
g++ -c src/foo1.cpp -Iinclude -o lib/static/foo1.o

# 打包得到.a静态库,注意前面是输出文件,后面是输入文件(可以有多个输入)
ar -crv lib/static/libfoo.a lib/static/foo1.o

# 从lib/static复制一份到lib/all
cp lib/static/libfoo.a lib/all/libfoo.a

动态库的编写

在 Linux 中使用 gcc 编写一个动态库,也需要两步

1
2
3
4
5
6
7
8
9
10
# 注意在生成目标文件的时候需要加-fPIC选项,生成位置无关的目标文件
g++ -Iinclude -fPIC -c src/foo2.cpp -o lib/shared/foo2.o
# 注意得到动态库时,需要加-shared选项
g++ -shared lib/shared/foo2.o -o lib/shared/libfoo.so

# 两步可以合并为一步,效果一样
g++ -fPIC -shared src/foo2.cpp -Iinclude -o lib/shared/libfoo.so

# 从lib/shared复制一份到lib/all
cp lib/shared/libfoo.so lib/all/libfoo.so

注意:

  • 从目标文件到动态库文件,必须使用-shared选项
  • 从源文件到目标文件,建议使用-fPIC选项,可以生成位置无关的代码,此时动态库在内存中只需要加载一次,多个程序可以共同并且同时使用;否则只能相当于代码拷贝的方式,多个程序需要多次加载同一个动态库到内存中使用
  • 相比于静态库,动态库生成之后的存放位置是很重要的,因为可执行程序在运行时,还需要找到动态库并一起加载到内存中
  • 由于动态库通常有-fPIC,而静态库通常没有,因此动态库中如果想要链接一个静态库会报错,解决办法可以是把静态库也添加选项,改为生成位置无关的代码。

库的使用

首先,库的名称需要满足一定的命名规范,对于静态库通常命名为libxxx.a,对于动态库的名称通常为libxxx.so.x.y.z,还需要附带动态库的版本号。

在遵循这种命名规范的前提下,gcc 可以使用-lxxx选项链接相应的库,如果命名不规范则需要给出库文件的完整名称。

gcc 相应的选项

  • -l: 链接指定的库,例如-lxxx希望链接名为 xxx 的库,可能是静态也可能是动态链接
  • -L: 指出在编译时,库文件的优先查找目录,例如-Llib会在./lib目录下查找,缺省目录时-L代表当前目录,-L只能后接一个目录,多个目录使用多个-L选项
  • -Wl,rpath=your_dir: 指出在运行时,动态库文件的优先查找目录,可以接多个目录,用:分隔不同目录,类似于环境变量中的路径分隔。(在 gcc 中,-Wl之类的选项会传递给链接程序,-Wa之类的选项会传递给汇编程序)

注意:选项-l不能过早出现,因为 gcc 从左到右检索,记住在源文件中没找到的符号,在对应的库当中查找并提取,对于没用到的部分,静态库可能丢弃剔除!(似乎是 GCC 编译器自作聪明的缘故,这导致了链接也有顺序问题),因此库的链接选项最好在用到的源代码之后,在库的查找路径之后。

在这里要区分两个概念:编译时和运行时

  • 编译时,gcc 需要找到库文件,然后完成二进制文件的生成,这对于静态链接和动态链接都是必须的;
  • 运行时,可执行文件需要找到它需要的动态库文件,然后一起加载执行。由于静态链接的库文件在运行时是不需要的,因此我们只有使用动态链接时,才有在运行时找到动态库文件的需求。

-L选项告诉 gcc 编译器,编译时在哪找到库文件,但这个信息通常不会留在可执行文件上,如果使用-Wl,rpath=选项,则这个信息会留存在可执行文件上,明白在运行时首先去哪找到动态库文件。这两个路径的区分是必要的,因为编译时的开发环境和运行时的生产环境,动态库存放的位置很可能是不一样的。

对于一个可执行文件,可以使用ldd命令查看运行它所需要的动态库,以及它们现在是否能被系统找到,如果显示有的动态库没有找到,直接运行就会报错,这个选项也可以查看找到的是否是正确的动态库。

1
2
3
4
ldd ./a
linux-vdso.so.1 => (0x00007ffddb3dc000)
libc.so.6 => /lib64/libc.so.6 (0x00002b1c545b4000)
/lib64/ld-linux-x86-64.so.2 (0x00002b1c54391000)

可以使用ld -verbose命令查看系统的链接器默认查找的策略文件,例如如下片段说明了系统的默认查找路径

1
2
3
4
5
6
7
8
SEARCH_DIR("/usr/x86_64-redhat-linux/lib64");
SEARCH_DIR("/usr/lib64");
SEARCH_DIR("/usr/local/lib64");
SEARCH_DIR("/lib64");
SEARCH_DIR("/usr/x86_64-redhat-linux/lib");
SEARCH_DIR("/usr/local/lib");
SEARCH_DIR("/lib");
SEARCH_DIR("/usr/lib");

完整的路径查找顺序如下:

  • 编译时,按照如下顺序查找需要的库文件
    1. 编译选项指定的编译时库搜索路径-L,多个路径需要用多个-L
    2. 环境变量LIBRARY_PATH指定编译时库的搜索路径
    3. 内定路径/lib,/usr/lib
  • 运行时,可执行文件将要执行时,按照如下顺序查找需要的动态库文件
    1. RPATH:编译选项指定的运行时动态库搜索路径-Wl,-rpath=your_lib_dir,当指定多个运行时动态库搜索路径时,路径之间用冒号:分隔
    2. 环境变量LD_LIBRARY_PATH:指定的运行时动态库搜索路径(通常在普通用户的.bashrc中会进行相应的修改)
    3. RUNPATH:和RPATH类似,但是如果设置了RUNPATHRPATH无效,并且RUNPATH查找优先级低于LD_LIBRARY_PATH
    4. 在系统配置文件/etc/ld.so.conf中指定的动态库搜索路径
    5. 内定路径/lib/usr/lib/usr/local/lib

这里对配置文件/etc/ld.so.conf的修改需要root权限,无论是修改配置文件,还是向内定路径中加入新的动态库,都需要root权限调用ldconfig命令进行数据刷新。

注意到-lxxx选项并没有告诉 gcc 用静态链接还是动态链接,使用哪种链接的选择逻辑如下

  • 如果 gcc 只找到了静态库版本,则使用静态链接
  • 如果 gcc 只找到了动态库版本,则使用动态链接
  • 如果 gcc 在同一目录下同时找到了静态库版本和动态库版本,则默认使用动态链接。默认行为可以被下列选项所更改
    • -static这个选项强制让所有的链接都使用静态链接,这很可能会在链接中报错,因为 glibc,libstdc++等最基础的库,通常在系统中只有动态库版本,压根没有静态库版本,并且这基础库并不适合静态链接
    • 更精细的强制链接选项: -Wl,-Bstatic指示跟在后面的-l选项都使用静态链接,-Wl,-Bdynamic指示跟在后面的-l选项都使用动态链接,在后面还可以被这类选项进行更改。注意这种用法需要保证在最后生效的是-Wl,-Bdynamic,这是为了最后动态链接glibc等基础库而准备的
1
2
gcc test.cpp -L. -Wl,-Bstatic -ltest1 -Wl,-Bdynamic -ltest2
gcc test.cpp -L. -Wl,-Bdynamic -ltest2 -Wl,-Bstatic -ltest1 -Wl,-Bdynamic

例如基于精细的强制链接选项,在 Makefile 中可以如下实现,先分类,链接时把静态库放在前面,把动态库放在后面

1
2
3
4
LIBS += -l<auto-link-lib>
STATIC_LIBS += -l<static-lib>
DYN_LIBS += -l<dynamic-lib>
LDFLAGS := ${LIBS} -Wl,-Bstatic ${STATIC_LIBS} -Wl,-Bdynamic ${DYN_LIBS}

注意: 对于静态库,其实并不是必须要以-lxxx格式调用它,也可以简单粗暴地直接把.o或者.a文件和源文件放在一起编译。

链接库的示例

这里首先将 MY_LD_LIBRARY_PATH 环境变量写入 rpath 中,这是最优先的查找顺序(编译时和运行时),之所以这样做,是因为我的系统中存在多个 g++,我希望使用高版本的 g++而非系统自带的低版本 g++,因此在 MY_LD_LIBRARY_PATH 中直接写入高版本 g++对应的标准库路径,可以避免错误链接。

虽然很多系统自带的 gcc 版本超级低,但仍然不建议直接升级系统自带的 gcc,因为这很可能导致系统崩溃,并且升级过程需要 root 权限。

  1. lib/static只找到静态库时,使用静态链接

    1
    2
    3
    g++ -Wl,-rpath=$MY_LD_LIBRARY_PATH src/main1.o -Llib/static -lfoo -o test_static_1

    g++ -Wl,-rpath=$MY_LD_LIBRARY_PATH src/main1.o lib/static/libfoo.a -o test_static_2

  2. lib/shared只找到动态库时,使用动态链接

    1
    g++ -Wl,-rpath=$MY_LD_LIBRARY_PATH src/main1.o -Llib/shared -lfoo -o test_shared_1

  3. lib/all同时找到两种库的时候,默认动态链接,可以使用选项改成静态链接

    1
    2
    3
    g++ -Wl,-rpath=$MY_LD_LIBRARY_PATH src/main1.o -Llib/all -lfoo -o test_shared_2

    g++ -Wl,-rpath=$MY_LD_LIBRARY_PATH src/main1.o -Llib/all -Wl,-Bstatic -lfoo -Wl,-Bdynamic -o test_static_3

补充

PIC 和 PIE

PIC( position-independent code ,位置无关代码)和 PIE(position-independent executable ,位置无关可执行文件)选项会引起目标代码生成中关于寻址方式的变化,允许更简单地实现重定位而共享加载的动态库二进制代码,以及使加载的代码不适用绝对地址而实现 ASLR(address space layout randomization,地址空间随机化)提升安全性。这在许多Linux平台上生成动态库时是需要的。

但是基于 Windows 系统自身的原因,实际上这个概念是多余的:

  • 因为 ABI 要求,32 位 Windows 不使用 PIC/PIE 。
  • 因为 ABI 要求,x86_64 位 Windows 总是相当于使用 PIC 。

在使用 MinGW-W64 的 gcc 等工具时,编译器可能忽略显式的 -fPIC/-fPIE-fpic/-fpie 选项,或者明确表述不支持这些选项。