Cpp构建和编译笔记——2.头文件和库
头文件
头文件的存在,目的是把接口和实现分离,便于多文件编程中的组织,比如
- 在多文件的项目中,把函数声明都集中到若干头文件中,在源文件中引用它们,便于跨文件的函数调用
- 在提供库的同时,我们也需要提供库的使用接口(头文件),通过头文件中的类和函数声明,用户可以知道如何使用这个库
- 在使用库的时候,首先需要在源代码中引用头文件,然后在链接步骤中链接需要的库文件
gcc 查找头文件
gcc 在编译过程中,预处理环节需要 include 相应的头文件,这里存在一个问题:如何找到头文件?
gcc
存在专门的选项:-Ipath
,也可以写成-I path
,带不带空格都可以,但是只能后接一个路径,如果使用多个路径就需要多个-I
,例如
1
gcc hello.c -I mydir1 -I mydir2
如果直接写完整路径加文件名,那么不存在查找文件的问题,但是如果使用的是不完整路径加文件名,则存在查找顺序的问题。在 include 语句中,双引号和尖括号引用头文件的查找顺序有一点区别:
- 双引号 include 的查找顺序:
- 使用
#include
的源文件所在的路径 -I
指定的路径- 环境变量
CPATH
、C_INCLUDE_PATH
或CPLUS_INCLUDE_PATH
包含的路径 - 内定路径
- 使用
- 尖括号 include 的查找顺序:
-I
指定的路径- 环境变量
CPATH
、C_INCLUDE_PATH
或CPLUS_INCLUDE_PATH
包含的路径 - 内定路径
注意:
- 查找顺序是重要的,如果两个路径下有同名的头文件,gcc 会使用先找到的那个,这很可能导致 bug
- 环境变量
CPATH
被 c/c++共用,C_INCLUDE_PATH
或CPLUS_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
通常是递归的,即一个头文件可能还包括另一个头文件,我们考虑如下的情景:
- A include B
- B include C
- C include D
那么为了将内容完整地拷贝到 A 中,需要首先完成 B 的头文件查找,查找顺序是:
- A 所在的位置
- 其它路径
然后完成 C 文件的查找,查找顺序是:
- B 所在的位置
- A 所在的位置
- 其它路径
然后完成 D 文件的查找,查找顺序是:
- C 所在的位置
- B 所在的位置
- A 所在的位置
- 其它路径
即按照递归顺序倒序查找所在的目录。
gcc 与 g++区别
GNU gcc 是一个整体,但是在处理
c/c++文件时,我们可以分别采用gcc
或者g++
命令,
两者效果类似,可以理解为g++
命令在gcc
命令之上又针对
c++进行了一层封装, 因此区别主要是对待 c/c++文件时的细节处理,例如:
- g++ 对于.c/.cpp 结尾的文件全都默认当作 c++文件处理。
- gcc 对于.c 视作 c 文件,对于.cpp 视作 c++文件处理。
- 对于 STL 标准库,如果使用 g++会自动链接进来,如果使用 gcc 则需要加参数-lstdc++显式地完成,并且可能有细节差异。
- 对于预定义宏,两者支持的宏不完全一样。
涉及的其他细节我们不用关注,只需要关注最重要的标准库,我们用如下例子体现:
1
2
3
4
5
6//helloworld2.cpp
int main(){
std::cout<<"hello world,cpp\n";
return 0;
}
首先使用 gcc 完成 1
2
3
4
5
6
7
8gcc helloworld2.cpp
# 会报错iostream cout各种未定义
gcc helloworld2.cpp -lstdc++ -o helloworld2
# 链接STL后可以顺利进行
./helloworld2
hello world,cpp
然后使用 g++完成,无需显式链接即可自动进行。 1
2
3
4g++ helloworld2.cpp -o helloworld2
./helloworld2
hello world,cpp
补充
C 语言提供的头文件例如 stdio.h,在 C++层面又提供了 cstdio
进行对应的封装,需要留意的是:这两者并不完全相同,可能在 cstdio
里面触发或修改某些宏,导致运行结果不完全一致。(例如在 MinGW
上,下面两个头文件的区别会导致程序对 long double 类型处理时有不同)
1
2
3
建议 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
int getnum(){
return 1;
}
void sayhello(){
std::cout<<"hello,I am foo1\n";
}
// foo2.cpp
int getnum(){
return 2;
}
void sayhello(){
std::cout<<"hello,I am foo2\n";
}
这里foo1.cpp
和foo2.cpp
都对头文件foo.h
中的两个函数getnum()
,sayhello()
分别进行了实现。
我们将在main.cpp
中引用头文件foo.h
,用多种方式调用这个库
foo 1
2
3
4
5
6
7
8// main.cpp
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
4ldd ./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
8SEARCH_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");
完整的路径查找顺序如下:
- 编译时,按照如下顺序查找需要的库文件
- 编译选项指定的编译时库搜索路径
-L
,多个路径需要用多个-L
- 环境变量
LIBRARY_PATH
指定编译时库的搜索路径 - 内定路径
/lib,/usr/lib
等
- 编译选项指定的编译时库搜索路径
- 运行时,可执行文件将要执行时,按照如下顺序查找需要的动态库文件
RPATH
:编译选项指定的运行时动态库搜索路径-Wl,-rpath=your_lib_dir
,当指定多个运行时动态库搜索路径时,路径之间用冒号:
分隔- 环境变量
LD_LIBRARY_PATH
:指定的运行时动态库搜索路径(通常在普通用户的.bashrc
中会进行相应的修改) RUNPATH
:和RPATH
类似,但是如果设置了RUNPATH
则RPATH
无效,并且RUNPATH
查找优先级低于LD_LIBRARY_PATH
- 在系统配置文件
/etc/ld.so.conf
中指定的动态库搜索路径 - 内定路径
/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 | gcc test.cpp -L. -Wl,-Bstatic -ltest1 -Wl,-Bdynamic -ltest2 |
例如基于精细的强制链接选项,在 Makefile 中可以如下实现,先分类,链接时把静态库放在前面,把动态库放在后面
1 | LIBS += -l<auto-link-lib> |
注意:
对于静态库,其实并不是必须要以-lxxx
格式调用它,也可以简单粗暴地直接把.o
或者.a
文件和源文件放在一起编译。
链接库的示例
这里首先将 MY_LD_LIBRARY_PATH 环境变量写入 rpath 中,这是最优先的查找顺序(编译时和运行时),之所以这样做,是因为我的系统中存在多个 g++,我希望使用高版本的 g++而非系统自带的低版本 g++,因此在 MY_LD_LIBRARY_PATH 中直接写入高版本 g++对应的标准库路径,可以避免错误链接。
虽然很多系统自带的 gcc 版本超级低,但仍然不建议直接升级系统自带的 gcc,因为这很可能导致系统崩溃,并且升级过程需要 root 权限。
在
lib/static
只找到静态库时,使用静态链接1
2
3g++ -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在
lib/shared
只找到动态库时,使用动态链接1
g++ -Wl,-rpath=$MY_LD_LIBRARY_PATH src/main1.o -Llib/shared -lfoo -o test_shared_1
在
lib/all
同时找到两种库的时候,默认动态链接,可以使用选项改成静态链接1
2
3g++ -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
选项,或者明确表述不支持这些选项。