在编写程序时,我们为了实现代码复用,通常会把通用的功能模块化,编译成库文件,供其他程序使用。在 C++ 中,库文件分为静态库和动态库两种。
1. 静态库简介#
静态库在 Windows 上通常是 .lib
文件,在 Linux 或 macOS 上是.a
(archive) 文件。它是一组目标文件(.o
或 .obj
文件)的集合。在程序的链接阶段,链接器会从静态库中提取出程序所需要的所有代码和数据,并将它们完整复制到最终的可执行文件中。编译完成后,即使静态库不存在了,程序也是可以正常运行的。
静态库的优点是独立性强,发布简单。由于所有依赖的代码都被复制到了可执行文件中,所以程序在运行时不再需要外部库文件,可以独立运行,程序的部署和分发更加简单。因为所有代码都在一个文件中,所以可以减少程序在启动时或者调用库函数时的开销。
静态库也有明显的缺点,那就是生成的可执行文件体积较大。因为每个使用该库的程序都包含了一份完整的库代码副本,如果多个程序都使用了同一个静态库,那么在磁盘和内存中就会有多份重复的代码,造成空间浪费。如果静态库进行更新,那么所有使用这个旧版本静态库的程序都必须重新编译、链接,才能得到更新后的代码,这是非常麻烦的。所以静态库在小型软件用得比较多。
2. 动态库简介#
动态库在 Windows 上是.dll
文件,在 Linux 上是.so
文件,在 macOS 上是 .dylib
文件。动态库在程序的链接阶段不会被复制到可执行文件中,链接器只是在可执行文件中记录下对该动态库的引用信息(比如库的名字和函数签名)。当程序运行时,操作系统会根据这些记录的信息,在内存中找到并加载这个动态库。因此所有使用该动态库的程序在内存中是共享同一份库的实例。
多个程序共享同一个动态库,磁盘和内存中只需要存在一份库的副本,大大减少了资源的占用。如果动态库更新了,只需要替换掉系统中的旧库文件即可,所有依赖于它的程序在它下次运行时,就会加载新版本的库,而不需要编译程序本身,但前提是要保持接口兼容。因此大型程序中,通常会拆分出多个较小的动态库模块,按需加载,实现插件化功能。
当然,动态库也是有缺点的。发布程序时,除了可执行文件本身,还必须同时提供其依赖的所有动态库文件,如果用户系统中缺少某个动态库或者版本不兼容,程序将无法运行。这就是我们在 Windows 中有时会碰到“缺少 xxx.dll”的错误。程序启动时需要由操作系统去查找、加载和链接动态库,这会带来一定的性能开销。
3. 静态库的创建#
以在 Linux 环境中使用g++编译器创建一个简单的数学计算库为例,静态库的创建一般流程如下:
- 编写库文件代码,这通常需要包含头文件和源文件。头文件用来声明库对外提供的接口(函数原型),使用我们编写的库则需要包含这个头文件:
同时,还需要包含源文件,源文件是库函数的具体实现,是不对外暴露的,使用这个静态库的用户无须关心这部分的代码:
cpp#ifndef MATH_LIB_H #define MATH_LIB_H // 函数声明:一个加法函数 int add(int a, int b); // 函数声明:一个减法函数 int subtract(int a, int b); #endif // MATH_LIB_H
cpp#include "math_lib.h" // 加法函数的实现 int add(int a, int b) { return a + b; } // 减法函数的实现 int subtract(int a, int b) { return a - b; }
- 将源文件编译成目标文件(
.o
),我们使用g++
的-c
选项,这个选项告诉编译器只进行编译,生成目标文件,而不进行链接。执行成功后,目录下会多一个
shellg++ -c math_lib.cpp -o math_lib.o
math_lib.o
文件。如果有多个.cpp
文件,则需要都将它们编译成对应的.o
文件。 - 使用
ar
工具将一个或者多个目标文件打包成静态库,静态库的命名一般遵循lib<库名>.a
的规范,例如我们的库叫math
,那么库文件应该叫libmath.a
shell# ar 是一个用于创建和管理归档文件的工具 # rcs 是三个选项的组合: # r: replace,若库中已存在同名目标文件,则替换它;若不存在,则添加。 # c: create,如果库文件不存在,就创建一个。 # s: create symbol index,为库创建一个索引,可以加快链接速度。 # libmath.a 是我们想要创建的静态库文件名 # math_lib.o 是要打包进去的目标文件 ar rcs libmath.a math_lib.o
- 将静态库拷贝到系统默认存放库目录下(
/usr/lib
或/usr/local/lib
)这部分是可选的,这通常只用于安装系统级的、供所有用户和程序使用的共享库。如果不拷贝到系统目录,可以在编译主程序时,使用-L
选项指定库文件所在的路径。 - 编写主程序并使用静态库,我们编写如下主程序:
在编译时,如果我们编写的库被拷贝到系统目录中,那我们通常需要使用
cpp#include <iostream> #include "math_lib.h" // 包含我们库的头文件 int main() { int x = 20; int y = 10; // 调用静态库中的函数 int sum = add(x, y); int diff = subtract(x, y); std::cout << "The sum is: " << sum << std::endl; std::cout << "The difference is: " << diff << std::endl; return 0; }
-L
来指定:
shell# g++: 编译器 # main.cpp: 主程序源文件 # -o main_app: 指定输出的可执行文件名为 main_app # -I. : -I (大写i) 选项用于指定头文件的搜索路径。'.' 表示当前目录。 # 这样编译器才能找到 #include "math_lib.h"。 # -L. : -L 选项用于指定库文件的搜索路径。'.' 表示当前目录。 # 这样链接器才能找到 libmath.a。 # -lmath: -l (小写L) 选项用于指定要链接的库的名称。 # 编译器会自动在库名前加上 "lib",在库名后加上 ".a" 或 ".so"。 # 所以 -lmath 就对应着 libmath.a。 g++ main.cpp -o main_app -I. -L. -lmath
4. 动态库的创建#
动态库和静态库的创建流程相似,动态库的一般创建流程如下:
- 和静态库一样,也是需要编写库文件,包括头文件和源文件。
- 因为动态库运行时会被加载到内存的任意位置,所以它的代码和数据引用不能使用绝对地址,必须使用相对地址,这种代码被称为“位置无关码”(PIC)。需要使用
g++
的-fPIC
标志来生成这种代码。执行后,会生成名为
shellg++ -fPIC -c math_lib.cpp -o math_lib.o
math_lib.o
的目标文件。 - 将刚才生成的位置无关的目标文件链接成一个共享库文件。假设库名为
math
,仍然遵循lib<name>.so
的命名约定。执行完毕后,当前目录下就会生成
shellg++ -shared -o libmath.so math_lib.o
libmath.so
文件,就是我们成功创建的动态库。 - 编写主程序并使用动态库,我们编写如下主程序:
进行编译:
cpp#include <iostream> #include "math_lib.h" // 包含我们库的头文件 int main() { int x = 20; int y = 10; // 调用动态库中的函数 int sum = add(x, y); int diff = subtract(x, y); std::cout << "The sum is: " << sum << std::endl; std::cout << "The difference is: " << diff << std::endl; return 0; }
运行时可能会看到一个这样的错误:
shell# -I. 指定头文件搜索路径为当前目录 # -L. 指定库文件搜索路径为当前目录 # -lmath 指定链接名为 "math" 的库 (即 libmath.so) g++ main.cpp -o main_app -I. -L. -lmath
因为在运行时,操作系统需要知道去哪里找
plaintexterror while loading shared libraries: libmath.so: cannot open shared object file: No such file or directory
libmath.so
。编译时使用的-L.
只是告诉编译器在编译期间去哪里检查库,这个路径信息默认不会被保存到最终的可执行文件中。一般来说有以下三种方法解决这个问题:- 将库所在的目录临时添加到
LD_LIBRARY_PATH
环境变量中。
shell# 将当前目录(.)添加到该环境变量中 export LD_LIBRARY_PATH=.:$LD_LIBRARY_PATH # 然后再次运行程序 ./main_app
export
命令只在当前的终端会话中有效。关闭终端后需要重新设置。因此这是一个临时性的设置,常用于开发当中。 - 我们可以在编译
main_app
时,把库的路径信息直接“写死”到可执行文件里。这样运行时,程序就知道去哪里找库了。我们使用链接器选项-rpath
。这样程序在运行时会永远在这个路径寻找动态库,比较适用于发布的程序当中。
shell# -Wl,<option> 是告诉g++将<option>传递给链接器(ld) # -rpath,. 表示将当前目录(.)作为运行时库的搜索路径 g++ main.cpp -o main_app -I. -L. -lmath -Wl,-rpath,.
- 对于要被系统上很多程序使用的通用库,可以将其安装到标准的系统库目录中,如
/usr/local/lib
、/usr/lib
和/lib
。
shell# 1. 将库文件拷贝到系统目录 (需要root权限) sudo cp libmath.so /usr/local/lib/ # 2. 更新系统的动态链接器缓存 sudo ldconfig # 3. 现在,你可以在任何地方编译和运行你的程序了(编译时仍需-l,但无需-L) g++ main.cpp -o main_app -lmath
- 将库所在的目录临时添加到
5. 动态库的加载与执行过程#
当一个文件被执行时,其内部对动态库的依赖关系必须在运行时得到解析和满足,这个过程是由操作系统的动态链接器来完成的:
- 启动加载器:操作系统内核首先会读取可执行文件(Linux中为ELF格式)的头部,里面会有一个叫
.interp
的段,该段指定了动态链接的解释器路径,内核会加载并启动这个动态链接器,并将程序的控制权和相关信息移交给它。 - 依赖发现与库搜索:动态链接器接管后,会读取可执行文件的
.dynamic
段,此段包含一系列的DT_NEEDED
条目,每个条目都制定了一个必须的共享库名称,动态链接器会按一下顺序去搜索:- 运行时搜索路径 (
rpath
、runpath
):这是通过在链接时使用-rpath
或-runpath
标志指定的。 LD_LIBRARY_PATH
环境变量:用户可以通过设置此变量,临时性地将一个或多个目录加入到搜索路径中。ld.so.cache
缓存:搜索/etc/ld.so.cache
文件。这是一个由ldconfig
命令生成的二进制缓存,它索引了配置文件/etc/ld.so.conf
及其包含的子目录中所有已知的库及其路径。通过缓存进行查找的速度极快。- 默认库路径:如果以上路径均未找到,链接器会搜索标准的系统默认路径,通常包括
/lib
、/usr/lib
、/lib64
、/usr/lib64
。
- 运行时搜索路径 (
- 符号解析与重定位:由于程序代码中对动态库函数或变量的引用只是一个符号名称或者占位符,动态链接器需要将这些符号引用修正为它们在虚拟内存中的实际地址,这个过程主要依赖以下两个机制:
- 全局偏移表 (GOT - Global Offset Table): 存储外部全局变量和函数的绝对地址,代码通过访问GOT来间接访问这些外部符号。
- 过程链接表 (PLT - Procedure Linkage Table): 对外部函数的第一次调用会通过PLT跳转到动态链接器的一段代码,该代码负责查找函数的实际地址,并填入GOT中,后续对同一函数的调用就可以通过PLT直接跳转到GOT记录的实际地址,无需再经过链接器
- 所有的库成功加载、重定向和初始化后,动态链接器就会将执行控制权交给可执行文件的主入口点。
6. 动态依赖与符号问题排查指南#
在我们平时的开发当中,经常会遇到找不到动态库或者找不到符号的问题,下面给出一些常见的排查方法。
运行时找不到动态库文件会报如下信息:
error while loading shared libraries: libxxx.so: cannot open shared object file: No such file or directory
plaintext遇到这种问题,我们可以使用ldd
命令来列出一个可执行文件或另一个动态库所依赖的所有共享库,并显式动态链接器找到的库路径。
ldd /path/to/your_executable
shell然后我们可以使用find
命令在整个系统中搜索该库文件,并确认它是否真实存在于磁盘上,以及它的具体位置。
# 2>/dev/null 是为了忽略权限不足的错误提示
find / -name "libxxx.so" 2>/dev/null
shell如果发现库文件存在但是ldd
找不到,说明其所在的目录不在动态链接器的搜索路径中,可以使用上面介绍的方法将其加入链接器的搜索路径。
还有一种常见的错误是库文件被成功加载了,但是在执行的过程中,程序需要调用某个函数或者访问某个变量时,发现该符号在库中不存在,典型的报错信息如下:
./your_executable: symbol lookup error: ./your_executable: undefined symbol: _Zxyz...
plaintext或者在编译的时候出现:
undefined reference to ...
plaintext遇到这种问题,可以使用nm
工具列出目标文件或者库文件中的符号,关键是使用 -D
选项来查看动态符号表,因为只有这里的符号才能被外部链接。
# 检查 libxxx.so 是否导出了你需要的符号
nm -D /path/to/libxxx.so | grep 'your_function_name'
shellC++为了支持函数重载,会把函数名修饰成复杂的符号,因此nm
显式的是修饰后的名字,为了查看原始的函数签名,可以使用c++fit
解码,可以将 nm
的输出通过管道传给 c++filt
来查看原始的函数签名。
# -n 表示 c++filt 尝试将输入作为符号名处理
nm -D /path/to/libxxx.so | c++filt -n
shell