【Linux内核系列】:动静态库详解
🔥 本文专栏:Linux
🌸作者主页:努力努力再努力wz
💪 今日博客励志语录:
有些鸟儿是注定是关不住的,因为它们的每一片羽翼都沾满了自由的光辉
★★★ 本文前置知识:
编译与链接的过程
1.引入
那么在正式接触动静态库之前,那么我们得先有一个前置知识的储备,那么便是从源文件形成一个可执行文件的一个过程,那么我们自己经常在诸如VS或者Dev c++平台上编写我们的代码形成可执行程序,那么你是否清楚的了解你编写的源文件到最终的可执行文件的一个过程呢?如果你对此过程感到有点陌生,没关系,那么我们就先来对该过程进行一个简单的回顾。
那么我们要形成一个可执行文件,那么该可执行文件就好比我们下厨做饭最终要在桌子上做的一道菜,那么要成功完成好这一道菜,那么我们手上必须有该菜所需的原材料和调料以及有了原材料和调料之后我们接下来做菜的一个个步骤或者环节我们得掌握清楚,其中对于我们可执行程序来说,那么它所谓的“原材料”就是保存该程序调用的函数的声明所在的头文件以及保存该函数定义所在的源文件以及主函数的代码所在的源文件,那么它依赖这三种源文件,那么有了这三种源文件之后,那么要形成最终的可执行程序,便要经历两个大的环节,那么便是编译和链接,其中的链接环节对于们要掌握动静态库来说,非常重要,那么我们先来谈第一个阶段,也就是编译:
那么编译这个大环节其实可以分解为四个步骤,分别是预处理以及语法检查和编译以及最后的汇编,那么首先我们的头文件以及源文件会经历第一个环节,便是预处理,那么在预处理阶段,我们源文件的第一行会有一个#include XXX的语句,那么该语句就是引用对应的头文件,那么此时预处理阶段就会将该语句替换为引用的对应的头文件的内容,那么将头文件的内容,也就是保存的函数以及变量的声明给展开到引用该头文件的源文件中,那么这个工作就是头文件展开,并且预处理阶段还会进行注释的消去以及宏替换等等,那么此时经过预处理阶段的源文件就会生成对应的后缀名为.i的临时文件,那么由于头文件的内容被复制拷贝到引用它的源文件中,那么头文件就不再参与后序的所有流程了,而此时预处理阶段生成的每一个临时.i文件中的内容还是未经编译的代码。
经过预处理阶段之后,下一个环节便是语法以及语义检查等,那么这个阶段所做的工作,我们就可以简单理解为编译器会检查每一个文件的每一行代码是否有语法错误,那么知道这点便足矣,至于它是如何检查的,以及该阶段会构建一个语法树,那么这些都是编译原理的知识,那么本文便不再对此赘述,读者感兴趣下来可以自行了解
那么经过语法检查之后,便顺利进入第三个环节,便是编译,此时会将每一个文件的代码给转换成会汇编码,并且会对每一个文件生成一个符号表,那么该符号表则是记录了每一个文件的全局变量以及全局函数的声明以及他们所对应的定义的位置或者说地址,那么一定要注意的就是该地址不是物理内存中的地址,因为我们现在还在编译阶段,那么此时都还没生成可执行文件,更别说加载到内存了,所以不存在所谓的分配物理地址,这点一定要注意理解,而这个地址是逻辑上的地址,也就是该函数的定义在文件模块中的偏移量,因为全局函数或者变量意味着它们会被其他文件给调用,那么就一定要记录其定义所在的位置,从而在符号表中从而能够寻找到,那么这里每一个文件都会有一个符号表,那么有的文件可能只看到函数或者变量的声明没看到定义,那么此时该文件对应的符号表对应的该函数的定义所在的位置就会为空,但是编译器不会进行所谓的报错处理,因为编译器知道它为空不一定代表着它没有定义可能是它的定义不在该文件当中,那么此时它会被标记为未解析,会在链接阶段进一步完善,最后经过编译阶段之后,那么此时每一个.i文件会生成一个后缀名为.s的文件
编译的最后一个阶段便是汇编了,那么此时会将每一个.s文件中的汇编码给转换成机器码也就是二进制序列,那么此时经过汇编阶段之后,那么.s文件会最终生成一个后缀名为.o的文件,那么至此编译阶段就结束了
第二个大阶段便是链接,那么链接这个环节十分的重要,和我们之后的动静态库的内容有非常大的关联,那么我们知道在编译阶段,最初的所有的头文件以及源文件最终会形成一个.o的文件,而链接阶段所做的做核心的工作就是将这些.o文件给合并成一个文件,那么该文件就是可执行文件,那么其中涉及的过程就包括将之前每一个文件的符号表合成生成一张全局的符号表,那么链接阶段的详细过程,我会在后文补充,那么我们脑海中有一个简单的理解即可
源文件(.c/.cpp) → [预处理] → 预处理文件(.i) → [编译] → 汇编文件(.s) → [汇编] → 目标文件(.o) → [链接] → 可执行文件
2.库
那么我们有了生成可执行文件的一个大致的流程,那么现在假设有这么一个场景,你要编写一个功能类似于计算器的一个程序,能够实现简单的加减乘除,那么此时你在源文件中编写了加减乘除的对应的四个函数的定义,然后在头文件中编写了该4个函数的声明,最终成功实现了一个简易的计算器程序,而与此同时其他人写的程序此时有相应的需求比如需要计算两个数相加或者相乘,那么他们看到你已经实现了相应功能模块的函数并且认为你写的非常完善非常好,那么他们认为没必要再自己造轮子重新写一份相应的函数,直接偷懒调用你的写好的函数就可以了,那么此时对于你来说,你是采取直接将头文件以及源文件直接发送给他们还是说采取其他的方式?
那么假设你和需要你提供源代码的那几个人是好兄弟或者说是同事关系,那么在这种情况下,你假设选择第一种方式,也就是将写好的加减乘除的四个函数所对应的头文件以及源文件给打包发给你的好伙伴,但是根据我们上文所讲的生成可执行文件的一个流程,我们知道你的好伙伴获取到你的头文件以及源文件之后,那么它们只能从头开始也就是得将这些文件先编译最后在链接,那么要知道假设此时做的是一个大型的项目,那么该项目涉及到的源文件以及代码量是很大的,那么此时对不起,你的好伙伴如果重新编译再链接的话,那么他们就需要等待较长时间的编译直到生成最终的可执行文件
所以为了提高效率,那么聪明的你选择了另一种方式,那么就是你将源文件先只进行编译形成.o文件,那么你再将程序需要的.o文件一起打包到一个文件夹,那么该文件夹就是一个.o文件的集合,而这个文件夹我们有一个专业的术语,那就是库,那么有了库之后,我们在将库和对应的头文件发给你的好伙伴,那么你的好伙伴就只需要完成链接工作,那么就能生成最后的可执行文件了。
而如果说你和需要你提供源代码的那几个人是陌生人或者说来自不同的公司,那么此时你就只能采取第二种而不是第一种,不采取第一种不是因为你不想坑人家,而是你直接将保存定义的源代码文件给别人了,那么别人就能够看到定义,那么如果你写的这些函数是你们公司的核心业务所需要的函数,那么别人看到之后,就可以直接窃取到,而采取第二种不仅是因为提升了编译的效率,更为重要的是你保存定义的源文件一旦形成.o文件之后,那么此时对方打开该.o文件,看到的全是一堆二进制码,也就意味着对面只能获取你函数的声明,他们只能知道怎么调用你这个函数也就是函数名以及参数列表是个啥,但是对方是不知道你这函数是怎么实现的,那么这就是我们动静态库一个非常常见的引用场景
那么我们自己写代码的时候,我们经常公式的在代码开头写#include<stdio.h>语句,那么这个所谓的stdio.h是c语言的标准库,其保存了c库函数的声明,那么c库函数的定义则是系统的链接器有保存了c库的所在的路径,那么它能够从这些路径找到对应的库然后链接到我们的可执行程序中,那么这些库就是所谓的第一方库,也就是我们每一个人都能够用到的,那么像我们上文举得那个例子,也就是你自己写了一个加减乘除的函数然后打包成库,那么这就是所谓的第三方库,也就是我们用的是别人提供的库,而其中第三方库有两种,也就是我们本文的重点,便是动态库和静态库。
3.静态库
静态库是什么
那么前面铺垫了这么久就是为了引入这个动静态库,那么我们先来认识一下静态库,那么静态库的关键就是它的这个"静"字,那么相信你看完下文,你便能知道它为何为“静"
那么我们知道了要形成可执行程序,假设我们手上持有了写好的头文件以及源文件,那么我们知道我们可以将我们的源文件给先编译成.o文件,然后我们只需要将.o文件给打包成库,然后再与头文件一起封装交给别人来进行链接即可
那么对于之后下文所讲的动态库,那么他们其实本身都是将保存定义的源文件进行一个编译形成一个个.o文件然后整体打包成库,而之所以他们不同,就是根链接的方式有关,那么对于静态库来说,此时编译器有了我们编译好的.o文件集合的库,那么此时它会扫描我们主函数也就是main.o对应的符号表,来确定哪些函数没有对应的定义,那么接着它便会扫描静态库当中的.o文件的符号表,看是否有匹配的函数定义,如果有的话,那么此时编译器就会将文件中的相应函数的定义的代码段给直接拷贝到可执行文件当中,所以在运行可执行文件的时候,就不需要系统的链接器到系统的保存默认路径下去寻找相应的库,因为调用的函数的声明以及定义都在该执行文件当中,那么这就是静态链接
要注意的一些细节就是,我们假设我们该程序只调用了比如静态库中某一个.o文件中的其中几个函数而不是所有该.o文件的函数,那么我们此时编译器不会将调用函数的定义所在的.o文件全部给拷贝复制,而是按需复制,将该文件中被真正调用的函数的定义的内容给拷贝复制过去
静态库的优点也就很明显,也就是不用担心库缺失,假设你不小心删除了静态库,那么静态库被删除了一点也不影响你的可执行文件的正常运行,因为编译器已经将静态库的内容全部拷贝复制到可执行文件当中,而这点则是动态库所做不到的,我在下文会说到,那么缺点就是很明显,那么可执行文件由于有了静态库文件的拷贝,那么体积就会很大
实现一个静态库
那么这里我们知道在Linux系统下,静态库的库名的标准是libXXX.a,那么任何静态库必须遵循这个标准,也就是得有固定前缀lib以及固定后缀.a,那么接着系统的连接器会从默认的以下的路径去搜索静态库:
/usr/lib:存放系统级静态库和动态库。
/lib:核心系统库(如基础 C 库)。
/usr/local/lib:用户自行安装的第三方库。
所以我们的静态库的命名规则得按照libXXX.a来命名,其中的XXX是自己自定义的库名,那么现在我们手头持有一个包含源文件以及头文件的project的目录,那么它的具体结构是这样的:
project/
├── main.c
├── include/
│ └── mylib.h
├── src/
│ └── mylib.c
└── Makefile
那么我们的makefile和project处于同一级目录下,其中我们的mylib.h则是保存我们main.c调用的函数的声明而mylib.c则是保存调用函数的定义
其中mylib.h头文件代码内容:
#pragma once
#include<stdio.h>
int add(int x,int y);
mylib.c源文件代码内容:
#include"mylib.h"
int add(int x,int y)
{
return x+y;
}
main.c源文件代码内容:
#include"mylib.h"
int main()
{
int ret=add(10,20);
printf("%d\n",ret);
return 0;
}
而生成目标可执行文件文件所在目录结构我们希望是这样的:
└── output/
├── myprogram
├── libmylib.a
└── include/
└── mylib.h
那么接着我们就用makefile来构建我们的可执行程序,那么我们首先将我们的各个目录以及可执行文件名在makefile分别定义一个变量来保存,然后我们先从可执行文件也就是myprogram来进行构建,那么myprogram依赖两个文件,分别是main.o以及libmylib.o,而libmylib.o是我们自己实现的一个静态库
LIB=libmylib.a
SRC_DIR=src
INC_DIR=include
EXE=myprogrma
OUT_DIR=output
那么接着makefile会检查当前目录下是否有main.o以及libmylib.o,那么如果没有,那么接着它会寻找到main.o以及libmylib.o的一个实现,然后在当前目录下生成main.o以及libmylib.o,
那么对于main.o文件,我们接下来要给编写对应的依赖关系以及依赖方法,那么它依赖的文件是main.c,那么由于main.c和makefile文件不在同一级目录,那么makefile是无法知道main.c的存在,所以这里编写依赖关系的时候,我们就得注意要带相对路径,然后就是依赖方法,这里注意gcc编译的时候,后面得添加-c选项,也就是告诉编译器只编译生成.o文件,而我们的main.c中有#include"mylib.h"语句也就是引用了mylib.h头文件,那么此时编译器会去寻找mylib.h,那么编译器要么就是从我们上文所说的那几个默认路径下去寻找到,要么就是当前main.c所处的同级目录下寻找,但是main.c和mylib.h不在同一级,所以我们需要加-I选项后面指定引用头文件所处的目录,然后编译器会从该目录中找到mylib.h
mylib.o:project/$(SRC_DIR)/mylib.c
gcc -c $^ -Iproject/$(INC_DIR) -o $@
那么有了main.o之后,下一步则是构建linmylib.a,libmylib则是.o文件的集合,那么它则是依赖于mylib.c编译形成的mylib.o,我们将.o文件给打包成静态库文件的指令则是:ar rcs
其中ar指令则是将依赖关系后面的.o文件打包成一个库,而rcs选项每一个选项的作用则是:
r(replace)
:替换库中已有的同名模块c(create)
:若库不存在则创建s(index)
:生成符号表加速链接
$(LIB):mylib.o
ar rcs $@ $^
然后接着再是构建mylib.o,那么mylib.o则依赖mylib.c,那么我们编写依赖关系的时候,同样要加上路径,因为mylib.c与makefile不处于同一级目录下,我们得让makefile知道我们mylib.c的存在,而对于依赖方法,那么由于mylib.c也引用了mylib.h,所以gcc编译时和上面一样需要加-I选项
mylib.o:project/$(SRC_DIR)/mylib.c
gcc -c $^ -Iproject/$(INC_DIR) -o $@
那么有了最终的libmylib.a以及main.o,那么下一步就是将其链接形成myprogram,而上文我们知道连接器只能在系统保存的默认路径下以及当前所处的目录寻找静态库,那么我们就需要告诉它我们链接的静态库所处的目录,以及静态库的名称,也就是-L选项后面指定路径,-l指定名称,而名称则注意是去掉lib前缀以及.a后缀的名称
有了可执行文件以及静态库,那么我们最后在makefile中定义一个伪目标,创建一个output目录并且将libmylib.o等文件给打包到output目录中
$(OUT_DIR): $(LIB) main.o $(EXE) project/$(INC_DIR)
mkdir -p $(OUT_DIR)
mv $(LIB) $(OUT_DIR)/
mv main.o $(OUT_DIR)/
cp $(EXE) $(OUT_DIR)/
cp -r project/$(INC_DIR) $(OUT_DIR)
完整makefile:
LIB=libmylib.a
SRC_DIR=src
INC_DIR=include
EXE=myprogrma
OUT_DIR=output
$(EXE):$(LIB) main.o
gcc main.o -L. -lmylib -o $@
$(LIB):mylib.o
ar rcs $@ $^
mylib.o:project/$(SRC_DIR)/mylib.c
gcc -c $^ -Iproject/$(INC_DIR) -o $@
main.o:project/main.c
gcc -c $^ -Iproject/$(INC_DIR)
.PHONY:clean
clean:
rm -rf $(OUT_DIR)
$(OUT_DIR): $(LIB) main.o $(EXE) project/$(INC_DIR)
mkdir -p $(OUT_DIR)
mv $(LIB) $(OUT_DIR)/
mv main.o $(OUT_DIR)/
cp $(EXE) $(OUT_DIR)/
cp -r project/$(INC_DIR) $(OUT_DIR)
Linux运行截图:
补充:
那么我们实现静态库的核心并不是如何编写得到一个静态库,而是编译器如何找到静态库,那么我们这里让编译器找到静态库有几种方式,那么其中第一种方式便是上文编译时带选项告诉给编译器静态库所在位置,而我们知道编译器还会从默认路径下user/lib中搜索,所以你可以选择将libmylib.a拷贝到该路径下
还有一种方式则是在该user/lib下定义建立一个软链接,文件名为libmylib.a,那么内容就是我们编写的静态库的路径,那么编译器找到后能够跳转到找到我们自定义的静态库位置
动态库
动态库是什么
而所谓的动态库,其实上文说了,本质上它的构成和静态库其实是一样的,但是动态库的链接方式和静态库不一样,那么在Linux上,动态库的命名标准是libXXX.so,也就是自己定义的动态库必须有lib前缀以及.so后缀
其中这里对于动态库的链接,那么这里关键要和静态库进行区分的是,它是在运行时进行链接的,而不是编译时进行链接的,因为程序调用了动态库的库函数,但是此时该可执行程序并没有相应的库函数的定义,而需要的库函数的定义是在系统其他位置保存着的,所以为了寻找到库函数的定义,那么此时就是系统的动态链接器来寻找,那么它会从几个默认路径下去寻找,其中就包括环境变量LD_LIBRARY_PATH,然后找到库中对应调用的函数的定义所在的库文件,然后将库文件给加载到内存中,而具体怎么找到对应的库文件,是因为我们在编译阶段会生成一个符号表,上文说过,符号表会记录哪些未解析的函数是外部引用需要链接的,那么此时链接器会拿着这些函数的声明与动态库文件的导出符号表一一匹配,那么导出符号表就记录了函数定义所在的地址,然后将其加载到内存,这也就是为什么动态库文件具有可执行权限的原因
动态库的优点就是生成的可执行文件体积小,而缺点则是如果动态库缺失,那么程序就无法正常执行了
动态库怎么实现
那么我们这里的源文件以及头文件所处的目录结构以及对应的头文件以及源文件的内容和上文静态库是一样的,只不过这里将源文件打包到动态库,
而这里打包到动态库的时候,要加-fPIC选项,那么该选项代表生成一个位置无关码,是该动态库加载到内存所必须的
gcc -c fPIC $^ -o $@
其次就是链接的时候,需要加-shared选项
gcc $^ -shared -o $@
makefile完整实现:
LIB=libmylib.so
INC_DIR=include
SRC_DIR=src
EXE=myprogma
OUT_DIR=output
$(EXE):main.o $(LIB)
gcc main.o -L. -lmylib -o $@
$(LIB):mylib.o
gcc $^ -shared -o $@
mylib.o:project/src/mylib.c
gcc -c -fPIC $^ -Iproject/$(INC_DIR) -o $@
main.o:project/main.c
gcc -c $^ -Iproject/$(INC_DIR) -o $@
.PHONY:clean
clean:
rm -rf main.o mylib.o $(EXE) $(OUT_DIR)
output:$(EXE) main.o $(LIB)
mkdir -p $(OUT_DIR)
cp $(EXE) $(OUT_DIR)/
cp -r project/$(INC_DIR) $(OUT_DIR)/
mv $(LIB) $(OUT_DIR)/
cp main.o $(OUT_DIR)/
Linux运行截图:
但是我们在运行的时候,发现动态连接器找不到对应的动态库,那么此时我们就要确保系统找到动态库,那么我们就需要将我们动态库所处的目录添加到环境变量也就是LD_LISARBRY当中,那么这样就可以成功运行了
结语
那么这就是本篇关于动静态库的全面剖析,那么我的下一篇文章便会进入一个全新的章节,也就是进程地址空间,那么希望读者下来可以自己去实现一个自己的动静态库,那么我会持续更新,希望你多多关注,如果本文有帮组到你,还请三连加关注,你的支持,就是我创作的最大的动力!