文章目录
很高兴和大家见面,给生活加点impetus!!开启今天的编程之路
作者:٩( 'ω' )و260
我的专栏:Linux,C++进阶,C++初阶,数据结构初阶,题海探骊,c语言
欢迎点赞,关注!!
库的制作与原理
前置知识
在学习linux中的编译器时,如gcc/g++,或多或少肯定了解一定的动静态库,即动静态库和gcc/g++有一定的关联性。
讲解动静态库,先让大家见到动静态库:
指令:ldd / file
在前面讲解基础IO部分时,封装了库,我们对该可执行二进制文件来演示:

ldd能够查看到具体依赖了哪些库,file能够查看到文件类型,链接方式等等。
库的命名:
需去掉前缀和后缀,如上图:libc.so.6,去掉lib与.so.6,其实这个库就是c标准库。
库的后缀:
linux下,动态库以.so结尾,静态库以.a结尾
Windows下,动态库以.dll结尾,静态库以.lib结尾
从上面发现,gcc默认是动态链接,如果要静态链接,需添加--static选项:

我们来对比一下静态链接和动态链接生成的可执行的差别:

静态链接的可执行比动态链接的可执行体积大,而且大很多。
其实静态链接时是把代码中所用到的函数,将库中的方法加载进入可执行中,即库在可执行文件中,但是动态链接就不是了。那动态库是如何被加载的呢?动/静态库是如何被找到的呢?我们放到后面谈
制作者/使用者角度看待动静态库
gcc是编译器和链接器的集合,编译器是将各自源文件独立编译成可重定向目标文件(即点o文件),注意该过程各个源文件不会相互影响。此时只会报编译时的错误。当要生成可执行时,需要将.o文件链接,同时此时会链接库文件。

即库的本质就是一个一个点o文件,库就是点o文件的集合。
为什么库提供点o文件,而不提供源文件呢?
就算提供源文件,也需要将源文件编译成点o文件。
我们可以使用指令来查看库中的所有文件:
指令:ar -tv 静态库路径 + 文件名

静态库制作者与使用者角度:
静态库制作,既然我们知道静态库本质是点o的集合,所以我们只做静态库直接将点o文件打包即可。
指令:ar -rc libXXX.a *.o
同时我们模拟制作库之后库的发布,直接来看makefile:

为什么需要踢提供include?即提供头文件:
因为源代码被编译后不知道代码中有哪些方法,即需要头文件来声明。
我们先来明确一个点:当我们把这个库制作好之后,如果需要提供给别人使用,那么我们应该给别人提供什么?简单明了,库文件以及头文件,因为要告诉别人我的库中有哪些方法。
所以我们新建一个目录,同时cp拷贝生成的库到新建的目录中。
静态库的使用,静态库使用时肯定需要添加--static选项,同时也需要找到库文件,头文件,因为链接运行一个文件,肯定需要找到这个文件。但运行这个库必须有头文件

很明显这是预处理的错误,此时头文件无法展开。
编译器找头文件时,会在当前路径下查找,由于当前路径没有头文件,所以会报错,那把头文件也拷贝过来:

此时头文件展开就没任何问题了,该错误一看就是链接错误,ld表示Linker(链接器),为什么会报链接错误呢,就是因为找不到库文件。
解决使用库时的查找路径问题:
方法1:使用gcc指令的选项找:
-I(大i):表示头文件路径是什么,-L:表示库的路径是什么,-l(小L):表示要运行的库是谁
这样我们不仅能够找头文件,也能够找库文件,即此时能够将先前拷贝的头文件直接给删除了。

一定注意:库的名称是去掉了前缀和后缀的,此时-l(小L)查的是这个去了前缀和后缀的库名称。
那还有一个问题:先之前gcc编译c语言代码时,我们还是日常操作,没有带这些选项,那为什么还是能够编译通过,找到库文件呢?因为gcc专门用来编译c语言,默认知道c标准库的位置。而我们实现的是第三方库,gcc怎么知道未来需要链接哪个第三方库呢,即你怎么知道你未来的同事是谁呢?
方法2:gcc查的时候,会去当前路径下查头文件,不会在当前路径下查库文件,但是都是回到/lib64路径下查头文件和库文件。
/lib64/是linux下库的放置位置。所以我们可以将自己的库拷贝到该路径下,此时就能够找到库文件了。
/usr/include是linux下库的头文件放置的地方,gcc会去该路径查找,所以同样可以把头文件拷贝到这个位置。
细节:gcc使用第三方库时,比加-l(小L)选项,因为-I(大i)和-L找时,我们传递的是路径,而非文件名,gcc默认去找时同样找的是路径,必须指令使用哪个库!!

同样的,我们也可以建立软链接,软链接文件的内容是目标文件的路径。

注意gcc编译第三方库时,-l选项是必须要带上的。
动态库制作者与使用者角度
制作动态库:
选项:-shared + -fPIC

我们可以发现,-shared是gcc选项,为什么静态库的制作需要新的指令来生成,而动态库使用gcc就能够生成?因为动态库比静态库更常见,用的也更多,从一定方面上锁,动态库比静态库好。
-fPIC:与位置无关码,这个我们放到后面来讲。
同时我们将生成的mylib动态库拷贝到新路径中,等待被使用。
动态库的使用:第三方库动态库要被使用,同样需要找到头文件,找到库文件已经运行哪个库,跟静态库的使用相差无几,直接来看解决方案:
1:将mylib/lib/拷贝到/lib64/路径下,将mylib/include/*.h拷贝到/usr/include路径下。
2:在/lib64/和/usr/include/路径下分别建立库文件和头文件的软连接。
我们来讲解找到动态库的新的两种方法:
3:设置环境变量:

但是在这里还有一个概念,通过环境变量找库是运行时去找库,除了运行时需要去找到库,还需要去编译时找到库,编译时找库时只能够-L找,或者到系统级目录下找,如/lib64
那为什么静态库不存在运行时找呢?静态库编译时已经被加载到可执行中了,运行时都不需要再去找了
但是环境变量当我们重启shell后就会失效,所以还有
4:修改配置文件:/ etc/ld.so.conf.d/,ldconfig
修改之后,同时执行ldconfig指令,表示让该配置文件立即生效。
此时,还有一些细节:
细节1:当动静态库同时存在时,链接是连接谁?
直接来说明结论:动静态库只能够对应动态和静态链接,--static选项是强制静态链接,如果此时没有对应的静态库,就直接连接失败,如果动态链接,但是没有动态库,gcc就会去链接静态库,注意:此时仍是静态链接,但是不妨碍库加载进入程序中。
细节2:FILE文件是调用fopen函数时,库帮助我们创建的,而调用的底层open函数只会返回fd给上层。
ELF文件
ELF轮廓
在谈论ELF是什么时,我们先来谈谈可执行程序,我们都知道可执行程序是二进制的,如果使用vim查看,会出现一对乱码,如下:

那么这些乱码是不是真的乱呢?不是,在二进制文件中,这些二进制不是随便放在一起的,elf规定了这个二进制文件的结构和格式。
那么哪些文件是遵循elf文件的格式的呢?
三类:可执行文件,可重定向目标文件(点o文件),库文件
因为点o文件和库文件也都是二进制的。
细节问题:gcc包含链接器和编译器,编译器是将源文件生成点o文件,且互不影响,那么链接器是在干什么呢?以致于为什么是点o文件生成可执行或者库文件,那为什么不能是源文件生成可执行或者库文件呢?
原因是结构,链接器是将点o文件中,elf结构相同的区域合并,从而形成更大的elf文件,这个更大的elf文件就是可执行或者是库文件。

在这里合并的是代码部分和数据部分~~
但其实并非合并这么简单,但这里不再深究~
ELF细节
elf轮廓知道,接下来该具体讲解每个结构了。
elf结构包含LEF Header,Program Header Table, Section1...Secionn(一个个的节),Section Header Table。
LEF Header:记录了每个区域的开始位置,这样就能够做到区域划分作用。听到这里可能回想起在虚拟地址空间中的区域划分,即虚拟地址空间区域划分,你凭什么能够划分,你怎么划分的?与elf有关,这里我们下一部分再谈。
指令:readelf -h elf格式文件

同时还记录了起始位置Entry point address。
Section Header Tables:节
指令:readelf -S elf格式文件
能够发现节总共有30个,下标从0开始,其实节的编号就可以理解为数组的下标。这些节被存储在Section Header Table中。
同时,每个节都会包含不同的权限,其中Flag就表示权限。
Program Header Table:
指令:readelf -l elf格式文件

在这里我们需要区分一下节与段的概念:节其实就是Section,总共有30个,而且不同的节含有不同的权限,当然也含有相同的权限。
根据节的相同的权限,会被合并成一个段,为什么需要被合并呢?
如果不合并,一个节加载磁盘中的一个块,但是合并之后多个节占一个块,空间的使用效率就变高了。
同时,上面的映射关系反映了那些节合并到一个段中了。
细节:当加载可执行程序时,是先看Program Header Table,找到需要被加载的段,同时访问ELF Header,找到Section Header Table,找到访问的一个一个节。
为什么是先看Program Header Table?在段与节的关系中,有节Type为LOAD,即表示需要被加载的段,也表示要求被加载的节。
那么段具体是被加载到哪里了呢?这个我们等下谈。
理解虚拟地址空间
想要深刻来理解虚拟地址空间,需要同时理解编译器层虚拟地址空间与进程的虚拟地址空间,后者已经在进程部分讲过了。
首先两个问题:
1:创建一个进程,是先创建内核数据结构,还是先加载elf格式文件?
2:程序被gcc编译后,还未加载到内存,程序有没有地址?什么地址?
先解决问题一:这个在进程部分已经详细讲了,OS是管理者,对应加载到内存中的程序,此时已经变成进程了,OS需要对其先描述,再组织。
即需要先创建内核数据结构
问题二:gcc被程序编译后,其实是有地址的,这个地址我们通过反汇编就能看到。
在Section Header Table中,如果我们具体想看节更详细信息,可以转到反汇编
指令:objdump -S 路径+elf文件名

可以查看elf文件每个节,转成反汇编的地址。
那么这个地址是什么?--可以理解成虚拟地址空间或逻辑地址。
此时需要扩展--平坦模式---"0 + 偏移量式"存储:从0开始,如果在32为系统下,最终都全1
平坦模式:在程序编译时,对我们可执行程序的每一行代码都进行编址,采用的是 0 + 偏移量的格式进行定址,即从0开始编址,直到编址到全1。
早期计算机中没有虚拟地址空间,即CPU访问的直接是物理地址,早期同样存在各种段,编译时每行代码以相对地址(相对于每个段的起始位置)的形式存在,加载时转换为物理地址,直接被访问。
虚拟地址空间可以理解为其实就是将这些段合并起来了,现代CPU中含有MMU(内存管理单元),能够将虚转换虚拟地址与物理内存,即CPU返回的是虚拟地址,经转换后返回的是物理内存。
结论:即虚拟地址空间不仅是编译器遵守的,同时也是OS遵守的。
理解可执行程序的加载过程,OS和编译器都做了什么?运行程序时,OS怎么知道程序第一行在哪里,怎么知道呢?
在学习类的时候,我们知道类需要有构造函数来进行初始化,那么虚拟地址空间需不需要来初始化呢?怎么初始化?拿什么初始化?
当我们完成源文件书写时,gcc编译之后,产生elf格式文件,即可执行文件,此时就有了地址,该地址是虚拟地址,并且每行指令都有对应的虚拟地址,当我们加载程序到内存中时,一定能够产生真实的物理内存地址,有了虚拟地址和真实的物理地址,就能够来初始化页表,建立虚拟地址与真实物理地址的映射关系。这样页表就被初始化好了。
在ELF Header的属性中时,其中有一个Entry point address,标记的是程序进入的位置,在观察节的汇编信息时,其中有一个start节,存储的就是Entry point address的地址,这就是程序运行时运行的第一个位置:

那么这个进入程序的第一个位置保存在哪里呢?
在CPU中有两个寄存器,分别是EIP寄存器以及CR3寄存器,EIP寄存器存储的数据就是cpu需要运行代码的数据,而CR3寄存器存储的数据指向了进程的页表。
这样,运行的代码也就能够找到了。
在反汇编查看Program Header Table的内容时,能够发现每个段的内容,在段的Type为LOAD时,这些数据会被用来初始化进程PCB中每个区域的指针。

这样PCB的内容就被初始化好了。
程序被加载,需要先描述,再组织,创建PCB,页表,虚拟地址空间,当程序在内存中,有了物理内存地址,此时依靠elf + 编译产生的虚拟内存(如mm_struct上的区域划分的指针+正文/未初始化/已初始化数据等) + 物理内存初始化页表,虚拟地址空间,CPU寄存器。
当执行代码时,访问elf中的Program Header,即要访问段,随后访问ELF Header->Section Header Tale,找到具体的节,找到代码的虚拟地址,喂给CPU虚拟地址,由CPU内部MMU转换,吐出来物理地址经地址总线传给控制器定位代码+数据,再通过数据总线传给CPU,最终CPU执行该代码+数据。
结论:虚拟地址空间OS会参与构建,编译器也会参与构建
动态库被加载的过程:
为什么加载动态库,因为静态库库已经被加载到程序内部,运行时链接根本不需要链接,但动态库运行时是需要链接的。
而主流编译器如gcc和g++等等都是动态链接,动态链接比较与静态链接的好处就是静态链接的程序的大小太大了,导致占用率高,文件体积大等等问题
那么动态库被加载到哪里去了呢?
OS能能够同时管理多个进程,那么所有进程就有可能使用到一个库,比如c++输入输出流的文件肯定是需要被使用的,即动态库是被加载到虚拟地址空间的共享区。
加载到共享区之后进程的页表建立虚拟地址与物理地址的链接,也就能够找到动态库文件
为什么是加载到共享区呢?进程a和进程b使用到的动态库都是相同的,即只用加载一份,加载多份会造成内存利用率低,且这一个被建立了映射关系的进程是共享的。

动态库的加载
前置知识
问题一:程序调用的第一个函数是main函数吗?如果不是,那么第一个调用的函数是什么?
指令:
1:objdump -S 路径 + 文件名
2:readelf -h test

那么为什么是先调用_start函数而不是直接调用main函数呢?
在c语言阶段我们学习过,函数调用需要创建函数栈帧,main函数也是函数,肯定也需要函数栈帧。而且,main函数是有参数的,如下,是环境变量,那么环境变量是谁给他传递的呢?

所以先调用_start函数在调用main函数的作用是:获取命令行参数和环境变量,创建堆栈等等,为main函数调用做准备。
其实呢,_start是ld库中的一个函数,这是一个链接器的库,负责查找和加载动态库的。这个做一个了解即可。
那么反汇编中包含什么内容呢?
指令:objdump -S 路径 + elf格式文件
形如:

从左到右依次为:逻辑地址,操作码/机器码, 汇编指令, 操作对象 + 辅助信息。
以逻辑地址为1151举例,机器码有七个,16进制对应4个bit位,两个16进制对应一个字节,即操作码的长度是7个字节,lea是汇编指令,后面的操作对象,#后面的就是辅助信息
所有汇编指令的集合就是指令集,那么汇编语言和指令集的区别是什么?
汇编语言是人能够识别的,而指令集是CPU识别并执行的代码。那么汇编语言在底层也是会被翻译成指令集的形式来控制CPU的。
所以也就能够解答为什么我们要把程序翻译成elf二进制文件,目的就是转换成CPU能够识别的指令集。这样程序才能够被运行,那么也就是说:为什么相同的代码在不同芯片的机器上运行不了?硬件层面:CPU指令不同 软件层面:系统调用不同。
静态库与动态库
静态链接
先来讲静态库:
首先,静态库需不需要加载?
答案肯定是不需要,因为在编译时已经将静态库编译进程序了,不然也不可能可执行程序的文件大小那么大。
那么动态库是如何被加载进入程序的呢?
先来介绍两个之前的指令:
readelf -s elf格式文件:能够查看elf文件的文件符号表
objdump -S 路径 + elf格式文件:查看调用位置信息是否被重定向了
来看图片:

在点o文件中,因为还没有链接,puts函数,类似于printf函数,都是库函数,因为还未链接,在test.c源文件中未实现printf函数,此时就会显示undefine,即未定义。
当直接编译生成可执行文件后,该puts不再是未定义的,因为该函数是可以在c库中找到的。
所以,静态链接会在所有点o文件中找函数,找到了会将UND替换为数字,否则就会报错未定义!!

同理,当我们反汇编test.o文件时,call的位置地址是0,因为此时找不到该函数,当静态链接加载库到程序中,能够找到该函数,此时该地址就会被重定向修改为调用函数的虚拟地址。
总结:静态链接:链接合并多个点o,合并多个方法实现到一个可执行,随后统一编址,并对点o文件重定向修改,修改e8后面的无效地址->有效地址
e8是汇编指令call的机器码!!
动态链接
前置知识:
问题1:加载器是先加载动态库?还是先加载程序?
问题2:动态库加载进来时,是被加载到哪里了?
问题1:源文件被编译成点o文件,其实肯定百分之百需要用到库中的内容。就像简单的printf都要用到c库。文件符号表中一定有UND的函数。如果说动态库没有加载到内存中去。那么这些UND函数call的地址一定不会重定向。即一定会报错。
结论:加载器是先加载动态库,随后再加载程序的。
那为什么前面静态链接没有分谁先谁后的问题呢?静态库直接搬进了可执行程序中,肯定不会存在静态库的加载了。
问题2:是被加载到共享区了,而进程都有虚拟地址空间,只要在页表中建立虚拟地址与物理地址之间的映射关系之后,该进程就能够使用该库了。即动态库只需要在内存中存在一份。对比静态库,由于链接时整个静态库被编入可执行程序了,内存中可能存在多份静态库!!
既然动态库只有一份,那么其他进程怎么能够知道动态库已经被加载进入内存了呢?
动态库被加载一定,OS为了管理,肯定会对动态库进行先描述,再组织。从组织的数据结构中找该库即可。找到了那么该动态库肯定已经被加载进来了。
动态链接:
概念:边调用库方法,边运行程序。
对比静态链接:静态链接时链接器做的,动态链接时OS做的。动态链接采用的是平坦模式(0 + 偏移量)
核心:三个加载顺序:
1:内核数据结构:
就是进程创建PCB,虚拟地址空间,页表。
2:加载动态库
目的是初始化页表,那怎么初始化?
前面提到的平坦模式(0 + 偏移量)。动态库的本质也是点o文件的集合,既然是点o文件,那么库也是elf文件,一定有虚拟地址。而OS找UND函数就是通过库的起始地址 + 偏移量的形式找到库函数的。
与位置无关码的理解:在前面库的制作者角度看待库时,制作动态库时我们提到了选项-fPIC,为什么叫与位置无关码呢?
因为找库中的任意方法只要能够找到库的起始地址 + 偏移量即可。即动态库存储在磁盘上的任意位置都是可以的,加载到内存中的任意位置也是可以的。虚拟地址的共享区任意位置也是可以的。
细节:库集中记载或者分块加载都是可以的,本质都能通过起始地址 + 偏移量来找到任意方法。
3:加载代码数据
修改call后面的地址。即重定向地址。
直接使用一张图来哦说明即可:

直接使用起始地址 + 偏移量找到任意方法。
而且,链接器链接时就能够知道需要用到哪些库,而且,没有找到的方法一定是UND的,所以也能够知道哪些方法还没有被定义。

这里有一个细节:由于找方法是OS来找且是边运行程序,边找库函数。那么程序运行之后代码区可以修改吗?不行,虚拟地址空间的代码区是只读的。
对比静态链接:静态链接不是也会重定位call后面的地址吗?难道不会有权限限制吗?静态链接是编译时完成的。程序还没运行呢,直接编辑代码肯定是允许的啊!!

为了解决这种情况,在权限为读写的数据区会存在GOT表。
GOT表中存储着该可执行使用了哪些库方法以及该方法在库中的偏移量。
动态链接时call的是GOT表的起始地址+ GOT表的偏移量找到调用的库函数(包含在哪个库中,以及库中的偏移量),随后去共享区找该库函数,找到之后重定位修改代码调用库函数的call无效地址。
所以肯定会存在从代码区跳转数据区,再跳转到共享区,最后回到代码区继续向后执行代码。
直接看下图:

这里还有一些细节仅供了解即可:

在反汇编中,plt是什么?
修改GOT表(下次调用相同的函数就不会重新重定位),实现间接调用到直接调用的过程。节省了转换的时间!!
延迟绑定:其实只有当调用到这个库函数时,才会真的区动态库中找并重定位call后面的无效地址。即动态链接,因为有些库函数大多数情况下调用不到,如perror函数。目的是减少重定位的开销,按需刷新!!
还有一些细节:因为点o文件也是elf格式的文件,所以肯定会存在一个库调用另一个库的情况。但是有了plt重定位之后就能够保证下次直接用。不用再去重定位了。
