1.库的理解
库就是写好的现有的,成熟的,可复用的代码。现实中每个程序都要依赖很多基础的底层库,不可能每个人的代码都从零开始,因此库的存在意义非同寻常。
本质上来说库是一种可执行代码的二进制形式,是预编译代码的集合,可以被程序重新使用,能够被操作系统载入内存执行。
库有两种:静态库(.a、.lib)和动态库(.so、.dll)。所谓静态、动态是指链接。具体来说,二者链接的时间点不同,代码被载入的时刻不同,具体详见下文。
回顾一下,将一个程序编译成可执行程序的步骤:
2.源文件、编译、链接到可执行文件
源代码要经过上图中预编译(Processing)、编译(Compilation)、汇编(Assembly)、链接(Linking)等步骤生成可执行文件。
2.1编译
(1)预编译,即预处理,主要处理在源代码文件中以“#”开始的预编译指令,如宏展开、处理条件编译指令、处理#include指令等。
(2)编译过程就是把预处理完的文件进行一系列词法分析、语法分析、语义分析以及优化后生成相应的汇编代码文件。
(3)汇编是将汇编代码转变成机器可以执行的指令。
至此,C/C++源代码文件经过预编译、编译和汇编直接输出目标文件(.o文件)。这个过程也就是编译器所做的事(即将高级语言翻译成机器语言),比如我们用C/C+语言写的一个程序可以使用编译器将其翻译成机器可以执行的指令及数据。
现代的编译器将一个源代码文件编译成一个未链接的目标文件,然后由链接器最终将这些目标文件链接起来形成可执行文件。
2.2链接
程序设计的模块化是人们一直在追求的目标,因为当一个系统十分复杂的时候,我们不得不将一个复杂的系统逐步分割成多个小的系统以达到各个突破的目的。一个复杂的软件也是如此,人们把每个源代码模块独立地编译,然后按照需要将它们“组装”起来,这个组装模块的过程就是链接(Linking),链接的主要内容就是把各个模块之间相互引用的部分都处理好,使得各个模块之间能够正确地衔接。
3.静态链接与动态链接
3.1静态链接
最基本的静态链接过程如下图所示。每个模块的源代码文件(如.c)文件经过编译器编译成目标文件(Objet File,一般扩展名为.o或.obj), 目标文件和库(Library)一起链接形成最终可执行文件。
而最常见的库就是运行时库(Runtime Library),它是支持程序运行的基本函数的集合。库其实是一组目标文件的包,就是一些最常用的代码编译成目标文件后打包存放。
现代的编译和链接过程也并非想象中的那么复杂,它还是一个比较容易理解的概念。比如我们在程序模块main.c中使用另外一个模块func.c中的函数foo()。我们在main.c模块中每一处调用foo的时候都必须确切知道foo这个函数的地址,但是由于每个模块都是单独编译的,在编译器编译main.c的时候它并不知道foo函数的地址,所以它暂时把这些调用foo的指令的目标地址搁置,等待最后链接的时候由链接器去将这些指令的目标地址修正。如果没有链接器,须要我们手工把每个调用foo的指令进行修正,然后填入正确的foo函数地址。当func.c模块被重新编译, foo函数的地址有可能改变时,那么我们在main.c中所有使用到foo的地址的指令将要全部重新调整。这些繁琐的工作将成为程序员的噩梦。使用链接器,你可以直接引用其他模块的函数和全局变量而无须知道它们的地址,因为链接器在链接的时候,会根据你所引用的符号foo, 白动去相应的func.c模块查找foo的地址,然后将main.c模块中所有引用到foo的指令重新修正,让它们的目标地址为真正的foo函数的地址。这就是静态链接的最基本的过程和作用。
3.2动态链接
静态链接这种方法的确很简单,原理上很容易理解,实践上很难实现,在操作系统和硬件不发达的早期,绝大部分系统采用这种方案。
随着计算机软件的发展,这种方法的缺点很快就暴露出来了,那就是静态连接的方式对于计算机内存和磁盘的空间浪费非常严重。特别是多进程操作系统情况下,静态链接极大地浪费了内存空间。
想象一下每个程序内部除了都保留着printf()函数、scanf()函数、strlen()等这样的公用库函数,还有数量相当可观的其他库函数及它们所需要的辅助数据结构。
此外,静态链接对程序的更新、部署和发布也会带来很多麻烦,即一旦程序中有任何模块更新,整个程序就要重新链接、发布给用户。
比如一个程序有20个模块,每个模块1 MB,那么每次更新任何一个模块,用户就得重新获取这个20 MB的程序。如果程序都使用静态链接,那么通过网络来更新程序将会非常不便,因为一旦程序任何位置的一个小改动,都会导致整个程序重新下载。
要解决空间浪费和更新困难这两个问题最简单的办法就是把程序的模块相互分割开来,形成独立的文件,而不再将它们静态地链接在一起。
简单地讲,就是不对那些组成程序的目标文件进行链接,等到程序要运行时才进行链接。也就是说,把链接这个过程推迟到运行时再进行,这就是**动态链接(Dynamic Linking)**的基本思想。同样举个小例子:
还是以Program1和Program2为例,假设我们保留Programl.o、Program2.o和Lib.o三个目标文件。当我们要运行Program1这个程序时,系统首先加载Program1.o,当系统发现Program1.o中用到了Lib.o,即Program1.o依赖于Lib.o,那么系统接着加载Lib.o,如果Program1.0或Lib.o还依赖于其他目标文件, 系统会按照这种方法将它们全部加载至内存。所有需要的目标文件加载完毕之后,如果依赖关系满足,即所有依赖的目标文件都存在于磁盘,系统开始进行链接工作。这个链接工作的原理与静态链接非常相似,包括符号解析、地址重定位等。完成这些步骤之后,系统开始把控制权交给Program1.o的程序入口处,程序开始运行。这时如果我们需要运行Program2,那么系统只需要加载Program2.o,而不需要重新加载Lib.o,因为内存中已经存在了一份Lib.o的副本,系统要做的只是将Program2.0和Lib.o链接起来,不同于静态链接,还需要再次加载Lib.o的副本。
4.静态库与动态库对比
结合上述静态链接与动态链接的对比,可以得到下面静态库与动态库的比较:
***静态库:包含在编译时链接到用户程序的代码,函数和数据都被编译进一个二进制文件(通常扩展名为.LIB)。在使用静态库的情况下,编译链接生成可执行文件时,链接器从库中复制这些函数和数据并把它们和应用程序的其它模块组合起来创建最终的可执行文件(.exe文件)。生成的可执行文件保留了自己的库代码副本。
***动态库(或共享库)包含旨在由多个程序共享的代码,由此动态库也称为共享库。库中的内容在运行时加载到内存中。每个可执行文件不维护其库的复制。使用它的时候往往提供两个文件:一个引入库和一个DLL。引入库包含被DLL导出的函数和变量的符号名,DLL包含实际的函数和数据。在编译链接可执行文件时,只需要链接引入库,DLL中的函数代码和数据并不复制到可执行文件中,在运行的时候,再去加载DLL,访问DLL中导出的函数。
这是使用静态库与动态库的说明。由下图可以看到静态库包含在可执行文件中。而动态库只需要在程序中创建一个符号表(库代码中引用的函数、变量)。
在运行时,动态库在现代操作系统中只被加载到内存中一次,并在依赖它的所有程序之间共享。相反,当使用静态库时,每个可执行文件都必须将库代码加载到内存中。当有多个可执行文件运行时,前者可以提高内存利用率。下图可以解释这种比较。
使用静态库会导致两个明显的缺点:
1. 增加应用程序的大小。如果应用程序包含多个可执行文件,问题会变得更糟。您最终可能会保留同一个库的多个副本。
2. 修改/升级库代码需要重新运行应用程序其他部分的编译/链接。这可能是部署/维护目的的痛苦。大多数时候,一个(非接口相关的)动态库升级不需要重新编译其他部分。
通常,由于上述原因,人们多数时候倾向于选择动态库而不是静态库。然而,动态库并不完美。他们对开发人员有自己的障碍——需要额外关注安装。与生成整体包的静态库不同,动态库必须位于适当的位置以确保可执行文件可以在运行时找到库。
-----静态库在程序编译时会被连接到目标代码中,程序运行时将不再需要该静态库,因此体积较大。
-----动态库在程序编译时并不会被连接到目标代码中,而是在程序运行是才被载入,因此在程序运行时还需要动态库存在,因此代码体积较小。动态库的好处是,不同的应用程序如果调用相同的库,那么在内存里只需要有一份该共享库的实例。带来好处的同时,也会有问题!如经典的DLL Hell问题。
静态和动态 C++ 库之间的差异10条
序号 | 比较维度 | 动态库 | 静态库 |
1 | build程序 (编译+链接) | 编译时:不需要 链接时:需要 | 编译时:需要 链接时:不需要 (链接发生在构建使用静态库的客户端可执行文件时) |
2 | 二进制文件的本质 | 没有启动例程的可执行文件。 包含已解析的引用。 | 目标文件的存档。 所有部分都存在,但大多数引用未解决(本地引用除外) |
3 | 生成可执行文件后需要吗? | 需要 动态库需要与可执行文件一起打包,并且必须在可执行文件开始运行时调用(更具体地说,调用动态库提供的函数) | 不需要 仅在构建可执行文件在链接阶段)才需要静态库。在运行可执行文件时不再需要,因为库代码已嵌入在应用程序中。 |
4 | 磁盘空间效率:磁盘上应用程序之间的代码共享 | 共享度高 同一个动态库可以在磁盘上的多个可执行文件之间共享。 | 共享度低 每个可执行文件都需要链接它的静态库的单独副本。这会导致磁盘上出现大量二进制文件副本,尤其是资源受限的移动设备。但是,如果每个应用程序只使用整个静态库的一小部分,磁盘空间效率仍然可以与单个大型 DLL 竞争。 |
5 | 内存效率 | 高 许多现代操作系统会尝试将动态库代码一次加载到内存中,并在所有需要它的应用程序之间共享。例如,一个 http 网络堆栈可能会在你的日历和笔记本应用程序之间共享。 | 低 如果 http 网络堆栈位于静态库中,则每个需要此功能的应用程序都将加载它自己的网络堆栈副本,并通常会影响运行时内存。 |
6 | 版本控制问题 | 可能存在 当应用程序使用的动态库版本与操作系统上存在的旧/新版本库冲突时,你可能会遇到问题。 | 不存在 由于所有库功能都链接到应用程序中,因此系统上的其他应用程序是否使用不同版本的静态库并没有关系。 |
7 | 提供更新/补丁 | 方便 如果用户希望使用动态库的新(ABI 兼容)版本对应用程序打补丁,他们只需从你那里获取一个新的 dll 并仅修补该 DLL,而无需build整个应用程序。 | 不太方便 整个应用程序将需要重新构建和修补。这对大型应用程序来说是个大问题,因为现在你需要通过网络提供更大的完整的更新文件。 |
8 | 控制加载 | 是 在某些系统中,应用程序通过系统调用(如(Windows 上的 LoadLibrary))明确控制何时加载和卸载库功能。这有助于在资源受限的系统上以有效的方式管理应用程序的内存。 | 不是 当应用程序启动时,整个库被加载到进程空间,并一直保留到应用程序关闭。 |
9 | 打包 | 复杂 在大多数系统中,需要一个单独的步骤来为应用程序创建清单/依赖清单并将其打包。 | 简单 默认情况下与应用程序/可执行文件本身一起分发 - 无需单独打包。 |
10 | 开发过程中的适用性 | 好 只有动态库中的功能需要重新编译。 | 繁琐 整个应用程序将需要重新编译。对于大型应用工具或像 Office 这样大的应用程序,如果所有功能都静态链接而不是在单独的 DLL 中,则可能需要数小时。 |
5.静态库与动态库的创建与使用
5.1 创建和使用静态库
在这个例子中,我们将创建一个具有一个倒数函数的lib库。库源包含头文件 my_math.h 和源文件 my_math.cpp:
#ifndef H_MY_MATH
#define H_MY_MATH
// my_math.h
double reciprocal(double d);
#endif // H_MY_MATH
#include "my_math.h"
//my_math.cpp
double reciprocal(double d) {
return 1.0 / d;
}
#include "my_math.h"
#include <iostream>
// main.cpp
int main(){
std::cout << reciprocal(2.0) << std::endl;
return 0;
}
头文件 my_math.h 包含在 main.cpp 中,它从库中调用函数:
在第一次编译中,我们将 my_math.cpp 视为一个普通的源文件,一切都按预期的那样:
g++ -c main.cpp -o main.o
g++ -c my_math.cpp -o my_math.o
g++ main.o my_math.o -o a.out
./a.out
0.5
现在让我们将 my_math 打包为静态库。该过程包括 2 个步骤。第 1 步是使用上面相同的命令生成目标文件 my_math.o。第 2 步涉及使用ar(Linux ar chive 实用工具)创建库文件:
ar cr libmy_math.a my_math.o
“cr”标志表示创建一个新的静态库文件。它后面首先是输出文件名的请求名称。注意输出的名称是“libmy_math.a”。在 Linux 中将文件命名为 libXXX.a 作为静态库是一种惯例,请务必这样做。当使用该库时,命令行工具实际上依赖于此约定以使链接器正常工作。
现在我们要使用静态库文件。一种方法是在 g++/gcc 链接命令中将该文件与其他目标文件放在一起。
g++ main.o libmy_math.a -o a.out
另一种更常用的方法是使用 (-L) 和库名称 (-l) 显式指定库路径:
g++ main.o -L. -l my_math -o a.out
这告诉编译器在路径 (.) 中查找名称为 libmy_math.a 的库。注意这里我们使用 -l my_math。链接器会将其视为指定文件名 libmy_math.a(记住我们刚才谈到的创建库的命名约定)。
我们可以通过删除库并运行来验证库是否已被复制到可执行文件中:
rm libmy_math.a
./a.out
0.5
的确有用。我们刚刚创建了一个静态库并在我们的程序中使用它,验证了静态库的打包及使用。
5.2 创建和使用动态库
这次让我们使用相同的示例代码,而不是创建一个动态库。这是命令:
g++ -shared -o libmy_math.so my_math.o
“-shared”标志指示生成共享库。同样,输出文件命名约定 libXXX.so 是必须的,稍后将由链接器使用。
类似于静态库,我们有两种方式来使用它。1. 将其作为链接器/编译器的输入。2.明确指定库位置(-L)和名称(-l):
g++ main.o my_math.so -o a.out
# or
g++ main.o -L. -lmy_math -o a.out
简单吧?让我们运行它:
./a.out
./a.out: error while loading shared libraries: libmy_math.so: cannot open shared object file: No such file or directory
糟糕,我们遇到了一个错误(生成了任何一个可执行文件)。运行时试图找到一个名为 libmy_math.so 的共享库,但找不到。发生了什么以及如何解决?
事实证明,用户必须向运行时的可执行文件或操作系统提供提示才能找到共享库 (libmy_math.so)。在 Linux 中有两种方式:
将共享库路径附加到环境变量 LD_LIBRARY_PATH。
在构建可执行文件时使用-rpath标志指定共享库路径。
让我们测试一下:
将库路径添加到 LD_LIBRARY_PATH:
export LD_LIBRARY_PATH=$LD_LIBRARY_PATH:/home/cpp_tutorial/static_library
./a.out
0.5
在运行时,OS 会搜索 LD_LIBRARY_PATH 中的每个路径(以“:”分隔)以找到它需要的动态库。通过将路径附加到 LD_LIBRARY_PATH,我们修复了它。
2. 使用-rpath标志:
g++ main.o -L. -lmy_math -o a.out -Wl,-rpath,/home/cpp_tutorial/static_library
./a.out
0.5
这里的“-Wl 标志”意味着它后面的内容(以逗号分隔的标志列表)将被传递给链接器。在这种情况下,“-rpath /home/cpp_tutorial/static_library”被传递给链接器。链接器将此路径信息插入到可执行文件 (a.out) 自己的搜索路径中。这也有效。
比较这两种方法,修改 LD_LIBRARY_PATH 涉及更改影响所有程序的全局变量。使用-rpath通常是首选方式,因为它是本地更改,不会改变其他可执行文件的行为
参考资料
https://blog.csdn.net/qq_41073715/article/details/118516662
https://www.acodersjourney.com/cplusplus-static-vs-dynamic-libraries/
https://domiyanyue.medium.com/c-development-tutorial-4-static-and-dynamic-libraries-7b537656163e