动静态库
- 库的介绍
- 开发环境 & 编译器
- 库存在的意义
- 库的实现
- 库的命名
- 静态库制作和使用
- 总结
- 动态库的制作和使用
- 动态库的使用方法
- 方法一
- 方法二
- 方法三
- 库加载问题
- 静态库加载问题
- 动态库的加载问题
- 与位置无关码
- C/C++静态库下载方式
库的介绍
- 静态库:程序在编译链接的时候把库的代码链接到可执行文件中,程序运行的时候将不再需要静态库
- 动态库:程序在运行的时候才去链接动态库的代码,多个程序共享使用库的代码
在 Linux 下,静态库文件 通常以 .a
为后缀名;静态库文件 通常以 .so
为后缀名
- 可以在 Linux下的
/usr/lib64
路径中查看对应的系统库:
在 windows 下, 静态库文件 通常以 .lib
为后缀名;动态库文件 通常以 .dll
为后缀名
- 可以在windows中的C盘目录下:C:\Windows\System32,存放着大量的以
.dll
结尾的动态库:
Linux 系统中已经预装载了 C/C++ 的头文件和库文件。头文件提供函数的说明方法,库用于提供方法的实现。
头文件通常保存在 Linux 的 /usr/include
路径下:
头文件和库文件虽然没有保存在同一个目录下,但是不妨碍它们不能一起工作!
代码被编写好后,需要经过4个步骤才能翻译变成可执行文件,分别是:预处理、编译、汇编、链接
头文件就是在 预处理 阶段就被引入到代码中,当处理完 预处理、编译、汇编工作后才到 链接阶段。链接的本质其实就是链接库的操作!
开发环境 & 编译器
一般情况下,我们要使用一种语言进行开发的时候都需要安装对应的开发环境。例如:安装 VS2022 集成开发环境
安装这些环境实质是在安装 编译软件 与 安装对应开发语言对应的配套的 库文件 和 头文件。
- 在使用编译器编写代码时发生的 语法高亮 和 语法提示 也是因为包含了对应语言的头文件。编译器或者编辑器会将用户输入的内容,自动的不断的在被包含的头文件内进行搜索,自动提醒功能就是依赖头文件而来!
- 编译器包含有命令行模式和其他自动化的模式。就拿 VS2022来说,为什么在编写代码就能检测到语法问题?
这是在当用户在编写代码的时,编译器在后端的会创建多线程在对应的自动化模式下对代码进行检测(也就是在进行编译阶段的工作),不断的对代码语法进行检测,从而体现出检测语法的功能现象。
库存在的意义
库的存在就是提高开发者的效率!
诸如 IO类的功能、字符串相关功能、与数学相关的功能等等, 这些公共的用到比较多、比较频繁的功能,如果都让每个开发者都去实现的话,那么将会花费大量的时间和精力。
但是,如果将用得比较频繁的功能都全部封装成库,后面只需要将这些库下载下来,开发者直接去使用这些库,在这些库上直接调用接口,进行二次开发。将可以大大缩减开发时间,不用再去实现,也即是直接使用轮子!
库的实现
开头提到,库分为: 静态库 和 动态库
库的命名
库通常由 前缀 lib
+ 库名称 + 版本号 + 后缀 组成。
后缀要做分类:动态库 和 静态库
静态库后缀为:
.a
;
动态库后缀为:.so
;
示例:
在
/usr/lib64/
目录下的C++动态库:
libstdc++.so.6
这个库名称为:去掉前缀 lib
、去掉后缀 .so
、去掉版本号 6.0.19 ,最后所剩的 stdc++
就是库名!
静态库名称也是如此,只不过是去掉的后缀名称不一样,在这里就不再做演示。
提示:一般的云服务器只有动态库,静态库是需要下载的!
下面来模拟实现一个加减乘除的库:
注意:一般的库都是比较庞大的,这里为了方便介绍,所实现的内容都是简单易懂,是为了接下来实现库的来进行讲解!
首先创建 4 个文件分别是:myadd.c、myadd.h、mysub.c、mysub.h
myadd.c源文件实现加法功能;mysub.c源文件实现减法功能;两个头文件内部实现分别包含对应的函数声明:
同一目录下,可以直接将这些源代码同时翻译变成可执行文件,直接运行:
上面可以直接通过编译是因为源文件和头文件都在同一个目录文件下,如果不是在同一个目录下呢?如下情况:
直接编译会报错:
下面就来讲解一下库的封装过程:
思考一下,要怎么样将 myadd.c、myadd.h、mysub.c、mysub.h 变成一个库?
是直接将源代码和头文件一起打包给到其他人?
出于安全问题考虑,一般的库都不会将源文件直接打包给出,而是需要经过一些处理,我们接着往下看:
下面来想一个问题:库是什么时候被程序调用的?
代码被翻译分为4个过程:预处理、编译、汇编、链接
- 预处理阶段是将源文件所包含的头文件进行展开、宏定义的替换和去注释
- 编译是接收预处理后的代码,将其转换为汇编语言代码,进行语法分析、语义分析,并生成中间代码
- 汇编将编译阶段生成的汇编语言代码转换为机器语言指令,生成可重定位的目标二进制文件(以
.o
为后缀)- 链接器接收由汇编器生成的目标文件,以及需要用到的库文件
因此,可以将 myadd.c mysub.c 通过编译器编译成目标二进制文件就停下来,然后,将二进制目标文件和头文件传给调用这些函数的人即可。
将源文件编译成目标二进制文件的方法:
gcc -o test.o -c test.c
- 将 myadd.c 和 mysub.c 编译成目标二进制文件:
- 将 myadd.o 和 mysub.o 以及头文件拷贝到 orderfile 目录下:
- 再将 main.c 源文件编译成 目标二进制文件 main.o:
- 再一起将 目标二进制文件 通过 gcc 编译形成可执行文件:
传目标二进制文件的方法可以避免源代码泄漏和安全问题。
但是,一个库中的源文件不仅仅只有一两个,甚至有多个,那么传目标二进制文件的方法就很挫,也很不推荐。
解决方案:将目标二进制文件打包!
静态库制作和使用
静态库打包目标二进制文件的方式:利用 ar
指令
这里举例打包的库文件名字为 :mymath
ar -rc libmymath.a *.o # *.o 表示所有的目标二进制文件
-rc
选项:r 表示替换、c 表示创建
下面将 mylib 目录下的所有 .o
文件打包再和.h
一起拷贝到 ortherfile 目录下:
此时再将 main.c 进行编译:
直接报错了,报错内容发生在链接阶段。这是为什么呢?
原因:不管是自己实现的库还是网上下载的库,在编译器看来都是第三方库。诸如 C标准库、系统调用库 编译器是会自动识别的,在面对第三方库如果不主动告诉编译器,就算这个库就在当前文件下,编译器还是两眼一抹黑装作看不见!!!
因此,不管是自己实现的库还是网上下载的库,都需要事先告诉编译器。
方法如下:告诉 gcc 这个 libmymath.a
库的路径还有这个库的名称
gcc -o test main.c -L. -lmymath
- -L 选项:告诉编译器库所在的路径
- -l 选项:告诉编译器库的名称(去掉前缀、去掉版本号、去掉后缀的库名字)
使用上面两个选项可以不用带空格!!!
此时就可以将 main.c 源文件编译成功了。
就拿C标准库来说,IO库、字符库等等,所实现的头文件会有很多个,零零散散不好管理。
因此,会专门创建两个目录:include
、lib
。
include
目录用于存储各种的头文件lib
目录用于存储各种库文件
将实现的所有的库文件都放到 lib 目录下,所有的头文件的都放到 include 目录下:
当一个用户想要用到某个库时,从网上下载这两个文件目录即可,也比较直观。
但是接收的这两个目录文件怎么去直接编译呢?如下情况:
直接进行编译的话会报错:
找不到库文件所对应的头文件,此时就要用手动告诉 gcc 编译器头文间的路径,方法如下:
gcc -o test main.c -lmymath -L./lib -I./include
-I
(i的大写) 选项:表示告诉gcc库文件包含的头文件的路径
凡是有代价的,为了更好的管理将头文件全部放到同一个目录下。但是,带来的麻烦就是需要将头文件的路径告诉编译器!当然还包括:库的路径还有名称统统都要告诉编译器。
解决方式也有,将自己制作的库或者是从网上下载的第三方库 和 头文件一起存放到系统默认路径下,编译器也可以找到。但是,这些库不属于标准库的范畴内,最后的最后还是需要告诉 gcc 编译器库的名称的。
例如:将 libmymath.a 这个库拷贝到 /lib64 目录下,需要获取管理员权限!
此时,再编译就可以不用告诉 gcc 编译器库的所在路径了:
对于头文件也是可以拷贝到默认的系统路径下,在这里就不再演示。
对于任何软件来说,安装和卸载本质就是将这个软件的库和头文件拷贝到系统的特定的路径下,这样使用软件的时候就可以直接使用对于的库内函数的内容了!但是,安装到系统默认路径下都是要获取管理员权限的,这也是为什么每次下载安装软件的时候都需要 sudo 提权的原因!
总结
- 静态库的制作
- 将封装成库的所有源文件都编译成
.o
目标二进制文件- 通过
ar
指令的-rc
选项,将所有的.o
目标二进制文件打包形成lib().a
库文件,括号内为自己命名的库名称!- 创建 include 和 lib 目录,将库文件都放入到
lib
目录下;将头文件都放到 include 目录下
至此,就将 include
和 lib
目录打包形成压缩包就形成了一个静态的第三方库了。
- 静态库的使用
- 告诉 gcc 编译器,静态库的名称(去掉前缀、去掉后缀、去掉版本号的库名称),用到选项
-l
- 告诉 gcc 编译器,静态库的所在路径,用到选项
-L
- 告诉 gcc 编译器,静态库用到的头文件所在的路径,用到选项
-I
动态库的制作和使用
动态库的制作和静态库有相似的地方,接下来开始进行动态库的制作:
- 将源文件编译生成目标二进制文件
与静态库不同的是,形成目标二进制文件使用 gcc 编译选项是不一样的,这里还是拿上面的 myadd.c、mysub.c 为样例:
gcc -fPIC -c myadd.c
gcc -fPIC -c mysub.c
-fPIC
选项:表示形成的.o
目标二进制文件是一个与位置无关码
后续会谈到 与位置无关码的概念,现在先来看看结果:
这里 gcc 不用带上 -o
选项去命名一个 以 .o
文件,gcc 会自动生成对应源文件的目标二进制文件。
库中的源文件很多的话,也会形成很多的目标二进制文件,这一步也跟静态库一样需要进行打包!
- 将所有目标二进制文件进行打包
打包时,用的还是 gcc 编译器。没想到吧,居然不是 ar
指令,这也是动态库和静态库制作方式不同之一。这是因为,现在使用库的主流都是以动态库为主,所以 gcc 编译器会自带打包的选项:
gcc -shared -o libmymath.so *.o
-shared
选项:表示打的包为一个共享库或者说是一个动态库
- 将库和头文件进行分类处理
这一步和静态库一样,创建 include 目录 和 lib 目录。include 目录下存放着这个库需要用到的头文件;lib 目录下存放着库文件:
动态库的使用方法
这里,在使用动态库的方式可以和上面提到的静态库使用方法一样吗?
下面来测试一下:
现在通过在静态库方法来编译一下 main.c 源文件,告诉编译器库的路径、名称 还有头文件的路径:
编译是通过了,但是运行却报错。问题出在哪里呢?
问题出现在 OS 身上!
把库的路径、名称,头文件所在路径告诉的是 gcc 编译器,并不是操作系统。gcc 编译的时候当然能够通过,但是当可执行文件在运行的时候,OS 不知道去哪里找这个库 和 头文件(这个库没有在系统的默认路径下),因此程序就运行不起来!!
但是问题又来了:为什么在刚刚的静态库就能找到呢?
因为,静态库的链接原则是将用户使用的二进制代码直接拷贝一份到目标可执行文件中。这样做法就是牺牲了空间,会造成可执行文件内容变得很大。
处理 OS 找不到库的方式有 4 种,其中一种方法在静态库那里提过了,就是 将第三方库和自己实现的库拷贝一份放到 /usr/lib64
路径下,将包含头文件的 include 目录下的所有头文件放到 /lib64
路径下即可。在这里不再演示,下面主要来介绍其他三种方法:
方法一
- 通过环境变量的方式让 OS 找到库
将下载的第三方库 或是 自己编写库的路径添加到 LD_LIBRARY_PATH
环境变量下:
下面我以刚刚编写库的路径为例子:
注意:这里填写的路径是 绝对路径
export LD_LIBRARY_PATH=$LD_LIBRARY_PATH:/home/kunkun/c++_exercise/Test_4_17/orderfile/lib
环境变量设置好后,执行 test 可执行文件:
但是这个环境变量的方法是一种临时方案。当用户退出后,环境变量会初始化为最初样子,需要每次登录都配置以下很麻烦。
方法二
下面来介绍第二中方法:
- 在系统默认的库路径下,通过创建的软连接的方式来找到第三方库
注意:这个方式需要管理员权限!
sudo ln -s /home/kunkun/c++_exercise/Test_4_17/orderfile/lib/libmymath.so /lib64/libmymath.so
通过创建软连接的方式告诉OS 库的所在位置,是一种文件数据持久性的方法,一次设置永久使用。
方法三
- 使用配置文件方案
在 /etc
目录下存在一个 ld.so.conf.d
基本的配置文件的目录。在这个目录下,只需要创建一个文件 再将我们自己创建的库 或者 网上下载的第三方库的路径保存到这个文件即可。
示例:
- 创建文件,这个文件可以自己随便命名:
- 将第三方库的路径保存到刚刚创建的文件中,记得使用管理员权限
- 保存退出,最后将配置文件生效,需要管理员权限:
ldconfig
:让配置文件即刻生效
运行:
库加载问题
程序在翻译的链接阶段会使用到库,库分为两种:静态库 和 动态库。
当动态库和静态库同时存在时,操作系统会优先使用动态库链接方式!
为什么呢?因为操作系统为了节省空间的开销。
下面就来介绍一下,库是如何加载的。
静态库加载问题
- 静态库链接形成的可执行文件,本身就包含有静态库中各种函数实现方法
代码在链接阶段,将需要用到的函数实现方式从静态库中拷贝一份,跟原来源文件一起编译形成可执行文件。
这样的好处就是,当静态库被删除后,编译后的可执行文件还是可以运行。
但是,如果这个源代码依赖静中的态函数比较多,会造成这个可执行文件变得很大!毕竟是直接进行拷贝静态库中的代码。
诸多源文件在经过程序翻译形成可执行文件,如果都采用静态库这个方式。假设这些源文件都使用到静态库中的其中一个或多个相同的函数实现,那么在形成可执行文件的时候,这些可执行文件的都会独自保存一份这样的函数。这样不仅仅会造成磁盘的空间浪费,在加载这些可执行文件的时候也会造成内存负荷!可执行文件的大小摆在那里,程序被加载,是被加载到内存中代码区。
静态库不是没有优势,但是为了空间的使用率,还是不建议采用静态库。
这便提出了另一种节省空间的方式:动态库
动态库的加载问题
- 程序在运行时,操作系统会将可执行文件中外部符号,替换成库中的具体地址,完成动态库的加载
在编译器将代码链接库时,编译形成的可执行文件内部需要到的动态库的函数会替换成一些特殊的符号和一些偏移量(偏移量后面说),这些符号操作系统认识。
一个被编译好的可执行文件是被存放到磁盘中的。
可执行文件被运行起来后,操作系统会将这个可执行文件被加载到内存(物理内存)然后,操作系统会创建对应的进程 PCB ,创建进程地址空间,创建对应的页表 来维护 进程地址空间 和 物理内存空间 。
程序运行到一定的时候,在需要用到某个动态库中的函数时,会在对应进程地址空间中找这个函数的实现方法。此时,在进程地址空间种是找不到的,因为这个可执行文件在编译的时候,只有这个动态库对应函数的符号。此时,操作系统就要出手了,因为操作系统认识这些符号,OS就会在磁盘中默认路径去找这个动态库(这也是为什么前面提到 4 种方法中,要告诉操作系统动态库的存储位置)假设找到了这个动态库,OS就会将这个库加载到物理内存中。为了能够让这个进程找到这个函数的实现方式,OS就会把动态库所在的物理内存的地址,通过页表的方式映射到进程地址空间。
那么问题来了,映射到进程地址空的哪个区块呢?
进程地址空间存在那么一块区域,位于栈区和堆区之间的共享区。这里提一下,可执行文件的代码被加载到进程地址空间,是在代码区的!以上准备工作都做好后,此时程序再用到动态库中函数时,就会在进程的地址空间的 共享区去找对应函数实现方式。因为在共享区这个地方,进程可以通过页表的映射方式,直接找到动态库在物理内存的地址,进而,推进代码继续运行下去!
一个进程在使用动态库的方式就犹如上面所提到那般的过程。那么,如果两个可执行文件用到同一个库呢?甚至多个呢?这个动态库是都要被加载多次吗?
一个动态库被加载一次后,不用再次被加载多次。OS在检测到动态库被加载后,会直接将动态库所在的物理内存地址,直接通过第二个进程的页表映射到该进程地址空间中!
这也是为什么动态库能够节省内存空间资源的情况了!
下面来回答一下,上面提到的 与位置无关码
与位置无关码
动态库多大,取决于这个库所实现内容有多少。一个进程可能使用到多个动态库,进程运行多久了这个也不能确定。那么,此时就会面临这样的一个问题:进程地址空间的共享区,使用一个动态库就加载到共享区中,那么剩余的共享区空闲位置难以确定。后面加载的库在共享区内的位置,操作系统能知道。但是动态库具体的函数实现方法的地址,进程又是如何去正确的寻找呢?
还记得 与位置无关码 吗?
使用 gcc 编译器的 -fPIC
选项,就可以将源文件形成一个 与位置无关码 的目标二进制文件。
与位置无关码作用:gcc 编译器在汇编阶段,在源文件需要用到的库函数的代码中,记录一下这个函数在库中的相对于库的起始位置的偏移量。
有了这个偏移量能干嘛呢?
当一个进程运行到某个阶段,加载不知道第几个动态库时。为什么加载库,因为要使用这个库中的某个函数,一个库很大。但是,在汇编阶段我们已经形成了一个与位置无关码。此时,我们只需要找到这个库在进程地址空间的共享区的起始位置即可。动态库加载到进程地址空间时,操作系统是知道这个库的起始位置的。库的起始位置加上这个偏移量就可以在库中找到这个函数实现方式了。
因此,动态库在进程地址空间的共享区可以随便加载。我们并不需要去找这个库的具体某个函数的实现的地址,因为在编译阶段,就已经记录了这个函数实现在库中的具体偏移量了!
C/C++静态库下载方式
C语言静态库下载方式:
sudo yum -y install glibc-static
C++静态库下载方式:
sudo yum -y install libstdc++-static